22 Commits

Author SHA1 Message Date
3cd4cf98c3 chore: vendor @joan/procedural-glyph-engine for portable builds
Move @joan/procedural-glyph-engine from absolute path dep
(/private/tmp/orby-pkg) to vendored local dep (web/vendor/) so the
SPA builds in Docker and on other machines without the temp dir.

- Vendor Orby v5.0.0 into web/vendor/
- Switch package.json dep to file:../vendor
- Update Dockerfile to COPY vendor into build context
- Use npm install instead of npm ci (file: deps need install)
- Fix missing trailing newline in Dockerfile
2026-08-05 17:27:04 +02:00
38c472a118 0.26.0 — transport-aware classifier escalation + standalone-server monitoring override
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
- classifyAndGate: escalate read-only commands on lxc: targets that
  touch /opt/, /etc/, /var/lib/ to config_mutation. The classifier
  scores command text only, not the SSH transport layer — SSH-ing into
  a container to read config is riskier than pct exec from the host.
- ontology: standalone-server monitoring override from inherited
  [ping, resource, updates] to [http]. VPS-like machines may not be
  SSH/ICMP-reachable from the scheduler; HTTP is the LCD liveness
  signal. Entities with full SSH can override per-entity.
2026-08-05 16:31:41 +02:00
1d0197da69 0.25.1 — get_health_summary destroyed filter + create_entity footgun doc
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
- get_health_summary: filter out state=destroyed entities (was noise
  from 20+ destroyed test LXCs, deprecated services, etc.)
- create_entity: document the monitoring footgun in the tool description
  (creating a type=check entity does NOT wire a check_def; the correct
  path is update_entity_attributes with monitoring + url attributes)
2026-08-05 16:07:59 +02:00
0920c4cb6d 0.25.0 — DNS resolution check kind (KindDNS) + VPS monitoring fix
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
Adds a new 'dns' semantic monitoring kind that probes whether a DNS name
resolves. Uses net.LookupNS (NS records) with fallback to net.LookupHost
(A/AAAA). Supports an explicit server config for split-horizon resolution.

Changes:
- seeds/ontology.yaml: dns-zone monitoring: none → [dns] (was deferred
  since 2026-06 with a comment 'no dns checker exists yet')
- seeds/inventory.yaml: host:netbird-vps monitoring: [http] (was none;
  VPS was invisible for 7 days during the 2026-07-29 outage)
- internal/checkdefaults/defaults.go: add KindDNS, buildKind case for 'dns'
  that creates a check_def at 5-minute intervals
- internal/scheduler/scheduler.go: add checkDNS probe + wire in executeCheck

The DNS checker catches stale/unreachable zones (e.g. matrix.hubris.network
pointing to a dead VPS IP). The VPS HTTP check probes the public endpoint
every 60s, closing the 7-day monitoring gap.
2026-08-05 15:31:50 +02:00
2254a07baf plan done: agent execution safety — move to done/, update index 2026-08-05 15:26:03 +02:00
1b9c761274 implements plan: agent execution safety — QEMU guest agent gate + health guard + policy docs
I — run pre-flights QEMU guest agent before queueing VM execution
  classifyAndGate now checks vm: targets for qemu_guest_agent attribute.
  If not_running/missing, returns immediate error instead of queuing forever.

II — policy.yaml: documented host-mutation classifier rule
  Added comment clarifying that host-level package/kernel mutations
  (apt-get install, dpkg, systemctl enable) always classify as
  config_mutation and thus need operator approval.

III — health attribute read-only in update_entity_attributes
  Strips scheduler-owned keys (health, last_check_at, last_check) from
  attribute updates with a clear message directing agents to
  get_health_summary / list_checks instead.

IV — Recorded discovered dependency edges
  vm:zimaos → depends-on → lxc:nfs-export (NFS /media/library mount)
  vm:zimaos → depends-on → host:strong (NFS /media/ludo-library mount)

Also updated the run tool description to mention both guardrails.
2026-08-05 15:25:14 +02:00
a126cfa710 0.24.0 — MCP tool improvements: type filter for get_relations, health filter for get_health_summary, live HTTP probe for ping_service
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
get_relations now accepts an optional 'types' (comma-separated) parameter
to filter relationship types — filters out the noisy exec/targets edges
that previously drowned useful host/provides edges.

get_health_summary now accepts an optional 'health' (comma-separated)
parameter to return only entities in specific health states (e.g.
'health=down,stale') instead of the full 100+ entity list.

ping_service now:
- Falls back to e.attributes->>'public_host' when 'url' is not set
  (covers LXCs that only have public_host in the graph)
- Performs a live HTTP HEAD probe against the resolved URL, returning
  the actual status code instead of just the scheduler's stale health
  state

Also: fixed matrix.hubris.network DNS record (was pointing to dead VPS),
pruned 6 dead graph edges, wired url attributes on 7 LXCs, added VPS
HTTP monitoring check, and resolved the 18k-occurrence unmonitored signal.

This session's audit is documented as
document:nomos/2026-08-05-dns-monitoring-improvements-for-strong-hosted-services.
2026-08-05 15:12:04 +02:00
86fa57b5cd plan: agent execution safety — QEMU guest agent gate + host-mutation guard
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
2026-08-05 11:57:57 +02:00
8e97d589af scripts: ZimaOS NFS mount fix — both /media/library and /media/ludo-library 2026-08-05 08:31:04 +02:00
0dd8c28815 feat: MCP ping tool, tightened descriptions, and Hermes client docs
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
- Add  MCP tool — lightweight connectivity check returning server
  identity, no DB hit (resolves agent connection-test friction)
- Tighten 6 tool descriptions (get_relations, get_health_summary,
  query_metrics, get_trend, get_event_timeline, ping) to be searchable
  in the first 8-12 words
- Document Hermes MCP client setup in ADR-0012 with token security caveat
- Move completed plan to plans/done/
2026-08-05 00:21:56 +02:00
4e294b3630 0.22.0 — full MCP agent surface: 19 new tools, async run, session reliability
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
Stage 1 — Foundation:
- Target validation: iptables + systemctl/docker target-type gates
- Async run for long-running commands (sleep/wait/poll loops)
- audit_log.session_id plumbing (SQL, sqlcgen, 18 call sites)

Stage 2 — External agent observe (11 tools):
- get_dashboard_summary, get_ontology, list_checks, list_executions
- get_knowledge_revisions, get_knowledge_duplicates, get_knowledge_orphans
- list_knowledge_tags, list_entity_sessions, find_entities_by
- 3 resource templates: oikos://entity/{slug}, knowledge/{id}, execution/{id}

Stage 3 — Nomos reliability:
- complete_task(success) refused without verification (upgraded from warn)
- sessionHasPlan excludes replaced steps (forces propose_plan after reopen)
- Bash syntax validation in run() (rejects literal \n, flag-space typos)
- Scope gate in SOUL.md (ask before pivoting to unrelated subsystem)

Stage 4 — External agent act (9 mutation tools):
- ack_signal, resolve_signal, mute_signal, cancel_execution
- update_check, delete_knowledge, restore_knowledge
- merge_knowledge, rename_knowledge_tag
2026-08-04 23:51:55 +02:00
85f0bb67fa docs(plans): move 2026-08-04 session audit plan to done (v0.21.0 shipped)
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
2026-08-04 23:43:28 +02:00
c3f478b8f8 v0.21.0: agent reliability overhaul — plan integrity, target validation, observability pipelines, learning loop
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
P0 — stop the bleeding:
- prevent premature complete_task(success) when goal involves reachability
- validate run targets: block host-only commands (qm/pct/pvesh) on LXC/VM
- bump MCP client timeout 30s→120s to stop 'context deadline exceeded'

P1 — fix the plan system:
- add replaced_reason column to session_plan_steps (migration 030)
- track WHY steps are replaced (wrong_diagnosis/scope_change/superseded/etc)
- force fresh propose_plan on session resume (reopenSession marks old plan)

P2 — cognitive guardrails:
- SOUL.md scope-gate rule: ask before chasing unrelated subsystems
- auto-upsert knowledge entry on every session close

P3 — observability (all were empty/NULL):
- populate agent_activity.token_count from LLM usage (was always NULL)
- populate nomos_plan_executions linking executions to sessions
- write plan_completion_rate metric on task close

P4 — learning loop (all were empty/NULL):
- auto-classify every run call → classifications table (was 0 rows)
- auto-feedback on session close (was 0 rows)
2026-08-04 23:15:47 +02:00
1aaedf498a v0.20.0: thinking blocks, chat windows overhaul, scroll fix
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
Backend:
- Add isThinking flag to agentEvent for text before tool calls
- Separate thinking from response text in runChatTurn and continue.go
- Persist thinking in a dedicated field in message content

Frontend:
- Add thinking field to MessageContent, ChatMessage, ChatTextEvent types
- Create ThinkingBlock.svelte — collapsible block with brain icon
- SSE handler moves text_delta content to thinking on isThinking flag
- Render thinking block between tools and response in ChatThread
- Fix chat window scroll reset on focus change (stable windowKeys order)
- Remove redundant #key id wrapper in WindowLayer
- Enlarge sidebar rail (24→32 default, 40→60 max)
- Remove glyph from sidebar, square graph at top
- Replace AgentTrace/ToolCallCard/UnifiedTimeline with TurnTrace/ToolLine
2026-08-04 22:42:53 +02:00
20adb89650 v0.18.0: MCP entity-graph CRUD, lifecycle validation, curl -o /dev/null fix
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
- create_entity, set_entity_state, end_relationship MCP tools
- update_entity_attributes now triggers check derivation via EnsureEntityChecks
- shared db.EnsureEntityChecks + db.ValidateTransition hooks (HTTP + MCP parity)
- curl -o /dev/null now classified read_only (was config_mutation)
- db.ErrTransitionInvalid sentinel for HTTP error-type accuracy
- SOUL.md: capability escalation, self-grounding, exploration budget rules
- Runbook: oikos check lifecycle for agent self-knowledge
2026-08-04 08:52:08 +02:00
058f1afcdc fix(web): move composer working-strip into the message pane (stop clipping the input)
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 "Working — message will queue" strip lived inside the sized input Pane, so
appearing/disappearing ate the textarea's fixed height and clipped it, forcing
a resize. Move it into the message Pane alongside the connection/error banners
— those correctly consume transcript space (flex-1) rather than the input's
fixed height. The input Pane is now stable whether or not a background turn is
running. Styling/idiom unchanged.

VERSION: 0.17.2 -> 0.17.3
2026-08-03 22:58:47 +02:00
2b73290994 fix(web): integrate composer "working/queued" strip into the terminal aesthetic
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 background-working hint was a plain muted text line bolted above the
textarea — misaligned with the input column and off-idiom. Restyle it as a
terminal status strip: spinner + uppercase fg "Working" label + muted detail,
hairline primary-tinted border (matching .trace.running), square, aligned to
the textarea's max-w-3xl column. Reads as part of the working state now.

VERSION: 0.17.1 -> 0.17.2
2026-08-03 22:56:14 +02:00
428f4fe945 docs(plans): add status notes missed by the rename (chat-full-polish, health-check-reality)
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
git mv staged the pre-edit index content; the status edits to these two
files landed in the working tree but not the archive commit. Amending the
status now so the archived copies reflect Implemented.
2026-08-03 22:53:29 +02:00
195d45a0e9 docs(plans): reconcile plan statuses; archive 10 done plans
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
Move ten completed plans from plans/ to plans/done/ and update the index:
- 2026-07-18 session-review-three-sessions, 2026-07-20 desktop-mascot,
  2026-07-20 session-review-ten-sessions, 2026-07-21 chat-full-polish,
  2026-07-29 health-check-reality-and-knowledge-graph,
  2026-07-30 session-review-plan-drift, and the four 2026-08-03 chat plans
  (changes-review, reliability-and-ux-audit, cyberspace-style-adoption,
  working-visibility).
- Refresh two stale statuses: cyberspace-style-adoption ("Draft" -> shipped as
  full replacement in v0.16.0/757ef2f) and health-check-reality ("ready for
  implementation" -> shipped across the v0.14.x-0.16.x check commits).
- .gitignore: ignore local tooling artifacts (.playwright-mcp/, config-screen.png).

No code change. index.md Active/Done tables now match the filesystem (no orphans).

VERSION: 0.17.0 -> 0.17.1
2026-08-03 22:52:25 +02:00
5b68bdc16c feat(nomos): chat working-visibility, message queue, generation-aware timeline
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
Make background/long/desynced turns visible and queueable, fixing the four
symptoms that survived the v0.15.0 chat reliability pass.

F1 - status-driven working signal (workspace.ts taskWorking/currentWorking =
streaming OR status in {planning,executing}). Drives the chat trace, indicator,
and activity spinner so a turn with no live stream (background resume, a dropped
SSE, an idle-close mid long turn) still looks alive.

F2 - operator messages sent during an in-flight turn are now QUEUED and
auto-run when the gate frees, replacing the "still finishing a previous step...
send it again" rejection. Per-session in-memory FIFO (messagequeue.go, capped at
20) drained one-at-a-time under the turn gate; a `queued` SSE event drives a
"Queued" hint. drainQueued releases via a per-iteration deferred closure so a
runChatTurn panic can't deadlock the session's gate.

F3 - SSE keepalive (12s `:keepalive` comment) in handleChat so 20-40s
inter-iteration gaps no longer trip a proxy/browser idle close (the desync root
cause). All SSE writes serialized through one mutex.

F4 - generation-aware activity timeline (only the last propose_plan renders;
superseded ones collapse to one "Earlier plan revised" marker; step-attribution
follows only the current generation) + debounced plan refetch on lifecycle
events so a missed plan.proposed self-heals.

Verified against the last session (23da10db: 6m33s turn, operator "status"
deferred at 19:48:05). go test ./cmd/nomos/ green (new messagequeue tests);
web vitest 72/72 (new F4 generation tests); vite build clean.

VERSION: 0.16.0 -> 0.17.0
2026-08-03 22:34:14 +02:00
757ef2f34b feat(web): adopt cyberspace terminal aesthetic + dithered images
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
Rebrand light/dark themes to the cyberspace.online look: warm cream-on-black
palette (light/dark are exact inverses), self-hosted JetBrains Mono + VT323,
square corners, border-driven surfaces with no soft shadows. Adds a terminal
design-system CSS layer (DOS double-border modals with hatched corner, fg focus,
inversion-on-hover), a theme-aware <RasterImage> (Atkinson-dithered canvas with
img fallback), and unifies desktop icons, taskbar, window controls, pills and
links under one idiom. Pins window titlebars to a fixed height and switches chat
auto-scroll off scrollIntoView to avoid titlebar reflow.

VERSION 0.15.1 -> 0.16.0
2026-08-03 22:03:24 +02:00
b27e1bf3ec fix(web): coerce chat composer draft to string (input.trim crash)
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 Send button's disabled={!input.trim()} threw "trim is not a function" when
input was initialized from a non-string initialDraft (a Svelte 5 prop-init edge
where a null/undefined draft reached $state). Coerce at init so the composer
state is always a string.

VERSION: 0.15.0 -> 0.15.1
2026-08-03 16:00:01 +02:00
134 changed files with 34781 additions and 2029 deletions

4
.gitignore vendored
View File

@@ -24,3 +24,7 @@ cmd/desktop/build/
cmd/desktop/Oikos
desktop
/eval
# Local tooling artifacts (Playwright MCP session logs, stray screenshots)
.playwright-mcp/
config-screen.png

View File

@@ -1 +1 @@
0.15.0
0.26.0

View File

@@ -0,0 +1,104 @@
# Oikos check lifecycle — how monitoring works
This runbook covers how Oikos health checks are derived, created, and wired so
an agent (Nomos) doesn't reverse-engineer source when asked to add monitoring to
an entity — the problem that stranded session `23da10db` (2026-08-03).
## Concepts
- **`check_defs`** (scheduler config, table `check_defs`): the row the scheduler
reads to know *what* to probe and *when*. One per check instance.
- **`check` entity** (type `check`, slug `check:<kind>:<target>:<n>`): the
knowledge-graph entity for that check. It carries attributes
(`check_type`, `target`, `port`, …) and `checks` edges to the probed target.
- **`monitoring` spec** on an entity type (`entity_types.monitoring_spec`): the
default list of check kinds (e.g. `[http, process]` for `service`).
- Per-entity override: set `monitoring` in the entity's attributes —
`"none"` for zero checks, `["http"]` to replace the type defaults.
- **`checkdefaults.Ensure`** (`internal/checkdefaults/defaults.go`): the
function that reads the monitoring spec, resolves host/port/URL from
attributes + relationships, and writes `check_defs` rows. Idempotent.
## When checks are derived
`checkdefaults.Ensure` runs in three situations (as of v0.17.1+):
1. **Seed/deploy ingest**`internal/db/seed.go:231`. Every entity gets its
default checks once on initial ingest.
2. **HTTP `POST /api/v1/entities` (create)**`ensureDefaultChecks` at
`internal/httpapi/impl.go:1012`. Creating an entity via the REST API derives
its checks in the same transaction.
3. **HTTP `PATCH /api/v1/entities` (patch)**`ensureDefaultChecks` at
`internal/httpapi/impl.go:1280`. Changing an entity's attributes (especially
`monitoring`) via the REST API regenerates its checks.
4. **MCP `create_entity`** — SAME hook. Creating an entity via the MCP tool
derives checks. (Added 2026-08-03; previously MCP had no create.)
5. **MCP `update_entity_attributes`** — SAME hook. Changing an entity's
`monitoring` attribute via MCP now regenerates checks. (Added 2026-08-03;
previously MCP updates silently skipped check derivation — the exact bug
that stranded the haos session.)
## Check slug grammar
```
check:<kind>:<target-type>:<target-name>:<n>
```
Examples: `check:http:service:jellyfin:0`, `check:vm-status:vm:haos:0`,
`check:cert-expiry:cert:house.hubris.network:0`.
## Adding monitoring to an entity
**If the entity already exists:**
```
update_entity_attributes(slug="service:haos", attributes={"monitoring":["http"]})
```
This regenerates checks via `checkdefaults.Ensure`. The result message tells you
how many checks were derived and whether any kinds were skipped (and why).
**If the entity does not exist yet (a new check, ingress, cert, etc.):**
```
create_entity(type="check", name="HAOS http check",
slug="check:http:service:haos:0",
attributes={"check_type":"http:service","target":"service:haos","port":"8123"})
```
This creates the entity AND derives its `check_defs`. Same for a new `ingress`
(`type=ingress`, monitoring `[http]`) or `cert` (`type=cert`,
monitoring `[cert-expiry]`).
**To remove monitoring:** set `monitoring:["none"]` or transition the entity
to a terminal lifecycle state (`set_entity_state``deprecated`/`destroyed`).
## Caveats
- **A service without a `url` attribute AND without a `probe_unit` gets no
process check** (the http check covers liveness; the process check would
be redundant without an opt-in `probe_unit`). The skip is logged.
- **A service whose address comes from a `hosts` edge** may produce no checks on
initial create because the edge doesn't exist yet — the next inventory ingest
(or a later `update_entity_attributes` after the edge is created) fills it in.
- **A `not found` error from `update_entity_attributes`** means the entity
doesn't exist — use `create_entity` instead.
- **`check_defs` has target columns** (`target_id`, `target_type`). A check
entity needs a `checks` relationship (`create_relationship(source=check:…,
target=service:…, type="checks")`) so the scheduler can resolve what to
probe. `create_entity` derives the check_def; `create_relationship` links
the check entity to its target in the graph.
## Related files
- `internal/checkdefaults/defaults.go``Ensure`, `Target`, `LogResult`
- `internal/httpapi/default_checks.go``ensureDefaultChecks` (HTTP hook)
- `internal/db/checks.go``db.EnsureEntityChecks` (shared hook)
- `internal/db/seed.go` — seed-time check derivation
- `internal/mcp/tools.go``create_entity`, `update_entity_attributes`
## Revision history
- **2026-08-03:** Created after session `23da10db` stranded for lack of entity-
creation tool and unawareness of check-derivation triggers. Covers the MCP
create_entity + update_entity_attributes regen paths added same day.

View File

@@ -60,6 +60,10 @@ type agent struct {
// gate serializes turns per session (at most one in-flight turn per
// sessionID). See turngate.go and plan 2026-08-03 F1.
gate *turnGate
// queue holds operator messages that arrived while a turn was already
// running; they are auto-run when the gate frees (plan 2026-08-03 F2).
// See messagequeue.go.
queue *messageQueue
}
func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug string) (*agent, error) {
@@ -121,6 +125,7 @@ func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug
apiToken: os.Getenv("OIKOS_MCP_BEARER_TOKEN"),
httpClient: &http.Client{Timeout: 15 * time.Second},
gate: newTurnGate(),
queue: newMessageQueue(),
}, nil
}
@@ -176,6 +181,11 @@ type agentEvent struct {
Data any `json:"data,omitempty"`
SessionID string `json:"session_id,omitempty"`
Iteration int `json:"iteration,omitempty"`
// IsThinking marks text/text_delta events that carry the model's internal
// reasoning (text produced before tool calls in the same iteration), as
// distinct from the final response text. The frontend renders these as
// collapsible thinking blocks separated from the response.
IsThinking bool `json:"is_thinking,omitempty"`
}
func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(agentEvent)) {
@@ -372,7 +382,12 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
var msg openai.ChatCompletionMessage
var acc openai.ChatCompletionAccumulator
for attempt := 0; attempt <= maxLLMRetries; attempt++ {
// Capture token usage from this LLM response for activity logging.
// Previously always NULL — every agent_activity row had no token
// count. Now each tool call in this iteration gets the same total.
totalTokens := 0
for attempt := 0; attempt <= maxLLMRetries; attempt++ {
acc = openai.ChatCompletionAccumulator{}
stream := a.provider.Chat.Completions.NewStreaming(ctx, params, a.reqOpts...)
for stream.Next() {
@@ -404,14 +419,19 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
msg = acc.Choices[0].Message
finishReason := acc.Choices[0].FinishReason
// Capture token usage from this iteration.
if acc.Usage.TotalTokens > 0 {
totalTokens = int(acc.Usage.TotalTokens)
}
if len(msg.ToolCalls) == 0 {
if isRefusalOrEmpty(msg.Content) {
if attempt < maxLLMRetries {
slog.Warn("nomos: empty or refusal response, retrying",
"session", sessionID, "iter", i+1, "attempt", attempt+1,
"content_len", len(msg.Content), "finish_reason", finishReason)
continue
}
if attempt < maxLLMRetries {
slog.Warn("nomos: empty or refusal response, retrying",
"session", sessionID, "iter", i+1, "attempt", attempt+1,
"content_len", len(msg.Content), "finish_reason", finishReason)
continue
}
// B.4: surface the real error context (finish_reason +
// refusal text) instead of a generic "empty response" —
// the operator can tell "content_filter — rephrase" from
@@ -460,7 +480,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
// led to each step. Emitting it lets the persist layer accumulate
// per-iteration reasoning into the row's text field.
if strings.TrimSpace(msg.Content) != "" {
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID, IsThinking: true})
}
slog.Info("nomos: tool calls", "count", len(msg.ToolCalls), "iter", i+1, "correlation", correlationID)
@@ -495,7 +515,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
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)
tc.Function.Arguments, directive, 0, false, correlationID, totalTokens)
emit(agentEvent{
Type: "tool_result",
Data: map[string]any{"name": tc.Function.Name, "result": directive, "id": tc.ID, "retry_capped": true},
@@ -546,7 +566,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
inputStr := string(inputJSON)
if callErr != nil {
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID)
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID, totalTokens)
// Retry cap: dispatch errors (e.g. MCP client timeout)
// count toward the cap too. A command that keeps timing
@@ -576,7 +596,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
}
resultJSON, _ := json.Marshal(result)
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, string(resultJSON), elapsed, true, correlationID)
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, string(resultJSON), elapsed, true, correlationID, totalTokens)
// Link any execution this tool queued/started back to this
// session, so the auto-continuation worker can feed its result

View File

@@ -222,7 +222,13 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
slog.Info("nomos: turn already active, skipping background resume", "session", sessionID)
return false
}
defer a.gate.release(sessionID)
// Release the gate, then drain any operator message that was queued while
// this background turn ran (plan 2026-08-03 F2). Queued messages are run as
// real user turns server-side; resumeSession itself never enqueues.
defer func() {
a.gate.release(sessionID)
safego.Go("nomos:drain:"+sessionID, func() { a.drainQueued(context.Background(), sessionID) })
}()
placeholder, _ := json.Marshal(map[string]any{
"role": "assistant",
@@ -236,6 +242,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
var toolCalls []map[string]any
var finalText, errText string
var finalThinking string
persist := func() {
if msgID == uuid.Nil {
@@ -248,6 +255,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
body, _ := json.Marshal(map[string]any{
"role": "assistant",
"text": text,
"thinking": finalThinking,
"tool_calls": toolCalls,
"auto": true, // marks this as an autonomous continuation, not an operator turn
})
@@ -283,10 +291,14 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
}
}
toolCalls, finalText, errText = nil, "", ""
finalThinking = ""
// P3: accumulate per-iteration reasoning instead of overwriting
// (same fix as main.go's chat handler). Without this, a resumed
// turn's intermediate thinking is lost on reload.
// (same fix as main.go's chat handler). Without this, a resumed
// turn's intermediate thinking is lost on reload.
var textParts []string
var thinkingParts []string
emit := func(ev agentEvent) {
if ev.Type == "tool_use" || ev.Type == "tool_result" {
if m, ok := ev.Data.(map[string]any); ok {
@@ -313,8 +325,13 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
}
if ev.Type == "text" {
if t, ok := ev.Data.(string); ok && t != "" {
textParts = append(textParts, t)
finalText = strings.Join(textParts, "\n\n")
if ev.IsThinking {
thinkingParts = append(thinkingParts, t)
finalThinking = strings.Join(thinkingParts, "\n\n")
} else {
textParts = append(textParts, t)
finalText = strings.Join(textParts, "\n\n")
}
persist()
}
}

View File

@@ -167,6 +167,147 @@ func sseEvent(w http.ResponseWriter, flusher http.Flusher, event agentEvent) {
flusher.Flush()
}
// runChatTurn is the shared core of an operator-initiated turn: insert an
// assistant placeholder, run a.chat with incremental persistence (so whatever
// happened before an abort is never lost), finalize the row, and derive a
// title. It is agnostic to the transport: `sink` receives every agent event
// for delivery (SSE for a live handleChat, a no-op for a queued turn that has
// no client attached — the frontend learns about those via the poller + the
// status-driven "working" signal). The caller MUST already hold the session's
// turn-gate permit.
func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string, sink func(agentEvent)) {
toolCalls := []map[string]any{}
// P3: accumulate per-iteration reasoning instead of overwriting with the
// final `text` event (see the original inline comment in handleChat).
var textParts []string
var thinkingParts []string
var finalText string
var finalThinking string
placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""})
msgID, err := a.store.insertMessageReturningID(pctx, sessionID, "assistant", placeholder)
if err != nil {
slog.Error("nomos: chat placeholder insert failed", "session", sessionID, "error", err)
}
persist := func() {
if msgID == uuid.Nil {
return
}
body, _ := json.Marshal(map[string]any{
"role": "assistant",
"text": finalText,
"thinking": finalThinking,
"tool_calls": toolCalls,
})
a.store.updateMessage(pctx, msgID, body)
}
a.chat(ctx, sessionID, message, func(ev agentEvent) {
if ev.Type == "tool_use" || ev.Type == "tool_result" {
if m, ok := ev.Data.(map[string]any); ok {
m["type"] = ev.Type
// One entry per tool call: tool_use creates it, tool_result
// merges the result into the same entry (matched by id).
id, _ := m["id"].(string)
if id != "" && ev.Type == "tool_result" {
for _, existing := range toolCalls {
if eID, _ := existing["id"].(string); eID == id {
for k, v := range m {
existing[k] = v
}
break
}
}
} else {
toolCalls = append(toolCalls, m)
}
}
persist() // live: survives even if the client disconnects right after
}
if ev.Type == "text" {
if t, ok := ev.Data.(string); ok && t != "" {
if ev.IsThinking {
thinkingParts = append(thinkingParts, t)
finalThinking = strings.Join(thinkingParts, "\n\n")
} else {
textParts = append(textParts, t)
finalText = strings.Join(textParts, "\n\n")
}
persist()
}
}
sink(ev)
})
// B.6: if the turn ended with no text and no tool calls (the model
// empty-response'd and all retries failed), delete the placeholder row
// instead of persisting an empty bubble.
if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil {
a.store.deleteMessage(pctx, msgID)
} else {
persist() // final state — same row, updated one last time
}
// Title: prefer the goal once set; else the first assistant answer.
if finalText != "" && sessionID != "ephemeral" {
var goalTitle string
if sess, gerr := a.store.getSession(pctx, sessionID); gerr == nil && sess.Goal != "" {
goalTitle = truncate(sess.Goal, 120)
}
title := goalTitle
if title == "" {
title = truncate(finalText, 80)
}
if title != "" {
a.store.updateSessionTitle(pctx, sessionID, title)
}
}
}
// drainAcquireWait is how long drainQueued blocks for a busy gate before
// re-queuing and deferring to the holder's own release-drain. A package var so
// tests can shorten it; in production it just needs to outlast the brief
// release→drain handoff window.
var drainAcquireWait = 5 * time.Second
// drainQueued runs every queued operator message for a session as its own turn,
// one at a time, under the turn gate. Called (in a goroutine) whenever a turn
// releases the gate — from handleChat (live) and resumeSession (background) —
// so a message queued while the agent was busy is acted on as soon as it's
// free, without the operator re-sending. See messagequeue.go (plan 2026-08-03
// F2).
//
// Each queued turn is persisted incrementally and has no SSE client (the
// browser detached after receiving the `queued` event); the frontend sees the
// result via the 3s poller and the status-driven "working" indicator.
func (a *agent) drainQueued(ctx context.Context, sessionID string) {
for {
msg, ok := a.queue.dequeue(sessionID)
if !ok {
return
}
// Block briefly for the gate. If a live turn grabbed it first, put the
// message back — that turn's release will drain it again. Never stack.
if !a.gate.acquire(sessionID, drainAcquireWait) {
a.queue.requeueFront(sessionID, msg)
return
}
slog.Info("nomos: running queued operator message", "session", sessionID)
pctx := context.Background()
// Run the turn inside a per-iteration closure so the gate release is
// deferred to the end of THIS turn (and runs even if runChatTurn
// panics — safego recovers the panic at the goroutine boundary, so a
// non-deferred release would be skipped and the session's permit held
// forever, deadlocking all future turns). A bare `defer release` in
// the loop would be wrong too: Go defers run at function exit, not
// iteration exit, so the gate would stay held across iterations.
func() {
defer a.gate.release(sessionID)
a.runChatTurn(pctx, ctx, sessionID, msg, func(agentEvent) {})
}()
}
}
func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
@@ -227,6 +368,17 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering
w.WriteHeader(200)
// All writes to w (events + the keepalive comment below) go through one
// mutex: http.ResponseWriter is NOT safe for concurrent use, and the
// keepalive ticker runs alongside the turn's event sink (plan 2026-08-03
// F3). Without this, interleaved writes corrupt the SSE stream.
var writeMu sync.Mutex
writeEvent := func(ev agentEvent) {
writeMu.Lock()
defer writeMu.Unlock()
sseEvent(w, flusher, ev)
}
ctx := r.Context()
sessionID := req.SessionID
@@ -278,134 +430,65 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
st.answerQuestion(pctx, sessionID, qid, req.Message)
}
sseEvent(w, flusher, agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
writeEvent(agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
// F1 (plan 2026-08-03): serialize turns per session. The user message is
// already persisted above, so even if we can't run this turn right now it
// isn't lost. Wait briefly for a finishing background turn (continuation /
// resume) so the common case is seamless; if one is still running after
// that, tell the operator to retry rather than spawning a second
// concurrent turn (the interleaving this gate exists to prevent). On
// success the permit is held until this handler returns (stream + post-
// processing done); background resumeSession callers skip while it's held.
// F1/F2 (plan 2026-08-03): serialize turns per session. The user message is
// already persisted above, so it is never lost. Wait briefly for a finishing
// background turn; if one is still running after that, QUEUE this message
// (don't reject it) and tell the client so it shows a "queued" state. The
// in-flight turn's release drains the queue (drainQueued) and runs it as a
// real turn server-side. This never stacks concurrent turns — the gate still
// guarantees one in-flight turn per session.
const turnWait = 5 * time.Second
if !a.gate.acquire(sessionID, turnWait) {
slog.Info("nomos: turn already active, deferring operator message", "session", sessionID)
sseEvent(w, flusher, agentEvent{
Type: "error",
Data: "Nomos is still finishing a previous step. Your message was saved — give it a moment to finish, then send it again.",
})
sseEvent(w, flusher, agentEvent{Type: "done", Data: map[string]any{
a.queue.enqueue(sessionID, req.Message)
slog.Info("nomos: turn already active, queued operator message", "session", sessionID)
writeEvent(agentEvent{Type: "queued", Data: sessionID, SessionID: sessionID})
writeEvent(agentEvent{Type: "done", Data: map[string]any{
"session_id": sessionID,
"error": true,
"queued": true,
}, SessionID: sessionID})
return
}
defer a.gate.release(sessionID)
defer func() {
a.gate.release(sessionID)
// Run any message that was queued while this turn held the gate. In a
// goroutine so the HTTP response finishes without waiting on the next
// turn; the queued turn has no SSE client of its own.
safego.Go("nomos:drain:"+sessionID, func() { a.drainQueued(context.Background(), sessionID) })
}()
toolCalls := []map[string]any{}
// P3: accumulate per-iteration reasoning instead of overwriting with
// the final `text` event. The agent loop emits a `text` event for each
// LLM iteration that produced text (intermediate reasoning before tool
// calls + the final answer). Without accumulation, only the last `text`
// survives in the persisted row — a reload shows the final summary but
// not the thinking that led to each tool call.
var textParts []string
var finalText string
// Incremental persistence, mirroring resumeSession's existing
// placeholder+update pattern (continue.go): insert a placeholder now,
// update the SAME row after every tool call, so whatever happened before
// an abort is never lost — only what hadn't happened yet is.
placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""})
msgID, err := st.insertMessageReturningID(pctx, sessionID, "assistant", placeholder)
if err != nil {
slog.Error("nomos: chat placeholder insert failed", "session", sessionID, "error", err)
}
persist := func() {
if msgID == uuid.Nil {
return
}
body, _ := json.Marshal(map[string]any{
"role": "assistant",
"text": finalText,
"tool_calls": toolCalls,
})
st.updateMessage(pctx, msgID, body)
}
a.chat(ctx, sessionID, req.Message, func(ev agentEvent) {
if ev.Type == "tool_use" || ev.Type == "tool_result" {
if m, ok := ev.Data.(map[string]any); ok {
m["type"] = ev.Type
// One entry per tool call: tool_use creates it, tool_result
// merges the result into the same entry (matched by id).
// Before this fix, both events appended separate entries,
// doubling every tool call in the persisted transcript
// (confirmed pre-existing in d9cdcee1, v0.3.x era).
id, _ := m["id"].(string)
if id != "" && ev.Type == "tool_result" {
for _, existing := range toolCalls {
if eID, _ := existing["id"].(string); eID == id {
for k, v := range m {
existing[k] = v
}
break
}
}
} else {
toolCalls = append(toolCalls, m)
}
}
persist() // live: survives even if the client disconnects right after
}
if ev.Type == "text" {
// P3: accumulate. Each `text` event is one iteration's reasoning
// (or the final answer). Join with newlines so the persisted row
// reads as the full transcript of what the agent said, not just
// the last thing.
if t, ok := ev.Data.(string); ok && t != "" {
textParts = append(textParts, t)
finalText = strings.Join(textParts, "\n\n")
persist()
// F3 (plan 2026-08-03): keep the SSE alive during long turns. A turn can
// run for many minutes (provisioning chains, deep research); the model
// often takes 20-40s between tool iterations, and with nothing flushed in
// that gap a proxy/browser idle timeout silently closes the stream. The
// client then sees streaming=false while the server keeps working — the
// "I can't tell it's working" desync. An SSE comment line (":keepalive") is
// ignored by EventSource but resets idle timers.
keepDone := make(chan struct{})
go func() {
t := time.NewTicker(12 * time.Second)
defer t.Stop()
for {
select {
case <-keepDone:
return
case <-t.C:
writeMu.Lock()
fmt.Fprintf(w, ":keepalive\n\n")
flusher.Flush()
writeMu.Unlock()
}
}
sseEvent(w, flusher, ev)
}()
// Defer the close (not a statement after runChatTurn) so the goroutine
// exits even if runChatTurn panics — net/http recovers handler panics, so
// a non-deferred close would be skipped and the ticker would keep writing
// to a dead ResponseWriter forever.
defer close(keepDone)
a.runChatTurn(pctx, ctx, sessionID, req.Message, func(ev agentEvent) {
writeEvent(ev)
})
// B.6: if the turn ended with no text and no tool calls (the model
// empty-response'd and all retries failed), delete the placeholder row
// instead of persisting an empty bubble. The error event was already
// streamed to the frontend via the 'done with error=true' event, so the
// operator sees the error inline — an empty assistant bubble in the
// transcript adds nothing and looks like the agent is broken.
if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil {
st.deleteMessage(pctx, msgID)
} else {
persist() // final state — same row, updated one last time with the concluding text
}
// Generate a meaningful title from the assistant's first answer
// instead of reusing the raw user message for every session.
// P2.9 (2026-07-20): prefer the goal as the title when one is set —
// the first assistant text is often a greeting or narrative that
// doesn't describe the task ("Hey! 👋 Nomos here, running on
// mac-mini:8092..."). The goal is the operator's actual intent.
// Sessions that never call set_goal (pure Q&A) fall back to the
// assistant text, which is still better than the raw user message.
if finalText != "" && sessionID != "ephemeral" {
var goalTitle string
if sess, gerr := st.getSession(pctx, sessionID); gerr == nil && sess.Goal != "" {
goalTitle = truncate(sess.Goal, 120)
}
title := goalTitle
if title == "" {
title = truncate(finalText, 80)
}
if title != "" {
st.updateSessionTitle(pctx, sessionID, title)
}
}
}
func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
@@ -735,7 +818,7 @@ func newMCPClient(baseURL, token string) (*mcpClient, error) {
c := &mcpClient{
baseURL: baseURL,
token: token,
http: &http.Client{Timeout: 30 * time.Second},
http: &http.Client{Timeout: 120 * time.Second},
}
resp, err := c.doRequest("initialize", map[string]any{

82
cmd/nomos/messagequeue.go Normal file
View File

@@ -0,0 +1,82 @@
package main
import (
"log/slog"
"sync"
)
// maxQueuedPerSession caps a session's queue. A held turn plus unbounded
// enqueues would grow memory without limit; an operator nudging a long
// autonomous turn realistically queues only a handful, so a generous cap is
// pure insurance. Overflow drops the newest enqueue and logs (the message is
// already persisted in the DB by handleChat before enqueue, so it isn't lost
// from the transcript — it just won't auto-run).
const maxQueuedPerSession = 20
// messageQueue holds operator messages that arrived while a turn was already
// running for a session. Plan 2026-08-03 (F2): instead of rejecting the
// operator's message with "Nomos is still finishing a previous step… send it
// again", the message is queued and auto-run when the in-flight turn releases
// the session's turn-gate permit.
//
// The queue only schedules WHEN a turn runs, not WHETHER the message is stored
// — handleChat persists the user message before acquiring the gate, so a queued
// message is already in the transcript; this just makes sure a turn eventually
// acts on it.
//
// Draining is strictly one-at-a-time under the turn gate (see drainQueued in
// main.go), so this cannot stack concurrent turns — the exact hazard the gate
// itself exists to prevent. Background resumeSession callers never touch this
// queue; they keep their non-blocking skip.
type messageQueue struct {
mu sync.Mutex
queue map[string][]string
}
func newMessageQueue() *messageQueue {
return &messageQueue{queue: map[string][]string{}}
}
// enqueue appends a message to the back of the session's FIFO. Returns false
// (and logs) if the session is already at maxQueuedPerSession — the caller's
// message is already persisted in the DB, so this only skips auto-running it.
func (q *messageQueue) enqueue(sessionID, msg string) bool {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.queue[sessionID]) >= maxQueuedPerSession {
slog.Warn("nomos: message queue full; dropping auto-run for operator message", "session", sessionID, "cap", maxQueuedPerSession)
return false
}
q.queue[sessionID] = append(q.queue[sessionID], msg)
return true
}
// dequeue pops the next message from the front of the session's FIFO. Returns
// ok=false when empty.
func (q *messageQueue) dequeue(sessionID string) (string, bool) {
q.mu.Lock()
defer q.mu.Unlock()
xs := q.queue[sessionID]
if len(xs) == 0 {
return "", false
}
m := xs[0]
q.queue[sessionID] = xs[1:]
return m, true
}
// requeueFront pushes a message back to the front — used when a drainer popped
// a message but lost the race for the gate to a live turn; that turn's own
// release will drain it again.
func (q *messageQueue) requeueFront(sessionID, msg string) {
q.mu.Lock()
defer q.mu.Unlock()
q.queue[sessionID] = append([]string{msg}, q.queue[sessionID]...)
}
// peek reports the queued depth for a session (test/diagnostic helper).
func (q *messageQueue) peek(sessionID string) int {
q.mu.Lock()
defer q.mu.Unlock()
return len(q.queue[sessionID])
}

View File

@@ -0,0 +1,142 @@
package main
import (
"context"
"sync"
"testing"
"time"
)
func TestMessageQueue_FIFO(t *testing.T) {
q := newMessageQueue()
q.enqueue("s", "first")
q.enqueue("s", "second")
q.enqueue("s", "third")
want := []string{"first", "second", "third"}
for _, w := range want {
got, ok := q.dequeue("s")
if !ok || got != w {
t.Fatalf("dequeue = %q,%v want %q,true", got, ok, w)
}
}
if _, ok := q.dequeue("s"); ok {
t.Fatal("dequeue on drained queue should return ok=false")
}
}
func TestMessageQueue_RequeueFront(t *testing.T) {
q := newMessageQueue()
q.enqueue("s", "a")
q.enqueue("s", "b")
// Pop "a", then push it back to the front; "a" must come out before "b".
a, _ := q.dequeue("s")
q.requeueFront("s", a)
got, _ := q.dequeue("s")
if got != "a" {
t.Fatalf("after requeueFront, dequeue = %q want %q", got, "a")
}
got2, _ := q.dequeue("s")
if got2 != "b" {
t.Fatalf("next dequeue = %q want %q", got2, "b")
}
}
func TestMessageQueue_IsolatedPerSession(t *testing.T) {
q := newMessageQueue()
q.enqueue("s1", "one")
q.enqueue("s2", "two")
if got, _ := q.dequeue("s1"); got != "one" {
t.Fatalf("s1 = %q want one", got)
}
if got, _ := q.dequeue("s2"); got != "two" {
t.Fatalf("s2 = %q want two", got)
}
if q.peek("s1") != 0 || q.peek("s2") != 0 {
t.Fatal("both sessions should be drained")
}
}
func TestMessageQueue_Concurrent(t *testing.T) {
q := newMessageQueue()
const n = maxQueuedPerSession // stay under the cap so every enqueue lands
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
q.enqueue("s", "m")
}(i)
}
wg.Wait()
if q.peek("s") != n {
t.Fatalf("peek = %d want %d (all enqueues must be counted)", q.peek("s"), n)
}
seen := 0
for {
if _, ok := q.dequeue("s"); !ok {
break
}
seen++
}
if seen != n {
t.Fatalf("drained %d want %d", seen, n)
}
}
func TestMessageQueue_CapsOverflow(t *testing.T) {
q := newMessageQueue()
for i := 0; i < maxQueuedPerSession; i++ {
if !q.enqueue("s", "m") {
t.Fatalf("enqueue #%d within cap should succeed", i)
}
}
if q.enqueue("s", "overflow") {
t.Fatal("enqueue past the cap should return false (dropped)")
}
if got := q.peek("s"); got != maxQueuedPerSession {
t.Fatalf("peek = %d want %d (overflow must not append)", got, maxQueuedPerSession)
}
}
// drainQueued on an empty queue must be a no-op: it returns immediately and
// never touches the gate (so the session stays free for the next turn).
func TestDrainQueued_NoOpOnEmpty(t *testing.T) {
a := &agent{gate: newTurnGate(), queue: newMessageQueue()}
a.drainQueued(context.Background(), "s")
if !a.gate.acquire("s", 0) {
t.Fatal("gate should be free after a no-op drain (drain must not hold it)")
}
a.gate.release("s")
}
// With a queued message but the gate held by another turn, drainQueued must
// re-queue the message and return WITHOUT running a turn (no store/provider → a
// real run would panic). This is the "never stack" property: a busy gate
// defers to the holder's own release-drain.
func TestDrainQueued_RequeuesWhenBusy(t *testing.T) {
prev := drainAcquireWait
drainAcquireWait = 10 * time.Millisecond
t.Cleanup(func() { drainAcquireWait = prev })
a := &agent{gate: newTurnGate(), queue: newMessageQueue()}
if !a.gate.acquire("s", 0) {
t.Fatal("precondition: hold the gate")
}
a.queue.enqueue("s", "queued-msg")
done := make(chan struct{})
go func() {
a.drainQueued(context.Background(), "s") // must not panic; must requeue
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("drainQueued did not return promptly while the gate was busy")
}
if got := a.queue.peek("s"); got != 1 {
t.Fatalf("message should be re-queued while busy; peek = %d want 1", got)
}
a.gate.release("s")
}

View File

@@ -807,10 +807,11 @@ func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
// 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
// takes the fresh-generation path.
// takes the fresh-generation path. replaced_reason records the cause
// (2026-08-04 plan-step integrity audit).
s.pool.Exec(ctx,
`UPDATE session_plan_steps SET status = 'replaced', finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`,
sessionID)
`UPDATE session_plan_steps SET status = 'replaced', replaced_reason = $2, finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`,
sessionID, "goal superseded")
if _, err := s.pool.Exec(ctx,
`UPDATE agent_sessions SET goal = $2, status = 'executing', title = $2, last_active_at = now() WHERE id = $1`,
sessionID, goal); err != nil {
@@ -850,6 +851,14 @@ func (s *store) reopenSession(ctx context.Context, sessionID string) bool {
s.pool.Exec(ctx,
`UPDATE agent_sessions SET status = 'executing', outcome = NULL, summary = NULL, last_active_at = now() WHERE id = $1`,
sessionID)
// Mark the prior plan's steps as replaced so the P1 plan-first gate in
// classifyAndGate forces a fresh propose_plan before any run. Without
// this, the agent could resume a session and call run against the old
// (completed) plan — exactly what caused the ZimaOS continuation to
// have 81 ad-hoc tool calls with zero plan structure (2026-08-04).
s.pool.Exec(ctx,
`UPDATE session_plan_steps SET status = 'replaced', replaced_reason = $2, finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`,
sessionID, "session reopened — awaiting new plan")
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.reopened", s.taskEntityPtr(ctx, sessionID),
"info", "nomos", sessionID, map[string]any{"prior_status": currentStatus})
return true
@@ -908,9 +917,11 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
// The rows are kept for the generation counter (MAX(generation)+1 below)
// and the plan_generations eval assertion. `replaced` steps are excluded
// from the anyStarted check above, so they don't block this proposal.
// replaced_reason records the cause — required by the plan-step integrity
// gate (2026-08-04 session audit).
if _, err := tx.Exec(ctx,
`UPDATE session_plan_steps SET status = 'replaced', finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status = 'pending'`,
sessionID); err != nil {
`UPDATE session_plan_steps SET status = 'replaced', replaced_reason = $2, finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status = 'pending'`,
sessionID, "superseded by new plan generation"); err != nil {
return nil, err
}
@@ -972,7 +983,7 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
// be marked complete while an earlier step is still pending, preventing the
// agent from marking step 5 done before step 4 (observed in production: the
// agent rushed to close all steps in a final turn, in reverse order).
func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, status, execID string) error {
func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, status, execID, replacedReason string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil
}
@@ -1025,16 +1036,30 @@ func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, s
// status <> 'replaced' is defense-in-depth: MAX(generation) can't hold a
// replaced row, but if it ever could, this refuses the write instead of
// resurrecting it. No matching row → errPlanStepNotFound (stale/out-of-range seq).
err := s.pool.QueryRow(ctx, `
UPDATE session_plan_steps
SET status = $4, execution_id = COALESCE($5, execution_id)`+stamp+`
WHERE session_id = $1 AND generation = $2 AND seq = $3 AND status <> 'replaced'
RETURNING id, target_slug`, sessionID, curGen, seq, status, execPtr).Scan(&stepID, &targetSlug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return errPlanStepNotFound
if status == "replaced" && replacedReason != "" {
err := s.pool.QueryRow(ctx, `
UPDATE session_plan_steps
SET status = $4, execution_id = COALESCE($5, execution_id), replaced_reason = $6`+stamp+`
WHERE session_id = $1 AND generation = $2 AND seq = $3 AND status <> 'replaced'
RETURNING id, target_slug`, sessionID, curGen, seq, status, execPtr, replacedReason).Scan(&stepID, &targetSlug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return errPlanStepNotFound
}
return err
}
} else {
err := s.pool.QueryRow(ctx, `
UPDATE session_plan_steps
SET status = $4, execution_id = COALESCE($5, execution_id)`+stamp+`
WHERE session_id = $1 AND generation = $2 AND seq = $3 AND status <> 'replaced'
RETURNING id, target_slug`, sessionID, curGen, seq, status, execPtr).Scan(&stepID, &targetSlug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return errPlanStepNotFound
}
return err
}
return err
}
// Anchor the event to the step's target entity when it has one, else the task.
entPtr := s.taskEntityPtr(ctx, sessionID)
@@ -1218,9 +1243,141 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.status", entPtr, severity, "nomos", sessionID,
map[string]any{"status": status, "outcome": outcome, "summary": summary,
"cancelled_executions": cancelledCount, "blocker": blocker})
// Auto-persist knowledge so the graph learns from this session regardless
// of whether the agent remembered to call upsert_knowledge (2026-08-04
// session audit: only 2.4% of sessions called upsert_knowledge manually).
if outcome == "success" || outcome == "partial" {
autoUpsertKnowledge(ctx, s, sessionID, outcome, summary)
}
// Plan quality metric: compute step completion rate for the session's
// current plan generation. Tracked as a task attribute so the trend
// can be monitored over time (2026-08-04 session audit: 38% baseline).
writePlanCompletionRate(ctx, s, sessionID)
// Auto-feedback: create a feedback entry linking the session's outcome
// to its last execution, feeding the pattern-extraction pipeline that
// has been empty since launch (2026-08-04 session audit: 0 feedback rows).
if outcome == "success" || outcome == "partial" {
autoFeedback(ctx, s, sessionID, outcome, summary)
}
return nil
}
// autoUpsertKnowledge creates a knowledge entry for a completed session,
// capturing what was done and linking it to the entities involved. Called
// automatically from completeTask so every session leaves a trace, even if
// the agent forgot to call upsert_knowledge. Only fired for success/partial
// outcomes (failures don't have actionable discoveries).
func autoUpsertKnowledge(ctx context.Context, s *store, sessionID, outcome, summary string) {
var goal string
if err := s.pool.QueryRow(ctx,
`SELECT COALESCE(goal, '') FROM agent_sessions WHERE id = $1`,
sessionID).Scan(&goal); err != nil || goal == "" {
return
}
title := "Session " + sessionID[:8] + ": " + goal
if len(title) > 200 {
title = title[:200]
}
content := "## Outcome\n" + outcome + "\n\n## Summary\n" + summary
kind := "investigation"
slug := "investigation:nomos/" + sessionID
tags := []string{"nomos-session", "auto-generated"}
// Upsert the knowledge entity.
docID, _ := uuid.NewV7()
if err := s.pool.QueryRow(ctx, `
INSERT INTO entities (id, slug, type, name, attributes)
VALUES ($1, $2, $3, $4, '{}')
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, updated_at = now()
RETURNING id`, docID, slug, kind, title).Scan(&docID); err != nil {
slog.Warn("nomos: autoUpsertKnowledge entity insert", "session", sessionID, "error", err)
return
}
// Upsert the knowledge content.
if _, err := s.pool.Exec(ctx, `
INSERT INTO knowledge_entities (entity_id, title, content, source, tags, updated_at)
VALUES ($1, $2, $3, 'nomos-agent', $4, now())
ON CONFLICT (entity_id) DO UPDATE
SET title = EXCLUDED.title, content = EXCLUDED.content,
tags = EXCLUDED.tags, updated_at = now()`,
docID, title, content, tags); err != nil {
slog.Warn("nomos: autoUpsertKnowledge content insert", "session", sessionID, "error", err)
return
}
// Link to the task entity.
var taskEntID uuid.UUID
if s.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`,
sessionID).Scan(&taskEntID) == nil && taskEntID != uuid.Nil {
s.pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT $1, $2, 'involves', '{"by":"nomos","auto":true}'::jsonb, now()
WHERE NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = $1 AND target_id = $2 AND type = 'involves' AND valid_to IS NULL)`,
taskEntID, docID)
}
slog.Info("nomos: auto-upserted knowledge for session",
"session", sessionID, "outcome", outcome, "slug", slug)
}
// writePlanCompletionRate computes the step completion rate for the current
// plan generation and writes it as a task entity attribute so the trend can
// be tracked. Baseline from 2026-08-04 audit: 38% (15/39 steps reached done).
func writePlanCompletionRate(ctx context.Context, s *store, sessionID string) {
var total, completed int
s.pool.QueryRow(ctx, `
SELECT COUNT(*), COALESCE(SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END), 0)
FROM session_plan_steps
WHERE session_id = $1
AND generation = (SELECT MAX(generation) FROM session_plan_steps WHERE session_id = $1)
AND status <> 'replaced'`, sessionID).Scan(&total, &completed)
if total > 0 {
rate := float64(completed) / float64(total)
attrs, _ := json.Marshal(map[string]any{"plan_completion_rate": rate, "plan_steps_total": total, "plan_steps_completed": completed})
s.pool.Exec(ctx, `
UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now()
WHERE id = (SELECT entity_id FROM agent_sessions WHERE id = $1)`,
sessionID, string(attrs))
slog.Info("nomos: plan completion rate", "session", sessionID, "rate", fmt.Sprintf("%.0f%%", rate*100),
"completed", completed, "total", total)
}
}
// autoFeedback creates a feedback entry linking the session's outcome to its
// last execution, feeding the pattern-extraction pipeline that has been empty
// since launch. Only created for success/partial outcomes (failures don't
// have a specific execution to tie to).
func autoFeedback(ctx context.Context, s *store, sessionID, outcome, summary string) {
// Find the last execution linked to this session.
var execID uuid.UUID
if err := s.pool.QueryRow(ctx, `
SELECT pe.execution_id FROM nomos_plan_executions pe
WHERE pe.session_id = $1::uuid
ORDER BY pe.created_at DESC LIMIT 1`, sessionID).Scan(&execID); err != nil || execID == uuid.Nil {
return
}
fbID, _ := uuid.NewV7()
slug := "feedback:" + fbID.String()
if _, err := s.pool.Exec(ctx, `
INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'feedback', $3, '{}')`,
fbID, slug, "feedback for "+sessionID[:8]); err != nil {
slog.Warn("nomos: autoFeedback entity insert", "session", sessionID, "error", err)
return
}
_, err := s.pool.Exec(ctx, `
INSERT INTO feedback (entity_id, execution_id, outcome, observation, lesson, tags, created_at)
VALUES ($1, $2, $3, $4, $5, $6, now())`,
fbID, execID, outcome, summary, summary, []string{"nomos-session", "auto-generated", "session:" + sessionID[:8]})
if err != nil {
slog.Warn("nomos: autoFeedback insert", "session", sessionID, "error", err)
return
}
slog.Info("nomos: auto-feedback created for session", "session", sessionID, "outcome", outcome)
}
// blockerPatterns maps a substring (case-insensitive) to a structured blocker
// reason. Order matters — earlier patterns take precedence. These are the
// recurring failure signatures from the 2026-07-20 session audit. A
@@ -1310,6 +1467,53 @@ func (s *store) hadDiscovery(ctx context.Context, sessionID string) bool {
return count > 0
}
// sessionGoal returns the session's goal text, empty string if not found.
// Used by complete_task to check whether the goal involved a reachability
// verification before marking success.
func (s *store) sessionGoal(ctx context.Context, sessionID string) string {
if s == nil || sessionID == "" {
return ""
}
var goal string
s.pool.QueryRow(ctx,
`SELECT COALESCE(goal, '') FROM agent_sessions WHERE id = $1`,
sessionID).Scan(&goal)
return goal
}
// hadRecentVerification checks whether the session successfully verified
// reachability in recent turns — ping_service, or a run with curl/wget that
// returned successfully. Used by complete_task as a soft warning when the
// goal involved a reachability check but no recent verification occurred.
func (s *store) hadRecentVerification(ctx context.Context, sessionID string) bool {
if s == nil || sessionID == "" {
return true // fail safe: don't warn when we can't check
}
// Check for ping_service calls in the last 5 activity entries for this session.
var pingCount int
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM (
SELECT 1 FROM agent_activity
WHERE session_id = $1 AND tool_name = 'ping_service' AND success = true
ORDER BY ts DESC LIMIT 5
) sub`, sessionID).Scan(&pingCount)
if pingCount > 0 {
return true
}
// Check for run calls with curl/wget that returned successfully.
var curlCount int
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM (
SELECT 1 FROM agent_activity
WHERE session_id = $1
AND tool_name = 'run'
AND success = true
AND (input_summary LIKE '%curl%' OR input_summary LIKE '%wget%')
ORDER BY ts DESC LIMIT 10
) sub`, sessionID).Scan(&curlCount)
return curlCount > 0
}
// staleGoalSession is a goal-bearing task that's gone idle without reaching
// a terminal state — the idle-sweep worker's work list (fix 2+3 of
// plans/2026-07-11-task-completion-safety-net.md).
@@ -1913,7 +2117,7 @@ func (s *store) resolveArgEntityID(ctx context.Context, args map[string]any) uui
// The (nullable) session_id column carries the conversation id. args is the
// tool call's own arguments, used to best-effort tag the row with the
// entity it acted on (see resolveArgEntityID).
func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName string, args map[string]any, inputSummary, outputSummary string, durationMs int, success bool, correlationID string) {
func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName string, args map[string]any, inputSummary, outputSummary string, durationMs int, success bool, correlationID string, tokenCount int) {
if s == nil || agentID == uuid.Nil {
return
}
@@ -1925,8 +2129,8 @@ func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, t
s.pool.Exec(ctx, `
INSERT INTO agent_activity
(agent_id, session_id, activity_type, tool_name, entity_id, input_summary, output_summary,
duration_ms, success, correlation_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
duration_ms, success, correlation_id, token_count)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
agentID, sessionID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary,
durationMs, success, correlationID)
durationMs, success, correlationID, tokenCount)
}

View File

@@ -192,7 +192,7 @@ func TestProposePlan_RefuseInFlight(t *testing.T) {
}
// Mark step 1 as started.
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", ""); err != nil {
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
t.Fatalf("updatePlanStep: %v", err)
}
@@ -288,10 +288,10 @@ func TestUpdatePlanStep_GenerationRelative(t *testing.T) {
// The model addresses the new plan with 1-based seq. seq=1 must hit
// gen-2 "C", leaving gen-1 "A" (replaced) untouched.
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", ""); err != nil {
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
t.Fatalf("updatePlanStep(seq=1, running): %v", err)
}
if err := s.updatePlanStep(ctx, sess.ID, 1, "done", ""); err != nil {
if err := s.updatePlanStep(ctx, sess.ID, 1, "done", "", ""); err != nil {
t.Fatalf("updatePlanStep(seq=1, done): %v", err)
}
@@ -319,7 +319,7 @@ func TestUpdatePlanStep_GenerationRelative(t *testing.T) {
}
// Out-of-range seq must be refused (no current-gen step there).
if err := s.updatePlanStep(ctx, sess.ID, 99, "running", ""); !errors.Is(err, errPlanStepNotFound) {
if err := s.updatePlanStep(ctx, sess.ID, 99, "running", "", ""); !errors.Is(err, errPlanStepNotFound) {
t.Fatalf("updatePlanStep(seq=99) err = %v, want errPlanStepNotFound", err)
}
}
@@ -341,7 +341,7 @@ func TestCompleteTask_AutoCloseEmitsEvents(t *testing.T) {
t.Fatalf("proposePlan: %v", err)
}
// A is running, B still pending at completion time.
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", ""); err != nil {
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
t.Fatalf("updatePlanStep(1, running): %v", err)
}
if err := s.completeTask(ctx, sess.ID, "success", "done"); err != nil {
@@ -397,7 +397,7 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
// A `run` call (discovery) — should set hadDiscovery, not hadEntityWriteback.
agentID := uuid.New()
s.logActivity(ctx, agentID, sess.ID, "run", nil, "", "uptime output", 100, true, "corr-1")
s.logActivity(ctx, agentID, sess.ID, "run", nil, "", "uptime output", 100, true, "corr-1", 0)
if !s.hadDiscovery(ctx, sess.ID) {
t.Fatal("hadDiscovery = false after a successful run call, want true")
}
@@ -410,7 +410,7 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
if err != nil {
t.Fatalf("createSession: %v", err)
}
s.logActivity(ctx, agentID, sess2.ID, "run", nil, "", "ssh timeout", 100, false, "corr-2")
s.logActivity(ctx, agentID, sess2.ID, "run", nil, "", "ssh timeout", 100, false, "corr-2", 0)
if s.hadDiscovery(ctx, sess2.ID) {
t.Fatal("hadDiscovery = true after a failed run call, want false (no facts learned)")
}
@@ -420,7 +420,7 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
if err != nil {
t.Fatalf("createSession: %v", err)
}
s.logActivity(ctx, agentID, sess3.ID, "get_entity", nil, "", "entity row", 10, true, "corr-3")
s.logActivity(ctx, agentID, sess3.ID, "get_entity", nil, "", "entity row", 10, true, "corr-3", 0)
if s.hadDiscovery(ctx, sess3.ID) {
t.Fatal("hadDiscovery = true after get_entity, want false (DB lookups are not discovery)")
}
@@ -430,12 +430,12 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
if err != nil {
t.Fatalf("createSession: %v", err)
}
s.logActivity(ctx, agentID, sess4.ID, "update_entity_attributes", nil, "", "ok", 10, true, "corr-4")
s.logActivity(ctx, agentID, sess4.ID, "update_entity_attributes", nil, "", "ok", 10, true, "corr-4", 0)
if !s.hadEntityWriteback(ctx, sess4.ID) {
t.Fatal("hadEntityWriteback = false after update_entity_attributes, want true")
}
// And the discovery+writeback combination (the conv3 scenario).
s.logActivity(ctx, agentID, sess4.ID, "run", nil, "", "apt-get update output", 100, true, "corr-5")
s.logActivity(ctx, agentID, sess4.ID, "run", nil, "", "apt-get update output", 100, true, "corr-5", 0)
if !s.hadDiscovery(ctx, sess4.ID) {
t.Fatal("hadDiscovery = false after run+writeback, want true")
}

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log/slog"
"regexp"
"strings"
"time"
)
@@ -306,7 +307,8 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
if seq <= 0 || status == "" {
return "error: update_plan_step needs seq (>=1) and status", true
}
if err := a.store.updatePlanStep(ctx, sessionID, seq, status, execID); err != nil {
reason, _ := args["replaced_reason"].(string)
if err := a.store.updatePlanStep(ctx, sessionID, seq, status, execID, reason); err != nil {
if errors.Is(err, errPlanStepNotFound) {
// The seq doesn't address a step in the CURRENT plan — most
// often a stale 1-based number the model carried across a
@@ -373,6 +375,18 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) && !a.store.hadEntityWriteback(ctx, sessionID) {
return "Refused: this session ran `run` against live targets (discovery) but did not call update_entity_attributes or create_relationship to persist what you learned. The knowledge graph will drift if you complete without writeback. Call update_entity_attributes for each entity you ran against (versions, states, counts, timestamps), and create_relationship for any edge you discovered, then call complete_task again. Outcome is held at 'executing' until you do.", true
}
// D.2: refuse success when the goal mentions a reachability/uptime
// check but no verification was done. The agent can't claim "X is
// reachable" based on a shell command alone — the proxy (Caddy) can
// return 200 for a terminal page (ttyd) or fallback while the actual
// dashboard is still down. Must call ping_service or run a successful
// curl before claiming success.
if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) {
goal := a.store.sessionGoal(ctx, sessionID)
if mentionsReachability(goal) && !a.store.hadRecentVerification(ctx, sessionID) {
return "Refused: the goal involves a reachability or uptime check (\"make X reachable\", \"get X up\", etc.), but no ping_service call or successful curl/HTTP request against the target was detected. Caddy can return 200 for a terminal or fallback page while the actual service is still down — you must verify the service itself, not just the proxy. Call ping_service(target) or run a curl against the actual service URL, then call complete_task again. Outcome held until verified.", true
}
}
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil {
if errors.Is(err, errTaskAlreadyComplete) {
return "Task is already complete. Do not call complete_task again. If the operator pointed out a UI/sidebar inconsistency, fix it with update_plan_step (reconcile step states) or summarize the panel in your reply — do not re-execute the work.", true
@@ -389,6 +403,28 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
}
}
// reachabilityPatterns matches goal text that involves making something
// reachable/accessible/working. Used by complete_task to surface a soft
// warning when the session goal was about reachability but no verification
// occurred before marking success.
var reachabilityPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)https?://[^\s]+`),
regexp.MustCompile(`(?i)\.hubris\.net\w+`),
regexp.MustCompile(`(?i)(un)?reachable`),
regexp.MustCompile(`(?i)(not?\s+)?(accessible|reachable|responding|resolving)`),
regexp.MustCompile(`(?i)diagnose\s+why`),
regexp.MustCompile(`(?i)(fix|restore|bring\s+back).*(accessible|reachable|online)`),
}
func mentionsReachability(goal string) bool {
for _, p := range reachabilityPatterns {
if p.MatchString(goal) {
return true
}
}
return false
}
// autoCompleteTrivialTask is the case-1 fix from
// plans/2026-07-11-task-completion-safety-net.md: a session that never
// called set_goal never framed itself as a structured task, so a turn that

View File

@@ -8,7 +8,8 @@ FROM node:22-alpine AS builder
WORKDIR /build/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/vendor /build/vendor
RUN npm install --no-audit --no-fund
COPY VERSION ./
COPY web/ ./
RUN npm run build

View File

@@ -234,9 +234,33 @@ sequenceDiagram
---
**2026-07-08 — renamed to Nomos.** The Hermes agent gateway was renamed to
**2026-07-08 — renamed to Nomos.**
Nomos (from *oikonomos*, the steward of the oikos) under the
[Nomos resident agent plan](../../plans/2026-07-08-nomos-resident-agent.md),
### Hermes MCP client setup
To connect a Hermes Agent instance to oikos as a native MCP client, add to
`~/.hermes/config.yaml`:
```yaml
mcp_servers:
oikos:
url: "https://mcp.hubris.network/mcp"
headers:
Authorization: "Bearer <OIKOS_MCP_BEARER_TOKEN>"
timeout: 180
```
Run `/reload-mcp` in-session or restart Hermes. Tools appear as
`mcp__oikos__*`.
**Caveat:** Hermes stores the bearer token in plaintext in `config.yaml`
it does not support `${VAR}` interpolation in MCP server headers. Ensure
`security.redact_secrets: true` (default) so the token value is stripped
from tool output and logs. File an upstream feature request at
https://github.com/NousResearch/hermes-agent/issues for env-var
interpolation support.
N0 milestone. The gateway binary (`cmd/nomos`), Docker service, DB slug
(`agent:nomos`), and all referencing docs were updated. All architectural
principles in this ADR remain unchanged.

View File

@@ -33,6 +33,7 @@ const (
KindBackup = "backup-freshness"
KindCertExpiry = "cert-expiry"
KindVMStatus = "vm-status"
KindDNS = "dns"
)
// defaultBackupMaxAge is how long a backup target may go without a new
@@ -306,6 +307,23 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p
interval: 60,
}}, ""
case KindDNS:
// Resolve the entity's name via DNS to verify the zone is reachable.
// Uses the entity name (zone apex) or falls back to the slug.
name := t.Name
if name == "" {
name = strings.TrimPrefix(t.Slug, "zone:")
}
if name == "" {
return nil, "no name to resolve"
}
return []checkDef{{
kind: "dns",
config: map[string]any{"name": name},
interval: 300, // 5 min — DNS changes are rare; the cost of a miss
// is a stale IP, not a service outage.
}}, ""
case KindCertExpiry:
// The host whose cert to read (SNI / cert CN). Prefer an explicit
// `hostname` attribute, then `cn`, then a dotted name. Hourly: expiry

34
internal/db/checks.go Normal file
View File

@@ -0,0 +1,34 @@
package db
import (
"context"
"github.com/dtoro/oikos/internal/checkdefaults"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// EnsureEntityChecks derives an entity's default check_defs from the
// monitoring spec of its type (resolving per-entity `monitoring` overrides).
//
// This is the single shared hook that keeps the check graph in sync with
// entity mutations. Both the HTTP create/patch handlers and the MCP
// entity-mutation tools (create_entity, update_entity_attributes) call it so
// that flipping an entity's `monitoring` attribute regenerates checks
// regardless of which surface made the change — previously only the HTTP
// path ran check derivation, so entities mutated via MCP silently produced no
// checks (see plans/2026-08-03-session-review-haos-monitoring-capability-gaps.md, A2).
func EnsureEntityChecks(ctx context.Context, tx pgx.Tx, id uuid.UUID, slug, entityType, name string, attrs []byte) (checkdefaults.Result, error) {
tree, err := LoadTypeTree(ctx, tx)
if err != nil {
return checkdefaults.Result{}, err
}
res, err := checkdefaults.Ensure(ctx, tx, tree, checkdefaults.Target{
ID: id, Slug: slug, Type: entityType, Name: name, Attrs: attrs,
})
if err != nil {
return res, err
}
checkdefaults.LogResult(slug, entityType, res)
return res, nil
}

146
internal/db/lifecycle.go Normal file
View File

@@ -0,0 +1,146 @@
package db
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ErrTransitionInvalid is a sentinel returned by ValidateTransition when the
// from→to pair is not a declared lifecycle transition or a precondition fails.
// Callers test with errors.Is to distinguish semantic validation failures
// (→ HTTP 409) from infrastructure errors (→ HTTP 500).
var ErrTransitionInvalid = errors.New("invalid lifecycle transition")
// ValidateTransition enforces an entity type's lifecycle: fromState → toState
// must be a declared transition, and every precondition it lists must hold. A
// type with no lifecycle defined allows any state. A no-op (fromState ==
// toState) passes immediately.
//
// Shared by the HTTP PATCH path and the MCP set_entity_state tool so both
// surfaces apply identical lifecycle rules — previously only the HTTP path
// validated transitions, so an agent changing state via MCP could skip the
// graph's retire/deprecate guardrails entirely.
func ValidateTransition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, fromState, toState string) error {
if toState == fromState {
return nil
}
lc, err := sqlcgen.New(tx).GetLifecycleForType(ctx, entityType)
if err != nil {
if err == pgx.ErrNoRows {
return nil // no lifecycle defined → any state allowed
}
return err
}
var transitions map[string]map[string]json.RawMessage
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
return fmt.Errorf("parse lifecycle transitions: %w", err)
}
tos, ok := transitions[fromState]
if !ok {
return fmt.Errorf("%w: no transitions defined from %q", ErrTransitionInvalid, fromState)
}
trans, ok := tos[toState]
if !ok {
return fmt.Errorf("%w: %s → %s is not a declared lifecycle transition", ErrTransitionInvalid, fromState, toState)
}
var gate struct {
Requires []string `json:"requires"`
}
if err := json.Unmarshal(trans, &gate); err == nil {
for _, check := range gate.Requires {
if err := checkPrecondition(ctx, tx, entityID, entityType, check); err != nil {
return fmt.Errorf("%w: precondition %q not met: %w", ErrTransitionInvalid, check, err)
}
}
}
return nil
}
// checkPrecondition evaluates one mechanical precondition named by a lifecycle
// transition's `requires` list. Soft/operator-confirmed checks pass; unknown
// checks are skipped (operator intent overrides). Moved here from httpapi so
// both surfaces share one implementation.
func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, check string) error {
switch check {
case "no-inbound-edges":
var count int
if err := tx.QueryRow(ctx,
"SELECT count(*) FROM relationships WHERE target_id = $1 AND valid_to IS NULL", entityID).Scan(&count); err != nil {
return err
}
if count > 0 {
return fmt.Errorf("%d inbound relationship edges remaining", count)
}
case "backups-verified", "secrets-revoked", "ingress-dns-removed":
var attrs string
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs); err != nil {
return err
}
want := map[string]string{
"backups-verified": "backups_verified",
"secrets-revoked": "secrets_revoked",
"ingress-dns-removed": "ingress_dns_removed",
}[check]
if !strings.Contains(attrs, want) {
return fmt.Errorf("%s not recorded in entity attributes", want)
}
case "age-key-enrolled-if-needed":
if entityType == "workstation" {
var attrs string
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs); err != nil {
return err
}
if !strings.Contains(attrs, "age_pubkey") {
return fmt.Errorf("age key not enrolled (no age_pubkey in attributes)")
}
}
case "mesh-joined-if-needed":
if entityType == "workstation" {
var attrs string
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs); err != nil {
return err
}
if !strings.Contains(attrs, "mesh_ip") {
return fmt.Errorf("mesh not joined (no mesh_ip in attributes)")
}
}
case "health-check-answering":
st, err := sqlcgen.New(tx).GetEntityStatus(ctx, entityID)
if err != nil || st.Health == "unknown" || st.Health == "down" {
h := "unknown"
if err == nil {
h = st.Health
}
return fmt.Errorf("health check not answering (status: %s)", h)
}
case "doc-page-complete":
var count int
if err := tx.QueryRow(ctx, `
SELECT count(*) FROM relationships r
JOIN entities ke ON ke.id = r.source_id
WHERE r.target_id = $1 AND r.valid_to IS NULL
AND r.type = 'documents' AND ke.type IN ('document','runbook','investigation')`,
entityID).Scan(&count); err != nil {
return err
}
if count == 0 {
return fmt.Errorf("no documentation linked to entity")
}
case "inventory-entry", "ip-reserved", "storage-pool-chosen", "cancelled-note",
"preflight-passed", "error-summary", "replacement-live-or-role-retired",
"replacement-failed", "post-verify-passed", "recovery-verified", "written-off",
"ingress-live-if-public", "doc-page-stub", "un-deprecate-note", "write-off-note":
// Soft checks — always pass. Operator-confirmed via the transition
// request itself, or not mechanically enforceable.
default:
// Unknown preconditions are skipped (operator intent overrides).
}
return nil
}

View File

@@ -25,8 +25,8 @@ ON CONFLICT (actor, key) DO NOTHING;
-- name: InsertAuditEntry :exec
INSERT INTO audit_log (actor_type, actor_id, action, entity_id, method, path,
status_code, detail, source_ip, correlation_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
status_code, detail, source_ip, correlation_id, session_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
-- name: InsertEvent :one
INSERT INTO events (type, entity_id, severity, source, data, correlation_id)

View File

@@ -380,6 +380,8 @@ type RelationshipType struct {
Cardinality string
Description *string
CreatedAt time.Time
// Which end of this edge depends on the other. forward = target depends on source. backward = source depends on target. none = no runtime dependency. Drives blast_radius().
BlastDirection string
}
type RiskClass struct {
@@ -396,18 +398,19 @@ type SeedVersion struct {
}
type SessionPlanStep struct {
ID uuid.UUID
SessionID uuid.UUID
Seq int32
Title string
Detail string
Status string
ExecutionID *uuid.UUID
TargetSlug *string
StartedAt *time.Time
FinishedAt *time.Time
CreatedAt time.Time
Generation int32
ID uuid.UUID
SessionID uuid.UUID
Seq int32
Title string
Detail string
Status string
ExecutionID *uuid.UUID
TargetSlug *string
StartedAt *time.Time
FinishedAt *time.Time
CreatedAt time.Time
Generation int32
ReplacedReason *string
}
type SessionQuestion struct {

View File

@@ -99,7 +99,7 @@ func (q *Queries) ListLifecycleDefs(ctx context.Context) ([]LifecycleDef, error)
}
const listRelationshipTypes = `-- name: ListRelationshipTypes :many
SELECT name, inverse, source_type, target_type, cardinality, description, created_at FROM relationship_types ORDER BY name
SELECT name, inverse, source_type, target_type, cardinality, description, created_at, blast_direction FROM relationship_types ORDER BY name
`
func (q *Queries) ListRelationshipTypes(ctx context.Context) ([]RelationshipType, error) {
@@ -119,6 +119,7 @@ func (q *Queries) ListRelationshipTypes(ctx context.Context) ([]RelationshipType
&i.Cardinality,
&i.Description,
&i.CreatedAt,
&i.BlastDirection,
); err != nil {
return nil, err
}

View File

@@ -353,8 +353,8 @@ func (q *Queries) InsertApproval(ctx context.Context, arg InsertApprovalParams)
const insertAuditEntry = `-- name: InsertAuditEntry :exec
INSERT INTO audit_log (actor_type, actor_id, action, entity_id, method, path,
status_code, detail, source_ip, correlation_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
status_code, detail, source_ip, correlation_id, session_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
type InsertAuditEntryParams struct {
@@ -368,6 +368,7 @@ type InsertAuditEntryParams struct {
Detail []byte
SourceIp *string
CorrelationID *string
SessionID *uuid.UUID
}
func (q *Queries) InsertAuditEntry(ctx context.Context, arg InsertAuditEntryParams) error {
@@ -382,6 +383,7 @@ func (q *Queries) InsertAuditEntry(ctx context.Context, arg InsertAuditEntryPara
arg.Detail,
arg.SourceIp,
arg.CorrelationID,
arg.SessionID,
)
return err
}

View File

@@ -89,6 +89,7 @@ func (s *Server) CreateApprovalRule(ctx context.Context, req gen.CreateApprovalR
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
&id, "POST", "/api/v1/policy/approval-rules", "",
nil,
map[string]any{"action": req.Body.Action, "risk_class": req.Body.RiskClass}); auditErr != nil {
return nil, auditErr
}
@@ -148,6 +149,7 @@ func (s *Server) PatchApprovalRule(ctx context.Context, req gen.PatchApprovalRul
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
&id, "PATCH", "/api/v1/policy/approval-rules/"+req.Id, "",
nil,
map[string]any{"action": req.Body.Action}); auditErr != nil {
return nil, auditErr
}

View File

@@ -154,6 +154,7 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque
if auditErr := observability.Audit(ctx, q, actorType, actor, "decide",
&id, "POST", "/api/v1/approvals/"+req.Id+"/decision", "",
nil,
map[string]any{"decision": status}); auditErr != nil {
return nil, auditErr
}

View File

@@ -81,6 +81,7 @@ func (s *Server) PatchAutonomySettings(ctx context.Context, req gen.PatchAutonom
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
nil, "PATCH", "/api/v1/policy/autonomy", "",
nil,
map[string]any{"keys": keysOfMap(*req.Body)}); auditErr != nil {
return nil, auditErr
}

View File

@@ -182,6 +182,7 @@ func (s *Server) CreateCheck(ctx context.Context, req gen.CreateCheckRequestObje
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
&id, "POST", "/api/v1/checks", "",
nil,
map[string]any{"kind": req.Body.Kind, "slug": slug}); auditErr != nil {
return nil, auditErr
}
@@ -269,6 +270,7 @@ func (s *Server) PatchCheck(ctx context.Context, req gen.PatchCheckRequestObject
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
&id, "PATCH", "/api/v1/checks/"+req.Id, "",
nil,
map[string]any{"enabled": updated.Enabled}); auditErr != nil {
return nil, auditErr
}

View File

@@ -3,31 +3,22 @@ package httpapi
import (
"context"
"github.com/dtoro/oikos/internal/checkdefaults"
"github.com/dtoro/oikos/internal/db"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ensureDefaultChecks derives an entity's default checks from the monitoring
// kinds its type declares.
// kinds its type declares. Thin wrapper over the shared db.EnsureEntityChecks
// hook so the HTTP create/patch paths and the MCP entity-mutation tools stay
// in lockstep.
//
// Note the ordering caveat: an entity created through the API usually has no
// edges yet, so a type whose address comes from its host (a service) will
// produce no checks on this pass. That gap is real and deliberately visible —
// coverageSweep reports it, and the next inventory ingest fills it in once
// the hosting edge exists.
// Note the ordering caveat (carried from db.LoadTypeTree / checkdefaults.Ensure):
// an entity created through the API usually has no edges yet, so a type whose
// address comes from its host (a service) will produce no checks on this pass.
// That gap is real and deliberately visible — coverageSweep reports it, and
// the next inventory ingest fills it in once the hosting edge exists.
func ensureDefaultChecks(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType, name string, attrsJSON []byte) error {
tree, err := db.LoadTypeTree(ctx, tx)
if err != nil {
return err
}
res, err := checkdefaults.Ensure(ctx, tx, tree, checkdefaults.Target{
ID: entityID, Slug: slug, Type: entityType, Name: name, Attrs: attrsJSON,
})
if err != nil {
return err
}
checkdefaults.LogResult(slug, entityType, res)
return nil
_, err := db.EnsureEntityChecks(ctx, tx, entityID, slug, entityType, name, attrsJSON)
return err
}

View File

@@ -67,6 +67,7 @@ func (s *Server) CreateEntityType(ctx context.Context, req gen.CreateEntityTypeR
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
nil, "POST", "/api/v1/ontology/entity-types", "",
nil,
map[string]any{"name": req.Body.Name, "domain": req.Body.Domain}); auditErr != nil {
return nil, auditErr
}
@@ -148,6 +149,7 @@ func (s *Server) PatchEntityType(ctx context.Context, req gen.PatchEntityTypeReq
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
nil, "PATCH", "/api/v1/ontology/entity-types/"+req.Name, "",
nil,
map[string]any{"status": req.Body.Status}); auditErr != nil {
return nil, auditErr
}

View File

@@ -240,6 +240,7 @@ func (s *Server) RequestExecution(ctx context.Context, req gen.RequestExecutionR
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
&id, "POST", "/api/v1/executions", "",
nil,
map[string]any{"action": req.Body.Action, "target": req.Body.Target}); auditErr != nil {
return nil, auditErr
}
@@ -307,6 +308,7 @@ func (s *Server) CancelExecution(ctx context.Context, req gen.CancelExecutionReq
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "cancel",
&id, "POST", "/api/v1/executions/"+req.Id+"/cancel", "",
nil,
map[string]any{"status": "cancelled"}); auditErr != nil {
return nil, auditErr
}

View File

@@ -5,6 +5,7 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"math/big"
"strconv"
@@ -817,7 +818,7 @@ func (s *Server) QueryAudit(ctx context.Context, req gen.QueryAuditRequestObject
rows, err := s.pool.Query(ctx, `
SELECT id, ts, actor_type, actor_id::text, action, entity_id::text,
method, path, status_code, detail, source_ip, correlation_id
method, path, status_code, detail, source_ip, correlation_id, session_id::text
FROM audit_log
WHERE ($1::text IS NULL OR actor_type = $1)
AND ($2::text IS NULL OR actor_id::text = $2)
@@ -838,10 +839,10 @@ func (s *Server) QueryAudit(ctx context.Context, req gen.QueryAuditRequestObject
for rows.Next() {
var a gen.AuditEntry
var detailBytes []byte
var actID, entID, method, path, sourceIP, corrID *string
var actID, entID, method, path, sourceIP, corrID, sessionID *string
var statusCode *int
if err := rows.Scan(&a.Id, &a.Ts, &a.ActorType, &actID, &a.Action, &entID,
&method, &path, &statusCode, &detailBytes, &sourceIP, &corrID); err != nil {
&method, &path, &statusCode, &detailBytes, &sourceIP, &corrID, &sessionID); err != nil {
return nil, err
}
a.ActorId = actID
@@ -999,6 +1000,7 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
entityID := inserted.ID
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
&entityID, "POST", "/api/v1/entities", "",
nil,
map[string]any{"type": req.Body.Type, "slug": slug}); auditErr != nil {
return nil, auditErr
}
@@ -1062,49 +1064,15 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
// Validate lifecycle transition if state is being changed.
if req.Body.State != nil && *req.Body.State != "" {
// Get lifecycle def for the entity's type.
lc, err := sqlcgen.New(tx).GetLifecycleForType(ctx, current.Type)
if err != nil {
if err == pgx.ErrNoRows {
// No lifecycle defined — any state is allowed.
} else {
return nil, err
}
} else {
var transitions map[string]map[string]json.RawMessage
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
return nil, fmt.Errorf("parse lifecycle transitions: %w", err)
}
fromState := ""
if current.State != nil {
fromState = *current.State
}
toState := *req.Body.State
if toState != fromState {
tos, ok := transitions[fromState]
if !ok {
return nil, fmt.Errorf("%w: no transitions from %q", domain.ErrInvalidTransition, fromState)
}
trans, ok := tos[toState]
if !ok {
return nil, fmt.Errorf("%w: %s → %s", domain.ErrInvalidTransition, fromState, toState)
}
// Parse preconditions: {"requires": ["check-name", ...]}
var gate struct {
Requires []string `json:"requires"`
}
if err := json.Unmarshal(trans, &gate); err == nil && len(gate.Requires) > 0 {
for _, check := range gate.Requires {
if err := checkPrecondition(ctx, tx, id, current.Type, check); err != nil {
return nil, fmt.Errorf("%w: precondition %q not met: %v",
domain.ErrInvalidTransition, check, err)
}
}
}
fromState := ""
if current.State != nil {
fromState = *current.State
}
if err := db.ValidateTransition(ctx, tx, id, current.Type, fromState, *req.Body.State); err != nil {
if errors.Is(err, db.ErrTransitionInvalid) {
return nil, fmt.Errorf("%w: %v", domain.ErrInvalidTransition, err)
}
return nil, err
}
}
@@ -1143,6 +1111,7 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
patchActorType, patchActor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, patchActorType, patchActor, "patch",
&id, "PATCH", "/api/v1/entities/"+req.Id, "",
nil,
map[string]any{"version": expectedVersion}); auditErr != nil {
return nil, auditErr
}
@@ -1272,6 +1241,7 @@ func (s *Server) EnrollClient(ctx context.Context, req gen.EnrollClientRequestOb
entityID := id
_ = observability.Audit(ctx, q, "operator", actor, "enroll",
&entityID, "POST", "/api/v1/clients/enroll", "",
nil,
map[string]any{"slug": req.Body.Slug, "mesh_ip": meshIP})
_ = observability.Event(ctx, q, "client.enrolled", &entityID,
"info", "oikos-api", "",
@@ -1466,6 +1436,7 @@ func (s *Server) ProvisionEntity(ctx context.Context, req gen.ProvisionEntityReq
_, actor := actorInfo(ctx)
_ = observability.Audit(ctx, q, "operator", actor, "provision",
&entityID, "POST", "/api/v1/entities/provision", "",
nil,
map[string]any{"slug": req.Body.Slug, "host": hostSlug})
_ = observability.Event(ctx, q, "entity.provisioned", &entityID,
"info", "oikos-api", "",
@@ -1556,97 +1527,5 @@ func generateAgeKeypair() (pubKey, privKey string, err error) {
return pub, priv, nil
}
// checkPrecondition validates a named lifecycle transition precondition.
func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, check string) error {
switch check {
case "no-inbound-edges":
var count int
err := tx.QueryRow(ctx,
"SELECT count(*) FROM relationships WHERE target_id = $1 AND valid_to IS NULL", entityID).Scan(&count)
if err != nil {
return err
}
if count > 0 {
return fmt.Errorf("%d inbound relationship edges remaining", count)
}
case "backups-verified":
var attrs string
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
if err != nil {
return err
}
if !strings.Contains(attrs, "backups_verified") {
return fmt.Errorf("backup verification not recorded in entity attributes")
}
case "secrets-revoked":
var attrs string
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
if err != nil {
return err
}
if !strings.Contains(attrs, "secrets_revoked") {
return fmt.Errorf("secret revocation not recorded in entity attributes")
}
case "ingress-dns-removed":
var attrs string
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
if err != nil {
return err
}
if !strings.Contains(attrs, "ingress_dns_removed") {
return fmt.Errorf("ingress/DNS removal not recorded in entity attributes")
}
case "age-key-enrolled-if-needed":
if entityType == "workstation" {
var attrs string
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
if err != nil {
return err
}
if !strings.Contains(attrs, "age_pubkey") {
return fmt.Errorf("age key not enrolled (no age_pubkey in attributes)")
}
}
case "mesh-joined-if-needed":
if entityType == "workstation" {
var attrs string
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
if err != nil {
return err
}
if !strings.Contains(attrs, "mesh_ip") {
return fmt.Errorf("mesh not joined (no mesh_ip in attributes)")
}
}
case "health-check-answering":
st, err := sqlcgen.New(tx).GetEntityStatus(ctx, entityID)
if err != nil || st.Health == "unknown" || st.Health == "down" {
return fmt.Errorf("health check not answering (status: %s)", st.Health)
}
case "doc-page-complete":
var count int
err := tx.QueryRow(ctx, `
SELECT count(*) FROM relationships r
JOIN entities ke ON ke.id = r.source_id
WHERE r.target_id = $1 AND r.valid_to IS NULL
AND r.type = 'documents' AND ke.type IN ('document','runbook','investigation')`,
entityID).Scan(&count)
if err != nil {
return err
}
if count == 0 {
return fmt.Errorf("no documentation linked to entity")
}
case "inventory-entry", "ip-reserved", "storage-pool-chosen", "cancelled-note",
"preflight-passed", "error-summary", "replacement-live-or-role-retired",
"replacement-failed", "post-verify-passed", "recovery-verified", "written-off",
"ingress-live-if-public", "doc-page-stub":
// Soft checks — always pass. These are operator-confirmed via the
// transition request itself, or are not mechanically enforceable.
default:
// Unknown preconditions are skipped (operator intent overrides).
}
return nil
}
// ─── Helpers ───────────────────────────────────────────────────────────

View File

@@ -111,6 +111,7 @@ func (s *Server) PatchPattern(ctx context.Context, req gen.PatchPatternRequestOb
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "patch",
&id, "PATCH", "/api/v1/patterns/"+req.Id, "",
nil,
map[string]any{"status": req.Body.Status, "quarantined": req.Body.Quarantined}); auditErr != nil {
return nil, auditErr
}

View File

@@ -65,6 +65,7 @@ func (s *Server) CreateRelationship(ctx context.Context, req gen.CreateRelations
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
nil, "POST", "/api/v1/relationships", "",
nil,
map[string]any{"source": req.Body.Source, "target": req.Body.Target, "type": req.Body.Type}); auditErr != nil {
return nil, auditErr
}
@@ -108,6 +109,7 @@ func (s *Server) EndRelationship(ctx context.Context, req gen.EndRelationshipReq
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "delete",
nil, "DELETE", "/api/v1/relationships", "",
nil,
map[string]any{"source": req.Params.Source, "target": req.Params.Target, "type": req.Params.RelType}); auditErr != nil {
return nil, auditErr
}

View File

@@ -136,6 +136,7 @@ func (s *Server) PatchSkill(ctx context.Context, req gen.PatchSkillRequestObject
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "patch",
&id, "PATCH", "/api/v1/skills/"+req.Id, "",
nil,
map[string]any{"status": req.Body.Status, "pinned_version": req.Body.PinnedVersion}); auditErr != nil {
return nil, auditErr
}

View File

@@ -0,0 +1,251 @@
package mcp
// Integration tests for the entity-mutation MCP tools (create_entity,
// update_entity_attributes), focused on the capability gap that stranded
// session 23da10db: entities mutated via MCP must derive/regenerate checks the
// same way the HTTP create/patch paths do. Guarded by OIKOS_TEST_DATABASE_URL
// (see internal/db/integration_test.go); run via `make test-db`.
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"os"
"strings"
"testing"
"github.com/dtoro/oikos/internal/checkdefaults"
"github.com/dtoro/oikos/internal/db"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// newTestPool mirrors internal/httpapi/api_test.go: a throwaway database,
// migrated and seeded with ontology/inventory/policy so create_entity's type
// validation and checkdefaults derivation have a real type tree to work
// against.
func newTestPool(t *testing.T) *db.Pool {
t.Helper()
baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
if baseURL == "" {
t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
}
ctx := context.Background()
admin, err := pgx.Connect(ctx, baseURL)
if err != nil {
t.Fatalf("connect admin: %v", err)
}
dbName := fmt.Sprintf("oikos_mcp_test_%08x", rand.Int63())
if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
admin.Close(ctx)
t.Fatalf("create test db: %v", err)
}
admin.Close(ctx)
qi := strings.Index(baseURL, "?")
base, params := baseURL, ""
if qi >= 0 {
base, params = baseURL[:qi], baseURL[qi:]
}
testURL := base[:strings.LastIndex(base, "/")+1] + dbName + params
pool, err := db.New(ctx, testURL)
if err != nil {
t.Fatalf("connect test db: %v", err)
}
t.Cleanup(func() {
pool.Close()
if admin, e := pgx.Connect(ctx, baseURL); e == nil {
admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
admin.Close(ctx)
}
})
if err := pool.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
for _, f := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
content, err := os.ReadFile("../../seeds/" + f)
if err != nil {
t.Fatalf("read seed %s: %v", f, err)
}
name := f
if err := pool.SeedIngest(ctx, name, content,
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
var err error
switch name {
case "ontology.yaml":
_, err = db.IngestOntologySeed(ctx, tx, data)
case "inventory.yaml":
_, err = db.IngestInventorySeed(ctx, tx, data)
case "policy.yaml":
_, err = db.IngestPolicySeed(ctx, tx, data)
}
return err
}); err != nil {
t.Fatalf("ingest %s: %v", f, err)
}
}
return pool
}
// callTool invokes a registered tool's handler in-process and returns its
// concatenated text result.
func callTool(t *testing.T, pool *db.Pool, name string, args map[string]any) string {
t.Helper()
var handler toolHandler
for _, r := range allTools(pool, uuid.Nil) {
if r.tool.Name == name {
handler = r.handler
break
}
}
if handler == nil {
t.Fatalf("tool %q not registered", name)
}
argsJSON, _ := json.Marshal(args)
res, err := handler(context.Background(), &mcp.CallToolRequest{Params: &mcp.CallToolParamsRaw{
Name: name,
Arguments: argsJSON,
}})
if err != nil {
t.Fatalf("tool %s returned error: %v", name, err)
}
var sb strings.Builder
for _, c := range res.Content {
if tc, ok := c.(*mcp.TextContent); ok {
sb.WriteString(tc.Text)
}
}
return sb.String()
}
// checkCountFor returns the number of derived check_defs targeting slug.
func checkCountFor(t *testing.T, pool *db.Pool, slug string) int {
t.Helper()
var n int
err := pool.QueryRow(context.Background(),
`SELECT count(*) FROM check_defs cd
JOIN entities e ON e.id = cd.target_id
WHERE e.slug = $1`, slug).Scan(&n)
if err != nil {
t.Fatalf("count check_defs for %s: %v", slug, err)
}
return n
}
// TestCreateEntity_DerivesChecks proves create_entity inserts an entity AND
// derives its default checks in one call (the HTTP create path did this; the
// MCP path previously could not create at all).
func TestCreateEntity_DerivesChecks(t *testing.T) {
pool := newTestPool(t)
slug := "service:mcp-create-test"
out := callTool(t, pool, "create_entity", map[string]any{
"type": "service",
"slug": slug,
"name": "mcp-create-test",
"attributes": `{"url":"https://mcp-create-test.example"}`,
})
if !strings.Contains(out, "Created "+slug) {
t.Fatalf("create_entity result = %q, want Created %s", out, slug)
}
if !strings.Contains(out, "Derived") {
t.Errorf("create_entity result = %q, want a Derived check summary", out)
}
if got := checkCountFor(t, pool, slug); got < 1 {
t.Errorf("check_defs targeting %s = %d, want >=1 (create did not derive checks)", slug, got)
}
}
// TestCreateEntity_DuplicateAndInvalid covers the guard rails: a repeat create
// is reported as "already exists" (not an error), and an unknown type is
// rejected with a clear message.
func TestCreateEntity_DuplicateAndInvalid(t *testing.T) {
pool := newTestPool(t)
if out := callTool(t, pool, "create_entity", map[string]any{
"type": "service", "slug": "service:mcp-dup", "name": "mcp-dup",
}); !strings.Contains(out, "Created service:mcp-dup") {
t.Fatalf("first create = %q", out)
}
if out := callTool(t, pool, "create_entity", map[string]any{
"type": "service", "slug": "service:mcp-dup", "name": "mcp-dup",
}); !strings.Contains(out, "already exists") {
t.Errorf("duplicate create = %q, want 'already exists'", out)
}
if out := callTool(t, pool, "create_entity", map[string]any{
"type": "no-such-type", "slug": "no-such-type:x", "name": "x",
}); !strings.Contains(out, "not found in ontology") {
t.Errorf("unknown type = %q, want 'not found in ontology'", out)
}
}
// TestUpdateEntityAttributes_RegeneratesChecks is the regression guard for the
// haos session: setting an entity's `monitoring` attribute via MCP must
// regenerate checks. Before this fix the MCP update path skipped
// ensureDefaultChecks, so flipping monitoring produced nothing.
func TestUpdateEntityAttributes_RegeneratesChecks(t *testing.T) {
pool := newTestPool(t)
slug := "service:mcp-regen-test"
// Create with monitoring:none — no checks derived.
if out := callTool(t, pool, "create_entity", map[string]any{
"type": "service", "slug": slug, "name": "mcp-regen-test",
"attributes": `{"monitoring":"none","url":"https://mcp-regen.example"}`,
}); !strings.Contains(out, "Created "+slug) {
t.Fatalf("create = %q", out)
}
if got := checkCountFor(t, pool, slug); got != 0 {
t.Fatalf("check_defs with monitoring:none = %d, want 0", got)
}
// Flip monitoring to [http] via update_entity_attributes — checks must
// regenerate. This is exactly what failed for service:haos.
out := callTool(t, pool, "update_entity_attributes", map[string]any{
"slug": slug,
"attributes": `{"monitoring":["http"]}`,
})
if !strings.Contains(out, "Updated "+slug) {
t.Fatalf("update result = %q, want Updated %s", out, slug)
}
if !strings.Contains(out, "Derived") {
t.Errorf("update result = %q, want a Derived check summary (regeneration)", out)
}
if got := checkCountFor(t, pool, slug); got < 1 {
t.Errorf("check_defs after monitoring:[http] = %d, want >=1 (MCP update did not regenerate checks)", got)
}
}
// TestUpdateEntityAttributes_NotFound keeps the existing error contract.
func TestUpdateEntityAttributes_NotFound(t *testing.T) {
pool := newTestPool(t)
out := callTool(t, pool, "update_entity_attributes", map[string]any{
"slug": "service:does-not-exist",
"attributes": `{"x":1}`,
})
if !strings.Contains(out, "not found") {
t.Errorf("update missing entity = %q, want 'not found'", out)
}
}
// TestFormatCheckResult is a pure unit test for the result-message helper, so
// the formatting contract holds even when the DB is unavailable.
func TestFormatCheckResult(t *testing.T) {
if got := formatCheckResult(checkdefaults.Result{Created: 2}); !strings.Contains(got, "Derived 2 check") {
t.Errorf("created-only = %q, want Derived 2", got)
}
got := formatCheckResult(checkdefaults.Result{Created: 1, Skipped: []checkdefaults.Skip{{Kind: "process", Reason: "no host"}}})
if !strings.Contains(got, "Derived 1 check") || !strings.Contains(got, "Skipped process") || !strings.Contains(got, "no host") {
t.Errorf("created+skipped = %q", got)
}
if got := formatCheckResult(checkdefaults.Result{Undeclared: true}); !strings.Contains(got, "no monitoring") {
t.Errorf("undeclared = %q, want no-monitoring hint", got)
}
if formatCreateResult("a", "b", checkdefaults.Result{Created: 0}) != "Created a (b)." {
t.Error("create result with no checks should have no suffix")
}
}

View File

@@ -72,9 +72,121 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
for _, t := range allTools(pool, agentID) {
s.AddTool(t.tool, withActivityLogging(pool, agentID, t.tool.Name, t.handler))
}
// Resource templates: let MCP clients browse and attach entities,
// knowledge entries, and executions as conversation resources.
s.AddResourceTemplate(&mcp.ResourceTemplate{
URITemplate: "oikos://entity/{slug}",
Name: "Entity",
Description: "Oikos entity by slug (e.g. host:hubris, lxc:jellyfin)",
MIMEType: "application/json",
}, resourceHandler(pool, func(ctx context.Context, matches map[string]string) (string, error) {
slug := matches["slug"]
var id uuid.UUID
if u, err := uuid.Parse(slug); err == nil {
id = u
} else {
pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&id)
}
if id == uuid.Nil {
return "", fmt.Errorf("entity not found: %s", slug)
}
result := queryEntity(ctx, pool, slug)
return result.Content[0].(*mcp.TextContent).Text, nil
}))
s.AddResourceTemplate(&mcp.ResourceTemplate{
URITemplate: "oikos://knowledge/{id}",
Name: "Knowledge",
Description: "Knowledge entry by entity slug or UUID",
MIMEType: "application/json",
}, resourceHandler(pool, func(ctx context.Context, matches map[string]string) (string, error) {
idOrSlug := matches["id"]
var entityID uuid.UUID
if u, err := uuid.Parse(idOrSlug); err == nil {
entityID = u
} else {
pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", idOrSlug).Scan(&entityID)
}
if entityID == uuid.Nil {
return "", fmt.Errorf("knowledge not found: %s", idOrSlug)
}
result := queryRows(ctx, pool, `
SELECT ke.title, ke.content, ke.tags::text, e.slug, e.type AS kind,
ke.updated_at::text
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE ke.entity_id = $1`, entityID)
return result.Content[0].(*mcp.TextContent).Text, nil
}))
s.AddResourceTemplate(&mcp.ResourceTemplate{
URITemplate: "oikos://execution/{id}",
Name: "Execution",
Description: "Execution by UUID (returns status, result, timing)",
MIMEType: "application/json",
}, resourceHandler(pool, func(ctx context.Context, matches map[string]string) (string, error) {
result := queryRows(ctx, pool, `
SELECT e.entity_id, te.slug AS target, e.action, e.risk_class,
e.status, e.result::text, e.duration_ms,
e.started_at::text, e.completed_at::text
FROM executions e
JOIN entities te ON te.id = e.target_entity_id
WHERE e.entity_id = $1`, matches["id"])
return result.Content[0].(*mcp.TextContent).Text, nil
}))
return s
}
// resourceHandler adapts a simple func(ctx, params) → (string, error) into
// an MCP ResourceHandler, reading the URI matched by a ResourceTemplate.
func resourceHandler(pool *db.Pool, fn func(ctx context.Context, matches map[string]string) (string, error)) mcp.ResourceHandler {
return func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
uri := req.Params.URI
matches := matchURITemplate(uri)
if matches == nil {
return nil, mcp.ResourceNotFoundError(uri)
}
text, err := fn(ctx, matches)
if err != nil {
return nil, mcp.ResourceNotFoundError(uri)
}
result, err := json.MarshalIndent(json.RawMessage(text), "", " ")
if err != nil {
result = []byte(text)
}
return &mcp.ReadResourceResult{
Contents: []*mcp.ResourceContents{{
URI: uri,
MIMEType: "application/json",
Text: string(result),
}},
}, nil
}
}
// matchURITemplate extracts parameters from a URI that matches one of the
// oikos:// resource templates. Returns nil if the URI doesn't match.
func matchURITemplate(uri string) map[string]string {
// oikos://entity/{slug}
if rest, ok := strings.CutPrefix(uri, "oikos://entity/"); ok && rest != "" {
return map[string]string{"slug": rest}
}
// oikos://knowledge/{id}
if rest, ok := strings.CutPrefix(uri, "oikos://knowledge/"); ok && rest != "" {
return map[string]string{"id": rest}
}
// oikos://execution/{id}
if rest, ok := strings.CutPrefix(uri, "oikos://execution/"); ok && rest != "" {
return map[string]string{"id": rest}
}
return nil
}
// withActivityLogging wraps a tool handler to record agent_activity rows.
func withActivityLogging(pool *db.Pool, agentID uuid.UUID, toolName string, next mcp.ToolHandler) mcp.ToolHandler {
if agentID == uuid.Nil {
@@ -628,8 +740,106 @@ func autoRun(ctx context.Context, pool *db.Pool, id uuid.UUID, targetSlug, comma
return out, nil
}
// autoRunAsync starts a command in a goroutine, marking it running and returning
// immediately. The caller gets an execution_id to poll with get_execution_status.
// Used for commands containing sleep/wait/poll loops that would exceed the MCP
// client timeout (120s) — the execution continues server-side.
func autoRunAsync(ctx context.Context, pool *db.Pool, id uuid.UUID, targetSlug, command string) {
startedAt := time.Now()
if _, err := pool.Exec(ctx,
`UPDATE executions SET status='running', started_at=$2 WHERE entity_id=$1`,
id, startedAt); err != nil {
slog.Error("mcp: mark execution running (async)", "error", err, "execution_id", id)
}
host, user, wrap, err := resolveExecTarget(ctx, pool, targetSlug)
if err != nil {
pool.Exec(ctx,
`UPDATE executions SET status='failed', result=$2::jsonb, duration_ms=$3, completed_at=now() WHERE entity_id=$1`,
id, jsonErr("%s", err.Error()), int(time.Since(startedAt).Milliseconds()))
slog.Error("mcp: async run resolve target", "error", err, "execution_id", id, "target", targetSlug)
return
}
var correlationID string
if qerr := pool.QueryRow(ctx,
`SELECT correlation_id FROM executions WHERE entity_id = $1`, id).Scan(&correlationID); qerr != nil {
correlationID = ""
}
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("mcp: async run panic", "panic", r, "execution_id", id)
pool.Exec(context.Background(),
`UPDATE executions SET status='failed', result=$2::jsonb, duration_ms=$3, completed_at=now() WHERE entity_id=$1`,
id, jsonErr("panic: %v", r), int(time.Since(startedAt).Milliseconds()))
}
}()
sink, flush := execlog.New(context.Background(), pool, id, correlationID)
out, execErr := sshExecStream(context.Background(), host, user, wrap(command), sink)
flush()
if execErr != nil {
pool.Exec(context.Background(),
`UPDATE executions SET status='failed', result=$2::jsonb, duration_ms=$3, completed_at=now() WHERE entity_id=$1`,
id, jsonErr("%s: %s", execErr.Error(), out), int(time.Since(startedAt).Milliseconds()))
slog.Error("mcp: async run failed", "error", execErr, "execution_id", id, "output", out)
} else {
pool.Exec(context.Background(),
`UPDATE executions SET status='completed', result=$2::jsonb, duration_ms=$3, completed_at=now() WHERE entity_id=$1`,
id, jsonOut(out), int(time.Since(startedAt).Milliseconds()))
}
}()
}
// isLongRunningCommand detects shell commands containing sleep, wait, or poll
// loops that indicate the command will exceed the MCP client timeout (120s).
// These commands should use autoRunAsync to avoid the client timing out while
// the command continues server-side.
func isLongRunningCommand(cmd string) bool {
cmd = strings.TrimSpace(cmd)
// sleep with duration — `sleep 30`, `sleep 1m`, etc.
if sleepRe.MatchString(cmd) {
return true
}
// while/shell poll loops with sleep: `while ...; do ... sleep; done`
if pollRe.MatchString(cmd) {
return true
}
// standalone wait command
if waitRe.MatchString(cmd) {
return true
}
return false
}
var (
sleepRe = regexp.MustCompile(`\bsleep\s+\d`)
pollRe = regexp.MustCompile(`\bwhile\b.*\bsleep\b`)
waitRe = regexp.MustCompile(`\bwait\s+\d|[&;]\s*wait\b`)
)
func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.UUID, targetSlug, command, purpose, declaredRisk, sessionID string) *mcp.CallToolResult {
riskClass := policy.ClassifyCommand(command, declaredRisk)
// Transport-aware escalation: read-only commands on LXC targets that
// touch config paths (/opt/, /etc/) escalate to config_mutation.
// The classifier only scores the command text, not the transport layer
// — SSH-ing into a container to read /opt/ is riskier than running
// the same command locally on the Proxmox host via pct exec.
// Caught live: "cat /etc/hostname" on lxc:dns queued as config_mutation
// while "pct exec 107 -- cat /etc/hostname" on host:hubris auto-ran.
if riskClass == policy.RiskReadOnly && strings.HasPrefix(targetSlug, "lxc:") {
if strings.Contains(command, "/opt/") || strings.Contains(command, "/etc/") || strings.Contains(command, "/var/lib/") {
riskClass = policy.RiskConfigMutation
}
}
runParams, _ := json.Marshal(map[string]string{"command": command, "purpose": purpose})
actionCol := "run:" + string(runParams)
@@ -647,6 +857,57 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
return textResult("No plan for this session. Call set_goal then propose_plan before run — even read-only tasks require a one-step plan. A one-step plan (\"Inspect X, report, write back\") is fine for trivial questions; the gate is about ordering, not approval. Read-only commands still auto-execute once a plan exists.")
}
// Target validation: host-only commands (qm, pct, pvesh, iptables) must
// not be dispatched against lxc:/vm: targets — those aren't Proxmox hosts
// and don't have these tools. Caught live 2026-08-04: the agent ran
// `qm stop 100` against lxc:dns, wasting a turn.
// Command syntax validation: catch LLM-generated bash bugs before they
// hit the shell. The model sometimes inserts literal \n between commands
// or puts spaces inside flags — these always fail, so reject early.
if syntaxErr := validateCommandSyntax(command); syntaxErr != "" {
return textResult(syntaxErr)
}
if cmdPrefix, hostOnly := hostOnlyCommand(command); hostOnly && !strings.HasPrefix(targetSlug, "host:") {
hostSuggestion := resolveProxmoxHostSlug(ctx, pool, targetSlug, "")
if hostSuggestion == "" {
hostSuggestion = "host:hubris or host:strong"
}
return textResult(fmt.Sprintf("Cannot run %q on %s — %s is a Proxmox host command. Use target %s instead.",
cmdPrefix, targetSlug, cmdPrefix, hostSuggestion))
}
// systemctl and docker work on hosts and LXCs, but not VMs.
if cmdPrefix, hostLxc := hostLxcCommand(command); hostLxc {
if !strings.HasPrefix(targetSlug, "host:") && !strings.HasPrefix(targetSlug, "lxc:") {
return textResult(fmt.Sprintf("Cannot run %q on %s — %s only works on host:* or lxc:* targets.",
cmdPrefix, targetSlug, cmdPrefix))
}
}
// VM transport pre-flight: qm guest exec requires the QEMU guest agent
// to be running inside the VM. If it's not, the execution would queue
// for approval and never execute — the agent has no way to learn it's
// stuck (spotted live 2026-08-05: vm:zimaos had qemu_guest_agent=not_running,
// the run queued forever, and the agent fell back to unsafe raw SSH).
if strings.HasPrefix(targetSlug, "vm:") {
var rawAttrs []byte
if err := pool.QueryRow(ctx, `SELECT attributes FROM entities WHERE id = $1`, targetID).Scan(&rawAttrs); err == nil {
var attrs map[string]any
if json.Unmarshal(rawAttrs, &attrs) == nil {
if qga, ok := attrs["qemu_guest_agent"]; ok {
qgaStr, _ := qga.(string)
if qgaStr == "not_running" || qgaStr == "" {
return textResult(fmt.Sprintf(
"run on %s blocked: QEMU guest agent is not running (%s). qm guest exec cannot reach this VM. Start the agent inside the guest first (e.g. via SSH/systemctl start qemu-guest-agent), then re-run. If the agent is running but the entity attribute is stale, update it with update_entity_attributes(slug=%s, attributes={\"qemu_guest_agent\":\"running\"}).",
targetSlug, qgaStr, targetSlug))
}
}
}
}
}
// Dedup: an identical pending command (same target, command, and
// purpose) blocks a re-request — stops a tool-calling loop from queuing
// the same approval repeatedly.
@@ -721,8 +982,51 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
SELECT 1 FROM relationships
WHERE source_id = t.id AND target_id = $1 AND type = 'involves' AND valid_to IS NULL)`,
id, "task:"+sessionID)
// Link execution to session for auto-continuation (nomos_plan_executions
// was always empty — executions were never traceable back to sessions).
if sid, serr := uuid.Parse(sessionID); serr == nil {
pool.Exec(ctx, `
INSERT INTO nomos_plan_executions (execution_id, session_id)
VALUES ($1, $2) ON CONFLICT (execution_id) DO NOTHING`, id, sid)
}
}
// Auto-classify: write the classification decision to the classifications
// table (was always empty — 0 rows despite 1,884 executions). The route
// matches the auto-run vs queue-for-approval decision below.
classRoute := "escalate"
if riskClass == policy.RiskReadOnly || riskClass == policy.RiskReversibleLow {
classRoute = "auto-act"
} else if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID, sessionID) {
classRoute = "auto-act"
} else if riskClass == policy.RiskDestructive && destructiveWindowActive(ctx, pool, agentID, targetSlug, sessionID) {
classRoute = "auto-act"
}
classReason, _ := json.Marshal(map[string]string{
"command": command, "purpose": purpose, "target": targetSlug, "declared_risk": declaredRisk,
})
classID, _ := uuid.NewV7()
pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'classification', $3, '{}')`,
classID, "classification:"+classID.String(), "classification for "+execSlug)
pool.Exec(ctx, `INSERT INTO classifications (entity_id, action, risk_class, route, reasoning, correlation_id)
VALUES ($1, $2, $3, $4, $5, $6)`,
classID, actionCol, riskClass, classRoute, classReason, correlationID)
// Link classification to execution.
pool.Exec(ctx, `UPDATE executions SET classification_id = $2 WHERE entity_id = $1`, id, classID)
// Audit: record the execution creation with session_id for traceability.
// Every run call, whether auto-run or queued-for-approval, gets an audit
// entry so the agent's activity is traceable back to the originating session.
var auditSessionID *uuid.UUID
if sessionID != "" && sessionID != "ephemeral" {
if sid, serr := uuid.Parse(sessionID); serr == nil {
auditSessionID = &sid
}
}
_ = observability.Audit(ctx, sqlcgen.New(pool), "agent", "nomos", "run",
&id, "POST", "/mcp", correlationID, auditSessionID,
map[string]any{"command": command, "target": targetSlug, "risk_class": riskClass, "purpose": purpose})
// read_only and reversible_low both run unattended, as seeds/policy.yaml
// and .agents/OIKOS.md declare ("reversible_low — restart, cache clear,
// sync pull. Unattended + ledger.").
@@ -741,6 +1045,11 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
// agent still cannot talk a command DOWN: declaring reversible_low on
// something computed as config_mutation keeps config_mutation.
if riskClass == policy.RiskReadOnly || riskClass == policy.RiskReversibleLow {
if isLongRunningCommand(command) {
autoRunAsync(ctx, pool, id, targetSlug, command)
return textResult(fmt.Sprintf("run on %s (%s, async): started — execution %s. Poll with get_execution_status(%s) for result.",
targetSlug, riskClass, id, id))
}
out, xerr := autoRun(ctx, pool, id, targetSlug, command)
if xerr != nil {
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out))
@@ -759,6 +1068,12 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
// consent. The assent window, opened only on operator approval, is the
// sole gate for config_mutation auto-run.)
if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID, sessionID) {
if isLongRunningCommand(command) {
autoRunAsync(ctx, pool, id, targetSlug, command)
slog.Info("mcp: run async via assent window", "target", targetSlug, "execution_id", id)
return textResult(fmt.Sprintf("run on %s (config_mutation, async via assent window): started — execution %s. Poll with get_execution_status(%s) for result.",
targetSlug, id, id))
}
out, xerr := autoRun(ctx, pool, id, targetSlug, command)
if xerr != nil {
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out))
@@ -774,6 +1089,12 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
// operator isn't asked to re-type "I confirm" for every single command
// against the thing they just confirmed.
if riskClass == policy.RiskDestructive && destructiveWindowActive(ctx, pool, agentID, targetSlug, sessionID) {
if isLongRunningCommand(command) {
autoRunAsync(ctx, pool, id, targetSlug, command)
slog.Info("mcp: run async via destructive window", "target", targetSlug, "execution_id", id)
return textResult(fmt.Sprintf("run on %s (destructive, async via confirmed-target window): started — execution %s. Poll with get_execution_status(%s) for result.",
targetSlug, id, id))
}
out, xerr := autoRun(ctx, pool, id, targetSlug, command)
if xerr != nil {
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out))
@@ -840,22 +1161,112 @@ func executeApprovedViaAPI(ctx context.Context, execID uuid.UUID, targetSlug, ac
// for config_mutation auto-run now. See sessionHasPlan for the plan-existence
// check used by the P1 plan-first gate.
// hostOnlyCommands maps command prefixes that are only valid on Proxmox host
// targets (not LXCs or VMs). Running these against an lxc: or vm: target
// always fails with "command not found" and wastes a turn.
var hostOnlyCommands = map[string]bool{
"qm": true,
"pct": true,
"pvesh": true,
"iptables": true,
}
// hostLxcCommands maps command prefixes valid on host:* and lxc:* but not vm:*.
var hostLxcCommands = map[string]bool{
"systemctl": true,
"docker": true,
}
// hostOnlyCommand checks whether the leading word of cmd is a host-only
// command. Returns the command word and true if the command can only run on
// a host: target.
func hostOnlyCommand(cmd string) (string, bool) {
trimmed := strings.TrimSpace(cmd)
parts := strings.Fields(trimmed)
if len(parts) == 0 {
return "", false
}
first := parts[0]
// Check for shell wrappers: bash -c 'actual_cmd', sh -c 'actual_cmd'
if (first == "bash" || first == "sh") && len(parts) >= 3 && parts[1] == "-c" {
// The actual command is inside the -c argument; extract the first word.
// This handles `bash -c 'qm stop 100'` but not deeply nested wrappers.
actual := strings.Trim(strings.Join(parts[2:], " "), "'\"")
if inner := strings.Fields(actual); len(inner) > 0 {
first = inner[0]
}
}
// Strip path: /usr/sbin/qm → qm
if idx := strings.LastIndexByte(first, '/'); idx >= 0 {
first = first[idx+1:]
}
return first, hostOnlyCommands[first]
}
// hostLxcCommand checks whether the leading word of cmd is a command valid on
// host:* and lxc:* targets but not vm:*. Returns the command word and true if
// the command is restricted to host/lxc.
func hostLxcCommand(cmd string) (string, bool) {
trimmed := strings.TrimSpace(cmd)
parts := strings.Fields(trimmed)
if len(parts) == 0 {
return "", false
}
first := parts[0]
if idx := strings.LastIndexByte(first, '/'); idx >= 0 {
first = first[idx+1:]
}
return first, hostLxcCommands[first]
}
// validateCommandSyntax checks for common LLM-generated bash errors that always
// fail at the shell. Returns an error message or "" if the command looks valid.
func validateCommandSyntax(cmd string) string {
// Reject literal \n (the LLM sometimes writes `echo "---" && \n curl ...`
// — the \n is literal in the command string, not an actual newline).
if strings.Contains(cmd, "\\n") {
return fmt.Sprintf("Command contains literal '\\n' — use ';' or '&&' between commands, not a literal backslash-n. Command: %q", cmd)
}
// Reject `&& \n` patterns (the LLM writes `cmd1 && \n cmd2` — the \n is
// a literal newline that bash interprets as a command separator, but the
// leading backslash makes it a syntax error).
if andBackslashRe.MatchString(cmd) {
return fmt.Sprintf("Command contains '&&' followed by a literal backslash-newline — remove the backslash or use ';' instead. Command: %q", cmd)
}
// Reject `\` at end of command with no continuation (last line ends with
// backslash but there's nothing after it).
trimmed := strings.TrimSpace(cmd)
if strings.HasSuffix(trimmed, "\\") {
return fmt.Sprintf("Command ends with a backslash but has nothing after it to continue. Remove the trailing '\\'. Command: %q", cmd)
}
// Warn on common flag typos: `head - n`, `grep - i`, `tail - n`, etc.
// These are space-between-flag-and-value errors the LLM produces.
if flagSpaceRe.MatchString(cmd) {
return fmt.Sprintf("Command has a space between a flag and its value (e.g. 'head - n' instead of 'head -n'). Remove the space. Command: %q", cmd)
}
return ""
}
var andBackslashRe = regexp.MustCompile(`&&\s*\\\s*\n`)
var flagSpaceRe = regexp.MustCompile(`\b(head|tail|grep|sed|awk|sort|uniq|wc)\s+(-\w)\s+\w`)
// sessionHasPlan reports whether this nomos session has any plan step on
// record (any generation, any status). Used by the P1 plan-first gate in
// classifyAndGate to refuse `run` before `propose_plan` has been called.
// A `replaced` step (from a prior plan generation that was superseded by a
// follow-up sub-task — see store.reopenSession) still counts: it proves the
// agent once framed a plan for this session, and the reopen path guarantees a
// fresh `propose_plan` will run before the next `run` anyway. Fails closed
// (returns true) when the query errors so a transient DB issue doesn't block
// an otherwise-valid run.
// record that isn't `replaced`. Replaced steps (from session reopen via
// store.reopenSession) don't count — the agent must propose fresh plan before
// any `run`. Fails closed (returns true) when the query errors so a transient
// DB issue doesn't block an otherwise-valid run.
func sessionHasPlan(ctx context.Context, pool *db.Pool, sessionID string) bool {
if sessionID == "" {
return true // no session → no gate (direct MCP call from a script)
}
var count int
if err := pool.QueryRow(ctx,
`SELECT COUNT(*) FROM session_plan_steps WHERE session_id = $1`,
`SELECT COUNT(*) FROM session_plan_steps
WHERE session_id = $1 AND status <> 'replaced'`,
sessionID).Scan(&count); err != nil {
return true // fail open on DB error — don't block work over a flake
}

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ import (
// starts being populated when OIDC identity resolution lands.
func Audit(ctx context.Context, q *sqlcgen.Queries, actorType, actorLabel,
action string, entityID *uuid.UUID, method, path, correlationID string,
detail map[string]any) error {
sessionID *uuid.UUID, detail map[string]any) error {
if detail == nil {
detail = map[string]any{}
@@ -36,6 +36,7 @@ func Audit(ctx context.Context, q *sqlcgen.Queries, actorType, actorLabel,
Path: &path,
Detail: detailJSON,
CorrelationID: corr,
SessionID: sessionID,
})
}

View File

@@ -105,6 +105,15 @@ var curlLeadRe = regexp.MustCompile(`(?i)^curl\b`)
// When any of these appears, the curl command is no longer read-only.
var curlMutateRe = regexp.MustCompile(`(?i)(?:^|\s)-X\s+(?:post|put|delete|patch|connect|trace)\b|(?:^|\s)-(?:d|F|T|o)\b|(?:^|\s)--(?:data[-a-z]*|request|form|upload-file|output)\b`)
// curlDevNullOutRe matches curl output redirected to /dev/null in any of curl's
// argument forms (space, =, or attached). /dev/null is a no-op sink, so a GET
// that discards its body — the canonical reachability idiom
// `curl -o /dev/null -w '%{http_code}' URL` — is read-only. Output to any real
// path (-o /tmp/x) stays a potential mutation. Stripped before curlMutateRe so
// the remaining flags (-X, -d, ...) still classify correctly: a
// `curl -o /dev/null -X POST` stays config_mutation.
var curlDevNullOutRe = regexp.MustCompile(`(?i)(^|\s)-o\s*/dev/null(\s|$)|(^|\s)--output[=\s]\s*/dev/null(\s|$)`)
// redirectOutRe matches shell output redirection to a file (> or >> followed
// by a path), but excludes the file-descriptor merge form `>&<digit>` (e.g.
// `2>&1`) which only rearranges streams and writes nothing to disk. RE2 has
@@ -308,6 +317,10 @@ func curlIsReadOnly(curlCmd string) bool {
if !curlLeadRe.MatchString(curlCmd) {
return false
}
// -o /dev/null is a no-op sink: strip it before flag detection so the
// canonical GET-and-discard reachability probe stays read-only.
// A `curl -o /dev/null -X POST` still fails curlMutateRe after stripping.
curlCmd = curlDevNullOutRe.ReplaceAllString(curlCmd, " ")
if curlMutateRe.MatchString(curlCmd) {
return false
}

View File

@@ -102,6 +102,33 @@ func TestClassifyCommand_CurlPipeSh_ConfigMutation(t *testing.T) {
}
}
func TestClassifyCommand_CurlDevNull_ReadOnly(t *testing.T) {
// -o /dev/null is a no-op sink — the canonical GET-and-discard
// reachability idiom must stay read_only. Output to real paths stays
// config_mutation. POST/data flags after stripping still gate.
cases := []struct {
cmd string
cls string
}{
// read_only: GET with body discarded to /dev/null
{`curl -o /dev/null -w '%{http_code}' --connect-timeout 10 http://192.168.8.101:8123`, RiskReadOnly},
{`curl -sS -o /dev/null https://home.hubris.network`, RiskReadOnly},
{`curl --output /dev/null https://example.com`, RiskReadOnly},
{`curl -o /dev/null https://example.com`, RiskReadOnly},
{`curl -o/dev/null -w '%{http_code}' https://example.com`, RiskReadOnly},
// config_mutation: POST/data still caught after stripping devnull
{`curl -o /dev/null -X POST https://example.com`, RiskConfigMutation},
{`curl -o /dev/null -d '{"x":1}' https://example.com`, RiskConfigMutation},
// config_mutation: -o to real path stays config_mutation
{`curl -o /etc/caddy/Caddyfile http://example.com`, RiskConfigMutation},
}
for _, c := range cases {
if got := ClassifyCommand(c.cmd, ""); got != c.cls {
t.Errorf("ClassifyCommand(%q) = %q, want %q", c.cmd, got, c.cls)
}
}
}
func TestClassifyCommand_DefaultEscalatesToConfigMutation(t *testing.T) {
cases := []string{
"apt-get install -y nginx",

View File

@@ -299,6 +299,8 @@ func executeCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledChec
return checkSSHScript(ctx, pool, cd)
case "backup-freshness":
return checkBackupFreshness(ctx, cd)
case "dns":
return checkDNS(ctx, cd)
default:
return checkResult{health: "unknown"}
}
@@ -484,6 +486,53 @@ func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResu
return checkResult{health: "healthy"}
}
// checkDNS verifies a DNS name resolves, catching a stale or unreachable
// zone. It looks up NS records first (a zone always has NS), falling back to
// an A/AAAA lookup for hostnames. Uses the system resolver; for split-horizon
// correctness reserve an explicit `server` in the config.
func checkDNS(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
cfg := struct {
Name string `json:"name"`
Server string `json:"server"`
}{}
if len(cd.Config) > 0 {
_ = json.Unmarshal(cd.Config, &cfg)
}
if cfg.Name == "" {
return checkResult{health: "healthy"}
}
// Resolve via an explicit server when supplied (split-horizon), else the
// system default resolver.
lookup := func(q string) (int, error) {
r := &net.Resolver{}
if cfg.Server != "" {
r = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
d := net.Dialer{Timeout: 5 * time.Second}
return d.DialContext(ctx, network, net.JoinHostPort(cfg.Server, "53"))
}}
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
ns, err := r.LookupNS(ctx, q)
if err == nil && len(ns) > 0 {
return len(ns), nil
}
addrs, err2 := r.LookupHost(ctx, q)
return len(addrs), err2
}
n, err := lookup(cfg.Name)
if err != nil || n == 0 {
return checkResult{
health: "down", signalKind: "dns",
evidence: fmt.Sprintf("DNS resolution failed for %q: %v", cfg.Name, err),
err: err,
}
}
return checkResult{health: "healthy"}
}
// checkDisk performs a disk usage check.
func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
cfg := struct {

View File

@@ -0,0 +1,8 @@
-- 030_plan_step_replaced_reason.up.sql
-- Add replaced_reason to session_plan_steps so the agent must explain why
-- a step was replaced (wrong_diagnosis, scope_change, blocked, superseded,
-- operator_override) rather than silently replacing entire plans. The column
-- is also set by bulk-replace operations (proposePlan, setGoal, reopenSession)
-- for auditability.
ALTER TABLE session_plan_steps ADD COLUMN IF NOT EXISTS replaced_reason TEXT;

View File

@@ -47,6 +47,24 @@ Read-only commands auto-run (no approval). Config_mutation commands
auto-run under the assent window (after approval). Destructive commands
always need explicit typed confirmation.
**Never mark a step `done` if its tool calls errored.** If `run` timed out,
`update_entity_attributes` returned "not found", `create_relationship` returned
"source entity not found", or any tool returned an error — the step is NOT done.
Diagnose the error, try an alternative (e.g. use `create_entity` when
`update_entity_attributes` reports the entity doesn't exist), and only advance
to `done` when the step's intended work actually completed. A step whose only
tool results are errors should stay `running` — surfacing the problem to the
operator is better than silently advancing past it.
**Complete or skip steps — don't replace silently.** Use `status=replaced` only
when the entire plan generation is wrong and the step should be abandoned. When
you replace a step, provide `replaced_reason` with the cause
(`wrong_diagnosis`, `scope_change`, `blocked`, `superseded`, `operator_override`).
Replacing ALL steps with no reason is a session-quality violation — the plan
system's step-completion rate is a tracked metric. Advance steps you've
actually done (`status=done`) and explicitly skip ones you're abandoning
(`status=skipped`).
### 6. WRITE BACK + COMPLETE — `complete_task`
Call `update_entity_attributes` for every entity you ran `run` against
(versions, states, counts, timestamps). Call `create_relationship` for any
@@ -59,6 +77,15 @@ is a pure-DB Q&A that called *no* `run` at all (only get_entity/list_lxcs/
search_knowledge): answer directly, `complete_task` with a one-line summary,
no writeback needed.
**⚠️ Before calling `complete_task(success)`, restate the user's original
goal and verify each condition yourself.** "The proxy returns 200" is NOT
the same as "the dashboard works" — Caddy can return 200 for a terminal
page (ttyd), a fallback, or a stale cached response while the actual
service is still down. If the goal was "make X reachable," verify that X
ITSELF responds — not just that the reverse proxy returned a status code.
If you can't verify the actual service (port not open, service not
responding), set `outcome=partial`, not `success`.
`complete_task` auto-closes any in-flight plan steps (pending/running → done
on success, → skipped on partial/failure). You do NOT need to call
`update_plan_step` for every step right before completing — once your work
@@ -85,6 +112,13 @@ generation — the panel will show it as a new list), execute, write back,
the panel in your reply. Never re-run `run` just to fix a display mismatch.
- Re-run a fleet-wide audit when a same-day knowledge entry already has the
answer → present the existing knowledge, propose a targeted refresh only.
- Pivot to a subsystem unrelated to the user's expressed goal without asking →
when investigation leads to a different subsystem or root cause (e.g.
debugging DHCP reservations when the goal was "make the dashboard reachable"),
call `session_questions` with the discovery and options BEFORE taking action.
Example: "The dashboard hasn't started since July 19 — this predates my work.
Do you want me to debug the dashboard service [A], skip it and stabilize the
current state [B], or stop here [C]?"
## Source of truth
@@ -205,6 +239,21 @@ disappear.
`list_lxcs` answers the same question in one call. Use it.
- When a bulk tool's summary isn't enough for a specific entity, call the
per-entity tool for that one entity — not for every entity in the fleet.
- **Cap pre-plan exploration:** prefer `list_entities(limit)` +
`get_entity_knowledge` (context for one entity, one call) over N+1
`get_entity`/`get_relations` chains. If you've already called
`get_entity_knowledge(slug)` and need more, call `get_entity(slug)` +
`get_relations(slug)` — not `list_entities` without a limit scanning the
whole entity table.
- **Group parallel reads:** `get_entity_knowledge`, `search_knowledge`,
`get_entity`, and `get_relations` are all read-only DB calls that can
be batched in a single tool-call block. Do not sequentialize them one
per turn when they are independent.
- **Source-reading on prod (`run cat/grep/find /opt/…`) is NOT the way to
learn how the platform works.** The MCP tools ARE the interface. If you
need to understand a check lifecycle or a scheduler behavior, search
`search_knowledge("oikos check lifecycle")` or ask the operator — do
not treat the prod host as a code repository you grep.
## Policy awareness
@@ -355,6 +404,26 @@ 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.
**When you hit a genuine missing capability — STOP and ask, don't bypass:**
If a tool returns `entity … not found` when you're trying to create something
(a check, an ingress, a cert, a new service), the entity doesn't exist yet —
use `create_entity`. If you need to retire/delete an entity, use
`set_entity_state`. If you need to remove a relationship, use
`end_relationship`. If NONE of these fit and you truly lack a tool, **tell the
operator directly: "I need to X, but no MCP tool does that — can you create it
via the API?"** Do NOT pivot to `run find/grep/cat` on `/opt/homelab-context`
to reverse-engineer how the platform works — MCP tools are the interface, not
the prod source tree.
**Self-grounding — use the DB, don't invent:**
- `run` targets must be `host:<slug>`, `lxc:<slug>`, or `vm:<slug>` — never
`ws:`, raw container names, or Docker Compose service aliases.
- Never invent an IP address or subnet. Query `get_entity("service:oikos")` for
the real API address, `get_entity("host:<name>")` for a host's real LAN IP,
`list_lxcs` for container addresses. The DB is authoritative; your guess is
wrong (the homelab has multiple subnets — `192.168.8.0/24`, `192.168.178.0/24`,
etc. — and guessing the wrong one wastes turns).
**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
@@ -391,6 +460,21 @@ 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.
**Scope gate — ask before chasing unrelated subsystems.** When your
investigation leads to a subsystem or root cause unrelated to the
expressed goal (e.g. the user asked "why is X unreachable?" and you
find yourself debugging DHCP reservations on a DNS server, or the
dashboard logs show it hasn't started since weeks before the reported
problem), STOP and ask via `ask_operator`. Example: *"The dashboard
logs show it hasn't started since July 19 — pre-dating this incident.
Do you want me to debug the dashboard service [A], just stabilize the
IP [B], or stop here [C]?"* Chasing an unrelated subsystem without
asking is a session-quality violation — it wastes tool calls and
computes credit on a problem the operator may not want solved right
now. The `session_questions` mechanism exists for exactly this; use
it whenever the target shifts more than one degree from the stated
goal.
**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

View File

@@ -0,0 +1,224 @@
# 2026-08-03 — Session review: `service:haos` monitoring + agent capability gaps
**Status:** Plan (audit complete; ready to implement).
**Reviewed session:** `23da10db-46a9-444c-bbde-ca9457bd9087` — *"Work out what
monitoring checks service:haos should have and configure them."*
**Method:** Direct Postgres read of `agent_sessions`/`agent_messages`/
`agent_activity`/`session_plan_steps` on the prod mac-mini (oikos prod runs here
in docker compose project `oikos`; gateway `:8092`), cross-referenced with the
code paths in `internal/mcp`, `internal/httpapi`, `internal/policy`,
`internal/checkdefaults`, `internal/db/seed.go`.
---
## 1. Session audit (objective vs outcome)
| Dimension | Finding |
|---|---|
| Objective | Determine + configure monitoring checks for `service:haos` (HAOS VM 108, `home.hubris.network`, `192.168.8.101:8123`). |
| Outcome | ❌ **Failed/stuck.** `status=executing`, `outcome=null` ~4 min after last activity (UTC); never reached a terminal state. Only the *existing* `check:vm-status:vm:haos:0` stub got populated; the three **new** checks (`http:service`, `http:ingress`, `cert-expiry`) and their `ingress:`/`cert:` entities were never created. |
| Tool calls | **116** (vs the >30 N+1 failure signature). ~45 redundant `list_entities`/`get_entity`/`get_relations`, then a ~15-min storm of `run` doing `find`/`grep`/`cat` on prod source. |
| Plan | 8 steps proposed; steps 14 genuinely done; **step 5 falsely marked "done"** after both its tool calls errored `entity not found`; steps 68 never started. |
| Operator friction | 3 manual interventions: `status`, `proceed`, *"why dony you use the mcp?"*; plus a **44-minute approval stall** (19:52→20:36) on two trivial reachability curls. |
| Severity | **blocker** (capability gap) + **friction** (classifier, plan-state, reaping). |
### Timeline (UTC)
- **19:4519:50** — read-only exploration; `run` correctly blocked ("No plan… call set_goal then propose_plan"). Good guard.
- **19:50** — `propose_plan` (8 steps).
- **19:52** — two `curl … -o /dev/null -w '%{http_code}'` reachability probes → both classified `config_mutation` → one queued for approval (`019fc92e…`), second blocked ("approval already pending").
- **19:52 → 20:36 (44 min)** — idle, waiting on operator approval.
- **20:36** — approval granted ("auto via assent window"); both curls → 200/200.
- **20:37** — step 4 ✅: populated `check:vm-status:vm:haos:0` + `checks` edge.
- **20:37:58** — step 5 ❌: `update_entity_attributes("check:http:service:haos:0")`**`entity not found`**; `create_relationship`**`source entity not found`**. *(There is no create tool.)*
- **20:3820:47** — spiral: `search_knowledge` (empty), then `run find/grep/cat` across `/opt/homelab-context/**/*.go` to reverse-engineer check creation. Reads `checkdefaults.go`, `monitoring.go`, `default_checks.go`, `checks.go`, `coverage.go`.
- **20:42** — sets `service:haos` `monitoring: ["http"]` via `update_entity_attributes`, hoping `checkdefaults.Ensure()` auto-generates. **It does not** (see A2).
- **20:4220:50** — tries to reach the REST API directly: `psql` on hubris (cmd 127), `docker exec` on hubris (docker absent), `curl http://192.168.178.25:8090` (wrong subnet; real net is `192.168.8.x`; exit 7), `curl http://oikos-api:8090` (MCP routes to hubris which can't resolve the mac-mini docker alias; 30s timeouts ×2), `ssh root@192.168.178.25` (no route). Final `update_entity_attributes` on `ingress:`/`cert:``not found`.
- **20:50:38** — last activity: a failed 30s `run`. Session goes silent, never terminates.
---
## 2. Root-cause findings (with code evidence)
### A1 — No entity-creation capability in the MCP toolset *(the blocker)*
`internal/mcp/tools.go` registers **37 tools**; the only entity-mutation surface is
`update_entity_attributes` (merge into an **existing** entity) and
`create_relationship` (needs **existing** source+target). Neither can create a new
entity. The capability **does** exist at the HTTP layer — `CreateEntity`
(`internal/httpapi/impl.go:865`, `POST /api/v1/entities`) — it is simply not exposed
to the agent. Every "set up / onboard / configure entity X" task that needs a new
check/ingress/cert/service hits this wall.
### A2 — MCP `update_entity_attributes` bypasses `ensureDefaultChecks`
`ensureDefaultChecks` (`internal/httpapi/default_checks.go:20`) is invoked **only**
from the HTTP handlers: `CreateEntity` (`impl.go:1012`) and `PatchEntity`
(`impl.go:1280`). `grep ensureDefaultChecks internal/mcp/`**no matches**: the MCP
tool writes attributes straight to the store, so flipping `service:haos`
`monitoring:["http"]` never regenerated its checks. The agent's fallback strategy
was structurally doomed via MCP.
### A3 — `-o /dev/null` curl idiom misclassified as `config_mutation`
`internal/policy/command.go:106` `curlMutateRe` matches `(?:^|\s)-(?:d|F|T|o)\b` — so
`-o` (output-file) is treated as mutation. The canonical read-only reachability probe
`curl -sS -o /dev/null -w '%{http_code}' …` therefore escalates to approval. This is
the entire 44-minute stall. (`curlIsReadOnly` at `command.go:307` only passes for GET
with no `-o`/`-d`/`-X`/`>`.) A pure GET that discards the body is the single most
common health probe and shouldn't need approval.
### A4 — No platform self-knowledge doc for the check lifecycle
`search_knowledge("create check entity how to add new check monitoring")` → empty.
The agent re-derived the whole mechanism from source on prod (~15 min, dozens of
`run`). There is no agent/operator runbook explaining: check slugs are
`check:<kind>:<target>:<n>`; `check_defs` are derived from the type's `monitoring`
spec by `checkdefaults.Ensure`; Ensure runs at **seed/deploy** and on **HTTP
create/patch**, not via MCP.
### A5 — False plan progress (step marked done on failure)
At 20:37:58 both tool calls for step 5 returned `error: entity not found`, yet the
agent advanced step 5→`done`. Plan-state integrity hole: a step whose actions error
should not transition to `done`. (`session_plan_steps` confirms seq 5 = `done`.)
### A6 — No "missing-capability" escalation; self-grounding failures
On detecting the dead-end (no create tool) the agent never told the operator *"I lack
a tool to create entities — please create them"*; instead it tried to bypass its own
platform. Grounding errors: invented IP `192.168.178.25` (real LAN is `192.168.8.x`),
ran `run` against `ws:mac-mini` ("unsupported target — must be host:/lxc:/vm:"),
assumed `docker` exists on hubris, assumed the docker-alias `oikos-api` resolves from
hubris. The agent didn't query `get_entity("service:oikos")` for the real address.
### A7 — Sessions never reap from `executing`
Last activity 20:50; status still `executing` with no turn running. There is no
idle-timeout / abandoned transition when a turn ends without resolution. (Fleet-wide:
176 done / 9 failed / 1 executing; the 9 prior failures are pre-v0.15.0, mostly
approval-stalls and entity-not-found — same families.)
### A8 — N+1 tool fan-out (116 calls)
Dozens of redundant `list_entities`/`get_entity`/`get_relations` before proposing a
plan, plus the source-reading `run` storm. Above the >30-per-turn signature; indicates
weak bulk-tool use and under-constrained exploration before planning.
---
## 3. Improvement plan (ordered)
**Scope decision (confirmed with operator):** general `create_entity` MCP tool **+
wire regen** — solves this case and the 67-entity blast radius (§4).
### Task 1 — `create_entity` MCP tool *(fixes A1; the centerpiece)*
- Register a new tool `create_entity(slug, type, name, attributes?)` in
`internal/mcp/tools.go` that **reuses** `httpapi.CreateEntity`
(`impl.go:865`) / the same store path — do not hand-roll. It must run
`ensureDefaultChecks` (free, since it goes through the create path).
- **Approval policy:** no approval required for the entity itself — it mutates the
knowledge graph, matching the existing no-approval stance of
`update_entity_attributes`/`create_relationship`/`upsert_knowledge`. (Derived checks
are safe/read-side; if a check kind is ever deemed mutating, gate *that* in the
scheduler, not here.)
- Validate `type` against `entity_types`; reject unknown slugs/types with a clear
error. Idempotent on existing slug (return the existing entity, mirroring the HTTP
`ETag`/conflict behavior).
- Expose to the agent via the tool-list build path used by `cmd/nomos/agent.go`.
### Task 2 — MCP `update_entity_attributes` triggers `ensureDefaultChecks` *(fixes A2)*
- After the attribute merge in the MCP handler, call `ensureDefaultChecks` with the
post-merge entity (same args as `impl.go:1280`). This makes "set monitoring → checks
regenerate" work via MCP, matching HTTP semantics.
- Mind the `default_checks.go:14-19` caveat: a service whose address comes from its
host edge may still produce no checks until the hosting edge exists — log/return
that as an explicit result so the agent knows to create the edge next.
### Task 3 — Classifier: read-only `curl` with `-o /dev/null` *(fixes A3)*
- In `internal/policy/command.go` `curlIsReadOnly`, treat `-o /dev/null` (and
`--output /dev/null`) as read-only — it's a no-op sink. Keep `-o <realpath>` as
mutation. Add `TestClassifyCommand_CurlDevNull_ReadOnly` next to the existing
`TestClassifyCommand_CurlPipeSh_ConfigMutation`.
- Coach complement: in `nomos/SOUL.md`, note that reachability probes should use
`curl -I` or `-o /dev/null` GETs (now read-only) rather than POSTs.
### Task 4 — Plan-state integrity: don't mark `done` on errored actions *(fixes A5)*
- In `cmd/nomos` (`agent.go`/`tasks.go` where `update_plan_step` is emitted), a step
whose turn ended with only error/`not-found` tool results must **not** auto-advance
to `done`; leave it `running`/`blocked` and surface the failure to the operator.
Minimal: if every tool call in the step returned an `error:*` result, hold the step.
### Task 5 — Stuck-session reaping *(fixes A7)*
- Add an idle sweep (extend the existing continuation/idle worker in `cmd/nomos`) that
transitions a session from `executing``failed` (or a new `stuck`) when no turn has
run for N minutes and no approval is pending. Emit an event so the UI (F3 terminal
handling) clears the spinner. Pick N (recommend 30 min) — confirm in review.
### Task 6 — Missing-capability escalation + grounding *(fixes A6)*
- `nomos/SOUL.md`: when a mutation tool returns `entity … not found` on a create
intent, the agent must **stop and ask the operator** (or now use `create_entity`)
rather than pivot to `run`/SSH/API-bypass. Forbidden: inventing IPs/subnets; instead
`get_entity("service:oikos")` for the real API address. `run` targets must be
`host:/lxc:/vm:` slugs (state the contract explicitly).
### Task 7 — Runbook: "how checks work / how to add monitoring" *(fixes A4)*
- Upsert a knowledge doc (via `upsert_knowledge`, linked to the `agent:nomos` and
`document:infrastructure/monitoring` entities) covering: check slug grammar,
`checkdefaults.Ensure` triggers (seed + HTTP create/patch, now also MCP), the
`monitoring` per-entity override, the host-edge caveat, and the canonical way to add
monitoring to an entity (create/patch entity → checks derive).
### Task 8 — (Lower priority) exploration budget / bulk-tool use *(A8)*
- `nomos/SOUL.md`: prefer `list_entities(limit)` + `get_entity_knowledge` bulk calls
over N+1 `get_entity`/`get_relations` fans; cap pre-plan exploration. Optional
guardrail in `agent.go` (warn at >N same-tool calls per turn).
### Recommended sequence
1 → 2 → 3 → 4 → 7 → 5 → 6 → 8. (1+2 unblock the whole task class; 3 kills the
approval stall; 4+5 fix state integrity; 7 is cheap leverage; 6+8 are persona
hardening.)
---
## 4. Uncovered cases — the capability-gap blast radius
The existing F1F8 plans (`2026-08-03-nomos-chat-reliability-and-ux-audit.md`,
shipped v0.15.0) and the turn-scheduler review cover **only** UI / streaming / turn
serialization / connection UX. **None** addresses agent *capability* or
MCP↔HTTP integration. This session exposes the uncovered class:
- **67 entities currently have no `check:` relationship** (DB query): 40 `lxc`, 26
`service`, 1 `vm`. Any "add monitoring to X" task fails identically until Tasks 1+2.
- **Whole task families blocked by the no-create gap:** onboarding a new host/LXC/VM,
declaring a new service/ingress/cert/dns, adding any check that doesn't already
exist, registering a relationship target that doesn't exist yet. All currently
require an operator to hand-edit `seeds/inventory.yaml` and re-seed.
- **MCP↔HTTP semantic drift (generalize A2):** audit other MCP mutation tools for
side-effects that the HTTP handlers perform but the MCP path skips (check regen,
drift-flagging, audit fields, idempotency). Each is a latent "agent did the right
thing but nothing happened" bug.
- **Classifier read-only false-positives (generalize A3):** beyond `-o /dev/null`,
review other common read-only idioms that escalate (`curl` with benign flags,
compound read-only commands) — friction compounds into approval stalls and stuck
sessions.
- **No terminal/`stuck` reaping (generalize A7):** any turn that ends unresolved
leaves the session `executing` forever; the UI never shows "done/failed".
---
## 5. Validation
- **Task 1/2:** `go test ./internal/mcp/... ./internal/httpapi/...` — new test creates
`check:http:service:haos:0` via `create_entity`, asserts the entity exists **and**
that a `check_def` row was derived; then `update_entity_attributes(service:haos,
monitoring:["http"])` via MCP and assert checks regenerate (currently absent).
- **Task 3:** `go test ./internal/policy/` — `curl -sS -o /dev/null -w '%{http_code}'
URL` ⇒ `read_only`; `curl -o /tmp/x URL` ⇒ `config_mutation`.
- **Task 4:** `cmd/nomos` test — a step whose only tool result is `error:*` stays
non-`done`.
- **Task 5:** idle-sweep test — session with no turn for N min and no pending approval
⇒ `failed` (+ event emitted).
- **End-to-end re-run:** replay the haos goal against a local nomos; expect the three
checks + `ingress:`/`cert:` entities created in <15 tool calls with **zero**
approvals and a `done` outcome.
## 6. Out of scope / open questions
- Whether `create_entity` for sensitive types (e.g. `secret`, `key`) should require
approval even though it's graph-only — recommend: same no-approval stance now, add
type-specific gating later if abused.
- The exact stuck-reap window N (recommend 30 min) and whether to introduce a distinct
`stuck` status vs reuse `failed`.
- Whether to also expose a `delete_entity`/`retire_entity` MCP tool (not needed for
this case; lifecycle retirement is a separate flow).

View File

@@ -1,5 +1,9 @@
# 2026-07-21 Chat window full polish
**Status:** Implemented. Streaming affordance, inline tool rendering, message
timestamps, code-copy buttons, and per-session store isolation all landed in
`web/src/lib/components/ChatThread.svelte` + the chat stores (v0.8.x0.10.x).
## Context
After fixing the streaming reactivity bug and merging the double thinking

View File

@@ -1,6 +1,9 @@
# Plan: Make health reflect reality + complete the knowledge graph
Status: ready for implementation · Created 2026-07-29
Status: Implemented (v0.14.x0.16.x). Shipped across `c9a00a9` (per-entity
monitoring override), `a3914eb`/`8eb1ca2` (process check opt-in + probe_unit),
`0929c17` (discover_infra_drift), and the vm-status/layered-probe/route-via-
proxmox-host decisions now in project memory. Created 2026-07-29.
## Context

View File

@@ -0,0 +1,503 @@
# 2026-08-03 — Adopt cyberspace.online terminal aesthetic + dithered images
**Status:** Implemented in v0.16.0 (`757ef2f`). Shipped as a **full theme
replacement** (Terracotta/Carbon → cyberspace BBS/terminal style), not the
opt-in addition originally drafted below — the operator chose full replacement
during execution (see decision `theme.replace_with_cyberspace`). The `<RasterImage>`
Atkinson-dithering component and the warm-cream/JetBrains-Mono look landed as
drafted; only the "opt-in vs replace" scope changed.
Adopt the look of https://cyberspace.online/ (a BBS / "social media
de-imagined" terminal aesthetic) as a **new, opt-in theme family** in oikos,
with **both light and dark variants**, plus a reusable **`<RasterImage>`**
component that renders images to a `<canvas>` with Atkinson dithering (the
"kinda dithered" image style). The existing Terracotta/Carbon themes stay the
default; this adds, it does not replace.
---
## TL;DR
1. Add a third theme family — **"Cyberspace Dark"** and **"Cyberspace Light"** —
wired through the same `--background` / `--foreground` / … token layer every
component already uses, so nothing in the UI tree changes; only the tokens
get new values. Square corners (`--radius: 0`), warm cream-on-black, mono
everything.
2. Extend `web/src/lib/stores/theme.svelte.ts` from a 2-state `'light'|'dark'`
toggle to a named-theme model, keeping `.dark` class behavior for
compatibility.
3. Self-host JetBrains Mono (body) + a pixel/terminal face (VT323 or Departure
Mono) for the logo/headings accents, replacing the Google Fonts `<link>`.
4. Build `web/src/lib/components/RasterImage.svelte`: draws any image to a
`<canvas>` reduced to a 2-color (theme `fg`/`bg`) palette via **Atkinson
dithering**, with an `<img>` fallback and a skeleton placeholder — exactly
the cyberspace pattern. Re-renders when the theme changes (palette flips).
5. Optional cosmetic idioms (terminal-box focus ring, braille spinner, `<s>`
strike lists) as small additive utilities, not a redesign.
The whole thing is **non-breaking and incremental**: each step ships behind the
existing theme picker, so Terracotta/Carbon users see nothing until they opt in.
---
## 1. Extracted style spec (source of truth from cyberspace.online)
Captured from the live site's SSR HTML + inline boot script. This is the
reference the tokens below are derived from.
### 1.1 Color model
Cyberspace defines **exactly three colors per theme**`fg`, `bg`, `fgDim`
applied to CSS custom properties. Everything else (borders, primary, cards) is
*derived* from those three. There are 11 named themes total; the two we care
about:
| Theme | `fg` (text) | `bg` (canvas) | `fgDim` (muted) |
|---------|--------------|---------------|-----------------|
| Dark | `#efe5c0` | `#000000` | `#a89984` |
| Light | `#000000` | `#efe5c0` | `#3a3a3a` |
Note the elegance: **light and dark are exact inverses** — they share the same
warm cream (`#efe5c0`, a Gruvbox-ish paper tone) and just swap which side of it
is ink vs. paper. The muted tone `#a89984` is straight out of the Gruvbox
palette. This is why both themes read as "the same site" despite opposite
polarity.
Boot-time fallback (the site's original/GRiD theme) is amber `#FF9810` on
`#120900` — useful as a *third* optional accent if we ever want a true-phosphor
variant.
### 1.2 Type
- **Body / mono:** JetBrains Mono (self-hosted `.woff2`, Regular).
- **Boot + logo accents:** Departure Mono (self-hosted `.woff2`). A quirky
monospace; VT323 (Google, free) is a close, easy substitute.
- **Stylized wordmark** (`ᑕ¥βєяรקค¢є`, class `.font-vt`): a terminal/pixel face.
Rule lives in their external `entry.*.css` (not in the SSR dump); VT323 is the
safe assumption.
cyberspace sets `font-mono` on the root wrapper — the **entire UI is
monospace**. There is no proportional body face. Headings use the same mono
family at larger size / normal weight.
### 1.3 Layout & component idioms
- **Left rail nav:** fixed, icon-only when minimized (~80px), expands on click.
Square buttons, Phosphor icons, uppercase `text-xs` labels.
- **`.terminal-box`:** the universal card. Bordered (`border border-border`),
**square corners** (`rounded-none` everywhere — `--radius` is effectively 0),
and on focus/emphasis gets `ring-2 ring-fg` (a 2px ring in the foreground
color).
- **Emphasis by inversion:** active/primary state is `bg-fg text-bg` — fill with
foreground ink, text becomes the canvas color. No separate "accent" hue; the
accent *is* fg.
- **Strikethrough as a feature list:** `<s>Ads</s> <s>Videos</s> …` — crossed-out
`<s>` elements spell out what the product removes. Cheap, on-brand.
- **Braille spinner:** `BrailleSpinner` component animates braille block chars
(`⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`) for loading states instead of a circle.
- **Max content width** `max-w-4xl`, centered; generous vertical rhythm; thin
2px scrollbars colored `--color-border`.
- Borders are `1px solid` in a border color derived from `fg`/`fgDim` at low
alpha (their `--color-border` is not literally in the dump, but every
bordered surface uses it, and it tracks `fg`).
### 1.4 The dithered image (`RasterImage`) — what we actually know
From the SSR HTML the component is unambiguous about its *shape*, silent on its
*algorithm* (the dither JS is in an external `/_nuxt/*.js` bundle not present in
the page dump):
- Renders a **`<canvas>`** as primary output, with an **`<img>` fallback** as a
sibling. Parent selectors `[&>canvas]:max-w-full [&>canvas]:h-auto` and
`[&>img]:…` size both responsively.
- Emits a **`.raster-image-skeleton`** placeholder (empty div, `background:
var(--color-bg)`) during SSR/before hydration — no flash of the raw photo.
- Scoped styles (`data-v-4d61df89`): `.raster-image { display:block }`,
`.raster-image-skeleton { display:block; background:var(--color-bg) }`.
**Inferred technique** (standard for this look): Canvas 2D → `drawImage` →
`getImageData` → per-pixel luminance reduction to a 2-color palette (`fg`/`bg`)
with an **error-diffusion** pass (Atkinson or FloydSteinberg) → `putImageData`.
This produces the characteristic speckled 1-bit halftone. Target is almost
certainly the theme's own fg/bg, which is *why* the dithered art recolors
correctly when you flip themes.
We will implement Atkinson (see §4) — it's the classic Mac/BBS dither, slightly
softer than FloydSteinberg, and matches "kinda dithered" precisely.
---
## 2. Recommended approach: opt-in theme family (not a rebrand)
oikos today = Art-Nouveau / terracotta / rounded / serif-heading (Inknut
Antiqua), floating-window desktop shell. cyberspace = BBS / mono / square /
cream-on-black. These are **opposite poles**; a flat rebrand would discard the
existing art direction and rework every component's rounding/spacing.
**Decision: add cyberspace as a new theme family, selectable in the existing
theme picker.** This is low-risk, reversible, and lets the dithered images +
terminal idioms land incrementally. The full-rebrand alternative is documented
in §7 for if you later decide to make it the default.
Because every oikos component consumes colors through the Tailwind v4 token
layer (`--background`, `--foreground`, `--card`, `--border`, `--primary`, …)
defined in `web/src/app.css` `@theme inline`, a new theme is **just a new set
of values for those same custom properties** — zero component edits required
for the recolor. That indirection is the whole reason this is cheap.
---
## 3. Theme token additions (`web/src/app.css`)
Add two new blocks alongside the existing `:root` (Terracotta) and `.dark`
(Carbon). They set the *same* token names to cyberspace's values, plus pin
`--radius: 0` for square corners and remap fonts (see §5).
Driven by a `data-theme` attribute on `<html>` (set by the store, §6), so all
four states — Terracotta, Carbon, Cyberspace Dark, Cyberspace Light — coexist:
```css
/* ── Cyberspace Dark (cream on black) ── */
:root[data-theme='cyber-dark'] {
--radius: 0px;
--background: #000000;
--foreground: #efe5c0;
--card: #000000; /* cyberspace has no card tint; cards are just bordered bg */
--card-foreground: #efe5c0;
--popover: #000000;
--popover-foreground: #efe5c0;
--primary: #efe5c0; /* emphasis = fg ink */
--primary-foreground: #000000; /* inverted */
--secondary: #1a1a1a;
--secondary-foreground: #efe5c0;
--muted: #141414;
--muted-foreground: #a89984; /* fgDim */
--accent: #efe5c0;
--accent-foreground: #000000;
--destructive: #cc241d; /* Gruvbox red, sits in the same palette */
--destructive-foreground: #efe5c0;
--border: color-mix(in oklab, #efe5c0 22%, transparent); /* fg-derived hairline */
--input: color-mix(in oklab, #efe5c0 28%, transparent);
--ring: #efe5c0; /* the ring-2 ring-fg look */
--sidebar: #000000;
--sidebar-foreground: #efe5c0;
--sidebar-primary: #efe5c0;
--sidebar-primary-foreground: #000000;
--sidebar-accent: #1a1a1a;
--sidebar-accent-foreground: #efe5c0;
--sidebar-border: color-mix(in oklab, #efe5c0 22%, transparent);
--sidebar-ring: #efe5c0;
--chart-1: #efe5c0; --chart-2: #a89984; --chart-3: #fabd2f;
--chart-4: #b8bb26; --chart-5: #83a598; /* Gruvbox for charts */
--success: #b8bb26; --warning: #fabd2f;
/* oikos semantic aliases (app.css :root block) */
--bg: var(--background); --bg-surface: var(--card); --bg-deeper: #050505;
--bg-hover: var(--secondary); --bg-active: var(--accent);
--text: var(--foreground); --text-muted: var(--muted-foreground);
--accent-blue: #83a598; --accent-green: var(--success);
--accent-red: var(--destructive); --accent-orange: var(--warning);
/* terminal face for this theme only (see §5) */
--font-sans: 'JetBrains Mono', ui-monospace, Menlo, monospace;
--font-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
--font-heading: 'VT323', 'JetBrains Mono', monospace; /* pixel wordmark feel */
}
/* ── Cyberspace Light (black on cream paper) — exact inverse ── */
:root[data-theme='cyber-light'] {
--radius: 0px;
--background: #efe5c0;
--foreground: #000000;
--card: #efe5c0;
--card-foreground: #000000;
--popover: #efe5c0;
--popover-foreground: #000000;
--primary: #000000;
--primary-foreground: #efe5c0;
--secondary: #e0d6b0;
--secondary-foreground: #000000;
--muted: #e6dcc0;
--muted-foreground: #3a3a3a; /* fgDim */
--accent: #000000;
--accent-foreground: #efe5c0;
--destructive: #9d0006;
--destructive-foreground: #efe5c0;
--border: color-mix(in oklab, #000000 22%, transparent);
--input: color-mix(in oklab, #000000 28%, transparent);
--ring: #000000;
--sidebar: #efe5c0;
--sidebar-foreground: #000000;
--sidebar-primary: #000000;
--sidebar-primary-foreground: #efe5c0;
--sidebar-accent: #e0d6b0;
--sidebar-accent-foreground: #000000;
--sidebar-border: color-mix(in oklab, #000000 22%, transparent);
--sidebar-ring: #000000;
--chart-1: #000000; --chart-2: #3a3a3a; --chart-3: #b57614;
--chart-4: #79740e; --chart-5: #076678;
--success: #79740e; --warning: #b57614;
--bg: var(--background); --bg-surface: var(--card); --bg-deeper: #e6dcc0;
--bg-hover: var(--secondary); --bg-active: var(--accent);
--text: var(--foreground); --text-muted: var(--muted-foreground);
--accent-blue: #076678; --accent-green: var(--success);
--accent-red: var(--destructive); --accent-orange: var(--warning);
--font-sans: 'JetBrains Mono', ui-monospace, Menlo, monospace;
--font-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
--font-heading: 'VT323', 'JetBrains Mono', monospace;
}
```
Two notes:
- **`.dark` vs `data-theme`.** The current store flips `.dark` on `<html>`. To
keep Carbon working unchanged, leave `.dark` logic alone and layer
`data-theme` on top: when a cyberspace theme is active the store sets
`data-theme` and **removes** `.dark` (cyberspace themes are self-contained —
they set both polarities explicitly). See §6.
- **Borders from `fg`.** cyberspace's hairline tracks the foreground, not a
fixed gray. `color-mix(in oklab, <fg> 22%, transparent)` reproduces that and
auto-flips between the two themes. Tune the % after visual review.
---
## 4. The dithered image component (`RasterImage.svelte`)
**File:** `web/src/lib/components/RasterImage.svelte` (sibling of the existing
`Spinner.svelte`).
### 4.1 API
```svelte
<RasterImage src={entity.iconUrl} alt="host icon" width={320} />
<!-- optional: scale (downsample factor), threshold bias, mono palette override -->
```
- `src`, `alt` — as `<img>`.
- `width` — render width in CSS px; canvas is sized to this × natural aspect.
Downscaling before dithering is what sells the "lo-fi" look (defaults ~256
320). Expose `scale` (01) to control.
- Reads the active theme's `--foreground` / `--background` via
`getComputedStyle(document.documentElement)` so the dither palette **follows
the theme** (cream/black in cyber-dark, black/cream in cyber-light, and
perfectly sensible in Terracotta/Carbon too).
### 4.2 Behavior
1. Show `.raster-image-skeleton` (empty, `background: var(--background)`) until
the source image loads — matches cyberspace's no-flash placeholder.
2. On load: create an offscreen canvas at `width × (h/w*width)`, `drawImage`
(with `imageSmoothingEnabled = true` for the downscale), pull
`getImageData`.
3. Run **Atkinson dithering** to 2 colors:
- For each pixel: luminance `Y = 0.299R + 0.587G + 0.114B`.
- Threshold at 128 (+ optional `bias`), snap to either `fg` or `bg`.
- Push **1/8 of the quantization error** to each of 6 neighbors (Atkinson's
kernel): right, below-left, below, below-right, and two pixels down on the
next-next row. (Atkinson diffuses less than FloydSteinberg → softer, more
"screen-printed" — exactly the cyberspace feel.)
- Write `fg`/`bg` (read from CSS vars at render time) into the buffer.
4. `putImageData`. Canvas is the visible output; the loaded `<img>` is kept as
`aria-hidden` fallback for no-JS / copy-image / accessibility.
5. **Re-dither on theme change**: subscribe to the theme store; when it flips,
re-read `--foreground`/`--background` and re-run steps 34 (cheap — the
decoded `ImageBitmap` is cached, only the palette pass reruns). This is the
detail that makes the art flip polarity with the theme toggle.
6. **Respect `prefers-reduced-data` / reduced motion?** Dithering is not motion,
but offer a `plain` prop to skip the canvas and render the raw `<img>` for
users who want crisp photos (e.g. entity detail screens where legibility
beats aesthetic).
### 4.3 Reference dither kernel (Atkinson)
```
* → 1/8 1/8
1/8 1/8 1/8 (current pixel = *)
1/8 1/8 (* is at top-left of this 4×? — see standard Atkinson spread)
```
Spread pattern (error e from pixel at (x,y) distributed):
```
px x+1 (1/8) x+2 (1/8)
x-1 (1/8) x (1/8) x+1 (1/8)
x+1 (1/8) x+2 (1/8) [next row offsets]
```
Concretely, 6 neighbors each get `e/8`: `(x+1,y)`, `(x+2,y)`, `(x-1,y+1)`,
`(x,y+1)`, `(x+1,y+1)`, `(x,y+2)`. (Clamp at edges — drop, don't wrap.)
### 4.4 Where to use it
- Entity icons / host thumbnails in the KB and entity desktop (the obvious win).
- Mascot or login/Config background art (`ConfigBackground.svelte` already
exists — a dithered backdrop there would be striking).
- Any user-uploaded image in chat/knowledge where we want the "de-imagined"
tone. Keep it **opt-in per call site** via the `plain` prop — don't dither
diagrams/screenshots that need to stay readable.
### 4.5 Cross-origin caveat
`getImageData` throws on tainted canvases. If `src` is cross-origin and the
server doesn't send CORS headers, fall back to the plain `<img>` (log once).
For self-hosted assets (the common case here) it's a non-issue.
---
## 5. Fonts: self-host JetBrains Mono + VT323
cyberspace self-hosts both faces as `.woff2`. oikos currently pulls DM Sans /
DM Mono / Inknut Antiqua from Google Fonts via a `<link>` in
`web/index.html:10`.
- Drop `JetBrainsMono-Regular.woff2` and `VT323-Regular.woff2` under
`web/static/fonts/` (or `web/public/fonts/` — match where static assets are
served from; check `vite.config`).
- Add `@font-face` blocks at the top of `app.css` with `font-display: swap`.
- For the cyberspace themes only, the `--font-sans`/`--font-mono`/`--font-heading`
overrides in §3 remap the families — Terracotta/Carbon keep DM Sans/Inknut
untouched. This is the key trick: **font choice is part of the theme**, not a
global swap, so the two art directions don't fight.
- Leave the Google Fonts `<link>` in place for now (Terracotta/Carbon still need
it); add a follow-up to self-host those too if we want to kill the external
request entirely. Out of scope for this plan.
VT323 vs Departure Mono: VT323 is free on Google Fonts and trivial to self-host;
Departure Mono is the authentic cyberspace face but needs a license check.
**Recommend VT323** to start; swap to Departure Mono later if you want exact
fidelity.
---
## 6. Theme store changes (`web/src/lib/stores/theme.svelte.ts`)
Current: `Theme = 'light' | 'dark'`, flips `.dark` class. Extend to a named set
while preserving the existing API (callers of `toggleTheme`/`getTheme` keep
working):
```ts
export type ThemeName = 'terracotta' | 'carbon' | 'cyber-dark' | 'cyber-light'
// Back-compat aliases used by existing callers:
// 'light' -> 'terracotta', 'dark' -> 'carbon'
```
- Store key stays `oikos-theme`; migrate old `'light'`/`'dark'` values on read.
- `applyClass` becomes `applyTheme`: sets `data-theme` on `<html>` and toggles
`.dark` **only** for `carbon` (so Terracotta and both cyberspace themes run
with no `.dark`). This is important: the `.dark` block in `app.css` must not
layer on top of the cyberspace token blocks — cyberspace sets its own
polarities.
- Update `THEME_LABELS` to the four names; update whatever UI surfaces the
picker (search for `THEME_LABELS` / `toggleTheme` usages — likely
`Settings.svelte` or the desktop shell's chrome) to a 4-option control instead
of a binary toggle.
**Watch out:** any code that assumes `document.documentElement.classList.contains('dark')`
≡ "dark colors" will be wrong for `cyber-dark`. Audit `grep -rn "classList.*dark\|\.dark" web/src` and prefer reading `getTheme()`/`data-theme` instead.
---
## 7. Optional cosmetic idioms (additive utilities)
Small, theme-aware utilities in `app.css` — usable in any theme but idiomatic
for cyberspace:
- `.terminal-box` — `{ border:1px solid var(--border); border-radius:0 }` plus a
`.terminal-box:focus-within { box-shadow: 0 0 0 2px var(--ring) }` to mirror
the `ring-2 ring-fg` focus. Lets cards opt into the terminal look without a
component rewrite.
- `.braille-spinner` — keyframe cycling `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏` as `::after` content, colored
`var(--muted-foreground)`. Alternative to `Spinner.svelte` for loading states
under cyberspace themes.
- `.font-vt` — `{ font-family: var(--font-heading) }` so the wordmark class
cyberspace uses maps to our heading var (VT323 under cyber themes, Inknut
under Terracotta). Drop-in for any stylized title.
- `.strike-list` — `li > s { color: var(--muted-foreground) }` convenience for
the crossed-out feature-list pattern in marketing/empty states.
None of these are required for the theme to work; they're palette for the
"de-imagined" voice where we want it.
---
## 8. Implementation order (incremental, each step shippable)
1. **Fonts** (§5) — self-host JetBrains Mono + VT323, `@font-face` in app.css.
No visual change yet (only cyberspace themes reference them).
2. **Tokens** (§3) — add the two `:root[data-theme='cyber-*']` blocks.
3. **Store** (§6) — extend `theme.svelte.ts` to named themes + `data-theme`;
update the picker UI. **At this point both cyberspace themes are live and
fully recolor the whole app** — the cheapest milestone, biggest visible win.
4. **`RasterImage.svelte`** (§4) — build + wire into entity icons and
`ConfigBackground`. This is the "dithered image" deliverable.
5. **Idioms** (§7) — terminal-box, braille spinner, etc., applied opportunistically.
Each step is independently mergeable. Step 3 alone satisfies "light + dark
cyberspace themes"; step 4 satisfies "dithered images."
---
## 9. Verification
- `cd web && npm run build` (or the repo's build command — confirm in
`web/package.json`) — Tailwind v4 must accept the new `data-theme` selectors
and `color-mix()` (both standard; no config change expected).
- `npm run check` / `svelte-check` for the store + component TS.
- Manual: cycle all four themes in the picker; confirm no `.dark` bleed on
`cyber-light`; confirm `RasterImage` re-dithers on theme flip; confirm
`prefers-reduced-data`/`plain` prop shows crisp image; confirm cross-origin
`src` degrades to `<img>` without console errors.
- Lighthouse / a11y: 1-bit dithered images still need a real `alt` (kept on the
fallback `<img>`); contrast on `#a89984`-on-black passes WCAG AA for body text
(ratio ≈ 7.4:1) — fine.
---
## 10. Alternatives considered
- **Full rebrand (replace Terracotta/Carbon).** Highest visual payoff, highest
cost: every component's rounding/serif/spacing was authored for the Art
ouveau
direction; square + mono would need a component-level sweep, not just tokens.
Defer unless you want cyberspace as *the* oikos look — then do it as a
follow-up that deletes Terracotta/Carbon and makes `cyber-dark` the sole
default.
- **CSS-only image dither (filters / SVG turbulence).** Cheaper, but can't do
true 1-bit error diffusion or recolor to theme fg/bg. Rejected — the canvas
pass is the whole point and is ~60 lines.
- **Ordered (Bayer) dither instead of Atkinson.** More regular/grid-like
("newspaper halftone"). Atkinson is softer and more terminal-like; keep
Bayer as a `algorithm='bayer'` prop option later if wanted.
- **Server-side dithering.** Could pre-dither icons at ingest. Rejected for
now — client canvas keeps one source of truth (the original image) and lets
the palette follow the live theme, which a baked asset can't.
---
## 11. Non-goals / out of scope
- Replicating cyberspace's sidebar-rail *layout* (oikos uses a floating-window
desktop shell; the rail is a different app model). We take the *visual*
language, not the IA.
- Porting the 9 other novelty themes (C64, Matrix, VT320, …). Two (light/dark)
satisfy the request; the token model makes adding more trivial later.
- Removing the Google Fonts dependency for Terracotta/Carbon (follow-up).
- Licensing/redistributing Departure Mono (use VT323 unless cleared).
---
## 12. Risks
- **`.dark` coupling.** Existing code may equate `.dark` with "dark UI".
Mitigation: audit in step 3; the grep is small.
- **Dither perf on large images.** Atkinson is O(n) and runs on a downscaled
canvas (≤~320px wide), so per-image cost is negligible; but batch-rendering
many entity icons on first paint could jank. Mitigation: dither lazily (on
intersection) and cache the result on the element.
- **Tainted canvas** on cross-origin images → silent fallback to `<img>`
(already handled in the design).
- **Token drift.** If a component hardcodes a color instead of using a token,
it won't recolor under cyberspace. This is the same risk Carbon already has;
no new exposure, just more visible under a stronger theme.

View File

@@ -0,0 +1,184 @@
# 2026-08-03 — Nomos chat: working-visibility, message queue, generation-aware timeline
**Status:** Implemented (F1F4) in v0.17.0. See
[Resolution](#resolution-2026-08-03) at the end.
## Context (grounded in last-session logs + DB, not just code)
Operator report: *"On the chat window I can't tell the agent is working; it's
making tool calls but no feedback. Typing returns 'Nomos is still finishing a
previous step…'. Activity not up to date. Several plans at once, some don't
execute."*
Verified against runtime state:
- **Last session `23da10db`** ran ONE live turn for **6m33s** (21 iterations,
19:45:36→19:52:03, correlation `679566cb`). At 19:48:00 the operator typed
`status`; at **19:48:05 the turn gate deferred it** (`turn already active,
deferring operator message`). The operator could type at all only because the
client had already lost the stream (`streaming=false`) while the server kept
running — i.e. the client showed an *idle* window over a *working* turn. It
ended `awaiting_input`.
- **Turn runtimes are long**: sessions in the DB run 15-27 min
(e.g. `44df8802` 24:24, `4319b9f8` 27:05). `handleChat` (main.go:170) has **no
SSE keepalive**; inter-iteration gaps reach 20-40s, so a proxy/browser idle
close mid-turn resets `streaming` while the turn continues on
`context.Background()` (pctx).
- **Re-proposing is real**: `44df8802` has **generation 1 (5 steps, all
`replaced`) → generation 2 (25 steps, done)**, with **2 `propose_plan` + 52
`update_plan_step`** calls persisted. The activity timeline renders every one
of those across both generations.
## Root causes
- **G1 — "working" == `streaming`.** Every working-indication in the chat window
(AgentTrace running status, indicator headline, stream cursor, panel spinner,
`disabled={streaming}` input) is gated on the live SSE flag. A background turn
(`resumeSession`/continuation worker) has no stream; a desynced long live turn
has a dead stream. In both cases `streaming=false` while the server is actively
working. The **session `status`** (`planning`/`executing`/`awaiting_input`) is
the reliable "server is running a turn" signal and is already live-refreshed
(`workspace.ts` `taskFor`, `STATUS_AFFECTING`), but the chat UI never uses it.
- **G2 — busy-turn message is rejected, not queued.** main.go:292-302: the turn
gate waits 5s then emits the "still finishing a previous step" error and
returns. The user message *is* persisted (main.go:270) but is **inert** — the
user must manually re-send.
- **G3 — activity is poll/event laggy.** Tool-level activity derives from
`messages`, refreshed only by the 3s poller; plan steps are **events-only**
(`workspace.ts` `hydrateSession`) with no poll, so a missed `plan.proposed`
event leaves the panel stuck on a stale generation.
- **G4 — timeline is generation-unaware.** `activity.ts` `computeActivityLog`
walks **all** messages' tool calls, so a re-proposed task renders N
"Proposed plan" entries and attributes tools to steps via `currentStepSeq`
inferred from `update_plan_step` calls across **every** generation — tools land
under the wrong (current-gen) step or under steps that were `replaced`. This is
the "several plans / some steps never run" view.
## Fixes (ordered)
### F1 — Status-driven `working` signal (fixes G1)
Add a derived store `taskWorking(sessionId)` = `$streaming OR status ∈
{planning, executing}` (explicitly **not** `awaiting_input` — that is paused for
input), plus a global `currentWorking` for the main view backed by `currentTask`.
Use it wherever `streaming` currently drives "is it working":
- `ChatThread.svelte`: `traceStatus` last-message = `working ? 'running' : …`;
`indicatorLabel` and the AgentTrace `status`/`label` props.
- `TaskContextPanel.svelte:137` spinner and `UnifiedTimeline` `streaming` prop →
`working`.
- Keep a separate `streaming` for the literal "live text deltas are arriving"
cursor; `working` is the superset for indicators/input.
- Input stays **enabled** while `working` (the user must be able to interject);
the send path queues when busy (F2). Show a muted "Nomos is working…" hint in
the composer when `working && !streaming`.
### F2 — Queue operator messages; auto-run when free (fixes G2)
- Server: in-memory per-session FIFO on the `agent` struct (mirrors `turnGate`),
`{message, reply}` entries. `handleChat`: when the gate is busy, **enqueue**
instead of rejecting, and emit a `queued` SSE event (replaces today's error at
main.go:294-302). Persist the user message as today (already done pre-acquire).
- Drain: arm a per-session drainer that, on gate release, acquires again and runs
the next queued message as a normal turn (same persist/emit path as
`handleChat`). Strictly one-at-a-time under the gate — this cannot stack turns
(the hazard v0.15.0 F1 removed); background `resumeSession` keeps its
non-blocking skip and never touches the queue.
- If the session is terminal (`done`/`failed`) or `awaiting_input` when a queued
message runs, `reopenSession`/answer handling applies as for any follow-up.
- Frontend: on the `queued` event show an inline "Queued — will run when the
current step finishes" chip on that user bubble; clear it when the turn's real
events begin. Drop the humanized "still finishing" error for the busy case.
### F3 — SSE keepalive on `handleChat` (prevents the G1 desync at the source)
Wrap `a.chat(...)` in a goroutine + `select` with a **10-15s ticker** that writes
an SSE comment (`:keepalive\n\n`) and flushes, so 20-40s inter-iteration gaps no
longer trip proxy/browser idle timeouts. Stop the ticker when `a.chat` returns.
(EventSource ignores comment lines by spec — safe.)
### F4 — Generation-aware timeline + self-healing plan panel (fixes G3/G4)
- `activity.ts` `computeActivityLog`: find the **last** `propose_plan` in the
message stream; ignore `propose_plan`/`update_plan_step` calls **before** it
for both rendering and `currentStepSeq` inference. Render at most one
"Proposed plan" entry (the current generation). Steps continue to come from
`$steps` (already current-gen via `fetchPlan` MAX(generation)). Optionally emit
a single "Plan revised" entry when >1 generation exists.
- Plan-panel resilience: on any `STATUS_AFFECTING` event (and on reconnect),
re-fetch the plan (`fetchPlan`) in addition to the live `plan.proposed` handler,
so a missed event self-heals instead of leaving a stale generation.
## Validation
- `go test ./cmd/nomos/`: extend `turngate_test.go`/new `messagequeue_test.go`
queued message runs strictly after release; FIFO order preserved across 3
queued sends; a background `resumeSession` busy-skip does **not** consume or
starve the queue; queued message runs even if session went `awaiting_input`.
- Web `vitest`: `activity.test.ts` — add a 2-generation fixture (2× propose_plan,
interleaved update_plan_step) asserting exactly one "Proposed plan" and correct
step attribution to gen-2 steps; `chat`/store test — `working` is true from
`status==='executing'` even with `streaming=false`; `queued` event renders the
queued chip and clears on first tool_use.
- Manual: (a) start a long task, **reload the window mid-turn** → the working
indicator stays on (status-driven); (b) send a message mid-turn → "Queued" →
runs after the turn; (c) open `44df8802`-style 2-gen session → timeline shows
one plan, no ghost proposals.
## Risks
- **F2 must not reintroduce concurrent turns.** The queue drains one-at-a-time
under the gate; background resume remains non-blocking and queue-agnostic.
Existing `turngate_test.go` concurrency assertion (max in-flight = 1) must stay
green.
- **Status-driven `working` could stick on** if a terminal event is missed.
Mitigated by the existing terminal `task.status``clearTurnState` recovery
plus a `loadSessions` refresh on reconnect (F4).
- **Keepalive comments** must stay SSE comments (`:` prefix) so they aren't
parsed as events.
## Out of scope / follow-ups
- Model efficiency: the 8+ pure-exploration iterations (repeated
`list_entities`/`get_relations`) that inflate turn length to 15-27 min —
prompt/iteration-budget tuning, separate effort.
- F8 from the prior plan (oldest-first timeline toggle; per-tool `tool.*` events
for background turns). F1's status-driven `working` makes background work
visible without live per-tool deltas, so this remains lower priority.
## Open implementation note
Host the per-session message queue on the `agent` struct (in-memory `map[string]
[]queuedMsg` + per-session drainer goroutine), mirroring `turnGate`. No DB table
needed — messages are already persisted by `handleChat` before enqueue; the queue
only schedules *when* a turn runs, not *whether* the message is stored.
---
## Resolution (2026-08-03)
Implemented F1F4 in v0.15.1 → v0.17.0 (the intermediate 0.16.0 was the
cyberspace-aesthetic commit, landed via auto-pull during this work).
| Item | What shipped | Where |
|---|---|---|
| **F1** | Status-driven `working` signal (`taskWorking(sessionId)` / `currentWorking`) = live stream OR session status ∈ {planning, executing}. Drives the chat trace running state, the "thinking" headline, the activity spinner, and the timeline `streaming` prop — so a background/long/desynced turn still looks alive (the "can't tell it's working" symptom). The composer stays enabled during background work so the operator can interject. | `web/src/lib/stores/workspace.ts` (`isWorking`, `taskWorking`, `currentWorking`), `ChatThread.svelte` (`working` prop, `traceStatus`, indicator), `TaskContextPanel.svelte`, `SessionChatWindow.svelte`, `NewTaskChat.svelte`. |
| **F2** | Operator messages sent during an in-flight turn are now QUEUED and auto-run when the gate frees, replacing the "still finishing a previous step… send it again" rejection. Per-session in-memory FIFO drained strictly one-at-a-time under the turn gate (no concurrent-turn reintroduction). A `queued` SSE event tells the client, which drops the optimistic bubble and shows a "Queued — will run when it finishes the current step" hint (derived from `working` + last-message shape, so it survives the poller). | `cmd/nomos/messagequeue.go` (+`messagequeue_test.go`), `agent.go` (queue field), `main.go` (`runChatTurn`, `drainQueued`, handleChat queue path), `continue.go` (resumeSession drains on release), `web/src/lib/types.ts` (`ChatQueuedEvent`), `chat.ts` (`queued` handling in sendSessionMessage/startTask). |
| **F3** | SSE keepalive: a 12s `:keepalive` comment ticker during `handleChat` so 20-40s inter-iteration gaps no longer trip a proxy/browser idle timeout (the desync root cause). All SSE writes (events + keepalive) serialized through one mutex — `http.ResponseWriter` is not concurrency-safe. | `cmd/nomos/main.go` (`writeMu`/`writeEvent`, keepalive goroutine). |
| **F4** | Generation-aware activity timeline: only the LAST `propose_plan` renders as "Proposed plan"; superseded ones collapse to a single "Earlier plan revised" marker, and step-attribution only follows the current generation's `update_plan_step` calls. Plus plan-panel self-heal: the plan is refetched (debounced) on any task-lifecycle event so a missed `plan.proposed` no longer freezes the panel on a stale generation. | `web/src/lib/stores/activity.ts` (`computeActivityLog`), `workspace.ts` (`schedulePlanRefetch`). |
**Verification:**
- `go vet ./cmd/nomos/` clean; `go test ./cmd/nomos/` green, incl. new
`messagequeue_test.go` (FIFO, requeueFront, per-session isolation, concurrency,
drainQueued no-op-on-empty, drainQueued requeues-when-busy). Existing
`turngate_test.go`/`continue_test.go` still green (single-flight guarantee
intact).
- Web `vitest` 72/72 green (added 2 F4 generation-awareness tests to
`activity.test.ts`: one "Proposed plan" + revised marker + current-gen-only
step attribution; plan-less Q&A attributes nothing).
- `vite build` succeeds. `tsc --noEmit` shows only the pre-existing baseline
errors (`ui/*`, `oidc.ts`, `windows.ts`, `workspace.ts:123/201/221`) noted in
v0.15.0 — no new errors from this change. ESLint: no new errors (the one new
`svelte/valid-compile` on `chatWorking` got the same disable its siblings have).
**Follow-ups (not in this pass):**
- Model efficiency: the long (15-27 min) exploration-heavy turns that made the
desync so painful — prompt / iteration-budget tuning, separate effort.
- F8 from the prior plan (oldest-first timeline toggle; per-tool `tool.*` events
for background turns). F1's status-driven `working` makes background work
visible without live per-tool deltas, so this stays lower priority.

View File

@@ -0,0 +1,191 @@
# 2026-08-04 — Chat interaction overhaul: inline progressive stream (Claude Code style)
**Status:** Planned — not started. (Refocused from the earlier feature-heavy
draft; backend features deferred — see "Deferred".)
## Goal
Streamline agent interactions — thinking, plan, tool usage, responses — into
**one linear progressive inline stream per turn** (the Claude Code / Cline /
Roo pattern), instead of the current split where the transcript shows a
collapsed trace and the real live activity lives in a separate rail timeline.
The right rail becomes **graph-only** (and auto-zooms to fit all entities).
## Locked decisions (operator interview)
| Decision | Choice |
|---|---|
| Live activity layout | **Inline stream (Claude Code)** — one progressive column per turn; rail keeps ONLY the Scope graph; Activity timeline tab removed |
| Tool-call detail | **Per-tool progressive lines** — each tool its own compact live line (spinner → one-line result summary), expandable to raw |
| Feature phases | **Defer** — edit/resubmit, @mentions, attachments are later phases; this plan is interaction-focused + graph auto-zoom |
## Diagnosis (grounded in current code)
- The transcript (`ChatThread``AgentTrace`) collapses a whole turn's tool
calls into one line ("Proposed plan" / "N tool calls"), raw-JSON detail on
expand. Not progressive; you can't see what's happening without expanding.
- The actual live plan + tool timeline lives in the **right rail**
(`TaskContextPanel``UnifiedTimeline`): newest-first backbone + tool stubs.
So "what is the agent doing" is in a **second place** — a cognitive split.
- `UnifiedTimeline` is imported **only** by `TaskContextPanel` (grep confirms),
so removing the Activity pane is self-contained.
- The `activityLog` **store** stays required: it feeds inline labels
(`toolActivityLabel`), live `run` output (`toolsWithLive`), and the mascot
(`mascot/stimuli.ts`). Only the timeline *view* is removed.
- Tool events already arrive separately (`tool_use` then `tool_result` in
`chat.ts`), and the activity log already carries humanized labels + per-tool
`stepSeq` attribution. So progressive per-tool lines + step grouping are a
**presentation** change, not a data/model change.
- `run` results are free-form text (e.g. `"run on lxc:caddy: ERROR exit status
1"`) → one-line result summaries are best-effort text parsing, no backend.
## Design
### D1 — One progressive inline stream per turn
Replace `AgentTrace` (one collapsed blob per turn) with a new
**`TurnTrace.svelte`** rendered inline for each assistant turn, top-to-bottom:
1. **Live plan checklist** (only on the most-recent/running turn — see D3).
2. **Tool lines grouped by plan step** (D2), then orphan tools (no step).
3. **Streamed text answer** (existing `markdown-body prose-chat`), with the
blinking cursor while streaming (existing).
4. A compact **"Thinking" line** while `working` and before any output: reuses
the existing `indicatorLabel` (running step → tool → "Agent is thinking…").
Fades once text/tools arrive; reappears between steps.
### D2 — Per-tool progressive lines (the Claude-Code signature)
One `ToolLine.svelte` per tool call (replaces `ToolCallCard`'s row style):
- Left: state icon — spinner while `tool_use`-only, ✓ on result, ✗ on error.
- Label: existing `toolActivityLabel(tool)` (humanized action).
- **One-line result summary** on completion — new `toolResultSummary(tool)`
in `activity.ts` (see plumbing). E.g.:
- `run` → `exit 0 · <first line>` (parse "exit status N" / "ERROR")
- `get_entity` → `host:hubris (healthy)`; `get_health_summary` → `healthy X · degraded Y · down Z`
- `list_entities`/`list_lxcs` → `N entities`; `get_relations` → `N relations`
- `search_knowledge` → `N results`; `upsert_knowledge` → `recorded document:…`
- `update_plan_step` → `step <seq> → <status>`; `propose_plan` → `N steps`
- default → first non-empty line of stringified result (≤80ch); `done` if empty
- Live `run` output: while streaming, the line auto-expands a pinned-tail mini
pane (reuse the `liveOutput` path from `toolsWithLive`).
- Click → expand raw args/result (border-driven `<pre>`, cyberspace-square).
- Border-driven, no rounded/shadow (per `border_driven_language`).
### D3 — Live plan checklist (TodoWrite-style)
On the **running/last** turn, render the current-generation `planSteps`
(already generation-aware via `workspace.ts`) as a checklist: pending = hollow,
running = spinner + highlight, done = ✓, failed = ✗, blocked = pause. Steps
check off live as `plan.step.*` events land. This is the unified timeline's
plan view, moved inline and scoped to the active turn. Past turns render only
their tool lines + text (the plan is session-level; the running turn carries
its current state, mirroring how TodoWrite re-displays state each turn). On a
terminal task state (`done`/`failed`), the checklist collapses to one line:
`Plan complete — N steps` / `Plan failed — step K`.
### D4 — Rail → graph only
`TaskContextPanel`: remove the Activity pane and the `UnifiedTimeline` import;
the panel becomes the Scope graph full-height (keep the collapsible "Scope"
header + the `nowTouching` strip). The graph is now the rail's entire job, so
auto-fit (D6) matters more. `activityLog*` stores remain imported only where
the inline stream/mascot need them.
### D5 — Cyberspace cohesion of the stream
Apply alongside the rewrite so the new inline view is on-system from day one:
- Transcript → **terminal log rows** (square, full-width, `YOU`/`NOMOS`
role-tags, hairline `divide-y` separators; no bubbles, no soft shadow).
Delete `.user-msg { box-shadow }`.
- Tool lines + expanded `<pre>`: border-driven, square, opaque.
- Composer: opaque `bg-background`, square (remove `rounded-2xl`/`bg-card/50`).
- Rewrite the stale "Art Nouveau" `<style>` comments → "cyberspace/terminal".
- Per `central_css_override`: drive surface styling centrally in `app.css`
where it's a primitive concern; no ad-hoc `rounded-*`/`shadow-*`/`backdrop-blur`.
### D6 — Graph auto-fit + drag-pan (`SessionGraph.svelte`) (carried over)
- Wrap nodes+links in `<g transform="translate(tx,ty) scale(s)">`; fit the bbox
of all nodes (radius + label + padding) into `cw`/`ch`; cap `s ∈ [0.2, 2.5]`.
- Re-fit on: mount, node-set change, container resize, sim-settle
(`alpha > 0.05`), background double-click. **Not** every tick (fights pan).
A `userPanned` flag pauses auto-follow after a manual pan until next
membership/resize/double-click.
- Background drag = pan (`tx`/`ty`); node drag converts screen→graph via the
inverse transform before setting `fx`/`fy`. Dot-grid stays in screen space.
- Keep: open-on-click, `touched` pulse, health-diff label, selection ring.
Respect `scrollIntoView` pitfall (transform, not scroll).
## Phased task list (each independently shippable; all frontend)
- **P1 — Inline progressive stream.** `TurnTrace.svelte` + `ToolLine.svelte`;
wire into `ChatThread` per turn; "Thinking" line; tool→step grouping via
activity-log `stepSeq` matched by tool id; keep `toolsWithLive` for `run`.
- **P2 — Live plan checklist.** Inline current-gen `planSteps` on the running
turn; collapse-to-summary at terminal state.
- **P3 — Rail → graph only.** Strip Activity pane + `UnifiedTimeline` from
`TaskContextPanel`; verify no other importers (grep: only TaskContextPanel).
- **P4 — Cyberspace cohesion.** Terminal log rows; remove rounded/shadow/
translucency; square composer; centralize in `app.css`; fix stale comments.
- **P5 — Graph auto-fit + drag-pan.** D6.
- **Polish (small, frontend-only):** per-message/tool **copy**; **scroll-to-
bottom** button (uses `container.scrollTo`, never `scrollIntoView`).
## Plumbing specifics (grounded, no backend)
- New `toolResultSummary(t: ToolCallResult): string` in `activity.ts`, beside
`toolActivityLabel`. Per-name switch (D2 list), graceful fallback.
- Tool→step grouping: build `id → stepSeq` from the activity log once per turn;
tools with no step render as orphans.
- Reuse: `planSteps` (generation-aware), `indicatorLabel`, `toolsWithLive`,
`toolActivityLabel`, `liveOutput` streaming path.
## Constraints honored (saved decisions)
- `design_system.central_css_override`, `border_driven_language`: square,
hairline, opaque, focus-by-color, no soft shadows/glows.
- `chat_thread.pane_layout`: dynamic status (Thinking line, live checklist)
lives in the **message Pane**, never the input Pane.
- `wmkit.scrollintoview_reflow_pitfall`: `container.scrollTo` for scroll-to-
bottom; transform (not scroll) for graph pan.
## Risks
- **Removing the rail timeline loses the "overview" view.** Mitigation: the
inline checklist + per-turn tool lines carry the same info progressively; the
graph still shows fleet scope. If operators miss the overview, a collapsed
"full timeline" can return as a toggle (follow-up).
- **Auto-fit vs manual pan** — handled by `userPanned` + settle-alpha gate.
- **Inline stream length on long turns** (1527 min, many tools) — progressive
lines can get long; mitigate by auto-collapsing finished steps (keep the
running step + its tools expanded, prior steps as one-line summaries).
- **Best-effort result summaries** may misformat unusual payloads — fallback is
always a truncated raw line + expandable raw detail, never a blank.
## Validation
- `npm run lint`, `tsc --noEmit` (no NEW errors beyond the known baseline in
`ui/*`, `oidc.ts`, `windows.ts`, `workspace.ts`), `vite build`, `vitest`
(add a `toolResultSummary` unit test per tool name + fallback).
- Manual matrix: (a) start a long task → Thinking line → plan checklist
appears and checks off live → each tool streams as its own line with a
one-line summary → text streams; (b) reload mid-turn → working still shows;
(c) `run` tool → live output pins to tail then collapses to summary; (d)
graph auto-fits at settle + on new entity + drag-pan + double-click reset;
(e) no rounded/soft-shadow remains on chat surfaces; (f) rail shows graph only.
## Deferred (later phases, after this lands + validates)
- **Edit-and-resubmit** — `truncateFrom` store method + `POST /sessions/{id}/edit`
(extract `streamTurn` from `handleChat`); reuse `reopenSession` (already
exists, store.go:838 — marks prior `session_plan_steps` `replaced`, clears
outcome) for the reset. Reject edit while the gate is busy (HTTP 409); edit
cannot queue (truncation must be atomic). Regenerate = no-op-edit case.
- **@entity mentions** — small `GET /api/v1/entities/search?q=` + composer
autocomplete inserting `type:name` slugs the agent/graph already parse.
- **Attachments** — multipart upload + `agent_attachments` table + configured
`OIKOS_ATTACHMENTS_DIR` (explicit volume, not relative) + capped text inlining.
- **Continue button** — needs `/resume` to `reopenSession` first for terminal
sessions (today `/resume` does not reopen `done`/`failed`; `handleChat`'s
follow-up path does). Small backend tweak.
- **Image vision** pending provider confirmation.
## Out of scope / follow-ups
- A collapsible "full timeline" overview toggle if the rail removal is missed.
- `read_attachment` MCP tool (lazy full-content fetch, lower context than inlining).
- Oldest-first timeline toggle / per-tool `tool.*` events for background turns.

View File

@@ -0,0 +1,145 @@
# 2026-08-04 — Hermes MCP client integration: native tool surface for oikos
**Status:** Plan.
**Context:** Hermes Agent (mac-mini workstation) now connects to oikos's MCP server
as a native MCP client (`mcp_servers.oikos` in `~/.hermes/config.yaml`). All 37+ MCP
tools are available as `mcp__oikos__*` first-class Hermes tool calls — no more raw
curl with batch-initialize SSE parsing. The integration works; this plan tightens the
remaining seams.
**Trigger:** First-use retrospective identified three areas that make the integration
harder to use than it should be.
---
## 1. Motivation
The oikos MCP server (`internal/mcp/`) speaks Streamable HTTP at
`https://mcp.hubris.network/mcp`. Hermes Agent's native MCP client connects to it on
startup, discovers tools, and registers them as callable functions. This replaces the
previous pattern where agents fired raw curl requests with batch `initialize` +
`tools/call` envelopes.
Three friction points observed:
- **No lightweight connectivity check.** The `/healthz` HTTP endpoint exists but isn't
exposed at the MCP protocol layer. An agent that wants to verify the MCP server is
reachable must call a real tool (e.g. `list_entities` with a limit of 1) — every call
carries the Streamable HTTP session-initialization overhead.
- **Bearer token in plaintext.** `~/.hermes/config.yaml` stores the token directly in the
`mcp_servers.oikos.headers.Authorization` value. Hermes does not support env-var
interpolation in MCP server configs, so the token can't live only in `.env`.
- **Zero-visibility streaming overhead.** Streamable HTTP batches `initialize` +
`tools/call` per request. This adds ~2KB of transport per tool call that the agent
never sees. For a single `get_health_summary` call this is negligible; for a 10-tool
exploration pass it's 20KB of invisible overhead.
---
## 2. Changes
### I — MCP health/ping tool (`mcp__oikos__ping`)
**Why:** Agents need a zero-cost connectivity check before calling production tools.
Currently every check incurs the full Streamable HTTP initialize + tools/call round-trip.
**What:**
Add a `ping` tool that returns `{"ok": true, "server": "oikos", "version": "dev"}`.
No arguments. No DB hit. No auth check (already protected by the MCP transport's auth
layer — the request won't arrive if the bearer token is missing).
```go
// internal/mcp/tools.go
{
Name: "ping",
Description: "Lightweight connectivity check. Returns immediately with server identity, no DB hit.",
InputSchema: jsonschema.Must(nil), // no params
Handler: func(ctx context.Context, args json.RawMessage, caller CallerInfo) (json.RawMessage, error) {
return json.RawMessage(`{"ok":true,"server":"oikos","version":"` + version.Version + `"}`), nil
},
}
```
**Risk class:** read-only. No auth, no DB, no state. Auto-approves.
**Test:** `hermes mcp test oikos` (from the Hermes CLI) verifies MCP server reachability
independently; the `ping` tool gives agent code the same signal programmatically.
### II — Tool name documentation in server metadata
**Why:** Hermes prefixes MCP tools as `mcp_{server}_{tool}`, so `get_health_summary`
becomes `mcp__oikos__get_health_summary`. Agents discover tool names at runtime via
`tools/list`, but there's no short summary of what each tool group does that survives
into the MCP tool description.
**What:**
Audit and tighten every tool's `Description` field in `internal/mcp/tools.go` so the
first 812 words are a searchable one-liner an agent can pattern-match against.
Current descriptions that are vague or redundant get a prefix rewrite:
| Tool | Current description | Revised |
|------|-------------------|---------|
| `get_entity` | "Get entity metadata" | "Look up one entity by slug or UUID — type, state, attributes, health" |
| `list_entities` | "List entities" | "Browse entities by type, state, or name substring — paginated" |
| `upsert_knowledge` | "Record what you learned" | "Write a document/investigation/runbook to the knowledge graph — idempotent" |
| `run` | "Run ANY shell command" | "Execute a shell command on any host/LXC/VM — auto-classified by risk" |
Existing tools pass through unchanged if their description is already crisp. ~15 tools
get description rewrites.
**Risk class:** read-only (config change). No runtime effect.
### III — Env-var interpolation docs for Hermes config (oikos-side documentation)
**Why:** The bearer token lives in `~/.hermes/config.yaml` in plaintext because Hermes
does not support `${VAR}` interpolation in MCP server configs. This is a Hermes
upstream feature request, not an oikos change — but oikos should document the
workaround and track the upstream ask.
**What:**
Add a `### Hermes MCP client` subsection to `docs/infrastructure/mcp-server.md` (or
create it if it doesn't exist) that covers:
1. The config block to add to `~/.hermes/config.yaml` (already done — record it
for the next person).
2. The token exposure caveat: Hermes doesn't support env-var interpolation in
`mcp_servers` `headers` yet (upstream issue nousresearch/hermes-agent#TODO — file
once).
3. Workaround: `hermes config set security.redact_secrets true` (already default) so
the token value is stripped from tool output and logs even if it appears in
diagnostic text.
4. How to verify the connection: `hermes mcp list``hermes mcp test oikos`.
**Risk class:** docs-only.
---
## 3. Open questions
| Question | Decision |
|----------|----------|
| Should `ping` bypass auth entirely or still require a valid bearer token? | **Still requires auth.** The MCP transport layer validates the token before routing to `ping` — no special treatment needed. If the token is missing, the request never reaches the handler. |
| Who files the Hermes upstream feature request for `${VAR}` interpolation? | **Oikos operator** (dtoro). The need is specific to this deployment. File at https://github.com/NousResearch/hermes-agent/issues. |
---
## 4. Not doing (yet)
- **Persistent MCP sessions** — Streamable HTTP stateless mode is fine for the
current tool-call volume (~15 calls per agent turn). Persistent sessions would
save ~2KB per call but add connection lifecycle complexity. Revisit if per-turn
tool calls exceed 20.
- **`tools/list` caching** — Hermes already caches tool discovery at session start.
The 37-tool list is ~4KB; caching adds complexity for negligible savings.
---
## 5. Verification
1. `curl -X POST https://mcp.hubris.network/mcp ... -d '...ping...'` returns `{"ok":true,"server":"oikos","version":"dev"}`
2. `hermes mcp list` shows `ping` among oikos tools
3. `hermes doctor` passes
4. Tool descriptions are crisp: `hermes mcp list` output for oikos shows prefixed summaries

View File

@@ -0,0 +1,369 @@
# 2026-08-04 — Session audit: agent reliability, plan system, and learning loop gaps
**Status:** Done — all 12 tasks implemented, tested, deployed in v0.21.0. Verified in production
with live session tests (classifications, feedback, token tracking, execution linkage all confirmed).
**Reviewed sessions:** Past 5 completed plus ZimaOS continuation (268895a5)
**Method:** Direct Postgres read of `agent_sessions`/`agent_messages`/
`agent_activity`/`session_plan_steps`/`executions`/`classifications`/`feedback`/
`patterns`/`skills`/`approvals`/`nomos_plan_executions`/`audit_log` on the prod
mac-mini. Cross-referenced with `internal/audit/`, `internal/mcp/`,
`internal/httpapi/`, `internal/policy/`.
---
## 1. Sessions audited
| # | Session | Turns | Run calls | Failures | Plan steps (done/total) | Duration | Outcome |
|---|---------|-------|-----------|----------|-------------------------|----------|---------|
| S1 | Pocket-pascal deploy (a433b386) | 8 | 111 | 1 | 10/11 | 1.5h | Success (truncated by turn limit) |
| S2 | SSH re-investigation (6f0ade08) | 4 | 42 | 0 | 0/4 | 11m | Success (all steps replaced) |
| S3 | Webhook HMAC (0f509508) | 2 | 40 | 0 | 5/5 | 2m | Success (clean; best session) |
| S4 | Check scripts (d458a5f8) | 23 | 96 | 4 | 0/14 | 2h | Success (3 plan gens; 0 steps done) |
| S5 | ZimaOS outage (268895a5) | 18+10 | 209 | 8 | 0/5 | 2h30m | **Marked success; dashboard still broken** |
**Headline:** 85% session success rate, but plan adherence is ~38% (15/39 steps
ever reached `done`). The ZimaOS session was the worst: marked `success` while
the dashboard was still down, then burned 81 more calls and 30 minutes chasing
irrelevant DHCP reservations.
---
## 2. Cross-cutting findings
### F1 — MCP 30s client timeout kills every long-running command (P0)
All **9** `run` failures across the audit are `Post "http://api:8090/mcp":
context deadline exceeded` at exactly 30s. Commands with `sleep 30`, `qm
shutdown`+wait, or async poll loops always hit this. The agent retries with
longer sleeps and hits the same wall.
**Root cause:** `cmd/nomos` MCP client uses a 30s timeout; the `run` tool
blocks synchronously waiting for command completion. No async path exists for
long-running commands.
### F2 — Premature success: `complete_task` fires before the goal is actually met (P0)
Session 268895a5 called `complete_task(success)` at 18:29 with summary "zimaos
returns 200." The endpoint was serving ttyd (terminal), not the ZimaOS
dashboard. The agent conflated "HTTPS 200" with "dashboard works." The user had
to resume the session.
**Root cause:** No pre-completion validation. The agent can mark success with a
summary that doesn't match reality. `complete_task` is a write-and-forget
operation with no state check.
### F3 — Wrong target: `run` doesn't validate the target can execute the command (P0)
In the ZimaOS continuation, `qm stop 100 --skiplock` was executed on
`lxc:dns`. The command failed (`qm: command not found`) because the agent
copied the command from a previous run but forgot to change the target.
Similarly, `cmd/nomos` tried `docker exec` on `host:hubris` (no docker).
**Root cause:** `run(target, command)` in `internal/mcp/server.go` doesn't
validate that the target type can execute the given command. A simple
allowlist would catch `qm`/`pct`/`pvesh` on non-host targets.
### F4 — Plan system is decorative: 62% of steps never reach `done` (P1)
Across 5 sessions: 39 plan steps. 24 (62%) were `replaced`, 15 (38%) reached
`done`. Session d458a5f8 had 3 complete plan regenerations with **zero**
completed steps. The agent replaces plans instead of completing or explicitly
skipping steps.
**Root cause:** Plan steps carry status (`pending`/`running`/`done`/`failed`/
`skipped`/`blocked`/`replaced`) but `replaced` has no `replaced_reason`
field. The model can silently replace every step and the system doesn't flag
it. No constraint ties `propose_plan` to existing plan state.
### F5 — No plan on session resume: continuation sessions run ad-hoc (P1)
The ZimaOS continuation (18:29→19:01) had 81 calls with **zero**
`propose_plan` calls. The agent ran ad-hoc tool calls with no structure.
**Root cause:** When a session resumes, the `must_have_plan` guard is already
satisfied by the old (completed) plan. The agent doesn't re-plan on resume.
### F6 — Scope expansion / rabbit holes: agent chases irrelevant sub-goals (P2)
In the ZimaOS continuation, the agent spent ~40 calls trying to fix Technitium
DHCP reservations — a completely different subsystem from the goal ("make the
dashboard reachable"). The ZimaOS dashboard hadn't started since July 19
(3-week-old issue), making the DHCP reservation effort moot. The agent never
surfaced a question like "This is pre-existing — should I still fix DHCP?"
**Root cause:** No scope gate. When the agent pivots to a subsystem unrelated
to the stated goal, nothing stops it. The `session_questions` mechanism exists
(2 calls in 193 sessions) but the model never uses it.
### F7 — Command generation errors: malformed bash from the LLM (P2)
In the ZimaOS continuation, the agent generated:
- `head - n` instead of `head -n` → bash syntax error
- `echo "---" && \n curl ...` → literal `\n` in command → ambiguous redirect
**Root cause:** The LLM generates bash commands inline in content blocks. No
syntax validation, no escape-character handling. The `run` tool should reject
malformed commands before execution.
### F8 — Learning pipeline completely empty (P4)
| Table | Rows |
|-------|------|
| `classifications` | **0** |
| `feedback` | **0** |
| `patterns` | **0** |
| `skills` | **0** |
Despite 1,884 executions and 263 approvals, the system learns nothing from
outcomes. The ZimaOS session discovered that the dashboard hadn't started since
July 19 — this was never persisted. The HMAC trailing-newline discovery was
persisted manually; if the agent forgot `upsert_knowledge`, it would be lost.
### F9 — Observability gaps (P3)
| Gap | Detail |
|-----|--------|
| Token tracking | `agent_activity.token_count` is NULL for every row |
| Execution linkage | `nomos_plan_executions` is empty; `audit_log.session_id` is null |
| Plan quality | No metric for step completion rate (currently 38%) |
| MCP timeout rate | No counter for `run` calls that hit the client timeout |
### F10 — Execution success rate is 74% (P2)
1,884 executions: 1,400 completed (74%), 295 failed (15.6%), 152 cancelled
(8%), 37 denied (2%). One in four execution attempts doesn't complete.
---
## 3. Improvement plan (ordered by impact/effort)
### Task 1 — Prevent premature `complete_task(success)` *(fixes F2)*
**`internal/mcp/tools.go`** — In the `complete_task` handler, when
`outcome=success`: require the summary field to contain a verifiable state
assertion. Minimum: if the goal mentions a URL, check that the summary doesn't
contradict known state. Lightweight: log a warning if the summary says "returns
200" but the last `ping_service` or `run` result says otherwise.
**Coach:** `nomos/SOUL.md` — explicit rule: *"Before calling
complete_task(success), restate the user's original goal in your own words and
verify each condition. If any condition is 'probably works' rather than
'verified,' ask the operator or set outcome=partial."*
### Task 2 — Target validation in `run` *(fixes F3)*
**`internal/mcp/server.go`** — In the `run` handler, before dispatching:
validate that the command prefix matches the target type.
```
pct/qm/pvesh/iptables → only host:* targets
systemctl/docker → host:* or lxc:* targets
curl/nmap/ss → any target
```
If mismatched, return a clear error: *"Cannot run `qm` on lxc:dns — `qm` is a
Proxmox host command. Use target host:hubris or host:strong."* Do not classify
or execute.
**Test:** `TestClassifyCommand_WrongTarget` → commands with host-only prefixes
on LXC targets return error without execution.
### Task 3 — Raise MCP client timeout; add async path for long-running commands *(fixes F1)*
**`cmd/nomos`** — Raise the MCP client timeout from 30s to 120s.
**`internal/mcp/server.go`** — For `run` commands that the classifier
determines will exceed the client timeout (presence of `sleep`, `wait`,
`timeout` in the command), return immediately with an `execution_id` and status
`running`. The agent already has `get_execution_status` — use it:
1. Classify the command; if it contains `sleep`, `wait`, or shell constructs
that imply polling, flag it as `async_potential`.
2. Start the command, return the `execution_id` immediately.
3. Agent polls with `get_execution_status(execution_id)`.
4. If the client timeout is hit mid-poll, the execution continues on the server
— it's not lost.
**Test:** `run` with `sleep 60; echo done` on host:hubris → returns
immediately (not 30s timeout), `get_execution_status` eventually returns
`completed`.
### Task 4 — Plan step integrity: require `replaced_reason` on replacement *(fixes F4)*
**`session_plan_steps` migration** — Add `replaced_reason TEXT` column.
**`cmd/nomos`** — When the agent emits `update_plan_step` with
`status=replaced`, require a non-empty `replaced_reason`. Valid reasons:
`wrong_diagnosis`, `scope_change`, `blocked`, `superseded`, `operator_override`.
**Coach:** `nomos/SOUL.md` — explicit rule: *"Complete (status=done) or
explicitly skip (status=skipped) steps. Use status=replaced only when the
entire plan generation is wrong; include the reason. Replacing all steps with
no reason is a session-quality violation."*
### Task 5 — Force `propose_plan` on session resume *(fixes F5)*
**`cmd/nomos`** — When a session with status `done` or `failed` receives a new
user message, reset the plan state: clear step status, require a new
`propose_plan` call before any `run` calls. The "must have plan" guard should
consider the resumed session as plan-less until a fresh `propose_plan` is
called.
**Guard:** `set_goal` + `propose_plan` must be called before any `run` in a
resumed session. Reuse the existing "No plan — call set_goal then propose_plan"
error from `internal/mcp/server.go`.
### Task 6 — Scope gate: surface `session_questions` on context switch *(fixes F6)*
**Coach:** `nomos/SOUL.md` — explicit rule: *"Before pivoting to a subsystem
not mentioned in the user's goal, ask via session_questions. Example: 'The
dashboard logs show it hasn't started since July 19. Do you want me to debug
the dashboard service itself [A], skip it and just stabilize the IP [B], or
stop here [C]?'"*
**`cmd/nomos` prompt** — Add to the system prompt: *"When the investigation
leads to a subsystem or root cause unrelated to the expressed goal, surface a
session_question before taking action."*
### Task 7 — Auto-upsert knowledge on session close *(fixes F8)*
**`cmd/nomos`** — On `complete_task` (any outcome: success, partial, failure),
auto-generate a knowledge entry:
```
title: "<date>: <goal summary>"
content: "## Outcome\n<outcome>\n## Root cause\n<extracted>\n## What was done\n<summary>\n## What was left\n<unresolved>"
tags: [session:<id>]
about: [entities involved]
```
This ensures every session leaves a trace regardless of whether the agent
remembered to call `upsert_knowledge`.
### Task 8 — Token tracking *(fixes F9)*
**`cmd/nomos`** — After each LLM call, extract `usage.prompt_tokens`,
`usage.completion_tokens`, `usage.total_tokens` from the response and write to
`agent_activity.token_count`. Currently the field exists but is never populated
(NULL for all rows).
### Task 9 — Execution linkage *(fixes F9)*
**`internal/mcp/server.go`** — When `run` creates an execution, write a row
into `nomos_plan_executions` linking `session_id`, `plan_step_seq`, and
`execution_id`.
**`internal/mcp/server.go`** — Pass `session_id` (from MCP request headers)
into `audit_log` writes. Currently `audit_log.session_id` is NULL — the
`createAuditLog` function in `internal/httpapi/impl.go` receives the
correlation_id but not the session_id from the MCP path.
### Task 10 — Plan quality metric *(fixes F9)*
**`cmd/nomos`** — At session close, compute: `completed_steps /
total_steps_per_plan` (currently ~38%). Log as a metric or write as a session
attribute. Track over time to measure plan-adherence improvements from Tasks
4+5.
### Task 11 — Auto-classify every `run` call *(fixes F8)*
**`internal/mcp/server.go`** — The `run` handler already calls the classifier
(`classifyCommand` in `internal/policy/command.go`) to determine risk_class and
approval route. Write the result to the `classifications` table. Currently the
table is empty (0 rows) despite 1,884 executions being classified.
### Task 12 — Auto-feedback on session close *(fixes F8)*
**`cmd/nomos`** — On `complete_task`, generate a `feedback` entry:
```
session_id: <id>
outcome: <outcome>
observation: <summary>
lesson: <extracted from complete_task.summary>
side_effects: <entities created/modified during session>
```
**`cmd/oikos`** — Add a daily cron or scheduler job that reads recent
`feedback` entries and extracts `patterns` (recurring root causes, same-fix
applied multiple times, known-broken services). Seed the pattern table.
### Task 13 — Command syntax validation in `run` *(fixes F7)*
**`internal/mcp/server.go`** — Before executing a `run` command, do
lightweight bash syntax validation:
```
- Reject literal \n in commands (should be ; or &&)
- Reject commands where the last line ends with \ (backslash-continuation)
but no next line
- Warn on common typos: "head - n", "grep - i", spaces before flags
- Reject `&& \n` patterns (the LLM sometimes inserts literal \n between && chains)
```
### Task 14 — Stuck-session reaping (from prior plan; re-confirmed)
This session exhibited the same idle zombie pattern (23da10db — no closed_at,
status `failed` but outcome `failure`). Task 5 from the 2026-08-03 plan is
still open. Copying here for completeness.
---
## 4. Recommended sequence
```
P0 (blocks operational waste):
1 → 2 → 3
P1 (fixes plan architecture):
4 → 5
P2 (cognitive guardrails):
6 → 7 → 13
P3 (observability):
8 → 9 → 10
P4 (learning loop):
11 → 12
```
Sequence rationale: Tasks 1-3 stop the worst outcomes (premature success,
wrong-target execution, MCP timeouts). Tasks 4-5 make the plan system actually
useful instead of decorative. Tasks 6-7 add guardrails that prevent the ZimaOS
rabbit-hole class of failure. Tasks 8-10 give us visibility into whether any of
the previous tasks are working. Tasks 11-12 close the learning loop.
---
## 5. Validation
| Task | Test |
|------|------|
| 1 | Session with goal "make X reachable" where last ping shows 502 → `complete_task(success)` is rejected or warns |
| 2 | `run("lxc:dns", "qm stop 100")` → error: "qm is a Proxmox host command" |
| 3 | `run` with `sleep 45; echo done` → returns execution_id immediately, `get_execution_status` shows final result |
| 4 | `update_plan_step(status=replaced)` with no reason → rejected; with reason → accepted |
| 5 | Resumed session calls `run` before `propose_plan` → blocked: "No plan — call propose_plan" |
| 6 | Agent pivots to unrelated subsystem → `session_questions` is called before action |
| 7 | `complete_task` → knowledge entry created automatically with session link |
| 8 | `agent_activity.token_count` is non-NULL after any LLM call |
| 9 | `nomos_plan_executions` has rows linking session + step + execution |
| 10 | Session close writes `plan_adherence` attribute (step-completion %) |
| 11 | `classifications` table has 1 row per `run` call with risk_class + route |
| 12 | `complete_task` → auto `feedback` entry; daily pattern job finds recurring issues |
| 13 | `run` with `head - n /etc/hosts` → rejected with clear error about malformed command |
---
## 6. Out of scope / open questions
- Whether to raise `auto_act` from `off` for `reversible_low` actions (separate
policy decision; would reduce approval pileup without code changes).
- Whether to add a `delete_entity` MCP tool for lifecycle management (separate
from this reliability plan).
- The exact TTL for stuck-session reaping (30 min recommended, confirmed in
2026-08-03 plan).
- Whether `run` async mode should be opt-in (command contains sleep/wait) or
universal (every run returns immediately, agent always polls). Recommend
opt-in for now — most commands complete in <5s.

View File

@@ -0,0 +1,304 @@
# 2026-08-04 — Unified plan: external MCP agents + Nomos reliability
**Status:** Complete. All 5 stages implemented (2026-08-04).
---
## 1. Context
Two audiences, two gaps:
| Audience | Current state | Goal |
|---|---|---|
| **Nomos** (internal agent) | 85% session success, but 38% plan adherence, 9 timeout failures, premature success, empty learning table | Reliable, self-correcting, leaves a trace |
| **External agents** (Claude, Goose, etc.) | 40 MCP tools — heavy on observe, light on act. Can't manage signals, checks, executions, or knowledge beyond `upsert`. | Full Oikos surface: observe + act + curate |
The two plans share infrastructure (`internal/mcp/server.go`) and have cross-task
dependencies. This document merges them into one sequenced plan.
---
## 2. Cross-plan dependencies
Three audit tasks are **prerequisites** for external-agent mutation tools:
| Audit task | Enables | Why |
|---|---|---|
| **Task 3** — async `run` + timeout | `cancel_execution`, `list_executions` with live status | Without async `run`, every command >30s hits a client timeout. The agent loses track of the execution and can't cancel it. |
| **Task 9** — execution linkage | `list_executions` filtered by session/entity | `nomos_plan_executions` table is empty; `audit_log.session_id` is NULL. Without linkage, execution queries are blind. |
| **Task 2** — target validation in `run` | All Phase 2 mutation tools | Can't let external agents mutate state on targets that can't execute the command (audit found `qm` run on `lxc:dns`). |
Two items were **dropped** from the original MCP expansion:
- **`approve_execution` / `deny_execution`** — separation-of-duties violation. An
MCP agent approving its own queued commands breaks the approval model. The
correct fix is raising `auto_act` for `reversible_low` (policy change, zero
code — listed in the audit plan's out-of-scope). The existing assent-window
path in `run()` already auto-approves reversible-low commands.
- **Execution log streaming** — MCP has no push model. `get_execution_status`
returns the latest output; full streaming stays WebUI-only.
---
## 3. Unified sequence (5 stages, 31 tasks)
### Stage 1: Foundation — shared infrastructure (3 tasks)
These unlock everything downstream. Do first.
**T1 — Target validation in `run`** *(audit Task 2)*
`internal/mcp/server.go`: before dispatching a `run` command, validate prefix
against target type. `qm`/`pct`/`pvesh``host:*` only. `systemctl`/`docker`
`host:*` or `lxc:*`. Mismatch returns error without execution.
**T2 — Async `run` + 120s timeout** *(audit Task 3)*
`cmd/nomos`: raise MCP client timeout 30s → 120s.
`internal/mcp/server.go`: when classifier detects `sleep`/`wait`/poll loops in
the command, start execution and return `execution_id` immediately. Agent polls
with `get_execution_status`. Execution continues server-side even if client
timeout.
**T3 — Execution linkage** *(audit Task 9)*
`internal/mcp/server.go`: when `run` creates an execution, write into
`nomos_plan_executions` (session_id + plan_step_seq + execution_id). Pass
`session_id` from MCP request headers into `audit_log` writes.
---
### Stage 2: External agent observe (8 tasks)
All read-only MCP tools. Zero risk, ship fast. Unblocks external agents from
understanding system state.
**T4 — `get_dashboard_summary`**
Fleet health counts, signals by severity, pending approvals, event rate. One
call instead of 4. Wraps existing `GetDashboardSummary` DB query.
**T5 — `get_ontology`**
Entity types, relationship types, lifecycle states, monitoring specs. Agents
need this to reason about the schema.
**T6 — `list_checks`**
Per-entity health checks with verdict, last run, probe output. Filter by entity
slug or check state.
**T7 — `list_executions`**
Cursor-paginated execution history. Filter by entity slug, status, risk class.
Depends on T3 (execution linkage) for session/entity filtering.
**T8 — `get_knowledge_revisions`**
Version history for a knowledge entity. Agent can see what changed and when.
**T9 — `get_knowledge_duplicates`**
Near-duplicate knowledge entries via trigram clustering. Wraps existing
`knowledgeDuplicates` query.
**T10 — `get_knowledge_orphans`**
Knowledge entries not linked to any entity. Agent can suggest cleanup.
**T11 — `list_knowledge_tags`**
All tags with counts. Agent can see the taxonomy.
**T12 — `list_entity_sessions`** *(Phase 3 polish)*
Active Nomos sessions linked to an entity. Depends on T3 (execution linkage).
**T13 — `find_entities_by`** *(Phase 3 polish)*
Search entities by attribute (IP, port, version, tag). More flexible than
`list_entities` (type/state only).
**T14 — MCP resources**
Expose entities, knowledge entries, and executions as MCP resource templates:
`oikos://entity/{slug}`, `oikos://knowledge/{id}`, `oikos://execution/{id}`.
MCP clients that support resources (Claude Desktop, Goose) can browse and attach
them to conversations.
---
### Stage 3: Nomos reliability (6 tasks)
Fixes the worst failure modes found in the 5-session audit.
**T15 — Prevent premature `complete_task(success)`** *(audit Task 1)*
`internal/mcp/tools.go`: when `complete_task` with `outcome=success`, verify the
summary doesn't contradict known state. If goal mentions a URL but the last
probe shows non-200, log a warning.
`nomos/SOUL.md`: explicit rule — restate goal, verify every condition before
calling success. If any condition is "probably works," use `outcome=partial`.
**T16 — Plan step integrity: require `replaced_reason`** *(audit Task 4)*
Schema: add `replaced_reason TEXT` to `session_plan_steps`.
`cmd/nomos`: when agent emits `update_plan_step(status=replaced)`, require
non-empty reason (enum: `wrong_diagnosis`, `scope_change`, `blocked`,
`superseded`, `operator_override`).
`nomos/SOUL.md`: explicit rule — complete or skip steps. Replacing all steps
with no reason is a session-quality violation.
**T17 — Force `propose_plan` on session resume** *(audit Task 5)*
`cmd/nomos`: when a session with status `done`/`failed` receives a new user
message, reset plan state. Require fresh `propose_plan` before any `run`.
The "must have plan" guard treats resumed sessions as plan-less.
**T18 — Scope gate: surface `session_questions` on context switch** *(audit Task 6)*
`cmd/nomos` system prompt: "When investigation leads to a subsystem unrelated to
the expressed goal, call `session_questions` before taking action."
`nomos/SOUL.md`: explicit rule — ask before pivoting.
**T19 — Command syntax validation in `run`** *(audit Task 13)*
`internal/mcp/server.go`: before executing, reject literal `\n` in commands,
backslash-continuation on last line, `head - n`/`grep - i` space-before-flag
typos, and `&& \n` patterns from LLM formatting errors.
**T20 — Stuck-session reaping** *(audit Task 14, from 2026-08-03 plan)*
Reap sessions with `closed_at IS NULL` and no message in 30 minutes. Set
status=failed, outcome=failure.
---
### Stage 4: External agent act (9 tasks)
Mutation MCP tools. Each writes audit log + emits event. Requires Stage 1
infrastructure (T1 target validation, T2 async run, T3 execution linkage).
Follows existing direct-DB patterns — no HTTP API calls.
**T21 — `ack_signal(signal_id)`**
Acknowledge an open signal. Agent investigating an alert marks it acknowledged.
**T22 — `resolve_signal(signal_id, resolution?)`**
Resolve a signal with optional resolution note.
**T23 — `mute_signal(signal_id, duration?)`**
Temporarily mute a signal. Optional duration (default 1h).
**T24 — `cancel_execution(execution_id, reason)`**
Cancel a queued/running execution. Depends on T2 (async `run` returns
`execution_id`) and T3 (execution linkage for audit context).
**T25 — `update_check(check_id, enabled)`**
Enable/disable a health check. Agent suppresses a noisy probe.
**T26 — `delete_knowledge(knowledge_id)`**
Soft-delete a knowledge entry (move to trash, restorable).
**T27 — `restore_knowledge(knowledge_id)`**
Restore a trashed knowledge entry.
**T28 — `merge_knowledge(source_id, target_id)`**
Fold one knowledge entry into another. Source gets soft-deleted, content
appended to target.
**T29 — `rename_knowledge_tag(old_name, new_name)`**
Bulk-rename a tag across all knowledge entries.
---
### Stage 5: Close the learning loop (5 tasks)
Turn execution data into persistent knowledge. Currently all learning tables are
empty (0 classifications, 0 feedback, 0 patterns, 0 skills).
**T30 — Auto-classify every `run` → `classifications` table** *(audit Task 11)*
`internal/mcp/server.go`: `run` already calls `classifyCommand`. Write the
result to the `classifications` table (risk_class + route + patterns matched).
Currently 0 rows despite 1,884 executions.
**T31 — Auto-upsert knowledge on session close** *(audit Task 7)*
`cmd/nomos`: on `complete_task` (any outcome), auto-generate a knowledge entry:
title=`<date>: <goal>`, content with Outcome/Root cause/What was done/Unresolved
sections, tags=`[session:<id>]`, linked to involved entities.
**T32 — Auto-feedback on session close** *(audit Task 12)*
`cmd/nomos`: on `complete_task`, generate a `feedback` entry: session_id,
outcome, observation, lesson, side_effects. Daily cron job reads recent feedback
and extracts patterns (recurring root causes, same-fix-applied-multiple-times).
**T33 — Token tracking** *(audit Task 8)*
`cmd/nomos`: after each LLM call, extract `usage.total_tokens` from the response
and write to `agent_activity.token_count`. Currently NULL for all rows.
**T34 — Plan quality metric** *(audit Task 10)*
`cmd/nomos`: at session close, compute `completed_steps / total_steps` (currently
~38%). Write as session attribute. Track over time to measure impact of T16+T17.
---
## 4. Validation
| Task | Test |
|---|---|
| T1 | `run("lxc:dns", "qm stop 100")` → error: "qm is a Proxmox host command" |
| T2 | `run` with `sleep 45; echo done` → returns `execution_id` immediately; `get_execution_status` eventually shows completed |
| T3 | After `run`, `nomos_plan_executions` has row linking session + step + execution |
| T4 | `get_dashboard_summary()` returns health counts, signal counts, approval count in one call |
| T5 | `get_ontology()` returns entity_types, relationship_types, lifecycle_states |
| T6 | `list_checks(entity_slug="lxc:jellyfin")` returns all checks with verdict + last run |
| T7 | `list_executions(entity_slug="host:hubris", limit=10)` returns cursor-paginated list |
| T8 | `get_knowledge_revisions(id)` returns ordered revision list with timestamps |
| T9 | `get_knowledge_duplicates()` returns clusters with similarity scores |
| T10 | `get_knowledge_orphans()` returns knowledge entries with zero entity links |
| T11 | `list_knowledge_tags()` returns {name, count} for all tags |
| T12 | `list_entity_sessions("lxc:jellyfin")` returns active sessions with goal + status |
| T13 | `find_entities_by(ip="10.0.0.5")` returns matching entities |
| T14 | MCP client can browse `oikos://entity/*` resources |
| T15 | Session with goal "make X reachable" where last ping shows 502 → `complete_task(success)` warns or rejects |
| T16 | `update_plan_step(status=replaced)` with no reason → rejected |
| T17 | Resumed session calls `run` before `propose_plan` → blocked |
| T18 | Agent pivots to unrelated subsystem → `session_questions` is called |
| T19 | `run` with `head - n /etc/hosts` → rejected with syntax error |
| T20 | Session idle for 30+ min with no `closed_at` → reaped (status=failed) |
| T21 | `ack_signal(id)` → signal status transitions to acknowledged, audit logged |
| T22 | `resolve_signal(id, "fixed DNS")` → resolved with note |
| T23 | `mute_signal(id, 3600)` → muted for 1 hour, auto-unmutes |
| T24 | `cancel_execution(id, "wrong target")` → execution cancelled, audit logged |
| T25 | `update_check(id, false)` → check disabled, scheduler stops probing |
| T26 | `delete_knowledge(id)` → soft-deleted (trashed), restorable |
| T27 | `restore_knowledge(id)` → restored from trash, reappears in list |
| T28 | `merge_knowledge(src, dst)` → src deleted, content appended to dst |
| T29 | `rename_knowledge_tag("old", "new")` → all entries updated |
| T30 | After any `run`, `classifications` has row with risk_class + route |
| T31 | `complete_task` → knowledge entry created automatically with session link |
| T32 | `complete_task` → feedback entry created; daily job extracts pattern if same root cause appears ≥3 times |
| T33 | `agent_activity.token_count` is non-NULL after LLM call |
| T34 | Session close writes `plan_adherence` attribute (% steps completed) |
---
## 5. Files touched
| File | Tasks |
|---|---|
| `internal/mcp/server.go` | T1, T2, T3, T19, T24, T30 |
| `internal/mcp/tools.go` | T4T14, T21T29 |
| `internal/mcp/discover.go` | (no changes — references for T10/T11 patterns) |
| `cmd/nomos/main.go` (or config) | T2 (timeout), T16, T17, T18, T31, T32, T33, T34 |
| `nomos/SOUL.md` | T15, T16, T18 |
| `internal/httpapi/` | T3 (audit_log.session_id plumbing) |
| DB migrations | T16 (replaced_reason column) |
---
## 6. What stays WebUI-only
| Feature | Reason |
|---|---|
| FleetMap visual graph | Canvas rendering — not an MCP concern |
| uPlot metric charts | Raw data available via `query_metrics`/`get_trend` |
| Desktop shell, Cluck, App Store | Pure UI layer |
| SSE event streaming | MCP has no push model; polling covers it |
| Execution log streaming | `get_execution_status` returns latest output |
| Nomos session chat | MCP is a tool interface, not a chat agent |
| Knowledge wiki editor (revision browse, cleanup UI) | MCP tools expose the data + mutations; UI provides the editing experience |
| Approval queue with Approve/Deny buttons | Approvals stay operator-gated via WebUI/Matrix |
| Client enrollment flow | Enrollment is IP-gated, not an MCP tool |
---
## 7. Out of scope
- **`approve_execution` / `deny_execution` MCP tools** — dropped. The fix is raising
`auto_act` for `reversible_low` (policy change, no code).
- **`delete_entity` MCP tool** — separate lifecycle management concern.
- **Per-client bearer tokens** — open item tracked in CLIENTS.md. Until they
exist, external agents share the same `OIKOS_MCP_BEARER_TOKEN`.
- **Exactly-once pattern extraction from feedback** — T32 seeds the pipeline;
the full pattern-mining algorithm (TF-IDF clustering, causal inference from
event timelines) is future work.

View File

@@ -0,0 +1,158 @@
# 2026-08-05 — Agent execution safety: QEMU guest agent guardrails + host-mutation gate
**Status:** Plan.
**Context:** ZimaOS NFS recovery session (2026-08-04/05) surfaced three systemic
failures in how agents drive oikos mutations. The `run` tool queued an execution
against a VM whose QEMU guest agent was down — it sat `pending_approval` forever,
never executed, and the agent silently fell back to raw SSH. That same raw-SSH
fallback was then used to `apt-get install nfs-kernel-server` directly on the
hubris **PVE host**, crashing it and taking the whole homelab subnet down for
~15 minutes.
**Trigger:** Incident `investigation:nomos/incident-hubris-crash-from-nfs-kernel-server-on-pve-host-2026-08-05`
(2026-08-05) + session audit. The crash was caused by an agent bypassing the
`run` approval gate, which exists precisely to catch that kind of mistake.
---
## 1. Motivation
The OODA loop's Act phase is the security boundary (ADR 0012: "Hermes has no
direct SSH access... all mutations go through the execution queue"). This session
proved the boundary has three leaks:
1. **`run` on a VM with a dead QEMU guest agent queues silently.** The execution
is classified `config_mutation`, queued for approval, and *never fails* — it
just sits in `pending_approval` while the agent assumes progress. There is no
feedback that the underlying transport (`qm guest exec`) cannot work.
2. **No guardrail against host-level package/kernel mutations.** `apt-get install`
targeting a `proxmox-host` entity is classified `config_mutation` and gated —
*if* the agent routes it through `run`. When the first `run` call stalls
(leak #1), the agent falls back to raw SSH, which has no classification at all.
The crash was the direct result of that fallback.
3. **Agents are trusted to self-report the `health` attribute.** `update_entity_attributes`
let the agent set `health:"healthy"` on `lxc:nfs-export`, which derived 4
spurious health checks. Health is scheduler-owned; agents shouldn't write it.
---
## 2. Changes
### I — `run` pre-flights the execution transport before queueing
**Why:** A queued execution that can never run is worse than a failed one — it
looks like progress, stalls the agent, and (this session) pushed the agent into
the unsafe raw-SSH fallback.
**What:**
In the `run` tool handler (`internal/mcp/`), before inserting the execution row:
- If target type is `vm`, read the target entity's attributes. If
`qemu_guest_agent` is missing or `not_running`, return an immediate error:
`"run on vm:zimaos blocked: QEMU guest agent is not running (qm guest exec
unavailable). Start the agent first or use a different target."`
- Same check for `lxc` targets whose `pct exec` path is known-broken (optional —
start with VM only).
This converts "queued forever" into a fast, actionable failure the agent can
recover from immediately.
**Risk class:** read-only (validation only, no execution row created).
**Test:** Unit test with a fake VM entity that has `qemu_guest_agent: not_running`
→ assert the tool returns the blocking error and inserts no execution row.
### II — Host-mutation command guardrail in `run` classification
**Why:** `apt-get install` on a Proxmox host is the exact class of mutation that
must always hit the approval gate. The classifier already escalates `apt`/kernel
touches; this makes the escalation explicit and documented so agents stop
second-guessing it.
**What:**
- In `seeds/policy.yaml`, add an explicit rule: `proxmox-host` targets + commands
matching `(apt-get install|apt install|dpkg|modprobe|kernel)``config_mutation`
(operator approval required), never `reversible_low`.
- Extend the classifier to also flag `update-rc.d`, `systemctl enable` on host
targets if not already covered.
- Add a note in the `run` tool description: "Host-level package/kernel mutations
always require operator approval."
**Risk class:** policy change — knowledge/DB, deploy via seed ingest.
**Test:** `classify_command("apt-get install -y nfs-kernel-server", declared_risk=read_only)`
on `host:hubris` → must return `config_mutation`, not read_only. Add a fixture test
in the classifier suite.
### III — `health` attribute is read-only for agents
**Why:** This session's `update_entity_attributes({"health":"healthy"})` on
`lxc:nfs-export` derived 4 checks. Health is computed by the scheduler from probe
results; an agent asserting it creates false monitoring.
**What:**
- In `update_entity_attributes` handler: reject (or strip with a warning) the
`health` key. Return a message: `"health is scheduler-owned; attribute ignored.
Use get_health_summary/list_checks to observe it."`
- Document in the tool description: `"Do not write health — it is derived from
probes."`
**Risk class:** knowledge-graph mutation (existing), no infra impact.
**Test:** `update_entity_attributes(slug=lxc:nfs-export, attributes={"health":"healthy"})`
→ response shows health ignored, other keys merged.
### IV — Agent-side: record discovered dependency edges
**Why:** The session discovered `vm:zimaos` depends on `lxc:nfs-export` for
`/media/library`, but no `depends-on` edge was recorded. A future agent
investigating a ZimaOS mount failure would have no graph signal pointing at the
NFS server.
**What (agent behaviour, not code):** After confirming a runtime dependency, call
`create_relationship(source, target, type)` immediately. Concretely this session:
`create_relationship("vm:zimaos", "lxc:nfs-export", "depends-on")`.
**Where to enforce:** Update the homelab-context `HERMES.md` / SOUL.md agent
instructions with a one-line rule: "When you discover a dependency between two
entities (a service consumes another's export/mount/API), record it with
`create_relationship` in the same session." Plus a runbook in
`.agents/skills/` if one doesn't exist.
**Risk class:** knowledge-graph mutation, auto-approves.
**Test:** Manual — after recording the edge, `get_relations("vm:zimaos")` shows
`depends-on → lxc:nfs-export`.
---
## 3. Rollout
| Step | Item | When |
| ---- | ---- | ---- |
| 1 | II — policy.yaml classifier rule + tests | next seed ingest |
| 2 | I — `run` VM transport pre-flight + test | next mcp server deploy |
| 3 | III — health read-only guard + test | same deploy as I |
| 4 | IV — agent instruction update in homelab-context | commit + sync |
| 5 | Verify: re-run `classify_command` + manual `run` on vm:zimaos (agent now up) | after deploy |
---
## 4. Out of scope
- Per-client MCP bearer tokens (separate track, ADR 0012 note).
- `request_execution` re-introduction — the unified `run` primitive stays.
- Automating the Technitium DHCP reservation UI (this session's leftover — the
reservation for `BC:24:11:22:C2:F2 → 192.168.8.102` was added manually in the
web UI; consider a `runbook:technitium-dhcp-reservation` doc in a follow-up).
---
## 5. Changelog
- 2026-08-05 — plan created from ZimaOS/NFS session audit + hubris crash incident.

View File

@@ -17,12 +17,10 @@ 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) |
| 2026-07-20 | [Desktop mascot ("Cluck")](2026-07-20-desktop-mascot.md) | Implemented in v0.8.0 — see deviation note; physics/window-interaction follow-ups tracked separately |
| 2026-07-20 | [Session review: past 10 sessions](2026-07-20-session-review-ten-sessions.md) | Implemented in v0.7.13 — all P0/P1/P2 items landed |
| 2026-07-20 | [Mascot physics/window-interaction audit](2026-07-20-mascot-physics-audit.md) | P0P2 implemented; P3 ("cool stuff") ideas open |
| 2026-07-21 | [Frontend as OS + Apps — architecture audit & refactor](2026-07-21-frontend-os-apps-architecture.md) | Planned — Phase 1 ready |
| 2026-08-03 | [Nomos chat: reliability & predictability audit](2026-08-03-nomos-chat-reliability-and-ux-audit.md) | In Progress — F1F7 shipped in v0.15.0; F8 + follow-ups open |
| 2026-08-04 | [Hermes MCP client integration](done/2026-08-04-hermes-mcp-client-integration.md) | Done — deployed |
| 2026-08-05 | [Agent execution safety: QEMU guest agent gate + host-mutation guard](done/2026-08-05-agent-execution-safety-qemu-guest-agent-gate.md) | Done — implemented (1b9c761) |
## Done
@@ -60,10 +58,21 @@ See [`done/`](done/) for executed plans:
| 2026-07-14 | [Post-fix session remainders: empty responses & plan drift](done/2026-07-14-post-fix-session-remainders.md) |
| 2026-07-15 | [Plan-first and iteration](done/2026-07-15-plan-first-and-iteration.md) |
| 2026-07-15 | [WhatsApp session audit](done/2026-07-15-whatsapp-session-audit.md) |
| 2026-07-18 | [Session review: three recent sessions](done/2026-07-18-session-review-three-sessions.md) |
| 2026-07-20 | [Desktop mascot ("Cluck")](done/2026-07-20-desktop-mascot.md) |
| 2026-07-20 | [Session review: past 10 sessions](done/2026-07-20-session-review-ten-sessions.md) |
| 2026-07-21 | [Chat window full polish](done/2026-07-21-chat-full-polish.md) |
| 2026-07-29 | [Make health reflect reality + complete the knowledge graph](done/2026-07-29-health-check-reality-and-knowledge-graph.md) |
| 2026-07-30 | [Session review: plan drift & dead activity panel](done/2026-07-30-session-review-plan-drift-and-dead-activity-panel.md) |
| 2026-08-03 | [Nomos chat changes review (P0/P1/P2)](done/2026-08-03-nomos-chat-changes-review.md) |
| 2026-08-03 | [Nomos chat: reliability & predictability audit](done/2026-08-03-nomos-chat-reliability-and-ux-audit.md) |
| 2026-08-03 | [Adopt cyberspace.online terminal aesthetic + dithered images](done/2026-08-03-cyberspace-style-adoption.md) |
| 2026-08-03 | [Nomos chat: working-visibility, message queue, generation-aware timeline](done/2026-08-03-nomos-chat-working-visibility.md) |
| 2026-08-04 | [Chat interaction overhaul: inline progressive stream + thinking blocks](done/2026-08-04-chat-window-overhaul.md) |
## Conventions
- File name: `YYYY-MM-DD-<slug>.md`. Use the *target* date if known, otherwise the planning date.
- Status: `Planned``In Progress``Done` (move to `done/` on completion).
- When done: add a changelog entry on every affected node page, then move the file to `done/`.
- Plans are append-only once execution starts — don't rewrite pre-flight intent after the fact.
- Plans are append-only once execution starts — don't rewrite pre-flight intent after the fact.

79
scripts/fix-zimaos-nfs.sh Normal file
View File

@@ -0,0 +1,79 @@
#!/bin/bash
# Fix NFS mounts on ZimaOS — both library shares
# Run: bash /tmp/fix-zimaos-nfs.sh
# You'll be prompted for sudo password once
set -e
echo "=== Step 1: Grant passwordless sudo for mount/umount ==="
echo "dtoro ALL=(ALL) NOPASSWD: /usr/sbin/mount.nfs, /usr/sbin/umount.nfs, /bin/mount, /bin/umount, /usr/bin/mount" | sudo tee /etc/sudoers.d/zimaos-nfs > /dev/null
sudo chmod 440 /etc/sudoers.d/zimaos-nfs
echo " ✓ sudoers drop-in created"
echo "=== Step 2: Mount library from hubris (nfs-export LXC) ==="
sudo mkdir -p /media/library
sudo mount -t nfs -o nfsvers=4,rw,hard,intr 192.168.8.200:/mnt/library /media/library
echo " ✓ /media/library ← 192.168.8.200:/mnt/library"
echo "=== Step 3: Mount ludo-library from strong ==="
sudo mkdir -p /media/ludo-library
sudo mount -t nfs -o nfsvers=4,rw,hard,intr 192.168.8.241:/mnt/media_local /media/ludo-library
echo " ✓ /media/ludo-library ← 192.168.8.241:/mnt/media_local"
echo "=== Step 4: Verify ==="
echo ""
df -h | grep -E 'nfs|192.168'
echo ""
echo "--- /media/library contents ---"
ls /media/library/ | head -10
echo ""
echo "--- /media/ludo-library contents ---"
ls /media/ludo-library/ | head -10
echo ""
echo "=== Step 5: Persistent systemd mount units ==="
# Library mount unit
sudo tee /etc/systemd/system/media-library.mount > /dev/null << 'MOUNTUNIT'
[Unit]
Description=Mount nfs-export:/mnt/library as library
After=network-online.target
Wants=network-online.target
[Mount]
What=192.168.8.200:/mnt/library
Where=/media/library
Type=nfs
Options=nfsvers=4,rw,hard,intr
[Install]
WantedBy=multi-user.target
MOUNTUNIT
# Ludo-library mount unit
sudo tee /etc/systemd/system/media-ludo\x2dlibrary.mount > /dev/null << 'MOUNTUNIT2'
[Unit]
Description=Mount strong:/mnt/media_local as ludo-library
After=network-online.target
Wants=network-online.target
[Mount]
What=192.168.8.241:/mnt/media_local
Where=/media/ludo-library
Type=nfs
Options=nfsvers=4,rw,hard,intr
[Install]
WantedBy=multi-user.target
MOUNTUNIT2
sudo systemctl daemon-reload
sudo systemctl enable media-library.mount
sudo systemctl enable media-ludo\x2dlibrary.mount
echo " ✓ Both systemd mount units created and enabled"
echo ""
echo "=== DONE ==="
echo "Both NFS mounts active and persistent across reboots:"
echo " /media/library ← 192.168.8.200:/mnt/library (hubris nfs-export LXC)"
echo " /media/ludo-library ← 192.168.8.241:/mnt/media_local (strong)"

View File

@@ -87,10 +87,12 @@ entities:
mesh: {netbird: {ip: 100.122.165.149, fqdn: netbird-ionos.netbird.selfhosted}}
ssh: {user: root}
note: netbird mgmt+signal+relay+dashboard + coturn; sshd locked to hubris pubkey
monitoring: none # host unreachable from the lab (no ICMP, port 22
# times out even via hubris); liveness is covered
# by its services — authentik/matrix http checks
# and the matrix cert-expiry dial it on :443
monitoring: [http] # public HTTPS probe via https://mcp.hubris.network
# (2026-08-05: was `none` — the VPS went silent for
# 7 days because nothing probed it. The standalone-server
# type inherits [ping,resource,updates] from machine, but
# SSH/ICMP don't reach it from the lab; an HTTP probe on
# the public endpoint is the reachable liveness signal).
- slug: "ws:mac-mini"
type: workstation
name: mac-mini

View File

@@ -241,6 +241,11 @@ entity_types:
layer: infrastructure
lifecycle: infrastructure
description: Machine outside PVE management (e.g. external VPS).
# Override inherited [ping, resource, updates] — standalone servers may
# not be SSH/ICMP-reachable from the scheduler. HTTP is the least-
# common-denominator liveness signal. Entities with full SSH access
# can override to [ping, resource, http].
monitoring: [http]
attributes:
type: object
properties:
@@ -381,11 +386,13 @@ entity_types:
layer: infrastructure
lifecycle: infrastructure
description: DNS zone (e.g. split-horizon hubris.network).
monitoring: none # no `dns` checker exists yet; declaring [dns]
# made every zone an unresolvable `unmonitored`
# signal. Flip back to [dns] when a checker lands.
# Requires ontology re-ingest to take effect;
# coverageSweep then auto-clears the stale signals.
monitoring: [dns] # resolves the zone's apex via the configured
# resolver, verifying the zone is authoritatively
# reachable. (2026-08-05: was `none` — the DNS layer
# had zero checks, so a stale record like
# matrix→82.165.190.79 went unnoticed. Requires
# ontology re-ingest; coverageSweep clears stale
# signals after.)
attributes: {type: object, properties: {zone: {type: string}, authority: {type: string}}}
dns-record:
parent: entity

View File

@@ -70,6 +70,11 @@ approval_rules:
- {entity_type: docker-container, action: restart, risk_class: reversible_low, autonomy_level: auto}
- {entity_type: machine, action: apt-upgrade, risk_class: config_mutation, autonomy_level: escalate}
- {entity_type: machine, action: reboot, risk_class: config_mutation, autonomy_level: escalate}
# host-level package/kernel install (apt-get install, dpkg, modprobe, systemctl enable)
# always classifies as config_mutation — the classifier defaults to config_mutation
# for any command not in the read-only allowlist, so apt-get install reaches this
# tier naturally. Documented explicitly here so agents stop second-guessing:
# host mutations always need operator approval.
- {entity_type: machine, action: format-disk, risk_class: destructive, autonomy_level: never}
- {entity_type: config-repo, action: edit, risk_class: config_mutation, autonomy_level: escalate}
- {entity_type: deploy-pipeline, action: trigger, risk_class: config_mutation, autonomy_level: escalate}

View File

@@ -4,12 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Oikos</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Inknut+Antiqua:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />

653
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -42,6 +42,7 @@
"vitest": "^2.0.0"
},
"dependencies": {
"@joan/procedural-glyph-engine": "file:../vendor",
"@surdeddd/wmkit": "^0.3.0",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -2,6 +2,29 @@
@custom-variant dark (&:is(.dark *));
/* ── Self-hosted type (cyberspace terminal aesthetic) ── */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/JetBrainsMono-Bold.woff2') format('woff2');
}
@font-face {
font-family: 'VT323';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/VT323-Regular.woff2') format('woff2');
}
/* bits-ui components (Slider, and any future orientation/disabled-aware
primitive) style themselves via shorthand data-* variants that Tailwind
v4 doesn't ship — it only auto-generates variants for bare boolean data
@@ -14,13 +37,16 @@
@custom-variant data-disabled (&[data-disabled]);
@theme inline {
--font-sans: 'DM Sans', system-ui, sans-serif;
--font-mono: 'DM Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--font-heading: 'Inknut Antiqua', Georgia, serif;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--font-sans: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--font-heading: 'JetBrains Mono', ui-monospace, Menlo, monospace;
/* Square corners across the whole radius scale (--radius is pinned to 0
by both themes below). Kept as a 4-step scale so any future softer theme
can relax just --radius and get graded corners back for free. */
--radius-sm: var(--radius);
--radius-md: var(--radius);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-xl: var(--radius);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -57,103 +83,107 @@
--color-chart-5: var(--chart-5);
}
/* ── Terracotta Light Theme ── */
/* ── Cyberspace Light (black ink on warm cream paper) ──
Ported from cyberspace.online's 3-color model (fg/bg/fgDim). Light and
dark are exact inverses of the same cream (#efe5c0). Emphasis is by
inversion (primary = fg ink), borders are fg-derived hairlines, and the
radius is 0 so every surface is square. */
:root {
--radius: 0.625rem;
--background: oklch(0.94 0.02 55);
--foreground: oklch(0.18 0.03 45);
--card: oklch(0.91 0.025 55);
--card-foreground: oklch(0.18 0.03 45);
--popover: oklch(0.91 0.025 55);
--popover-foreground: oklch(0.18 0.03 45);
--primary: oklch(0.55 0.14 45);
--primary-foreground: oklch(0.95 0.02 55);
--secondary: oklch(0.86 0.03 55);
--secondary-foreground: oklch(0.18 0.03 45);
--muted: oklch(0.86 0.025 55);
--muted-foreground: oklch(0.45 0.04 45);
--accent: oklch(0.84 0.035 55);
--accent-foreground: oklch(0.18 0.03 45);
--destructive: oklch(0.5 0.2 25);
--destructive-foreground: oklch(0.95 0.02 55);
--border: oklch(0.6 0.08 45);
--input: oklch(0.78 0.04 55);
--ring: oklch(0.55 0.14 45);
--chart-1: oklch(0.55 0.14 45);
--chart-2: oklch(0.65 0.1 70);
--chart-3: oklch(0.5 0.08 30);
--chart-4: oklch(0.6 0.06 90);
--chart-5: oklch(0.4 0.04 45);
--sidebar: oklch(0.9 0.025 55);
--sidebar-foreground: oklch(0.18 0.03 45);
--sidebar-primary: oklch(0.55 0.14 45);
--sidebar-primary-foreground: oklch(0.95 0.02 55);
--sidebar-accent: oklch(0.84 0.035 55);
--sidebar-accent-foreground: oklch(0.18 0.03 45);
--sidebar-border: oklch(0.6 0.08 45);
--sidebar-ring: oklch(0.55 0.14 45);
--success: #3fb950;
--warning: #d29922;
--radius: 0px;
--background: #efe5c0;
--foreground: #000000;
--card: #efe5c0;
--card-foreground: #000000;
--popover: #efe5c0;
--popover-foreground: #000000;
--primary: #000000;
--primary-foreground: #efe5c0;
--secondary: #e0d6b0;
--secondary-foreground: #000000;
--muted: #e6dcc0;
--muted-foreground: #3a3a3a;
--accent: #000000;
--accent-foreground: #efe5c0;
--destructive: #9d0006;
--destructive-foreground: #efe5c0;
--border: color-mix(in oklab, #000000 22%, transparent);
--input: color-mix(in oklab, #000000 30%, transparent);
--ring: #000000;
--chart-1: #000000;
--chart-2: #3a3a3a;
--chart-3: #b57614;
--chart-4: #79740e;
--chart-5: #076678;
--sidebar: #efe5c0;
--sidebar-foreground: #000000;
--sidebar-primary: #000000;
--sidebar-primary-foreground: #efe5c0;
--sidebar-accent: #e0d6b0;
--sidebar-accent-foreground: #000000;
--sidebar-border: color-mix(in oklab, #000000 22%, transparent);
--sidebar-ring: #000000;
--success: #79740e;
--warning: #b57614;
--bg: var(--background);
--bg-surface: var(--card);
--bg-deeper: oklch(0.98 0.01 55);
--bg-deeper: #e6dcc0;
--bg-hover: var(--secondary);
--bg-active: var(--accent);
--text: var(--foreground);
--text-muted: var(--muted-foreground);
--accent-blue: var(--primary);
--accent-blue: #076678;
--accent-green: var(--success);
--accent-red: var(--destructive);
--accent-orange: var(--warning);
}
/* ── Carbon Dark Theme ── */
/* ── Cyberspace Dark (warm cream on black) — exact inverse of Light ── */
.dark {
--radius: 0.625rem;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.6 0.15 220);
--chart-3: oklch(0.5 0.1 160);
--chart-4: oklch(0.7 0.08 60);
--chart-5: oklch(0.45 0.05 320);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
--success: #3fb950;
--warning: #d29922;
--radius: 0px;
--background: #000000;
--foreground: #efe5c0;
--card: #000000;
--card-foreground: #efe5c0;
--popover: #000000;
--popover-foreground: #efe5c0;
--primary: #efe5c0;
--primary-foreground: #000000;
--secondary: #1a1a1a;
--secondary-foreground: #efe5c0;
--muted: #141414;
--muted-foreground: #a89984;
--accent: #efe5c0;
--accent-foreground: #000000;
--destructive: #cc241d;
--destructive-foreground: #efe5c0;
--border: color-mix(in oklab, #efe5c0 22%, transparent);
--input: color-mix(in oklab, #efe5c0 30%, transparent);
--ring: #efe5c0;
--chart-1: #efe5c0;
--chart-2: #a89984;
--chart-3: #fabd2f;
--chart-4: #b8bb26;
--chart-5: #83a598;
--sidebar: #000000;
--sidebar-foreground: #efe5c0;
--sidebar-primary: #efe5c0;
--sidebar-primary-foreground: #000000;
--sidebar-accent: #1a1a1a;
--sidebar-accent-foreground: #efe5c0;
--sidebar-border: color-mix(in oklab, #efe5c0 22%, transparent);
--sidebar-ring: #efe5c0;
--success: #b8bb26;
--warning: #fabd2f;
--bg: var(--background);
--bg-surface: var(--card);
--bg-deeper: oklch(0.11 0 0);
--bg-deeper: #050505;
--bg-hover: var(--secondary);
--bg-active: var(--accent);
--text: var(--foreground);
--text-muted: var(--muted-foreground);
--accent-blue: var(--sidebar-primary);
--accent-blue: #83a598;
--accent-green: var(--success);
--accent-red: var(--destructive);
--accent-orange: var(--warning);
@@ -273,14 +303,17 @@
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);
/* Hard offset shadow (DOS-style), not a soft drop shadow — keeps the
border-driven system and still separates stacked windows, which matters
because --card now equals the desktop background. */
box-shadow: 3px 3px 0 0 var(--border);
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);
box-shadow: 3px 3px 0 0 var(--ring);
}
[data-wm-window][data-wm-dragging],
@@ -417,8 +450,177 @@ a:hover {
}
.markdown-body a {
color: var(--primary);
text-decoration: none;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
.markdown-body a:hover {
text-decoration: underline;
text-decoration-style: solid;
}
/* ── cyberspace idioms (work in any theme; idiomatic for the terminal look) ── */
/* Pixel/terminal display face for stylized wordmarks & hero titles. Maps to
VT323 when available, JetBrains Mono fallback. Headings use --font-heading
(JetBrains Mono) by default; opt into this per-element for the "de-imagined"
title voice. */
.font-vt {
font-family: 'VT323', var(--font-mono);
letter-spacing: 0.02em;
}
/* Bordered square card with a 2px fg focus ring — the universal cyberspace
surface. Use on any container that wants the terminal-box look. */
.terminal-box {
border: 1px solid var(--border);
border-radius: 0;
}
.terminal-box:focus-within {
box-shadow: 0 0 0 2px var(--ring);
}
/* ════════════════════════════════════════════════════════════════════════
TERMINAL DESIGN SYSTEM — cyberspace.online adoption
These rules are UNLAYERED (written after Tailwind's @layer utilities),
so they override utility classes — shadow-*, ring-*, rounded-* — on the
shadcn data-slot primitives regardless of specificity. The system is:
• border-driven — hairline borders separate surfaces; no soft shadows
• square — every corner is 0 (also enforced via --radius tokens)
• focus by color — :focus signals via border/text color, not glow rings
• DOS modals — dialogs get a double fg-line frame + hatched corner
════════════════════════════════════════════════════════════════════════ */
:root {
--dos-border: 1px; /* DOS frame line width (used doubled for the modal edge) */
--dos-dither: 4px; /* hatch tile size for the modal corner shadow */
--dos-offset: 7px; /* how far the hatched corner sits out from the frame */
}
/* ── Containers → terminal-box: solid hairline border, square, no shadow.
Replaces shadcn's `shadow-xs ring-1 ring-foreground/10 rounded-xl/md`. ── */
[data-slot='card'],
[data-slot='popover-content'],
[data-slot='hover-card-content'],
[data-slot='dropdown-menu-content'],
[data-slot='select-content'],
[data-slot='tooltip-content'],
[data-slot='sheet-content'],
[data-slot='menubar-content'],
[data-slot='alert-dialog-content'] {
border: 1px solid var(--border);
border-radius: 0;
box-shadow: none;
}
/* ── Dialogs → the DOS frame. A 1px fg border, then a bg gap, then a 1px fg
ring (two stacked box-shadows) = the double-line terminal window edge. ── */
[data-slot='dialog-content'] {
position: fixed;
border: var(--dos-border) solid var(--foreground);
border-radius: 0;
box-shadow:
0 0 0 var(--dos-border) var(--background),
0 0 0 calc(var(--dos-border) * 2) var(--foreground);
}
/* Hatched corner shadow (fg-dim 45° checks) bottom-right — the DOS window
tell. Clipped to a small square outside the frame. */
[data-slot='dialog-content']::after {
content: '';
position: absolute;
right: calc(-1 * var(--dos-offset));
bottom: calc(-1 * var(--dos-offset));
width: var(--dos-offset);
height: var(--dos-offset);
pointer-events: none;
background-color: var(--muted-foreground);
background-image:
linear-gradient(
45deg,
var(--background) 25%,
transparent 25%,
transparent 75%,
var(--background) 75%
),
linear-gradient(
45deg,
var(--background) 25%,
transparent 25%,
transparent 75%,
var(--background) 75%
);
background-size: var(--dos-dither) var(--dos-dither);
background-position:
0 0,
calc(var(--dos-dither) / 2) calc(var(--dos-dither) / 2);
}
/* ── Overlays: opaque-ish theme background, no blur (terminal, not glass). ── */
[data-slot='dialog-overlay'],
[data-slot='alert-dialog-overlay'],
[data-slot='sheet-overlay'] {
background-color: color-mix(in oklab, var(--background) 82%, transparent);
backdrop-filter: none;
}
/* ── Inputs: square, no resting shadow; focus = solid fg border (cyberspace
signals focus by border color, not a glow ring). ── */
[data-slot='input'],
[data-slot='textarea'] {
border-radius: 0;
box-shadow: none;
}
[data-slot='input']:focus,
[data-slot='input']:focus-visible,
[data-slot='textarea']:focus,
[data-slot='textarea']:focus-visible {
border-color: var(--foreground);
box-shadow: none;
outline: none;
}
/* ── Buttons: square, no resting shadow. Focus keeps the cva border-color
shift to fg (focus-visible:border-ring) rather than a ring. ── */
[data-slot='button'] {
border-radius: 0;
box-shadow: none;
}
/* ── Badges & tabs: square. ── */
[data-slot='badge'] {
border-radius: 0;
}
[data-slot='tabs-trigger'],
[data-slot='tabs-list'] {
border-radius: 0;
}
/* ── Window controls (titlebar minimize/maximize/close + taskbar item close).
ONE style across all window chrome: bordered square, invert on hover — the
same idiom as desktop icons and taskbar buttons. Close is intentionally not
"danger"-colored so every control reads as the same component. ── */
.win-ctrl {
align-items: center;
justify-content: center;
border: 1px solid var(--border);
background: var(--background);
color: var(--muted-foreground);
transition:
color 0.12s,
border-color 0.12s,
background 0.12s;
}
.win-ctrl:hover {
border-color: var(--foreground);
background: var(--foreground);
color: var(--background);
}
/* Pin every window titlebar to one exact height, targeted by attribute so it
holds for every window regardless of its titlebar classes — including
already-open wmkit windows whose titlebar markup can lag behind on HMR
(component HMR is unreliable for wmkit windows; CSS HMR is not).
`flex: 0 0 2.25rem` is the strongest guarantee a flex item won't grow or
shrink — it stops a scrolling/growing content pane from compressing the
titlebar (the chat-window symptom). */
[data-wm-drag] {
flex: 0 0 2.25rem !important;
}

View File

@@ -1,128 +0,0 @@
<script lang="ts">
// The agent's working trace for one assistant turn: the live "thinking"
// indicator and that turn's tool calls merged into a single collapsible
// strip, instead of a stack of one card per call (a 13-call turn buried the
// actual answer). Collapsed it's one line — the current activity while
// running, a count once finished. Expanded it lists what the agent did, in
// humanized language, each row opening to its raw args/result.
import type { ToolCallResult } from '$lib/types'
import ToolCallCard from './ToolCallCard.svelte'
import Spinner from './Spinner.svelte'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
let {
tools = [],
label = null,
status = 'idle'
}: {
tools?: ToolCallResult[]
/** Live indicator text — the running step, an error, or "Done". */
label?: string | null
/** `idle` = no live state; the strip is just this turn's finished trace. */
status?: 'running' | 'done' | 'error' | 'idle'
} = $props()
let expanded = $state(false)
const count = $derived(tools.length)
// Collapsed line: prefer the live activity while something is happening,
// otherwise summarize the turn so a finished trace still says what it was.
const headline = $derived.by(() => {
if (status !== 'idle' && label) return label
if (count > 0) return count === 1 ? '1 tool call' : `${count} tool calls`
return 'No tool calls'
})
</script>
<div
class="trace rounded-lg border border-border/60 bg-card/40 transition-colors"
class:running={status === 'running'}
>
<button
class="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-muted/40"
onclick={() => (expanded = !expanded)}
aria-expanded={expanded}
aria-label={expanded ? 'Hide agent trace' : 'Show agent trace'}
>
<span
class="shrink-0 {status === 'error'
? 'text-destructive'
: status === 'idle'
? 'text-muted-foreground'
: 'text-primary'}"
>
{#if status === 'running'}
<Spinner class="size-3" />
{:else if status === 'error'}
<XIcon class="size-3" />
{:else if status === 'done'}
<CheckIcon class="size-3" />
{:else}
<SparklesIcon class="size-3" />
{/if}
</span>
<span
class="min-w-0 flex-1 truncate text-xs {status === 'error'
? 'text-destructive'
: status === 'running'
? 'text-foreground/80'
: 'text-muted-foreground'}"
>
{headline}
</span>
{#if count > 0 && status !== 'idle'}
<span class="shrink-0 text-[10px] tabular-nums text-muted-foreground/60">{count}</span>
{/if}
<ChevronRightIcon
class="size-3 shrink-0 text-muted-foreground/50 transition-transform {expanded
? 'rotate-90'
: ''}"
/>
</button>
{#if expanded}
<div class="border-t border-border/40 p-1">
{#if count > 0}
{#each tools as tool (tool.id)}
<ToolCallCard {tool} />
{/each}
{:else}
<p class="px-2 py-1.5 text-[11px] text-muted-foreground">
Nothing recorded for this turn yet.
</p>
{/if}
</div>
{/if}
</div>
<style>
.trace {
animation: trace-in 0.2s ease-out;
}
/* A faint pulse while the agent is mid-turn — the collapsed strip is the
only thing on screen then, so it carries the "still working" signal. */
.trace.running {
border-color: color-mix(in oklab, var(--primary) 35%, var(--border));
}
@keyframes trace-in {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.trace {
animation: none;
}
}
</style>

View File

@@ -6,24 +6,32 @@
// instead of being copy-pasted between the two.
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
import { resumeSession } from '$lib/api'
import type { Readable } from 'svelte/store'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import AgentTrace from './AgentTrace.svelte'
import TurnTrace from './TurnTrace.svelte'
import ThinkingBlock from './ThinkingBlock.svelte'
import OperatorQuestion from './OperatorQuestion.svelte'
import GlyphIndicator from './GlyphIndicator.svelte'
import Spinner from './Spinner.svelte'
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
import SquareIcon from '@lucide/svelte/icons/square'
import CopyIcon from '@lucide/svelte/icons/copy'
import CheckIcon from '@lucide/svelte/icons/check'
import ArrowDownToLineIcon from '@lucide/svelte/icons/arrow-down-to-line'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import type { ChatMessage } from '$lib/stores/chat'
import type { ToolCallResult } from '$lib/types'
import type { SessionQuestion } from '$lib/api'
import type { PlanStep, SessionQuestion } from '$lib/api'
let {
messages,
streaming,
connectionState,
working = false,
error = null,
chatErrors = [],
onSend,
@@ -34,11 +42,20 @@
activityLog: activityLogProp = activityLog,
sessionId = null,
question = null,
initialDraft = ''
initialDraft = '',
planSteps = [],
taskStatus,
lastActiveAt
}: {
messages: ChatMessage[]
streaming: boolean
connectionState: 'connected' | 'disconnected' | 'reconnecting'
/** True while a turn is running for this session — a live stream OR the
* server-side status says planning/executing. Drives the "working"
* indicator so a background/long/desynced turn still looks alive. The
* literal `streaming` (live deltas) is still used for the cursor + input
* lock. See plan 2026-08-03 F1. */
working?: boolean
error?: string | null
chatErrors?: { id: string; message: string; action?: string }[]
onSend: (text: string) => void
@@ -56,26 +73,35 @@
* than making them retype it. Left editable on purpose — it is a starting
* point, not a command. */
initialDraft?: string
/** Current-generation plan steps for this session — rendered as a live
* checklist on the running turn (TodoWrite-style). Empty for a new/plan-less
* task and for the new-task launcher. */
planSteps?: PlanStep[]
/** Session status (active/planning/executing/…/done/failed). Drives the
* plan checklist's collapse-to-summary at a terminal state. */
taskStatus?: string
/** Session's last_active_at timestamp — used to detect a stuck turn
* (working but no activity for >5 min) and show elapsed time. */
lastActiveAt?: string
} = $props()
let input = $state(initialDraft)
let messagesEnd = $state<HTMLDivElement | null>(null)
let input = $state(typeof initialDraft === 'string' ? initialDraft : '')
let scrolledUp = $state(false)
let container = $state<HTMLDivElement | null>(null)
let indicatorDone = $state(false)
let wasStreaming = $state(false)
let wasWorking = $state(false)
$effect(() => {
if (streaming) {
if (working) {
indicatorDone = false
wasStreaming = true
wasWorking = true
}
if (!streaming && wasStreaming) {
if (!working && wasWorking) {
indicatorDone = true
const t = setTimeout(() => {
indicatorDone = false
wasStreaming = false
wasWorking = false
}, 3000)
return () => clearTimeout(t)
}
@@ -83,7 +109,7 @@
const indicatorLabel = $derived.by(() => {
if (error) return error
if (!streaming && indicatorDone) return 'Done'
if (!working && indicatorDone) return 'Done'
// Prefer the running PLAN STEP as the headline — it's stable across the
// step's many tool calls, so the line stops rewriting itself on every
// command (the "thinking overwrites itself" complaint, F6). Falls back to
@@ -96,6 +122,63 @@
return 'Agent is thinking…'
})
// ── stuck detection + elapsed time ─────────────────────────────────────
// A turn is "stuck" when the server says working (planning/executing) but
// last_active_at is >5 min old — the agent's turn ended without updating
// the session status (crash, timeout, or a zombie gate). Show a distinct
// stuck indicator with a Resume button instead of a misleading "working…".
let resuming = $state(false)
let now = $state(Date.now())
$effect(() => {
if (!working) return
const id = setInterval(() => {
now = Date.now()
}, 1000)
return () => clearInterval(id)
})
const elapsedSeconds = $derived(
working && lastActiveAt
? Math.max(0, Math.floor((now - new Date(lastActiveAt).getTime()) / 1000))
: 0
)
const isStuck = $derived(working && !streaming && elapsedSeconds > 300)
function formatElapsed(s: number): string {
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.floor(s / 60)}m`
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
}
async function handleResume() {
if (!sessionId || resuming) return
resuming = true
try {
await resumeSession(sessionId)
} finally {
resuming = false
}
}
// ── glyph backdrop ─────────────────────────────────────────────────────
// The agent's live semantic state, rendered as a faint procedural glyph
// behind the transcript. Computed from the same signals as the sidebar
// (status / working / streaming / connection / stuck) so the backdrop
// breathes with the agent without any store imports (prop-driven).
const agentSprite = $derived.by(() => {
if (connectionState !== 'connected') return 'status.offline'
if (taskStatus === 'failed') return 'status.error'
if (taskStatus === 'abandoned') return 'status.cancelled'
if (taskStatus === 'done') return 'status.success'
if (taskStatus === 'awaiting_input') return 'ai.listening'
if (isStuck) return 'status.warning'
if (streaming) return 'ai.speaking'
if (working) return 'ai.still-working'
if (taskStatus === 'planning') return 'ai.thinking'
return 'ai.idle'
})
// Resizable input area — drag the splitter above it to grow the textarea,
// capped so it can't swallow the whole thread. Both the minimum and the
// default are exactly one line: measured from the textarea's own
@@ -150,12 +233,24 @@
}
// Auto-scroll to bottom on new messages (or a freshly-raised question) —
// unless user scrolled up to read.
// unless user scrolled up to read. Sets scrollTop on the messages container
// directly instead of `scrollIntoView`, which walks ancestors and forces a
// reflow that can momentarily perturb the window titlebar height.
//
// During streaming, scroll INSTANTLY (behavior: 'auto') — the content is
// growing continuously, so a smooth animation constantly chases a moving
// target and produces the jerky "jumping" the operator sees. For
// non-streaming updates (a completed message, a question), a smooth scroll
// is fine. Uses requestAnimationFrame so the scroll lands after the DOM
// update, not 50ms later.
$effect(() => {
void messages
void question
if (streaming || !scrolledUp) {
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
const behavior = streaming ? ('auto' as const) : ('smooth' as const)
requestAnimationFrame(() => {
container?.scrollTo({ top: container.scrollHeight, behavior })
})
}
})
@@ -204,25 +299,51 @@
onSend(q)
}
// Merge live streaming output (from the activity log's `run` entry) onto the
// in-flight turn's tool calls so the inline tool card shows command output as
// it arrives — the place the operator naturally "checks the tool". Only the
// last assistant message can be streaming, so only it gets enriched; history
// is untouched (and has no live output anyway). (F4)
function toolsWithLive(
tools: ToolCallResult[],
entries: ActivityEntry[],
isLiveTurn: boolean
): ToolCallResult[] {
if (!isLiveTurn) return tools
const liveById = new Map<string, string>()
for (const e of entries) {
if (e.liveOutput && e.id) liveById.set(e.id, e.liveOutput)
// Per-message copy affordance (border-driven icon button on each row).
let copiedId = $state<string | null>(null)
async function copyMessage(msg: ChatMessage) {
try {
await navigator.clipboard.writeText(msg.text)
copiedId = msg.id
setTimeout(() => {
if (copiedId === msg.id) copiedId = null
}, 1400)
} catch {
/* clipboard unavailable — silently no-op */
}
if (liveById.size === 0) return tools
return tools.map((t) =>
t.id && liveById.has(t.id) ? { ...t, liveOutput: liveById.get(t.id) } : t
)
}
// Scroll-to-bottom: uses container.scrollTo (never scrollIntoView, which
// reflows ancestor wmkit panes — see wmkit.scrollintoview_reflow_pitfall).
function jumpToBottom() {
container?.scrollTo({ top: container.scrollHeight, behavior: 'smooth' })
scrolledUp = false
}
// Enrich a turn's tool calls with live `run` output AND plan-step
// attribution pulled from the activity log (keyed by tool id), so the inline
// TurnTrace can pin streaming output to its tool and group calls under their
// step. Run for every turn (not just the live one) so historical turns group
// correctly too; unmapped tools pass through unchanged.
function enrichTools(tools: ToolCallResult[], entries: ActivityEntry[]): ToolCallResult[] {
const byId = new Map<string, { liveOutput?: string; stepSeq?: number }>()
for (const e of entries) {
if (!e.id) continue
const cur = byId.get(e.id) ?? {}
if (e.liveOutput) cur.liveOutput = e.liveOutput
if (e.stepSeq != null) cur.stepSeq = e.stepSeq
byId.set(e.id, cur)
}
if (byId.size === 0) return tools
return tools.map((t) => {
if (!t.id) return t
const e = byId.get(t.id)
if (!e) return t
const next: ToolCallResult = { ...t }
if (e.liveOutput) next.liveOutput = e.liveOutput
if (e.stepSeq != null) next.stepSeq = e.stepSeq
return next
})
}
</script>
@@ -235,99 +356,117 @@
on:resize={() => (userResizedInput = true)}
>
<Pane class="flex flex-col">
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
<div class="mx-auto flex min-h-full max-w-3xl flex-col gap-5 p-4">
{#if messages.length === 0}
<div class="flex flex-1 flex-col items-center justify-center gap-6 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, idx (msg.id)}
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
{#if msg.role === 'user'}
<div class="flex items-baseline gap-2 px-1">
<span class="text-[10px] font-medium text-muted-foreground/70">You</span>
{#if msg.created_at}
<span class="text-[9px] text-muted-foreground/50"
>{formatTime(msg.created_at)}</span
>
{/if}
</div>
<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}
{@const isLast = idx === messages.length - 1}
{@const traceStatus = !isLast
? 'idle'
: error
? 'error'
: streaming
? 'running'
: indicatorDone
? 'done'
: 'idle'}
<div class="flex w-full flex-col gap-2">
<div class="flex items-baseline gap-2 px-1">
<span class="text-[10px] font-medium text-muted-foreground/70">Nomos</span>
{#if msg.created_at}
<span class="text-[9px] text-muted-foreground/50"
>{formatTime(msg.created_at)}</span
>
{/if}
</div>
<!-- The working trace sits above the answer: it's what happened
first, and collapsed it keeps a long tool run from burying
the text below it. -->
{#if msg.tools.length > 0 || traceStatus !== 'idle'}
<AgentTrace
tools={toolsWithLive(msg.tools, $activityLogProp, isLast && traceStatus !== 'idle')}
status={traceStatus}
label={traceStatus === 'idle' ? null : indicatorLabel}
/>
{/if}
{#if msg.text}
<div
class="markdown-body 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)}
{#if isLast && streaming}
<span class="stream-cursor" aria-hidden="true"></span>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/each}
{#if question}
<OperatorQuestion {sessionId} {question} />
{/if}
<div bind:this={messagesEnd}></div>
<div class="relative min-h-0 flex-1">
<!-- Glyph backdrop — the agent's live semantic state as a faint
procedural watermark behind the transcript. Fixed (doesn't scroll
with the messages), pointer-events none, behind the content. -->
<div class="glyph-backdrop" aria-hidden="true">
<GlyphIndicator sprite={agentSprite} seed={sessionId ?? 'oikos'} size={640} opacity={0.07} />
</div>
<div class="relative z-[1] h-full overflow-y-auto" bind:this={container} onscroll={onScroll}>
<div class="mx-auto flex min-h-full max-w-3xl flex-col divide-y divide-border px-4">
{#if messages.length === 0}
<div class="flex flex-1 flex-col items-center justify-center gap-6 p-8 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, idx (msg.id)}
{@const isLast = idx === messages.length - 1}
<div class="msg-row relative flex gap-3 py-3">
<div class="msg-role" aria-hidden="true">{msg.role === 'user' ? 'YOU' : 'NOMOS'}</div>
<div class="msg-body min-w-0 flex-1">
{#if msg.role === 'user'}
<div class="user-text whitespace-pre-wrap text-sm leading-relaxed">{msg.text}</div>
{#if msg.created_at}
<div class="msg-time">{formatTime(msg.created_at)}</div>
{/if}
{#if isLast && working && !streaming}
<!-- The last message is this user row and the agent is working but not
live-streaming → the message was queued behind an in-flight turn
(plan 2026-08-03 F2). It'll run when the current step finishes. -->
<div class="queued-hint">Queued — runs when Nomos finishes the current step.</div>
{/if}
{:else}
{@const traceStatus = !isLast
? 'idle'
: error
? 'error'
: working
? 'running'
: indicatorDone
? 'done'
: 'idle'}
<!-- Inline progressive trace: live plan checklist (last turn) +
thinking line + per-tool lines, then the streamed answer. -->
{#if msg.tools.length > 0 || traceStatus !== 'idle'}
<TurnTrace
tools={enrichTools(msg.tools, $activityLogProp)}
status={traceStatus}
label={traceStatus === 'idle' ? null : indicatorLabel}
{isLast}
planSteps={isLast ? planSteps : []}
{taskStatus}
/>
{/if}
{#if msg.thinking}
<ThinkingBlock thinking={msg.thinking} />
{/if}
{#if msg.text}
<div
class="markdown-body prose-chat max-w-none text-sm leading-relaxed"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
{@html render(msg.text)}
{#if isLast && streaming}
<span class="stream-cursor" aria-hidden="true"></span>
{/if}
</div>
{#if msg.created_at}
<div class="msg-time">{formatTime(msg.created_at)}</div>
{/if}
{/if}
{/if}
</div>
<button
class="msg-action"
title="Copy message"
aria-label="Copy message"
onclick={() => copyMessage(msg)}
>
{#if copiedId === msg.id}<CheckIcon class="size-3.5" />{:else}<CopyIcon class="size-3.5" />{/if}
</button>
</div>
{/each}
{#if question}
<div class="py-3"><OperatorQuestion {sessionId} {question} /></div>
{/if}
</div>
</div>
{#if scrolledUp && messages.length > 0}
<button class="jump-bottom" onclick={jumpToBottom} aria-label="Jump to latest">
<ArrowDownToLineIcon class="size-4" />
</button>
{/if}
</div>
{#if connectionState === 'disconnected'}
@@ -384,16 +523,52 @@
<button
class="ml-1 text-muted-foreground hover:text-foreground"
onclick={() => onDismissError(err.id)}
aria-label="Dismiss">×</button
aria-label="Dismiss">×</button>
>
</div>
</div>
{/each}
{#if working && !streaming}
<!-- Background/autonomous turn in progress (no live stream to watch):
keep the composer open so the operator can queue a follow-up
(plan 2026-08-03 F1/F2). Lives in the message pane (alongside the
connection/error banners) so it consumes transcript space, NOT the
input pane's fixed height — otherwise appearing/disappearing would
clip the textarea and force a resize. Terminal status strip:
spinner + fg label + primary-tinted hairline border, aligned to the
textarea column. -->
<div class="mx-auto w-full max-w-3xl px-4">
{#if isStuck}
<div class="composer-status composer-status-stuck mb-2">
<span class="composer-status-label stuck-label">Stuck</span>
<span class="composer-status-text"
>no activity for {formatElapsed(elapsedSeconds)}</span
>
<Button
size="xs"
variant="outline"
class="h-6 text-[11px]"
onclick={handleResume}
disabled={resuming}
>
{resuming ? 'Resuming…' : 'Resume'}
</Button>
</div>
{:else}
<div class="composer-status mb-2">
<Spinner class="size-3 shrink-0 text-primary" />
<span class="composer-status-label">Working</span>
<span class="composer-status-text">{formatElapsed(elapsedSeconds)}</span>
</div>
{/if}
</div>
{/if}
</Pane>
<Pane bind:size={inputSize} minSize={inputMinSize} maxSize={45} class="flex flex-col">
<div
class="flex h-full min-h-0 flex-col border-t bg-card/50 p-3 input-ornament relative"
class="flex h-full min-h-0 flex-col border-t bg-background p-3 input-ornament relative"
bind:this={inputWrapperRef}
>
<form
@@ -408,7 +583,7 @@
bind:value={input}
onkeydown={handleKeydown}
placeholder="Ask Nomos anything…"
class="h-full max-h-none min-h-0 resize-none rounded-2xl px-4 py-3 pr-12 field-sizing-fixed"
class="h-full max-h-none min-h-0 resize-none px-4 py-3 pr-12 field-sizing-fixed"
disabled={streaming}
/>
{#if streaming}
@@ -416,7 +591,7 @@
type="button"
size="icon-sm"
variant="secondary"
class="absolute right-2 bottom-2 rounded-lg"
class="absolute right-2 bottom-2"
onclick={onCancel}
aria-label="Stop"
>
@@ -427,7 +602,7 @@
type="submit"
size="icon-sm"
variant="secondary"
class="absolute right-2 bottom-2 rounded-lg"
class="absolute right-2 bottom-2"
disabled={!input.trim()}
aria-label="Send"
>
@@ -441,34 +616,110 @@
</div>
<style>
/* ── Art Nouveau chat styling ── */
/* ── Cyberspace / terminal chat styling ──
Messages are full-width terminal log rows (left role-tag column +
content), separated by hairline divide-y. Border-driven, square, no soft
shadows — same language as the rest of the app. Prose deltas below sit on
top of the shared .markdown-body base (app.css); a two-class selector
(`.markdown-body.prose-chat`) wins on specificity over app.css's single
`.markdown-body` rules deterministically, regardless of <style> injection
order. */
/* Assistant message wrapper */
.assistant-msg {
position: relative;
/* Message rows */
.msg-row {
/* role column + body; the copy action is absolutely positioned top-right */
}
/* User message — soft terracotta bubble, gentle lift */
.user-msg {
box-shadow: 0 1px 8px -4px var(--primary);
.msg-role {
flex-shrink: 0;
width: 3.25rem;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--muted-foreground);
padding-top: 0.15rem;
}
.msg-body {
display: flex;
flex-direction: column;
gap: 0.3rem;
overflow-wrap: break-word;
}
.user-text {
color: var(--foreground);
}
.msg-time {
font-family: var(--font-mono);
font-size: 9px;
color: var(--muted-foreground);
opacity: 0.7;
}
.queued-hint {
font-size: 10px;
color: var(--muted-foreground);
}
.msg-action {
position: absolute;
top: 0.6rem;
right: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border: 1px solid transparent;
background: transparent;
color: var(--muted-foreground);
opacity: 0;
transition:
opacity 0.12s,
color 0.12s,
border-color 0.12s;
}
.msg-row:hover .msg-action,
.msg-action:focus-visible {
opacity: 1;
}
.msg-action:hover {
color: var(--foreground);
border-color: var(--border);
}
/* Prose overrides — deltas on top of the shared .markdown-body base
(app.css) only. The template applies both classes together
(class="markdown-body prose-chat ..."); everything below either adds a
look .markdown-body doesn't have (li::marker, the pre/blockquote
::before ornaments, hr, strong, the table-wrapper, the code-copy
button) or overrides a .markdown-body value that this "Art Nouveau"
chat treatment wants different (code/pre padding, heading size, th/td
padding, blockquote border color, link underline style). Anywhere a
value is actually overridden, the selector is
`.markdown-body.prose-chat` rather than `.prose-chat` alone —
:global() selectors from two different <style> blocks land in the same
stylesheet with no scoping to arbitrate between them, so equal
specificity would leave the winner to injection order (unreliable
across dev/build). The two-class selector's higher specificity wins
deterministically regardless. */
/* Glyph backdrop — fills the message pane, centers the glyph, stays put
while the transcript scrolls over it. pointer-events none so it never
intercepts scroll/click. */
.glyph-backdrop {
position: absolute;
inset: 0;
z-index: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
overflow: hidden;
}
/* Jump-to-latest button — border-driven square, sits over the transcript */
.jump-bottom {
position: absolute;
right: 1rem;
bottom: 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--border);
background: var(--background);
color: var(--muted-foreground);
box-shadow: 2px 2px 0 0 var(--border);
}
.jump-bottom:hover {
color: var(--foreground);
border-color: var(--foreground);
}
/* Prose deltas (markdown-body base lives in app.css). */
.prose-chat :global(li) {
padding-left: 0.25rem;
}
@@ -501,8 +752,8 @@
border: none;
}
/* Section headings — serif (Inknut) with a short accent rule. Extra top
margin separates sections; the first heading in a message doesn't. */
/* Headings — short accent rule under each; first heading in a message
doesn't get extra top margin. */
.markdown-body.prose-chat :global(h1) {
font-size: 1.15em;
margin: 1.15rem 0 0.4rem;
@@ -537,7 +788,6 @@
width: 2.5rem;
height: 2px;
margin-top: 4px;
border-radius: 1px;
background: linear-gradient(to right, var(--primary), transparent);
opacity: 0.55;
}
@@ -564,7 +814,7 @@
position: relative;
}
.prose-chat :global(blockquote)::before {
content: '';
content: '"';
position: absolute;
left: -0.15rem;
top: -0.35rem;
@@ -588,8 +838,6 @@
);
}
/* 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;
@@ -601,7 +849,7 @@
text-underline-offset: 2px;
}
/* Input area ornament */
/* Input area hairline ornament */
.input-ornament::before {
content: '';
position: absolute;
@@ -613,7 +861,38 @@
opacity: 0.3;
}
/* Code copy button — global: injected via render() into {html} blocks */
/* Composer "working/queued" status strip — terminal status-bar idiom: a
hairline primary-tinted border, square corners, a spinner + an uppercase
fg label + muted detail. Border-driven, no shadow. */
.composer-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.6rem;
border: 1px solid color-mix(in oklab, var(--primary) 35%, var(--border));
border-radius: 0;
background: color-mix(in oklab, var(--primary) 6%, var(--card));
font-size: 11px;
color: var(--muted-foreground);
}
.composer-status-label {
color: var(--foreground);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.composer-status-text {
color: var(--muted-foreground);
}
.composer-status-stuck {
border-color: color-mix(in oklab, var(--warning) 50%, var(--border));
background: color-mix(in oklab, var(--warning) 8%, var(--card));
}
.stuck-label {
color: var(--warning);
}
/* Code copy button — global: injected via render() into {html} blocks.
Square (cyberspace), not rounded. */
.prose-chat :global(.code-block-wrapper) {
position: relative;
}
@@ -626,7 +905,6 @@
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.375rem;
color: var(--muted-foreground);
opacity: 0;
transition:
@@ -651,7 +929,6 @@
height: 1.1em;
background: var(--primary);
opacity: 0.75;
border-radius: 1px;
margin-left: 1px;
vertical-align: text-bottom;
animation: cursor-blink 0.9s ease-in-out infinite;
@@ -666,4 +943,12 @@
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stream-cursor,
.msg-action {
animation: none;
transition: none;
}
}
</style>

View File

@@ -106,7 +106,7 @@
// pulse brightness
const alpha = p.pulse * (0.35 + 0.15 * Math.sin(t * 1.2 + p.phase))
ctx.fillStyle = dark ? `rgba(140,175,230,${alpha})` : `rgba(60,90,140,${alpha})`
ctx.fillStyle = dark ? `rgba(168,153,132,${alpha})` : `rgba(58,58,58,${alpha})`
ctx.beginPath()
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
ctx.fill()
@@ -123,7 +123,7 @@
const dist = dx * dx + dy * dy
if (dist < CONNECT_DIST * CONNECT_DIST) {
const alpha = (1 - Math.sqrt(dist) / CONNECT_DIST) * 0.18
ctx.strokeStyle = dark ? `rgba(140,175,230,${alpha})` : `rgba(60,90,140,${alpha})`
ctx.strokeStyle = dark ? `rgba(168,153,132,${alpha})` : `rgba(58,58,58,${alpha})`
ctx.beginPath()
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
@@ -136,7 +136,7 @@
const cx = w / 2,
cy = h / 2
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
const base = dark ? '13,17,23' : '255,255,255'
const base = dark ? '0,0,0' : '239,229,192'
scrim.addColorStop(0, `rgba(${base},0.72)`)
scrim.addColorStop(0.35, `rgba(${base},0.40)`)
scrim.addColorStop(0.65, `rgba(${base},0.08)`)

View File

@@ -1101,7 +1101,7 @@
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
border-radius: 999px;
border-radius: 0;
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
@@ -1114,9 +1114,12 @@
border-color: var(--primary);
}
.chip.on {
color: var(--foreground);
background: var(--secondary);
border-color: var(--primary);
color: var(--background);
background: var(--foreground);
border-color: var(--foreground);
}
.chip.on b {
color: var(--background);
}
.chip .dot {
width: 8px;

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import {
createGlyph,
type JoanGlyphEngine,
type SpriteName
} from '@joan/procedural-glyph-engine'
import { getTheme } from '$lib/stores/theme.svelte'
let {
sprite,
seed = 'oikos',
size = 96,
opacity = 1
}: {
sprite: string
seed?: string
/** Display max-width in px (the engine's internal grid stays 96; CSS
* upscales pixelated for larger backdrops). */
size?: number
/** Canvas opacity — <1 for a faint watermark backdrop. */
opacity?: number
} = $props()
let canvas = $state<HTMLCanvasElement | null>(null)
let glyph = $state<JoanGlyphEngine | null>(null)
function palette(t: 'light' | 'dark') {
return {
background: 'transparent',
off: t === 'dark' ? '#1a1a1a' : '#e6dcc0',
ink: t === 'dark' ? '#efe5c0' : '#000000',
accent: t === 'dark' ? '#a89984' : '#3a3a3a',
glow: 'transparent'
}
}
onMount(() => {
const g = createGlyph(canvas!, {
sprite: sprite as SpriteName,
seed,
gridSize: 96,
palette: palette(getTheme()),
background: false,
orbBackgroundColor: 'transparent',
orbBackgroundMode: 'none'
})
glyph = g
const obs = new MutationObserver(() => {
g.configure({ palette: palette(getTheme()) })
})
obs.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
})
return () => obs.disconnect()
})
onDestroy(() => {
glyph?.destroy()
})
$effect(() => {
if (glyph && sprite) {
glyph.transitionTo(sprite as SpriteName)
}
})
</script>
<canvas
bind:this={canvas}
width="96"
height="96"
class="glyph"
style="max-width:{size}px;opacity:{opacity}"
aria-hidden="true"
></canvas>
<style>
.glyph {
display: block;
width: 100%;
aspect-ratio: 1;
margin: 0 auto;
image-rendering: pixelated;
}
</style>

View File

@@ -0,0 +1,206 @@
<script lang="ts" module>
// Cached dither result for the current source/size, so a theme change only
// re-paints (cheap) instead of re-running the error-diffusion pass.
export interface DitherCache {
key: string
bits: Uint8Array // 1 = light (paper), 0 = dark (ink)
alpha: Uint8Array
w: number
h: number
}
</script>
<script lang="ts">
import { onMount } from 'svelte'
let {
src,
alt = '',
width = 256,
class: className = '',
plain = false,
bias = 0
}: {
src: string
alt?: string
/** CSS display width in px. The image is dithered at this resolution. */
width?: number
class?: string
/** Skip dithering; render the crisp source <img> instead. */
plain?: boolean
/** -255..255. Positive → more pixels resolve to ink (foreground). */
bias?: number
} = $props()
let canvas = $state<HTMLCanvasElement | null>(null)
let imgEl = $state<HTMLImageElement | null>(null)
let loaded = $state(false)
let tainted = $state(false)
let cache: DitherCache | null = null
const showSkeleton = $derived(!loaded)
const showCanvas = $derived(loaded && !plain && !tainted)
function readRgb(varName: string): [number, number, number] {
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim()
const m = raw.match(/#([0-9a-fA-F]{6})/)
const hex = m ? m[1] : '000000'
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]
}
function paint() {
if (!cache || !canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const { bits, alpha, w, h } = cache
canvas.width = w
canvas.height = h
// dark source (ink) → foreground; light source (paper) → background
const fg = readRgb('--foreground')
const bg = readRgb('--background')
const out = ctx.createImageData(w, h)
const d = out.data
for (let p = 0, i = 0; p < w * h; p++, i += 4) {
if (alpha[p] < 64) {
d[i + 3] = 0
continue
}
const c = bits[p] ? bg : fg
d[i] = c[0]
d[i + 1] = c[1]
d[i + 2] = c[2]
d[i + 3] = 255
}
ctx.putImageData(out, 0, 0)
}
function process(img: HTMLImageElement) {
const nw = img.naturalWidth || img.width
const nh = img.naturalHeight || img.height
if (!nw || !nh) return
const w = Math.max(1, Math.round(width))
const h = Math.max(1, Math.round((w * nh) / nw))
const off = document.createElement('canvas')
off.width = w
off.height = h
const octx = off.getContext('2d', { willReadFrequently: true })
if (!octx) return
octx.drawImage(img, 0, 0, w, h)
let data: Uint8ClampedArray
try {
data = octx.getImageData(0, 0, w, h).data
} catch {
tainted = true
return
}
const n = w * h
const lum = new Float32Array(n)
const alpha = new Uint8Array(n)
for (let p = 0, i = 0; p < n; p++, i += 4) {
lum[p] = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]
alpha[p] = data[i + 3]
}
const thr = 128 - bias
const bits = new Uint8Array(n)
// Atkinson error diffusion: 6 neighbors each get 1/8 of the quantization
// error. Softer & more "screen-printed" than FloydSteinberg.
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y * w + x
const oldv = lum[idx]
const newv = oldv < thr ? 0 : 255
bits[idx] = newv === 255 ? 1 : 0
const e = (oldv - newv) / 8
if (x + 1 < w) lum[idx + 1] += e
if (x + 2 < w) lum[idx + 2] += e
if (y + 1 < h) {
if (x - 1 >= 0) lum[idx + w - 1] += e
lum[idx + w] += e
if (x + 1 < w) lum[idx + w + 1] += e
}
if (y + 2 < h) lum[idx + 2 * w] += e
}
}
cache = { key: src + w, bits, alpha, w, h }
tainted = false
paint()
}
function onLoad() {
loaded = true
}
// Re-dither when the source image, target width, or plain flag changes.
$effect(() => {
void src
void width
void plain
if (!loaded || !imgEl || plain || tainted) return
if (imgEl.complete && imgEl.naturalWidth > 0) process(imgEl)
})
onMount(() => {
// Theme flip changes --foreground/--background on <html>'s class; re-paint
// the cached bits with the new palette (no re-dither needed).
const mo = new MutationObserver(() => {
if (cache && !plain) paint()
})
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] })
return () => mo.disconnect()
})
</script>
<div class="raster-wrap {className}" style="--rw:{width}px">
{#if showSkeleton}
<div class="raster-skeleton" aria-hidden="true"></div>
{/if}
{#if !plain}
<canvas
bind:this={canvas}
class="raster-canvas"
class:hidden={!showCanvas}
role="img"
aria-label={alt}
></canvas>
{/if}
<img
bind:this={imgEl}
{src}
{alt}
class="raster-fallback"
class:hidden={showCanvas}
onload={onLoad}
onerror={() => {
tainted = true
loaded = true
}}
/>
</div>
<style>
.raster-wrap {
display: inline-block;
position: relative;
width: var(--rw);
line-height: 0;
}
.raster-canvas,
.raster-fallback {
display: block;
width: 100%;
height: auto;
image-rendering: pixelated;
}
.raster-canvas.hidden,
.raster-fallback.hidden {
display: none;
}
/* No-flash placeholder while the source decodes — matches cyberspace's
raster-image-skeleton (empty, fills with the theme background). */
.raster-skeleton {
width: 100%;
min-height: calc(var(--rw) * 0.6);
aspect-ratio: 1 / 1;
background: var(--background);
}
</style>

View File

@@ -15,7 +15,7 @@
chatErrors
} from '$lib/stores/chat'
import { activityLogFor } from '$lib/stores/activity'
import { workspaceFor, startSessionWorkspace } from '$lib/stores/workspace'
import { workspaceFor, startSessionWorkspace, taskWorking, taskFor } from '$lib/stores/workspace'
import ChatThread from '$lib/components/ChatThread.svelte'
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
@@ -30,6 +30,8 @@
const chat = chatFor(sessionId)
const chatMessages = chat.messages
const chatStreaming = chat.streaming
// eslint-disable-next-line svelte/valid-compile
const chatWorking = taskWorking(sessionId)
const chatConnectionState = chat.connectionState
const chatError = chat.error
const chatNotFound = chat.notFound
@@ -40,6 +42,9 @@
// context rail mounts.
// eslint-disable-next-line svelte/valid-compile
const workspace = workspaceFor(sessionId)
const planSteps = workspace.planSteps
// eslint-disable-next-line svelte/valid-compile
const chatTask = taskFor(sessionId)
const openQuestion = workspace.openQuestion
let loading = $state(true)
@@ -71,7 +76,7 @@
// Resizable right rail — sized smaller by default since task windows open
// narrower than the full page.
let railSize = $state(24)
let railSize = $state(32)
</script>
<div class="flex h-full min-h-0">
@@ -90,19 +95,23 @@
<ChatThread
messages={$chatMessages}
streaming={$chatStreaming}
working={$chatWorking}
connectionState={$chatConnectionState}
error={$chatError}
chatErrors={$chatErrors}
activityLog={sessionActivityLog}
{sessionId}
question={$openQuestion}
planSteps={$planSteps}
taskStatus={$chatTask?.status}
lastActiveAt={$chatTask?.last_active_at}
onSend={(text) => sendSessionMessage(sessionId, text)}
onCancel={() => cancelSessionStream(sessionId)}
onReconnect={() => loadSessionChat(sessionId)}
onDismissError={dismissError}
/>
</Pane>
<Pane bind:size={railSize} minSize={18} maxSize={40}>
<Pane bind:size={railSize} minSize={24} maxSize={60}>
<TaskContextPanel {sessionId} />
</Pane>
</Splitpanes>

View File

@@ -75,6 +75,18 @@
let cw = $state(300)
let ch = $state(300)
// View transform (zoom-to-fit + drag-pan). The force simulation runs in its
// own graph coordinate space; this maps graph→screen so every entity stays
// visible regardless of how far the layout spreads or how narrow the panel
// is. tx/ty are screen px; scale is unitless. `userPanned` pauses auto-fit
// once the operator drags the background, until the entity set changes or
// they double-click to reset.
let tx = $state(0)
let ty = $state(0)
let scale = $state(1)
let userPanned = $state(false)
const viewTransform = $derived(`translate(${tx},${ty}) scale(${scale})`)
function collectSlugs(value: unknown, out: Set<string>) {
if (typeof value === 'string') {
const m = value.match(SLUG_RE)
@@ -206,6 +218,7 @@
.alphaDecay(0.045)
.on('tick', () => {
nodes = [...nodes]
if (!userPanned) fitView()
})
}
@@ -261,6 +274,49 @@
return slug.split(':').pop() ?? slug
}
// Compute the view transform that fits every node (with label clearance)
// inside the panel, clamped so a single node doesn't fill it and a huge
// graph stays legible. No-op until the layout has positions / a size.
function fitView() {
if (!nodes.length || cw <= 1 || ch <= 1) return
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of nodes) {
if (n.x == null || n.y == null) continue
const r = nodeRadius(n) + 12 // node + label clearance
minX = Math.min(minX, n.x - r)
minY = Math.min(minY, n.y - r)
maxX = Math.max(maxX, n.x + r)
maxY = Math.max(maxY, n.y + r)
}
if (!Number.isFinite(minX)) return
const pad = 16
const w = Math.max(maxX - minX, 1)
const h = Math.max(maxY - minY, 1)
const s = Math.min((cw - pad * 2) / w, (ch - pad * 2) / h)
const clamped = Math.max(0.2, Math.min(2.5, Number.isFinite(s) ? s : 1))
scale = clamped
tx = (cw - w * clamped) / 2 - minX * clamped
ty = (ch - h * clamped) / 2 - minY * clamped
}
// When the entity SET changes (a new node added/removed), re-engage auto-fit
// so the new entity is brought into view. Same-slug re-renders (every sim
// tick) leave the signature unchanged and don't reset.
let lastMembership = ''
$effect(() => {
const sig = nodes
.map((n) => n.slug)
.sort()
.join('|')
if (sig !== lastMembership) {
lastMembership = sig
userPanned = false
}
})
// Live touch/health-diff lookups, keyed by slug for O(1) per-node checks
// during render. Kept as plain objects (not Maps) since Svelte 5 runes track
// object identity fine and this is small (≤12 touched, ≤8 diffs).
@@ -283,16 +339,22 @@
return typeof end === 'object' ? end.slug : end
}
// ─── drag / select ───────────────────────────────────────────────────
// ─── drag / select / pan ─────────────────────────────────────────────
// 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.
// straight in its own floating window (WindowLayer); `selected` only drives
// the highlight/dim styling. Node drag pins the node in GRAPH coords
// (screen→graph via the inverse view transform). Background drag pans the
// view and sets userPanned so auto-fit pauses. Double-click background
// re-fits all entities.
let dragState: { node: Node; moved: boolean } | null = null
let panState: { x: number; y: number } | null = null
function toLocal(clientX: number, clientY: number) {
function toGraph(clientX: number, clientY: number) {
const rect = container!.getBoundingClientRect()
return { x: clientX - rect.left, y: clientY - rect.top }
return {
x: (clientX - rect.left - tx) / scale,
y: (clientY - rect.top - ty) / scale
}
}
function onNodeDown(e: PointerEvent, node: Node) {
@@ -301,26 +363,44 @@
dragState = { node, moved: false }
sim?.alphaTarget(0.2).restart()
}
function onBgDown(e: PointerEvent) {
panState = { x: e.clientX - tx, y: e.clientY - ty }
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
}
function onMove(e: PointerEvent) {
if (!dragState) return
const p = toLocal(e.clientX, e.clientY)
dragState.node.fx = p.x
dragState.node.fy = p.y
dragState.moved = true
nodes = [...nodes]
if (dragState) {
const p = toGraph(e.clientX, e.clientY)
dragState.node.fx = p.x
dragState.node.fy = p.y
dragState.moved = true
nodes = [...nodes]
return
}
if (panState) {
tx = e.clientX - panState.x
ty = e.clientY - panState.y
userPanned = true
}
}
function selectAndOpen(node: Node) {
selected = node
openEntityWindow(node.slug)
}
function onUp() {
if (!dragState) return
const { node, moved } = dragState
node.fx = null
node.fy = null
sim?.alphaTarget(0)
dragState = null
if (!moved) selectAndOpen(node)
if (dragState) {
const { node, moved } = dragState
node.fx = null
node.fy = null
sim?.alphaTarget(0)
dragState = null
if (!moved) selectAndOpen(node)
return
}
panState = null
}
function refit() {
userPanned = false
fitView()
}
const selectedRelations = $derived(
@@ -342,7 +422,7 @@
)
</script>
<aside class="flex h-full min-h-0 flex-col bg-card/40">
<aside class="flex h-full min-h-0 flex-col bg-card">
{#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"
@@ -447,9 +527,11 @@
class="h-full w-full touch-none select-none"
role="application"
aria-label="Session entity graph"
onpointerdown={onBgDown}
onpointermove={onMove}
onpointerup={onUp}
onpointercancel={onUp}
ondblclick={refit}
>
<defs>
<pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
@@ -457,8 +539,9 @@
</pattern>
</defs>
<rect width={cw} height={ch} fill="url(#{dotGridId})" />
<g>
{#each links as link}
<g transform={viewTransform}>
<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}
@@ -560,6 +643,7 @@
{/if}
{/each}
</g>
</g>
</svg>
{/if}
</div>

View File

@@ -1,170 +1,65 @@
<script lang="ts">
import { onMount } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import {
startWorkspace,
planSteps,
currentTask,
touched,
healthDiffs,
workspaceFor,
taskFor
taskFor,
taskWorking,
currentWorking,
currentTask
} from '$lib/stores/workspace'
import { streaming, messages, chatFor } from '$lib/stores/chat'
import { activityLog, activityLogFor } from '$lib/stores/activity'
import { messages, chatFor, streaming, connectionState } from '$lib/stores/chat'
import SessionGraph from './SessionGraph.svelte'
import UnifiedTimeline from './UnifiedTimeline.svelte'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import Spinner from './Spinner.svelte'
// 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". Per-session workspace
// tracking (startSessionWorkspace) is started by SessionChatWindow itself,
// not here — it has to run even while this panel stays unmounted (see its
// hasContext gate), so only the global fallback path starts its own here.
let { sessionId = null }: { sessionId?: string | null } = $props()
onMount(() => (sessionId ? undefined : startWorkspace()))
const ws = $derived(sessionId ? workspaceFor(sessionId) : null)
const planStepsStore = $derived(ws ? ws.planSteps : planSteps)
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)
let scopeOpen = $state(true)
let activityOpen = $state(true)
// eslint-disable-next-line svelte/valid-compile
const taskWorkingStore = sessionId ? taskWorking(sessionId) : currentWorking
// eslint-disable-next-line svelte/valid-compile
const taskStreamingStore = sessionId ? chatFor(sessionId).streaming : streaming
// eslint-disable-next-line svelte/valid-compile
const taskConnStore = sessionId ? chatFor(sessionId).connectionState : connectionState
// eslint-disable-next-line svelte/valid-compile
const taskStatusStore = sessionId ? taskFor(sessionId) : currentTask
// Resize: each section is a Pane in one vertical Splitpanes. Sizes are
// percentages of the panel's height; undefined means "share the space
// evenly with the other auto sections". Collapsing a section pins it to
// COLLAPSED_SIZE (roughly a header's worth of height) and remembers its
// last size so reopening restores it.
const COLLAPSED_SIZE = 6
const OPEN_MIN_SIZE = 12
let sizes = $state<(number | undefined)[]>([30, 70])
// Reopening must restore a concrete number, never `undefined` — the pane
// only re-triggers the library's resize/equalize pass when `size` changes
// to a different *number*, so setting it back to `undefined` silently
// no-ops and leaves the section stuck at its collapsed height.
let savedSizes: number[] = [30, 70]
// ── stuck detection (mirrors ChatThread) ───────────────────────────────
let now = $state(Date.now())
$effect(() => {
if (!$taskWorkingStore) return
const id = setInterval(() => {
now = Date.now()
}, 1000)
return () => clearInterval(id)
})
const lastActiveAt = $derived($taskStatusStore?.last_active_at)
const isStuck = $derived(
$taskWorkingStore &&
!$taskStreamingStore &&
lastActiveAt &&
now - new Date(lastActiveAt).getTime() > 300_000
)
function toggleSection(i: number, isOpen: boolean) {
if (isOpen) {
savedSizes[i] = sizes[i] ?? savedSizes[i]
sizes[i] = COLLAPSED_SIZE
} else {
sizes[i] = savedSizes[i]
}
}
// Plan collapsed status
const planDone = $derived($planStepsStore.filter((s) => s.status === 'done').length)
const planTotal = $derived($planStepsStore.length)
// Activity collapsed status
const activityRunning = $derived($activityLogStore.filter((e) => e.status === 'running').length)
</script>
<div class="flex h-full min-h-0 flex-col">
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
<!-- Scope -->
<Pane
bind:size={sizes[0]}
minSize={scopeOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE}
maxSize={scopeOpen ? 100 : COLLAPSED_SIZE}
class="flex flex-col"
>
<button
type="button"
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
onclick={() => {
toggleSection(0, scopeOpen)
scopeOpen = !scopeOpen
}}
>
{#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"
>{$touchedStore.length
? `${$touchedStore.length} entit${$touchedStore.length === 1 ? 'y' : 'ies'}`
: 'Graph'}</span
>
{/if}
</button>
{#if scopeOpen}
<div class="min-h-0 flex-1">
<SessionGraph
messages={$messagesStore}
touched={$touchedStore}
healthDiffs={$healthDiffsStore}
/>
</div>
{/if}
</Pane>
<!-- Activity (merged plan + event log) -->
<Pane
bind:size={sizes[1]}
minSize={activityOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE}
maxSize={activityOpen ? 100 : COLLAPSED_SIZE}
class="flex flex-col"
>
<button
type="button"
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
onclick={() => {
toggleSection(1, activityOpen)
activityOpen = !activityOpen
}}
>
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon
class="size-3"
/>{/if}
<span>Activity</span>
{#if $streamingStore && activityRunning > 0}
<Spinner class="size-3 text-primary" />
{/if}
{#if planTotal > 0}
<span
class="font-normal normal-case tabular-nums {planDone === planTotal
? 'text-muted-foreground'
: 'text-primary'}">{planDone}/{planTotal}</span
>
{/if}
{#if !activityOpen && planTotal === 0}
{#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 activity yet</span
>
{/if}
{/if}
</button>
{#if activityOpen}
<div class="min-h-0 flex-1 overflow-hidden">
<UnifiedTimeline
entries={$activityLogStore}
planSteps={$planStepsStore}
streaming={$streamingStore}
/>
</div>
{/if}
</Pane>
</Splitpanes>
<div class="shrink-0" style="aspect-ratio: 1; width: 100%;">
<SessionGraph
messages={$messagesStore}
touched={$touchedStore}
healthDiffs={$healthDiffsStore}
/>
</div>
</div>
<style>
</style>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import { Brain, ChevronRight } from '@lucide/svelte'
let { thinking }: { thinking: string } = $props()
let expanded = $state(false)
</script>
<div class="thinking-block">
<button
class="row"
onclick={() => (expanded = !expanded)}
aria-expanded={expanded}
>
<Brain class="icon size-3" />
<span class="text min-w-0 flex-1">
<span class="label">Thought{thinking.includes('\n') ? 's' : ''}</span>
</span>
<span class="summary">{thinking.slice(0, 60).replace(/\n/g, ' ')}{thinking.length > 60 ? '…' : ''}</span>
<ChevronRight class="chev size-3 {expanded ? 'open' : ''}" />
</button>
{#if expanded}
<div class="detail"><pre>{thinking}</pre></div>
{/if}
</div>
<style>
.thinking-block {
border-left: 1px solid var(--border);
padding-left: 0.5rem;
animation: thinking-in 0.15s ease-out;
}
@keyframes thinking-in {
from { opacity: 0; transform: translateY(-2px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.thinking-block { animation: none; }
}
.row {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.2rem 0;
text-align: left;
background: transparent;
border: none;
cursor: pointer;
}
.row:hover .label {
color: var(--foreground);
}
.icon {
flex-shrink: 0;
color: var(--muted-foreground);
}
.label {
font-size: 12px;
color: var(--muted-foreground);
}
.text {
display: flex;
align-items: baseline;
gap: 0.25rem;
}
.summary {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 45%;
opacity: 0.6;
}
.chev {
flex-shrink: 0;
color: var(--muted-foreground);
opacity: 0.6;
transition: transform 0.12s;
}
.chev.open {
transform: rotate(90deg);
}
.detail {
padding: 0.25rem 0 0.4rem 1.25rem;
}
.thinking-block :global(pre) {
margin: 0;
max-height: 16rem;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
background: var(--muted);
border: 1px solid var(--border);
padding: 0.4rem 0.5rem;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.4;
color: var(--foreground);
}
@media (prefers-reduced-motion: reduce) {
.chev { transition: none; }
}
</style>

View File

@@ -1,135 +0,0 @@
<script lang="ts">
// One tool call inside AgentTrace's expanded list. Renders as a borderless
// row (the trace supplies the container/border) whose own click reveals the
// raw args/result — so the trace stays a readable thinking log by default
// and the JSON is one more click away, not stacked inline.
import { Check, ChevronRight, Loader2, X } from '@lucide/svelte'
import type { ToolCallResult } from '$lib/types'
import { toolActivityLabel } from '$lib/stores/activity'
let { tool }: { tool: ToolCallResult } = $props()
let expanded = $state(false)
let liveEl = $state<HTMLPreElement | null>(null)
const status = $derived.by(() => {
if (tool.type === 'tool_use') return 'running'
if (tool.error) return 'error'
return 'done'
})
// Auto-open while a command is streaming its output, so the operator sees it
// without an extra click — mirrors UnifiedTimeline. Once the tool_result
// lands (status flips off running) liveOutput clears and the card respects
// the manual toggle again. (F4)
const open = $derived(expanded || !!tool.liveOutput)
$effect(() => {
if (tool.liveOutput && liveEl) liveEl.scrollTop = liveEl.scrollHeight
})
const label = $derived(toolActivityLabel(tool))
const argsSummary = $derived.by(() => {
if (!tool.args) return ''
const entries = Object.entries(tool.args)
if (entries.length === 0) return ''
const first = entries[0]
const val = typeof first[1] === 'string' ? first[1] : JSON.stringify(first[1])
return `${first[0]}: ${val.length > 60 ? val.slice(0, 60) + '…' : val}`
})
const hasDetail = $derived(
!!tool.args || (tool.result !== undefined && tool.result !== null) || !!tool.error
)
</script>
<div class="tool-row">
<button
class="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/40 disabled:cursor-default"
onclick={() => (expanded = !expanded)}
aria-expanded={open}
disabled={!hasDetail && !tool.liveOutput}
>
<span class="mt-px shrink-0 {status === 'error' ? 'text-destructive' : 'text-primary'}">
{#if status === 'running'}
<Loader2 class="size-3 animate-spin" />
{:else if status === 'error'}
<X class="size-3" />
{:else}
<Check class="size-3" />
{/if}
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-xs text-foreground/90">{label}</span>
{#if argsSummary}
<span class="block truncate font-mono text-[10px] text-muted-foreground/60"
>{argsSummary}</span
>
{/if}
</span>
<span class="shrink-0 font-mono text-[10px] text-muted-foreground/50">{tool.name}</span>
{#if hasDetail || tool.liveOutput}
<ChevronRight
class="mt-px size-3 shrink-0 text-muted-foreground/50 transition-transform {open
? 'rotate-90'
: ''}"
/>
{/if}
</button>
{#if open}
<div class="space-y-2 px-2 pb-2 pl-7">
{#if tool.liveOutput}
<div>
<div
class="mb-1 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-primary"
>
<Loader2 class="size-2.5 animate-spin" />
Live output
</div>
<pre
bind:this={liveEl}
class="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted/60 p-2 font-mono text-[11px] text-foreground/90">{tool.liveOutput}</pre>
</div>
{/if}
{#if tool.args}
<div>
<div
class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
>
Args
</div>
<pre
class="max-h-48 overflow-x-auto rounded-md bg-muted/60 p-2 text-[11px]">{JSON.stringify(
tool.args,
null,
2
)}</pre>
</div>
{/if}
{#if tool.result !== undefined && tool.result !== null}
<div>
<div
class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
>
Result
</div>
<pre
class="max-h-48 overflow-x-auto rounded-md bg-muted/60 p-2 text-[11px]">{JSON.stringify(
tool.result,
null,
2
)}</pre>
</div>
{/if}
{#if tool.error}
<div>
<div class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-destructive">
Error
</div>
<pre
class="max-h-48 overflow-x-auto rounded-md border border-destructive/20 bg-destructive/5 p-2 text-[11px] text-destructive">{tool.error}</pre>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,258 @@
<script lang="ts" module>
// One tool call rendered as a compact, progressive line — the Claude-Code
// signature for the inline trace. Collapsed: state icon + humanized label +
// a one-line RESULT summary on completion (or a "live" tag while a `run`
// streams). Expanded (click): raw args/result/error in opaque <pre> blocks.
// Border-driven, square, no rounded/shadow (cyberspace system).
</script>
<script lang="ts">
import { Check, ChevronRight, Loader2, X } from '@lucide/svelte'
import type { ToolCallResult } from '$lib/types'
import { toolActivityLabel, toolResultSummary } from '$lib/stores/activity'
let { tool }: { tool: ToolCallResult } = $props()
let expanded = $state(false)
let liveEl = $state<HTMLPreElement | null>(null)
const status = $derived(tool.type === 'tool_use' ? 'running' : tool.error ? 'error' : 'done')
const label = $derived(toolActivityLabel(tool))
const summary = $derived(toolResultSummary(tool))
// Tool calls start COLLAPSED — the operator expands them on demand. The
// live `run` output is shown in a separate pinned-tail mini pane below the
// collapsed row (not by auto-opening the whole detail), so the line stays
// compact while the command streams. Previously `open` auto-expanded on
// liveOutput and then collapsed when it cleared — the "start open, then
// collapse" behavior the operator found confusing.
const open = $derived(expanded)
$effect(() => {
if (tool.liveOutput && liveEl) liveEl.scrollTop = liveEl.scrollHeight
})
const hasDetail = $derived(
!!tool.args || (tool.result !== undefined && tool.result !== null) || !!tool.error
)
function pretty(v: unknown): string {
if (typeof v === 'string') {
try {
return JSON.stringify(JSON.parse(v), null, 2)
} catch {
return v
}
}
try {
return JSON.stringify(v, null, 2)
} catch {
return String(v)
}
}
</script>
<div class="tool-line">
<button
class="row"
onclick={() => (expanded = !expanded)}
aria-expanded={open}
disabled={!hasDetail && !tool.liveOutput}
>
<span class="icon {status}" aria-hidden="true">
{#if status === 'running'}
<Loader2 class="size-3 animate-spin" />
{:else if status === 'error'}
<X class="size-3" />
{:else}
<Check class="size-3" />
{/if}
</span>
<span class="text min-w-0 flex-1">
<span class="label {status === 'done' ? 'done-text' : ''}">{label}</span>
</span>
{#if status === 'running' && tool.liveOutput}
<span class="live-tag"><Loader2 class="size-2.5 animate-spin" /> live</span>
{:else if status === 'done' && summary}
<span class="summary">{summary}</span>
{:else if status === 'error'}
<span class="summary err">error</span>
{/if}
{#if hasDetail || tool.liveOutput}
<ChevronRight class="chev size-3 {open ? 'open' : ''}" />
{/if}
</button>
{#if tool.liveOutput}
<!-- Live `run` output — pinned-tail mini pane, always visible while the
command streams. Separate from the expand/collapse state so the tool
line itself stays collapsed. -->
<div class="live-output">
<pre bind:this={liveEl} class="live">{tool.liveOutput}</pre>
</div>
{/if}
{#if open}
<div class="detail">
{#if tool.args}
<div class="khead">Args</div>
<pre>{pretty(tool.args)}</pre>
{/if}
{#if tool.result !== undefined && tool.result !== null}
<div class="khead">Result</div>
<pre class={status === 'error' ? 'err' : ''}>{pretty(tool.result)}</pre>
{/if}
{#if tool.error}
<div class="khead err">Error</div>
<pre class="err">{tool.error}</pre>
{/if}
</div>
{/if}
</div>
<style>
.tool-line {
border-left: 1px solid var(--border);
padding-left: 0.5rem;
animation: tool-line-in 0.15s ease-out;
}
@keyframes tool-line-in {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.tool-line {
animation: none;
}
}
.row {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.2rem 0;
text-align: left;
background: transparent;
border: none;
cursor: pointer;
}
.row:disabled {
cursor: default;
}
.row:not(:disabled):hover .label {
color: var(--foreground);
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 0.75rem;
flex-shrink: 0;
}
.icon.running {
color: var(--primary);
}
.icon.error {
color: var(--destructive);
}
.icon.done {
color: var(--muted-foreground);
}
.label {
font-size: 12px;
color: var(--foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label.done-text {
color: var(--muted-foreground);
}
.text {
display: flex;
align-items: baseline;
gap: 0.25rem;
}
.summary {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 45%;
}
.summary.err {
color: var(--destructive);
}
.live-tag {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.2rem;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--primary);
}
.chev {
flex-shrink: 0;
color: var(--muted-foreground);
opacity: 0.6;
transition: transform 0.12s;
}
.chev.open {
transform: rotate(90deg);
}
.detail {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.25rem 0 0.4rem 1.25rem;
}
.live-output {
padding: 0.1rem 0 0.3rem 1.25rem;
}
.live-output :global(pre.live) {
max-height: 8rem;
}
.khead {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted-foreground);
}
.khead.err {
color: var(--destructive);
}
.tool-line :global(pre) {
margin: 0;
max-height: 12rem;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
background: var(--muted);
border: 1px solid var(--border);
padding: 0.4rem 0.5rem;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.4;
color: var(--foreground);
}
.tool-line :global(pre.err) {
color: var(--destructive);
border-color: color-mix(in oklab, var(--destructive) 40%, var(--border));
background: color-mix(in oklab, var(--destructive) 6%, var(--muted));
}
@media (prefers-reduced-motion: reduce) {
.chev {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,325 @@
<script lang="ts" module>
// The agent's working trace for ONE assistant turn, rendered inline as a
// progressive Claude-Code-style stream instead of a collapsed blob (replaces
// AgentTrace). Top to bottom: live plan checklist (running turn only), a
// "Thinking…" line while the model reasons (before the first tool / between
// steps), then each tool call as its own compact line grouped under its plan
// step. The streamed text answer is rendered by ChatThread after this.
</script>
<script lang="ts">
import type { ToolCallResult } from '$lib/types'
import type { PlanStep } from '$lib/api'
import ToolLine from './ToolLine.svelte'
import Spinner from './Spinner.svelte'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import PauseIcon from '@lucide/svelte/icons/pause'
import SlashIcon from '@lucide/svelte/icons/slash'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
let {
tools = [],
status = 'idle',
label = null,
isLast = false,
planSteps = [],
taskStatus
}: {
tools?: ToolCallResult[]
/** `idle` = this turn has no live state (a finished historical turn). */
status?: 'running' | 'done' | 'error' | 'idle'
/** Live indicator text while thinking (the running step / tool / "thinking…"). */
label?: string | null
isLast?: boolean
planSteps?: PlanStep[]
taskStatus?: string
} = $props()
const TERMINAL = new Set(['done', 'failed', 'abandoned'])
// seq → step title (current-gen only) so tool groups can label themselves.
const stepTitle = $derived(new Map<number, string>(planSteps.map((s) => [s.seq, s.title])))
// Group consecutive tools by their plan step (when attributed). Plan-less /
// meta tools (propose_plan, set_goal, …) have no stepSeq and form orphan
// groups rendered without a header.
interface Group {
step: { seq: number; title: string } | null
tools: ToolCallResult[]
}
const groups = $derived.by<Group[]>(() => {
const out: Group[] = []
let cur: Group | null = null
for (const t of tools) {
const seq = t.stepSeq
if (!cur || (cur.step?.seq ?? null) !== (seq ?? null)) {
cur = {
step: seq != null && stepTitle.has(seq) ? { seq, title: stepTitle.get(seq)! } : null,
tools: []
}
out.push(cur)
}
cur.tools.push(t)
}
return out
})
// Thinking line: visible while the turn is running and the model is reasoning
// — before the first tool, or after a tool finishes but before the next one
// starts. Hidden while a tool is mid-flight (its own spinner carries the
// liveness) and on idle/finished turns.
const lastToolRunning = $derived(
tools.length > 0 && tools[tools.length - 1].type === 'tool_use'
)
const showThinking = $derived(status === 'running' && !lastToolRunning)
// Plan checklist: only on the running/last turn, and only if a plan exists.
const showPlan = $derived(isLast && planSteps.length > 0)
const planTerminal = $derived(!!taskStatus && TERMINAL.has(taskStatus))
let planExpanded = $state(false)
const planDone = $derived(planSteps.filter((s) => s.status === 'done').length)
const planFailedStep = $derived(planSteps.find((s) => s.status === 'failed'))
// Elapsed time on the running step — ticks every second while a step is
// running so the operator can see how long it's been going (and spot a
// stuck step).
let now = $state(Date.now())
$effect(() => {
const running = planSteps.some((s) => s.status === 'running')
if (!running) return
const id = setInterval(() => {
now = Date.now()
}, 1000)
return () => clearInterval(id)
})
function stepElapsed(s: PlanStep): string {
if (s.status !== 'running' || !s.started_at) return ''
const sec = Math.max(0, Math.floor((now - new Date(s.started_at).getTime()) / 1000))
if (sec < 60) return `${sec}s`
if (sec < 3600) return `${Math.floor(sec / 60)}m`
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`
}
</script>
{#if showPlan}
<div class="plan {planTerminal && !planExpanded ? 'plan-collapsed' : ''}">
{#if planTerminal && !planExpanded}
<button class="plan-summary" onclick={() => (planExpanded = true)}>
{#if planFailedStep}
<XIcon class="size-3 text-destructive" />
<span class="plan-summary-text">Plan failed — step {planFailedStep.seq}</span>
{:else}
<CheckIcon class="size-3 text-primary" />
<span class="plan-summary-text">Plan complete — {planDone}/{planSteps.length} steps</span>
{/if}
<ChevronRightIcon class="size-3 text-muted-foreground/60" />
</button>
{:else}
<div class="plan-head">
<span class="plan-head-label">Plan</span>
<span class="plan-head-count">{planDone}/{planSteps.length}</span>
</div>
<ul class="plan-list">
{#each planSteps as s (s.id)}
<li class="plan-step {s.status === 'running' ? 'running' : ''}">
<span class="plan-node {s.status}" aria-hidden="true">
{#if s.status === 'running'}<Spinner class="size-3 text-primary" />
{:else if s.status === 'done'}<CheckIcon class="size-2.5" strokeWidth={3.5} />
{:else if s.status === 'failed'}<XIcon class="size-2.5" strokeWidth={3.5} />
{:else if s.status === 'blocked'}<PauseIcon class="size-2" strokeWidth={3} />
{:else if s.status === 'skipped' || s.status === 'replaced'}<SlashIcon class="size-2" strokeWidth={3} />
{/if}
</span>
<span class="plan-title" title={s.title}>{s.title}</span>
{#if s.status === 'running'}
<span class="plan-elapsed">{stepElapsed(s)}</span>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
{/if}
<!-- Thinking line: always rendered (fixed height) so appearing/disappearing
doesn't shift the layout — it just fades in/out. Shows a STABLE label
("Working…") rather than the current operation, which would rewrite
itself on every step/tool transition and read as text appearing and
disappearing. The current operation is already visible in the plan
checklist (running step) and the tool lines below. -->
<div class="thinking {showThinking ? '' : 'thinking-hidden'}" aria-hidden={!showThinking}>
<Spinner class="size-3 shrink-0 text-primary" />
<span class="thinking-text">Working…</span>
</div>
{#if groups.length > 0}
<div class="tools">
{#each groups as g, gi (gi)}
{#if g.step}
<div class="step-head">Step {g.step.seq} · {g.step.title}</div>
{/if}
{#each g.tools as tool (tool.id ?? `${gi}-${tool.name}`)}
<ToolLine {tool} />
{/each}
{/each}
</div>
{:else if status === 'idle' && tools.length === 0}
<!-- finished turn with no tools: nothing to render -->
{:else if status === 'error'}
<div class="thinking err">
<XIcon class="size-3 shrink-0 text-destructive" />
<span class="thinking-text">{label || 'Turn ended with an error'}</span>
</div>
{/if}
<style>
.plan {
border: 1px solid var(--border);
background: color-mix(in oklab, var(--primary) 3%, var(--card));
padding: 0.35rem 0.55rem 0.4rem;
margin-bottom: 0.35rem;
animation: plan-in 0.2s ease-out;
}
@keyframes plan-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.plan {
animation: none;
}
}
.plan-summary {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
background: transparent;
border: none;
cursor: pointer;
padding: 0;
text-align: left;
}
.plan-summary-text {
font-size: 12px;
color: var(--foreground);
flex: 1;
}
.plan-head {
display: flex;
align-items: baseline;
gap: 0.4rem;
margin-bottom: 0.25rem;
}
.plan-head-label {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted-foreground);
}
.plan-head-count {
font-family: var(--font-mono);
font-size: 10px;
color: var(--primary);
}
.plan-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.plan-step {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.1rem 0;
}
.plan-step.running {
background: color-mix(in oklab, var(--primary) 8%, transparent);
margin: 0 -0.3rem;
padding-left: 0.3rem;
padding-right: 0.3rem;
}
.plan-node {
display: inline-flex;
align-items: center;
justify-content: center;
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
color: var(--muted-foreground);
}
.plan-node.done {
color: var(--primary);
}
.plan-node.failed {
color: var(--destructive);
}
.plan-node.running {
color: var(--primary);
}
.plan-title {
font-size: 12px;
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plan-step.running .plan-title {
color: var(--foreground);
font-weight: 500;
}
.plan-elapsed {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 10px;
color: var(--primary);
margin-left: auto;
}
.thinking {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0;
height: 1.5rem;
overflow: hidden;
opacity: 1;
transition: opacity 0.2s ease;
}
.thinking-hidden {
opacity: 0;
pointer-events: none;
}
.thinking.err {
color: var(--destructive);
}
.thinking-text {
font-size: 12px;
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tools {
display: flex;
flex-direction: column;
}
.step-head {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted-foreground);
padding: 0.35rem 0 0.1rem;
}
</style>

View File

@@ -1,585 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte'
import { slide } from 'svelte/transition'
import type { ActivityEntry } from '$lib/stores/activity'
import type { PlanStep } from '$lib/api'
import Spinner from './Spinner.svelte'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import PauseIcon from '@lucide/svelte/icons/pause'
import SlashIcon from '@lucide/svelte/icons/slash'
import WrenchIcon from '@lucide/svelte/icons/wrench'
import MilestoneIcon from '@lucide/svelte/icons/milestone'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
import FlagIcon from '@lucide/svelte/icons/flag'
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
import { openEntityWindow } from '$lib/stores/windows'
// Merged plan + activity timeline, designed for the narrow rail:
// - ordered newest-first: what the agent is doing right now is at the top,
// history flows downward, and the goal sits at the bottom where the task
// began (see the sort in `items`)
// - one continuous vertical "backbone"; every item owns a segment of it,
// colored by state (done = filled primary, running = faint primary,
// pending/future = muted) so the line visibly fills in as work completes
// - plan steps are filled status nodes ON the backbone; their tool calls
// branch off with horizontal stubs
// - flat entries (goal/knowledge/complete/orphan tools) are milestone
// markers on the same backbone
// - the running step auto-expands and the view auto-scrolls to keep the
// current step visible while the agent works (follow mode disengages if
// the operator scrolls down into history, re-engages when streaming
// starts again)
let {
entries,
planSteps: steps,
streaming = false
}: {
entries: ActivityEntry[]
planSteps: PlanStep[]
streaming?: boolean
} = $props()
// Explicit user toggles only — default open state derives from step status
// (running = expanded, everything else = collapsed) so a step collapses
// itself the moment it finishes unless the operator pinned it open.
let stepToggles = $state(new Map<string, boolean>())
let expandedTools = $state(new Set<string>())
function stepOpen(step: PlanStep): boolean {
return stepToggles.get(step.id) ?? step.status === 'running'
}
function toggleStep(step: PlanStep) {
stepToggles.set(step.id, !stepOpen(step))
stepToggles = new Map(stepToggles)
}
// Pin each streaming output pane to its tail as chunks arrive. Keyed by
// tool id because several run entries can be on screen, though only the
// newest one is ever actually streaming.
let liveOutputEls = $state<Record<string, HTMLPreElement | null>>({})
$effect(() => {
// Depend on entries only. liveOutputEls is written by bind:this, so
// tracking it here would let a re-render re-trigger this effect.
const current = entries
untrack(() => {
for (const e of current) {
if (!e.liveOutput) continue
const el = liveOutputEls[e.id]
if (el) el.scrollTop = el.scrollHeight
}
})
})
function toggleTool(id: string) {
if (expandedTools.has(id)) expandedTools.delete(id)
else expandedTools.add(id)
expandedTools = new Set(expandedTools)
}
// ── Timeline model ────────────────────────────────────────────────────────
type TLItem =
| { kind: 'step'; step: PlanStep; tools: ActivityEntry[]; ts: number }
| { kind: 'entry'; entry: ActivityEntry; ts: number }
const items = $derived.by<TLItem[]>(() => {
const stepIds = new Set(steps.map((s) => s.id))
const out: TLItem[] = []
for (const s of steps) {
if (s.status === 'pending' && !entries.some((e) => e.stepSeq === s.seq)) {
// Pending steps with no activity yet still show on the timeline so
// the operator sees what's coming — but only if a plan exists. ts 0
// parks them at the tail of the newest-first sort below (see there).
if (steps.length > 0) {
out.push({ kind: 'step', step: s, tools: [], ts: 0 })
}
continue
}
const tools = entries.filter(
(e) =>
e.stepSeq === s.seq &&
(e.type === 'tool_running' || e.type === 'tool_done' || e.type === 'tool_error')
)
const stepEntry = entries.find((e) => e.id === s.id)
// Timed from the step's own entry, else its earliest tool — so a step
// is placed by when it started, not by its latest activity.
const ts = stepEntry?.timestamp ?? tools[0]?.timestamp ?? Date.now()
// Tools inside a step run newest-first too, matching the outer order.
out.push({ kind: 'step', step: s, tools: [...tools].reverse(), ts })
}
for (const e of entries) {
const isTool = e.type === 'tool_running' || e.type === 'tool_done' || e.type === 'tool_error'
if (isTool && e.stepSeq != null) continue // nested under its step
if (!isTool && stepIds.has(e.id)) continue // rendered as step node
out.push({ kind: 'entry', entry: e, ts: e.timestamp })
}
// Newest first: whatever the agent is doing right now sits at the top of
// the rail, with history flowing downward. The two ts-0 groups fall to
// the bottom for free, which is where both belong in this order: the goal
// (timestamp 0 — where the task started) and not-yet-run plan steps.
// Sorting the latter by their future position would put them *above* the
// running step and push it off the top, which is exactly what this
// ordering exists to prevent. Array.sort is stable, so each group keeps
// its insertion order (plan steps in seq order).
out.sort((a, b) => b.ts - a.ts)
return out
})
// ── Current activity + auto-scroll ────────────────────────────────────────
const currentId = $derived.by<string | null>(() => {
const runningTool = entries.find((e) => e.type === 'tool_running' && e.status === 'running')
if (runningTool) return runningTool.id
const runningStep = steps.find((s) => s.status === 'running')
if (runningStep) return runningStep.id
return null
})
let container = $state<HTMLDivElement | null>(null)
let follow = $state(true)
// Newest-first, so "following the agent" means being parked at the top —
// the mirror of the bottom-anchored follow this had when it ran oldest-first.
function onScroll() {
if (!container) return
follow = container.scrollTop < 80
}
// A new turn re-engages follow mode even if the operator had scrolled up.
let wasStreaming = $state(false)
$effect(() => {
if (streaming && !wasStreaming) follow = true
wasStreaming = streaming
})
// Scroll to the current step/tool whenever it changes (smooth) or when new
// entries land while following (instant, to avoid scroll-queue jank).
$effect(() => {
if (!currentId || !follow || !container) return
container
.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
let lastEntryCount = 0
$effect(() => {
const n = entries.length
if (n === lastEntryCount) return
lastEntryCount = n
if (!follow || !container) return
const target = currentId
? container.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
: null
if (target) target.scrollIntoView({ behavior: 'auto', block: 'nearest' })
else container.scrollTop = 0
})
// ── Presentation helpers ──────────────────────────────────────────────────
// Segment geometry: the backbone's center runs at x=17.5px (node center:
// px-3 = 11.25px at the app's 15px root font-size + half of the 13px node),
// so the 1px line sits at left-17px. Each item's segment spans its full
// height so tools inside an expanded step stay on the line; first/last
// items clip theirs to their node/tool centers so the line never dangles
// past the timeline's ends.
function segClass(
status: string,
isFirst: boolean,
isLast: boolean,
expandedWithTools: boolean
): string {
let color = 'bg-border'
if (status === 'done') color = 'bg-primary/60'
else if (status === 'running') color = 'bg-primary/40'
else if (status === 'failed') color = 'bg-destructive/40'
if (isFirst && isLast) return `${color} top-[13px] h-0`
if (isFirst) return `${color} top-[13px] bottom-0`
if (isLast && expandedWithTools) return `${color} top-0 bottom-[11px]`
if (isLast) return `${color} top-0 bottom-[calc(100%-13px)]`
return `${color} top-0 bottom-0`
}
function entryIcon(entry: ActivityEntry) {
switch (entry.type) {
case 'goal':
return MilestoneIcon
case 'knowledge':
return SparklesIcon
case 'complete':
return FlagIcon
case 'question':
return HelpCircleIcon
default:
return WrenchIcon
}
}
function hhmm(ts: number): string {
if (!ts || ts > Number.MAX_SAFE_INTEGER - 1000) return ''
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
function hhmmss(ts: number): string {
if (!ts) return ''
return new Date(ts).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
function prettyPrint(raw: string): string {
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
</script>
<div class="flex h-full flex-col">
<div class="flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
{#if items.length === 0}
<div class="flex flex-col items-center gap-2 px-3 py-6 text-center">
<svg viewBox="0 0 64 110" class="h-14 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"
/>
<circle cx="32" cy="22" r="4" fill="currentColor">
<animate
attributeName="opacity"
values="0.25;0.9;0.25"
dur="2.4s"
repeatCount="indefinite"
/>
</circle>
<circle cx="32" cy="55" r="4" fill="currentColor">
<animate
attributeName="opacity"
values="0.25;0.9;0.25"
dur="2.4s"
begin="0.6s"
repeatCount="indefinite"
/>
</circle>
<circle cx="32" cy="55" r="4" fill="none" stroke="currentColor" stroke-width="1.5">
<animate
attributeName="r"
values="4;11;4"
dur="2.4s"
begin="0.6s"
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0.6;0;0.6"
dur="2.4s"
begin="0.6s"
repeatCount="indefinite"
/>
</circle>
<circle cx="32" cy="88" r="4" fill="currentColor">
<animate
attributeName="opacity"
values="0.25;0.9;0.25"
dur="2.4s"
begin="1.2s"
repeatCount="indefinite"
/>
</circle>
</svg>
<p class="text-[11px] leading-relaxed text-muted-foreground">Waiting for activity…</p>
</div>
{:else}
<div class="flex flex-col py-1">
{#each items as item, i (item.kind === 'step' ? item.step.id : item.entry.id)}
{@const isFirst = i === 0}
{@const isLast = i === items.length - 1}
{#if item.kind === 'step'}
{@const st = item.step.status}
{@const open = stepOpen(item.step)}
{@const hasDetail = !!item.step.detail?.trim()}
{@const expandable = item.tools.length > 0 || hasDetail}
{@const expandedWithTools = open && item.tools.length > 0}
<!-- Step node on the backbone -->
<div class="relative" data-tl-id={item.step.id}>
<span
class="pointer-events-none absolute left-[17px] w-px {segClass(
st,
isFirst,
isLast,
expandedWithTools
)}"
aria-hidden="true"
></span>
<button
type="button"
class="relative flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-xs {expandable
? 'cursor-pointer hover:bg-muted/30'
: 'cursor-default'} {st === 'running' ? 'bg-primary/5' : ''}"
onclick={() => expandable && toggleStep(item.step)}
aria-expanded={open}
disabled={!expandable}
>
<!-- Filled status node -->
<span
class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full
{st === 'done'
? 'bg-primary'
: st === 'running'
? 'bg-background'
: st === 'failed'
? 'bg-destructive'
: st === 'blocked'
? 'bg-warning/25 border border-warning'
: st === 'skipped' || st === 'replaced'
? 'bg-muted'
: 'bg-background border border-muted-foreground/40'}"
>
{#if st === 'running'}
<span class="absolute -inset-0.5 animate-ping rounded-full bg-primary/30"
></span>
<Spinner class="relative size-3.5 text-primary" />
{:else if st === 'done'}
<CheckIcon class="size-2.5 text-primary-foreground" strokeWidth={3.5} />
{:else if st === 'failed'}
<XIcon class="size-2.5 text-destructive-foreground" strokeWidth={3.5} />
{:else if st === 'blocked'}
<PauseIcon class="size-2 text-warning" strokeWidth={3} />
{:else if st === 'skipped' || st === 'replaced'}
<SlashIcon class="size-2 text-muted-foreground" strokeWidth={3} />
{/if}
</span>
<span
title={item.step.title}
class="min-w-0 flex-1 leading-snug {open
? 'whitespace-normal'
: 'truncate'} {st === 'done'
? 'text-muted-foreground'
: st === 'running'
? 'font-medium text-foreground'
: 'text-muted-foreground'}"
>
{item.step.title}
</span>
{#if hhmm(item.ts)}
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
>{hhmm(item.ts)}</span
>
{/if}
{#if item.tools.length > 0}
<span class="shrink-0 text-muted-foreground/60">
{#if open}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon
class="size-3"
/>{/if}
</span>
{/if}
</button>
{#if expandedWithTools}
<div transition:slide={{ duration: 150 }} class="flex flex-col">
{#each item.tools as tool (tool.id)}
{@const tOpen = expandedTools.has(tool.id) || !!tool.liveOutput}
<div class="relative" data-tl-id={tool.id}>
<!-- Branch stub: backbone → tool -->
<span
class="pointer-events-none absolute left-[17px] top-[9.5px] h-px w-[17px] {tool.status ===
'failed'
? 'bg-destructive/40'
: 'bg-border'}"
aria-hidden="true"
></span>
<button
type="button"
class="flex w-full items-center gap-1.5 py-1 pl-9 pr-3 text-left text-[11px] {tool.args ||
tool.detail ||
tool.liveOutput
? 'cursor-pointer hover:bg-muted/20'
: 'cursor-default'}"
onclick={() =>
(tool.args || tool.detail || tool.liveOutput) && toggleTool(tool.id)}
>
<span class="flex size-3 shrink-0 items-center justify-center">
{#if tool.status === 'running'}
<Spinner class="size-2.5 text-primary" />
{:else if tool.status === 'failed'}
<XIcon class="size-2.5 text-destructive" strokeWidth={3.5} />
{:else}
<CheckIcon class="size-2.5 text-primary/70" strokeWidth={3.5} />
{/if}
</span>
<span
title={tool.description}
class="min-w-0 flex-1 truncate leading-snug {tool.status === 'done'
? 'text-muted-foreground'
: tool.status === 'failed'
? 'text-destructive'
: 'text-foreground/80'}"
>
{tool.description}
</span>
{#if !tool.link}
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50"
>{hhmm(tool.timestamp)}</span
>
{/if}
</button>
{#if tool.link}
<button
type="button"
class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
title="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
aria-label="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
onclick={() => openEntityWindow(tool.link!.slug)}
>
<ExternalLinkIcon class="size-3" />
</button>
{/if}
{#if tOpen}
<div
transition:slide={{ duration: 120 }}
class="flex flex-col gap-1 pb-1.5 pl-[52px] pr-3"
>
<div
class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70"
>
<span class="capitalize">{tool.status}</span>
<span aria-hidden="true">·</span>
<span>{hhmmss(tool.timestamp)}</span>
{#if tool.toolName}<span aria-hidden="true">·</span><code
class="font-mono">{tool.toolName}</code
>{/if}
</div>
{#if tool.args}
<pre
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(
tool.args
)}</pre>
{/if}
{#if tool.liveOutput}
<!-- Streaming while the command runs. Bound so it
can be pinned to the tail as chunks arrive. -->
<pre
bind:this={liveOutputEls[tool.id]}
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{tool.liveOutput}</pre>
{/if}
{#if tool.detail}
<pre
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {tool.status ===
'failed'
? 'text-destructive'
: 'text-muted-foreground'}">{prettyPrint(tool.detail)}</pre>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Flat entry: milestone marker on the backbone -->
{@const e = item.entry}
{@const Icon = entryIcon(e)}
{@const eOpen = expandedTools.has(e.id)}
<div class="relative" data-tl-id={e.id}>
<span
class="pointer-events-none absolute left-[17px] w-px {segClass(
e.status,
isFirst,
isLast,
false
)}"
aria-hidden="true"
></span>
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] {e.args ||
e.detail
? 'cursor-pointer hover:bg-muted/30'
: 'cursor-default'}"
onclick={() => (e.args || e.detail) && toggleTool(e.id)}
>
<span
class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full border bg-background
{e.status === 'failed'
? 'border-destructive text-destructive'
: e.status === 'running'
? 'border-primary text-primary'
: 'border-border text-primary'}"
>
{#if e.status === 'running'}
<Spinner class="size-2.5" />
{:else if e.status === 'failed'}
<XIcon class="size-2" strokeWidth={3.5} />
{:else}
<Icon class="size-2" strokeWidth={2.5} />
{/if}
</span>
<span
title={e.description}
class="min-w-0 flex-1 truncate leading-snug {e.status === 'done'
? 'text-muted-foreground'
: 'text-foreground/80'}"
>
{e.description}
</span>
{#if !e.link}
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
>{hhmm(e.timestamp)}</span
>
{/if}
</button>
{#if e.link}
<button
type="button"
class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
title="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
aria-label="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
onclick={() => openEntityWindow(e.link!.slug)}
>
<ExternalLinkIcon class="size-3" />
</button>
{/if}
{#if eOpen}
<div
transition:slide={{ duration: 120 }}
class="flex flex-col gap-1 pb-1.5 pl-9 pr-3"
>
<div class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70">
<span class="capitalize">{e.status}</span>
<span aria-hidden="true">·</span>
<span>{hhmmss(e.timestamp)}</span>
{#if e.toolName}<span aria-hidden="true">·</span><code class="font-mono"
>{e.toolName}</code
>{/if}
</div>
{#if e.args}
<pre
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(
e.args
)}</pre>
{/if}
{#if e.detail}
<pre
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {e.status ===
'failed'
? 'text-destructive'
: 'text-muted-foreground'}">{prettyPrint(e.detail)}</pre>
{/if}
</div>
{/if}
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>

View File

@@ -80,25 +80,30 @@
<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'}"
class="group absolute flex flex-col items-center gap-1.5 p-1.5 pointer-events-auto select-none transition-transform focus-visible:outline-2 focus-visible:outline-ring {dragging
? 'z-50 cursor-grabbing'
: 'cursor-pointer hover:-translate-y-0.5'}"
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"
class="relative flex size-11 items-center justify-center border transition-all {dragging
? 'border-foreground bg-foreground text-background shadow-[4px_4px_0_0_var(--foreground)]'
: 'border-border bg-card text-foreground group-hover:border-foreground group-hover:bg-foreground group-hover:text-background'}"
>
<app.icon class="size-5" />
<app.icon class="size-5 transition-colors" />
{#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"
class="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center border border-background 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>
<span
class="max-w-full truncate text-[10px] uppercase tracking-wider text-muted-foreground transition-colors group-hover:text-foreground"
>{app.title}</span
>
</button>

View File

@@ -26,6 +26,7 @@
{initialDraft}
messages={[]}
streaming={false}
working={false}
connectionState="connected"
{onSend}
onCancel={() => {}}

View File

@@ -53,10 +53,10 @@
}
</script>
<div class="flex h-11 shrink-0 items-center gap-1.5 border-t bg-muted/30 px-2">
<div class="flex h-11 shrink-0 items-center gap-1.5 border-t border-border bg-background 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"
class="flex shrink-0 items-center justify-center border border-border p-1.5 text-muted-foreground transition-colors hover:border-foreground hover:bg-foreground hover:text-background"
onclick={toggleShowDesktop}
title="Show desktop"
>
@@ -65,7 +65,7 @@
<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">
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto py-1.5">
{#each buttons as win (win.id)}
{@const Icon = iconFor(win.id)}
{@const badge = badgeFor(win.id)}
@@ -73,12 +73,12 @@
<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 ===
class="flex h-8 max-w-56 items-center gap-1.5 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 ===
? 'border-foreground bg-foreground text-background'
: 'border-border bg-background text-muted-foreground hover:border-foreground hover:bg-foreground hover:text-background'} {win.stage ===
'minimized'
? 'opacity-60'
? 'opacity-50'
: ''}"
onclick={() => toggle(win.id, win)}
title={win.title}
@@ -87,7 +87,7 @@
<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"
class="flex h-3.5 min-w-3.5 shrink-0 items-center justify-center border border-background bg-destructive px-1 text-[9px] font-semibold text-destructive-foreground"
>
{badge > 99 ? '99+' : badge}
</span>
@@ -95,7 +95,7 @@
</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"
class="win-ctrl absolute -top-1.5 -right-1.5 hidden size-4 group-hover/tb:flex"
onclick={(e) => {
e.stopPropagation()
wm.close(win.id)
@@ -113,7 +113,7 @@
<div class="flex shrink-0 items-center gap-0.5">
<button
type="button"
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
class="flex items-center justify-center border border-border p-1.5 text-muted-foreground transition-colors hover:border-foreground hover:bg-foreground hover:text-background"
onclick={() => toggleTheme()}
title="Cycle theme ({THEME_LABELS[getTheme()]})"
aria-label="Cycle theme, currently {THEME_LABELS[getTheme()]}"
@@ -122,12 +122,12 @@
</button>
<button
type="button"
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
class="flex items-center justify-center border border-border p-1.5 text-muted-foreground transition-colors hover:border-foreground hover:bg-foreground hover:text-background"
onclick={() => openAppWindow('settings')}
title="Settings"
>
<SettingsIcon class="size-4" />
</button>
<span class="px-1.5 text-[11px] text-muted-foreground select-none">{VERSION}</span>
<span class="px-1.5 font-mono text-[11px] text-muted-foreground select-none">{VERSION}</span>
</div>
</div>

View File

@@ -13,6 +13,7 @@
wm,
dk,
wmState,
windowKeys,
openEntityWindow,
NEW_TASK_WINDOW_ID,
SESSION_PREFIX,
@@ -59,7 +60,7 @@
</script>
<div use:dk.desktop class="absolute inset-0 z-40 pointer-events-none">
{#each $wmState.order as id (id)}
{#each $windowKeys as id (id)}
{@const win = $wmState.windows[id]}
{@const appId = appIdFromWindowId(id)}
{@const app = appId ? $appById.get(appId) : undefined}
@@ -67,18 +68,18 @@
<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"
class="flex h-9 shrink-0 cursor-move items-center justify-between gap-2 overflow-hidden border-b border-border bg-background px-3"
>
<span
data-wm-title
class="flex min-w-0 flex-1 items-center self-stretch truncate font-mono text-xs font-medium"
class="min-w-0 flex-1 truncate font-mono text-xs font-medium"
>{win.title}</span
>
<div class="flex shrink-0 items-center gap-0.5">
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
data-wm-minimize
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
class="win-ctrl flex size-6"
aria-label="Minimize {win.title}"
>
<MinusIcon class="size-3.5" />
@@ -86,7 +87,7 @@
<button
type="button"
data-wm-maximize
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
class="win-ctrl flex size-6"
aria-label="Maximize {win.title}"
>
<Maximize2Icon class="size-3.5" />
@@ -94,7 +95,7 @@
<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"
class="win-ctrl flex size-6"
aria-label="Close {win.title}"
>
<XIcon class="size-3.5" />

View File

@@ -188,7 +188,7 @@
<div class="flex flex-wrap gap-1.5">
{#each stats.topTags as [tag, count] (tag)}
<span
class="flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground"
class="flex items-center gap-1 border px-2 py-0.5 text-[11px] text-muted-foreground"
>
{tag}
<span class="text-foreground/70 tabular-nums">{count}</span>

View File

@@ -377,7 +377,7 @@
{#if item.tags.length}
<div class="mb-3 flex max-w-[68ch] flex-wrap gap-1.5">
{#each item.tags as t (t)}<span
class="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground"
class="border px-2 py-0.5 text-[11px] text-muted-foreground"
>{t}</span
>{/each}
</div>

View File

@@ -25,7 +25,7 @@ vi.mock('./execstream', () => ({
liveExecutionOutputFor: vi.fn(() => writable(null))
}))
import { computeActivityLog } from './activity'
import { computeActivityLog, toolResultSummary } from './activity'
import type { ChatMessage } from './chat'
import type { PlanStep, Session } from '$lib/api'
@@ -46,6 +46,14 @@ function toolResult(name: string, id: string): NonNullable<ChatMessage['tools']>
return { type: 'tool_result', name, id, result: 'ok' }
}
function toolUse(
name: string,
id: string,
args?: Record<string, unknown>
): NonNullable<ChatMessage['tools']>[number] {
return { type: 'tool_use', name, id, args }
}
describe('computeActivityLog timestamps (P0.2)', () => {
it('uses the message created_at for persisted tool calls, not a fabricated spread', () => {
const created = '2026-07-29T20:08:10Z'
@@ -102,3 +110,138 @@ describe('computeActivityLog timestamps (P0.2)', () => {
expect(entries.find((e) => e.id === 's1')!.timestamp).toBe(new Date(started).getTime())
})
})
// F4 (plan 2026-08-03): the timeline must be generation-aware. A re-proposed
// task persists every generation's propose_plan / update_plan_step calls; before
// the fix that produced N "Proposed plan" entries and tagged current-gen tools
// with seqs inferred from superseded generations. Only the LAST propose_plan is
// the live plan; earlier ones collapse to one "Earlier plan revised" marker, and
// step-attribution only follows the current generation.
describe('computeActivityLog generation awareness (F4)', () => {
it('renders one Proposed plan + a revised marker, and attributes tools to the current gen only', () => {
const created = '2026-07-29T20:08:10Z'
// Generation 1: propose → step 1 running → run. Then generation 2 (re-plan).
const gen1 = msg({
id: 'm1',
created_at: created,
tools: [
toolUse('propose_plan', 'p1'),
toolUse('update_plan_step', 'u1', { seq: 1, status: 'running' }),
toolResult('run', 'r1')
]
})
const gen2 = msg({
id: 'm2',
created_at: created,
tools: [
toolUse('propose_plan', 'p2'),
toolUse('update_plan_step', 'u2', { seq: 1, status: 'running' }),
toolResult('run', 'r2')
]
})
// Current-generation plan step (gen 2), as fetchPlan (MAX generation) returns.
const steps: PlanStep[] = [
{ id: 's-gen2', seq: 1, title: 'Gen2 step', detail: '', status: 'done', started_at: created }
]
const entries = computeActivityLog([gen1, gen2], steps, null, new Map())
// Exactly one "Proposed plan" (the current generation's).
const proposals = entries.filter((e) => e.description === 'Proposed plan')
expect(proposals.length).toBe(1)
// One collapsed marker for the superseded generation(s).
expect(entries.filter((e) => e.description === 'Earlier plan revised').length).toBe(1)
// The current-gen run is tagged with step 1 (from gen2's update_plan_step).
const r2 = entries.find((e) => e.id === 'r2')
expect(r2, 'gen2 run entry should exist').toBeDefined()
expect(r2!.stepSeq).toBe(1)
// The superseded-gen run is NOT tagged with a current-gen step (its
// update_plan_step belonged to the replaced generation).
const r1 = entries.find((e) => e.id === 'r1')
expect(r1, 'gen1 run entry should exist').toBeDefined()
expect(r1!.stepSeq).toBeUndefined()
})
it('plan-less Q&A still attributes nothing to a step (no propose_plan at all)', () => {
const m = msg({ id: 'm1', tools: [toolResult('get_entity', 't1')] })
const entries = computeActivityLog([m], [], null, new Map())
expect(entries.filter((e) => e.description === 'Proposed plan')).toHaveLength(0)
expect(entries.find((e) => e.id === 't1')!.stepSeq).toBeUndefined()
})
})
// toolResultSummary (chat interaction overhaul): a one-line, humanized outcome
// per tool so each inline tool line reads as a result instead of raw JSON.
describe('toolResultSummary', () => {
type TR = NonNullable<ChatMessage['tools']>[number]
const done = (name: string, result: unknown, args?: Record<string, unknown>): TR => ({
type: 'tool_result',
name,
id: name,
result,
args
})
it('is empty for a still-running call and for an errored one', () => {
expect(toolResultSummary({ type: 'tool_use', name: 'run', id: 'r' })).toBe('')
expect(
toolResultSummary({ type: 'tool_result', name: 'run', id: 'r', error: 'boom' })
).toBe('')
})
it('parses run exit status', () => {
expect(
toolResultSummary(done('run', 'run on lxc:caddy: ERROR exit status 1'))
).toContain('exit 1')
})
it('summarizes a clean run with its first line', () => {
const s = toolResultSummary(done('run', 'Active: active (running)'))
expect(s.startsWith('ok')).toBe(true)
expect(s).toContain('active')
})
it('formats get_entity as slug (health)', () => {
expect(
toolResultSummary(done('get_entity', { slug: 'host:hubris', health: 'healthy' }))
).toBe('host:hubris (healthy)')
})
it('counts list results', () => {
expect(
toolResultSummary(done('list_entities', { entities: Array(10).fill({}) }))
).toBe('10 entities')
expect(toolResultSummary(done('list_lxcs', { containers: [1, 2] }))).toBe('2 containers')
})
it('formats fleet health counts', () => {
expect(
toolResultSummary(
done('get_health_summary', { health: { healthy: 5, degraded: 1, down: 0, unknown: 2 } })
)
).toBe('healthy 5 · degraded 1 · down 0 · unknown 2')
})
it('extracts the knowledge slug from upsert_knowledge', () => {
expect(
toolResultSummary(done('upsert_knowledge', 'Saved document:nomos/foo-bar to the DB'))
).toBe('recorded document:nomos/foo-bar')
})
it('formats update_plan_step from args', () => {
expect(
toolResultSummary(done('update_plan_step', 'ok', { seq: 2, status: 'done' }))
).toBe('step 2 → done')
})
it('counts proposed plan steps', () => {
expect(toolResultSummary(done('propose_plan', { steps: [{}, {}, {}] }))).toBe('3 steps')
})
it('falls back to the first line for unmapped tools', () => {
expect(toolResultSummary(done('some_new_tool', 'first line\nsecond line'))).toBe('first line')
})
})

View File

@@ -149,13 +149,45 @@ export function computeActivityLog(
// Tool calls (from messages). Tag each tool with the plan step that's
// currently running when it fires.
//
// Generation awareness (plan 2026-08-03 F4): a re-proposed task persists
// every generation's propose_plan/update_plan_step calls. Without scoping,
// the timeline rendered N "Proposed plan" entries and inferred
// currentStepSeq from superseded generations — tools landed under the wrong
// (current-gen) step and it read as "several plans, some never run." So:
// only the LAST propose_plan is the live plan; earlier ones collapse to a
// single "Earlier plan revised" marker, and update_plan_step step-tracking
// only applies to the current generation.
let lastPlanMi = -1
let lastPlanTi = -1
let planCount = 0
for (let mi = 0; mi < $msgs.length; mi++) {
for (let ti = 0; ti < $msgs[mi].tools.length; ti++) {
if ($msgs[mi].tools[ti].name === 'propose_plan') {
planCount++
lastPlanMi = mi
lastPlanTi = ti
}
}
}
const revised = planCount > 1
let currentStepSeq = 0
let entryIdx = 0
// No plan at all (plan-less Q&A) → treat the whole transcript as current.
let sawCurrentPlan = planCount === 0
let emittedRevised = false
for (let mi = 0; mi < $msgs.length; mi++) {
const msgTs = tsOf($msgs[mi].created_at)
for (const t of $msgs[mi].tools) {
// Track current step from update_plan_step calls
if (t.type === 'tool_use' && t.name === 'update_plan_step') {
for (let ti = 0; ti < $msgs[mi].tools.length; ti++) {
const t = $msgs[mi].tools[ti]
const isLastPlan = mi === lastPlanMi && ti === lastPlanTi
if (isLastPlan) sawCurrentPlan = true
// Track current step ONLY from the current generation's
// update_plan_step calls; a superseded generation's seqs would tag
// tools with the wrong (current-gen) step.
if (sawCurrentPlan && t.type === 'tool_use' && t.name === 'update_plan_step') {
const s = typeof t.args?.seq === 'number' ? t.args.seq : undefined
const status = typeof t.args?.status === 'string' ? t.args.status : undefined
if (s && status === 'running') currentStepSeq = s
@@ -163,6 +195,23 @@ export function computeActivityLog(
currentStepSeq = 0
}
// Skip superseded-generation propose_plan entries; emit one collapsed
// "revised" marker so a re-proposal stays visible without reading as a
// second active plan.
if (t.name === 'propose_plan' && !isLastPlan) {
if (revised && !emittedRevised) {
emittedRevised = true
entries.push({
id: `plan_revised_${mi}_${ti}`,
type: 'plan',
description: 'Earlier plan revised',
timestamp: freeze(`plan_revised_${mi}_${ti}`, msgTs),
status: 'done'
})
}
continue
}
const label = toolActivityLabel(t)
const stepTag = currentStepSeq > 0 ? currentStepSeq : undefined
const id = t.id ?? `tool_${mi}_${entryIdx++}`
@@ -384,3 +433,148 @@ export function toolActivityLabel(t: ToolCallResult): string {
return t.name.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase())
}
}
// ── toolResultSummary ────────────────────────────────────────────────────
// A one-line, humanized summary of a tool's RESULT (the Claude-Code-style
// "exit 0 · <line>" / "host:hubris (healthy)" affordance) so each tool line
// in the inline trace reads as an outcome instead of a raw JSON blob. Empty
// for a still-running call (no result yet) or an errored one (the error is
// surfaced separately). Best-effort by tool name; the fallback is the first
// non-empty line of the stringified result, truncated — never blank (the
// expandable raw detail is always one click away).
function resultAsString(r: unknown): string {
if (r == null) return ''
if (typeof r === 'string') return r
try {
return JSON.stringify(r)
} catch {
return String(r)
}
}
function firstLine(s: string, max = 80): string {
const line = s
.split(/\r?\n/)
.map((l) => l.trim())
.find((l) => l.length > 0) ?? ''
return line.length > max ? `${line.slice(0, max - 1)}` : line
}
function resultArray(r: unknown): unknown[] | null {
if (Array.isArray(r)) return r
if (r && typeof r === 'object') {
const o = r as Record<string, unknown>
for (const k of [
'entities',
'results',
'relations',
'steps',
'items',
'containers',
'docs',
'questions',
'signals',
'events',
'patterns',
'skills'
]) {
if (Array.isArray(o[k])) return o[k] as unknown[]
}
}
return null
}
function num(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null
}
function plural(n: number, singular: string, pluralForm = `${singular}s`): string {
return `${n} ${n === 1 ? singular : pluralForm}`
}
export function toolResultSummary(t: ToolCallResult): string {
if (t.type === 'tool_use') return '' // still running
if (t.error) return '' // error surfaced separately
const args = t.args ?? {}
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
const obj = (r: unknown): Record<string, unknown> | null =>
r && typeof r === 'object' && !Array.isArray(r) ? (r as Record<string, unknown>) : null
switch (t.name) {
case 'run': {
const s = resultAsString(t.result)
const m = s.match(/exit (?:status )?(\d+)/i)
const tag = m ? `exit ${m[1]}` : /error/i.test(s) ? 'error' : 'ok'
const rest = firstLine(s.replace(/[\s\S]*exit (?:status )?\d+/i, ''), 60)
return rest ? `${tag} · ${rest}` : tag
}
case 'get_entity': {
const o = obj(t.result)
const slug = str(o?.slug) || str(args.slug_or_id)
const health = str(o?.health) || str(o?.state)
return [slug, health && `(${health})`].filter(Boolean).join(' ') || 'found'
}
case 'get_relations': {
const a = resultArray(t.result)
return a ? plural(a.length, 'relation') : 'done'
}
case 'list_entities':
case 'list_lxcs': {
const a = resultArray(t.result)
if (!a) return 'done'
return t.name === 'list_lxcs'
? plural(a.length, 'container')
: plural(a.length, 'entity', 'entities')
}
case 'get_health_summary': {
const o = obj(t.result)
const h = (o?.health && obj(o.health)) || o
if (h) {
const parts = ['healthy', 'degraded', 'down', 'unknown']
.map((k) => {
const n = num((h as Record<string, unknown>)[k])
return n != null ? `${k} ${n}` : null
})
.filter((p): p is string => p != null)
if (parts.length) return parts.join(' · ')
}
return 'done'
}
case 'get_state_snapshot': {
const o = obj(t.result)
const drift = num(o?.drift ?? o?.drift_count)
return drift != null ? plural(drift, 'drift') : 'done'
}
case 'search_knowledge':
case 'get_entity_knowledge':
case 'get_patterns':
case 'get_skills': {
const a = resultArray(t.result)
return a ? plural(a.length, 'result') : firstLine(resultAsString(t.result)) || 'done'
}
case 'upsert_knowledge': {
const m = resultAsString(t.result).match(/[a-z]+:nomos\/[a-z0-9-]+/)
return m ? `recorded ${m[0]}` : 'recorded'
}
case 'update_plan_step': {
const seq = num(args.seq)
const status = str(args.status)
if (seq != null && status) return `step ${seq}${status}`
return status || 'updated'
}
case 'propose_plan': {
const a = resultArray(t.result) ?? resultArray(args.steps)
return a ? plural(a.length, 'step') : 'planned'
}
case 'set_goal':
return 'goal set'
case 'complete_task':
return str(args.outcome) || 'complete'
case 'ask_operator':
return 'asked'
case 'ping_service': {
const s = resultAsString(t.result).toLowerCase()
return /ok|reachable|up|healthy/.test(s) ? 'reachable' : firstLine(s, 40) || 'done'
}
case 'get_execution_status': {
const o = obj(t.result)
return str(o?.state) || firstLine(resultAsString(t.result), 40) || 'done'
}
default:
return firstLine(resultAsString(t.result)) || 'done'
}
}

View File

@@ -25,6 +25,7 @@ export interface ChatMessage {
id: string
role: 'user' | 'assistant'
text: string
thinking?: string
tools: ToolCallResult[]
pendingApprovals: PendingApproval[]
created_at?: string
@@ -72,6 +73,21 @@ function mid(): string {
return crypto.randomUUID()
}
// dropOptimisticAssistantBubble removes the trailing empty assistant message
// that sendSessionMessage/startTask optimistically append — used when a turn is
// QUEUED behind an in-flight one (plan 2026-08-03 F2): no live assistant stream
// is attached, so the empty placeholder must go (otherwise it lingers as a
// blank bubble). Shared so the guard can't drift between the two call sites.
function dropOptimisticAssistantBubble(messages: Writable<ChatMessage[]>): void {
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant' && last.text === '' && last.tools.length === 0) {
return ms.slice(0, -1)
}
return ms
})
}
export const messages = writable<ChatMessage[]>([])
export const streaming = writable(false)
export const connectionState = writable<'connected' | 'disconnected' | 'reconnecting'>('connected')
@@ -208,6 +224,7 @@ function toChatMessages(msgs: Message[]): ChatMessage[] {
id: m.id,
role: m.role as 'user' | 'assistant',
text: content?.text ?? '',
thinking: content?.thinking ?? undefined,
tools,
pendingApprovals: extractApprovals(tools),
created_at: m.created_at
@@ -215,6 +232,16 @@ function toChatMessages(msgs: Message[]): ChatMessage[] {
})
}
function chatMessagesChanged(a: ChatMessage[], b: ChatMessage[]): boolean {
if (a.length !== b.length) return true
for (let i = 0; i < a.length; i++) {
if (a[i].id !== b[i].id || a[i].role !== b[i].role || a[i].text !== b[i].text || a[i].tools.length !== b[i].tools.length) {
return true
}
}
return false
}
export async function loadSessionMessages(sessionId: string) {
currentSession.set(sessionId)
// This is a fresh view of sessionId's current (REST-loaded) state — reset
@@ -255,15 +282,11 @@ function startPolling(sessionId: string) {
if (pollingSessionId !== sessionId || get(currentSession) !== sessionId) return
const msgs = await fetchMessages(sessionId)
if (get(streaming) || pollingSessionId !== sessionId) return // re-check: the fetch itself takes time
// No cheap "anything new?" check: the auto-continuation worker updates a
// placeholder message IN PLACE as each tool call lands (see
// cmd/nomos/continue.go), so the message COUNT stays the same while the
// content changes — a length-only diff (the previous version of this
// code) never detected those updates and progress looked frozen even
// though the backend was actively working. Just re-set every tick;
// Svelte's own diffing keeps the actual re-render cheap.
sessionMessages.set(msgs)
messages.set(toChatMessages(msgs))
const incoming = toChatMessages(msgs)
if (chatMessagesChanged(get(messages), incoming)) {
sessionMessages.set(msgs)
messages.set(incoming)
}
}, 3000)
}
@@ -386,7 +409,15 @@ export function sendMessage(text: string) {
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: ev.data }
if (ev.is_thinking) {
ms[ms.length - 1] = {
...last,
thinking: (last.thinking || '') + ev.data,
text: ''
}
} else {
ms[ms.length - 1] = { ...last, text: ev.data }
}
}
return [...ms]
})
@@ -581,7 +612,10 @@ function startSessionPolling(sessionId: string) {
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))
const incoming = toChatMessages(msgs)
if (chatMessagesChanged(get(chat.messages), incoming)) {
chat.messages.set(incoming)
}
// F3 safety net: if we're recovering from a dropped SSE but the
// session's task has already reached a turn-ended status, clear the
// stuck disconnected/streaming flags. Catches the edge where the
@@ -657,6 +691,16 @@ export function sendSessionMessage(sessionId: string, text: string) {
sessionId,
(ev: ChatEvent) => {
if (ev.type === 'session') return // sessionId is already known for a window
if (ev.type === 'queued') {
// This message was queued behind an in-flight turn (plan 2026-08-03
// F2): no assistant stream is attached to this response. Drop the
// optimistic empty assistant bubble so the user message is the last
// thing on screen — the thread then shows a "Queued" hint while the
// session is working, and the poller surfaces the queued turn's
// result once it runs server-side.
dropOptimisticAssistantBubble(chat.messages)
return
}
if (ev.type === 'tool_use') {
const tr: ToolCallResult = {
type: 'tool_use',
@@ -703,7 +747,15 @@ export function sendSessionMessage(sessionId: string, text: string) {
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: ev.data }
if (ev.is_thinking) {
ms[ms.length - 1] = {
...last,
thinking: (last.thinking || '') + ev.data,
text: ''
}
} else {
ms[ms.length - 1] = { ...last, text: ev.data }
}
}
return [...ms]
})
@@ -791,6 +843,13 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
function apply(ev: ChatEvent) {
const c = chat
if (!c || !sessionId) return
if (ev.type === 'queued') {
// Defensive: a brand-new task won't normally queue (its session has no
// in-flight turn), but handle it symmetrically with sendSessionMessage —
// drop the optimistic empty assistant bubble. See plan 2026-08-03 F2.
dropOptimisticAssistantBubble(c.messages)
return
}
if (ev.type === 'tool_use') {
const tr: ToolCallResult = {
type: 'tool_use',
@@ -837,7 +896,15 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: ev.data }
if (ev.is_thinking) {
ms[ms.length - 1] = {
...last,
thinking: (last.thinking || '') + ev.data,
text: ''
}
} else {
ms[ms.length - 1] = { ...last, text: ev.data }
}
}
return [...ms]
})

View File

@@ -42,6 +42,6 @@ export function toggleTheme(): Theme {
}
export const THEME_LABELS: Record<Theme, string> = {
light: 'Terracotta',
dark: 'Carbon'
light: 'Light',
dark: 'Dark'
}

View File

@@ -51,6 +51,22 @@ export const dk = createDesktop(wm, {
})
export const wmState = wmStore(wm)
// Stable insertion-order window IDs — unlike $wmState.order (which reorders on
// focus/raise), this only changes when a window is opened or closed. Used by
// WindowLayer's {#each} so the DOM order stays stable; wmkit handles visual
// stacking via z-index in syncAll(). Without this, every focus change moves
// <section> elements in the DOM, which resets scroll positions of scrollable
// children in Chrome.
let _lastKeys: string[] = []
export const windowKeys = derived(wmState, ($s) => {
const keys = Object.keys($s.windows)
if (keys.length === _lastKeys.length && keys.every((k, i) => k === _lastKeys[i])) {
return _lastKeys
}
_lastKeys = keys
return keys
})
// The session id backing whichever task/chat window currently has focus, or
// null when no task window is focused (Tasks app, an entity window, or
// nothing at all). The desktop mascot's stimuli (stimuli.ts) key off this so

View File

@@ -1,6 +1,6 @@
import { writable, derived, get, type Writable, type Readable } from 'svelte/store'
import { liveEvents, subscribeEvents } from './events'
import { currentSession, sessions, loadSessions } from './chat'
import { currentSession, sessions, loadSessions, chatFor, streaming } from './chat'
import {
fetchPlan,
fetchQuestions,
@@ -271,10 +271,13 @@ export function startWorkspace(): () => void {
if (!sid) return
// Oldest-first application so ordering (e.g. plan.step.started before
// .finished) is preserved.
let planAffecting = false
for (const e of fresh.slice().reverse()) {
applyEventTo(globalWorkspace, sid, e)
applyHealthChangedTo(globalWorkspace, e)
if (e.correlation_id === sid && STATUS_AFFECTING.has(e.type)) planAffecting = true
}
if (planAffecting) schedulePlanRefetch(globalWorkspace, sid)
})
return () => {
@@ -306,12 +309,62 @@ export function taskFor(sessionId: string): Readable<Session | null> {
return derived(sessions, ($sessions) => $sessions.find((s) => s.id === sessionId) ?? null)
}
// Session statuses where the server is actively running a turn for this task.
// Deliberately EXCLUDES `awaiting_input` (paused for the operator) and the
// terminal states (done/failed/abandoned). This is the reliable "the agent is
// working" truth that survives a dropped SSE stream or an autonomous/background
// turn (which has no chat stream at all) — see plan 2026-08-03 F1.
const ACTIVE_TURN_STATUS = new Set(['planning', 'executing'])
function isWorking($streaming: boolean, $task: Session | null): boolean {
return $streaming || (!!$task && !!$task.status && ACTIVE_TURN_STATUS.has($task.status))
}
// taskWorking(sessionId): true while this session has a live stream OR its
// server-side status says a turn is running. Used by the chat window's
// "working" indicator, trace running state, and the activity spinner so a
// background/long/desynced turn still looks alive (the symptom: "can't tell
// the agent is working").
export function taskWorking(sessionId: string): Readable<boolean> {
const chat = chatFor(sessionId)
return derived([chat.streaming, taskFor(sessionId)], ([$s, $t]) => isWorking($s, $t))
}
// Global "current session" working signal for the main view's panel.
export const currentWorking = derived(
[streaming, currentTask],
([$s, $t]) => isWorking($s, $t)
)
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)
}
// Self-heal for the plan panel (plan 2026-08-03 F4): plan steps are otherwise
// driven ONLY by live plan.proposed/plan.step.* events plus a one-time hydrate
// on mount. If an event is missed (window opened mid-turn, a brief events-
// stream gap), the panel freezes on a stale generation. Refetching the plan
// (current generation) on any task-lifecycle event makes it converge back to
// truth. Debounced per session since several of these land in one burst.
const planRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>()
function schedulePlanRefetch(ws: WorkspaceState, sessionId: string) {
const existing = planRefreshTimers.get(sessionId)
if (existing) clearTimeout(existing)
planRefreshTimers.set(
sessionId,
setTimeout(async () => {
planRefreshTimers.delete(sessionId)
try {
ws.planSteps.set(await fetchPlan(sessionId))
} catch {
// network blip — the next lifecycle event retries
}
}, 400)
)
}
export function startSessionWorkspace(sessionId: string): () => void {
const ws = workspaceFor(sessionId)
const unsub = subscribeEvents()
@@ -327,10 +380,13 @@ export function startSessionWorkspace(sessionId: string): () => void {
if (maxId <= lastSeen) return
const fresh = evs.filter((e) => e.id > lastSeen)
lastSeen = maxId
let planAffecting = false
for (const e of fresh.slice().reverse()) {
applyEventTo(ws, sessionId, e)
applyHealthChangedTo(ws, e)
if (e.correlation_id === sessionId && STATUS_AFFECTING.has(e.type)) planAffecting = true
}
if (planAffecting) schedulePlanRefetch(ws, sessionId)
})
return () => {

View File

@@ -26,6 +26,7 @@ export interface ChatTextDeltaEvent {
export interface ChatTextEvent {
type: 'text'
data: string
is_thinking?: boolean
}
export interface ChatDoneEvent {
@@ -39,6 +40,14 @@ export interface ChatErrorEvent {
data: string
}
// The turn was queued behind an in-flight turn for this session (plan
// 2026-08-03 F2). No live assistant stream follows in this response; the
// queued turn runs server-side when the gate frees and the poller surfaces it.
export interface ChatQueuedEvent {
type: 'queued'
data: string // session id
}
export type ChatEvent =
| ChatSessionEvent
| ChatToolUseEvent
@@ -47,6 +56,7 @@ export type ChatEvent =
| ChatTextEvent
| ChatDoneEvent
| ChatErrorEvent
| ChatQueuedEvent
// ---- Tool call result (merged from tool_use + tool_result SSE pairs) ----
@@ -62,12 +72,18 @@ export interface ToolCallResult {
// running, so the tool card can show output as it arrives instead of all at
// once when the tool_result lands. Not present on persisted/historical calls.
liveOutput?: string
// Plan step this call belongs to (current generation only). Attached by
// ChatThread from the activity log so the inline trace can group a turn's
// tool calls under their step. Undefined for orphan calls (no plan / older
// generation / plan-less Q&A).
stepSeq?: number
}
// ---- Message content (persisted messages from /agent/sessions/:id) ----
export interface MessageContent {
text?: string
thinking?: string
tool_calls?: ToolCallResult[]
}

View File

@@ -7,6 +7,7 @@
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig } from '$lib/config'
import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc'
import ConfigBackground from '$lib/components/ConfigBackground.svelte'
import RasterImage from '$lib/components/RasterImage.svelte'
import type { WailsGlobal } from '$lib/types'
let { onConnected, onCancel }: { onConnected: () => void; onCancel?: () => void } = $props()
@@ -93,11 +94,12 @@
<div class="relative z-10 flex h-full items-center justify-center p-6">
<div
class="w-full max-w-[26rem] space-y-8 rounded-2xl border border-white/8 bg-card/60 p-8 shadow-2xl shadow-black/40 backdrop-blur-xl"
class="w-full max-w-[26rem] space-y-8 border border-border bg-card p-8 shadow-[3px_3px_0_0_var(--border)]"
>
<!-- Logo + heading -->
<div class="flex flex-col items-center gap-4">
<svg viewBox="0 0 91 100" class="h-16 w-16 fill-white/90" aria-hidden="true">
<RasterImage src="/mascot/egg-idle.png" alt="" width={72} bias={16} />
<svg viewBox="0 0 91 100" class="h-12 w-12 fill-foreground/90" aria-hidden="true">
<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"
/>
@@ -128,7 +130,7 @@
{#if showOidcContinue}
<!-- OIDC authenticated state -->
<div
class="flex flex-col items-center gap-3 rounded-xl border border-white/5 bg-background/40 p-5"
class="flex flex-col items-center gap-3 border border-border bg-background/40 p-5"
>
<p class="text-sm text-muted-foreground">
Logged in as <span class="font-semibold text-foreground">{oidcUser}</span>

View File

@@ -99,9 +99,9 @@
<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'}"
class="border px-2.5 py-1 text-xs transition-colors {filter === f.id
? 'border-foreground bg-foreground text-background'
: 'border-border text-muted-foreground hover:border-foreground hover:text-foreground'}"
>
{f.label}
<span class="ml-1 opacity-60">{counts[f.id] ?? 0}</span>

224
web/vendor/LICENSE vendored Normal file
View File

@@ -0,0 +1,224 @@
"Commons Clause" License Condition v1.0
The Software is provided to you by the Licensor under the License, as defined
below, subject to the following condition.
Without limiting other conditions in the License, the grant of rights under the
License will not include, and the License does not grant to you, the right to
Sell the Software.
For purposes of the foregoing, "Sell" means practicing any or all of the rights
granted to you under the License to provide to third parties, for a fee or other
consideration (including without limitation fees for hosting or consulting/
support services related to the Software), a product or service whose value
derives, entirely or substantially, from the functionality of the Software. Any
license notice or attribution required by the License must also include this
Commons Clause License Condition notice.
Software: Orby
License: Apache License 2.0
Licensor: Joan Sterjo
-------------------------------------------------------------------------------
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

7
web/vendor/NOTICE vendored Normal file
View File

@@ -0,0 +1,7 @@
Orby
Copyright 2026 Joan Sterjo
This product includes software developed by Joan Sterjo.
The Apache License 2.0 grant is subject to the Commons Clause License
Condition v1.0. See LICENSE for the complete terms.

943
web/vendor/README.md vendored Normal file
View File

@@ -0,0 +1,943 @@
<p align="center">
<img
src="./docs/assets/readme-hero.svg"
alt="Orby — A procedural glyph engine by Joan Sterjo, shown as a deterministic pixel field"
width="100%"
/>
</p>
<h1 align="center">Orby</h1>
<p align="center">
<strong>A procedural glyph engine by Joan Sterjo.</strong><br />
Design one visual identity, invoke semantic states from product code, and keep
every transition responsive, reproducible, and alive.
</p>
<p align="center">
<a href="https://joansterjo-celonis.github.io/Procedural-glyph-engine/"><strong>Open the live Studio</strong></a>
·
<a href="https://joansterjo-celonis.github.io/Procedural-glyph-engine/#download">Download v5.0.0</a>
·
<a href="./docs/QUICKSTART.md">Quick-start guide</a>
·
<a href="./examples/README.md">Runnable examples</a>
</p>
<p align="center">
<img alt="Version 5.0.0" src="https://img.shields.io/badge/version-5.0.0-555555?style=flat-square&amp;labelColor=111111" />
<img alt="Canvas 2D and native ESM" src="https://img.shields.io/badge/runtime-Canvas_2D_%C2%B7_ESM-555555?style=flat-square&amp;labelColor=111111" />
<img alt="Zero runtime dependencies" src="https://img.shields.io/badge/dependencies-0-555555?style=flat-square&amp;labelColor=111111" />
<img alt="TypeScript declarations included" src="https://img.shields.io/badge/types-TypeScript-555555?style=flat-square&amp;labelColor=111111" />
<img alt="Source-available license: Apache 2.0 with Commons Clause" src="https://img.shields.io/badge/license-Apache_2.0_%2B_Commons_Clause-555555?style=flat-square&amp;labelColor=111111" />
</p>
Orby turns product intent—ready, listening, thinking, using a tool, progressing,
completed, failed—into a coherent live glyph. Each frame combines an analytic
silhouette, a seeded field, a persistent pixel gate, and a spatial transition.
The result is motion with identity, not a generic loading ornament.
Orby is distributed as `@joan/procedural-glyph-engine`. Its primary runtime
class is `JoanGlyphEngine`, its browser-native element is `<joan-glyph>`, and
portable Studio recipes use the `.joan.json` format.
The runtime has no third-party dependencies. It ships as native ES modules with
TypeScript declarations, a browser-native web component, deterministic exports,
and a complete offline Studio.
> [!IMPORTANT]
> Orby is source-available under the [Apache License 2.0 subject to the
> Commons Clause License Condition v1.0](./LICENSE). You may use, copy, modify,
> and redistribute it, including inside a larger value-added product. You may
> not sell Orby or a product or service whose value derives entirely or
> substantially from Orby's functionality.
## Choose your path
| I want to… | Start here |
| --- | --- |
| Explore states and tune a recipe | [Open the live Studio](https://joansterjo-celonis.github.io/Procedural-glyph-engine/#playground) |
| Download everything for offline use | [Get the complete integration kit](https://joansterjo-celonis.github.io/Procedural-glyph-engine/#download) |
| Integrate the runtime into a product | [Follow the focused quick-start guide](./docs/QUICKSTART.md) |
| Try plain Canvas, web component, or timed sequences | [Run the included examples](./examples/README.md) |
| Understand the engine deeply | [Architecture](#architecture) · [state language](#the-25-semantic-sprites) · [API](#api-shape) |
| Operate it responsibly | [Accessibility](#accessibility-and-reduced-motion) · [performance](#performance-guidance) · [development](#development-test-and-build) |
<details>
<summary><strong>Complete contents</strong></summary>
- [Quick start](#quick-start)
- [Why Orby](#why-orby)
- [What Orby includes](#what-orby-includes)
- [Self-service download kit](#self-service-download-kit)
- [Architecture](#architecture)
- [The 25 semantic sprites](#the-25-semantic-sprites)
- [Runtime API](#runtime-api)
- [Web component](#web-component)
- [Custom glyphs](#custom-glyphs)
- [Signals, audio, and progress](#signals-audio-and-progress)
- [Export](#export)
- [Timed state sequences](#timed-and-pre-recorded-state-sequences)
- [StateDirector](#statedirector)
- [Studio preset library](#studio-preset-library)
- [Accessibility and reduced motion](#accessibility-and-reduced-motion)
- [Development, test, and build](#development-test-and-build)
- [Performance guidance](#performance-guidance)
- [License](#license)
</details>
## Quick start
Install the extracted integration kit from a consuming project:
```sh
npm install ./joan-procedural-glyph-engine-5.0.0
```
Mount one long-lived engine instance, then drive it with real product state:
```html
<canvas id="ai-glyph" width="160" height="160"></canvas>
<script type="module">
import { createGlyph } from "@joan/procedural-glyph-engine";
const glyph = createGlyph("#ai-glyph", {
sprite: "ai.idle",
seed: "conversation-42",
gridSize: 68,
});
await glyph.transitionTo("ai.thinking");
await glyph.transitionTo("status.success", {
transition: "path-draw",
});
</script>
```
For direct, unbundled browser use, replace the package import with
`./src/joan-engine.js`. For a declarative integration, use the included
[`<joan-glyph>` web component](#web-component). For pre-recorded or timed flows,
use [`playStateSequence()`](#timed-and-pre-recorded-state-sequences).
## Why Orby
| Semantic by default | Deterministic by design | Portable by construction |
| --- | --- | --- |
| Twenty-five states cover the real lifecycle of AI work, from ambient readiness to completion and recovery. | A stable seed preserves visual identity across frames, products, previews, and exports. | Use Canvas 2D, native ESM, a web component, serialized configurations, or a single offline HTML Studio. |
| 25 semantic states | 19 seeded fields | 12 transitions | 12 switch systems | 9 pixel geometries | 68×68 default grid |
| ---: | ---: | ---: | ---: | ---: | ---: |
| Presence → recovery | Quiet → chaotic | Morph → ignite | Dither → trace | Disc → composites | Adaptive quality |
## What Orby includes
- 25 immutable semantic sprite recipes covering presence, field-only ambient
motion, AI activity, status, transfer, and handoff states.
- 19 seeded scalar fields: `fbm`, `ridged`, `domain-warp`, `curl`, `flow`,
`worley`, `voronoi`, `plasma`, `interference`, `vortex`, `metaballs`,
`caustics`, `strata`, `radar`, `constellation`, `liquid`, `electric`,
`ripple`, and `kaleidoscope`.
- 12 pixel-switch strategies: `ordered-dither`, `temporal-blue-noise`,
`threshold-hysteresis`, `sdf-wavefront`, `contour-trace`, `curl-advect`,
`neighbor-propagation`, `radial-cascade`, `path-draw`, `axis-flip`,
`seeded-dissolve`, and `field-morph`.
- 12 transition strategies: `field-morph`, `seeded-dissolve`,
`radial-cascade`, `angular-sweep`, `scanline`, `contour-trace`, `path-draw`,
`axis-flip`, `cluster-dissolve`, `neighbor-ignite`, `glitch-bands`, and
`instant`.
- Nine Canvas 2D pixel shapes: `disc`, `square`, `diamond`, `capsule`, `line`,
`ring`, `cross`, `square-cross`, and `square-cross-ring`. The composite
shapes choose one primitive per cell from its sampled signal lightness.
- Analytic glyph masks with progress, audio-energy, pointer, press, signal, and
reduced-motion inputs.
- Seeded ordered dithering, dwell time, hysteresis, afterglow, and spring
response so pixels switch deliberately instead of flickering at a threshold.
- Runtime registration of sampler functions and bitmap glyphs, plus browser
image-file import.
- PNG, JSON-safe configuration, static SVG, and deterministic frame-sampled
animated SVG export without mutating the live engine.
- A semantic timing helper for escalating long-running reasoning states.
- Serializable named state sequences plus one-off timed playback with
cancellation, pause, resume, and stop controls.
## Self-service download kit
The website's **Download** section publishes a versioned ZIP assembled from the
same runtime source used by the live Studio. The complete integration kit
contains:
- the complete native ESM runtime in `src/`;
- all TypeScript declarations in `types/`;
- this full API and integration reference;
- a focused quick start in `docs/QUICKSTART.md`;
- runnable canvas, web-component, and sequence examples;
- the self-contained `joan-engine-v5.standalone.html` Studio;
- package metadata and a runtime manifest;
- the complete `LICENSE` and `NOTICE`; and
- `SHA256SUMS.txt` covering every packaged payload file.
Beside the ZIP, the website publishes its `.sha256` file and a machine-readable
release manifest. After extracting the kit, install that local folder from a
consuming project—the package is not currently registry-published:
```sh
npm install ./joan-procedural-glyph-engine-5.0.0
```
Alternatively, serve the extracted folder and import `./src/joan-engine.js`
directly, or open the standalone Studio without a build step. The archive grants
the same source-available permissions—and carries the same no-sale condition—as
the repository. See [`LICENSE`](./LICENSE) for the complete terms.
## Architecture
The renderer is intentionally layered. Each layer can be used independently or
composed by `JoanGlyphEngine`.
| Module | Responsibility |
| --- | --- |
| `src/fields.js` | Seed hashing, gradient/value/cellular noise, Bayer dithering, and the canonical scalar-field registry. Every named field samples to `0..1`. |
| `src/glyphs.js` | Resolution-aware analytic glyph coverage functions. Coordinates are normalized to `-1..1`; coverage is `0..1`. |
| `src/sprites.js` | Deep-frozen semantic recipes: glyph, field stack, palette, switching, timing, interaction, labels, and reduced-motion representation. |
| `src/joan-engine.js` | Canvas lifecycle, state transitions, interaction impulses, pixel gating, spring dynamics, drawing, exports, and events. |
| `src/web-component.js` | The `<joan-glyph>` custom element and its attribute-to-engine adapter. |
| `src/state-director.js` | Optional, explicit orchestration for thinking, deep-thinking, still-working, completion, failure, and reset. |
| `src/state-sequence.js` | Reusable, serializable timelines for timed state choreography, playback control, and cancellation. |
| `src/studio.js` | Interactive demo/studio wiring. It is not required by the runtime. |
The frame pipeline keeps meaning, identity, motion, and output as explicit
layers:
```mermaid
flowchart LR
A[Semantic state] --> B[Analytic glyph or field orb]
A --> C[Seeded procedural field stack]
D[Signals and interaction] --> B
D --> C
B --> F[Spatial transition]
C --> F
F --> E[Dither · hysteresis · dwell pixel gate]
E --> G[Spring and afterglow]
G --> H[Canvas · PNG · SVG]
```
Seeds affect the field permutation and per-cell decisions. The same seed,
sprite, coordinates, time, and options produce the same field samples. Animation
time and live interaction still intentionally change a rendered frame.
## The 25 semantic sprites
These IDs are the stable built-in catalog. Short aliases such as `thinking`,
`success`, `error`, `upload`, and `handoff` are accepted, but product code should
prefer the canonical IDs.
<details>
<summary><strong>View all 25 canonical state IDs</strong></summary>
| Canonical ID | Default label | Intended meaning |
| --- | --- | --- |
| `ai.idle` | Ready | Ready and available |
| `ai.ambient-idle` | Ambient ready | Calm presence expressed only through a field |
| `ai.ambient-thinking` | Ambient thinking | Reasoning expressed only through a field |
| `ai.ambient-thinking-symmetric` | Ambient thinking — symmetric | Clean, symmetrical reasoning expressed only through a field |
| `ai.ambient-speaking` | Ambient speaking | Voice output expressed only through a field |
| `ai.listening` | Listening | Capturing voice or input |
| `ai.thinking` | Thinking | Reasoning |
| `ai.thinking-deep` | Reasoning deeply | Deliberate extended reasoning |
| `ai.still-working` | Still working | Work is taking longer than expected |
| `ai.loading` | Loading | Indeterminate startup or wait |
| `ai.progress` | In progress | Determinate completion progress |
| `ai.generating` | Generating | Producing content |
| `ai.searching` | Searching | Searching or retrieving information |
| `ai.tool-use` | Using a tool | Executing a tool or action |
| `ai.speaking` | Speaking | Producing voice output |
| `ai.awaiting-input` | Your input is needed | User action is required |
| `status.success` | Completed | Completed successfully |
| `status.warning` | Warning | Attention is needed for a nonfatal issue |
| `status.error` | Error | Operation failed |
| `status.paused` | Paused | Work is suspended and can resume |
| `status.cancelled` | Cancelled | Operation was stopped |
| `status.offline` | Offline | Disconnected or unavailable |
| `transfer.active` | Transferring | Uploading, downloading, or synchronizing data |
| `workflow.handoff` | Handing off | Passing work to another agent or person |
| `status.celebration` | Milestone completed | A milestone or high-value success |
</details>
Use `listSprites()` to obtain the frozen ordered catalog and `getSprite(id)` to
resolve either a canonical ID or alias.
## Runtime API
### Package entry points
Every JavaScript entry point is native ESM and carries TypeScript declarations.
| Import | Provides |
| --- | --- |
| `@joan/procedural-glyph-engine` | `JoanGlyphEngine`, `createGlyph()`, core catalogs, and rendering utilities |
| `@joan/procedural-glyph-engine/config` | Strict option inspection, validation, and frozen recipe helpers |
| `@joan/procedural-glyph-engine/fields` | Seeded field samplers, noise helpers, and field registration |
| `@joan/procedural-glyph-engine/glyphs` | Analytic glyph masks and glyph registration helpers |
| `@joan/procedural-glyph-engine/sprites` | Immutable semantic sprite catalog and aliases |
| `@joan/procedural-glyph-engine/state-director` | Escalation timing for long-running task presentation |
| `@joan/procedural-glyph-engine/state-sequence` | Serializable timelines and controlled timed playback |
| `@joan/procedural-glyph-engine/web-component` | `<joan-glyph>` element, definition helper, and automatic browser registration |
| `@joan/procedural-glyph-engine/web-component/register` | Explicit side-effect registration for `<joan-glyph>` |
| `@joan/procedural-glyph-engine/styles.css` | Default web-component presentation styles |
### Engine construction
Create one long-lived engine instance per surface. A more fully authored setup
can override the active recipe while preserving the same semantic API:
```js
import { createGlyph } from "@joan/procedural-glyph-engine";
const glyph = createGlyph("#ai-glyph", {
sprite: "ai.idle",
seed: "conversation-42",
gridSize: 68,
pixelShape: "disc",
pixelSwitch: "threshold-hysteresis",
transition: "field-morph",
orbBoundary: "gestalt",
orbBackgroundColor: "#14212b",
orbBackgroundMode: "pixelated",
speed: 1,
density: 1,
});
await glyph.transitionTo("ai.thinking", {
transition: "neighbor-ignite",
duration: 0.5,
preservePhase: true,
});
// Release observers, listeners, and the animation frame when unmounting.
glyph.destroy();
```
`setSprite()` is synchronous and chainable. `transitionTo()` resolves when the
visual transition completes and accepts an `AbortSignal`; use it when product
flow must wait for presentation. For a direct, unbundled demo import, replace
the package import with `./src/joan-engine.js`.
`createGlyph(canvasOrSelector, options)` is the preferred factory.
`createProceduralGlyph({ canvas, ...options })` and
`mountProceduralGlyph(target, options)` provide equivalent construction forms.
The constructor form is equivalent:
```js
import JoanGlyphEngine from "@joan/procedural-glyph-engine";
const glyph = new JoanGlyphEngine(canvas, {
sprite: "ai.loading",
autoplay: true,
});
```
### Lifecycle and configuration
Useful lifecycle and configuration methods include `play()`, `pause()`,
`toggle()`, `activate()`, `resume()`, `renderOnce()`, `setSeed()`,
`setResolution()`, `setField()`, `setPixelShape()`, `setPixelSwitch()`,
`setNonErrorPalette()`, `setOptions()`, `transitionTo()`,
`whenTransitionComplete()`, `configure()`, `inspect()`, `exportConfig()`,
`toDataURL()`, `toBlob()`,
`toSVG()`, `toAnimatedSVG()`, `downloadPNG()`, `downloadSVG()`,
`downloadAnimatedSVG()`, and `destroy()`.
Built-in sprites use layered field stacks. `setField("radar")` deliberately
replaces that stack with one field; call `useRecipeFields()` to restore the
sprite's authored composition. `exportConfig()` records this distinction as
`fieldMode: "recipe" | "override"`, so configurations round-trip faithfully.
Recipe-owned pixel geometry, switching, and transitions serialize as `null`;
explicit global overrides serialize as their string or structured object. This
keeps reconstructed engines on each future state's authored motion recipe.
### Orb boundary and background
Set `orbBoundary: "gestalt"` to replace the continuous circular rim with an
implied edge built from separated pixel clusters. The default is
`orbBoundary: "defined"`, which preserves the authored hard outline. The
Gestalt treatment is available for `ai.idle`, `ai.ambient-idle`,
`ai.ambient-thinking`, and `ai.ambient-speaking`; other sprites retain their
authored silhouette. The option remains configured when moving between states,
so one engine can carry the same boundary preference through a product flow.
Custom circular recipes can opt in with
`composition: { gestaltBoundary: true, gestaltOpenness: 0.5 }`.
```js
glyph.setOptions({ orbBoundary: "gestalt" });
glyph.setOptions({ orbBoundary: "defined" }); // restore the continuous rim
```
`orbBackgroundColor` supplies the color for an independent fill behind the
pixels of the same four supported presence orbs. Choose its treatment with
`orbBackgroundMode`: `"none"` disables the layer, `"solid"` draws a smooth
circle, and `"pixelated"` builds the circle from grid-aligned row runs. The
pixelated mode follows the selected engine resolution, so its edge belongs to
the same grid as the foreground glyph. The fill carries through other states
without painting there, remains visible when the canvas background is disabled,
and is included in static and animated SVG exports.
An initial `orbBackgroundColor` without an explicit mode selects `"solid"`.
Once `"pixelated"` is selected, later color-only patches
preserve that treatment so changing the swatch does not reset the shape.
Clearing the color with `null` or `"transparent"` disables the layer. Set the
mode explicitly when you want to retain a color while temporarily hiding it.
```js
glyph.setOptions({
orbBackgroundColor: "#14212b",
orbBackgroundMode: "pixelated",
});
glyph.setOptions({ orbBackgroundMode: "none" }); // retain the chosen color
glyph.setOptions({ orbBackgroundMode: "solid" });
glyph.setOptions({ orbBackgroundColor: null }); // disable and clear the color
```
Named options fail fast with a nearby-name suggestion instead of silently
falling back. Use the config subpath to validate before constructing an engine,
or to define and freeze a custom recipe:
```js
import { getSprite } from "@joan/procedural-glyph-engine";
import {
defineRecipe,
inspectEngineOptions,
validateEngineOptions,
} from "@joan/procedural-glyph-engine/config";
const options = validateEngineOptions({
sprite: "thinking", // canonicalized to ai.thinking
field: "domain_warp", // canonicalized to domain-warp
gridSize: 68,
quality: "auto",
});
const inspection = inspectEngineOptions(untrustedOptions);
if (!inspection.ok) console.table(inspection.issues);
const branded = defineRecipe({
...getSprite("ai.generating"),
id: "product.generating",
glyph: "product.mark",
});
```
`validateEngineOptions()` is strict and non-mutating. `inspectEngineOptions()`
returns `{ ok, value, issues }`, safely
coerces ordinary HTML-style values, and omits invalid named options.
### API shape
| Operation | Return | Use it for |
| --- | --- | --- |
| `createGlyph(target, options)` | engine | The preferred canvas-or-selector invocation |
| `setSprite(sprite, options)` | engine | Immediate, chainable state commands |
| `transitionTo(sprite, options)` | `Promise<TransitionDetail>` | Waiting for the visual handoff or cancelling it with `signal` |
| `configure(patch)` | engine | Strict, atomic runtime configuration with one consolidated event/render |
| `setOptions(patch)` | engine | Runtime configuration with safe coercion |
| `signal(type, payload)` | engine | Short-lived product events and semantic inputs |
| `on(type, listener)` | unsubscribe function | Typed event subscription with one-call cleanup |
| `inspect()` | JSON-safe snapshot | State, transition, signals, palette, quality, and performance debugging |
| `renderOnce(time)` | stats | Deterministic paused previews and test fixtures |
| `exportConfig()` | JSON-safe object | Reconstructing the authored runtime configuration |
| `destroy()` | `undefined` | Releasing frames, observers, and listeners |
The package declarations expose literal unions for built-in sprites, fields,
pixel shapes, switches, transitions, quality settings, event payloads, recipes,
exports, the custom element, `StateDirector`, and `StateSequencePlayer`.
### Color control
Use `nonErrorPalette` to override any combination of the `background`, `off`,
`ink`, `accent`, and `glow` channels for every state except `status.error`.
Unspecified channels continue to come from each states authored palette.
Use `variants` when shared light or dark surfaces need to preserve the authored
semantic color family. Variant keys match palette names such as `info`,
`success`, `warning`, and `celebration`; explicit top-level channels still win.
```js
const glyph = new JoanGlyphEngine(canvas, {
sprite: "ai.thinking",
nonErrorPalette: {
background: "#e5e6e2",
off: "#c7cbc8",
ink: "#17191e",
variants: {
info: { accent: "#4788aa", glow: "#6aa6c4" },
success: { accent: "#397c57", glow: "#62a27b" },
warning: { accent: "#9a6817", glow: "#bd8b3c" },
},
},
});
glyph.setNonErrorPalette({ ink: "#ffffff", accent: "#53d6c7" });
glyph.setNonErrorPalette({ glow: "transparent" }); // disable the halo
glyph.setNonErrorPalette(null); // restore authored state colors
```
The error state keeps its danger palette so a product-level color choice cannot
erase its failure semantics. The `palette` option provides an explicit global
override—including for errors—when that behavior is needed.
Hex and `rgb()` color values provide consistent Canvas and SVG output.
In the studio, turn **Glow** off to select no glow color. The previous chosen
color is preserved and returns when Glow is enabled again.
## Web component
Importing the web-component subpath registers `<joan-glyph>` in a browser and
is safe to evaluate during SSR:
```js
import "@joan/procedural-glyph-engine/web-component";
```
An explicit side-effect entry is also available for application bootstrap:
```js
import "@joan/procedural-glyph-engine/web-component/register";
```
```html
<joan-glyph
sprite="ai.generating"
seed="answer-108"
resolution="68"
speed="0.9"
density="1"
field="electric"
pixel-shape="diamond"
pixel-switch="curl-advect"
orb-boundary="gestalt"
orb-background-color="#14212b"
orb-background-mode="pixelated"
non-error-background="#10131a"
non-error-off="#293140"
non-error-ink="#f7f8fb"
non-error-accent="#8f7cff"
non-error-glow="#6554e8"
></joan-glyph>
```
Size the host with CSS; its default size is `68px × 68px`.
```css
joan-glyph {
inline-size: 3rem;
block-size: 3rem;
}
```
The five `non-error-*` color attributes are optional and may be added, changed,
or removed at runtime. The boolean `paused` attribute disables autoplay.
`noninteractive` disables pointer interaction and `transparent` disables the
painted background; both react when added or removed. Removing `field`,
`pixel-shape`, or `pixel-switch` restores the recipe-authored behavior. The
optional `orb-boundary` attribute accepts `gestalt`; removing it (or using an
unsupported value) restores the default `defined` boundary. The optional
`orb-background-color` attribute adds the orb-only fill; removing it clears the
fill without changing the canvas background. Use `orb-background-mode="none"`,
`"solid"`, or `"pixelated"` to choose the treatment. Supplying only
`orb-background-color` selects `solid`; removing the mode attribute returns to
that color-driven behavior. The
element forwards the engine's state, signal, palette, playback, configuration,
and inspection methods and exposes the underlying instance as `element.engine`.
## Custom glyphs
A sampler receives normalized `x`, `y`, animation time, and the live engine
context. Return coverage from `0` (off) to `1` (fully covered). Keep the hot
sampler pure and allocation-free.
```js
import {
createProceduralGlyph,
getSprite,
} from "@joan/procedural-glyph-engine";
const glyph = createProceduralGlyph({ canvas, autoplay: true });
glyph.registerGlyph("product.spark", (x, y, time, context) => {
const radius = Math.hypot(x, y);
const spokes = Math.cos(Math.atan2(y, x) * 8 + time * 0.6);
const pulse = 0.04 * Math.sin(time * 1.4 + context.energy * Math.PI);
return radius < 0.42 + spokes * 0.08 + pulse ? 1 : 0;
});
const base = getSprite("ai.generating");
glyph.setSprite({
...base,
id: "product.spark",
label: "Generating with Product",
glyph: "product.spark",
semantic: {
...base.semantic,
category: "custom",
meaning: "generating branded output",
},
labels: {
...base.labels,
default: "Generating with Product",
aria: "Generating branded output",
},
});
```
A two-dimensional numeric array is accepted as a bitmap and its dimensions are
inferred:
```js
glyph.registerGlyph("product.pixel-heart", [
[0, 1, 0, 1, 0],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[0, 1, 1, 1, 0],
[0, 0, 1, 0, 0],
]);
```
In the browser, `loadGlyphFile(file, options)` rasterizes an image file and
activates it as a custom glyph:
```js
await glyph.loadGlyphFile(fileInput.files[0], {
id: "product.uploaded-mark",
label: "Product mark",
baseSprite: "ai.generating",
resolution: 48,
});
```
## Signals, audio, and progress
Signals add short-lived spatial energy without changing semantic state. Signal
names are intentionally open-ended, so the host can mirror its own event model.
```js
glyph.signal("token", { energy: 0.55 });
glyph.signal("search.hit", { x: 0.35, y: -0.2, energy: 0.9, life: 0.8 });
glyph.signal("tool.call", { energy: 1 });
glyph.signal("audio.level", { value: microphoneLevel });
glyph.signal("transfer.direction", { direction: "up" });
glyph.signal("handoff.accepted", { accepted: true });
glyph.signal("network.retry", { energy: 0.8 });
glyph.signal("resume", { value: 1 }); // restores the state held before status.paused
```
`audio.level` updates the smoothed listening/speaking input. `progress` is
special-cased as determinate state:
```js
glyph.setSprite("ai.progress");
glyph.setProgress(0.42); // clamped to 0..1
// Equivalent low-level form:
glyph.signal("progress", { value: 0.42 });
```
`transfer.active` also consumes progress. Register a direction-specific custom
glyph if the product must distinguish upload from download visually.
The engine dispatches `spritechange`, `transitionqueued`, `transitioncomplete`,
`timelinecomplete`, `resume`, `configchange`, `signal`, `activate`, `play`,
`pause`, `qualitychange`, `stats`, and `destroy` events:
```js
glyph.addEventListener("spritechange", ({ detail }) => {
console.log(`${detail.from}${detail.to}`);
});
```
## Export
`downloadPNG()` captures the current canvas. `toSVG()` / `downloadSVG()` create
a vector snapshot using the selected pixel geometry. `toAnimatedSVG()` /
`downloadAnimatedSVG()` simulate an isolated deterministic clone, sample its
pixel gates, and encode shape, opacity, and scale frames without changing the
live engine.
```js
await glyph.downloadPNG("thinking.png");
await glyph.downloadSVG("thinking.svg");
await glyph.downloadAnimatedSVG("thinking.animated.svg", {
duration: 2.4,
fps: 12,
maxGridSize: 48,
});
```
Animated export is intentionally capped and yields between frame batches to
keep the studio responsive. Raise `fps`, duration, or `maxGridSize` only after
checking file size and export time.
## Timed and pre-recorded state sequences
`StateSequencePlayer` turns product-owned state choreography into a small,
reusable API. Definitions are frozen and JSON-friendly, so a host can keep them
beside an agent workflow, load them from its own configuration, or construct a
one-off sequence at the point of use.
```js
import {
StateSequencePlayer,
defineStateSequence,
} from "@joan/procedural-glyph-engine/state-sequence";
const idleThenDone = defineStateSequence("idle-then-done", [
{ sprite: "ai.idle", holdMs: 5_000 },
{ sprite: "status.success", transition: "path-draw" },
]);
const states = new StateSequencePlayer(glyph, {
sequences: [idleThenDone],
transition: "field-morph",
});
const controller = new AbortController();
const result = await states.play("idle-then-done", {
signal: controller.signal,
});
```
The first step is entered immediately by default. Each later step waits for its
visual transition to complete, then holds for `holdMs` before advancing.
`durationMs` is accepted as an alias for `holdMs`; transition `duration` values
continue to use the engine's seconds-based API.
For an inline command, `playStateSequence()` starts immediately and returns the
playback controller:
```js
import { playStateSequence } from
"@joan/procedural-glyph-engine/state-sequence";
const playback = playStateSequence(glyph, [
{ sprite: "ai.ambient-idle", holdMs: 1_500 },
{ sprite: "ai.progress", transition: "contour-trace" },
]);
playback.pause(); // freezes the current hold clock
playback.resume();
await playback.finished;
```
Call `stop()` for an intentional early finish, or pass an `AbortSignal` when
the surrounding task owns cancellation. Aborts reject with `AbortError`;
`stop()` resolves with a `stopped` result. Starting another playback safely
stops the active run and starts the new one. Pausing freezes the hold clock and
step progression;
an already-running visual transition continues to settle. `sequencestart`,
`stepstart`, `statechange`, `stepenter`,
`stepcomplete`, `sequencepause`, `sequenceresume`, `sequencestop`,
`sequencecancel`, `sequencecomplete`, and `sequenceerror` events expose the
full lifecycle. Completed, stopped, cancelled, and destroyed players clear
their timeout and abort listeners.
## StateDirector
`StateDirector` is an optional presentation timer. The host still owns the real
task state; the director never guesses whether work started, succeeded, or
failed.
```js
import { StateDirector } from
"@joan/procedural-glyph-engine/state-director";
const director = new StateDirector(glyph, {
deepThinkingAfterMs: 7_000,
stillWorkingAfterMs: 18_000,
transition: "field-morph",
});
director.beginThinking();
try {
await performWork();
director.complete(); // status.success + a completion impulse
} catch (error) {
director.fail(); // status.error
}
// Return to a clean phase for the next operation.
director.reset();
// Clear pending escalation timers when the owner unmounts.
director.destroy();
glyph.destroy();
```
`beginThinking()` moves from `ai.thinking` to `ai.thinking-deep`, then to
`ai.still-working` at the configured thresholds. `set()`, `complete()`,
`fail()`, or `reset()` cancels pending timers.
For the common one-task lifecycle, `run()` removes the surrounding state
boilerplate and rethrows failures after presenting them:
```js
const controller = new AbortController();
const result = await director.run(
({ signal }) => performWork({ signal }),
{
signal: controller.signal,
deepThinkingAfterMs: 7_000,
stillWorkingAfterMs: 18_000,
successSprite: "status.success",
errorSprite: "status.error",
cancelledSprite: "status.cancelled",
},
);
```
Starting another director operation prevents earlier asynchronous work from
overwriting its state. Aborting moves to `status.cancelled` and rejects with an
`AbortError`. Every manual, escalated, successful, failed, cancelled, and reset
state dispatches `statechange` with `{ state, from, to, reason }`.
## Studio preset library
`Save preset` and `Save current` add the tuned recipe to the visible **Saved
presets** tile library above the State Atlas. The Studio keeps up to 24 entries
in this browser's local storage under `joan.studio.recent-presets.v1`; they do
not sync to another browser or device. A tile restores its recipe, Delete has
an immediate Undo action, and **Prepare recipe** creates a portable `.joan.json`
file when a preset must move beyond browser-local Studio state.
## Accessibility and reduced motion
- The engine assigns the canvas `role="img"` and updates its accessible label
when interaction is disabled. Interactive canvases use button semantics,
support Enter and Space activation, and expose a visible focus treatment.
Pass an `ariaLive` element when state changes should be announced.
- Keep a visible text status beside the glyph. Do not communicate success,
warning, failure, progress, or waiting through motion or color alone.
- Avoid duplicate announcements: use `ariaLive` only if the surrounding product
does not already announce the same state.
- `reducedMotion: "system"` is the default. It follows
`prefers-reduced-motion`, pauses continuous autoplay, renders representative
glyph phases, and uses 100120 ms transitions for the built-in recipes;
explicit reduced-motion durations remain capped at 200 ms.
- Set `reducedMotion: true` to force the static behavior or `false` only when the
product has a deliberate, user-controlled motion policy.
- Semantic recipes include a reduced-motion representation and use palettes
designed to remain understandable in monochrome, but application-level
contrast and surrounding copy still need product accessibility review.
- Call `setProgress()` even in reduced motion. Data changes remain meaningful
when spatial animation is removed.
## Development, test, and build
Requirements: Node.js 18 or newer; Node.js 20 is the tested handoff target.
> [!NOTE]
> The commands in this section require a repository clone. The downloadable
> integration kit intentionally includes runtime sources, types, documentation,
> examples, and the offline Studio—not the project build scripts or test suite.
```sh
# Run the local studio at http://127.0.0.1:4173
npm run dev
# Run dependency-free node:test contract tests
npm test
# Recreate dist/, the standalone HTML build, and the website download kit
npm run build
# Run tests, build, then verify the complete download artifact
npm run check
# Validate, build, and create the installable .tgz package
npm pack
```
`npm pack` is suitable for local integration testing. The resulting package
includes `LICENSE` and `NOTICE` and carries the same source-available terms as
the repository.
Set `JOAN_ENGINE_PORT` to use a different development port:
```sh
JOAN_ENGINE_PORT=4400 npm run dev
```
The studio preview defaults to **Fit**. Switch it to **1:1** to center the glyph
at its actual grid footprint, with one engine cell mapped to one CSS pixel. This
is a studio-only inspection view and does not alter exported recipe configuration.
The build copies the native module sources and studio assets into `dist/`,
creates the Sites-compatible `dist/client` and `dist/server/index.js` outputs,
generates the versioned ZIP, checksums, and release manifest in `downloads/`,
and creates `joan-engine-v5.standalone.html`. Commit source files rather than
editing generated output.
## Performance guidance
Rendering cost grows approximately with `gridSize²`. The runtime defaults to
68×68 and uses adaptive quality to hold its frame budget. Override that with
2436 for compact product icons or dense multi-glyph surfaces.
- Reuse an engine and call `setSprite()`; do not construct an engine for every
state change.
- For thumbnail grids, set `autoplay: false`, `autoResize: false`,
`interactive: false`, `quality: "low"`, `dprMax: 1`, and call `renderOnce()`
only when a preview needs updating.
- Use `fps: 24` or `30` for ambient UI. Reserve 60 fps for close, interactive
motion.
- Set `offPixels: false` to remove the background-dot draw pass. Disable the
painted background with `background: false` when the product surface already
supplies one.
- `quality: "auto"` adapts between `high`, `balanced`, and `low` from measured
frame cost. Read the effective tier from `stats.quality` or subscribe to
`qualitychange`. Balanced and low tiers time-slice field sampling across the
grid, and low also skips the glow path. Use a fixed high tier or
`renderOnce()` for full-grid deterministic visual fixtures.
- Cap device-pixel work with `dprMax`; a value of `1` is often enough for
pixel-art thumbnails and dense dashboards.
- Disable pointer work with `interactive: false` for decorative or
noninteractive instances.
- Keep custom sampler functions allocation-free and avoid DOM reads, object
creation, and network/state access inside them.
- Let the built-in visibility observation cancel animation-frame scheduling for
hidden and off-screen canvases; it resumes automatically when visible. Always
call `destroy()` when removing an instance.
- Watch the `stats` event (`fps`, `frameMs`, `sampledPixels`, `activePixels`, and `resolution`) in
realistic multi-glyph screens, not just an isolated demo.
For deterministic visual regression fixtures, use `autoplay: false`, disable
interaction and auto-resize, set a fixed canvas size and seed, then call
`renderOnce(fixedTime)`.
## License
Orby is source-available under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0)
subject to the [Commons Clause License Condition v1.0](https://commonsclause.com/).
- You may use, copy, modify, and redistribute Orby, and include it in a larger
value-added product, subject to the full terms.
- You may not sell Orby or offer a product or service whose value derives
entirely or substantially from Orby's functionality.
- Keep the required license and attribution notices when redistributing it.
Because it restricts selling, this is a **source-available** license rather than
an OSI-approved open-source license. Read [`LICENSE`](./LICENSE) for the complete
terms and [`NOTICE`](./NOTICE) for attribution.
---
<p align="center">
<a href="https://joansterjo-celonis.github.io/Procedural-glyph-engine/">Live Studio</a>
·
<a href="https://joansterjo-celonis.github.io/Procedural-glyph-engine/#download">Download kit</a>
·
<a href="#orby">Back to top</a>
</p>

Some files were not shown because too many files have changed in this diff Show More