11 Commits

Author SHA1 Message Date
aed068de12 feat(web): redesign UI as an OS-style desktop shell
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Replace the sidebar + hash-routed page shell with a desktop metaphor:
draggable app icons, apps opening as floating wmkit windows, a centered
"What should Nomos do?" task launcher, and a bottom taskbar showing all
open windows plus a system tray.

- New app registry ($lib/apps.ts) — adding an app is one entry, nothing
  else to touch.
- New desktop shell components (Desktop, WindowLayer, DesktopIcon,
  Taskbar, TaskLauncher) under $lib/components/desktop-shell/.
- Icon positions are a persisted, collision-avoiding grid ($lib/stores/icons.ts).
- Window layout persists across reloads (wmkit persist), with
  drag-to-maximize, F6 window cycling, and now a right-click desktop menu
  (cascade/tile/show desktop/reset icons) plus Cmd/Ctrl+Z undo/redo for
  window moves, resizes, and closes.
- Taskbar buttons get a hover-close and self-correct their title once a
  new task's real goal is known.
- New task windows (desktop launcher and the Tasks app's "New task"
  button) open as a window, not a dialog, and hand off to the real
  session window once the backend assigns an id.
- Fixed a real gap along the way: GET /sessions/{id} couldn't tell
  "session deleted" from "session has no messages yet" (both returned
  200 with an empty list) — cmd/nomos/main.go now checks existence and
  404s, so a stale/persisted task window shows "Task not found" instead
  of a misleadingly empty, live-looking chat.
- Test coverage for the new pure logic (icon placement/collision
  avoidance, app registry id helpers) plus a vitest matchMedia polyfill
  needed to import anything touching the theme store.

Deletes the now-superseded sidebar shell, MinimizedWindowsBar, and the
standalone Chat/EntityDetail pages (folded into the window layer).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 21:34:15 +02:00
8657ac5669 feat(web): open tasks/sessions as floating windows with independent live chat
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Clicking a task now opens it as a wmkit floating window (like entity
windows already do) instead of navigating away from wherever you were.
Several task windows can be open and actively streaming at once, each
fully independent — no "which one's on screen" guard needed, since
each window owns its own store bundle:

- chat.ts: chatFor(sessionId)/loadSessionChat/sendSessionMessage give
  each window its own messages/streaming/connectionState, alongside
  the existing singleton path the main Chat page still uses unchanged.
- workspace.ts: same split for plan/questions/touched/health-diffs
  (workspaceFor/startSessionWorkspace), each with its own live-event
  watermark since several windows can watch the same event stream.
- activity.ts: activityLogFor(sessionId) mirrors the global derivation.

SessionGraph.svelte, OperatorQuestion.svelte, and ActivityTimeline.svelte
were converted from store-importing to prop-driven (matching the new
ChatThread.svelte, extracted from Chat.svelte's transcript/input so both
the main page and task windows share one implementation instead of
duplicating markup/styling) so each can render either the global
"current session" or a specific window's session.

Also: minimized-window taskbar chips now cap at a max width with
middle-ellipsis truncation instead of growing unbounded, and the
window header's title/action-button row is fixed to genuinely match
heights (not just share a center point) for more robust alignment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 17:01:34 +02:00
e28e0e9ea3 feat(web): unify Knowledge Base filtering into one type multiselect
Replace the Fleet/Network/Identity/Knowledge category tabs (which
scoped entity fetches server-side) with a single "Types" multiselect
shared by both the table and graph views — both now fetch the whole
entity set (paginated via the new fetchAllEntities) and filter
client-side, defaulting to fleet's types. Table and graph also share
one search/highlight field instead of two separately-labeled ones.

Along the way, fixed a real bug the wider entity set exposed: the
treegrid's parent/child grouping fired one fetchGraph call per
candidate root entity, fine for the old ~50-entity fleet scope but an
ERR_INSUFFICIENT_RESOURCES flood once scoped to the full ~1700-entity
set. Replaced with a single whole-graph fetch, deriving parent/child
pairs from its edges client-side.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 17:01:34 +02:00
6051fb4845 feat(web): curved edges + unique SVG ids for concurrent graph views
Quadratic-bezier edges instead of straight lines, and drop the
auto-refit-on-load that caused a jarring zoom/pan snap once the force
simulation settled. Also namespace each graph's dot-grid pattern id
with a per-instance uuid — multiple SessionGraph instances can now be
mounted at once (one per open task window), and duplicate SVG ids
silently blanked out every graph's background but the first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 17:01:34 +02:00
544afae77f feat(nomos): retry cap, vm: targets, inspect_path, goal supersession, runbooks
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Session-review implementation for the three sessions audited in
plans/2026-07-18-session-review-three-sessions.md. v0.7.11 → v0.7.12.

P0.1 — retry cap + investigate-before-retry (cmd/nomos/retrycap.go,
agent.go): after 3 identical failing run calls in a single turn, refuse
to dispatch the call again and return a directive to investigate *why*
(ps/strace/lsof) or surface the blocker. Per-turn scope so a fresh turn
after the operator responds can retry once more. Session 1e9c7691's 20+
identical chown retries (knfsd held a kernel lock on the exported NFS
dir) is the direct motivation.

P0.2 + P1.8 + P2.10 — SOUL.md guidance: hung command is not a failed
command (investigate before retry); ask before proposing a multi-step
migration; multi-goal sessions summarize the arc not just the last goal.

P1.3 — two new runbook entities in seeds/knowledge.yaml:
  - nfs-exported-dir-mutation-hang (the knfsd fchownat lock procedure:
    killall → exportfs -u → mutate → exportfs -a → verify)
  - netbird-mgmt-oidc-race-after-upgrade (docker restart netbird-mgmt
    after ~30s for the traefik/authentik OIDC race)

P1.4 — setGoal emits task.superseded event when prior goal is overwritten
by a different goal (store.go, TestSetGoal_SupersededEvent). Session
55927f0a had two set_goal calls with the first silently abandoned.

P1.5 — inspect_path MCP tool: runs mount/df/ls/stat for one path across
up to 8 targets in one parallel call, replacing the 15+ run-call
fact-gathering fan-out sessions 1 and 2 each spent on cross-target path
tracing (tools.go, server.go: inspectPathAcrossTargets, inspectOneTarget).

P1.6 — vm: target support in run via qm guest exec (no more SSH-hop
with nested quoting). Extracted shared resolveProxmoxHostSlug for
LXC + VM, with hosts-relationship fallback when attributes.host is
absent (server.go, tools.go). Session 55927f0a's SSH-hop workarounds
for vm:zimaos are the direct motivation.

Deferred (documented in plan): P1.7 (approval window auto-extend on
timeout) and P2.9 (long-running command PENDING detection) — both
addressed at lower cost by the retry cap. Session 3's poll-after-timeout
pattern already works; the cap protects against the failure mode.
2026-07-19 00:09:39 +02:00
bd44626532 feat(web): floating entity-detail windows (wmkit), replacing sidebar/sheet
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Every place that showed entity detail (Knowledge Base's right sidebar,
the EntitySheet drawer used by Knowledge and the chat session graph,
the standalone /entity/:slug page) now opens the entity in its own
floating, draggable, resizable window instead — several can be open
side by side, and clicking a relation inside one opens another,
building up a stack. Windows are managed by one global wmkit instance
(new $lib/stores/windows.ts + $lib/components/EntityDesktop.svelte,
mounted once in App.svelte), themed with the app's own card/border/ring
tokens rather than wmkit's bundled themes (app.css).

- Delete EntitySheet.svelte (redundant) and the KnowledgeBase resizable
  detail pane; row/graph-node click handlers now call
  openEntityWindow(slug) instead of setting local sidebar state.
- SessionGraph (chat's "Scope" mini-graph): clicking a node opens its
  window directly instead of a click-through mini-detail panel with
  its own resize handle and "Full detail" button — that whole
  subsystem is now dead and removed. Node highlight ring is kept
  (still useful to see what you last opened) and now clears itself via
  an effect watching the shared window-manager store, so closing a
  window drops the highlight instead of leaving it pointing at nothing
  — same fix applied to Knowledge Base's row highlight.
- Compact the entity-detail panel's padding (container + each
  DetailSection) now that it's typically viewed in a small window
  rather than a full-height sidebar.
- Fix KnowledgeBase's browse pane losing its flex-1/min-w-0 (and thus
  full width) when the wrapping single-child div around it was removed
  along with the old detail-pane split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 10:46:27 +02:00
b0cdf64bbf fix(web): vite-env.d.ts missing vite/client types reference
import.meta.env (used by main.ts's dev-token auto-config) was untyped
since that landed — vite-env.d.ts never referenced Vite's client types.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 10:46:04 +02:00
d6b3d3c88b fix(web): relation rows wrap and break layout on long node names
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The clickable-button variant of relationRow was missing the truncate
class that the read-only span fallback already had — long slugs (task
UUIDs, exec IDs) rendered at their full pre-truncated length inside a
shrink-only flex item, overflowing the narrow detail sidebar and
wrapping to extra lines. Give both sides flex-1 + truncate so they
share the row's width evenly and always stay on one line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 08:20:40 +02:00
8615f2268f fix(web): entity Relations panel was missing true incoming edges
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
fetchGraph({root, depth:1}) is backed by blast_radius, which only
walks outgoing edges — so it could never surface an edge some other
entity points at this one (e.g. host:hubris —hosts→ lxc:sophia) unless
that other entity happened to also be reachable going forward from
here. The Outgoing/Incoming split was filtering correctly, but
"Incoming" was starved of data by construction.

Switch to GET /entities/{id}/relations?direction=both — a dedicated
endpoint that matches on source_id OR target_id directly — via a new
fetchEntityRelations(). Simplifies the incoming/outgoing derivation
too, since every relation returned is now actually incident to the
entity (no more sibling-edge filtering needed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 08:13:19 +02:00
258b14dcbc feat(web): ontology-driven fleet treegrid, relations grouping, dev auto-config
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Knowledge Base / Fleet browsing:
- EntityTable renders as a treegrid (arbitrary depth, expand/collapse,
  ARIA row/level/expanded), grouped by parent-child relationships
  derived entirely from the live ontology graph (cardinality ->
  direction; typeDepth specificity for ties) rather than a hardcoded
  relationship list — see loadFleetGrouping in KnowledgeBase.svelte.
- Fold Services and Storage categories into Fleet (services/pools/
  volumes/datasets now nest under the compute entity or pool that
  provides/contains them instead of having their own browsing tabs).
- Drop `cluster` entities from Fleet browsing so a host's `located-at`
  (site) relationship wins the tree-parent slot without needing a
  hardcoded priority override — member-of simply has no valid target
  left to point at.
- Add a "show destroyed/inactive" Switch (default off) filtering on
  entity.state, replacing an always-on checkbox.

Entity detail panel:
- Split the Relations section into Outgoing/Incoming groups (relative
  to the viewed entity), and scope the section's count to edges
  actually incident to it rather than the whole depth-1 neighborhood.

Dev experience:
- Auto-fill the SPA's token from the dev server's own OIKOS_API_TOKEN
  (vite.config.ts define + main.ts, dev-only, only when unconfigured)
  so the "Connect to Oikos" prompt doesn't reappear on every reload.
- .claude/launch.json: autoPort, since port 5173 is often already
  claimed by another worktree's dev server.

Adds ui/checkbox and ui/switch (bits-ui primitives, following the
existing shadcn-svelte wrapper pattern) and fetchOntology()/
RelationshipTypeDef to api.ts. Also fixes a missing types.ts import
in api.ts (ChatEvent/MessageContent) that predates this branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 23:26:04 +02:00
646373a676 fix(httpapi): GetGraph 500s when rel_type is omitted
req.Params.RelType is *[]string; passing the nil pointer straight
through as a pgx query arg (both in the blast_radius() call and in
ListGraphEdges) panics because pgx can't infer the array element type
from a nil *[]string, only from a concrete (possibly nil) []string.
Dereference once up front instead. Also affected the sqlc-based
ListGraphEdges path added by the R3 refactor, which had the same bug.

Add a regression test for GET /api/v1/graph?root=X&depth=N with no
rel_type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 23:25:41 +02:00
60 changed files with 4229 additions and 1543 deletions

View File

@@ -5,7 +5,8 @@
"name": "web",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "export OIKOS_API_TOKEN=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' oikos-api-1 | sed -n 's/^OIKOS_MCP_BEARER_TOKEN=//p'); exec npm --prefix web run dev"],
"port": 5173
"port": 5173,
"autoPort": true
}
]
}

View File

@@ -1 +1 @@
0.7.11
0.7.12

View File

@@ -168,10 +168,10 @@ type toolDef struct {
}
type agentEvent struct {
Type string `json:"type"`
Data any `json:"data,omitempty"`
SessionID string `json:"session_id,omitempty"`
Iteration int `json:"iteration,omitempty"`
Type string `json:"type"`
Data any `json:"data,omitempty"`
SessionID string `json:"session_id,omitempty"`
Iteration int `json:"iteration,omitempty"`
}
func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(agentEvent)) {
@@ -349,6 +349,15 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
messages = append(messages, openai.SystemMessage(systemInject))
}
// Retry cap (P0.1 from plans/2026-07-18-session-review-three-sessions.md):
// track failing `run` calls within this turn so an identical command that
// keeps failing is refused after maxRunRetries attempts. Without this,
// session 1e9c7691 retried the same `chown` ~20 times, each retry piling
// up a zombie process on the target (knfsd was holding a kernel lock).
// The tracker is per-turn — a fresh turn after the operator responds can
// retry once more, so this doesn't permanently block recovery.
retries := newRunRetryTracker()
for i := 0; i < maxIterations; i++ {
params := openai.ChatCompletionNewParams{
Model: openai.ChatModel(a.model),
@@ -467,6 +476,33 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
sawCompleteTask = true
}
// Retry cap: if this `run` call has already failed
// maxRunRetries times this turn with the same (target,
// command), refuse to dispatch it again. Return a synthetic
// tool result directing the agent to investigate *why* the
// command hangs instead of retrying. See retrycap.go and
// plans/2026-07-18-session-review-three-sessions.md P0.1.
if tc.Function.Name == "run" {
t, _ := args["target"].(string)
c, _ := args["command"].(string)
key := runFailureKey(t, c)
if n := retries.failures(key); n >= maxRunRetries {
directive := runRetryDirective(t, c, n)
slog.Warn("nomos: run retry cap hit — refusing dispatch",
"target", t, "failures", n, "session", sessionID)
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args,
tc.Function.Arguments, directive, 0, false, correlationID)
emit(agentEvent{
Type: "tool_result",
Data: map[string]any{"name": tc.Function.Name, "result": directive, "id": tc.ID, "retry_capped": true},
SessionID: sessionID,
Iteration: i + 1,
})
messages = append(messages, openai.ToolMessage(directive, tc.ID))
continue
}
}
emit(agentEvent{
Type: "tool_use",
Data: map[string]any{"name": tc.Function.Name, "args": args, "id": tc.ID},
@@ -508,6 +544,22 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
if callErr != nil {
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID)
// Retry cap: dispatch errors (e.g. MCP client timeout)
// count toward the cap too. A command that keeps timing
// out at the gateway is exactly the pattern we want to
// break — see session 1e9c7691's 20+ identical
// `chown` timeouts.
if tc.Function.Name == "run" {
t, _ := args["target"].(string)
c, _ := args["command"].(string)
key := runFailureKey(t, c)
n := retries.recordFailure(key)
if n >= maxRunRetries {
slog.Warn("nomos: run failure cap reached — next identical call will be refused",
"target", t, "failures", n, "session", sessionID)
}
}
emit(agentEvent{
Type: "tool_result",
Data: map[string]any{"name": tc.Function.Name, "error": callErr.Error(), "id": tc.ID},
@@ -551,6 +603,25 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
messages = append(messages, openai.ToolMessage(string(resultJSON), tc.ID))
slog.Info("nomos: tool success", "tool", tc.Function.Name, "ms", elapsed)
// Retry cap: record failures of `run` calls so the cap above
// can refuse a repeated identical failure. A "failure" here
// means the dispatch errored OR the MCP result text matches
// the "run on <target>: ERROR …" signature — both indicate
// the command actually ran and failed, not just that it
// queued for approval (pending approvals are not failures).
// Pass the RAW result text (not JSON-encoded) so the helper's
// HasPrefix check sees "run on …" not "\"run on …\"".
if isRunFailure(tc.Function.Name, runResultText(result), callErr) {
t, _ := args["target"].(string)
c, _ := args["command"].(string)
key := runFailureKey(t, c)
n := retries.recordFailure(key)
if n >= maxRunRetries {
slog.Warn("nomos: run failure cap reached — next identical call will be refused",
"target", t, "failures", n, "session", sessionID)
}
}
// ask_operator pauses the task: the agent has posed a decision only
// the operator can make. End the turn here so it doesn't barrel past
// its own question — the answer (panel or chat reply) resumes it.

View File

@@ -18,6 +18,7 @@ import (
"github.com/dtoro/oikos/internal/safego"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func main() {
@@ -448,6 +449,21 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
w.WriteHeader(204)
case http.MethodGet:
// getMessages alone can't distinguish "session exists but has no
// messages yet" from "session id doesn't exist at all" — it's a
// plain WHERE session_id=$1 query that returns zero rows either
// way. A frontend window opened for a deleted/invalid session
// (persisted layout, a stale link) needs to tell those apart, so
// check existence explicitly and 404 rather than silently
// returning an empty transcript that looks like a fresh task.
if _, err := st.getSession(r.Context(), id); err != nil {
if err == pgx.ErrNoRows {
http.Error(w, "session not found", 404)
return
}
http.Error(w, err.Error(), 500)
return
}
messages, err := st.getMessages(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)

170
cmd/nomos/retrycap.go Normal file
View File

@@ -0,0 +1,170 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"strings"
"sync"
)
// maxRunRetries is the per-turn cap on identical failing `run` tool calls.
// After this many failures with the same (target, command) key, the agent
// loop refuses to dispatch the call again and instead surfaces a directive
// to investigate *why* (ps/strace/lsof) or escalate to the operator.
//
// Background: session 1e9c7691 (2026-07-18) retried the same
// `chown :10000 /mnt/media_local && chmod 2775 …` ~20 times across direct
// runs, SSH-hop-via-hubris, wrapping in a shell script, and bare `echo test`
// sanity checks. Each retry piled up another zombie process on the target
// (knfsd was holding a kernel lock on the exported directory). The agent
// only investigated *why* after the operator explicitly asked
// "the command just keeps running?" — see
// plans/2026-07-18-session-review-three-sessions.md P0.1.
const maxRunRetries = 3
// runRetryTracker deduplicates failing `run` calls within a single chat
// turn (chatWith invocation). It is NOT persisted across turns — the cap
// is per-turn, so a fresh turn after the operator responds can retry once
// more. The intent is to break a tight retry loop within one turn, not to
// permanently block the agent from ever attempting the operation again.
//
// Threading: the agent loop is single-goroutine per turn, but the tracker
// is guarded by a mutex so future callers (e.g. concurrent tool dispatch)
// stay safe. The mutex is uncontended on the current hot path.
type runRetryTracker struct {
mu sync.Mutex
counts map[string]int
}
func newRunRetryTracker() *runRetryTracker {
return &runRetryTracker{counts: make(map[string]int)}
}
// runFailureKey is the dedup key for "this is the same command against the
// same target." Whitespace is collapsed so trivial reformatting
// (newlines vs spaces, trailing whitespace) doesn't escape the cap. The
// purpose field is intentionally NOT part of the key: the agent often
// rephrases purpose between retries while issuing the same command.
func runFailureKey(target, command string) string {
collapsed := strings.Join(strings.Fields(command), " ")
target = strings.TrimSpace(target)
h := sha256.Sum256([]byte(target + "\x00" + collapsed))
return hex.EncodeToString(h[:])
}
// recordFailure increments the failure count for the given key and returns
// the new count. The caller should check `count > maxRunRetries` BEFORE
// dispatching to decide whether to skip the call.
func (r *runRetryTracker) recordFailure(key string) int {
r.mu.Lock()
defer r.mu.Unlock()
r.counts[key]++
return r.counts[key]
}
// failures returns the current failure count for a key (0 if unseen).
func (r *runRetryTracker) failures(key string) int {
r.mu.Lock()
defer r.mu.Unlock()
return r.counts[key]
}
// isRunFailure reports whether a `run` tool call's outcome should count
// as a failure for retry-cap purposes. A call counts as failed when:
// - the dispatch itself errored (callErr != nil), OR
// - the result text starts with "run on <target>: ERROR" — the
// shape classifyAndGate/sshExec produce when SSH or the command fails.
//
// Approvals queued ("requires approval") do NOT count as failures: they
// are pending operator action, not a command execution failure. A read
// of the existing code paths (classifyAndGate in internal/mcp/server.go)
// confirms the "ERROR" prefix is the stable failure signature for `run`.
//
// The resultText parameter is the MCP tool's RAW text result (not JSON-
// re-encoded): when classifyAndGate returns a textResult like
// "run on host:strong: ERROR ...", the MCP client unwraps it back to a
// plain Go string (see mcpClient.callTool). The caller should pass that
// raw string, not json.Marshal's output (which would quote-wrap it).
func isRunFailure(toolName string, resultText string, callErr error) bool {
if callErr != nil {
return true
}
if toolName != "run" {
return false
}
// "run on host:strong: ERROR ..." or "run on lxc:caddy: ERROR ..."
// Both shapes start with "run on ".
if !strings.HasPrefix(resultText, "run on ") {
return false
}
return strings.Contains(resultText, ": ERROR")
}
// runResultText extracts the raw text from a `run` tool's result value as
// returned by mcpClient.callTool — typically a Go string, but may also be
// a []string (multi-content result) or other JSON-decoded shape. Returns
// "" for shapes we don't recognize. Used by the retry-cap path so
// isRunFailure receives the un-quoted text form (see its doc comment).
func runResultText(result any) string {
switch v := result.(type) {
case string:
return v
case []string:
if len(v) > 0 {
return v[0]
}
case []any:
var b strings.Builder
for _, e := range v {
if s, ok := e.(string); ok {
b.WriteString(s)
}
}
return b.String()
}
return ""
}
// runRetryDirective is the synthetic tool result returned to the model
// when the retry cap is hit, in place of dispatching the call again. It
// directs the agent to investigate *why* the command keeps failing before
// retrying, or to surface the blocker to the operator.
func runRetryDirective(target, command string, failures int) string {
return "Refused: this `run` against " + target + " has failed " +
itoa(failures) + " times this turn — retry cap hit. The command:\n " +
command + "\nis almost certainly blocked by something on the target " +
"(a hung process, a kernel lock, an unexported FS, a stuck SSH " +
"session, …) — NOT a transient gateway issue. Do NOT retry with " +
"different routing or quoting. Instead, BEFORE calling `run` again, " +
"investigate *why* the command hangs: e.g. `ps aux | grep <cmd>`, " +
"`lsof <path>`, `strace -f -p <pid>` or `strace -f <cmd>`, " +
"`mount | grep <path>`, `dmesg | tail`. If you find a structural " +
"blocker (e.g. a kernel lock on an exported NFS directory → " +
"unexport → mutate → re-export), say so to the operator and fix it " +
"with a different command. If you genuinely cannot diagnose, " +
"surface the blocker to the operator with what you've tried — do " +
"not just retry the same command."
}
// itoa is a tiny strconv.Itoa to keep this file dependency-free.
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}

129
cmd/nomos/retrycap_test.go Normal file
View File

@@ -0,0 +1,129 @@
package main
import (
"strings"
"testing"
)
func TestRunFailureKey_StableAcrossWhitespace(t *testing.T) {
cases := []struct{ a, b string }{
{"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local",
"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"},
{"chown :10000 /mnt/media_local\n&& chmod 2775 /mnt/media_local",
"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"},
{"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local ",
" chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"},
}
for i, c := range cases {
ka := runFailureKey("host:strong", c.a)
kb := runFailureKey("host:strong", c.b)
if ka != kb {
t.Errorf("case %d: keys differ for whitespace-equivalent commands:\n a=%q\n b=%q", i, c.a, c.b)
}
}
}
func TestRunFailureKey_DiffersByTarget(t *testing.T) {
a := runFailureKey("host:strong", "echo hi")
b := runFailureKey("host:hubris", "echo hi")
if a == b {
t.Error("keys should differ when target differs")
}
}
func TestRunFailureKey_DiffersByCommand(t *testing.T) {
a := runFailureKey("host:strong", "echo hi")
b := runFailureKey("host:strong", "echo bye")
if a == b {
t.Error("keys should differ when command differs")
}
}
func TestRunRetryTracker_CountsAndCaps(t *testing.T) {
r := newRunRetryTracker()
key := runFailureKey("host:strong", "chown :10000 /mnt/media_local")
for i := 1; i <= maxRunRetries; i++ {
if got := r.recordFailure(key); got != i {
t.Errorf("recordFailure #%d = %d, want %d", i, got, i)
}
}
// At the cap, failures() should report maxRunRetries, and the next
// identical call should be refused by the agent loop (failures() >=
// maxRunRetries).
if got := r.failures(key); got != maxRunRetries {
t.Errorf("failures = %d, want %d", got, maxRunRetries)
}
if r.failures(key) < maxRunRetries {
t.Errorf("cap should be enforced at maxRunRetries=%d", maxRunRetries)
}
}
func TestRunRetryTracker_PerTurnIsolation(t *testing.T) {
// Different keys don't interfere.
r := newRunRetryTracker()
k1 := runFailureKey("host:strong", "echo a")
k2 := runFailureKey("host:strong", "echo b")
r.recordFailure(k1)
r.recordFailure(k1)
if got := r.failures(k2); got != 0 {
t.Errorf("k2 failures = %d, want 0 (keys are isolated)", got)
}
}
func TestIsRunFailure(t *testing.T) {
cases := []struct {
desc string
tool string
result string
callErr error
want bool
}{
{"run with ERROR prefix", "run", "run on host:strong: ERROR ssh: signal: killed", nil, true},
{"run with exit error", "run", "run on lxc:caddy: ERROR exit status 1", nil, true},
{"run success (read-only auto)", "run", "run on host:strong (read_only, auto): hello", nil, false},
{"run success (assent window)", "run", "run on host:strong (config_mutation, auto via assent window): done", nil, false},
{"run queued for approval", "run", "run on host:strong requires approval (risk: config_mutation) — execution 019f4930 queued. Present the command and purpose to the operator and wait; do not re-request.", nil, false},
{"non-run tool", "get_entity", "lxc list result", nil, false},
{"callErr set (dispatch failure)", "run", "", errFake{}, true},
{"callErr set on non-run tool", "get_entity", "some result", errFake{}, true}, // callErr trumps name
}
for i, c := range cases {
got := isRunFailure(c.tool, c.result, c.callErr)
if got != c.want {
t.Errorf("case %d (%s): isRunFailure = %v, want %v", i, c.desc, got, c.want)
}
}
}
type errFake struct{}
func (errFake) Error() string { return "fake dispatch error" }
func TestRunRetryDirective_Content(t *testing.T) {
d := runRetryDirective("host:strong", "chown :10000 /mnt/media_local", 3)
for _, want := range []string{
"Refused:",
"host:strong",
"3 times",
"retry cap hit",
"Do NOT retry",
"strace",
"ps aux",
"lsof",
"surface the blocker",
} {
if !strings.Contains(d, want) {
t.Errorf("directive missing %q; got:\n%s", want, d)
}
}
}
func TestItoa(t *testing.T) {
cases := map[int]string{0: "0", 1: "1", 9: "9", 10: "10", 42: "42",
100: "100", -1: "-1", -42: "-42"}
for in, want := range cases {
if got := itoa(in); got != want {
t.Errorf("itoa(%d) = %q, want %q", in, got, want)
}
}
}

View File

@@ -492,10 +492,34 @@ func (s *store) taskEntityPtr(ctx context.Context, sessionID string) *uuid.UUID
// happens — not in reopenSession — because set_goal is the explicit signal
// for "new sub-task." An approval ("go ahead") does NOT call set_goal, so it
// won't destroy the plan the operator just approved.
//
// P1.4 (2026-07-18): when a non-empty prior goal is being overwritten by a
// different goal, emit a `task.superseded` event carrying the prior goal.
// This gives the UI/audit trail a clear signal that the operator pivoted —
// without it, the prior goal just silently disappears from
// agent_sessions.goal and there's no record the session ever had a
// different starting intent. See plans/2026-07-18-session-review-three-
// sessions.md P1.4 (session 55927f0a had two set_goal calls with the first
// implicitly abandoned when the operator said "lets just keep ludo-library
// then").
func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil
}
// Capture the prior goal BEFORE the UPDATE overwrites it. If non-empty
// and different from the new goal, emit task.superseded so the audit
// trail records the pivot — the row's goal column won't.
var priorGoal string
s.pool.QueryRow(ctx,
`SELECT COALESCE(goal, '') FROM agent_sessions WHERE id = $1`,
sessionID).Scan(&priorGoal)
if priorGoal != "" && priorGoal != goal {
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.superseded",
s.taskEntityPtr(ctx, sessionID), "info", "nomos", sessionID,
map[string]any{"prior_goal": priorGoal, "new_goal": goal})
slog.Info("nomos: task goal superseded by a new set_goal",
"session", sessionID, "prior_goal", priorGoal, "new_goal", goal)
}
// Replace any prior plan steps (done/running/pending/...) as `replaced`.
// The rows are kept for the generation counter + audit trail; proposePlan
// excludes `replaced` from its in-flight check, so the next propose_plan
@@ -868,7 +892,7 @@ type staleGoalSession struct {
}
// staleGoalSessions finds sessions that framed themselves as a real task
// (goal != '', so the inline safety net in agent.go intentionally left them
// (goal != , so the inline safety net in agent.go intentionally left them
// alone) but have sat non-terminal past idleThreshold. completion_nudges
// tells the caller whether to nudge (0) or give up and auto-close (>=1) —
// see processIdleSweep in continue.go.

View File

@@ -311,3 +311,69 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
t.Fatal("hadEntityWriteback = false after run+writeback, want true")
}
}
// TestSetGoal_SupersessionEvent is the store-level proof for P1.4 from
// plans/2026-07-18-session-review-three-sessions.md: when setGoal is called
// and a non-empty prior goal already exists with a DIFFERENT value, a
// task.superseded event must be emitted (so the audit trail records the
// pivot — the row's goal column will be overwritten, losing the prior intent
// without this event). When the goal is identical OR no prior goal exists,
// no supersession event is emitted.
//
// Background: session 55927f0a had two set_goal calls; the first was
// implicitly abandoned when the operator said "lets just keep ludo-library
// then." Without the event, the prior goal silently disappeared.
func TestSetGoal_SupersededEvent(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.createSession(ctx, "goal pivot test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
// First set_goal — no prior, no supersession event expected.
if err := s.setGoal(ctx, sess.ID, "Fix sabnzbd download folder to use ludo-lvm"); err != nil {
t.Fatalf("setGoal #1: %v", err)
}
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 0 {
t.Errorf("after first set_goal: %d task.superseded events, want 0", n)
}
// Second set_goal with a DIFFERENT goal — supersession event expected.
if err := s.setGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil {
t.Fatalf("setGoal #2: %v", err)
}
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 {
t.Errorf("after second set_goal with a different goal: %d task.superseded events, want 1", n)
}
// Third set_goal with the SAME goal as the second — no new supersession
// event (idempotent: same goal is a no-op, not a pivot).
if err := s.setGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil {
t.Fatalf("setGoal #3: %v", err)
}
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 {
t.Errorf("after third set_goal with same goal as second: %d task.superseded events, want 1 (no new pivot)", n)
}
// The session's current goal must be the latest one set.
got, err := s.getSession(ctx, sess.ID)
if err != nil {
t.Fatalf("getSession: %v", err)
}
if got.Goal != "Add NFS export of ludo-lvm to ZimaOS" {
t.Errorf("session goal = %q, want the second (latest) goal", got.Goal)
}
}
// countEvents counts observability events of the given type correlated to
// the given session. Used by TestSetGoal_SupersededEvent to assert the
// task.superseded audit-trail signal was emitted.
func countEvents(ctx context.Context, s *store, sessionID, eventType string) int {
var n int
s.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM events WHERE correlation_id = $1 AND type = $2`,
sessionID, eventType).Scan(&n)
return n
}

View File

@@ -263,6 +263,20 @@ func TestAPIEndToEnd(t *testing.T) {
}
})
// Regression: rel_type is an optional array param (*[]string); when
// omitted entirely (not an empty list), passing the nil pointer straight
// through to pgx as a query arg panics because pgx can't infer the array
// element type from a nil *[]string. root+depth alone must still work.
t.Run("graph without rel_type", func(t *testing.T) {
rec, body := get(t, h, "/api/v1/graph?root=host:hubris&depth=1", nil)
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
if len(body["nodes"].([]any)) < 2 {
t.Errorf("graph too small: %d nodes", len(body["nodes"].([]any)))
}
})
t.Run("ontology", func(t *testing.T) {
rec, body := get(t, h, "/api/v1/ontology", nil)
if rec.Code != 200 {

View File

@@ -211,7 +211,7 @@ func (s *Server) GetEntityRelations(ctx context.Context, req gen.GetEntityRelati
Type: r.Type,
Attributes: attrs,
ValidFrom: r.ValidFrom,
ValidTo: validTo,
ValidTo: validTo,
})
}
return gen.GetEntityRelations200JSONResponse{Items: items}, nil
@@ -282,6 +282,15 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
var err error
truncated := false
// pgx can't infer the array element type from a nil *[]string (the
// param is absent from the request, not an empty list), so dereference
// to a plain []string first — nil there still encodes as SQL NULL, but
// pgx has a concrete type to work with.
var relTypes []string
if req.Params.RelType != nil {
relTypes = *req.Params.RelType
}
if req.Params.Root != nil && *req.Params.Root != "" {
rootID, rerr := s.resolveEntityID(ctx, *req.Params.Root)
if rerr != nil {
@@ -291,7 +300,7 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
SELECT `+entityCols+`
FROM blast_radius($1, $2, $3) b JOIN entities e ON e.id = b.entity_id
LEFT JOIN entity_status st ON st.entity_id = e.id
ORDER BY e.slug`, rootID, depth, req.Params.RelType)
ORDER BY e.slug`, rootID, depth, relTypes)
} else {
// Whole-graph view: pick the most-connected entities first so the
// graph shows actual topology, not just whatever sorts first
@@ -327,7 +336,7 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
}
edgeRows, err := sqlcgen.New(s.pool).ListGraphEdges(ctx, sqlcgen.ListGraphEdgesParams{
Ids: ids,
RelTypes: *req.Params.RelType,
RelTypes: relTypes,
})
if err != nil {
return nil, err
@@ -348,7 +357,7 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
Type: r.Type,
Attributes: attrs,
ValidFrom: r.ValidFrom,
ValidTo: validTo,
ValidTo: validTo,
})
}

View File

@@ -511,16 +511,27 @@ func isPrivateHost(host string) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()
}
// resolveExecTarget resolves any target slug (host: or lxc:) to the SSH
// resolveExecTarget resolves any target slug (host:, lxc:, or vm:) to the SSH
// endpoint that will actually run the command, and a wrap function that turns
// a plain shell command into whatever must actually be sent over that SSH
// connection: identity for a host, `pct exec <pve_id> -- ...` for an LXC.
// connection: identity for a host, `pct exec <pve_id> -- ...` for an LXC,
// `qm guest exec <pve_id> -- ...` for a VM.
//
// The lxc.attributes.host value is stored WITHOUT a "host:" prefix (e.g.
// "strong", not "host:strong") — see pct_create's entity registration. The
// pre-existing pct_exec handler queried resolveHost with that bare value
// directly, which can never match a "host:*" slug and always fails; this
// prefixes it correctly.
//
// vm: support (2026-07-18): VMs in inventory.yaml carry `pve_id` and a `host`
// attribute (or a `hosts` relationship) just like LXCs, but they're reached
// via `qm guest exec` instead of `pct exec`. Previously the agent had to
// SSH-hop via `host:hubris` to reach a VM (e.g. `ssh root@<vm_ip> '...'`),
// which broke on nested shell quoting and forced manual escaping workarounds
// — see plans/2026-07-18-session-review-three-sessions.md P1.6. A VM's
// `host` attribute is optional: if absent, fall back to looking up the
// `hosts` relationship on the VM entity, then to hubris (the documented
// default Proxmox host) — same fallback chain as LXCs.
func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (host, user string, wrap func(cmd string) string, err error) {
if strings.HasPrefix(targetSlug, "host:") {
host, user, err = resolveHost(ctx, pool, targetSlug)
@@ -536,13 +547,7 @@ func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (h
if qerr := pool.QueryRow(ctx, "SELECT attributes->>'pve_id', COALESCE(attributes->>'host', '') FROM entities WHERE slug = $1", targetSlug).Scan(&pveID, &hostAttr); qerr != nil || pveID == "" {
return "", "", nil, fmt.Errorf("LXC not found or missing pve_id: %s", targetSlug)
}
hostSlug := hostAttr
if hostSlug == "" {
hostSlug = "hubris" // documented default Proxmox host when unset
}
if !strings.HasPrefix(hostSlug, "host:") {
hostSlug = "host:" + hostSlug
}
hostSlug := resolveProxmoxHostSlug(ctx, pool, targetSlug, hostAttr)
host, user, err = resolveHost(ctx, pool, hostSlug)
id := pveID
return host, user, func(cmd string) string {
@@ -550,7 +555,71 @@ func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (h
return fmt.Sprintf("pct exec %s -- bash -c 'echo %s | base64 -d | bash'", id, b64)
}, err
}
return "", "", nil, fmt.Errorf("unsupported target %q: must be host:<slug> or lxc:<slug>", targetSlug)
if strings.HasPrefix(targetSlug, "vm:") {
// VMs: same host-resolution chain as LXCs (attributes.host →
// `hosts` relationship → hubris default), but reached via
// `qm guest exec` instead of `pct exec`. Requires the QEMU
// guest agent running inside the VM (the standard Proxmox
// setup; ZimaOS/HAOS in this fleet already have it).
var pveID, hostAttr string
if qerr := pool.QueryRow(ctx, "SELECT attributes->>'pve_id', COALESCE(attributes->>'host', '') FROM entities WHERE slug = $1", targetSlug).Scan(&pveID, &hostAttr); qerr != nil || pveID == "" {
return "", "", nil, fmt.Errorf("VM not found or missing pve_id: %s", targetSlug)
}
hostSlug := resolveProxmoxHostSlug(ctx, pool, targetSlug, hostAttr)
host, user, err = resolveHost(ctx, pool, hostSlug)
id := pveID
return host, user, func(cmd string) string {
b64 := base64.StdEncoding.EncodeToString([]byte(cmd))
// `qm guest exec <id> -- /bin/bash -c '...'` returns JSON by
// default; pipe through `jq -r .out` if available, else cat.
// The base64 round-trip mirrors the LXC path so nested quoting
// (the original VM-target pain point — session 55927f0a) is
// handled identically to LXC dispatch.
return fmt.Sprintf(
"qm guest exec %s -- /bin/bash -c 'echo %s | base64 -d | bash' | jq -r '.out // .err // empty' 2>/dev/null || qm guest exec %s -- /bin/bash -c 'echo %s | base64 -d | bash'",
id, b64, id, b64)
}, err
}
return "", "", nil, fmt.Errorf("unsupported target %q: must be host:<slug>, lxc:<slug>, or vm:<slug>", targetSlug)
}
// resolveProxmoxHostSlug resolves the Proxmox host slug that owns a given
// LXC/VM target. Resolution order:
// 1. hostAttr if non-empty (the entity's attributes.host — stored without
// "host:" prefix in inventory.yaml and pct_create).
// 2. the `hosts` relationship on the entity (e.g. host:hubris → vm:zimaos),
// looked up in the relationships table — the canonical graph source.
// 3. "hubris" as a documented default Proxmox host fallback.
//
// Returns a slug with the "host:" prefix attached, ready for resolveHost.
// Extracted from the inline LXC path (2026-07-18) so the VM path shares the
// same chain — see plans/2026-07-18-session-review-three-sessions.md P1.6.
func resolveProxmoxHostSlug(ctx context.Context, pool *db.Pool, entitySlug, hostAttr string) string {
hostSlug := strings.TrimSpace(hostAttr)
if hostSlug == "" {
// Fall back to the `hosts` relationship — the graph edge from
// the Proxmox host to this LXC/VM. This is the canonical source
// for "who owns this VM" in inventory.yaml; the `host` attribute
// is a denormalized shortcut that not every entity has.
var relHostSlug string
// hosts relationship: source=host, target=lxc/vm. Look up the
// source slug given the target.
if err := pool.QueryRow(ctx, `
SELECT e.slug FROM relationships r
JOIN entities e ON e.id = r.source_id
WHERE r.target_id = (SELECT id FROM entities WHERE slug = $1)
AND r.type = 'hosts' AND r.valid_to IS NULL
LIMIT 1`, entitySlug).Scan(&relHostSlug); err == nil && relHostSlug != "" {
hostSlug = relHostSlug
}
}
if hostSlug == "" {
hostSlug = "hubris" // documented default Proxmox host when unset
}
if !strings.HasPrefix(hostSlug, "host:") {
hostSlug = "host:" + hostSlug
}
return hostSlug
}
// classifyAndGate is the shared classify→execute-or-queue path for every
@@ -994,4 +1063,83 @@ func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UU
// gated action is now awaiting a decision.
_ = observability.Event(ctx, sqlcgen.New(pool), "approval.created", &execID, "warning", "mcp", "",
map[string]any{"action": action, "params": params, "risk_class": riskClass})
}
}
// inspectPathAcrossTargets is the bulk fact-gathering helper behind the
// inspect_path MCP tool (plans/2026-07-18-session-review-three-sessions.md
// P1.5). For each target slug, it runs a single read-only shell command
// producing mount/df/ls/stat output for the given path, and returns the
// results as a map keyed by target slug.
//
// Why this exists: sessions 1e9c7691 and 55927f0a each spent ~15 `run`
// calls gathering identical facts (`mount | grep`, `df`, `ls -la`, `stat`)
// across hosts and LXCs to trace where a path lives, who mounts it, and
// what permissions it has. One call here replaces that fan-out. All
// commands are read-only — the tool bypasses classifyAndGate and runs
// directly via sshExec against resolveExecTarget's host/wrap. Failures
// (unresolvable target, SSH error) are reported per-target in the result
// map, not as a single tool-level error, so one bad target doesn't lose
// the others.
//
// The per-target command is intentionally compact: one combined shell
// invocation that prints mount source/dest, df, ls -la of the path's
// parent + the path itself, and stat. Output is truncated to 4KB per
// target to keep the total result reasonable for an 8-target call.
func inspectPathAcrossTargets(ctx context.Context, pool *db.Pool, path string, targets []string) map[string]any {
results := make(map[string]any, len(targets))
path = strings.TrimSpace(path)
var wg sync.WaitGroup
var mu sync.Mutex
wg.Add(len(targets))
for _, tgt := range targets {
go func(target string) {
defer wg.Done()
entry := inspectOneTarget(ctx, pool, path, target)
mu.Lock()
results[target] = entry
mu.Unlock()
}(tgt)
}
wg.Wait()
return results
}
// inspectOneTarget runs the read-only inspection for one target. Returns a
// map with keys: "ok" (bool), "output" (string, on success), "error"
// (string, on failure). Kept small so the JSON shape is stable across the
// parallel-call path.
func inspectOneTarget(ctx context.Context, pool *db.Pool, path, target string) map[string]any {
host, user, wrap, rerr := resolveExecTarget(ctx, pool, target)
if rerr != nil {
return map[string]any{"ok": false, "error": fmt.Sprintf("resolve target: %v", rerr)}
}
// One shell invocation, four sections, each guarded by `2>&1 || true`
// so a missing path doesn't kill the rest. Stat with -c gives a
// stable machine-readable line for ownership/perms; ls -la gives the
// human-readable listing of the path and its parent (so we can see
// both "what's in here" and "how the parent is laid out" — useful for
// NFS-root-vs-subdir permission mismatches, the exact issue in
// session 1e9c7691).
cmd := fmt.Sprintf(
`echo "=== mount ==="; mount 2>/dev/null | grep -- "%[1]s" || echo "(not a mount point)";
echo "=== df ==="; df -h "%[1]s" 2>&1 || true;
echo "=== stat ==="; stat -c '%%a %%U:%%G (size=%%s, type=%%F)' "%[1]s" 2>&1 || true;
echo "=== ls -la path ==="; ls -la "%[1]s" 2>&1 | head -40 || true;
echo "=== ls -la parent ==="; ls -la "$(dirname "%[1]s")" 2>&1 | head -20 || true`,
path)
out, xerr := sshExec(ctx, host, user, wrap(cmd))
if xerr != nil {
return map[string]any{"ok": false, "error": fmt.Sprintf("ssh: %v: %s", xerr, out)}
}
// Truncate per-target output to keep an 8-target call's total under
// ~32KB. 4KB per target is enough for the head -40/head -20 listings
// above; if a directory is enormous, the truncation keeps the result
// usable without flooding the model's context.
const maxPerTarget = 4096
if len(out) > maxPerTarget {
out = out[:maxPerTarget] + fmt.Sprintf("\n...truncated (%d bytes total)", len(out))
}
return map[string]any{"ok": true, "output": out}
}

View File

@@ -314,9 +314,9 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
// for future runbook extraction — especially pct_create DNS/VMID logic.
// DO NOT re-register this tool. See plans/2026-07-10-general-gated-execution.md.
{tool: &mcp.Tool{Name: "run", Description: "Run ANY shell command against any host or LXC. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.",
{tool: &mcp.Tool{Name: "run", Description: "Run ANY shell command against any host, LXC, or VM. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.",
InputSchema: objSchema(
prop{"target", "string", "Target entity slug: host:<slug> (e.g. host:strong) or lxc:<slug> (e.g. lxc:caddy). LXC commands run via pct exec on its Proxmox host automatically."},
prop{"target", "string", "Target entity slug: host:<slug> (e.g. host:strong), lxc:<slug> (e.g. lxc:caddy), or vm:<slug> (e.g. vm:zimaos). LXC commands run via pct exec on their Proxmox host automatically. VM commands run via qm guest exec on their Proxmox host (requires the QEMU guest agent inside the VM — standard for Proxmox VMs)."},
prop{"command", "string", "The shell command to run. Can be a full script (multi-line, &&-chained). Runs as root."},
prop{"purpose", "string", "One sentence: why you're running this. Shown to the operator alongside the approval — be specific, this is what they're approving."},
prop{"declared_risk", "string", "Optional self-assessment: read_only, reversible_low, config_mutation, or destructive. This can only ESCALATE the automatic classification, never lower it — declaring a mutating command as read_only has no effect."},
@@ -340,6 +340,44 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil
}},
// inspect_path is the bulk fact-gathering tool from
// plans/2026-07-18-session-review-three-sessions.md P1.5.
// Sessions 1e9c7691 and 55927f0a each spent ~15 `run` calls
// gathering identical facts (`mount | grep`, `df`, `ls -la`,
// `stat`) across hosts and LXCs to understand where a path
// lives, who mounts it, and what permissions it has. This tool
// collapses that fan-out into one call: pass a path and a list
// of targets, get back per-target mount/df/ls/stat output as
// JSON. All commands are read-only, so no approval is needed.
{tool: &mcp.Tool{Name: "inspect_path", Description: "Bulk fact-gathering: run mount/df/ls/stat for the same path across multiple host/LXC/VM targets in ONE call. Returns a JSON object keyed by target slug, each with the target's view of the path (mount source, filesystem, size, top-level entries with ownership/permissions). Use this instead of N separate `run` calls when you need to understand a path's footprint across the fleet (e.g. tracing where a volume is mounted, checking permissions on the same NFS path from server + client). All commands are read-only — no approval needed.",
InputSchema: objSchema(
prop{"path", "string", "Absolute path to inspect on each target (e.g. /mnt/media_local, /media/ludo-library)."},
prop{"targets", "array", "List of target entity slugs (host:strong, lxc:nfs-export, vm:zimaos, …). Up to 8 per call."},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
path, _ := args["path"].(string)
if path == "" {
return textResult("error: path is required"), nil
}
rawTargets, _ := args["targets"].([]any)
if len(rawTargets) == 0 {
return textResult("error: at least one target is required"), nil
}
if len(rawTargets) > 8 {
return textResult("error: at most 8 targets per inspect_path call (use two calls if you need more)"), nil
}
targets := make([]string, 0, len(rawTargets))
for _, t := range rawTargets {
if s, ok := t.(string); ok && s != "" {
targets = append(targets, s)
}
}
results := inspectPathAcrossTargets(ctx, pool, path, targets)
out, _ := json.MarshalIndent(results, "", " ")
return textResult(string(out)), nil
}},
{tool: &mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
InputSchema: objSchema(
prop{"url", "string", "Absolute http(s) URL to fetch"},

View File

@@ -336,6 +336,51 @@ port is busy, find a free one. Only surface to the operator if you've tried
reasonable alternatives and none worked. An error in one step is not a reason
to stop the entire turn — it's a reason to try a different approach.
**A hung command is not a failed command — investigate before retrying.**
If a `run` call times out or returns "ERROR" (e.g. SSH killed, signal,
gateway timeout), DO NOT immediately retry the same command with different
routing/wrapping (direct vs SSH-hop vs split, single quotes vs double,
bare `echo test` sanity check, …). That piles up zombie processes on the
target and burns tool calls. Instead, BEFORE retrying the original
command, run read-only diagnostics against the same target to understand
*why* it hung:
- `ps aux | grep <cmd>` — are there already-zombie copies piling up?
- `lsof <path>` — is something holding the file/dir open?
- `strace -f -p <pid>` or `timeout 5 strace -f <cmd>` — what syscall is
it stuck on? (e.g. `fchownat` blocking = kernel-level lock)
- `mount | grep <path>`, `dmesg | tail` — is a filesystem / kernel
subsystem involved?
- `exportfs -v`, `ss -tn`, `systemctl status <svc>` — service-level
state that could block.
Once you understand the blocker, fix it with a different command (e.g.
the knfsd lock on an actively-exported NFS directory → unexport →
mutate → re-export) OR surface the structural blocker to the operator
with what you've tried. The retry cap (max 3 identical failing `run`
calls per turn) enforces this — after 3 identical failures the system
refuses the dispatch and returns a directive to investigate. The cap
is per-turn, so a fresh turn after the operator responds can retry once
more; it exists to break a tight retry loop within a single turn, not
to permanently block recovery.
**Ask before proposing a multi-step migration.** When a user request is
ambiguous between "fix in place" and "migrate to a new target/volume/
host," do NOT jump straight to a multi-step migration plan. Use
`ask_operator` with one clarifying question ("fix in place, or migrate?")
before producing the plan. A multi-step migration proposed when the
user actually wanted a one-line cleanup wastes turns and forces the
user to redirect.
**Multi-goal sessions: summarize the arc, not just the last goal.**
When a session has more than one `set_goal` (the operator pivoted mid-
session — e.g. "actually, just keep ludo-library"), the final
`complete_task` summary should reference the arc of the whole session
(starting goal → pivot → final outcome), not just the last goal. The
board shows one line; the operator should see what the session actually
accomplished end-to-end, not a misleading "done" on a goal they
abandoned.
**Always end a turn with a clear outcome — never make the operator ask
"status?".** When you finish (or pause) a piece of work, your final message
must state the result plainly: what's now true, what you verified, what (if

View File

@@ -0,0 +1,414 @@
# 2026-07-18 — Session review (three recent sessions)
**Status:** Implemented — P0.1, P0.2, P1.3, P1.4, P1.5, P1.6, P1.8, P2.10
landed in v0.7.12. P1.7 and P2.9 deferred (retry cap addresses the same
symptom at lower cost); see "Deferred" section at the bottom.
**Updated:** 2026-07-18 — session 1 continued after initial audit; outcome
upgraded from ⚠️ partial to ✅ success, root cause revised (knfsd kernel
lock, not gateway timeout). Implementation landed same day.
Review of the last three Nomos sessions against the protocol in
`.agents/skills/session-review/SKILL.md`. Data pulled from the local
sessions API (`http://localhost:8092/sessions`).
---
## Session 1 — `1e9c7691` (2026-07-18T11:28)
**"Diagnose and fix ZimaOS folder move/delete failures on ludo-library"**
| Metric | Value |
|---|---|
| Messages | 13 (6 user / 7 assistant) |
| Tool calls | 97 across 7 turns |
| Top tools | `run` ×58, `update_plan_step` ×7, `get_execution_status` ×7, `search_knowledge` ×3, `list_entities` ×3, `get_entity` ×3, `whoami` ×2 |
| Objective | Diagnose and fix ZimaOS folder move/delete failures on the ludo-library NFS mount |
| Outcome | ✅ success — root cause found and fix applied; verified from ZimaOS |
| Severity | friction |
### What worked
- Root-cause analysis was fast and correct at the **NFS permissions** layer:
the export on `strong` uses `all_squash,anonuid=33,anongid=10000`, mapping
every NFS client to `www-data:media`. The export root `/mnt/media_local`
was owned `root:root / 755` while subdirs were `2775 media`. Subdir-level
ops worked, root-level (rename/delete top-level entries) failed.
- After the user prompted "the command just keeps running? does not
complete," the agent dug deeper and found the **real root cause**:
`knfsd` (kernel NFS server) holds a lock on actively-exported directories,
causing `chown` to hang indefinitely at the `fchownat()` syscall.
`strace -f chown :10000 /mnt/media_local` confirmed the hang point.
25+ zombie `chgrp`/`chown` processes had piled up from the session's
repeated attempts.
- The correct fix sequence was identified and applied: `killall -9 chgrp
chown` to clear zombies, then unexport → `chown :10000` + `chmod 2775`
→ re-export. Routed via SSH-hop from `host:hubris` (the `host:strong`
direct path kept timing out because the commands genuinely hang, not
because of a network issue).
- Verification was done from the client side: `touch`, `mv`, `rm`,
`mkdir`, `rmdir` all confirmed working at the NFS root from ZimaOS.
- Knowledge writeback was good: `upsert_knowledge` recorded an
`investigation` linked to `vm:zimaos`, `host:strong`, `pool:ludo-lvm`,
with the fix. `complete_task` was called with a clear summary.
- Plan lifecycle was followed: `set_goal` → `propose_plan` →
`update_plan_step` (running/done) → `complete_task`.
### What didn't
- **20+ blind retries before investigating why.** The agent retried the
same one-line `chown`/`chmod` roughly 20 times across direct runs,
SSH-hop-via-hubris, wrapping in a shell script, splitting into smaller
commands, and bare `echo test` sanity checks — all hung. Each retry
piled up another zombie process on `strong`. The agent only
investigated *why* the command hung after the user explicitly asked
"the command just keeps running?"
- **Misdiagnosed the timeout as a gateway/network issue.** The agent's
own narrative said "API seems to be struggling with timeouts," "API
keeps timing out on strong," "Strong mutations are consistently timing
out — read-only works." This framed the problem as the control plane,
when in fact the commands were genuinely hanging at the kernel level
on the target host. A `strace` on the first failure would have
revealed this immediately.
- **Approval window kept expiring between retries.** User had to say "go
ahead" twice and "proceed" + "status" once each because the assent
window closed while the agent was looping on the hung commands.
- **No back-off / cap on retries.** 58 `run` calls in 7 turns, of which
~20 are essentially the same `chown :10000 /mnt/media_local && chmod
2775 …`. Once a command has timed out 3× in a row, the agent should
stop retrying and investigate *or* surface the blocker to the operator.
### Fixes needed
- (friction) **Retry cap + "investigate before retry" rule.** In
`cmd/nomos/agent.go`, hash each outgoing `run` command; if the same
hash has failed 3× in the session, refuse to issue it again. Force the
agent to either change approach (e.g. `strace`, `ps`, `lsof` to see
*why*) or surface the blocker to the operator. This single change
would have turned session 1 from 58 `run` calls into ~8 and produced
the knfsd finding on the first failure instead of the 20th.
- (friction) **SOUL.md guidance: a hung command is not a failed command.**
When a `run` times out, the agent's first instinct should be to
inspect the target (`ps aux | grep <cmd>`, `strace -f -p <pid>`,
`lsof <path>`) — not to retry the same command. The current default
(retry with different routing/wrapping) wasted 20 calls.
- (friction) **Capture the unexport → mutate → re-export pattern as a
runbook.** "Mutating an actively-exported NFS directory hangs at
`fchownat()`" is a reusable finding. It belongs as a `runbook` entity
linked to `host:strong` / `lxc:nfs-export` so the next time someone
needs to chown/chmod an exported path, the agent finds it via
`get_entity_knowledge` and unexports first.
- (friction) Approval window robustness: when an execution times out,
extend the assent window for the same plan step automatically — the
operator already approved it; we shouldn't make them re-approve
because *our* command hung.
---
## Session 2 — `55927f0a` (2026-07-18T09:45)
**"Add NFS export of ludo-lvm (/mnt/library) from strong to ZimaOS, so ZimaOS
can see downloads/usenet/movies/ alongside the existing old-library NFS mount"**
| Metric | Value |
|---|---|
| Messages | 25 (12 user / 13 assistant) |
| Tool calls | 108 across 13 turns |
| Top tools | `run` ×49, `update_plan_step` ×13, `get_entity` ×10, `list_entities` ×6, `update_entity_attributes` ×3, `search_knowledge` ×3, `propose_plan` ×3, `get_relations` ×3, `get_knowledge_content` ×3, `get_execution_status` ×3, `upsert_knowledge` ×2, `set_goal` ×2, `create_relationship` ×2 |
| Objective | Originally: fix sabnzbd download folder to use ludo-lvm. Pivoted to: add NFS export of ludo-lvm to ZimaOS. Final outcome: just keep ludo-library (drop redundant /media/media mount) |
| Outcome | ✅ success — ZimaOS reduced to two clean tiles, fstab cleaned, knowledge + entity attrs written back |
| Severity | friction |
### What worked
- Writeback was thorough: `upsert_knowledge` ×2, `update_entity_attributes`
on `vm:zimaos` and `lxc:nfs-export`, `create_relationship` ×2. The
knowledge graph is current.
- The final cleanup was small and safe: unmount `/media/media` on ZimaOS,
remove the fstab entry, `rmdir` the empty directory, clear CasaOS caching
artifacts. Each step got its own `run` with a clear result.
- Agent correctly noticed the pivot: "wait — `/media/media` IS ludo-lvm
too, just via a double NFS hop through nfs-export. Redundant." That
insight is what turned a complex migration into a one-step cleanup.
### What didn't
- **Goal pivots were not closed cleanly.** `set_goal` was called twice —
once for the sabnzbd fix, once for the NFS export. The first goal was
implicitly abandoned when the user said "lets just keep ludo-library
then"; there's no `complete_task` for it. If the session state is keyed
on the latest `set_goal`, the first goal is orphaned in the UI.
- **Excessive fan-out on `run`.** 49 `run` calls, many of which repeat the
same diagnostic (`mount | grep`, `cat /etc/exports`, `exportfs -v`,
`ls -la /mnt/...`) across `lxc:arriman`, `lxc:jellyfin`, `lxc:nfs-export`,
`host:hubris`, `host:strong`. A single bulk "inventory this path across
these targets" tool would have collapsed 15+ runs into 1.
- **`vm:` targets aren't directly runnable.** Every ZimaOS command had to
be `ssh -o StrictHostKeyChecking=no root@192.168.8.195 '…'` from
`host:hubris`. Nested shell quoting broke once and the agent had to
re-escape. This was called out in the 2026-07-14 review and is still
open.
- **Agent over-scoped before checking with the user.** After "explain how
the migration would work", the agent produced a full multi-LXC migration
plan (move `/dev/mapper/library-library` consumers off the old volume,
migrate ZimaOS NFS export, etc.). The user replied "lets just keep
ludo-library then." A clarifying question — "do you want to migrate, or
just clean up the redundant mount?" — would have saved 4 turns.
- **Approval friction.** One execution came back with "status=cancelled,
but the assent window for this session is not active. The agent will not
auto-continue. Reply 'continue' or re-approve the plan to resume." User
had to type "proceed" to resume. This is the same assent-window-expiry
pattern from session 1.
### Fixes needed
- (friction) Track `set_goal` history per session. When a new goal is set,
the previous one should be auto-marked `complete` (or `superseded`) so
the UI doesn't show an orphaned active goal.
- (friction) Add a bulk inspection tool — e.g. `inspect_path(path, targets)`
that returns `mount`, `df`, `ls -la`, and ownership for the same path
across multiple entities in one call. Sessions like this routinely spend
15+ `run` calls gathering the same facts across hosts.
- (friction) `vm:` target support in `run`. Either expose a `qm guest exec`
wrapper or accept `vm:<slug>` as a target and route through the host. The
manual SSH-hop pattern is error-prone (nested quoting) and slow.
- (friction) SOUL.md guidance: before proposing a multi-step migration
plan, ask the user "migrate or clean up?" when both are plausible from
the prompt. This was a single extra clarification question away from a
4-turn savings.
---
## Session 3 — `2926de4e` (2026-07-15T22:22)
**"Deploy apt updates to host:netbird-vps — 92 pending packages including
Docker CE, netbird, kernel, ZFS, and security patches."**
| Metric | Value |
|---|---|
| Messages | 9 (4 user / 5 assistant) |
| Tool calls | 27 across 5 turns |
| Top tools | `update_plan_step` ×7, `run` ×6, `set_goal` ×2, `search_knowledge` ×2, `complete_task` ×2, `upsert_knowledge` ×1, `update_entity_attributes` ×1, `propose_plan` ×1, `list_lxcs` ×1, `get_relations` ×1, `get_knowledge_content` ×1, `get_entity` ×1, `get_execution_status` ×1 |
| Objective | Two-phase: (a) fleet-wide update audit by criticality, (b) deploy the 92-package upgrade to host:netbird-vps |
| Outcome | ✅ success — 92→0 packages pending; netbird-mgmt OIDC race caught and fixed; knowledge + entity attrs written back |
| Severity | cosmetic |
### What worked
- **Two goals, two clean lifecycles.** `set_goal` → `propose_plan` →
`update_plan_step` (running/done) → `complete_task` ran twice, once for
the audit and once for the upgrade. The session is the model for how
multi-goal sessions should look.
- **Pre-existing knowledge reuse.** First `search_knowledge` found a
today-dated audit; agent used `get_knowledge_content` and presented it
without needing any `run` for the audit half. Zero wasted tool calls.
- **Long-running upgrade handled correctly.** The 92-package `apt upgrade`
hit the HTTP gateway timeout mid-run. Agent didn't retry it — it called
`get_execution_status` and then ran a verification `run`
(`apt list --upgradable | wc -l`, `uname -r`, `docker ps`) to confirm
completion server-side despite the timeout. This is the right pattern;
session 1 should have done the same.
- **Gotcha caught.** After the upgrade, `docker logs netbird-mgmt`
revealed the management container was crash-looping because it tried to
fetch OIDC config from `auth.hubris.network` before traefik/authentik
were ready. Fix: `docker restart netbird-mgmt` after ~30s. Captured in
`upsert_knowledge` as an `investigation` tagged `apt, upgrade, netbird,
docker, gotcha` linked to `host:netbird-vps`.
- `update_entity_attributes` was called on `host:netbird-vps` to record the
new kernel version. Good writeback hygiene.
### What didn't
- (cosmetic) The HTTP timeout on long-running upgrades surfaced as a
transient error to the operator. The agent handled it correctly but the
UX would be cleaner if `run` returned `PENDING` immediately for known
long-running command patterns (`apt upgrade`, `pct migrate`, `rclone
sync`, etc.) instead of timing out at the gateway.
- (cosmetic) Two `complete_task` calls in one session produced two "task
complete" bubbles. Fine, but the second one could have noted the
first-task outcome as well in its summary so the chat reads as one
coherent arc.
### Fixes needed
- (cosmetic) Long-running command detection in `run`: if the command
matches a known-long pattern, return a `PENDING` execution id with a
hint to poll `get_execution_status`, rather than blocking at the HTTP
layer for 30s and timing out. Session 3 already proved the
poll-after-timeout pattern works — make it the default for these
commands.
- (cosmetic) Encourage the agent to fold the prior task's outcome into
the next `complete_task` summary when a session has multiple goals.
---
## Cross-session patterns
| # | Pattern | Sessions | Severity |
|---|---|---|---|
| 1 | Agent retries hung commands 20× before investigating *why* | 1 | friction |
| 2 | Approval window expires between turns forcing re-approval | 1, 2 | friction |
| 3 | `vm:` targets not directly runnable — must SSH-hop via `host:hubris` | 1, 2 | friction |
| 4 | N+1 fan-out on `run` for cross-entity fact-gathering | 1, 2 | friction |
| 5 | No retry cap — agent retries identical failing `run` 1020× | 1 | friction |
| 6 | Goal pivots not closed (`set_goal` called twice without closing prior) | 2 | friction |
| 7 | Long-running commands hit HTTP timeout instead of returning PENDING | 3 | cosmetic |
| 8 | Agent over-scopes migration plans before checking intent | 2 | friction |
| 9 | Reusable operational gotchas (knfsd lock, OIDC race) captured as investigations, not runbooks | 1, 3 | friction |
**What consistently works well**
- Plan lifecycle: `set_goal` → `propose_plan` → `update_plan_step` →
`complete_task` is now followed in all three sessions.
- Knowledge writeback: `upsert_knowledge`, `update_entity_attributes`,
`create_relationship` are used in every session. The graph is kept
current.
- Root-cause analysis quality is high once the agent digs in (NFS
all_squash + root dir perms → knfsd fchownat hang; double NFS hop;
OIDC race condition). The problem is getting the agent to dig in
*before* the 20th retry.
**What consistently breaks**
- **Hung commands get retried instead of investigated.** Session 1's
`chown` was blocked by knfsd for 30+ minutes while the agent retried
with different routing/wrapping. Session 3's `apt upgrade` timed out
and the agent correctly polled — but that's the exception, not the
rule. The default behavior is "retry the same thing differently."
- Approval window lifetime vs. agent retry loops — when execution times
out, the assent window lapses and the operator has to re-approve even
though the *intent* was never withdrawn.
- Reusable operational fixes (unexport → mutate → re-export for NFS
dirs; `docker restart netbird-mgmt` after stack upgrade) get recorded
as `investigation` entities. They should be `runbook` entities so the
agent finds them via `get_entity_knowledge` next time and applies the
procedure instead of rediscovering it.
---
## Improvement plan
### P0 — Friction (was blocker; downgraded after session 1 resolved)
1. **Retry cap + "investigate before retry" rule.** In
`cmd/nomos/agent.go`, hash each outgoing `run` command; if the same
hash has failed 3× in the session, refuse to issue it again. Force
the agent to either change approach (e.g. `strace`, `ps aux | grep`,
`lsof` to see *why*) or surface the blocker to the operator. This
single change would have turned session 1 from 58 `run` calls into
~8 and produced the knfsd finding on the first failure instead of
the 20th.
2. **SOUL.md guidance: a hung command is not a failed command.** When a
`run` times out, the agent's first instinct should be to inspect the
target (`ps aux | grep <cmd>`, `strace -f -p <pid>`, `lsof <path>`)
— not to retry the same command with different routing/wrapping. The
current default wasted 20 calls in session 1.
### P1 — Friction
3. **Capture operational gotchas as `runbook` entities, not just
`investigation`.** Two candidates from these sessions:
- **"Mutating an actively-exported NFS directory hangs at
`fchownat()`"** — procedure: `killall -9 chgrp chown` →
`exportfs -u <client>:<path>` → `chown`/`chmod` → `exportfs -a`.
Linked to `host:strong`, `lxc:nfs-export`.
- **"netbird-mgmt crash-loops after stack upgrade"** — procedure:
wait ~30s for traefik/authentik to come up, then
`docker restart netbird-mgmt`. Linked to `host:netbird-vps`.
Today both are `investigation` entries; the agent records them but
won't proactively apply them next time.
4. **Auto-close prior `set_goal` when a new one is set.** Mark the
previous goal `superseded` and emit a synthetic `complete_task`
summary so the UI doesn't show an orphaned active goal. (Session 2
had this.)
5. **Bulk inspection tool.** Add an MCP tool like
`inspect_path(path, targets[])` that runs `mount | grep`, `df`,
`ls -la`, and `stat` against a list of entity slugs in one call.
Sessions 1 and 2 each spent ~15 `run` calls gathering identical
facts across hosts/LXCs.
6. **`vm:` target support in `run`.** Accept `vm:<slug>` as a target
and route via `qm guest exec` on the host that owns the VM.
Eliminates the nested-quoting SSH-hop pattern that broke once in
session 2 and required manual SSH-hop workarounds in session 1.
7. **Approval window robustness.** When an execution times out, extend
the assent window for the same plan step automatically — the
operator already approved it; we shouldn't make them re-approve
because *our* command hung. Affects sessions 1 and 2.
8. **SOUL.md guidance: ask-before-migrating.** When a user request is
ambiguous between "fix in place" and "migrate," the agent should
ask one clarifying question before producing a multi-step migration
plan. Session 2 would have saved ~4 turns.
### P2 — Cosmetic
9. **Long-running command detection.** Maintain a small regex list
(`apt (upgrade|install)`, `pct migrate`, `rclone (sync|copy)`,
`dd if=`, `docker compose pull`) for commands that are known to
exceed 30s. Return `PENDING` immediately with an `execution_id`
instead of blocking at the gateway. Session 3 already uses the
poll pattern — make it the default.
10. **Multi-goal `complete_task` summaries.** When a session has more
than one `set_goal`, the final `complete_task` summary should
reference the arc of the whole session, not just the last goal.
---
## Revised note on the original P0
The original P0 ("Diagnose `host:strong` config_mutation timeouts —
suspect SSH latency / mesh routing, raise timeout") was **wrong**. The
timeouts were not a gateway or network issue — the commands were
genuinely hanging at the kernel level because `knfsd` holds a lock on
actively-exported directories. Raising the HTTP timeout would not have
helped; the `chown` would simply hang longer. The real fix is (a) the
retry-cap/investigate-before-retry rule (P0.1 above) and (b) the
unexport → mutate → re-export runbook (P1.3).
---
## Deferred
**P1.7 — Approval window auto-extends on execution timeout.** The assent
window lives in `autonomy_settings` and is read by `classifyAndGate`
(`internal/mcp/server.go:607`); timeout detection lives in `sshExec`
(`internal/mcp/server.go:332`). Wiring them requires the SSH-execution
path to signal back into the approval-state machine across the nomos ↔
api process boundary, and a future implementation needs to distinguish
"command genuinely hung" (knfsd case — don't extend, the command is
stuck) from "command is long-running" (apt upgrade — extend). Without
that distinction, auto-extending on every timeout would mask real hang
symptoms — exactly the misdiagnosis session 1 made. **The retry cap
(P0.1) addresses the same symptom at lower cost**: after 3 failures
the agent is forced to investigate or surface, which removes the
cascading retry storm that made the assent expiry visible in the first
place. Revisit if future sessions show the operator re-approving a
plan they never withdrew in intent (not just retrying a hung command).
**P2.9 — Long-running command PENDING detection.** A regex list of
known-long commands (`apt (upgrade|install)`, `pct migrate`, `rclone
(sync|copy)`, `dd if=`, `docker compose pull`) so `run` returns
`PENDING` immediately with an `execution_id` instead of blocking at
the HTTP gateway for 30s and timing out. **Session 3 already proved
the current poll pattern works:** the `apt upgrade` timed out at the
gateway, the agent called `get_execution_status`, then ran a
verification `run` (`apt list --upgradable | wc -l`, `uname -r`,
`docker ps`) — clean 92→0 packages result. The agent did the right
thing without any new machinery, and the retry cap (P0.1) protects
against the failure mode of this path (blind retry on timeout).
Implementing PENDING detection well requires a classifier extension
(`internal/policy`) plus a new return shape from `classifyAndGate`
that the agent loop has to learn to handle (poll instead of retry) —
a real protocol change, not a small fix. Worth doing if the
poll-after-timeout pattern proves fragile over the next few sessions;
not worth doing speculatively right now.
---
## Verification commands
```bash
# Re-pull any session for follow-up
curl -s http://localhost:8092/sessions/1e9c7691-5815-48d1-acb4-91a6a39691c9 | jq .
curl -s http://localhost:8092/sessions/55927f0a-597e-4561-aaef-077623051432 | jq .
curl -s http://localhost:8092/sessions/2926de4e-0b73-4c3d-a2cd-ee9a42089b46 | jq .
# Confirm host:strong mutation timeout reproduces
curl -s http://localhost:8092/sessions | jq -r '.sessions[].id' | head -1 # latest session id
```
## Related files
- `cmd/nomos/agent.go` — agent loop, retry behavior, goal state
- `cmd/nomos/store.go` — `set_goal` / `complete_task` persistence
- `internal/mcp/server.go` — `run` tool, timeout handling, `get_execution_status`
- `internal/httpapi/server.go` — HTTP gateway timeout for mutations
- `nomos/SOUL.md` — agent persona, ask-before-migrate guidance candidate
- `plans/2026-07-14-activity-gaps.md` — prior session review (same patterns recurring)

View File

@@ -17,6 +17,7 @@ went sideways, open an investigation.
| 2026-07-14 | [Activity gaps](2026-07-14-activity-gaps.md) | In Progress |
| 2026-07-14 | [Activity timeline](2026-07-14-activity-timeline.md) | In Progress |
| 2026-07-17 | [Codebase review, lint audit, and documentation maintenance](2026-07-17-codebase-review-and-cleanup.md) | Report delivered — doc/tooling fixes applied; code refactors pending |
| 2026-07-18 | [Session review: three recent sessions](2026-07-18-session-review-three-sessions.md) | Implemented in v0.7.12 — P0.1/P0.2/P1.3/P1.4/P1.5/P1.6/P1.8/P2.10; P1.7 and P2.9 deferred (retry cap covers) |
## Done

View File

@@ -6713,3 +6713,143 @@ runbooks:
tags:
- skill
- runbook
- slug: nfs-exported-dir-mutation-hang
name: Mutating an actively-exported NFS directory hangs at fchownat
risk_class: config_mutation
entity_type: host
procedure: {}
content: |
---
name: nfs-exported-dir-mutation-hang
risk_class: config_mutation
inputs: [target_host, exported_path, mutation_command]
verification: "stat -c '%a %U:%G' <exported_path> on the target host"
docs_update_checklist: [investigation_entry]
---
# Mutating an actively-exported NFS directory hangs at fchownat
Symptom: a `chown` / `chgrp` / `chmod` against a directory that is
actively exported via `nfs-kernel-server` (knfsd) hangs indefinitely — the
command appears to run but never returns. SSH/gateway time out waiting for it.
`ps aux | grep chown` shows the process in interruptible sleep (D state);
repeated retries pile up zombies (25+ observed in one session).
Root cause: knfsd holds a kernel lock on the directory while it's exported.
`fchownat()` blocks waiting for the lock. This is NOT a gateway or SSH issue —
raising the timeout just makes the hang longer.
## Procedure
1. **Clear the zombies** from prior failed attempts:
```
killall -9 chgrp chown chmod 2>/dev/null
```
2. **Temporarily unexport** the path for each client/network that has it exported:
```
exportfs -u <client>:<path> # e.g. exportfs -u 192.168.8.0/24:/mnt/media_local
```
3. **Apply the mutation** (now that knfsd has released the lock):
```
chown :<gid> <path> && chmod <mode> <path>
```
4. **Re-export** (restore the exports):
```
exportfs -a
```
5. **Verify** from an NFS client that the new permissions are visible and operations work end-to-end:
```
stat -c '%a %U:%G' /media/<mountpoint> # on a client
touch /media/<mountpoint>/.test && mv /media/<mountpoint>/.test /media/<mountpoint>/.moved && rm /media/<mountpoint>/.moved
```
## Detection signature (for agents)
A `run` call against a host that contains `chown|chgrp|chmod` of a path
exported by `nfs-kernel-server` AND the call times out → assume this runbook.
Don't retry the same command; run `strace -f <cmd>` (it will block at
`fchownat`) to confirm, then apply the procedure above.
## Caveats
- `exportfs -u` may emit a format-mismatch warning if the export was defined
via `/etc/exports` with a different option string than `exportfs -v`
reports. The unexport still succeeds; verify with `exportfs -v` afterward
that the path is gone, then re-add with `exportfs -a`.
- This applies to ANY mutating op on the exported dir (chown, chmod, rename,
rmdir of the root). Subdirectory mutations are fine as long as they don't
touch the exported root itself.
Recorded after session 1e9c7691 (2026-07-18) — 20+ retries of a
`chown :10000 /mnt/media_local` that hung for 30+ minutes before this
procedure was identified.
tags:
- runbook
- nfs
- knfsd
- gotcha
- slug: netbird-mgmt-oidc-race-after-upgrade
name: netbird-mgmt crash-loops after stack upgrade (OIDC race)
risk_class: reversible_low
entity_type: host
procedure: {}
content: |
---
name: netbird-mgmt-oidc-race-after-upgrade
risk_class: reversible_low
inputs: []
verification: "docker ps --filter name=netbird-mgmt --format '{{.Status}}' shows Up"
docs_update_checklist: [investigation_entry]
---
# netbird-mgmt crash-loops after stack upgrade (OIDC race)
Symptom: after a full-stack restart on `host:netbird-vps` (e.g. following an
apt upgrade that touched Docker, traefik, authentik, or the netbird
packages), `netbird-mgmt` enters a crash loop. `docker logs netbird-mgmt
--tail 30` shows repeated failed attempts to fetch OIDC config from
`auth.hubris.network` (connection refused / i/o timeout).
Root cause: startup ordering race. `netbird-mgmt` tries to fetch its OIDC
configuration from `auth.hubris.network` before traefik and authentik are
ready to serve. Connection refused → mgmt exits → docker restarts it →
same failure.
## Procedure
1. Confirm the race (not a real config breakage):
```
docker logs netbird-mgmt --tail 30 2>&1 | grep -E 'auth.hubris.network|OIDC|connection refused'
curl -fsS -o /dev/null -w '%{http_code}' https://auth.hubris.network/application/o/netbird/.well-known/openid-configuration
```
If the curl now returns 200, the race has already self-resolved — just restart mgmt.
2. Wait ~30s for traefik + authentik to finish coming up.
3. Restart just the management container:
```
docker restart netbird-mgmt
```
4. Verify:
```
docker ps --filter name=netbird-mgmt --format '{{.Names}} {{.Status}}'
docker logs netbird-mgmt --tail 10 2>&1 # should show clean startup, no OIDC errors
```
5. Check the rest of the stack is healthy too:
```
docker ps --format 'table {{.Names}}\t{{.Status}}'
```
## Detection signature (for agents)
After a `run` that upgraded anything Docker/traefik/authentik/netbird on
`host:netbird-vps`, run `docker ps` and `docker logs netbird-mgmt --tail 30`.
If mgmt is Restarting + logs mention auth.hubris.network OIDC fetch failure,
apply this procedure before declaring the upgrade complete.
Recorded after session 2926de4e (2026-07-15) — 92-package apt upgrade on
netbird-vps; mgmt crash-loop caught and fixed with `docker restart
netbird-mgmt` after ~30s.
tags:
- runbook
- netbird
- docker
- gotcha

70
web/package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "oikos-web",
"version": "0.1.0",
"dependencies": {
"@surdeddd/wmkit": "^0.3.0",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
"dompurify": "^3.4.11",
@@ -876,7 +877,7 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -887,7 +888,7 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -898,7 +899,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -908,14 +909,14 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -1282,11 +1283,44 @@
"win32"
]
},
"node_modules/@surdeddd/wmkit": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@surdeddd/wmkit/-/wmkit-0.3.0.tgz",
"integrity": "sha512-r5reUXN0Mcnx1nFjfB2grPCqpkH67S7UdaIY1bhyls5Tim4RyAAVscQSsimo1fOSqndZs9venjh3bueLJQVEMA==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@angular/core": ">=16",
"react": ">=18",
"solid-js": ">=1.8",
"svelte": ">=4",
"vue": ">=3.3"
},
"peerDependenciesMeta": {
"@angular/core": {
"optional": true
},
"react": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz",
"integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@@ -1643,7 +1677,7 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
@@ -2032,7 +2066,7 @@
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -2105,7 +2139,7 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -2132,7 +2166,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -2514,7 +2548,7 @@
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/dompurify": {
@@ -2824,7 +2858,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/espree": {
@@ -2862,7 +2896,7 @@
"version": "2.2.13",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz",
"integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@@ -3330,7 +3364,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@@ -3754,7 +3788,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/locate-path": {
@@ -3808,7 +3842,7 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -4470,7 +4504,7 @@
"version": "5.56.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@@ -6281,7 +6315,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
}
}

View File

@@ -40,6 +40,7 @@
"vitest": "^2.0.0"
},
"dependencies": {
"@surdeddd/wmkit": "^0.3.0",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
"dompurify": "^3.4.11",

View File

@@ -1,45 +1,42 @@
<script lang="ts">
import Chat from './pages/Chat.svelte'
import Overview from './pages/Overview.svelte'
import KnowledgeBase from './pages/KnowledgeBase.svelte'
import Ops from './pages/Ops.svelte'
import Signals from './pages/Signals.svelte'
import EntityDetail from './pages/EntityDetail.svelte'
import Knowledge from './pages/Knowledge.svelte'
import Learning from './pages/Learning.svelte'
import Config from './pages/Config.svelte'
import { newChat } from '$lib/stores/chat'
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
import { currentTask } from '$lib/stores/workspace'
import Desktop from '$lib/components/desktop-shell/Desktop.svelte'
import { subscribeContext } from '$lib/stores/context'
import { openAppWindow, openEntityWindow } from '$lib/stores/windows'
import { isConfigured } from '$lib/config'
import { truncateMiddle } from '$lib/utils'
import { onMount } from 'svelte'
import { processPendingCallback, initOIDC } from '$lib/oidc'
import * as Sidebar from '$lib/components/ui/sidebar'
import * as Sheet from '$lib/components/ui/sheet'
import { Button } from '$lib/components/ui/button'
import { Separator } from '$lib/components/ui/separator'
import { VERSION } from '$lib/version'
import { Toaster } from '$lib/components/ui/sonner'
import PlusIcon from '@lucide/svelte/icons/plus'
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
import DatabaseIcon from '@lucide/svelte/icons/database'
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
import SirenIcon from '@lucide/svelte/icons/siren'
import SearchIcon from '@lucide/svelte/icons/search'
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
import SettingsIcon from '@lucide/svelte/icons/settings'
import PaletteIcon from '@lucide/svelte/icons/palette'
import { getTheme, toggleTheme, THEME_LABELS } from '$lib/stores/theme.svelte'
let page = $state('overview')
let routeParam = $state('')
let drawerOpen = $state(false)
let configured = $state(isConfigured())
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
const openSignals = $derived(openSignalCount($summary))
// Old hash routes (#/kb, #/entity/<slug>, ...) from the sidebar-shell era —
// translated into opening the equivalent window once, then cleared, so
// links/bookmarks from before the desktop redesign keep working without
// reintroducing a router.
const LEGACY_APP_ROUTES: Record<string, string> = {
overview: 'tasks',
chat: 'tasks',
kb: 'kb',
entities: 'kb',
graph: 'kb',
ops: 'ops',
signals: 'signals',
knowledge: 'knowledge',
learning: 'learning'
}
function resolveLegacyHash() {
const path = location.hash.slice(2)
if (!path) return
const [head, ...rest] = path.split('/')
if (head === 'entity' && rest.length) {
openEntityWindow(rest.join('/'))
} else if (LEGACY_APP_ROUTES[head]) {
openAppWindow(LEGACY_APP_ROUTES[head])
}
history.replaceState(null, '', location.pathname + location.search)
}
onMount(async () => {
if (await processPendingCallback()) {
@@ -47,21 +44,7 @@
} else if (!configured) {
if (await initOIDC()) configured = true
}
function sync() {
const path = location.hash.slice(2) || 'overview'
const [head, ...rest] = path.split('/')
// Entities + Graph were merged into Knowledge Base — keep old links working.
if (head === 'entities' || head === 'graph') {
location.hash = '#/kb'
return
}
page = head || 'overview'
routeParam = rest.join('/')
}
sync()
window.addEventListener('hashchange', sync)
return () => window.removeEventListener('hashchange', sync)
resolveLegacyHash()
})
// Context (dashboard summary + approvals poll) and the SSE stream both
@@ -70,23 +53,6 @@
if (!configured) return
return subscribeContext()
})
function navigate(p: string) {
location.hash = '#/' + p
}
function cycleTheme() {
toggleTheme()
}
const navItems = [
{ id: 'overview', label: 'Tasks', icon: ListTodoIcon },
{ id: 'kb', label: 'Knowledge Base', icon: DatabaseIcon },
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
{ id: 'learning', label: 'Learning', icon: TrendingUpIcon }
]
</script>
{#if !configured}
@@ -95,158 +61,6 @@
onCancel={isConfigured() ? () => (configured = true) : undefined}
/>
{:else}
<Toaster />
<Sidebar.Provider class="h-svh" style="--header-height: calc(var(--spacing) * 12);">
<Sidebar.Root collapsible="icon" variant="inset">
<Sidebar.Header>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton
class="data-[slot=sidebar-menu-button]:!p-1.5"
onclick={() => navigate('overview')}
tooltipContent={`Oikos ${VERSION}`}
>
{#snippet child({ props })}
<button {...props}>
<svg viewBox="0 0 91 100" class="!size-5 shrink-0" fill="var(--primary)" aria-hidden="true" role="img">
<title>Oikos</title>
<path d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z" />
</svg>
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
<div class="group-data-[collapsible=icon]:hidden px-2.5 pb-1">
<span class="text-[11px] text-muted-foreground select-none">{VERSION}</span>
</div>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton
class="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
onclick={() => { newChat(); navigate('chat') }}
tooltipContent="New task"
>
{#snippet child({ props })}
<button {...props}>
<PlusIcon />
<span>New task</span>
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.Menu>
{#each navItems as item}
<Sidebar.MenuItem>
<Sidebar.MenuButton
isActive={page === item.id || (item.id === 'overview' && page === 'chat')}
onclick={() => navigate(item.id)}
tooltipContent={item.label}
>
{#snippet child({ props })}
<button {...props}>
<item.icon />
<span>{item.label}</span>
</button>
{/snippet}
</Sidebar.MenuButton>
{#if item.badge?.()}
<Sidebar.MenuBadge>{item.badge()}</Sidebar.MenuBadge>
{/if}
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer>
<Button
variant="ghost"
size="sm"
class="justify-start gap-2"
onclick={() => (drawerOpen = true)}
title="Chat over the current page without navigating away"
>
<PanelRightIcon />
<span>Chat drawer</span>
</Button>
<Button
variant="ghost"
size="sm"
class="justify-start gap-2"
onclick={cycleTheme}
title="Cycle theme"
>
<PaletteIcon />
<span>{THEME_LABELS[getTheme()]}</span>
</Button>
<Button
variant="ghost"
size="sm"
class="justify-start gap-2"
onclick={() => (configured = false)}
title="Server connection settings"
>
<SettingsIcon />
<span>Connection</span>
</Button>
</Sidebar.Footer>
</Sidebar.Root>
<Sidebar.Inset class="min-h-0 overflow-hidden">
<header class="flex h-(--header-height) shrink-0 items-center gap-1 border-b px-4 lg:gap-2 lg:px-6">
<Sidebar.Trigger class="-ms-1" />
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
{#if page === 'chat'}
{@const goalText = $currentTask?.goal ? $currentTask.goal.replace(/[*_`~#]|\[.*?\]\(.*?\)/g, '') : 'New Task'}
<button type="button" class="shrink-0 text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('overview')}>Tasks</button>
<span class="shrink-0 text-muted-foreground">/</span>
<span class="min-w-0 flex-1 truncate text-base font-medium" title={goalText}>
{truncateMiddle(goalText, 100)}
</span>
{:else}
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page === 'kb' ? 'Knowledge Base' : page === 'overview' ? 'Tasks' : page}</span>
{/if}
</header>
<main class="min-h-0 flex-1 overflow-hidden">
{#if page === 'overview'}
<Overview />
{:else if page === 'kb'}
<KnowledgeBase />
{:else if page === 'entity' && routeParam}
<EntityDetail slug={routeParam} />
{:else if page === 'ops'}
<Ops />
{:else if page === 'signals'}
<Signals />
{:else if page === 'knowledge'}
<Knowledge />
{:else if page === 'learning'}
<Learning />
{:else}
<Chat />
{/if}
</main>
</Sidebar.Inset>
</Sidebar.Provider>
<Sheet.Root bind:open={drawerOpen}>
<Sheet.Content side="right" class="w-[400px] p-0 sm:max-w-[400px]">
<Sheet.Header class="sr-only">
<Sheet.Title>Nomos chat</Sheet.Title>
<Sheet.Description>Persistent chat drawer</Sheet.Description>
</Sheet.Header>
<div class="flex h-full flex-col">
<Chat showRail={false} />
</div>
</Sheet.Content>
</Sheet.Root>
<Toaster />
<Desktop onOpenConnection={() => (configured = false)} />
{/if}

View File

@@ -242,6 +242,45 @@
height: 100%;
}
/* wmkit floating windows (EntityDesktop.svelte) — mapped onto the app's own
card/border/ring tokens instead of an imported wmkit theme, so windows
follow the terracotta/dark theme toggle for free. */
[data-wm-desktop] {
overflow: visible;
}
[data-wm-window] {
display: flex;
flex-direction: column;
box-sizing: border-box;
pointer-events: auto;
background: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 8px 24px oklch(0 0 0 / 0.18);
overflow: hidden;
outline: none;
}
[data-wm-window][data-wm-focused] {
border-color: var(--ring);
box-shadow: 0 12px 32px oklch(0 0 0 / 0.28);
}
[data-wm-window][data-wm-dragging],
[data-wm-window][data-wm-resizing] {
user-select: none;
}
[data-wm-window][data-wm-stage='minimized'] {
display: none;
}
[data-wm-resize] {
position: absolute;
}
a {
color: var(--accent-blue);
text-decoration: none;

View File

@@ -1,4 +1,7 @@
import { fetchWithAuth } from './config'
import type { ChatEvent, MessageContent } from './types'
export type { ChatEvent }
// Path prefixes only — NOT resolved URLs. fetchWithAuth resolves the actual
// origin (relative vs. configured apiUrl) fresh on every call via
@@ -47,6 +50,21 @@ export async function fetchMessages(sessionId: string): Promise<Message[]> {
return data.messages ?? []
}
// null distinguishes "session doesn't exist" (404 — the session was deleted,
// or a persisted/deep-linked window id was never valid) from a transient
// fetch failure, which should keep returning []/retrying rather than
// permanently flip a window into a "not found" state. Only the initial
// per-window load (chat.ts's loadSessionChat) needs this distinction — the
// polling loops keep using fetchMessages, where swallowing a blip into []
// and trying again next tick is the right behavior.
export async function fetchMessagesOrNotFound(sessionId: string): Promise<Message[] | null> {
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`)
if (res.status === 404) return null
if (!res.ok) return []
const data = await res.json()
return data.messages ?? []
}
export async function deleteSession(sessionId: string): Promise<boolean> {
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
return res.ok
@@ -210,6 +228,24 @@ export async function fetchEntities(filters: EntityFilters = {}): Promise<Entity
return data.items ?? []
}
// The entities endpoint caps at 200/page — the Knowledge Base wants the
// whole set (it filters by type/search client-side now instead of scoping
// the fetch server-side), so page through via cursor until exhausted.
export async function fetchAllEntities(): Promise<Entity[]> {
const all: Entity[] = []
let cursor: string | undefined
do {
const params = new URLSearchParams({ limit: '200' })
if (cursor) params.set('cursor', cursor)
const res = await fetchWithAuth(`${API}/entities?${params}`)
if (!res.ok) break
const data = await res.json()
all.push(...(data.items ?? []))
cursor = data.next_cursor ?? undefined
} while (cursor)
return all
}
export type OntologyLayer = 'meta' | 'infrastructure' | 'governance' | 'cognition'
export interface EntityType {
@@ -221,13 +257,33 @@ export interface EntityType {
description?: string | null
}
export type RelationshipCardinality = 'one-to-one' | 'one-to-many' | 'many-to-one' | 'many-to-many'
export interface RelationshipTypeDef {
name: string
inverse?: string | null
source_type: string
target_type: string
cardinality: RelationshipCardinality
description?: string | null
}
export interface Ontology {
entityTypes: EntityType[]
relationshipTypes: RelationshipTypeDef[]
}
export async function fetchOntology(): Promise<Ontology> {
const res = await fetchWithAuth(`${API}/ontology`)
if (!res.ok) return { entityTypes: [], relationshipTypes: [] }
const data = await res.json()
return { entityTypes: data.entity_types ?? [], relationshipTypes: data.relationship_types ?? [] }
}
// The graph endpoint has no layer param, so callers build a type→layer map from
// this to scope the graph client-side (the entities table filters server-side).
export async function fetchEntityTypes(): Promise<EntityType[]> {
const res = await fetchWithAuth(`${API}/ontology`)
if (!res.ok) return []
const data = await res.json()
return data.entity_types ?? []
return (await fetchOntology()).entityTypes
}
export interface EventFilters {
@@ -471,6 +527,18 @@ export interface Relationship {
valid_to?: string | null
}
// Direct relationships of an entity, both directions — unlike fetchGraph's
// blast_radius (which only walks outgoing edges, so it can never surface an
// edge some other entity points at this one unless that entity is also
// reachable going forward from here), this hits a dedicated endpoint that
// matches on source_id OR target_id directly.
export async function fetchEntityRelations(id: string): Promise<Relationship[]> {
const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}/relations?direction=both`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export type Health = 'healthy' | 'degraded' | 'down' | 'unknown'
export interface GraphView {

61
web/src/lib/apps.test.ts Normal file
View File

@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from 'vitest'
// apps.ts wires in every page component for real use, but that drags a
// heavy transitive graph into a unit test for no benefit here (and one of
// those pages imports svelte-sonner, which fails to resolve under vitest's
// bundled Vite — an unrelated, pre-existing package quirk). These tests only
// care about the registry's own shape (ids, sizes, window-id helpers), so
// stub the component imports out rather than pull all of that in.
vi.mock('../pages/Overview.svelte', () => ({ default: {} }))
vi.mock('../pages/KnowledgeBase.svelte', () => ({ default: {} }))
vi.mock('../pages/Ops.svelte', () => ({ default: {} }))
vi.mock('../pages/Signals.svelte', () => ({ default: {} }))
vi.mock('../pages/Knowledge.svelte', () => ({ default: {} }))
vi.mock('../pages/Learning.svelte', () => ({ default: {} }))
import { APPS, appById, appWindowId, appIdFromWindowId } from './apps'
describe('APPS registry', () => {
it('has unique, non-empty ids', () => {
const ids = APPS.map((a) => a.id)
expect(ids.length).toBeGreaterThan(0)
expect(new Set(ids).size).toBe(ids.length)
for (const id of ids) expect(id).not.toBe('')
})
it('gives every app a positive default size', () => {
for (const app of APPS) {
expect(app.width).toBeGreaterThan(0)
expect(app.height).toBeGreaterThan(0)
}
})
it('is indexed by id in appById', () => {
for (const app of APPS) {
expect(appById.get(app.id)).toBe(app)
}
expect(appById.size).toBe(APPS.length)
})
})
describe('appWindowId / appIdFromWindowId', () => {
it('round-trips an app id through its window id', () => {
for (const app of APPS) {
expect(appIdFromWindowId(appWindowId(app.id))).toBe(app.id)
}
})
it('returns null for ids that are not app windows', () => {
expect(appIdFromWindowId('session:abc-123')).toBeNull()
expect(appIdFromWindowId('host:strong')).toBeNull()
expect(appIdFromWindowId('new-task')).toBeNull()
})
it('namespaces window ids so they cannot collide with entity slugs', () => {
// Entity slugs are bare `type:identifier` strings (see windows.ts's
// openEntityWindow) — app window ids must never look like one.
for (const app of APPS) {
expect(appWindowId(app.id).startsWith('app:')).toBe(true)
}
})
})

118
web/src/lib/apps.ts Normal file
View File

@@ -0,0 +1,118 @@
// The desktop's app registry — single source of truth for what shows up as
// a desktop icon and what opens in its window. Adding a new app is one entry
// here; nothing else needs to change (Desktop.svelte renders icons from
// APPS, WindowLayer.svelte resolves `app:<id>` window ids back through
// appById, Taskbar.svelte reads title/icon the same way). Compare to the old
// App.svelte's hardcoded navItems array + if/else page branch, which required
// touching three places (nav list, header title, main content branch) to add
// one page.
import type { Component } from 'svelte'
import type { DashboardSummary } from '$lib/api'
import { openSignalCount } from '$lib/stores/context'
import Overview from '../pages/Overview.svelte'
import KnowledgeBase from '../pages/KnowledgeBase.svelte'
import Ops from '../pages/Ops.svelte'
import Signals from '../pages/Signals.svelte'
import Knowledge from '../pages/Knowledge.svelte'
import Learning from '../pages/Learning.svelte'
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
import DatabaseIcon from '@lucide/svelte/icons/database'
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
import SirenIcon from '@lucide/svelte/icons/siren'
import SearchIcon from '@lucide/svelte/icons/search'
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
export interface AppDef {
id: string
title: string
icon: Component
component: Component
width: number
height: number
minWidth?: number
minHeight?: number
// Pure function over the shared dashboard summary — used for both the
// desktop icon's badge and the taskbar button's badge, so a new app that
// wants one just supplies this instead of each surface reimplementing it.
badge?: (summary: DashboardSummary | null) => number
}
export const APPS: AppDef[] = [
{
id: 'tasks',
title: 'Tasks',
icon: ListTodoIcon,
component: Overview,
width: 960,
height: 680,
minWidth: 480,
minHeight: 420
},
{
id: 'kb',
title: 'Knowledge Base',
icon: DatabaseIcon,
component: KnowledgeBase,
width: 1000,
height: 700,
minWidth: 520,
minHeight: 420
},
{
id: 'ops',
title: 'Operations',
icon: ShieldCheckIcon,
component: Ops,
width: 860,
height: 620,
minWidth: 480,
minHeight: 360,
badge: (s) => s?.approvals_pending ?? 0
},
{
id: 'signals',
title: 'Signals',
icon: SirenIcon,
component: Signals,
width: 860,
height: 620,
minWidth: 480,
minHeight: 360,
badge: (s) => openSignalCount(s)
},
{
id: 'knowledge',
title: 'Knowledge',
icon: SearchIcon,
component: Knowledge,
width: 800,
height: 600,
minWidth: 440,
minHeight: 340
},
{
id: 'learning',
title: 'Learning',
icon: TrendingUpIcon,
component: Learning,
width: 800,
height: 600,
minWidth: 440,
minHeight: 340
}
]
export const appById = new Map(APPS.map((a) => [a.id, a]))
// Window ids are namespaced so WindowLayer.svelte can tell at a glance which
// content branch owns an id: `app:<id>` for registry apps, `session:<id>`
// for task chat windows (see windows.ts), anything else is an entity slug.
const APP_PREFIX = 'app:'
export function appWindowId(id: string): string {
return `${APP_PREFIX}${id}`
}
export function appIdFromWindowId(windowId: string): string | null {
return windowId.startsWith(APP_PREFIX) ? windowId.slice(APP_PREFIX.length) : null
}

View File

@@ -3,32 +3,26 @@
// which lumps very different things (an LXC and a DNS record and a storage
// volume) into one "infrastructure" bucket. Built from the ontology's
// `domain` field instead, which already draws these lines; this just
// groups the 9 domains into 6 browsing-sized buckets.
import type { EntityFilters } from './api'
export type Category = 'network' | 'fleet' | 'services' | 'storage' | 'identity' | 'knowledge'
export const categories: { id: Category; label: string }[] = [
{ id: 'fleet', label: 'Fleet' },
{ id: 'network', label: 'Network' },
{ id: 'services', label: 'Services' },
{ id: 'storage', label: 'Storage' },
{ id: 'identity', label: 'Identity' },
{ id: 'knowledge', label: 'Knowledge' }
]
// groups the domains into browsing-sized buckets. The Knowledge Base shows
// every entity at once now (filtered by the type multiselect, not by a
// fetch-time category), but "fleet" still names the default type selection.
export type Category = 'network' | 'fleet' | 'identity' | 'knowledge'
// entity_types.domain -> Category. `external` folds into Network (isp-link,
// domain-registration are network-adjacent); `physical` folds into Fleet
// (ups/sensor/site support compute, browsing them separately fragments
// "what's running where"). `meta` (the abstract root "entity" type) and
// `cognition` (see KNOWLEDGE_TYPES below) are handled outside this map.
// domain-registration are network-adjacent); `physical`, `software`, and
// `storage` fold into Fleet (ups/sensor/site support compute, services/apps
// nest under the compute entity that provides them, and pools/volumes/
// datasets nest under their compute entity or pool, all via EntityTable's
// treegrid) — browsing them separately fragments "what's running where".
// `meta` (the abstract root "entity" type) and `cognition` (see
// KNOWLEDGE_TYPES below) are handled outside this map.
const DOMAIN_TO_CATEGORY: Record<string, Category> = {
network: 'network',
external: 'network',
compute: 'fleet',
physical: 'fleet',
software: 'services',
storage: 'storage',
software: 'fleet',
storage: 'fleet',
identity: 'identity'
}
@@ -48,15 +42,3 @@ export function typeToCategory(type: string, domain: string): Category | undefin
if (domain === 'cognition') return undefined
return DOMAIN_TO_CATEGORY[domain]
}
// Filter sets to fetch and merge for a category's table view. Most
// categories are one or two `domain` values; Knowledge is a handful of
// specific `type`s carved out of the (otherwise excluded) cognition domain.
export function filtersForCategory(category: Category): EntityFilters[] {
if (category === 'knowledge') {
return Array.from(KNOWLEDGE_TYPES).map((type) => ({ type }))
}
return Object.entries(DOMAIN_TO_CATEGORY)
.filter(([, c]) => c === category)
.map(([domain]) => ({ domain }))
}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
import type { ActivityEntry } from '$lib/stores/activity'
import Spinner from './Spinner.svelte'
import CircleDotIcon from '@lucide/svelte/icons/circle-dot'
import CircleXIcon from '@lucide/svelte/icons/circle-x'
@@ -11,6 +11,9 @@
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
// Prop-driven (not store-imported) — see SessionGraph.svelte for why.
let { entries }: { entries: ActivityEntry[] } = $props()
let expanded = $state(new Set<string>())
function toggle(id: string) {
@@ -56,7 +59,7 @@
<div class="flex h-full flex-col">
<div class="flex-1 overflow-y-auto">
{#if $activityLog.length === 0}
{#if entries.length === 0}
<div class="flex flex-col items-center gap-3 px-3 py-8 text-center">
<svg viewBox="0 0 64 110" class="h-20 w-auto text-muted-foreground/40" fill="none">
<line x1="32" y1="8" x2="32" y2="102" stroke="currentColor" stroke-width="1" stroke-dasharray="2.5 4" opacity="0.35" />
@@ -78,8 +81,8 @@
</div>
{:else}
<div class="flex flex-col py-1">
{#each $activityLog as entry, i (entry.id)}
{@const isLast = i === $activityLog.length - 1}
{#each entries as entry, i (entry.id)}
{@const isLast = i === entries.length - 1}
{@const icon = typeIcon(entry.type)}
{@const isOpen = expanded.has(entry.id)}
{@const time = formatTime(entry.timestamp)}

View File

@@ -0,0 +1,377 @@
<script lang="ts">
// Pure prop-driven transcript + input — no store imports. Both the main
// Chat page (singleton "current session" stores) and a floating task
// window (its own per-session store bundle from chat.ts's chatFor) render
// through this, so the message-bubble/markdown styling lives in one place
// instead of being copy-pasted between the two.
import { activityLog } from '$lib/stores/activity'
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
import SquareIcon from '@lucide/svelte/icons/square'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import type { ChatMessage } from '$lib/stores/chat'
let {
messages,
streaming,
connectionState,
error = null,
chatErrors = [],
onSend,
onCancel,
onReconnect,
onDismissError,
suggestions = []
}: {
messages: ChatMessage[]
streaming: boolean
connectionState: 'connected' | 'disconnected' | 'reconnecting'
error?: string | null
chatErrors?: { id: string; message: string; action?: string }[]
onSend: (text: string) => void
onCancel: () => void
onReconnect: () => void
onDismissError: (id: string) => void
suggestions?: string[]
} = $props()
let input = $state('')
let messagesEnd = $state<HTMLDivElement | null>(null)
let scrolledUp = $state(false)
let container = $state<HTMLDivElement | null>(null)
function isNearBottom(): boolean {
if (!container) return true
const { scrollTop, scrollHeight, clientHeight } = container
return scrollHeight - scrollTop - clientHeight < 80
}
function onScroll() {
scrolledUp = !isNearBottom()
}
// Auto-scroll to bottom on new messages — unless user scrolled up to read.
$effect(() => {
void messages
if (streaming || !scrolledUp) {
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
}
})
function render(text: string): string {
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
}
function submit() {
const text = input.trim()
if (!text || streaming) return
input = ''
scrolledUp = false
onSend(text)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}
function ask(q: string) {
if (streaming) return
onSend(q)
}
</script>
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
{#if messages.length === 0}
<div class="flex flex-col items-center gap-6 pt-24 text-center">
<div>
<h2 class="text-xl font-semibold">Nomos</h2>
<p class="mt-1 text-sm text-muted-foreground">Your resident operator. Ask about the fleet, or tell it to act.</p>
</div>
{#if suggestions.length}
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
{#each suggestions as q}
<Button variant="outline" size="sm" class="h-auto justify-start whitespace-normal py-2 text-left text-xs" onclick={() => ask(q)}>
{q}
</Button>
{/each}
</div>
{/if}
</div>
{/if}
{#each messages as msg (msg.id)}
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
{#if msg.role === 'user'}
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap user-msg">{msg.text}</div>
{:else}
<div class="flex w-full flex-col gap-2">
{#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
{@html render(msg.text)}
</div>
{/if}
</div>
{/if}
</div>
{/each}
<AgentIndicator
active={streaming || $activityLog.some((e) => e.status === 'running')}
lastActivity={$activityLog.find((e) => e.status === 'running') ?? null}
{error}
/>
<div bind:this={messagesEnd}></div>
</div>
</div>
{#if connectionState === 'disconnected'}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 flex items-center gap-2 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs">
<RefreshCwIcon class="size-3 shrink-0" aria-hidden="true" />
<span class="text-warning-foreground flex-1">Agent connection lost. The task may still be running.</span>
<Button size="xs" variant="outline" class="h-6 text-[11px]" onclick={onReconnect}>Reconnect</Button>
</div>
</div>
{:else if connectionState === 'reconnecting'}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-xs">
<RefreshCwIcon class="size-3 shrink-0 animate-spin text-muted-foreground" aria-hidden="true" />
<span class="text-muted-foreground flex-1">Reconnecting to agent…</span>
</div>
</div>
{/if}
{#if error}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
</div>
{/if}
{#each chatErrors as err (err.id)}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<span class="flex-1">{err.message}</span>
{#if err.action}
<Button size="xs" variant="ghost" class="h-6 text-[11px]" onclick={() => onDismissError(err.id)}>{err.action}</Button>
{/if}
<button class="ml-1 text-muted-foreground hover:text-foreground" onclick={() => onDismissError(err.id)} aria-label="Dismiss">×</button>
</div>
</div>
{/each}
<div class="border-t bg-card/50 p-3 input-ornament relative">
<form
class="mx-auto flex max-w-3xl items-end gap-2"
onsubmit={(e) => {
e.preventDefault()
submit()
}}
>
<Textarea
bind:value={input}
onkeydown={handleKeydown}
placeholder="Ask Nomos anything…"
rows={2}
class="max-h-40 min-h-0 resize-none"
disabled={streaming}
/>
{#if streaming}
<Button type="button" size="icon" variant="destructive" onclick={onCancel} aria-label="Stop">
<SquareIcon />
</Button>
{:else}
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Send">
<ArrowUpIcon />
</Button>
{/if}
</form>
</div>
</div>
<style>
/* ── Art Nouveau chat styling ── */
/* Assistant message wrapper */
.assistant-msg {
position: relative;
}
/* User message — soft terracotta bubble, gentle lift */
.user-msg {
box-shadow: 0 1px 8px -4px var(--primary);
}
/* Prose overrides */
.prose-chat :global(p) {
margin: 0 0 0.5rem;
}
.prose-chat :global(p:last-child) {
margin-bottom: 0;
}
.prose-chat :global(ul),
.prose-chat :global(ol) {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.prose-chat :global(ul) {
list-style-type: disc;
}
.prose-chat :global(ol) {
list-style-type: decimal;
}
.prose-chat :global(li) {
margin-bottom: 0.125rem;
padding-left: 0.25rem;
}
.prose-chat :global(li::marker) {
color: var(--primary);
}
.prose-chat :global(code) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.15em 0.4em;
font-family: var(--font-mono);
font-size: 0.85em;
color: var(--primary);
}
.prose-chat :global(pre) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 0.875rem;
overflow-x: auto;
margin: 0 0 0.5rem;
position: relative;
}
.prose-chat :global(pre)::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(to right, transparent, var(--primary), transparent);
opacity: 0.4;
}
.prose-chat :global(pre code) {
background: none;
padding: 0;
font-size: 0.8125rem;
color: inherit;
border: none;
}
/* Section headings — serif (Inknut) with a short accent rule. Extra top
margin separates sections; the first heading in a message doesn't. */
.prose-chat :global(h1),
.prose-chat :global(h2),
.prose-chat :global(h3) {
font-weight: 600;
margin: 1.15rem 0 0.4rem;
font-size: 1.03em;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.prose-chat :global(> h1:first-child),
.prose-chat :global(> h2:first-child),
.prose-chat :global(> h3:first-child) {
margin-top: 0;
}
.prose-chat :global(h1)::after,
.prose-chat :global(h2)::after,
.prose-chat :global(h3)::after {
content: '';
display: block;
width: 2.5rem;
height: 2px;
margin-top: 4px;
border-radius: 1px;
background: linear-gradient(to right, var(--primary), transparent);
opacity: 0.55;
}
.prose-chat :global(table) {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.prose-chat :global(th) {
background: var(--muted);
font-weight: 600;
}
.prose-chat :global(th),
.prose-chat :global(td) {
border: 1px solid var(--border);
padding: 0.3rem 0.6rem;
text-align: left;
}
.prose-chat :global(blockquote) {
border-left: 3px solid var(--primary);
padding-left: 0.75rem;
color: var(--muted-foreground);
margin: 0 0 0.5rem;
font-style: italic;
position: relative;
}
.prose-chat :global(blockquote)::before {
content: '“';
position: absolute;
left: -0.15rem;
top: -0.35rem;
font-size: 1.5rem;
color: var(--primary);
opacity: 0.6;
font-style: normal;
line-height: 1;
}
.prose-chat :global(hr) {
border: none;
height: 1px;
margin: 0.75rem 0;
background: linear-gradient(to right, transparent, var(--border) 20%, var(--border) 80%, transparent);
}
/* Bold is emphasis, not color — dark weight reads cleanly and lets the
terracotta accent stay meaningful (code, headings, links). */
.prose-chat :global(strong) {
color: var(--foreground);
font-weight: 600;
}
.prose-chat :global(a) {
color: var(--primary);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
/* Input area ornament */
.input-ornament::before {
content: '';
position: absolute;
top: 0;
left: 2rem;
right: 2rem;
height: 1px;
background: linear-gradient(to right, transparent, var(--primary), transparent);
opacity: 0.3;
}
</style>

View File

@@ -22,7 +22,7 @@
</script>
<Collapsible.Root bind:open class="rounded-md border bg-card">
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center justify-between gap-2 px-2.5 py-1.5 text-left hover:bg-muted/50">
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center justify-between gap-2 px-2 py-1 text-left hover:bg-muted/50">
<span class="text-xs font-medium">{title}{count !== undefined ? ` (${count})` : ''}</span>
<ChevronDownIcon
class="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open ? 'rotate-180' : ''}"
@@ -30,7 +30,7 @@
/>
</Collapsible.Trigger>
<Collapsible.Content class="overflow-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in">
<div class="border-t px-2.5 py-2">
<div class="border-t px-2 py-1.5">
{@render children()}
</div>
</Collapsible.Content>

View File

@@ -6,7 +6,7 @@
import DOMPurify from 'dompurify'
import {
fetchEntity,
fetchGraph,
fetchEntityRelations,
fetchMetrics,
fetchEntityEvents,
fetchEntitySignals,
@@ -58,6 +58,15 @@
let actingSignal = $state<string | null>(null)
let chartContainers: Record<string, HTMLDivElement> = {}
// fetchEntityRelations already scopes to edges incident to this entity
// (source OR target = entity, both directions), so a plain split by which
// side matches is enough — no risk of an unrelated sibling-to-sibling edge
// sneaking into either group.
const outgoingRelations = $derived(entity ? relations.filter((r) => r.source === entity!.slug) : [])
const incomingRelations = $derived(
entity ? relations.filter((r) => r.target === entity!.slug && r.source !== entity!.slug) : []
)
async function load(s: string) {
loading = true
entity = await fetchEntity(s)
@@ -65,8 +74,8 @@
loading = false
return
}
const [graphView, m, ev, sig, tk, kh, oc, ch, aa, au] = await Promise.all([
fetchGraph({ root: entity.id, depth: 1 }),
const [rel, m, ev, sig, tk, kh, oc, ch, aa, au] = await Promise.all([
fetchEntityRelations(entity.id),
fetchMetrics(entity.id),
fetchEntityEvents(entity.id),
fetchEntitySignals(entity.id),
@@ -77,7 +86,7 @@
fetchAgentActivity({ entity_id: entity.id, limit: 50 }),
fetchAudit({ entity_id: entity.id, limit: 50 })
])
relations = graphView?.edges ?? []
relations = rel
metrics = m
events = ev
signals = sig
@@ -235,7 +244,7 @@
}
</script>
<div class="flex h-full flex-col gap-2 overflow-y-auto p-3 md:p-4">
<div class="flex h-full flex-col gap-1.5 overflow-y-auto p-2">
{#if loading}
<Skeleton class="h-6 w-48" />
<Skeleton class="h-8 w-full" />
@@ -378,26 +387,49 @@
{/if}
{/snippet}
{#snippet relationsContent()}
<div class="flex flex-col gap-1">
{#each relations as rel}
<div class="flex min-w-0 items-center gap-1 font-mono text-xs">
{#if onSelectEntity}
<button type="button" class="min-w-0 shrink hover:underline hover:text-foreground" title={rel.source} onclick={() => onSelectEntity(rel.source)}>{truncateMiddle(rel.source)}</button>
<span class="shrink-0 text-muted-foreground">{rel.type}</span>
<button type="button" class="min-w-0 shrink hover:underline hover:text-foreground" title={rel.target} onclick={() => onSelectEntity(rel.target)}>{truncateMiddle(rel.target)}</button>
{:else}
<span class="min-w-0 shrink truncate" title={rel.source}>{truncateMiddle(rel.source)}</span>
<span class="shrink-0 text-muted-foreground">{rel.type}</span>
<span class="min-w-0 shrink truncate" title={rel.target}>{truncateMiddle(rel.target)}</span>
{/if}
</div>
{#snippet relationRow(rel: Relationship)}
<div class="flex min-w-0 items-center gap-1 font-mono text-xs">
{#if onSelectEntity}
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={rel.source} onclick={() => onSelectEntity(rel.source)}>{truncateMiddle(rel.source)}</button>
<span class="shrink-0 text-muted-foreground">{rel.type}</span>
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={rel.target} onclick={() => onSelectEntity(rel.target)}>{truncateMiddle(rel.target)}</button>
{:else}
<p class="text-xs text-muted-foreground">No direct relations.</p>
{/each}
<span class="min-w-0 flex-1 truncate" title={rel.source}>{truncateMiddle(rel.source)}</span>
<span class="shrink-0 text-muted-foreground">{rel.type}</span>
<span class="min-w-0 flex-1 truncate" title={rel.target}>{truncateMiddle(rel.target)}</span>
{/if}
</div>
{/snippet}
{#snippet relationsContent()}
{#if outgoingRelations.length === 0 && incomingRelations.length === 0}
<p class="text-xs text-muted-foreground">No direct relations.</p>
{:else}
<div class="flex flex-col gap-3">
{#if outgoingRelations.length}
<div>
<div class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">Outgoing ({outgoingRelations.length})</div>
<div class="flex flex-col gap-1">
{#each outgoingRelations as rel}
{@render relationRow(rel)}
{/each}
</div>
</div>
{/if}
{#if incomingRelations.length}
<div>
<div class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">Incoming ({incomingRelations.length})</div>
<div class="flex flex-col gap-1">
{#each incomingRelations as rel}
{@render relationRow(rel)}
{/each}
</div>
</div>
{/if}
</div>
{/if}
{/snippet}
{#snippet metricsContent()}
{#if metrics.length}
<div class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
@@ -547,7 +579,7 @@
{ key: 'details', title: 'Details', count: 1, content: detailsContent },
{ key: 'monitoring', title: 'Monitoring', count: checks.length, content: monitoringContent },
{ key: 'attributes', title: 'Attributes', count: Object.keys(entity.attributes ?? {}).length, content: attributesContent },
{ key: 'relations', title: 'Relations', count: relations.length, content: relationsContent },
{ key: 'relations', title: 'Relations', count: outgoingRelations.length + incomingRelations.length, content: relationsContent },
{ key: 'metrics', title: 'Metrics', count: metrics.length, content: metricsContent },
{ key: 'signals', title: 'Signals', count: signals.length, content: signalsContent },
{ key: 'tasks', title: 'Tasks', count: tasks.length, content: tasksContent },

View File

@@ -1,13 +1,11 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
import { fetchGraph, fetchEntityTypes, type GraphView, type Entity, type Health } from '$lib/api'
import { fetchGraph, type GraphView, type Entity, type Health } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { typeToCategory, type Category } from '$lib/categories'
import { Skeleton } from '$lib/components/ui/skeleton'
export interface GraphInfo {
allNodeTypes: string[]
allRelTypes: string[]
relColors: Map<string, string>
visibleCount: number
@@ -16,7 +14,6 @@
}
let {
category,
selectedSlug = null,
onSelect,
root = $bindable(''),
@@ -24,11 +21,12 @@
search,
reloadToken,
resetToken,
activeNodeTypes = $bindable(new Set<string>()),
// Owned by the parent (shared with the entity table's type filter) —
// this graph only reads it to decide what's in focus, never writes it.
activeNodeTypes,
activeRelTypes = $bindable(new Set<string>()),
info = $bindable<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
info = $bindable<GraphInfo>({ allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
}: {
category: Category
selectedSlug?: string | null
onSelect: (slug: string | null) => void
root?: string
@@ -39,7 +37,7 @@
// this resizable pane), so they can't call load()/resetView() directly.
reloadToken: number
resetToken: number
activeNodeTypes?: Set<string>
activeNodeTypes: Set<string>
activeRelTypes?: Set<string>
info?: GraphInfo
} = $props()
@@ -59,19 +57,20 @@
type: string
}
// SVG ids are document-global, not scoped to this <svg> — see
// SessionGraph.svelte's dotGridId for why this needs a per-instance suffix
// (also covers the per-relationship-type arrow markers below, which were
// keyed only by type name and would collide the same way across two
// mounted EntityGraph instances).
const uid = crypto.randomUUID().slice(0, 8)
const dotGridId = `dot-grid-${uid}`
let graph = $state<GraphView | null>(null)
let loading = $state(true)
let nodes = $state<Node[]>([])
let links = $state<Link[]>([])
let sim: Simulation<Node, Link> | null = null
// type → browsing category, so the graph can be scoped client-side (the
// graph endpoint itself has no category/domain param). Value is undefined
// for types deliberately excluded from every category (e.g. execution/
// check/task — see categories.ts); the key is still present so inCategory
// can tell "excluded on purpose" apart from "not in the ontology at all."
let typeCategory = $state<Map<string, Category | undefined>>(new Map())
let hoveredId = $state<string | null>(null)
// viewport transform: translate(x, y) scale(k)
@@ -101,7 +100,7 @@
}
function markerId(type: string): string {
return 'arrow-' + type.replace(/[^a-z0-9]/gi, '_')
return `arrow-${uid}-` + type.replace(/[^a-z0-9]/gi, '_')
}
function endpoint(end: string | Node): Node | undefined {
@@ -111,46 +110,7 @@
return typeof end === 'object' ? end.id : end
}
// Node belongs to the active category? Types the ontology never returned
// at all fall back to visible (so a missing entry never blanks the
// graph); types the ontology returned but categories.ts deliberately
// excludes (key present, value undefined) do not.
function inCategory(type: string): boolean {
if (!typeCategory.has(type)) return true
return typeCategory.get(type) === category
}
// Brand-new nodes (no `prev`) get x/y left undefined, and d3-force's
// default init spreads those via a spiral centered on the ORIGIN — not
// (width/2, height/2) — while the x/y centering forces below are
// deliberately weak (0.04, so they don't fight the link/collide layout).
// Together that meant the cluster could settle noticeably off-origin
// instead of centered. Fixed by explicitly fitting the viewport to the
// node bounding box once the simulation settles, rather than relying on
// the force balance to land on center by itself.
function fitToView() {
const placed = nodes.filter((n) => n.x != null && n.y != null)
if (!placed.length) return
const xs = placed.map((n) => n.x as number)
const ys = placed.map((n) => n.y as number)
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
const minY = Math.min(...ys)
const maxY = Math.max(...ys)
const pad = 70
const bw = Math.max(maxX - minX, 1)
const bh = Math.max(maxY - minY, 1)
const k = Math.min((width - pad * 2) / bw, (height - pad * 2) / bh, 2.5)
const cx = (minX + maxX) / 2
const cy = (minY + maxY) / 2
view = { k, x: width / 2 - cx * k, y: height / 2 - cy * k }
}
// fit=false for passive background reloads (live entity/relationship
// events) — those shouldn't yank the view out from under someone
// actively panning/zooming. Fresh loads (mount, root/depth change,
// reset, re-root) default to fit=true.
async function load(fit = true) {
async function load() {
loading = true
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
loading = false
@@ -176,8 +136,8 @@
type: e.type
}))
// Default the node/edge-type toggles to the types present in the active category.
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
// Edge-type toggles default to everything present — node-type toggles
// are owned by the parent (activeNodeTypes) and persist across reloads.
activeRelTypes = new Set(links.map((l) => l.type))
sim?.stop()
@@ -193,17 +153,9 @@
.on('tick', () => {
nodes = [...nodes]
})
.on('end', () => {
if (fit) fitToView()
})
}
onMount(() => {
fetchEntityTypes().then((types) => {
typeCategory = new Map(types.map((t) => [t.name, typeToCategory(t.name, t.domain)]))
// Re-derive the active node types now that category membership is known.
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
})
load()
const unsubscribe = subscribeEvents()
return () => {
@@ -214,17 +166,11 @@
onDestroy(() => sim?.stop())
// When the category perspective changes, reset the node-type toggles to it.
$effect(() => {
category
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
})
$effect(() => {
const ev = $liveEvents[0]
if (!ev) return
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
load(false)
load()
}
})
@@ -266,14 +212,11 @@
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
}
// Only offer node-type toggles that live in the active category.
const allNodeTypes = $derived(Array.from(new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))).sort())
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
// Publish status/legend info up to the parent toolbar.
$effect(() => {
info = {
allNodeTypes,
allRelTypes,
relColors: relColorByType,
visibleCount: visibleNodeIds.size,
@@ -288,21 +231,21 @@
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
})
// Focus = in the active category AND its node-type toggle is on — these
// are what the category tab is "about."
const focusNodeIds = $derived(new Set(nodes.filter((n) => inCategory(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id)))
// Focus = the shared type multiselect (activeNodeTypes) says this type is
// visible — same control the entity table filters its rows by.
const focusNodeIds = $derived(new Set(nodes.filter((n) => activeNodeTypes.has(n.type)).map((n) => n.id)))
// Real infra relationships mostly cross category lines (a service sits on
// a network, uses storage, runs on an lxc — different categories under
// this taxonomy). Hard-hiding any edge whose other end isn't in-category
// left focus nodes looking like disconnected dots. Rooted views (the user
// is exploring out from one entity) pull in 1-hop neighbors of any
// category, dimmed, so the edges — and what they connect to — stay
// visible. Unscoped "browse the whole category" views (no root) skip
// this: with ~50 focus nodes that touch nearly everything, 1-hop
// expansion floods in most of the graph (measured: 417 of 479 total
// entities for an unrooted Fleet view) — worse than the isolated-dot
// problem it was meant to fix. There, same-category-only edges stay.
// Real infra relationships mostly cross type lines (a service sits on a
// network, uses storage, runs on an lxc). Hard-hiding any edge whose
// other end isn't in the active type set left focus nodes looking like
// disconnected dots. Rooted views (the user is exploring out from one
// entity) pull in 1-hop neighbors of any type, dimmed, so the edges — and
// what they connect to — stay visible. Unscoped "browse everything" views
// (no root) skip this: with dozens of focus nodes that touch nearly
// everything, 1-hop expansion floods in most of the graph (measured: 417
// of 479 total entities for an unrooted Fleet-typed view) — worse than
// the isolated-dot problem it was meant to fix. There, same-type-only
// edges stay.
const neighborNodeIds = $derived.by(() => {
const neighbors = new Set<string>()
if (!root.trim()) return neighbors
@@ -452,7 +395,7 @@
onpointercancel={onPointerUp}
>
<defs>
<pattern id="dot-grid" width="12" height="12" patternUnits="userSpaceOnUse">
<pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
</pattern>
{#each allRelTypes as type}
@@ -461,7 +404,7 @@
</marker>
{/each}
</defs>
<rect x="0" y="0" width={width} height={height} fill="url(#dot-grid)" />
<rect x="0" y="0" width={width} height={height} fill="url(#{dotGridId})" />
<g transform="translate({view.x},{view.y}) scale({view.k})">
<g>
{#each links as link}
@@ -472,25 +415,31 @@
{@const dx = t.x - s.x}
{@const dy = t.y - s.y}
{@const len = Math.max(Math.hypot(dx, dy), 1)}
{@const curve = Math.min(len * 0.15, 40)}
{@const cx = (s.x + t.x) / 2 - (dy / len) * curve}
{@const cy = (s.y + t.y) / 2 + (dx / len) * curve}
{@const cdx = t.x - cx}
{@const cdy = t.y - cy}
{@const clen = Math.max(Math.hypot(cdx, cdy), 1)}
{@const tr = nodeRadius(t) + 3}
{@const ex = t.x - (dx / len) * tr}
{@const ey = t.y - (dy / len) * tr}
<line
x1={s.x}
y1={s.y}
x2={ex}
y2={ey}
{@const ex = t.x - (cdx / clen) * tr}
{@const ey = t.y - (cdy / clen) * tr}
{@const mx = 0.25 * s.x + 0.5 * cx + 0.25 * ex}
{@const my = 0.25 * s.y + 0.5 * cy + 0.25 * ey}
<path
d="M {s.x},{s.y} Q {cx},{cy} {ex},{ey}"
fill="none"
stroke={relColor(link.type)}
stroke-width={vs.emphasized ? 2 : 1.2}
opacity={vs.opacity}
marker-end="url(#{markerId(link.type)})"
>
<title>{link.type}</title>
</line>
</path>
{#if vs.emphasized && view.k >= 0.7}
<text
x={(s.x + ex) / 2}
y={(s.y + ey) / 2 - 4}
x={mx}
y={my - 4}
text-anchor="middle"
font-size={10 / view.k}
fill={relColor(link.type)}

View File

@@ -1,28 +0,0 @@
<script lang="ts">
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
import * as Sheet from '$lib/components/ui/sheet'
let { slug, open = $bindable(false) }: { slug: string | null; open?: boolean } = $props()
// Lets a relation click inside the sheet drill into that entity in place,
// without closing/reopening. Resets to the externally-requested slug
// whenever the caller opens the sheet on a different entity.
let currentSlug = $state<string | null>(null)
$effect(() => {
currentSlug = slug
})
</script>
<Sheet.Root bind:open>
<Sheet.Content side="right" class="w-full p-0 sm:max-w-2xl">
<Sheet.Header class="sr-only">
<Sheet.Title>{currentSlug ?? 'Entity detail'}</Sheet.Title>
<Sheet.Description>Entity detail panel</Sheet.Description>
</Sheet.Header>
{#if currentSlug}
{#key currentSlug}
<EntityDetailContent slug={currentSlug} onSelectEntity={(s) => (currentSlug = s)} />
{/key}
{/if}
</Sheet.Content>
</Sheet.Root>

View File

@@ -7,22 +7,44 @@
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down'
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
let {
entities,
loading,
selectedSlug = null,
onSelect
onSelect,
childToParent = null
}: {
entities: Entity[]
loading: boolean
selectedSlug?: string | null
onSelect: (slug: string) => void
// child entity slug -> parent entity slug, derived from the ontology
// graph (arbitrary relationship types, not a fixed list — see
// KnowledgeBase.svelte). When set, rows nest under their parent —
// possibly several levels deep (host -> lxc -> service) — instead of
// rendering flat. Since the parent for a given child can come from
// whichever relationship happened to be processed last, a cycle across
// relationship types isn't structurally impossible; `row` tracks the
// ancestor chain and drops a child that would re-enter it, rather than
// recursing forever.
childToParent?: Map<string, string> | null
} = $props()
type SortKey = 'slug' | 'type' | 'name' | 'state' | 'health'
let sortKey = $state<SortKey>('slug')
let sortDir = $state<'asc' | 'desc'>('asc')
let collapsedNodes = $state<Set<string>>(new Set())
function toggleNode(slug: string, e: Event) {
e.stopPropagation()
const next = new Set(collapsedNodes)
if (next.has(slug)) next.delete(slug)
else next.add(slug)
collapsedNodes = next
}
function sortBy(key: SortKey) {
if (sortKey === key) {
@@ -52,6 +74,36 @@
return sorted
})
// ─── treegrid grouping: nest entities under their parent (per
// childToParent — host->lxc via `hosts`, lxc/vm/host->service via
// `provides`, chained to whatever depth the relationships form). An entity
// whose parent got filtered out of `entities` (e.g. by the type dropdown)
// has no parent row to nest under, so it falls back to rendering top-level
// rather than disappearing.
const childrenByParent = $derived.by(() => {
const map = new Map<string, Entity[]>()
if (!childToParent) return map
const visibleSlugs = new Set(entities.map((e) => e.slug))
for (const e of sortedEntities) {
const parentSlug = childToParent.get(e.slug)
if (parentSlug && visibleSlugs.has(parentSlug)) {
if (!map.has(parentSlug)) map.set(parentSlug, [])
map.get(parentSlug)!.push(e)
}
}
return map
})
const nestedSlugs = $derived.by(() => {
const set = new Set<string>()
for (const children of childrenByParent.values()) for (const c of children) set.add(c.slug)
return set
})
const topLevelEntities = $derived.by(() =>
childToParent ? sortedEntities.filter((e) => !nestedSlugs.has(e.slug)) : sortedEntities
)
function stateVariant(state?: string | null): 'default' | 'secondary' | 'outline' {
if (!state) return 'outline'
if (state === 'active' || state === 'healthy') return 'default'
@@ -124,8 +176,70 @@
</button>
</Table.Head>
{/snippet}
{#snippet row(entity: Entity, level: number, ancestors: Set<string>)}
{@const ancestorsWithSelf = new Set(ancestors).add(entity.slug)}
{@const children = (childrenByParent.get(entity.slug) ?? []).filter((c) => !ancestorsWithSelf.has(c.slug))}
<Table.Row
class="cursor-pointer {entity.slug === selectedSlug ? 'bg-muted' : ''}"
role="row"
aria-level={level}
aria-expanded={children.length > 0 ? !collapsedNodes.has(entity.slug) : undefined}
tabindex={0}
onclick={() => onSelect(entity.slug)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(entity.slug) } }}
>
<Table.Cell class="font-mono text-xs">
<span class="flex items-center gap-1" style="padding-left: {(level - 1) * 1.25}rem">
<span class="inline-flex size-3.5 shrink-0 items-center justify-center">
{#if children.length > 0}
<button
type="button"
class="rounded text-muted-foreground hover:text-foreground"
onclick={(e) => toggleNode(entity.slug, e)}
aria-label={collapsedNodes.has(entity.slug) ? `Expand ${entity.slug}` : `Collapse ${entity.slug}`}
>
{#if collapsedNodes.has(entity.slug)}
<ChevronRightIcon class="size-3.5" />
{:else}
<ChevronDownIcon class="size-3.5" />
{/if}
</button>
{/if}
</span>
{entity.slug}
{#if children.length > 0}
<span class="text-muted-foreground">({children.length})</span>
{/if}
</span>
</Table.Cell>
<Table.Cell><Badge variant="outline">{entity.type}</Badge></Table.Cell>
<Table.Cell>{entity.name}</Table.Cell>
<Table.Cell>
{#if entity.state}
<Badge variant={stateVariant(entity.state)}>{entity.state}</Badge>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell>
{#if entity.health}
<span class="flex items-center gap-1.5 text-xs" title={healthTitle(entity)}>
<span class="size-2 shrink-0 rounded-full {healthDot[entity.health]}"></span>
<span class="text-muted-foreground">{relativeTime(entity.last_check_at)}</span>
</span>
{:else}
<span class="text-xs text-muted-foreground"></span>
{/if}
</Table.Cell>
</Table.Row>
{#if children.length > 0 && !collapsedNodes.has(entity.slug)}
{#each children as child (child.id)}
{@render row(child, level + 1, ancestorsWithSelf)}
{/each}
{/if}
{/snippet}
<div class="h-full min-h-0 overflow-auto rounded-md border">
<Table.Root>
<Table.Root role={childToParent ? 'treegrid' : undefined}>
<Table.Header>
<Table.Row>
{@render sortHead('slug', 'Slug')}
@@ -136,35 +250,8 @@
</Table.Row>
</Table.Header>
<Table.Body>
{#each sortedEntities as entity (entity.id)}
<Table.Row
class="cursor-pointer {entity.slug === selectedSlug ? 'bg-muted' : ''}"
role="button"
tabindex={0}
onclick={() => onSelect(entity.slug)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(entity.slug) } }}
>
<Table.Cell class="font-mono text-xs">{entity.slug}</Table.Cell>
<Table.Cell><Badge variant="outline">{entity.type}</Badge></Table.Cell>
<Table.Cell>{entity.name}</Table.Cell>
<Table.Cell>
{#if entity.state}
<Badge variant={stateVariant(entity.state)}>{entity.state}</Badge>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell>
{#if entity.health}
<span class="flex items-center gap-1.5 text-xs" title={healthTitle(entity)}>
<span class="size-2 shrink-0 rounded-full {healthDot[entity.health]}"></span>
<span class="text-muted-foreground">{relativeTime(entity.last_check_at)}</span>
</span>
{:else}
<span class="text-xs text-muted-foreground"></span>
{/if}
</Table.Cell>
</Table.Row>
{#each topLevelEntities as entity (entity.id)}
{@render row(entity, 1, new Set())}
{:else}
<Table.Row>
<Table.Cell colspan={5} class="text-center text-muted-foreground"

View File

@@ -198,8 +198,14 @@
const z = (s.z + tg.z) / 2
const a = project(s.x, s.y!, z)
const b = project(tg.x, tg.y!, z)
const dx = b.x - a.x
const dy = b.y - a.y
const len = Math.max(Math.hypot(dx, dy), 1)
const curve = Math.min(len * 0.15, 40)
const mx = (a.x + b.x) / 2 - (dy / len) * curve
const my = (a.y + b.y) / 2 + (dx / len) * curve
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
ctx.quadraticCurveTo(mx, my, b.x, b.y)
}
ctx.stroke()

View File

@@ -1,17 +1,18 @@
<script lang="ts">
import { openQuestion } from '$lib/stores/workspace'
import { currentSession } from '$lib/stores/chat'
import { answerQuestion as postAnswer } from '$lib/api'
import { answerQuestion as postAnswer, type SessionQuestion } from '$lib/api'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import CircleHelpIcon from '@lucide/svelte/icons/circle-help'
// Prop-driven (not store-imported) — see SessionGraph.svelte for why.
let { sessionId, question }: { sessionId: string | null; question: SessionQuestion | null } = $props()
let freeText = $state('')
let submitting = $state(false)
async function submit(answer: string) {
const sid = $currentSession
const q = $openQuestion
const sid = sessionId
const q = question
if (!sid || !q || !answer.trim() || submitting) return
submitting = true
const ok = await postAnswer(sid, q.id, answer.trim())
@@ -23,8 +24,8 @@
}
</script>
{#if $openQuestion}
{@const q = $openQuestion}
{#if question}
{@const q = question}
<div class="flex shrink-0 flex-col gap-2 border-b bg-warning/5 px-3 py-2.5">
<div class="flex items-start gap-2">
<CircleHelpIcon class="mt-0.5 size-4 shrink-0 text-warning" />

View File

@@ -0,0 +1,97 @@
<script lang="ts">
// Floating-window content for a task/session — the per-window counterpart
// to the main Chat page (thread + rail), fully self-contained per
// sessionId via chat.ts's chatFor()/loadSessionChat()/sendSessionMessage()
// and workspace.ts's workspaceFor()/startSessionWorkspace(), so several of
// these can be open (and independently live) at once without the "which
// one's on screen" guarding the main page's singleton stores need.
import { onDestroy, onMount } from 'svelte'
import { chatFor, loadSessionChat, sendSessionMessage, cancelSessionStream, stopSessionPolling, dismissError, chatErrors } from '$lib/stores/chat'
import ChatThread from '$lib/components/ChatThread.svelte'
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
let { sessionId }: { sessionId: string } = $props()
// Svelte's `$store` auto-subscription only works on a plain identifier
// bound directly to a store, not a member expression — chatFor() returns
// an object of stores, so pull each one out into its own identifier here.
const chat = chatFor(sessionId)
const chatMessages = chat.messages
const chatStreaming = chat.streaming
const chatConnectionState = chat.connectionState
const chatError = chat.error
const chatNotFound = chat.notFound
let loading = $state(true)
onMount(async () => {
await loadSessionChat(sessionId)
loading = false
})
onDestroy(() => stopSessionPolling(sessionId))
// Resizable right rail — same behavior as Chat.svelte's, sized smaller by
// default since task windows open narrower than the full page.
const RAIL_MIN = 220
const RAIL_MAX = 480
let railWidth = $state(260)
let resizing = $state(false)
function startResize(e: PointerEvent) {
e.preventDefault()
resizing = true
const startX = e.clientX
const startW = railWidth
function move(ev: PointerEvent) {
railWidth = Math.min(RAIL_MAX, Math.max(RAIL_MIN, startW + (startX - ev.clientX)))
}
function up() {
resizing = false
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
</script>
<div class="flex h-full min-h-0">
{#if loading}
<div class="flex flex-1 items-center justify-center text-xs text-muted-foreground">Loading…</div>
{:else if $chatNotFound}
<div class="flex flex-1 flex-col items-center justify-center gap-1 p-6 text-center">
<p class="text-sm text-muted-foreground">Task not found.</p>
<p class="text-xs text-muted-foreground/70">It may have been deleted.</p>
</div>
{:else}
<ChatThread
messages={$chatMessages}
streaming={$chatStreaming}
connectionState={$chatConnectionState}
error={$chatError}
chatErrors={$chatErrors}
onSend={(text) => sendSessionMessage(sessionId, text)}
onCancel={() => cancelSessionStream(sessionId)}
onReconnect={() => loadSessionChat(sessionId)}
onDismissError={dismissError}
/>
<div class="flex shrink-0" style="width: {railWidth}px">
<button
type="button"
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
onpointerdown={startResize}
aria-label="Resize task panel"
>
<span
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
? 'bg-primary/60'
: 'bg-border group-hover/rz:bg-primary/50'}"
></span>
</button>
<div class="flex min-w-0 flex-1 flex-col">
<TaskContextPanel {sessionId} />
</div>
</div>
{/if}
</div>

View File

@@ -11,14 +11,21 @@
type Simulation
} from 'd3-force'
import { fetchGraph, type Entity } from '$lib/api'
import { messages } from '$lib/stores/chat'
import { touched, healthDiffs } from '$lib/stores/workspace'
import { relativeTime } from '$lib/utils'
import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
import EntitySheet from '$lib/components/EntitySheet.svelte'
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
import XIcon from '@lucide/svelte/icons/x'
import type { ChatMessage } from '$lib/stores/chat'
import type { TouchedEntity, HealthDiff } from '$lib/stores/workspace'
import { openEntityWindow, wmState } from '$lib/stores/windows'
// Prop-driven (not store-imported) so this can render either the main
// page's global "current session" data or a floating task window's own
// per-session data — see TaskContextPanel.svelte, which supplies both.
let { messages, touched, healthDiffs }: { messages: ChatMessage[]; touched: TouchedEntity[]; healthDiffs: HealthDiff[] } = $props()
// SVG ids are document-global, not scoped to this <svg> — several task
// windows can each have their own Scope graph open at once, and without a
// per-instance suffix every one of them would define (and reference)
// <pattern id="dot-grid">, so only the first in the document would ever
// actually paint (the rest resolve to nothing, background reads blank).
const dotGridId = `dot-grid-${crypto.randomUUID().slice(0, 8)}`
interface Node extends Entity {
x?: number
@@ -46,8 +53,6 @@
let nodes = $state<Node[]>([])
let links = $state<Edge[]>([])
let selected = $state<Node | null>(null)
let sheetSlug = $state<string | null>(null)
let sheetOpen = $state(false)
let sim: Simulation<Node, Edge> | null = null
@@ -66,14 +71,6 @@
let cw = $state(300)
let ch = $state(300)
// This component sits INSIDE one of TaskContextPanel's own resizable slots
// (Scope), so — unlike a top-level section — its total budget can change at
// any time from outside (dragging the outer Scope/Plan handle), including
// while the detail panel below is open. asideHeight tracks that live budget
// so detailHeight can self-clamp to it instead of trusting a one-time seed.
let asideEl = $state<HTMLElement | null>(null)
let asideHeight = $state(300)
function collectSlugs(value: unknown, out: Set<string>) {
if (typeof value === 'string') {
const m = value.match(SLUG_RE)
@@ -90,7 +87,7 @@
// get_health_summary would otherwise dump all 168 entities into the graph).
const candidateSlugs = $derived.by(() => {
const out = new Set<string>()
for (const m of $messages) {
for (const m of messages) {
collectSlugs(m.text, out)
for (const t of m.tools) collectSlugs(t.args, out)
}
@@ -217,13 +214,12 @@
return () => ro.disconnect()
})
// The highlight ring/dim styling below is tied to the node whose window
// was last opened — once that window is closed (from WindowLayer, not
// necessarily from here), the ring should go with it rather than pointing
// at a window that no longer exists.
$effect(() => {
if (!asideEl) return
const ro = new ResizeObserver((entries) => {
asideHeight = Math.max(entries[0].contentRect.height, 1)
})
ro.observe(asideEl)
return () => ro.disconnect()
if (selected && !$wmState.windows[selected.slug]) selected = null
})
onDestroy(() => sim?.stop())
@@ -250,15 +246,15 @@
// object identity fine and this is small (≤12 touched, ≤8 diffs).
const touchedBySlug = $derived.by(() => {
const m: Record<string, true> = {}
for (const t of $touched) m[t.slug] = true
for (const t of touched) m[t.slug] = true
return m
})
const diffBySlug = $derived.by(() => {
const m: Record<string, { from: string; to: string }> = {}
for (const d of $healthDiffs) if (!(d.slug in m)) m[d.slug] = d
for (const d of healthDiffs) if (!(d.slug in m)) m[d.slug] = d
return m
})
const nowTouching = $derived($touched[0] ?? null)
const nowTouching = $derived(touched[0] ?? null)
function endpoint(end: string | Node): Node | undefined {
return typeof end === 'object' ? end : nodes.find((n) => n.slug === end)
@@ -267,52 +263,11 @@
return typeof end === 'object' ? end.slug : end
}
// ─── graph / detail resize ───────────────────────────────────────────
// Same drag handle, same feel as TaskContextPanel's Scope/Plan/Activity
// split — but the graph side stays flex-1 (always auto-fills whatever's
// left) rather than tracking its own pixel number. Only detailHeight is
// explicit, and it's continuously clamped against asideHeight (this
// component's actual live budget) rather than a value seeded once — so
// resizing the OUTER Scope section while the detail panel is open can't
// push this panel past its container the way a one-time seed could.
const MIN_GRAPH = 80
const MIN_DETAIL = 80
const HANDLE = 6
let detailHeight = $state(200)
let resizing = $state(false)
let resizeStartY = $state(0)
let resizeStartH = $state(0)
function maxDetailHeight(): number {
return Math.max(MIN_DETAIL, asideHeight - MIN_GRAPH - HANDLE)
}
$effect(() => {
const max = maxDetailHeight()
if (detailHeight > max) detailHeight = max
})
function onPointerDown(e: PointerEvent) {
e.preventDefault()
resizing = true
resizeStartY = e.clientY
resizeStartH = detailHeight
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
function onPointerMove(e: PointerEvent) {
if (!resizing) return
const dy = e.clientY - resizeStartY
detailHeight = Math.min(maxDetailHeight(), Math.max(MIN_DETAIL, resizeStartH - dy))
}
function onPointerUp() {
resizing = false
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}
// ─── drag / select ───────────────────────────────────────────────────
// A click (pointerdown+up with no movement in between) opens the entity
// straight in its own floating window (WindowLayer) instead of a
// click-through mini-panel — `selected` now only drives the highlight/dim
// styling below, so you can see at a glance which node you last opened.
let dragState: { node: Node; moved: boolean } | null = null
function toLocal(clientX: number, clientY: number) {
@@ -334,6 +289,10 @@
dragState.moved = true
nodes = [...nodes]
}
function selectAndOpen(node: Node) {
selected = node
openEntityWindow(node.slug)
}
function onUp() {
if (!dragState) return
const { node, moved } = dragState
@@ -341,15 +300,7 @@
node.fy = null
sim?.alphaTarget(0)
dragState = null
if (!moved) {
const wasNull = selected === null
const next = selected?.slug === node.slug ? null : node
// A reasonable starting size on first open — the clamp effect above
// keeps it honest against the live container size from here on, so
// this doesn't need to be exact.
if (next && wasNull) detailHeight = Math.min(maxDetailHeight(), Math.round(ch * 0.45))
selected = next
}
if (!moved) selectAndOpen(node)
}
const selectedRelations = $derived(
@@ -362,15 +313,9 @@
})
: []
)
function openFull() {
if (!selected) return
sheetSlug = selected.slug
sheetOpen = true
}
</script>
<aside bind:this={asideEl} class="flex h-full min-h-0 flex-col bg-card/40">
<aside class="flex h-full min-h-0 flex-col bg-card/40">
{#if nowTouching}
<div class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary">
<span class="size-1.5 animate-pulse rounded-full bg-primary"></span>
@@ -415,28 +360,32 @@
onpointercancel={onUp}
>
<defs>
<pattern id="dot-grid" width="12" height="12" patternUnits="userSpaceOnUse">
<pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
</pattern>
</defs>
<rect width={cw} height={ch} fill="url(#dot-grid)" />
<rect width={cw} height={ch} fill="url(#{dotGridId})" />
<g>
{#each links as link}
{@const s = endpoint(link.source)}
{@const t = endpoint(link.target)}
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null}
{@const focus = selected && (s.slug === selected.slug || t.slug === selected.slug)}
<line
x1={s.x}
y1={s.y}
x2={t.x}
y2={t.y}
{@const dx = t.x - s.x}
{@const dy = t.y - s.y}
{@const len = Math.max(Math.hypot(dx, dy), 1)}
{@const curve = Math.min(len * 0.15, 40)}
{@const cx = (s.x + t.x) / 2 - (dy / len) * curve}
{@const cy = (s.y + t.y) / 2 + (dx / len) * curve}
<path
d="M {s.x},{s.y} Q {cx},{cy} {t.x},{t.y}"
fill="none"
stroke="var(--muted-foreground)"
stroke-width={focus ? 1.6 : 1}
opacity={selected ? (focus ? 0.7 : 0.12) : 0.35}
>
<title>{link.type}</title>
</line>
</path>
{/if}
{/each}
</g>
@@ -455,7 +404,7 @@
role="button"
tabindex="0"
onpointerdown={(e) => onNodeDown(e, node)}
onkeydown={(e) => e.key === 'Enter' && (selected = node)}
onkeydown={(e) => e.key === 'Enter' && selectAndOpen(node)}
>
{#if isSel}
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
@@ -500,72 +449,4 @@
</svg>
{/if}
</div>
{#if selected}
<!-- resize handle -->
<div
class="h-1.5 shrink-0 cursor-row-resize border-b hover:bg-primary/30 touch-none"
onpointerdown={onPointerDown}
role="separator"
aria-orientation="horizontal"
></div>
<div class="shrink-0 space-y-3 overflow-y-auto p-3 text-xs" style="height: {detailHeight}px">
<div class="flex flex-wrap items-center gap-1.5">
<span class="min-w-0 flex-1 truncate font-mono text-sm font-semibold">{selected.slug}</span>
<Badge variant="outline">{selected.type}</Badge>
{#if selected.state}<Badge variant="secondary">{selected.state}</Badge>{/if}
<button
type="button"
class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={() => (selected = null)}
aria-label="Close entity detail"
>
<XIcon class="size-3.5" />
</button>
</div>
{#if selected.health}
<div class="flex items-center gap-1.5 text-muted-foreground">
<span class="size-2 rounded-full" style="background: {nodeColor(selected)}"></span>
{selected.health} · checked {relativeTime(selected.last_check_at)}
</div>
{/if}
{#if selected.attributes && Object.keys(selected.attributes).length}
<div>
<p class="mb-1 font-medium text-muted-foreground">Attributes</p>
<dl class="flex flex-col gap-1">
{#each Object.entries(selected.attributes).slice(0, 6) as [key, value]}
<div class="flex items-start justify-between gap-3 border-b pb-1 last:border-0">
<dt class="shrink-0 font-mono text-muted-foreground">{key}</dt>
<dd class="min-w-0 flex-1 truncate text-right">{typeof value === 'object' ? JSON.stringify(value) : String(value)}</dd>
</div>
{/each}
</dl>
</div>
{/if}
{#if selectedRelations.length}
<div>
<p class="mb-1 font-medium text-muted-foreground">Relations ({selectedRelations.length})</p>
<div class="flex flex-col gap-1">
{#each selectedRelations as rel}
<div class="flex items-center gap-1 font-mono">
<span class="text-muted-foreground">{rel.dir} {rel.type}</span>
<button type="button" class="truncate hover:underline" onclick={() => { const n = nodes.find((x) => x.slug === rel.other); if (n) selected = n }}>
{rel.other}
</button>
</div>
{/each}
</div>
</div>
{/if}
<Button variant="outline" size="sm" class="w-full" onclick={openFull}>
<ExternalLinkIcon class="mr-1 size-3.5" />
Full detail
</Button>
</div>
{/if}
</aside>
<EntitySheet slug={sheetSlug} bind:open={sheetOpen} />

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte'
import { startWorkspace, planSteps, currentTask, touched } from '$lib/stores/workspace'
import { activityLog } from '$lib/stores/activity'
import { streaming } from '$lib/stores/chat'
import { startWorkspace, startSessionWorkspace, planSteps, currentTask, openQuestion, touched, healthDiffs, workspaceFor, taskFor } from '$lib/stores/workspace'
import { streaming, messages, currentSession, chatFor } from '$lib/stores/chat'
import { activityLog, activityLogFor } from '$lib/stores/activity'
import OperatorQuestion from './OperatorQuestion.svelte'
import SessionGraph from './SessionGraph.svelte'
import ActivityTimeline from './ActivityTimeline.svelte'
@@ -16,7 +16,29 @@
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash'
import CirclePauseIcon from '@lucide/svelte/icons/circle-pause'
onMount(() => startWorkspace())
// Omitted (main Chat page): tracks the global "current session" — one
// shared view, same as always. Passed (a floating task window's
// SessionChatWindow): this panel switches entirely to that session's own
// store bundle (workspaceFor/chatFor/activityLogFor), so several windows'
// panels can be open and live at once instead of all showing whatever
// happens to be the single global "current session".
let { sessionId = null }: { sessionId?: string | null } = $props()
onMount(() => (sessionId ? startSessionWorkspace(sessionId) : startWorkspace()))
const ws = $derived(sessionId ? workspaceFor(sessionId) : null)
const planStepsStore = $derived(ws ? ws.planSteps : planSteps)
const openQuestionStore = $derived(ws ? ws.openQuestion : openQuestion)
const touchedStore = $derived(ws ? ws.touched : touched)
const healthDiffsStore = $derived(ws ? ws.healthDiffs : healthDiffs)
const taskStore = $derived(sessionId ? taskFor(sessionId) : currentTask)
const chat = $derived(sessionId ? chatFor(sessionId) : null)
const streamingStore = $derived(chat ? chat.streaming : streaming)
const messagesStore = $derived(chat ? chat.messages : messages)
const activityLogStore = $derived(sessionId ? activityLogFor(sessionId) : activityLog)
// OperatorQuestion posts its answer against this id — the window's own
// session when set, otherwise whatever the main page currently has open.
const effectiveSessionId = $derived(sessionId ?? $currentSession)
let scopeOpen = $state(true)
let planOpen = $state(true)
@@ -58,8 +80,8 @@
}
// Plan collapsed status
const planDone = $derived($planSteps.filter((s) => s.status === 'done').length)
const planTotal = $derived($planSteps.length)
const planDone = $derived($planStepsStore.filter((s) => s.status === 'done').length)
const planTotal = $derived($planStepsStore.length)
const planPct = $derived(planTotal > 0 ? Math.round((planDone / planTotal) * 100) : 0)
// When there are no plan steps, the empty state depends on WHY: a task that's
@@ -67,19 +89,19 @@
// but a finished task that never planned (a read-only lookup, a direct answer)
// will never get one — a perpetual "Awaiting plan…" there is misleading.
const planPhase = $derived.by<'drafting' | 'none' | 'idle'>(() => {
const st = $currentTask?.status
const st = $taskStore?.status
if (st === 'done' || st === 'failed' || st === 'abandoned') return 'none'
if (st === 'planning' || $streaming) return 'drafting'
if (st === 'planning' || $streamingStore) return 'drafting'
return 'idle'
})
// Activity collapsed status
const activityRunning = $derived($activityLog.filter((e) => e.status === 'running').length)
const activityCount = $derived($activityLog.length)
const activityRunning = $derived($activityLogStore.filter((e) => e.status === 'running').length)
const activityCount = $derived($activityLogStore.length)
</script>
<div class="flex h-full min-h-0 flex-col">
<OperatorQuestion />
<OperatorQuestion sessionId={effectiveSessionId} question={$openQuestionStore} />
<!-- Scope -->
<div class="flex shrink-0 flex-col border-b">
@@ -91,12 +113,12 @@
{#if scopeOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
<span>Scope</span>
{#if !scopeOpen}
<span class="ml-auto font-normal normal-case">{$touched.length ? `${$touched.length} entit${$touched.length === 1 ? 'y' : 'ies'}` : 'Graph'}</span>
<span class="ml-auto font-normal normal-case">{$touchedStore.length ? `${$touchedStore.length} entit${$touchedStore.length === 1 ? 'y' : 'ies'}` : 'Graph'}</span>
{/if}
</button>
{#if scopeOpen}
<div style="height: {heights[0]}px">
<SessionGraph />
<SessionGraph messages={$messagesStore} touched={$touchedStore} healthDiffs={$healthDiffsStore} />
</div>
<!-- resize handle -->
<div
@@ -120,8 +142,8 @@
{#if !planOpen}
{#if planTotal > 0}
<span class="ml-auto font-normal normal-case">Step {planDone}/{planTotal}</span>
{:else if $currentTask?.goal}
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$currentTask.goal}</span>
{:else if $taskStore?.goal}
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$taskStore.goal}</span>
{:else}
<span class="ml-auto font-normal normal-case text-muted-foreground">No plan yet</span>
{/if}
@@ -129,10 +151,10 @@
</button>
{#if planOpen}
<div style="height: {heights[1]}px" class="flex flex-col overflow-y-auto">
{#if $currentTask?.goal}
{#if $taskStore?.goal}
<div class="flex items-start gap-2 px-3 py-2">
<MilestoneIcon class="mt-0.5 size-3 shrink-0 text-primary" />
<span class="text-xs leading-snug text-foreground/90">{$currentTask.goal}</span>
<span class="text-xs leading-snug text-foreground/90">{$taskStore.goal}</span>
</div>
{/if}
{#if planTotal > 0}
@@ -146,11 +168,11 @@
</div>
</div>
<ol class="flex flex-col overflow-y-auto px-2 pb-2 text-[11px]">
{#each $planSteps as step, i (step.id)}
{#each $planStepsStore as step, i (step.id)}
{@const isDone = step.status === 'done'}
{@const isRunning = step.status === 'running'}
<li class="relative flex items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors {isRunning ? 'bg-primary/5' : ''}">
{#if i < $planSteps.length - 1}
{#if i < $planStepsStore.length - 1}
<span class="pointer-events-none absolute bottom-[-2px] left-[13.5px] top-[22px] w-px bg-border" aria-hidden="true"></span>
{/if}
<span class="relative z-10 mt-px flex size-3.5 shrink-0 items-center justify-center rounded-full bg-background">
@@ -242,7 +264,7 @@
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
<span>Event log</span>
{#if !activityOpen}
{#if $streaming && activityRunning > 0}
{#if $streamingStore && activityRunning > 0}
<Spinner class="size-3 text-primary" />
<span class="font-normal normal-case text-primary">{activityRunning} running</span>
{:else}
@@ -252,7 +274,7 @@
</button>
{#if activityOpen}
<div class="min-h-0 flex-1 overflow-hidden">
<ActivityTimeline />
<ActivityTimeline entries={$activityLogStore} />
</div>
{/if}
</div>

View File

@@ -0,0 +1,163 @@
<script lang="ts">
// The desktop shell: full-viewport surface (background + icons + the
// centered task launcher + the floating window layer) with the taskbar
// docked below it as a real flex sibling, not an overlay — so a maximized
// or dragged window can never end up underneath the taskbar. This replaces
// the old sidebar + hash-routed page shell in App.svelte entirely; apps are
// desktop icons now (see $lib/apps.ts), not nav items.
import { APPS } from '$lib/apps'
import { iconPositions, resetIconLayout } from '$lib/stores/icons'
import { wm, openAppWindow, toggleShowDesktop } from '$lib/stores/windows'
import { summary } from '$lib/stores/context'
import GraphBackground from '../GraphBackground.svelte'
import DesktopIcon from './DesktopIcon.svelte'
import TaskLauncher from './TaskLauncher.svelte'
import WindowLayer from './WindowLayer.svelte'
import Taskbar from './Taskbar.svelte'
import LayersIcon from '@lucide/svelte/icons/layers'
import Rows3Icon from '@lucide/svelte/icons/rows-3'
import MonitorIcon from '@lucide/svelte/icons/monitor'
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
import Undo2Icon from '@lucide/svelte/icons/undo-2'
import Redo2Icon from '@lucide/svelte/icons/redo-2'
let { onOpenConnection }: { onOpenConnection: () => void } = $props()
// Clicking the bare desktop (not an icon, not a window) blurs the focused
// window — the familiar "click empty desktop to deselect" affordance.
function onSurfaceClick(e: MouseEvent) {
if (e.currentTarget === e.target) wm.blur()
}
// Right-click menu, bare desktop only (same currentTarget===target gate as
// onSurfaceClick above — icons and windows sit on pointer-events-auto
// layers above the otherwise pointer-events-none surface, so a right-click
// that lands on either of them never reaches here). canUndo/canRedo are
// plain wmkit method calls (not stores), so they're snapshotted once at
// open time rather than read reactively in the template.
let menuPos = $state<{ x: number; y: number } | null>(null)
let menuCanUndo = $state(false)
let menuCanRedo = $state(false)
function onSurfaceContextMenu(e: MouseEvent) {
if (e.currentTarget !== e.target) return
e.preventDefault()
menuCanUndo = wm.canUndo()
menuCanRedo = wm.canRedo()
menuPos = { x: e.clientX, y: e.clientY }
}
function closeMenu() {
menuPos = null
}
function runMenuAction(fn: () => void) {
fn()
closeMenu()
}
// Cmd/Ctrl+Z / Shift+Z for window-arrangement undo/redo (move, resize,
// close, ...) — wmkit tracks this history but ships no default keybinding.
// Skipped entirely while an editable element has focus so it never
// fights the browser's own text-undo inside the task input or a form
// field.
function onWindowKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && menuPos) {
closeMenu()
return
}
const target = e.target as HTMLElement | null
const editable = !!target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
if (editable) return
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'z') return
e.preventDefault()
if (e.shiftKey) wm.redo()
else wm.undo()
}
</script>
<svelte:window onkeydown={onWindowKeydown} onclick={closeMenu} />
<div class="fixed inset-0 flex flex-col">
<div
class="relative min-h-0 flex-1 overflow-hidden"
role="presentation"
onclick={onSurfaceClick}
oncontextmenu={onSurfaceContextMenu}
>
<GraphBackground />
<div class="pointer-events-none absolute inset-0 z-0">
{#each APPS as app (app.id)}
{@const pos = $iconPositions[app.id] ?? { col: 0, row: 0 }}
{@const badge = app.badge?.($summary) ?? 0}
<DesktopIcon {app} {pos} {badge} onOpen={() => openAppWindow(app.id)} />
{/each}
</div>
<div class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-6">
<div class="pointer-events-auto">
<TaskLauncher />
</div>
</div>
<WindowLayer />
</div>
<Taskbar {onOpenConnection} />
</div>
{#if menuPos}
<div
class="fixed z-50 min-w-48 rounded-md border bg-popover p-1 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10"
style="left: {menuPos.x}px; top: {menuPos.y}px"
role="menu"
>
<button
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
onclick={() => runMenuAction(() => wm.arrange('cascade'))}
>
<LayersIcon class="size-4" /> Cascade windows
</button>
<button
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
onclick={() => runMenuAction(() => wm.arrange('tile'))}
>
<Rows3Icon class="size-4" /> Tile windows
</button>
<button
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
onclick={() => runMenuAction(toggleShowDesktop)}
>
<MonitorIcon class="size-4" /> Show desktop
</button>
<div class="my-1 h-px bg-border"></div>
<button
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
onclick={() => runMenuAction(resetIconLayout)}
>
<RotateCcwIcon class="size-4" /> Reset icon layout
</button>
<div class="my-1 h-px bg-border"></div>
<button
type="button"
disabled={!menuCanUndo}
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
onclick={() => runMenuAction(() => wm.undo())}
>
<Undo2Icon class="size-4" /> Undo
</button>
<button
type="button"
disabled={!menuCanRedo}
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
onclick={() => runMenuAction(() => wm.redo())}
>
<Redo2Icon class="size-4" /> Redo
</button>
</div>
{/if}

View File

@@ -0,0 +1,100 @@
<script lang="ts">
// A single desktop icon: positioned from the grid store, draggable to any
// free cell, opens its app on a plain click. wmkit has nothing to do with
// icons — they're a flat non-overlapping grid, not floating/resizable
// windows, so this is a small self-contained pointer-drag implementation
// rather than pressing wmkit's window abstractions into a shape they don't
// fit. See $lib/stores/icons.ts for the grid model + persistence.
import { GRID, iconPixelPos, placeIcon, type IconPos } from '$lib/stores/icons'
import type { AppDef } from '$lib/apps'
let {
app,
pos,
badge = 0,
onOpen
}: { app: AppDef; pos: IconPos; badge?: number; onOpen: () => void } = $props()
const DRAG_THRESHOLD = 5
let dragging = $state(false)
let dragPos = $state<{ x: number; y: number } | null>(null)
function toCell(x: number, y: number): IconPos {
return {
col: Math.round((x - GRID.padding) / (GRID.cell + GRID.gap)),
row: Math.round((y - GRID.padding) / (GRID.cell + GRID.gap))
}
}
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return
const el = e.currentTarget as HTMLElement
const startX = e.clientX
const startY = e.clientY
const origin = iconPixelPos(pos)
let moved = false
el.setPointerCapture(e.pointerId)
function onMove(ev: PointerEvent) {
const dx = ev.clientX - startX
const dy = ev.clientY - startY
if (!moved && Math.hypot(dx, dy) > DRAG_THRESHOLD) {
moved = true
dragging = true
}
if (moved) {
dragPos = { x: origin.x + dx, y: origin.y + dy }
}
}
function onUp() {
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', onUp)
if (moved && dragPos) {
const cell = toCell(dragPos.x, dragPos.y)
placeIcon(app.id, cell.col, cell.row)
} else {
onOpen()
}
dragging = false
dragPos = null
}
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', onUp)
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onOpen()
}
}
const restPos = $derived(iconPixelPos(pos))
const left = $derived(dragging && dragPos ? dragPos.x : restPos.x)
const top = $derived(dragging && dragPos ? dragPos.y : restPos.y)
</script>
<button
type="button"
class="group absolute flex flex-col items-center gap-1 rounded-lg p-1.5 pointer-events-auto select-none focus-visible:outline-2 focus-visible:outline-ring {dragging
? 'z-50 cursor-grabbing bg-accent/40'
: 'cursor-pointer hover:bg-accent/30'}"
style="left: {left}px; top: {top}px; width: {GRID.cell}px;"
onpointerdown={onPointerDown}
onkeydown={onKeydown}
title={app.title}
>
<span class="relative flex size-10 items-center justify-center rounded-xl border bg-card/80 text-foreground shadow-sm backdrop-blur">
<app.icon class="size-5" />
{#if badge > 0}
<span class="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground">
{badge > 99 ? '99+' : badge}
</span>
{/if}
</span>
<span class="max-w-full truncate text-[11px] text-foreground/90">{app.title}</span>
</button>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
// "What should Nomos do?" — the desktop's centerpiece. Extracted from the
// old Overview page hero so both the desktop surface and the Tasks app
// window can mount it; startTask() (see $lib/stores/chat.ts) begins the
// stream immediately and hands back the session id once the backend
// assigns one, which is the earliest point a task window can be opened.
import { startTask } from '$lib/stores/chat'
import { openTaskWindow } from '$lib/stores/windows'
import { truncateMiddle } from '$lib/utils'
import { Textarea } from '$lib/components/ui/textarea'
import { Button } from '$lib/components/ui/button'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
let { compact = false, onStarted }: { compact?: boolean; onStarted?: () => void } = $props()
let input = $state('')
function submit() {
const text = input.trim()
if (!text) return
input = ''
startTask(text, (sessionId) => openTaskWindow(sessionId, truncateMiddle(text, 60)))
onStarted?.()
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}
</script>
<div class="w-full max-w-2xl text-center">
{#if !compact}
<h1 class="mb-1 text-2xl font-semibold tracking-tight">What should Nomos do?</h1>
<p class="mb-4 text-sm text-muted-foreground">
Describe a goal — Nomos will plan it, execute it, and report the outcome.
</p>
{/if}
<form
class="relative rounded-2xl border bg-card/70 shadow-lg backdrop-blur focus-within:border-primary/60"
onsubmit={(e) => {
e.preventDefault()
submit()
}}
>
<Textarea
bind:value={input}
onkeydown={handleKeydown}
placeholder="e.g. Roll the staging database back to last night's snapshot and verify the app is healthy…"
rows={compact ? 2 : 3}
class="max-h-52 min-h-24 resize-none border-0 bg-transparent px-4 py-3.5 text-base shadow-none focus-visible:ring-0"
/>
<div class="flex items-center justify-between px-3 pb-3">
<span class="text-[11px] text-muted-foreground">Enter to start · Shift+Enter for newline</span>
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Start task">
<ArrowUpIcon />
</Button>
</div>
</form>
</div>

View File

@@ -0,0 +1,131 @@
<script lang="ts">
// Bottom taskbar: a real flex row in the page layout (not an overlay), so
// windows can never be dragged/maximized underneath it — see Desktop.svelte
// for how the window layer's bounds are scoped to the surface above this.
// Shows a button for every open window (not just minimized ones — compare
// to the old MinimizedWindowsBar, which only ever showed minimized windows
// and gave no way to see/switch between windows that were merely
// unfocused), plus a system tray for theme/connection/version.
import { wm, wmState, toggleShowDesktop } from '$lib/stores/windows'
import { appById, appIdFromWindowId } from '$lib/apps'
import { summary } from '$lib/stores/context'
import { truncateMiddle } from '$lib/utils'
import { getTheme, toggleTheme, THEME_LABELS } from '$lib/stores/theme.svelte'
import { VERSION } from '$lib/version'
import MessageSquareIcon from '@lucide/svelte/icons/message-square'
import DatabaseIcon from '@lucide/svelte/icons/database'
import PaletteIcon from '@lucide/svelte/icons/palette'
import SettingsIcon from '@lucide/svelte/icons/settings'
import LayoutGridIcon from '@lucide/svelte/icons/layout-grid'
import XIcon from '@lucide/svelte/icons/x'
let { onOpenConnection }: { onOpenConnection: () => void } = $props()
const SESSION_PREFIX = 'session:'
const buttons = $derived(
[...$wmState.order]
.map((id) => $wmState.windows[id])
.filter((w): w is NonNullable<typeof w> => !!w)
.sort((a, b) => a.openedSeq - b.openedSeq)
)
function iconFor(id: string) {
const appId = appIdFromWindowId(id)
if (appId) return appById.get(appId)?.icon
if (id.startsWith(SESSION_PREFIX)) return MessageSquareIcon
return DatabaseIcon
}
function badgeFor(id: string): number {
const appId = appIdFromWindowId(id)
const app = appId ? appById.get(appId) : undefined
return app?.badge?.($summary) ?? 0
}
function toggle(id: string, win: (typeof buttons)[number]) {
if (win.stage === 'minimized') {
wm.restore(id)
wm.focus(id)
} else if ($wmState.focusedId === id) {
wm.minimize(id)
} else {
wm.focus(id)
}
}
</script>
<div class="flex h-11 shrink-0 items-center gap-1.5 border-t bg-muted/30 px-2">
<button
type="button"
class="flex shrink-0 items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={toggleShowDesktop}
title="Show desktop"
>
<LayoutGridIcon class="size-4" />
</button>
<div class="h-6 w-px shrink-0 bg-border"></div>
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
{#each buttons as win (win.id)}
{@const Icon = iconFor(win.id)}
{@const badge = badgeFor(win.id)}
<div class="group/tb relative flex shrink-0">
<button
type="button"
data-taskbar-btn={win.id}
class="flex h-8 max-w-56 items-center gap-1.5 rounded-md border px-2 font-mono text-xs transition-colors {$wmState.focusedId ===
win.id && win.stage !== 'minimized'
? 'border-primary/50 bg-primary/10 text-foreground'
: 'border-transparent bg-card/60 text-muted-foreground hover:bg-muted'} {win.stage === 'minimized' ? 'opacity-60' : ''}"
onclick={() => toggle(win.id, win)}
title={win.title}
>
{#if Icon}<Icon class="size-3.5 shrink-0" />{/if}
<span class="min-w-0 truncate">{truncateMiddle(win.title, 26)}</span>
{#if badge > 0}
<span class="flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-semibold text-destructive-foreground">
{badge > 99 ? '99+' : badge}
</span>
{/if}
</button>
<button
type="button"
class="absolute -top-1.5 -right-1.5 hidden size-4 items-center justify-center rounded-full bg-muted-foreground/80 text-background hover:bg-destructive group-hover/tb:flex"
onclick={(e) => {
e.stopPropagation()
wm.close(win.id)
}}
aria-label="Close {win.title}"
>
<XIcon class="size-2.5" />
</button>
</div>
{/each}
</div>
<div class="h-6 w-px shrink-0 bg-border"></div>
<div class="flex shrink-0 items-center gap-0.5">
<button
type="button"
class="flex items-center justify-center gap-1.5 rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={() => toggleTheme()}
title="Cycle theme"
>
<PaletteIcon class="size-4" />
<span class="hidden text-xs sm:inline">{THEME_LABELS[getTheme()]}</span>
</button>
<button
type="button"
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={onOpenConnection}
title="Server connection settings"
>
<SettingsIcon class="size-4" />
</button>
<span class="px-1.5 text-[11px] text-muted-foreground select-none">{VERSION}</span>
</div>
</div>

View File

@@ -0,0 +1,89 @@
<script lang="ts">
// The floating-window layer — mounted once inside Desktop.svelte, above the
// icons layer, so every window (an app, a task, an entity detail) shares
// one stack instead of each page owning its own single-entity
// sidebar/sheet. See $lib/stores/windows.ts. Content is resolved purely
// from the window's id, which is why persisted/hydrated windows (see
// wmPersist in windows.ts) need no extra bookkeeping to know what to render:
// app:<id> -> registry component (windows.ts openAppWindow)
// session:<id> -> SessionChatWindow (windows.ts openTaskWindow)
// new-task -> TaskLauncher (windows.ts openNewTaskWindow)
// anything else -> entity slug -> EntityDetailContent
import { wm, dk, wmState, openEntityWindow, NEW_TASK_WINDOW_ID } from '$lib/stores/windows'
import { appById, appIdFromWindowId } from '$lib/apps'
import EntityDetailContent from '../EntityDetailContent.svelte'
import SessionChatWindow from '../SessionChatWindow.svelte'
import TaskLauncher from './TaskLauncher.svelte'
import XIcon from '@lucide/svelte/icons/x'
import MinusIcon from '@lucide/svelte/icons/minus'
import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
const SESSION_PREFIX = 'session:'
// A hydrated `app:<id>` window whose id no longer matches any registry
// entry (the app was renamed/removed since the layout was persisted) has
// nothing to render — close it rather than leaving a permanently-blank
// window stuck in the taskbar.
$effect(() => {
for (const id of $wmState.order) {
const appId = appIdFromWindowId(id)
if (appId && !appById.has(appId)) wm.close(id)
}
})
</script>
<div use:dk.desktop class="absolute inset-0 z-40 pointer-events-none">
{#each $wmState.order as id (id)}
{@const win = $wmState.windows[id]}
{@const appId = appIdFromWindowId(id)}
{@const app = appId ? appById.get(appId) : undefined}
{#if win && (!appId || app)}
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
<header data-wm-drag class="flex shrink-0 cursor-move items-center justify-between gap-2 border-b bg-muted/40 px-3 py-1.5">
<span data-wm-title class="flex min-w-0 flex-1 items-center self-stretch truncate font-mono text-xs font-medium">{win.title}</span>
<div class="flex shrink-0 items-center gap-0.5">
<button
type="button"
data-wm-minimize
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Minimize {win.title}"
>
<MinusIcon class="size-3.5" />
</button>
<button
type="button"
data-wm-maximize
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Maximize {win.title}"
>
<Maximize2Icon class="size-3.5" />
</button>
<button
type="button"
data-wm-close
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
aria-label="Close {win.title}"
>
<XIcon class="size-3.5" />
</button>
</div>
</header>
<div data-wm-content class="min-h-0 flex-1 overflow-hidden">
{#key id}
{#if id.startsWith(SESSION_PREFIX)}
<SessionChatWindow sessionId={id.slice(SESSION_PREFIX.length)} />
{:else if id === NEW_TASK_WINDOW_ID}
<div class="flex h-full items-center justify-center p-6">
<TaskLauncher onStarted={() => wm.close(NEW_TASK_WINDOW_ID)} />
</div>
{:else if app}
<app.component />
{:else}
<EntityDetailContent slug={id} onSelectEntity={openEntityWindow} />
{/if}
{/key}
</div>
</section>
{/if}
{/each}
</div>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from 'bits-ui'
import CheckIcon from '@lucide/svelte/icons/check'
import MinusIcon from '@lucide/svelte/icons/minus'
import { cn } from '$lib/utils.js'
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: CheckboxPrimitive.RootProps = $props()
</script>
<CheckboxPrimitive.Root
bind:ref
bind:checked
bind:indeterminate
data-slot="checkbox"
class={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div data-slot="checkbox-indicator" class="flex items-center justify-center text-current transition-none">
{#if indeterminate}
<MinusIcon class="size-3.5" />
{:else if checked}
<CheckIcon class="size-3.5" />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>

View File

@@ -0,0 +1 @@
export { default as Checkbox } from './checkbox.svelte'

View File

@@ -0,0 +1 @@
export { default as Switch } from './switch.svelte'

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { Switch as SwitchPrimitive } from 'bits-ui'
import { cn } from '$lib/utils.js'
let {
ref = $bindable(null),
checked = $bindable(false),
class: className,
...restProps
}: SwitchPrimitive.RootProps = $props()
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
data-slot="switch"
class={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
class="bg-background pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
/>
</SwitchPrimitive.Root>

View File

@@ -1,6 +1,7 @@
import { derived } from 'svelte/store'
import { messages, type ToolCallResult } from './chat'
import { planSteps, currentTask } from './workspace'
import { derived, type Readable } from 'svelte/store'
import { messages, chatFor, type ChatMessage, type ToolCallResult } from './chat'
import { planSteps, currentTask, workspaceFor, taskFor } from './workspace'
import type { PlanStep, Session } from '$lib/api'
export { type ToolCallResult }
@@ -39,7 +40,10 @@ function stringifyResult(result: unknown): string {
return s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}\n… truncated` : s
}
export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) => {
// Pure derivation, parameterized so it can back both the global "current
// session" activityLog below and a per-session activityLogFor(sessionId) for
// a floating task window.
function computeActivityLog($msgs: ChatMessage[], $steps: PlanStep[], $task: Session | null): ActivityEntry[] {
const entries: ActivityEntry[] = []
const now = Date.now()
@@ -168,7 +172,18 @@ export const activityLog = derived([messages, planSteps, currentTask], ([$msgs,
entries.sort((a, b) => a.timestamp - b.timestamp)
return entries
})
}
export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) =>
computeActivityLog($msgs, $steps, $task)
)
export function activityLogFor(sessionId: string): Readable<ActivityEntry[]> {
const chat = chatFor(sessionId)
const ws = workspaceFor(sessionId)
const task = taskFor(sessionId)
return derived([chat.messages, ws.planSteps, task], ([$msgs, $steps, $task]) => computeActivityLog($msgs, $steps, $task))
}
function toolActivityLabel(t: ToolCallResult): string {
const args = t.args ?? {}

View File

@@ -1,5 +1,5 @@
import { writable, get } from 'svelte/store'
import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
import { writable, get, type Writable } from 'svelte/store'
import { streamChat, fetchSessions, fetchMessages, fetchMessagesOrNotFound, deleteSession as apiDeleteSession } from '$lib/api'
import type { ChatEvent, Session, Message } from '$lib/api'
import type { ToolCallResult } from '$lib/types'
@@ -481,3 +481,301 @@ export async function deleteSession(sessionId: string) {
}
loadSessions()
}
// ─── per-session chat state, for floating task windows ─────────────────────
//
// Everything above this point is the single "whatever's on screen" view used
// by the main Chat page and the chat drawer — one global `currentSession`,
// one `messages` array, guarded so a background stream never clobbers the
// view. Floating task windows break that assumption: several sessions can be
// open and legitimately streaming at once, each wanting its own live
// transcript. Rather than retrofit the guard-heavy logic above (streamed
// events checking `get(currentSession) === streamSessionID` before applying),
// each window gets its own isolated store bundle keyed by session id, so
// there's nothing to guard — events for session X always land in X's own
// bundle regardless of what else is open or on screen.
export interface SessionChatState {
messages: Writable<ChatMessage[]>
streaming: Writable<boolean>
connectionState: Writable<'connected' | 'disconnected' | 'reconnecting'>
error: Writable<string | null>
// Set by loadSessionChat when the backend 404s the session outright
// (deleted, or an id that was never valid — a stale persisted window, a
// bad deep link). Distinct from a merely-empty transcript, which is the
// normal state for a session that exists but hasn't sent a message yet.
notFound: Writable<boolean>
}
const sessionChats = new Map<string, SessionChatState>()
const sessionPollers = new Map<string, ReturnType<typeof setInterval>>()
// Lazily creates (and memoizes) the store bundle for a session — call this to
// get the stores to subscribe to; it does not fetch anything.
export function chatFor(sessionId: string): SessionChatState {
let c = sessionChats.get(sessionId)
if (!c) {
c = {
messages: writable([]),
streaming: writable(false),
connectionState: writable('connected'),
error: writable(null),
notFound: writable(false)
}
sessionChats.set(sessionId, c)
}
return c
}
function startSessionPolling(sessionId: string) {
const existing = sessionPollers.get(sessionId)
if (existing) clearInterval(existing)
const chat = chatFor(sessionId)
sessionPollers.set(
sessionId,
setInterval(async () => {
if (get(chat.streaming) && get(chat.connectionState) === 'connected') return
const msgs = await fetchMessages(sessionId)
if (get(chat.streaming)) return // re-check: the fetch itself takes time
chat.messages.set(toChatMessages(msgs))
}, 3000)
)
}
export function stopSessionPolling(sessionId: string) {
const t = sessionPollers.get(sessionId)
if (t) {
clearInterval(t)
sessionPollers.delete(sessionId)
}
}
// Fetches sessionId's current transcript into its own store bundle and
// starts polling it for auto-continuation updates — the per-session
// equivalent of loadSessionMessages, for a window rather than the main view.
export async function loadSessionChat(sessionId: string): Promise<void> {
const chat = chatFor(sessionId)
chat.streaming.set(false)
const msgs = await fetchMessagesOrNotFound(sessionId)
if (msgs === null) {
chat.notFound.set(true)
return // nothing to poll — the session doesn't exist
}
chat.messages.set(toChatMessages(msgs))
startSessionPolling(sessionId)
}
// Per-session equivalent of sendMessage — writes into sessionId's own store
// bundle unconditionally (no "is this still on screen" guard needed, since
// the bundle IS the screen for this session's window) and shares
// `activeControllers` with the singleton path above so cancelStream() from
// either a window or the main view (if the same session happens to be open
// in both) finds the same in-flight call.
export function sendSessionMessage(sessionId: string, text: string) {
const chat = chatFor(sessionId)
chat.error.set(null)
chat.streaming.set(true)
const userMsg: ChatMessage = { id: mid(), role: 'user', text, tools: [], pendingApprovals: [] }
chat.messages.update((ms) => [...ms, userMsg])
const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] }
chat.messages.update((ms) => [...ms, assistantMsg])
let activeTools: Map<string, ToolCallResult> = new Map()
let receivedDone = false
const controller = streamChat(
text,
sessionId,
(ev: ChatEvent) => {
if (ev.type === 'session') return // sessionId is already known for a window
if (ev.type === 'tool_use') {
const tr: ToolCallResult = { type: 'tool_use', name: ev.data.name, id: ev.data.id, args: ev.data.args }
activeTools.set(ev.data.id, tr)
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.tools = [...last.tools, tr]
return [...ms]
})
} else if (ev.type === 'tool_result') {
const existing = activeTools.get(ev.data.id)
if (existing) {
const updated: ToolCallResult = { ...existing, type: 'tool_result', result: ev.data.result, error: ev.data.error }
activeTools.set(ev.data.id, updated)
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
last.pendingApprovals = extractApprovals(last.tools)
}
return [...ms]
})
}
} else if (ev.type === 'text_delta') {
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text += ev.data
return [...ms]
})
} else if (ev.type === 'text') {
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text = ev.data
return [...ms]
})
} else if (ev.type === 'done') {
receivedDone = true
chat.connectionState.set('connected')
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.pendingApprovals = extractApprovals(last.tools)
return [...ms]
})
startSessionPolling(sessionId)
} else if (ev.type === 'error') {
chat.error.set(ev.data)
}
},
(err: string) => {
if (err === 'AbortError' || err.includes('aborted')) {
chat.streaming.set(false)
return
}
chat.error.set(err)
if (!receivedDone) {
chat.connectionState.set('disconnected')
startSessionPolling(sessionId)
addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
} else {
chat.streaming.set(false)
}
},
() => {
chat.streaming.set(false)
if (activeControllers.get(sessionId) === controller) activeControllers.delete(sessionId)
loadSessions()
}
)
activeControllers.set(sessionId, controller)
}
export function cancelSessionStream(sessionId: string) {
const controller = activeControllers.get(sessionId)
if (!controller) return
controller.abort()
activeControllers.delete(sessionId)
chatFor(sessionId).streaming.set(false)
}
// ─── new-task launcher (desktop center input / Tasks app) ───────────────────
//
// Starting a brand-new task has no session id to hang a window off of until
// the stream's own 'session' event assigns one (see the 'session' branch in
// sendMessage above) — the desktop launcher needs to open that task's window
// the moment an id exists, not before. startTask begins the stream
// immediately, buffers any events that arrive before 'session' (defensive:
// in practice 'session' always arrives first), then seeds that session's own
// chatFor() bundle exactly like sendSessionMessage does and hands the id back
// via onSession so the caller can open its window. From that point on the
// window behaves exactly like any other task window.
export function startTask(text: string, onSession: (sessionId: string) => void): void {
const userMsg: ChatMessage = { id: mid(), role: 'user', text, tools: [], pendingApprovals: [] }
const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] }
const activeTools: Map<string, ToolCallResult> = new Map()
let receivedDone = false
let sessionId: string | null = null
let chat: SessionChatState | null = null
const buffered: ChatEvent[] = []
function apply(ev: ChatEvent) {
const c = chat
if (!c || !sessionId) return
if (ev.type === 'tool_use') {
const tr: ToolCallResult = { type: 'tool_use', name: ev.data.name, id: ev.data.id, args: ev.data.args }
activeTools.set(ev.data.id, tr)
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.tools = [...last.tools, tr]
return [...ms]
})
} else if (ev.type === 'tool_result') {
const existing = activeTools.get(ev.data.id)
if (existing) {
const updated: ToolCallResult = { ...existing, type: 'tool_result', result: ev.data.result, error: ev.data.error }
activeTools.set(ev.data.id, updated)
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
last.pendingApprovals = extractApprovals(last.tools)
}
return [...ms]
})
}
} else if (ev.type === 'text_delta') {
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text += ev.data
return [...ms]
})
} else if (ev.type === 'text') {
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text = ev.data
return [...ms]
})
} else if (ev.type === 'done') {
receivedDone = true
c.connectionState.set('connected')
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.pendingApprovals = extractApprovals(last.tools)
return [...ms]
})
startSessionPolling(sessionId)
} else if (ev.type === 'error') {
c.error.set(ev.data)
}
}
const controller = streamChat(
text,
null,
(ev: ChatEvent) => {
if (ev.type === 'session') {
sessionId = ev.data
activeControllers.set(sessionId, controller)
chat = chatFor(sessionId)
chat.streaming.set(true)
chat.messages.update((ms) => [...ms, userMsg, assistantMsg])
onSession(sessionId)
for (const b of buffered.splice(0)) apply(b)
return
}
if (!chat) {
buffered.push(ev)
return
}
apply(ev)
},
(err: string) => {
if (!chat) return // never got a session id — nothing to show the error in
if (err === 'AbortError' || err.includes('aborted')) {
chat.streaming.set(false)
return
}
chat.error.set(err)
if (!receivedDone && sessionId) {
chat.connectionState.set('disconnected')
startSessionPolling(sessionId)
addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
} else {
chat.streaming.set(false)
}
},
() => {
if (chat) chat.streaming.set(false)
if (sessionId && activeControllers.get(sessionId) === controller) activeControllers.delete(sessionId)
loadSessions()
}
)
}

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// icons.ts only needs APPS for its default-layout ids — stub it out rather
// than pull in the real registry's full page-component graph (see
// apps.test.ts for why that graph is expensive/broken under vitest).
vi.mock('$lib/apps', () => ({
APPS: [{ id: 'tasks' }, { id: 'kb' }, { id: 'ops' }, { id: 'signals' }, { id: 'knowledge' }, { id: 'learning' }]
}))
import { GRID, iconPositions, placeIcon, iconPixelPos, maxCols, getIconPositions, resetIconLayout } from './icons'
// placeIcon mutates the shared module-level store, so each test starts from
// a known, empty layout rather than whatever the previous test (or apps.ts's
// registry-derived defaults) left behind.
beforeEach(() => {
iconPositions.set({})
localStorage.clear()
})
describe('iconPixelPos', () => {
it('converts a grid cell to pixel coordinates using GRID constants', () => {
expect(iconPixelPos({ col: 0, row: 0 })).toEqual({ x: GRID.padding, y: GRID.padding })
expect(iconPixelPos({ col: 1, row: 2 })).toEqual({
x: GRID.padding + (GRID.cell + GRID.gap),
y: GRID.padding + 2 * (GRID.cell + GRID.gap)
})
})
})
describe('maxCols', () => {
it('computes how many columns fit in a viewport width', () => {
const cellSpan = GRID.cell + GRID.gap
expect(maxCols(GRID.padding + cellSpan * 3)).toBe(3)
})
it('never returns less than 1, even for a tiny viewport', () => {
expect(maxCols(0)).toBe(1)
expect(maxCols(GRID.padding)).toBe(1)
})
})
describe('placeIcon', () => {
it('places an icon at the requested cell when it is free', () => {
placeIcon('tasks', 3, 4)
expect(getIconPositions().tasks).toEqual({ col: 3, row: 4 })
})
it('clamps negative coordinates to 0 for an otherwise-free cell', () => {
placeIcon('tasks', -5, -2)
expect(getIconPositions().tasks).toEqual({ col: 0, row: 0 })
})
it('nudges to the nearest free cell when the target is occupied', () => {
iconPositions.set({ kb: { col: 2, row: 2 } })
placeIcon('tasks', 2, 2)
const pos = getIconPositions().tasks
// Must not land on top of kb, and must be one of the 8 immediate
// neighbors (radius-1 ring) since all of them are free.
expect(pos).not.toEqual({ col: 2, row: 2 })
expect(Math.max(Math.abs(pos.col - 2), Math.abs(pos.row - 2))).toBe(1)
})
it('does not disturb the icon already occupying a cell when another icon is nudged past it', () => {
iconPositions.set({ kb: { col: 2, row: 2 } })
placeIcon('tasks', 2, 2)
expect(getIconPositions().kb).toEqual({ col: 2, row: 2 })
})
it('moving an icon back onto its own current cell is a no-op collision (never nudges against itself)', () => {
iconPositions.set({ tasks: { col: 5, row: 5 } })
placeIcon('tasks', 5, 5)
expect(getIconPositions().tasks).toEqual({ col: 5, row: 5 })
})
it('persists the updated layout to localStorage', () => {
placeIcon('tasks', 1, 1)
const stored = JSON.parse(localStorage.getItem('oikos-desktop-icons') ?? '{}')
expect(stored.tasks).toEqual({ col: 1, row: 1 })
})
})
describe('resetIconLayout', () => {
it('restores the classic left-edge column in registry order', () => {
iconPositions.set({ tasks: { col: 4, row: 7 }, kb: { col: 1, row: 1 } })
resetIconLayout()
expect(getIconPositions()).toEqual({
tasks: { col: 0, row: 0 },
kb: { col: 0, row: 1 },
ops: { col: 0, row: 2 },
signals: { col: 0, row: 3 },
knowledge: { col: 0, row: 4 },
learning: { col: 0, row: 5 }
})
})
})

113
web/src/lib/stores/icons.ts Normal file
View File

@@ -0,0 +1,113 @@
// Desktop icon positions — a simple column/row grid, persisted to
// localStorage so icons stay where the operator put them across reloads.
// Deliberately NOT wmkit: wmkit manages floating windows (pixel bounds,
// z-order, stage), icons are a flat, non-overlapping grid with a much
// simpler drag model (snap-to-cell, no resize/minimize/stack). Reinventing
// that inside wmkit would mean fighting its window-shaped abstractions for
// no benefit.
import { writable, get } from 'svelte/store'
import { APPS } from '$lib/apps'
export interface IconPos {
col: number
row: number
}
export const GRID = { cell: 96, gap: 12, padding: 16 }
const STORAGE_KEY = 'oikos-desktop-icons'
function defaultPositions(): Record<string, IconPos> {
// Classic OS default: one left-edge column, registry order.
const out: Record<string, IconPos> = {}
APPS.forEach((app, i) => {
out[app.id] = { col: 0, row: i }
})
return out
}
function load(): Record<string, IconPos> {
if (typeof localStorage === 'undefined') return defaultPositions()
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return defaultPositions()
const parsed = JSON.parse(raw) as Record<string, IconPos>
const out = defaultPositions()
// Merge over the defaults so a newly-registered app (not in the saved
// blob yet) still gets a sane starting position instead of being absent
// from the grid entirely.
for (const [id, pos] of Object.entries(parsed)) {
if (appIds.has(id)) out[id] = pos
}
return out
} catch {
return defaultPositions()
}
}
const appIds = new Set(APPS.map((a) => a.id))
export const iconPositions = writable<Record<string, IconPos>>(load())
function persist(positions: Record<string, IconPos>): void {
if (typeof localStorage === 'undefined') return
localStorage.setItem(STORAGE_KEY, JSON.stringify(positions))
}
iconPositions.subscribe((positions) => persist(positions))
function occupied(positions: Record<string, IconPos>, col: number, row: number, exceptId: string): boolean {
return Object.entries(positions).some(([id, p]) => id !== exceptId && p.col === col && p.row === row)
}
// Finds the nearest free cell to (col, row) via an expanding ring search,
// so dropping an icon onto an occupied cell nudges it to the closest open
// spot instead of silently overlapping or refusing the drop.
function nearestFreeCell(
positions: Record<string, IconPos>,
col: number,
row: number,
exceptId: string
): IconPos {
if (!occupied(positions, col, row, exceptId)) return { col: Math.max(0, col), row: Math.max(0, row) }
for (let radius = 1; radius < 64; radius++) {
for (let dc = -radius; dc <= radius; dc++) {
for (let dr = -radius; dr <= radius; dr++) {
if (Math.max(Math.abs(dc), Math.abs(dr)) !== radius) continue
const c = col + dc
const r = row + dr
if (c < 0 || r < 0) continue
if (!occupied(positions, c, r, exceptId)) return { col: c, row: r }
}
}
}
return { col: Math.max(0, col), row: Math.max(0, row) }
}
export function placeIcon(appId: string, col: number, row: number): void {
iconPositions.update((positions) => {
const target = nearestFreeCell(positions, col, row, appId)
return { ...positions, [appId]: target }
})
}
export function iconPixelPos(pos: IconPos): { x: number; y: number } {
return {
x: GRID.padding + pos.col * (GRID.cell + GRID.gap),
y: GRID.padding + pos.row * (GRID.cell + GRID.gap)
}
}
export function maxCols(viewportWidth: number): number {
return Math.max(1, Math.floor((viewportWidth - GRID.padding) / (GRID.cell + GRID.gap)))
}
export function getIconPositions(): Record<string, IconPos> {
return get(iconPositions)
}
// Bails a messy manual layout back to the classic left-edge column,
// registry order — the desktop's right-click menu's "Reset icon layout".
export function resetIconLayout(): void {
iconPositions.set(defaultPositions())
}

View File

@@ -0,0 +1,126 @@
// One global wmkit window manager for the whole app (mounted once by
// WindowLayer.svelte inside Desktop.svelte) — this is what lets an entity
// opened from Knowledge Base, chat, or anywhere else land in the same
// floating window layer, with several windows open side by side, rather than
// each page owning its own single-entity sidebar/sheet.
import { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte'
import { persist } from '@surdeddd/wmkit/persist'
import { appById, appWindowId } from '$lib/apps'
import { sessions } from '$lib/stores/chat'
import { heading } from '$lib/tasks'
export const wm = createManager({ defaultSize: { width: 480, height: 560 } })
export const dk = createDesktop(wm, {
// topEdge:'maximize' + preview gives the classic drag-to-top-maximizes
// affordance; magnetism/keyboard are wmkit defaults worth turning on now
// that windows are the whole app's primary surface, not a secondary layer.
snap: { topEdge: 'maximize', preview: true },
keyboard: true,
magnetism: true,
// Animates a minimized window toward its taskbar button instead of just
// vanishing — Taskbar.svelte tags each button with this same attribute.
minimizeTarget: (win) => document.querySelector(`[data-taskbar-btn="${CSS.escape(win.id)}"]`)
})
export const wmState = wmStore(wm)
// Layout survives reloads: every window id is self-describing (app:<id>,
// session:<id>, or a bare entity slug — see EntityDesktop/WindowLayer's
// content branch), so a hydrated window needs no extra bookkeeping to know
// what to render once the desktop remounts.
export const wmPersist = persist(wm, { key: 'oikos-windows', debounce: 300, autoRestore: true })
// A task window is titled from the operator's (truncated) prompt at
// creation time — openTaskWindow below and chat.ts's startTask both only
// know the raw text, not the goal/heading the backend eventually derives
// for the session. Whenever the sessions list refreshes (loadSessions(),
// called all over — on task events, after a turn completes, ...) resync
// any open task window's title to the session's real heading, so the
// taskbar/titlebar stop showing the placeholder forever.
sessions.subscribe((list) => {
for (const s of list) {
const id = `session:${s.id}`
const win = wm.get(id)
if (!win) continue
const title = heading(s)
if (win.title !== title) wm.update(id, { title })
}
})
// Classic show-desktop toggle: minimize everything, or if everything's
// already minimized (a prior show-desktop, or the operator minimized them
// all by hand), bring them all back rather than being a one-way action.
// Shared by the taskbar button and the desktop's right-click menu.
export function toggleShowDesktop(): void {
const anyVisible = wm.getState().order.some((id) => wm.get(id)?.stage !== 'minimized')
if (anyVisible) wm.minimizeAll()
else wm.restoreAll()
}
// Opens (or focuses/restores) a registry app's window. Apps are
// single-instance — double-clicking an already-open app's icon should never
// stack a second window, same dedupe pattern as openEntityWindow below.
export function openAppWindow(appId: string): void {
const app = appById.get(appId)
if (!app) return
const id = appWindowId(appId)
if (wm.get(id)) {
wm.restore(id)
wm.focus(id)
return
}
wm.open({
id,
title: app.title,
width: app.width,
height: app.height,
minWidth: app.minWidth,
minHeight: app.minHeight
})
}
// Opens a window for the entity, or focuses (and restores, if minimized) the
// existing one — wm.open() throws if a window with this id already exists,
// and slugs make natural, stable window ids (also dedupes "same entity
// opened twice" into one window instead of stacking duplicates).
export function openEntityWindow(slug: string | null): void {
if (!slug) return
if (wm.get(slug)) {
wm.restore(slug)
wm.focus(slug)
return
}
wm.open({ id: slug, title: slug })
}
// Singleton "compose a new task" window — the Tasks app's New Task button
// opens this rather than a dialog, since everything else in the desktop is
// already a window; TaskLauncher closes it itself (via its onStarted
// callback, wired up in WindowLayer.svelte) once the task's session window
// takes over.
export const NEW_TASK_WINDOW_ID = 'new-task'
export function openNewTaskWindow(): void {
if (wm.get(NEW_TASK_WINDOW_ID)) {
wm.restore(NEW_TASK_WINDOW_ID)
wm.focus(NEW_TASK_WINDOW_ID)
return
}
wm.open({ id: NEW_TASK_WINDOW_ID, title: 'New task', width: 480, height: 340, minWidth: 360, minHeight: 280 })
}
// Same dedupe/restore/focus pattern as openEntityWindow, for a task/session's
// chat window. Id is namespaced `session:<id>` — distinct from entity window
// ids (always a bare `type:identifier` slug, and task ENTITIES already use
// `task:<uuid>` as their own slug) so a task's chat window and its entity
// detail window never collide over the same wmkit id. See
// WindowLayer.svelte for the id -> content-component branch.
export function openTaskWindow(sessionId: string | null, title: string): void {
if (!sessionId) return
const id = `session:${sessionId}`
if (wm.get(id)) {
wm.restore(id)
wm.focus(id)
return
}
wm.open({ id, title, width: 900, height: 640 })
}

View File

@@ -1,7 +1,7 @@
import { writable, derived, get } from 'svelte/store'
import { writable, derived, get, type Writable, type Readable } from 'svelte/store'
import { liveEvents, subscribeEvents } from './events'
import { currentSession, sessions, loadSessions } from './chat'
import { fetchPlan, fetchQuestions, type PlanStep, type SessionQuestion } from '$lib/api'
import { fetchPlan, fetchQuestions, type PlanStep, type SessionQuestion, type Session } from '$lib/api'
import type {
PlanProposedData,
PlanStepEventData,
@@ -21,16 +21,11 @@ import type {
// keeps updating across a tab reload: hydrate() re-fetches REST state, then
// live events carry deltas from there.
export const planSteps = writable<PlanStep[]>([])
export const questions = writable<SessionQuestion[]>([])
export const openQuestion = derived(questions, (qs) => qs.find((q) => q.status === 'open') ?? null)
export interface TouchedEntity {
slug: string
tool: string
ts: number
}
export const touched = writable<TouchedEntity[]>([])
const TOUCHED_MAX = 12
const TOUCHED_PULSE_MS = 6000
@@ -40,9 +35,35 @@ export interface HealthDiff {
to: string
ts: number
}
export const healthDiffs = writable<HealthDiff[]>([])
const HEALTH_DIFF_MS = 8000
export interface WorkspaceState {
planSteps: Writable<PlanStep[]>
questions: Writable<SessionQuestion[]>
openQuestion: Readable<SessionQuestion | null>
touched: Writable<TouchedEntity[]>
healthDiffs: Writable<HealthDiff[]>
}
function createWorkspaceState(): WorkspaceState {
const questions = writable<SessionQuestion[]>([])
return {
planSteps: writable<PlanStep[]>([]),
questions,
openQuestion: derived(questions, (qs) => qs.find((q) => q.status === 'open') ?? null),
touched: writable<TouchedEntity[]>([]),
healthDiffs: writable<HealthDiff[]>([])
}
}
// ─── global "current session" workspace — used by the main Chat page's rail ─
const globalWorkspace = createWorkspaceState()
export const planSteps = globalWorkspace.planSteps
export const questions = globalWorkspace.questions
export const openQuestion = globalWorkspace.openQuestion
export const touched = globalWorkspace.touched
export const healthDiffs = globalWorkspace.healthDiffs
// The task's own fields (goal/status/outcome/summary) live on the session row.
// Rather than a dedicated endpoint, derive from the sessions list (already
// fetched for the task board) and keep it fresh here on task-lifecycle events.
@@ -50,33 +71,21 @@ export const currentTask = derived([sessions, currentSession], ([$sessions, $id]
$sessions.find((s) => s.id === $id) ?? null
)
// Events that can change agent_sessions.status/goal/outcome — see applyEvent.
// Events that can change agent_sessions.status/goal/outcome — see applyEventTo.
const STATUS_AFFECTING = new Set([
'goal.set', 'task.status', 'plan.proposed', 'question.raised', 'question.answered'
])
let hydratedFor: string | null = null
let unsubStream: (() => void) | null = null
let unsubLive: (() => void) | null = null
let refreshTimer: ReturnType<typeof setTimeout> | null = null
let lastSeenId = 0
async function hydrate(sessionId: string) {
hydratedFor = sessionId
planSteps.set([])
questions.set([])
touched.set([])
healthDiffs.set([])
const [steps, qs] = await Promise.all([fetchPlan(sessionId), fetchQuestions(sessionId)])
if (get(currentSession) !== sessionId) return // switched away while loading
planSteps.set(steps)
questions.set(qs)
function scheduleSessionsRefresh() {
if (refreshTimer) clearTimeout(refreshTimer)
refreshTimer = setTimeout(() => loadSessions(), 300)
}
function applyPlanStepEvent(sessionId: string, type: string, data: PlanStepEventData) {
function applyPlanStepEventTo(ws: WorkspaceState, data: PlanStepEventData) {
const stepID = data?.step_id
const seq = data?.seq
planSteps.update((steps) => {
ws.planSteps.update((steps) => {
const i = steps.findIndex((s) => (stepID && s.id === stepID) || (seq != null && s.seq === seq))
if (i === -1) return steps
const next = [...steps]
@@ -85,9 +94,11 @@ function applyPlanStepEvent(sessionId: string, type: string, data: PlanStepEvent
})
}
function applyEvent(ev: { type: string; correlation_id?: string | null; data?: unknown }) {
const sid = get(currentSession)
if (!sid || ev.correlation_id !== sid) return
// Applies a live event to `ws` if it belongs to session `sid` — shared by the
// global "current session" watcher and every per-session floating-window
// watcher, each passing its own target state and session id.
function applyEventTo(ws: WorkspaceState, sid: string, ev: { type: string; correlation_id?: string | null; data?: unknown }) {
if (ev.correlation_id !== sid) return
const data = (ev.data ?? {}) as Record<string, unknown>
// Task fields (status/goal/outcome) live on the session row — refetch the
@@ -100,11 +111,9 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
// server-side, with no client-streaming 'done' event to piggyback a refresh
// on (found live: answering a question via the panel left the header stuck
// on "Needs your input" after the agent had already resumed). Debounced
// since several of these can land in one burst.
if (STATUS_AFFECTING.has(ev.type)) {
if (refreshTimer) clearTimeout(refreshTimer)
refreshTimer = setTimeout(() => loadSessions(), 300)
}
// since several of these can land in one burst, and shared across
// sessions since it just refreshes the one global session list.
if (STATUS_AFFECTING.has(ev.type)) scheduleSessionsRefresh()
switch (ev.type) {
case 'plan.proposed': {
@@ -114,17 +123,17 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
id: s.id, seq: s.seq, title: s.title, detail: s.detail ?? '',
status: 'pending' as const, target_slug: s.target_slug || undefined
}))
planSteps.update((existing) => (d.appended ? [...existing, ...incoming] : incoming))
ws.planSteps.update((existing) => (d.appended ? [...existing, ...incoming] : incoming))
}
break
}
case 'plan.step.started':
case 'plan.step.finished':
applyPlanStepEvent(sid, ev.type, data as unknown as PlanStepEventData)
applyPlanStepEventTo(ws, data as unknown as PlanStepEventData)
break
case 'question.raised': {
const d = data as unknown as QuestionRaisedData
questions.update((qs) => [
ws.questions.update((qs) => [
{
id: d.question_id, prompt: d.prompt ?? '',
context: { why: d.why, options: d.options, entities: d.entities },
@@ -136,7 +145,7 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
}
case 'question.answered': {
const d = data as unknown as QuestionAnsweredData
questions.update((qs) =>
ws.questions.update((qs) =>
qs.map((q) => (q.id === d.question_id ? { ...q, status: 'answered', answer: d.answer } : q))
)
break
@@ -145,7 +154,7 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
const d = data as unknown as EntityTouchedData
if (d.slug) {
const now = Date.now()
touched.update((t) => [{ slug: d.slug, tool: d.tool ?? '', ts: now }, ...t].slice(0, TOUCHED_MAX))
ws.touched.update((t) => [{ slug: d.slug, tool: d.tool ?? '', ts: now }, ...t].slice(0, TOUCHED_MAX))
}
break
}
@@ -157,13 +166,30 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
// health.changed is task-agnostic (fleet-wide), so it's matched separately:
// show the diff whenever the changed entity is one this task has touched, not
// by correlation_id (health events don't carry one).
function applyHealthChanged(ev: { type: string; data?: unknown }) {
function applyHealthChangedTo(ws: WorkspaceState, ev: { type: string; data?: unknown }) {
if (ev.type !== 'health.changed') return
const data = (ev.data ?? {}) as HealthChangedData
if (!data.slug) return
const isRelevant = get(touched).some((t) => t.slug === data.slug)
const isRelevant = get(ws.touched).some((t) => t.slug === data.slug)
if (!isRelevant) return
healthDiffs.update((d) => [{ slug: data.slug, from: data.from ?? '', to: data.to ?? '', ts: Date.now() }, ...d].slice(0, 8))
ws.healthDiffs.update((d) => [{ slug: data.slug, from: data.from ?? '', to: data.to ?? '', ts: Date.now() }, ...d].slice(0, 8))
}
let hydratedFor: string | null = null
let unsubStream: (() => void) | null = null
let unsubLive: (() => void) | null = null
let lastSeenId = 0
async function hydrate(sessionId: string) {
hydratedFor = sessionId
globalWorkspace.planSteps.set([])
globalWorkspace.questions.set([])
globalWorkspace.touched.set([])
globalWorkspace.healthDiffs.set([])
const [steps, qs] = await Promise.all([fetchPlan(sessionId), fetchQuestions(sessionId)])
if (get(currentSession) !== sessionId) return // switched away while loading
globalWorkspace.planSteps.set(steps)
globalWorkspace.questions.set(qs)
}
// startWorkspace opens the global event subscription and begins tracking the
@@ -176,10 +202,10 @@ export function startWorkspace(): () => void {
if (sid && sid !== hydratedFor) hydrate(sid)
if (!sid) {
hydratedFor = null
planSteps.set([])
questions.set([])
touched.set([])
healthDiffs.set([])
globalWorkspace.planSteps.set([])
globalWorkspace.questions.set([])
globalWorkspace.touched.set([])
globalWorkspace.healthDiffs.set([])
}
})
@@ -191,11 +217,13 @@ export function startWorkspace(): () => void {
}
const fresh = evs.filter((e) => e.id > lastSeenId)
lastSeenId = maxId
const sid = get(currentSession)
if (!sid) return
// Oldest-first application so ordering (e.g. plan.step.started before
// .finished) is preserved.
for (const e of fresh.slice().reverse()) {
applyEvent(e)
applyHealthChanged(e)
applyEventTo(globalWorkspace, sid, e)
applyHealthChangedTo(globalWorkspace, e)
}
})
@@ -206,9 +234,69 @@ export function startWorkspace(): () => void {
}
}
// Sweep expired pulses/diffs on an interval so old touches stop glowing.
// ─── per-session workspace, for floating task windows ───────────────────────
//
// Same shape as the global workspace above, but keyed by session id instead
// of "whatever's on screen" — mirrors chat.ts's chatFor(). A window's
// TaskContextPanel calls startSessionWorkspace(sessionId) instead of
// startWorkspace(), and reads workspaceFor(sessionId)'s stores instead of the
// global ones, so several sessions' panels can be open and live at once.
const workspaces = new Map<string, WorkspaceState>()
export function workspaceFor(sessionId: string): WorkspaceState {
let w = workspaces.get(sessionId)
if (!w) {
w = createWorkspaceState()
workspaces.set(sessionId, w)
}
return w
}
export function taskFor(sessionId: string): Readable<Session | null> {
return derived(sessions, ($sessions) => $sessions.find((s) => s.id === sessionId) ?? null)
}
async function hydrateSession(ws: WorkspaceState, sessionId: string) {
const [steps, qs] = await Promise.all([fetchPlan(sessionId), fetchQuestions(sessionId)])
ws.planSteps.set(steps)
ws.questions.set(qs)
}
export function startSessionWorkspace(sessionId: string): () => void {
const ws = workspaceFor(sessionId)
const unsub = subscribeEvents()
hydrateSession(ws, sessionId)
// Own "seen" watermark rather than the global lastSeenId — several
// windows, each watching a different session, can be reading off the same
// liveEvents feed at once.
let lastSeen = 0
const unsubLive = liveEvents.subscribe((evs) => {
if (evs.length === 0) return
const maxId = evs[0].id
if (maxId <= lastSeen) return
const fresh = evs.filter((e) => e.id > lastSeen)
lastSeen = maxId
for (const e of fresh.slice().reverse()) {
applyEventTo(ws, sessionId, e)
applyHealthChangedTo(ws, e)
}
})
return () => {
unsubLive()
unsub()
}
}
// Sweep expired pulses/diffs on an interval so old touches stop glowing —
// across the global workspace and every per-session one currently in use.
setInterval(() => {
const now = Date.now()
touched.update((t) => t.filter((e) => now - e.ts < TOUCHED_PULSE_MS))
healthDiffs.update((d) => d.filter((e) => now - e.ts < HEALTH_DIFF_MS))
const sweep = (ws: WorkspaceState) => {
ws.touched.update((t) => t.filter((e) => now - e.ts < TOUCHED_PULSE_MS))
ws.healthDiffs.update((d) => d.filter((e) => now - e.ts < HEALTH_DIFF_MS))
}
sweep(globalWorkspace)
for (const ws of workspaces.values()) sweep(ws)
}, 1000)

View File

@@ -1,7 +1,20 @@
import { mount } from 'svelte'
import App from './App.svelte'
import './app.css'
import { initConfig, setConfig, getConfig } from '$lib/config'
import { initConfig, setConfig, getConfig, isConfigured } from '$lib/config'
// Dev convenience: `npm run dev` already proxies /api and /agent with
// OIKOS_API_TOKEN baked in server-side (vite.config.ts's authProxy), so the
// SPA's own token only matters for the one route that reads it from a query
// param (config.ts's sseUrl). Auto-fill it here so the "Connect to Oikos"
// prompt doesn't reappear every time localStorage is cleared — only when
// nothing's configured yet, so it never clobbers a deliberate manual
// connection (e.g. pointing dev at a remote server).
function devAutoConfig() {
if (import.meta.env.DEV && __OIKOS_DEV_TOKEN__ && !isConfigured()) {
setConfig({ apiUrl: '', token: __OIKOS_DEV_TOKEN__ })
}
}
function handleDesktopToken() {
const params = new URLSearchParams(location.search)
@@ -20,6 +33,7 @@ function handleDesktopToken() {
function start() {
initConfig()
devAutoConfig()
handleDesktopToken()
mount(App, { target: document.getElementById('app')! })

View File

@@ -1,412 +0,0 @@
<script lang="ts">
import { messages, streaming, connectionState, currentSession, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError } from '$lib/stores/chat'
import { activityLog } from '$lib/stores/activity'
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
import SquareIcon from '@lucide/svelte/icons/square'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
let { showRail = true }: { showRail?: boolean } = $props()
let input = $state('')
let messagesEnd = $state<HTMLDivElement | null>(null)
let scrolledUp = $state(false)
let container = $state<HTMLDivElement | null>(null)
function isNearBottom(): boolean {
if (!container) return true
const { scrollTop, scrollHeight, clientHeight } = container
return scrollHeight - scrollTop - clientHeight < 80
}
function onScroll() {
scrolledUp = !isNearBottom()
}
// Auto-scroll to bottom on new messages — unless user scrolled up to read.
$effect(() => {
void $messages
if ($streaming || !scrolledUp) {
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
}
})
// Reset scroll lock when user sends a message.
function submitFollows() {
scrolledUp = false
}
// Resizable right rail (session graph). Persisted so it survives reloads.
const RAIL_MIN = 260
const RAIL_MAX = 620
function loadRailWidth(): number {
if (typeof localStorage === 'undefined') return 320
const v = Number(localStorage.getItem('oikos-rail-width'))
return v >= RAIL_MIN && v <= RAIL_MAX ? v : 320
}
let railWidth = $state(loadRailWidth())
let resizing = $state(false)
function startResize(e: PointerEvent) {
e.preventDefault()
resizing = true
const startX = e.clientX
const startW = railWidth
function move(ev: PointerEvent) {
railWidth = Math.min(RAIL_MAX, Math.max(RAIL_MIN, startW + (startX - ev.clientX)))
}
function up() {
resizing = false
localStorage.setItem('oikos-rail-width', String(railWidth))
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
function render(text: string): string {
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
}
function submit() {
const text = input.trim()
if (!text || $streaming) return
input = ''
scrolledUp = false
sendMessage(text)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}
const suggestions = [
'What needs my attention right now?',
'Summarize fleet health',
'Any pending approvals or open signals?',
'What changed in the last hour?'
]
function ask(q: string) {
if ($streaming) return
sendMessage(q)
}
</script>
<div class="flex h-full min-h-0">
<div class="flex min-w-0 flex-1 flex-col">
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
{#if $messages.length === 0}
<div class="flex flex-col items-center gap-6 pt-24 text-center">
<div>
<h2 class="text-xl font-semibold">Nomos</h2>
<p class="mt-1 text-sm text-muted-foreground">Your resident operator. Ask about the fleet, or tell it to act.</p>
</div>
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
{#each suggestions as q}
<Button variant="outline" size="sm" class="h-auto justify-start whitespace-normal py-2 text-left text-xs" onclick={() => ask(q)}>
{q}
</Button>
{/each}
</div>
</div>
{/if}
{#each $messages as msg, i (msg.id)}
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
{#if msg.role === 'user'}
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap user-msg">{msg.text}</div>
{:else}
<div class="flex w-full flex-col gap-2">
{#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
{@html render(msg.text)}
</div>
{/if}
</div>
{/if}
</div>
{/each}
<AgentIndicator
active={$streaming || $activityLog.some((e) => e.status === 'running')}
lastActivity={$activityLog.find((e) => e.status === 'running') ?? null}
error={$error}
/>
<div bind:this={messagesEnd}></div>
</div>
</div>
{#if $connectionState === 'disconnected'}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 flex items-center gap-2 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs">
<RefreshCwIcon class="size-3 shrink-0" aria-hidden="true" />
<span class="text-warning-foreground flex-1">Agent connection lost. The task may still be running.</span>
<Button size="xs" variant="outline" class="h-6 text-[11px]" onclick={reconnect}>Reconnect</Button>
</div>
</div>
{:else if $connectionState === 'reconnecting'}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-xs">
<RefreshCwIcon class="size-3 shrink-0 animate-spin text-muted-foreground" aria-hidden="true" />
<span class="text-muted-foreground flex-1">Reconnecting to agent…</span>
</div>
</div>
{/if}
{#if $error}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{$error}
</div>
</div>
{/if}
{#each $chatErrors as err (err.id)}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<span class="flex-1">{err.message}</span>
{#if err.action}
<Button size="xs" variant="ghost" class="h-6 text-[11px]" onclick={() => dismissError(err.id)}>{err.action}</Button>
{/if}
<button class="ml-1 text-muted-foreground hover:text-foreground" onclick={() => dismissError(err.id)} aria-label="Dismiss">×</button>
</div>
</div>
{/each}
<div class="border-t bg-card/50 p-3 input-ornament relative">
<form
class="mx-auto flex max-w-3xl items-end gap-2"
onsubmit={(e) => {
e.preventDefault()
submit()
}}
>
<Textarea
bind:value={input}
onkeydown={handleKeydown}
placeholder="Ask Nomos anything…"
rows={2}
class="max-h-40 min-h-0 resize-none"
disabled={$streaming}
/>
{#if $streaming}
<Button type="button" size="icon" variant="destructive" onclick={cancelStream} aria-label="Stop">
<SquareIcon />
</Button>
{:else}
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Send">
<ArrowUpIcon />
</Button>
{/if}
</form>
</div>
</div>
{#if showRail}
<div class="hidden shrink-0 xl:flex" style="width: {railWidth}px">
<button
type="button"
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
onpointerdown={startResize}
aria-label="Resize task panel"
>
<span
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
? 'bg-primary/60'
: 'bg-border group-hover/rz:bg-primary/50'}"
></span>
</button>
<div class="flex min-w-0 flex-1 flex-col">
<TaskContextPanel />
</div>
</div>
{/if}
</div>
<style>
/* ── Art Nouveau chat styling ── */
/* Assistant message wrapper */
.assistant-msg {
position: relative;
}
/* User message — soft terracotta bubble, gentle lift */
.user-msg {
box-shadow: 0 1px 8px -4px var(--primary);
}
/* Prose overrides */
.prose-chat :global(p) {
margin: 0 0 0.5rem;
}
.prose-chat :global(p:last-child) {
margin-bottom: 0;
}
.prose-chat :global(ul),
.prose-chat :global(ol) {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.prose-chat :global(ul) {
list-style-type: disc;
}
.prose-chat :global(ol) {
list-style-type: decimal;
}
.prose-chat :global(li) {
margin-bottom: 0.125rem;
padding-left: 0.25rem;
}
.prose-chat :global(li::marker) {
color: var(--primary);
}
.prose-chat :global(code) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.15em 0.4em;
font-family: var(--font-mono);
font-size: 0.85em;
color: var(--primary);
}
.prose-chat :global(pre) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 0.875rem;
overflow-x: auto;
margin: 0 0 0.5rem;
position: relative;
}
.prose-chat :global(pre)::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(to right, transparent, var(--primary), transparent);
opacity: 0.4;
}
.prose-chat :global(pre code) {
background: none;
padding: 0;
font-size: 0.8125rem;
color: inherit;
border: none;
}
/* Section headings — serif (Inknut) with a short accent rule. Extra top
margin separates sections; the first heading in a message doesn't. */
.prose-chat :global(h1),
.prose-chat :global(h2),
.prose-chat :global(h3) {
font-weight: 600;
margin: 1.15rem 0 0.4rem;
font-size: 1.03em;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.prose-chat :global(> h1:first-child),
.prose-chat :global(> h2:first-child),
.prose-chat :global(> h3:first-child) {
margin-top: 0;
}
.prose-chat :global(h1)::after,
.prose-chat :global(h2)::after,
.prose-chat :global(h3)::after {
content: '';
display: block;
width: 2.5rem;
height: 2px;
margin-top: 4px;
border-radius: 1px;
background: linear-gradient(to right, var(--primary), transparent);
opacity: 0.55;
}
.prose-chat :global(table) {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.prose-chat :global(th) {
background: var(--muted);
font-weight: 600;
}
.prose-chat :global(th),
.prose-chat :global(td) {
border: 1px solid var(--border);
padding: 0.3rem 0.6rem;
text-align: left;
}
.prose-chat :global(blockquote) {
border-left: 3px solid var(--primary);
padding-left: 0.75rem;
color: var(--muted-foreground);
margin: 0 0 0.5rem;
font-style: italic;
position: relative;
}
.prose-chat :global(blockquote)::before {
content: '“';
position: absolute;
left: -0.15rem;
top: -0.35rem;
font-size: 1.5rem;
color: var(--primary);
opacity: 0.6;
font-style: normal;
line-height: 1;
}
.prose-chat :global(hr) {
border: none;
height: 1px;
margin: 0.75rem 0;
background: linear-gradient(to right, transparent, var(--border) 20%, var(--border) 80%, transparent);
}
/* Bold is emphasis, not color — dark weight reads cleanly and lets the
terracotta accent stay meaningful (code, headings, links). */
.prose-chat :global(strong) {
color: var(--foreground);
font-weight: 600;
}
.prose-chat :global(a) {
color: var(--primary);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
/* Input area ornament */
.input-ornament::before {
content: '';
position: absolute;
top: 0;
left: 2rem;
right: 2rem;
height: 1px;
background: linear-gradient(to right, transparent, var(--primary), transparent);
opacity: 0.3;
}
</style>

View File

@@ -1,7 +0,0 @@
<script lang="ts">
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
let { slug }: { slug: string } = $props()
</script>
<EntityDetailContent {slug} />

View File

@@ -6,7 +6,7 @@
import { Input } from '$lib/components/ui/input'
import { Button } from '$lib/components/ui/button'
import { ScrollArea } from '$lib/components/ui/scroll-area'
import EntitySheet from '$lib/components/EntitySheet.svelte'
import { openEntityWindow } from '$lib/stores/windows'
import SearchIcon from '@lucide/svelte/icons/search'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import BotIcon from '@lucide/svelte/icons/bot'
@@ -56,13 +56,6 @@
return `${Math.floor(s / 86400)}d ago`
}
let sheetOpen = $state(false)
let selectedSlug = $state<string | null>(null)
function openEntity(slug: string) {
selectedSlug = slug
sheetOpen = true
}
</script>
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
@@ -131,7 +124,7 @@
{#if hit.linked_entities?.length}
<div class="mt-1 flex flex-wrap gap-1">
{#each hit.linked_entities as slug}
<button type="button" class="font-mono text-xs text-muted-foreground underline" onclick={() => openEntity(slug)}>{slug}</button>
<button type="button" class="font-mono text-xs text-muted-foreground underline" onclick={() => openEntityWindow(slug)}>{slug}</button>
{/each}
</div>
{/if}
@@ -182,5 +175,3 @@
</ScrollArea>
{/if}
</div>
<EntitySheet slug={selectedSlug} bind:open={sheetOpen} />

View File

@@ -1,19 +1,19 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchEntities, type Entity } from '$lib/api'
import { fetchAllEntities, fetchOntology, fetchGraph, type Entity, type EntityType, type Ontology } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import EntityTable from '$lib/components/EntityTable.svelte'
import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte'
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte'
import { categories, filtersForCategory, type Category } from '$lib/categories'
import { typeToCategory, type Category } from '$lib/categories'
import { openEntityWindow, wmState } from '$lib/stores/windows'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import * as Select from '$lib/components/ui/select'
import { Switch } from '$lib/components/ui/switch'
import { Label } from '$lib/components/ui/label'
import NetworkIcon from '@lucide/svelte/icons/share-2'
import TableIcon from '@lucide/svelte/icons/table-2'
import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed'
import XIcon from '@lucide/svelte/icons/x'
type View = 'graph' | 'table'
@@ -22,9 +22,15 @@
return localStorage.getItem('oikos-kb-view') === 'table' ? 'table' : 'graph'
}
let category = $state<Category>('fleet')
let view = $state<View>(loadView())
let selectedSlug = $state<string | null>(null)
// Shared between table (filters rows) and graph (highlights/searches
// nodes) — one search box instead of two differently-labeled ones, since
// both views are asking the same underlying question ("show me X").
let search = $state('')
// Tracks only the most recently opened entity, for row/node highlight —
// actual detail viewing now happens in floating windows (WindowLayer, mounted inside Desktop.svelte),
// which can have several entities open at once.
let lastOpened = $state<string | null>(null)
function setView(v: View) {
view = v
@@ -32,50 +38,158 @@
}
function select(slug: string | null) {
selectedSlug = slug
lastOpened = slug
openEntityWindow(slug)
}
// ─── table data: fetched here (not inside EntityTable) so the search/type
// Drop the highlight once its window is closed (from WindowLayer, not
// necessarily from here) rather than leaving a row/node marked "open" when
// it isn't anymore.
$effect(() => {
if (lastOpened && !$wmState.windows[lastOpened]) lastOpened = null
})
// ─── entities: fetched here (not inside EntityTable) so the search/type
// toolbar lives in the shared page toolbar instead of the resizable browse
// pane, where its width is at the mercy of the divider and it would
// truncate. This also keeps the browse pane header-free, so it and the
// detail pane both start flush under the toolbar and end up the same height.
let tableEntities = $state<Entity[]>([])
let tableLoading = $state(true)
let query = $state('')
let typeFilter = $state('all')
// truncate. Both views now share the same full entity set — there's no
// more per-category server-side scoping, only the client-side type
// multiselect (activeTypes) below, which both the table (row visibility)
// and the graph (node visibility) read from.
let allEntities = $state<Entity[]>([])
let entitiesLoading = $state(true)
let showInactive = $state(false)
// child entity slug -> parent entity slug, derived from the ontology graph
// (see loadGrouping). Feeds EntityTable's treegrid grouping, nesting e.g.
// host -> lxc -> service, or storage-pool -> volume -> dataset — computed
// over the whole entity set so the hierarchy doesn't reshuffle as the type
// filter is toggled (EntityTable falls back a filtered-out parent's
// children to top-level rather than dropping them).
let childToParent = $state<Map<string, string> | null>(null)
async function loadTable() {
tableLoading = true
const filterSets = filtersForCategory(category)
const results = await Promise.all(filterSets.map((f) => fetchEntities(f)))
tableEntities = results.flat()
tableLoading = false
let ontologyPromise: Promise<Ontology> | null = null
function getOntology(): Promise<Ontology> {
ontologyPromise ??= fetchOntology()
return ontologyPromise
}
// type -> browsing category (see categories.ts), used only to seed the
// type multiselect's default selection ("fleet") — not to scope any fetch.
let typeCategory = $state<Map<string, Category | undefined>>(new Map())
// Distance from the ontology's abstract root ("entity") down to typeName —
// 0 for entity itself, 1 for its direct subtypes, etc. Used as a
// specificity score: a relationship whose parent-side type is a generic
// ancestor (e.g. `configured-by`'s source is literally `entity` — almost
// everything is configured-by a repo) is a weaker structural signal than
// one whose parent-side type is narrow and concrete (e.g. `hosts`' source
// is `machine`, `member-of`'s is `proxmox-host`).
function typeDepth(byName: Map<string, EntityType>, typeName: string): number {
let depth = 0
let t = byName.get(typeName)
for (; t?.parent_type && depth < 10; depth++) t = byName.get(t.parent_type)
return depth
}
// Nesting is derived entirely from the ontology's own relationship
// definitions — no relationship or entity type names hardcoded here. Every
// relationship whose cardinality isn't many-to-many has exactly one "one"
// side, which is the parent: one-to-many/one-to-one -> source is parent
// (e.g. machine hosts many compute-entities); many-to-one -> target is
// parent (e.g. many hosts are located-at one site). many-to-many
// relationships (mounts, stores-on, backs-up-to, ...) have no single
// parent, so they're excluded from tree nesting. A candidate parent that
// isn't actually part of the set being browsed (e.g. `cluster`, filtered
// out below) is dropped rather than kept as a dangling pointer — that's
// also what lets `located-at` surface as a host's parent instead of
// `member-of` without any special-cased priority: with cluster absent,
// member-of simply has nothing valid to point at. An entity can still be
// the child end of several different *remaining* relationship types at
// once (e.g. a service is `provides`-d by its lxc AND `configured-by` a
// repo) — only one can win as its tree parent, so ties go to the more
// specific relationship (see typeDepth) rather than whichever was fetched
// last.
async function loadGrouping(entities: Entity[]): Promise<Map<string, string>> {
const { entityTypes, relationshipTypes } = await getOntology()
const byName = new Map(entityTypes.map((t) => [t.name, t]))
const hierRels = relationshipTypes.filter((rt) => rt.cardinality !== 'many-to-many')
const relTypeNames = hierRels.map((rt) => rt.name)
const cardinalityByType = new Map(hierRels.map((rt) => [rt.name, rt.cardinality]))
const specificityByType = new Map(hierRels.map((rt) => [rt.name, typeDepth(byName, rt.source_type)]))
const slugs = new Set(entities.map((e) => e.slug))
// One whole-graph fetch instead of one rooted fetch per candidate parent
// — now that grouping runs over the entire entity set rather than a
// ~50-entity category, firing a request per entity blew past the
// browser's concurrent-connection limit (ERR_INSUFFICIENT_RESOURCES).
const g = await fetchGraph({ relType: relTypeNames })
const pairs = (g?.edges ?? [])
.filter((edge) => cardinalityByType.has(edge.type) && slugs.has(edge.source) && slugs.has(edge.target))
.map((edge) => {
const [child, parent] =
cardinalityByType.get(edge.type) === 'many-to-one'
? [edge.source, edge.target] // source is the child; target is the "one" (parent)
: [edge.target, edge.source] // source is the "one" (parent); target is the child
return { child, parent, weight: specificityByType.get(edge.type) ?? 0 }
})
const best = new Map<string, { parent: string; weight: number }>()
for (const { child, parent, weight } of pairs) {
const current = best.get(child)
if (!current || weight > current.weight) best.set(child, { parent, weight })
}
return new Map([...best].map(([child, { parent }]) => [child, parent]))
}
async function loadEntities() {
entitiesLoading = true
// cluster entities are dropped so a host's `member-of` edge has no valid
// parent to point at, leaving `located-at` (site) as the only remaining
// tree-parent candidate (see loadGrouping).
const fetched = (await fetchAllEntities()).filter((e) => e.type !== 'cluster')
childToParent = await loadGrouping(fetched)
allEntities = fetched
entitiesLoading = false
}
onMount(() => {
loadEntities()
getOntology().then((o) => {
typeCategory = new Map(o.entityTypes.map((t) => [t.name, typeToCategory(t.name, t.domain)]))
})
const unsubscribe = subscribeEvents()
return unsubscribe
})
$effect(() => {
if (view !== 'table') return
category
loadTable()
})
$effect(() => {
const ev = $liveEvents[0]
if (view !== 'table' || !ev || !ev.type.startsWith('entity.')) return
loadTable()
if (!ev || !ev.type.startsWith('entity.')) return
loadEntities()
})
const allTypes = $derived(Array.from(new Set(allEntities.map((e) => e.type))).sort())
// Shared show/hide-by-type filter — governs both the table's row
// visibility and the graph's node visibility. Seeded once (not
// re-derived) to "fleet" types as soon as both the entity set and the
// ontology's type->category map are loaded, so it doesn't clobber the
// user's own toggles on a later reload.
let activeTypes = $state<Set<string>>(new Set())
let typesSeeded = false
$effect(() => {
if (typesSeeded || allTypes.length === 0 || typeCategory.size === 0) return
activeTypes = new Set(allTypes.filter((t) => typeCategory.get(t) === 'fleet'))
typesSeeded = true
})
const tableTypes = $derived(Array.from(new Set(tableEntities.map((e) => e.type))).sort())
const filteredEntities = $derived.by(() => {
const q = query.trim().toLowerCase()
return tableEntities.filter((e) => {
if (typeFilter !== 'all' && e.type !== typeFilter) return false
const q = search.trim().toLowerCase()
return allEntities.filter((e) => {
if (!activeTypes.has(e.type)) return false
if (q && !e.slug.toLowerCase().includes(q) && !e.name.toLowerCase().includes(q)) return false
// entities with no tracked lifecycle state (state is null) aren't
// "destroyed or inactive" — only hide ones whose tracked state has
// moved off `active`.
if (!showInactive && e.state && e.state !== 'active') return false
return true
})
})
@@ -85,12 +199,10 @@
// width instead of being squeezed by the resizable browse pane.
let graphRoot = $state('')
let graphDepth = $state(2)
let graphSearch = $state('')
let graphReloadToken = $state(0)
let graphResetToken = $state(0)
let graphActiveNodeTypes = $state<Set<string>>(new Set())
let graphActiveRelTypes = $state<Set<string>>(new Set())
let graphInfo = $state<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
let graphInfo = $state<GraphInfo>({ allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
function commitGraphQuery() {
graphReloadToken++
@@ -98,166 +210,86 @@
function resetGraph() {
graphRoot = ''
graphSearch = ''
search = ''
graphResetToken++
}
function relColorFor(type: string): string {
return graphInfo.relColors.get(type) ?? '#30363d'
}
// ─── resizable browse/detail split (pattern from Chat.svelte) ─────────
const DETAIL_MIN = 320
const DETAIL_MAX = 900
function loadDetailWidth(): number {
if (typeof localStorage === 'undefined') return 420
const v = Number(localStorage.getItem('oikos-kb-detail-width'))
return v >= DETAIL_MIN && v <= DETAIL_MAX ? v : 420
}
let detailWidth = $state(loadDetailWidth())
let resizing = $state(false)
function startResize(e: PointerEvent) {
e.preventDefault()
resizing = true
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
const startX = e.clientX
const startW = detailWidth
function move(ev: PointerEvent) {
detailWidth = Math.min(DETAIL_MAX, Math.max(DETAIL_MIN, startW + (startX - ev.clientX)))
}
function up() {
resizing = false
if (typeof localStorage !== 'undefined') localStorage.setItem('oikos-kb-detail-width', String(detailWidth))
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
<!-- toolbar: category perspective + view toggle -->
<div class="flex flex-wrap items-center gap-3">
<div class="inline-flex overflow-hidden rounded-md border">
{#each categories as c}
<Button
variant={category === c.id ? 'secondary' : 'ghost'}
size="sm"
class="h-8 rounded-none border-0"
onclick={() => (category = c.id)}
>
{c.label}
</Button>
{/each}
</div>
<!-- single toolbar row: search + type filter are shared by both views
(one multiselect instead of a category tab, a single-select "All
types" dropdown, and a separate graph node-type toggle), the rest is
view-specific, and the graph/table switch sits inline with the rest
instead of floating in its own row. -->
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Filter / highlight by slug or name…" bind:value={search} class="h-8 max-w-xs text-xs" />
<MultiSelectFilter label="Types" options={allTypes} bind:selected={activeTypes} />
{#if view === 'table'}
<div class="flex items-center gap-1.5">
<Switch id="show-inactive" bind:checked={showInactive} />
<Label for="show-inactive" class="text-xs font-normal text-muted-foreground">Inactive</Label>
</div>
<span class="text-xs text-muted-foreground">{filteredEntities.length} of {allEntities.length}</span>
{:else}
<Input placeholder="Root entity…" bind:value={graphRoot} class="h-8 max-w-40 text-xs" onchange={commitGraphQuery} />
<Input type="number" min="1" max="5" bind:value={graphDepth} class="h-8 w-14 text-xs" onchange={commitGraphQuery} />
<Button variant="outline" size="sm" class="h-8" onclick={resetGraph}>
<LocateFixedIcon class="mr-1 size-3.5" />
Reset
</Button>
<MultiSelectFilter label="Edges" options={graphInfo.allRelTypes} bind:selected={graphActiveRelTypes} colorFor={relColorFor} />
<span class="text-xs text-muted-foreground">
{graphInfo.visibleCount} nodes{graphInfo.truncated ? ' · truncated' : ''} · {graphInfo.zoomPct}%
</span>
{/if}
<div class="ml-auto inline-flex overflow-hidden rounded-md border">
<Button
variant={view === 'graph' ? 'secondary' : 'ghost'}
size="sm"
class="h-8 rounded-none border-0"
class="h-8 rounded-none border-0 px-2"
onclick={() => setView('graph')}
title="Graph view"
aria-label="Graph view"
>
<NetworkIcon class="mr-1 size-3.5" /> Graph
<NetworkIcon class="size-3.5" />
</Button>
<Button
variant={view === 'table' ? 'secondary' : 'ghost'}
size="sm"
class="h-8 rounded-none border-0"
class="h-8 rounded-none border-0 px-2"
onclick={() => setView('table')}
title="Table view"
aria-label="Table view"
>
<TableIcon class="mr-1 size-3.5" /> Table
<TableIcon class="size-3.5" />
</Button>
</div>
</div>
{#if view === 'table'}
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Filter by slug or name…" bind:value={query} class="h-8 max-w-xs text-xs" />
<Select.Root type="single" bind:value={typeFilter}>
<Select.Trigger class="h-8 w-40 text-xs">
{typeFilter === 'all' ? 'All types' : typeFilter}
</Select.Trigger>
<Select.Content>
<Select.Item value="all">All types</Select.Item>
{#each tableTypes as type}
<Select.Item value={type}>{type}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<span class="text-xs text-muted-foreground">{filteredEntities.length} of {tableEntities.length}</span>
</div>
{:else}
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Root entity…" bind:value={graphRoot} class="h-8 max-w-44 text-xs" onchange={commitGraphQuery} />
<Input type="number" min="1" max="5" bind:value={graphDepth} class="h-8 w-16 text-xs" onchange={commitGraphQuery} />
<Input placeholder="Search / highlight…" bind:value={graphSearch} class="h-8 max-w-44 text-xs" />
<Button variant="outline" size="sm" class="h-8" onclick={resetGraph}>
<LocateFixedIcon class="mr-1 size-3.5" />
Reset
</Button>
<MultiSelectFilter label="Nodes" options={graphInfo.allNodeTypes} bind:selected={graphActiveNodeTypes} />
<MultiSelectFilter label="Edges" options={graphInfo.allRelTypes} bind:selected={graphActiveRelTypes} colorFor={relColorFor} />
<span class="ml-auto text-xs text-muted-foreground">
{graphInfo.visibleCount} nodes{graphInfo.truncated ? ' · truncated' : ''} · {graphInfo.zoomPct}%
</span>
</div>
{/if}
<!-- resizable browse | detail split -->
<div class="flex min-h-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col">
{#if view === 'graph'}
<EntityGraph
{category}
{selectedSlug}
onSelect={select}
bind:root={graphRoot}
depth={graphDepth}
search={graphSearch}
reloadToken={graphReloadToken}
resetToken={graphResetToken}
bind:activeNodeTypes={graphActiveNodeTypes}
bind:activeRelTypes={graphActiveRelTypes}
bind:info={graphInfo}
/>
{:else}
<EntityTable entities={filteredEntities} loading={tableLoading} {selectedSlug} onSelect={select} />
{/if}
</div>
<button
type="button"
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
onpointerdown={startResize}
aria-label="Resize detail panel"
>
<span
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
? 'bg-primary/60'
: 'bg-border group-hover/rz:bg-primary/50'}"
></span>
</button>
<div class="flex shrink-0 flex-col overflow-hidden rounded-lg border" style="width: {detailWidth}px">
{#if selectedSlug}
<div class="flex shrink-0 items-center justify-end border-b p-1">
<Button variant="ghost" size="icon" class="size-7" onclick={() => select(null)} aria-label="Close detail panel">
<XIcon class="size-4" />
</Button>
</div>
<div class="min-h-0 flex-1">
{#key selectedSlug}
<EntityDetailContent slug={selectedSlug} onSelectEntity={select} />
{/key}
</div>
{:else}
<div class="flex h-full items-center justify-center p-6 text-center text-sm text-muted-foreground">
Select an entity to see its detail.
</div>
{/if}
</div>
<!-- browse pane — selecting an entity opens it in a floating window
(WindowLayer, mounted globally inside Desktop.svelte) instead of a sidebar. -->
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
{#if view === 'graph'}
<EntityGraph
selectedSlug={lastOpened}
onSelect={select}
bind:root={graphRoot}
depth={graphDepth}
{search}
reloadToken={graphReloadToken}
resetToken={graphResetToken}
activeNodeTypes={activeTypes}
bind:activeRelTypes={graphActiveRelTypes}
bind:info={graphInfo}
/>
{:else}
<EntityTable entities={filteredEntities} loading={entitiesLoading} selectedSlug={lastOpened} onSelect={select} {childToParent} />
{/if}
</div>
</div>

View File

@@ -1,46 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchDashboardSummary, type DashboardSummary } from '$lib/api'
import { sessions, loadSessions, loadSessionMessages, newChat, sendMessage } from '$lib/stores/chat'
import { sessions, loadSessions } from '$lib/stores/chat'
import { openTaskWindow, openNewTaskWindow } from '$lib/stores/windows'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { bucket, statusStyle, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
import { relativeTime } from '$lib/utils'
import GraphBackground from '$lib/components/GraphBackground.svelte'
import { Textarea } from '$lib/components/ui/textarea'
import { Button } from '$lib/components/ui/button'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert'
// ── Dashboard metrics (ported from the old Overview cards) ──────────────
let summary = $state<DashboardSummary | null>(null)
async function loadSummary() {
summary = await fetchDashboardSummary()
}
const totalEntities = $derived(
summary ? Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0) : 0
)
const entityTypeCount = $derived(summary ? Object.keys(summary.entities_by_type).length : 0)
const totalMonitored = $derived(
summary
? summary.health.healthy + summary.health.degraded + summary.health.down + summary.health.unknown
: 0
)
const healthTone = $derived(
!summary ? 'ok' : summary.health.down > 0 ? 'down' : summary.health.degraded > 0 ? 'degraded' : 'ok'
)
const totalSignals = $derived(
summary ? Object.values(summary.signals_by_severity).reduce((a, b) => a + b, 0) : 0
)
const worstSeverity = $derived(
summary?.signals_by_severity.critical
? 'critical'
: summary?.signals_by_severity.warning
? 'warning'
: 'none'
)
import PlusIcon from '@lucide/svelte/icons/plus'
import type { Session } from '$lib/api'
// ── Task board ──────────────────────────────────────────────────────────
let filter = $state<'all' | Bucket>('all')
@@ -54,33 +22,13 @@
filter === 'all' ? $sessions : $sessions.filter((s) => bucket(s) === filter)
)
function openTask(id: string) {
loadSessionMessages(id)
location.hash = '#/chat'
}
// ── New task entry ──────────────────────────────────────────────────────
let input = $state('')
function submit() {
const text = input.trim()
if (!text) return
input = ''
newChat()
sendMessage(text)
location.hash = '#/chat'
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
function openTask(s: Session) {
openTaskWindow(s.id, heading(s))
}
onMount(() => {
loadSummary()
loadSessions()
const unsubStream = subscribeEvents()
const summaryTimer = setInterval(loadSummary, 15000)
// Refetch the board when a task's lifecycle changes anywhere. Scan all
// events newer than the last seen (entity.touched fires constantly and
@@ -102,161 +50,78 @@
return () => {
unsub()
unsubStream()
clearInterval(summaryTimer)
}
})
</script>
<div class="relative h-full overflow-hidden">
<div class="relative flex h-full flex-col gap-3 overflow-hidden p-4">
<GraphBackground />
<div class="relative z-10 h-full overflow-y-auto">
<!-- Hero: fills the viewport. The input is pinned to true vertical center via the
grid's middle 1fr row; the metrics strip and scroll hint sit in the auto rows
above/below without shifting it off-center. Scrolling lifts the whole hero to
reveal the table. -->
<section class="grid min-h-full grid-rows-[auto_1fr_auto] gap-6 px-4 py-16">
<!-- Metrics strip -->
<div class="flex flex-wrap items-center justify-center gap-2 text-xs">
<div class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur">
<span class="text-muted-foreground">Entities</span>
<span class="font-semibold tabular-nums">{totalEntities}</span>
{#if entityTypeCount}<span class="text-muted-foreground">· {entityTypeCount} types</span>{/if}
</div>
<div class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur">
<span
class="size-2 rounded-full {healthTone === 'ok' ? 'bg-success' : healthTone === 'degraded' ? 'bg-warning' : 'bg-destructive'}"
></span>
<span class="text-muted-foreground">Health</span>
<span class="font-semibold tabular-nums">{summary?.health.healthy ?? 0} / {totalMonitored}</span>
</div>
<button
type="button"
onclick={() => (location.hash = '#/signals')}
class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur transition-colors hover:border-primary/50"
>
{#if worstSeverity === 'critical'}
<TriangleAlertIcon class="size-3.5 text-destructive" />
{:else if worstSeverity === 'warning'}
<TriangleAlertIcon class="size-3.5 text-warning" />
{:else}
<CircleCheckIcon class="size-3.5 text-success" />
{/if}
<span class="text-muted-foreground">Signals</span>
<span class="font-semibold tabular-nums">{totalSignals}</span>
</button>
<button
type="button"
onclick={() => (location.hash = '#/ops')}
class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur transition-colors hover:border-primary/50"
>
<span class="text-muted-foreground">Approvals</span>
<span class="font-semibold tabular-nums {summary?.approvals_pending ? 'text-destructive' : ''}"
>{summary?.approvals_pending ?? 0}</span
>
</button>
</div>
<div class="relative z-10 flex flex-wrap items-center gap-1.5">
{#each FILTERS as f}
<button
type="button"
onclick={() => (filter = f.id)}
class="rounded-full border px-2.5 py-1 text-xs transition-colors {filter === f.id
? 'border-primary bg-primary/10 text-foreground'
: 'border-border bg-card/60 text-muted-foreground backdrop-blur hover:bg-muted/50'}"
>
{f.label}
<span class="ml-1 opacity-60">{counts[f.id] ?? 0}</span>
</button>
{/each}
<div class="flex-1"></div>
<Button size="sm" class="gap-1.5" onclick={openNewTaskWindow}>
<PlusIcon class="size-4" />
New task
</Button>
</div>
<!-- New task entry: centered in the middle (1fr) row -->
<div class="flex items-center justify-center">
<div class="w-full max-w-2xl text-center">
<h1 class="mb-1 text-2xl font-semibold tracking-tight">What should Nomos do?</h1>
<p class="mb-4 text-sm text-muted-foreground">
Describe a goal — Nomos will plan it, execute it, and report the outcome.
<div class="relative z-10 min-h-0 flex-1 overflow-auto rounded-xl border bg-card/70 backdrop-blur">
{#if visible.length === 0}
<div class="flex flex-col items-center gap-2 px-4 py-16 text-center">
<p class="max-w-sm text-sm text-muted-foreground">
{filter === 'all'
? 'No tasks yet. Start one and Nomos will plan it, execute it, and report the outcome.'
: `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`}
</p>
<form
class="relative rounded-2xl border bg-card/70 shadow-lg backdrop-blur focus-within:border-primary/60"
onsubmit={(e) => {
e.preventDefault()
submit()
}}
>
<Textarea
bind:value={input}
onkeydown={handleKeydown}
placeholder="e.g. Roll the staging database back to last night's snapshot and verify the app is healthy…"
rows={3}
class="max-h-52 min-h-24 resize-none border-0 bg-transparent px-4 py-3.5 text-base shadow-none focus-visible:ring-0"
/>
<div class="flex items-center justify-between px-3 pb-3">
<span class="text-[11px] text-muted-foreground">Enter to start · Shift+Enter for newline</span>
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Start task">
<ArrowUpIcon />
</Button>
</div>
</form>
</div>
</div>
<span class="justify-self-center text-[11px] text-muted-foreground/70">Scroll to see all tasks ↓</span>
</section>
<!-- Task table -->
<section class="mx-auto w-full max-w-5xl px-4 pb-16">
<div class="rounded-xl border bg-card/70 backdrop-blur">
<div class="flex flex-wrap items-center gap-1.5 border-b p-3">
{#each FILTERS as f}
<button
type="button"
onclick={() => (filter = f.id)}
class="rounded-full border px-2.5 py-1 text-xs transition-colors {filter === f.id
? 'border-primary bg-primary/10 text-foreground'
: 'border-border text-muted-foreground hover:bg-muted/50'}"
{:else}
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left text-xs text-muted-foreground">
<th class="w-36 px-4 py-2 font-medium">Status</th>
<th class="px-4 py-2 font-medium">Task</th>
<th class="hidden px-4 py-2 font-medium md:table-cell">Summary</th>
<th class="w-28 px-4 py-2 text-right font-medium">Last active</th>
</tr>
</thead>
<tbody>
{#each visible as s (s.id)}
{@const st = statusStyle(s)}
<tr
class="cursor-pointer border-b last:border-0 transition-colors hover:bg-muted/40"
onclick={() => openTask(s)}
>
{f.label}
<span class="ml-1 opacity-60">{counts[f.id] ?? 0}</span>
</button>
<td class="px-4 py-2.5">
<span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
{st.label}
</span>
</td>
<td class="max-w-0 px-4 py-2.5">
<span class="line-clamp-1 font-medium">{heading(s)}</span>
</td>
<td class="hidden max-w-0 px-4 py-2.5 md:table-cell">
<span class="line-clamp-1 text-xs text-muted-foreground">{s.summary || '—'}</span>
</td>
<td class="whitespace-nowrap px-4 py-2.5 text-right text-[11px] text-muted-foreground">
{relativeTime(s.last_active_at)}
</td>
</tr>
{/each}
</div>
{#if visible.length === 0}
<div class="flex flex-col items-center gap-2 px-4 py-16 text-center">
<p class="max-w-sm text-sm text-muted-foreground">
{filter === 'all'
? 'No tasks yet. Start one above and Nomos will plan it, execute it, and report the outcome.'
: `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`}
</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left text-xs text-muted-foreground">
<th class="w-36 px-4 py-2 font-medium">Status</th>
<th class="px-4 py-2 font-medium">Task</th>
<th class="hidden px-4 py-2 font-medium md:table-cell">Summary</th>
<th class="w-28 px-4 py-2 text-right font-medium">Last active</th>
</tr>
</thead>
<tbody>
{#each visible as s (s.id)}
{@const st = statusStyle(s)}
<tr
class="cursor-pointer border-b last:border-0 transition-colors hover:bg-muted/40"
onclick={() => openTask(s.id)}
>
<td class="px-4 py-2.5">
<span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
{st.label}
</span>
</td>
<td class="max-w-0 px-4 py-2.5">
<span class="line-clamp-1 font-medium">{heading(s)}</span>
</td>
<td class="hidden max-w-0 px-4 py-2.5 md:table-cell">
<span class="line-clamp-1 text-xs text-muted-foreground">{s.summary || '—'}</span>
</td>
<td class="whitespace-nowrap px-4 py-2.5 text-right text-[11px] text-muted-foreground">
{relativeTime(s.last_active_at)}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</section>
</tbody>
</table>
{/if}
</div>
</div>

17
web/src/test-setup.ts Normal file
View File

@@ -0,0 +1,17 @@
// jsdom doesn't implement matchMedia — anything that transitively imports
// theme.svelte.ts (which reads the OS color-scheme preference at module
// load) throws without this. Global vitest setup so every test file gets it
// for free instead of each one needing its own mock.
if (typeof window !== 'undefined' && !window.matchMedia) {
window.matchMedia = (query: string): MediaQueryList =>
({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false
}) as MediaQueryList
}

View File

@@ -1 +1,4 @@
/// <reference types="vite/client" />
declare const __OIKOS_VERSION__: string
declare const __OIKOS_DEV_TOKEN__: string

View File

@@ -36,6 +36,13 @@ export default defineConfig({
base: '/',
define: {
__OIKOS_VERSION__: JSON.stringify(`v${version}`),
// Lets the dev server auto-configure the SPA with the same token it
// already injects into proxied requests (see authProxy above), so `npm
// run dev` skips the "Connect to Oikos" prompt instead of re-asking for
// a token every time localStorage gets cleared. Empty string (never a
// real prod secret — see main.ts, only consulted in import.meta.env.DEV)
// when OIKOS_API_TOKEN isn't set, so the prompt still shows if unconfigured.
__OIKOS_DEV_TOKEN__: JSON.stringify(process.env.OIKOS_API_TOKEN ?? ''),
},
resolve: {
alias: { $lib: '/src/lib' }
@@ -56,6 +63,7 @@ export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['src/test-setup.ts'],
include: ['src/**/*.{test,spec}.{ts,js}']
}
})