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
This commit is contained in:
2026-08-03 22:52:25 +02:00
parent 5b68bdc16c
commit 195d45a0e9
13 changed files with 518 additions and 6 deletions

View File

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

View File

@@ -0,0 +1,431 @@
# 2026-07-20 — Desktop mascot ("Cluck")
**Status:** Implemented
> **Deviations from the original plan, applied 2026-07-20 during
> implementation:**
> - **Hatching is no longer timed.** The egg → chick transition fires
> once, on first naming (the name dialog opens on first mount of a
> fresh egg; submitting it calls `forceHatch()`). `HATCH_MS` is gone,
> `tickLifecycle` no longer advances `hatchProgress`, and the egg no
> longer plays a progressive `egg-crack` animation — it sits on
> `egg-idle` until named. `hatchProgress` is retained as a binary
> 0/1 flag so `advanceStageIfReady()` and the debug "Force hatch"
> action still work.
> - **Sprite art is PNG-sheet-based, not code-drawn pixel grids.** The
> chicken comes from a CC0 16x16 sprite-sheet pack at
> `web/public/mascot/`; the egg comes from the Onocentaur egg pack
> (also CC0). `palette.ts` was removed; `render.ts` slices 16x16
> frames from sheets instead of painting string grids. Chick and
> adult share sheets (distinguished only by render scale) until
> distinct adult art is added.
> - **The radial menu is a rounded-button column, not a circular
> ring.** The plan's polar-layout ring was found to hide labels; the
> menu now mirrors the desktop's own right-click menu styling
> (full-text buttons, nested via a "Back" breadcrumb).
> - **The sprite loop runs at ~60fps** (16ms `setTimeout`), not 30fps.
> Drag and fall motion at 30fps looked choppy on 60Hz+ displays. The
> `setTimeout`-not-`rAF` convention is preserved; `dt` is still
> clamped to 100ms. Position is applied via `transform: translate3d`
> + `will-change: transform` (compositor layer) instead of CSS
> `left`/`top` to avoid per-frame layout reflow.
> - **Egg-stage reactions are suppressed.** The stimulus bus still
> subscribes to chat/activity/events while the egg is on screen, but
> MascotLayer's emit callback drops any reaction when
> `model.stage === 'egg'` — the egg isn't "alive" yet, so playing
> alarm/eureka animations behind the naming dialog would be jarring.
> - **The mascot walks on top of windows.** The ground line is
> recomputed each tick from `wmState`: it's the top edge of the
> highest non-minimized window whose horizontal span covers the
> mascot's x, or the surface bottom when no window is beneath. When
> the mascot strolls over a window, the ground rises to that
> window's top edge; when it walks off the side, the ground drops
> and it flutter-falls to the next surface beneath (another window,
> or the desktop). This generalizes the original "walks along the
> desktop surface's bottom edge" decision to a multi-surface model.
## Why
The web control room is an OS-style desktop shell (icons, floating
windows, taskbar) but has no ambient, always-visible signal of what the
system is doing — you have to open a window to see a chat streaming, a
knowledge-graph write, or a critical signal land. The user asked for a
pixel-art chicken mascot that roams the desktop, is draggable and
interactable (Sims-style radial right-click menu with nested actions), and
is itself a tamagotchi (egg → chick → adult, nameable, persistent) that
visibly reacts to real app activity. This plan is scaffolding: every piece
(sprites, autonomous behaviors, menu actions, environment reactions) is a
data-driven registry so each can be extended independently later without
touching the engine code.
The MBSE subsystem model for this feature (Mission, Requirements,
Structural/Behavioral/Interfaces views, Verification) lives at
[docs/mascot/README.md](../docs/mascot/README.md) — read it first for the
full rationale and diagrams; this document is the concrete file-by-file
implementation plan derived from it.
**Design decisions already made with the user:**
- Renders **above windows** (desktop-pet style) — mascot layer `z-45`,
radial menu `z-[60]` (must beat the desktop's own right-click menu,
which is `z-50`).
- Art is **code-drawn pixel art** — string pixel-grids + a palette map in
TypeScript, rendered to a small canvas, no binary sprite assets.
- Movement is **gravity + ground** — walks along the desktop surface's
bottom edge (= the taskbar's top edge), flutter-falls when dropped
mid-air.
## Verified codebase facts this plan builds on
- `web/src/lib/components/desktop-shell/Desktop.svelte` — the surface div
(`relative min-h-0 flex-1 overflow-hidden`) hosts layered children: icon
layer `z-0`, `TaskLauncher` wrapper `z-10`, `WindowLayer` `z-40` — every
wrapper is `pointer-events-none` with interactive children re-enabling
`pointer-events-auto`. The desktop's own right-click menu is `fixed
z-50`, dismissed via `<svelte:window onclick={closeMenu}>` + Escape.
Bare-surface clicks are gated with `e.currentTarget === e.target`.
- Drag pattern to copy: `desktop-shell/DesktopIcon.svelte`
`pointerdown` + `el.setPointerCapture(e.pointerId)`, a 5px movement
threshold distinguishes a click from a drag, move/up listeners attached
to the element itself (not `window`), position blended via `$derived`
between rest and drag-in-progress values.
- Game loop convention: `GraphBackground.svelte` drives its canvas with
`setTimeout(() => draw(performance.now()), 33)` (~30fps), **not**
`requestAnimationFrame` — the code comment there explains some embedding
contexts report `document.hidden=true` and suspend rAF, which would
freeze the animation; `setTimeout` keeps ticking. Follow this for the
mascot loop, and clamp `dt` to 100ms so a throttled/backgrounded tab
doesn't produce a physics-breaking huge step on resume.
- Persistence convention: hyphenated `oikos-*` localStorage keys
(`oikos-desktop-icons`, `oikos-theme`, `oikos-windows`). Window layout
uses wmkit's `persist(wm, { key: 'oikos-windows', debounce: 300,
autoRestore: true })` — mirror the 300ms debounce for `oikos-mascot`;
never write on every animation frame, only on discrete state
transitions (behavior change, drag end, stage change, rename).
- Runes idiom for cross-component client state: a `.svelte.ts` module with
module-level `$state` plus exported getter/mutator functions —
`web/src/lib/stores/theme.svelte.ts` is the canonical example
(`let current: Theme = $state(initialTheme)`, `getTheme()`,
`setTheme()`, `toggleTheme()`).
- Awareness sources, all plain Svelte stores already in the codebase:
- `web/src/lib/stores/events.ts``liveEvents: Writable<OikosEvent[]>`
(newest-first, capped at 200), fed by a ref-counted SSE subscription
`subscribeEvents()`. `OikosEvent.type` families: `approval.*`,
`signal.*`, `execution.*`, `health.changed`; `severity: 'info' |
'warning' | 'critical'`.
- `web/src/lib/stores/chat.ts``streaming: Writable<boolean>`.
- `web/src/lib/stores/activity.ts``activityLog` is a **derived**
store recomputed wholesale from `messages`/`planSteps`/`currentTask`
on every emission, **not an append-only log** — detecting a "new"
entry (e.g. `type === 'knowledge'`) requires diffing entry `id`s
against the previous emission, not just reacting to the store firing.
- `web/src/lib/stores/context.ts` — `summary: Writable<DashboardSummary
| null>`, `openSignalCount(summary)`.
- No `@keyframes`, no `requestAnimationFrame`, no sprite/pixel-art code
exists anywhere in the repo today — this is greenfield within the
established canvas-loop convention above.
## File layout
All new, under `web/src/lib/mascot/`:
```
types.ts PixelGrid, AnimName, MascotStage, BehaviorId, Stimulus, RadialAction
palette.ts Record<char, cssColor>; '.' = transparent
sprites.ts SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>> + resolveAnim() fallback
render.ts drawFrame(ctx, grid, palette, flip) — stateless canvas painter
state.svelte.ts Tamagotchi model: module $state + mutators, debounced persist, versioned schema
behavior.ts FSM: BEHAVIORS registry + stepMascot(rt, model, now, dt)
stimuli.ts Stimulus bus: REACTIONS registry + attachStimuli(emit), ref-counted
actions.ts MASCOT_ACTIONS radial tree + registerMascotAction()
Mascot.svelte canvas sprite, 30fps loop, pointer drag/click/contextmenu
MascotLayer.svelte pointer-events-none absolute inset-0 z-45 overlay; hosts Mascot + RadialMenu + bubble
RadialMenu.svelte round nested menu, fixed z-[60]
NameDialog.svelte naming prompt (hatch + rename)
```
**Integration — 2 lines in `Desktop.svelte`:** import `MascotLayer` and
render `<MascotLayer />` inside the surface `<div>`, after `<WindowLayer
/>`, so its `absolute inset-0` shares the surface's coordinate space and
its ground line lands exactly at the surface's bottom edge (the taskbar's
top edge).
## Sprite system (`types.ts`, `palette.ts`, `sprites.ts`, `render.ts`)
Frames are human-editable string pixel-grids indexing a palette, e.g.:
```ts
export type PixelGrid = string[] // rows of same-length strings, one char per pixel
export interface AnimDef { frames: PixelGrid[]; fps: number; loop: boolean }
export type AnimName =
| 'egg-idle' | 'egg-wiggle' | 'egg-crack' | 'hatch'
| 'idle' | 'blink' | 'walk' | 'peck' | 'flap' | 'sleep'
| 'dragged' | 'fall-flutter' | 'land'
| 'react-eureka' | 'react-alarm' | 'react-think' | 'react-happy'
```
- Grids: egg 12×12, chick 14×14, adult 16×16, all bottom-anchored inside a
fixed 20×20 logical canvas so feet land on the ground line consistently
across stages.
- `SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>>` is
the registry; `resolveAnim(stage, name)` falls back to that stage's
`idle` and finally a 1-frame placeholder, so a missing animation never
crashes the renderer.
- Canvas is sized to the logical grid; screen scale is pure CSS (`width:
20*SCALE px; image-rendering: pixelated`), `ctx.imageSmoothingEnabled =
false` set once. Horizontal facing flip via `ctx.translate(w,0);
ctx.scale(-1,1)` — no mirrored frame data needed.
- Initial animation set (24 frames each): egg-idle/egg-wiggle/egg-crack/
hatch; idle/blink/walk/peck/flap/sleep; dragged/fall-flutter/land;
react-think/react-eureka/react-alarm/react-happy.
- Frame index = `floor((now - animStart) / 1000 * fps)`, wrapped if
`loop`.
## Behavior engine (`behavior.ts`)
```ts
export interface MascotRuntime {
x: number; y: number // sprite bottom-center, surface coords
vx: number; vy: number
facing: 1 | -1
behavior: BehaviorId // 'egg' | 'idle' | 'wander' | 'peck' | 'sleep' | 'dragged' | 'falling' | 'react'
behaviorUntil: number
anim: AnimName
animStart: number
reactAnim: AnimName | null
bounds: { w: number; h: number }
}
export interface BehaviorDef {
id: BehaviorId
anim: (rt: MascotRuntime, model: MascotModel) => AnimName
enter?: (rt: MascotRuntime) => void
tick: (rt: MascotRuntime, dt: number, now: number) => void
next: (rt: MascotRuntime, now: number) => BehaviorId | null
weight?: number // idle-selectable when > 0; undefined/0 = not auto-picked
minMs: number; maxMs: number
}
export const BEHAVIORS: Record<BehaviorId, BehaviorDef>
export function stepMascot(rt: MascotRuntime, model: MascotModel, now: number, dt: number): void
export function forceBehavior(rt: MascotRuntime, id: BehaviorId, opts?: { anim?: AnimName; durationMs?: number }): void
```
- **Ground/gravity**: `GROUND_Y = bounds.h`. When above ground and not
dragged, behavior is `falling`: `vy += GRAVITY * dt`, capped at a slow
flutter terminal velocity, anim `fall-flutter` with occasional `flap`;
on reaching ground, snap `y`, brief `land`, then `idle`.
- **Wander**: constant `vx = facing * ~40px/s`, flip `facing` at the
surface margins.
- **Idle selection**: when `now > behaviorUntil` and the current
behavior's `next()` returns null, roll a weighted random pick over
`BEHAVIORS` entries that declare `weight` — starting weights: idle 3,
wander 4, peck 2, sleep 1.
- **Non-self-selecting behaviors** (`dragged`, `falling`, `react`) have no
`weight` and are entered only via `forceBehavior()` — pointer code calls
it for `dragged`, gravity logic for `falling`, the stimulus bus for
`react`.
- **Egg stage**: `behavior` locked to `'egg'` (renders `egg-idle`,
wiggles gently via a render-time transform); dragging is still
allowed (the egg can be picked up and moved). The egg → chick
transition fires once, on first naming — see the deviation note at
the top of this plan.
- Loop lives in `Mascot.svelte`: `setTimeout(() => tick(performance.now()),
33)` inside an `$effect`, cleared on teardown, `dt` clamped to 100ms.
## Tamagotchi model (`state.svelte.ts`)
```ts
export type MascotStage = 'egg' | 'chick' | 'adult'
export interface MascotModel {
version: 1
stage: MascotStage
name: string | null
hatchProgress: number // binary 0/1: 0 until first naming, 1 after (egg stage only)
happiness: number // 0..100, slow decay, boosted by pet/feed
xp: number // chick -> adult growth hook
hatchedAt: number | null
lastPos: { x: number } | null
lastSeen: number // for capping passive decay
}
export const ADULT_XP = 200
export function grantXp(n: number): void
export function feed(): void
export function pet(): void
export function setName(name: string): void
export function tickLifecycle(dt: number): void // called ~1x/sec, not per frame
export function advanceStageIfReady(): void
export function forceHatch(): void // called by the name-dialog submit handler on first naming
```
- `load()` parses `localStorage['oikos-mascot']`, checks `version`, falls
back to `defaultModel()` on mismatch/corruption. `migrate(raw):
MascotModel` is a stub switch on `version` for future schema changes —
v1 has no migrations to perform, the stub just documents where they go.
- Every mutator calls a shared `schedulePersist()` — a 300ms trailing
debounce, plus a `beforeunload` flush so a quick reload doesn't lose a
rename. `lastPos.x` is written only on behavior transitions and
drag-end, never per frame.
- The egg → chick transition is **not** timed: a fresh egg (stage=egg,
name=null) opens the name dialog on mount; submitting it calls
`forceHatch()` which sets `hatchProgress=1` and `setStage('chick')`.
Returning users with a named mascot skip the dialog. See the deviation
note at the top of this plan.
- Multi-tab races (two tabs both writing `oikos-mascot`) are
last-writer-wins — accepted for this scaffolding, not solved; a future
pass could listen to the `storage` event if it becomes a real problem.
## Radial menu (`actions.ts`, `RadialMenu.svelte`)
```ts
export interface RadialAction {
id: string
label: string
icon?: Component // lucide, same convention as the desktop menu
visible?: (model: MascotModel) => boolean // e.g. Rename only once hatched
children?: RadialAction[]
action?: (ctx: MascotActionCtx) => void // leaf only
}
export const MASCOT_ACTIONS: RadialAction[]
export function registerMascotAction(a: RadialAction, parentId?: string): void
```
v1 tree: **Interact** [Pet, Feed → [Seeds, Worm]], **Care** [Sleep, Wake],
**Identity** [Rename], **Debug** [Force hatch/stage, Reset].
- Rendered by `MascotLayer.svelte` as `fixed`, positioned at the
chicken's screen center, **`z-[60]`** (must beat the desktop context
menu's `z-50`, comfortably above `WindowLayer`'s `z-40`).
- Layout: items on a circle (radius ≈ 70px) via polar `transform`s around
the menu's anchor point. Open animation: buttons start scale-0 at
center and transition to their polar position with `transform 120ms
cubic-bezier(.2,1.4,.4,1)`, staggered ~20ms per item — pure CSS, no
keyframes, reads as snappy/springy per the "snappy" requirement.
- **Nesting**: selecting a node with `children` swaps the ring's contents
to those children plus a center "back" button; track the breadcrumb as
a local `$state<RadialAction[][]>` stack.
- Dismissal mirrors the desktop menu's existing pattern:
`<svelte:window onclick={close}>`, Escape pops one level then closes on
the next press; the menu's own clicks `stopPropagation()`. Clamp the
ring's screen position so it never renders off-viewport (relevant near
screen edges/corners).
- Opened from `Mascot.svelte`'s `oncontextmenu`:
`e.preventDefault(); e.stopPropagation();` then tell `MascotLayer` to
open at the sprite's center (the surface's own `onSurfaceContextMenu`
already gates on `currentTarget === target`, so this is defensive, not
strictly required — but keep it for clarity).
## Stimulus / reaction system (`stimuli.ts`)
```ts
export interface ReactionDef {
id: string
anim: AnimName
priority: number
cooldownMs: number
durationMs: number
interruptsSleep?: boolean
effect?: () => void // e.g. grantXp(5) on eureka
}
export const REACTIONS: Record<string, ReactionDef>
export function attachStimuli(emit: (r: ReactionDef) => void): () => void // ref-counted, owns subscribeEvents()
```
Initial wiring:
| Source | Trigger | Reaction |
|---|---|---|
| `chat.ts` `streaming` | `false → true` edge, held while `true` | `thinking` (`react-think`, priority 1) |
| `activity.ts` `activityLog` | new entry with `type === 'knowledge'`, detected by diffing entry ids against the last-seen set (see note above — the store is recomputed wholesale) | `eureka` (`react-eureka`, priority 2, cooldown 10s, `effect: grantXp(5)`) |
| `events.ts` `liveEvents` | new head event (`id > lastSeen`) with `severity === 'critical'` or `type` starting `signal.` | `alarmed` (`react-alarm`, priority 3, cooldown 15s, `interruptsSleep: true`) |
| `events.ts` `liveEvents` | new head event, `type` starting `execution.`, success-ish | `happy` (`react-happy`, priority 1, cooldown 20s) |
- `attachStimuli` calls `subscribeEvents()` itself and folds its
unsubscribe into the returned teardown, so the mascot keeps the SSE
stream open (ref-counted alongside any page that also subscribes) only
while mounted.
- **Egg-stage reactions are suppressed.** MascotLayer's stimulus
callback drops any reaction when `model.stage === 'egg'` — the egg
isn't "alive" yet (no name, no hatched chick to react), so stimulus
events are silently ignored until the egg hatches. This keeps the egg
calm during the naming dialog rather than playing alarm animations
behind it.
- On first emission of `liveEvents`, just record the head event id — do
not replay history as reactions on mount.
- Dispatch: `emit(reaction)` checks the cooldown map and
`priority >= currentReactPriority` (or current behavior isn't
`dragged`), then calls `forceBehavior(rt, 'react', { anim,
durationMs })`. `dragged` always wins over any reaction; `sleep` is
broken only when `interruptsSleep` is true.
## Implementation order (sized for one PR each)
1. `types.ts`, `palette.ts`, `sprites.ts` (egg + chick idle/walk only),
`render.ts` — pure data/functions, no UI yet.
2. `state.svelte.ts` model + debounced persistence — verify by hand via
devtools console before wiring any UI.
3. `behavior.ts` FSM (egg/idle/wander/falling/dragged) + `Mascot.svelte` +
`MascotLayer.svelte`, insert into `Desktop.svelte`. **First visible
milestone** — an egg sits on the ground and can be dragged.
4. Hatch flow: `tickLifecycle` wired into the loop, egg→chick transition +
`NameDialog.svelte`, remaining animations, `peck`/`sleep` behaviors.
5. `actions.ts` + `RadialMenu.svelte` (nested rings, open animation,
dismissal).
6. `stimuli.ts` + the four reaction animations + the wiring table above.
7. Adult stage sprites + XP threshold; polish (speech/name bubble, a
squash frame on `land`).
8. A short "how to add an animation / behavior / action / reaction" doc
comment at the top of `sprites.ts`, `behavior.ts`, `actions.ts`, and
`stimuli.ts` respectively (this plan's registry tables above become
those comments, condensed).
## Verification checklist
Run `npm run dev` in `web/`, then in the browser:
- Egg renders on the ground at the surface bottom, wiggles occasionally,
and is at the same `x` after a reload (`oikos-mascot` in localStorage —
confirm it is *not* being written on every frame while merely idling or
walking, only on discrete transitions).
- Dragging the egg up and releasing triggers a flutter-fall back down with
no tunneling below the taskbar; dragging past the surface's left/right
edges clamps rather than escaping the viewport.
- The debug "force hatch" action transitions egg → chick, opens the name
dialog, and the chosen name persists across a reload.
- The chick wanders and flips its sprite at the surface edges, pecks, and
sleeps on its own; a plain click (below the 5px drag threshold) triggers
a pet/hop reaction and grants a little xp.
- Right-clicking the chicken opens the radial menu centered on it — and
right-clicking bare desktop elsewhere still opens the *original* desktop
menu, unaffected. The nested Feed submenu opens; Escape pops one level
then closes on the next press; clicking outside the menu closes it;
the menu stays fully on-screen when the chicken is near a corner.
- With a maximized window open, the chicken visibly walks above it; window
drag/resize/close still work normally when the chicken merely passes
under the cursor (not when it's directly over a button being clicked —
known, accepted overlap per the "renders above windows" decision).
- Sending a chat message and watching it stream triggers the `thinking`
animation for the duration; simulate (or trigger for real) a
knowledge-graph write and confirm `eureka` fires once and respects its
cooldown on a second write; simulate a critical signal and confirm
`alarmed` fires even while the chicken is asleep.
- Resizing the browser viewport re-grounds the chicken and keeps it
within the new bounds.
- Both the Terracotta and Carbon themes keep the pixel palette legible.
- `npm run build` passes with no new errors or warnings beyond the
pre-existing baseline.
## Risks
- Z-index ordering is easy to get subtly wrong: radial menu must be
`z-[60]` to beat the desktop context menu's `z-50`; the mascot layer
itself is `z-45` (above `WindowLayer`'s `z-40`, below both menus).
- The mascot rendering above windows means it can occlude/steal clicks on
window chrome directly beneath it — accepted per the "above windows"
decision; mitigate by keeping the pointer hitbox tight to the canvas
element only (no oversized invisible padding).
- `activityLog` is a derived store recomputed wholesale on every
emission, not an append-only log — any "new entry" detection must diff
entry ids between emissions, never assume the store only ever grows by
appending.
- `setTimeout`-driven loops can receive large `dt` spikes after tab
throttling/backgrounding resumes — clamp `dt` before feeding it into
physics or lifecycle ticking.

View File

@@ -0,0 +1,332 @@
# 2026-07-20 — Session review: past 10 sessions
**Status:** Implemented — all P0/P1/P2 items landed in v0.7.13.
**Scope:** Ten most-recently-active `agent:nomos` sessions by
`last_active_at`, pulled from `http://localhost:8092/sessions` on
2026-07-20. Method per `.agents/skills/session-review/SKILL.md`. Three
(`1e9c7691`, `55927f0a`, `2926de4e`) overlap with the 2026-07-18 review
and are summarized; the other seven are new.
---
## Sessions reviewed
| # | sid | goal (short) | outcome | msgs | toolcalls | top tools |
|---|---|---|---|---|---|---|
| 1 | `a51e2086` | reset rclone-backup & re-run | **partial** | 12 | 20 | run:7, set_goal:2, propose_plan:2, get_execution_status:2 |
| 2 | `fefa4fa3` | fix rclone OOM | success | 9 | 84 | run:28, update_plan_step:15, get_entity:8, list_entities:5 |
| 3 | `95fdd322` | quick fleet health check | success | 3 | 6 | get_health_summary/state_snapshot/list_lxcs/signal_history |
| 4 | `8c76bb3a` | greeting + title-sync test | success | 2 | 9 | update_plan_step:4, propose_plan, whoami, get_state_snapshot |
| 5 | `438ec8bd` | (no goal set) greeting | success | 2 | 2 | whoami, get_health_summary |
| 6 | `8acea2e3` | inspect rclone timer (live) | **partial** | 4 | 19 | run:6, update_plan_step:5, propose_plan, get_entity_knowledge |
| 7 | `1e9c7691` | debug chown hang on strong | success | 13 | 97 | run:60, update_plan_step:7, get_execution_status:7 |
| 8 | `55927f0a` | add NFS ludo-lvm → ZimaOS | success | 25 | 104 | run:49, update_plan_step:13, get_entity:10 |
| 9 | `2926de4e` | apt upgrade host:netbird-vps | success | 9 | 27 | update_plan_step:7, run:6, search_knowledge:2 |
| 10 | `cb8c8a4a` | inspect rclone timer (live) | success | 2 | 14 | update_plan_step:4, run:4, get_entity_knowledge |
**Score: 8 success / 2 partial / 0 blocked. No message exceeded 2.8 KB.**
---
## What worked
- **Read-only DB Q&A is now clean.** `95fdd322` and `438ec8bd` did exactly
what the 2026-07-18 review asked: pure-DB question →
`get_health_summary` + `get_state_snapshot` + `list_lxcs`, no `run`.
The agent even narrates "This is a pure-DB Q&A — no `run` calls needed."
- **Knowledge writeback hygiene continues.** Every long-running session
did `upsert_knowledge` + `update_entity_attributes` + `create_relationship`
when applicable. The graph is current.
- **Plan lifecycle is followed everywhere** — `set_goal``propose_plan`
`update_plan_step``complete_task`. Even trivial sessions (greeting)
follow it.
- **Poll-after-timeout pattern** is now the default — `fefa4fa3` after
the rclone LXC reboot, `2926de4e` after the apt upgrade. No more blind
retry storms like the 2026-07-18 chown case.
- **The rclone saga ended well** (`fefa4fa3`): root cause (2 GiB LXC OOM)
was diagnosed via DB + live check; fix (pct set 2→4 GiB) was applied;
test backup verified 245 transfers / 4 min / no OOM.
## What didn't
### 1. The rclone objective took three sessions to close (blocker)
Same operator goal — "rclone backup is broken" — spawned `a51e2086`
(partial), `8acea2e3` (partial), `cb8c8a4a` (success), and finally
`fefa4fa3` (success). The first three were the agent trying to inspect
the live systemd state and bouncing off the classifier:
- `8acea2e3`: `pct exec 132 systemctl status rclone-backup.timer`
flagged `config_mutation` — sat in approval limbo until the user moved
on.
- `a51e2086`: `curl http://192.168.8.214:5572/rc/...` (read-only RC API)
flagged `config_mutation`. The agent kept reframing; user said "lets
just close this session."
- `cb8c8a4a`: same goal, eventually succeeded — but only after the agent
found a different path.
- `fefa4fa3`: only when the user escalated to "fix it so the backup
works" did the agent pivot to the actual root cause (memory).
This is the single biggest friction point in the batch.
### 2. Classifier overreach on read-only `pct exec` / `curl` (blocker)
The preflight classifier in `internal/policy` matches command substrings
(`pct exec`, `curl`, `dd`, etc.) without parsing the actual command. A
read-only `systemctl status` becomes `config_mutation`. The agent has
no tool to ask "classify this command before I send it" — it just keeps
retrying with cosmetic changes until the user bails.
### 3. `update_plan_step` is the second-largest tool bucket (cosmetic → friction)
Across 10 sessions: `run` ~199, `update_plan_step` ~57. That's ~22% of
all tool calls spent on bookkeeping. For a 2-message greeting session
(`8c76bb3a`) the agent still called `update_plan_step` ×4 plus
`propose_plan`. The scaffolding is louder than the work.
### 4. `pending_approvals` doesn't match reality (cosmetic, but misleading)
`a51e2086` summary literally says *"Both commands are queued"* — yet
`pending_approvals=0`. The field is `hasPendingApprovals`
(`store.go:962`) which only counts executions currently in
`pending_approval` state; once they're cancelled/expired it drops to 0
even though the session was *blocked* by approvals. As an audit signal
it lies. A session can be `outcome=partial` because of approval
friction without `pending_approvals` ever being non-zero at review time.
### 5. Title is still the first sentence of the first assistant message (cosmetic)
`"Assent window is open — executing the plan\n\nMemory bumped:
4294967296..."` is not a useful label. Same complaint applies to
`8c76bb3a` ("Hey! 👋 Nomos here, running on mac-mini:8092...") and
`95fdd322` ("This is a pure-DB Q&A — no `run` calls needed..."). The
list view ends up being unreadable without opening each row.
### 6. Goal field empty on one session (`438ec8bd`) (cosmetic)
`set_goal` was never called for the bare greeting. Minor, but it means
the session is unsearchable by goal text.
---
## Ease of getting session details
I had to write Python+curl to audit 10 sessions. The pain points:
1. **Two endpoints must be merged by hand.** `/sessions` returns
metadata (`title`, `goal`, `outcome`, `summary`, `status`,
`pending_approvals`, timestamps) but **no message/tool counts**.
`/sessions/{id}` returns **only** `session_id` + `messages` — no
metadata at all. `cmd/nomos/eval/main.go:302-303` already carries a
comment complaining about this ("only session_id + messages"). Any
consumer has to do the same join I did.
2. **No aggregates on the list endpoint.** `message_count`,
`tool_call_count`, `top_tools`, `duration` — all require fetching
every session's full transcript and walking the message tree. For
10 sessions that's 10 extra HTTP round trips and ~600 KB of JSON
parsed client-side. For a fleet audit at scale it's quadratic.
3. **No filtering or pagination on `/sessions`.** It returns every
session in one shot. The skill's own script does `.sessions[:5]` and
`.sessions[:10]` client-side.
4. **Tool calls are nested two levels deep**
(`messages[].content.tool_calls[].name`) with `content` stored as
`json.RawMessage`. The jq path requires `?.` everywhere. A flat
`/sessions/{id}/tool_calls` view would be far easier to analyze.
5. **No `/sessions?outcome=partial` or `?entity_id=...` filter.**
Finding "show me every session that touched `lxc:rclone` and didn't
succeed" requires the full scan.
6. **`title` is the raw first assistant text.** Useless for skimming a
list — you have to open each row to know what it was.
7. **No `closed_at` / `outcome_set_at`.** `last_active_at` is the
closest proxy but it conflates "agent is still working" with
"operator just opened the transcript." Duration can only be
computed as `last_active - created`, which is wrong for reopened
sessions (`a51e2086` shows "5647 min" = 4 days because the user
re-opened it on 2026-07-19 to close it).
8. **No "blocker reason" field.** When `outcome=partial`, the *why* is
buried in the last assistant text. A structured
`blocker: "approval_timeout"` / `blocker: "classifier_overreach"` /
`blocker: "user_abandoned"` would make trend analysis trivial.
---
## Improvement plan
### P0 — Blockers ✅
1. ✅ **Stop the classifier from flagging read-only `pct exec` / `curl` as
`config_mutation`.** In `internal/policy`, parse the command (not
just substring-match) before assigning risk class. Concretely:
`pct exec <id> -- <cmd>` should be classified by *the inner command*,
not the wrapper. `curl <url>` without `-X POST` / `-d` /
`--upload-file` is read-only. This single change would have
collapsed sessions #1, #3, #6, #10 into a handful of tool calls each
and avoided three duplicate rclone sessions.
- Done: `internal/policy/command.go` now unwraps `pct exec`, `qm
guest exec`, `bash -c`, `sh -c`, `sudo`, and env-var assignments
before classification. Curl GET (the default) without POST/data/
upload/output flags is now read-only. Output redirection (`>`/
`>>`) disqualifies the read-only path. Tests in
`internal/policy/command_test.go` cover the new behaviors.
2. ✅ **Add a command-scoped `preflight` MCP tool.** The existing `preflight`
in AGENTS.md §3 is entity/service-scoped, not command-scoped. The
agent today has to keep reframing and re-submitting to discover what
the classifier will accept. A command preflight returns
`{risk_class, reason}` synchronously so the agent can decide whether
to submit, rephrase, or surface to the operator.
- Done: new `classify_command` MCP tool in `internal/mcp/tools.go`
that takes `command` + optional `declared_risk` and returns the
exact risk class that `run` would assign. Documented in
`nomos/SOUL.md` with explicit guidance to pre-classify before
`run` when the classification is uncertain — "Do NOT submit a `run`,
get it queued for approval, and then retry with cosmetic variations."
### P1 — Friction ✅
3. ✅ **De-dupe sessions for the same entity + problem.** When a session
is `outcome=partial` against an entity and a new session is created
within 24h with a similar goal, surface the prior session to the
agent at `set_goal` time. Three rclone sessions exist because each
new session started from scratch.
- Done: `cmd/nomos/store.go` gained `recentPartialSessions(ctx,
excludeSessionID, since)`; the `set_goal` handler in
`cmd/nomos/tasks.go` calls it and includes up to 5 prior partial/
failed sessions (with goal + summary) in the response. The agent
is told to search_knowledge or read the prior transcript before
re-planning.
4. ✅ **Quiet the `update_plan_step` scaffolding.** Either (a) make the
agent not call it for single-step sessions (greeting/health-check),
or (b) stop persisting it as a message — keep it only in a
`plan_steps` table that the UI hydrates from `/sessions/{id}/plan`
(which already exists). It currently inflates transcript size and
tool-call counts.
- Done: `completeTask` in `cmd/nomos/store.go` now auto-closes any
in-flight plan steps (pending/running → done on success, →
skipped on partial/failure). SOUL.md §6 documents the new pattern:
"for one-step plans ... propose_plan → answer → complete_task,
skipping the per-step running→done dance entirely."
5. ✅ **Add `blocker` and `closed_at` to the `session` struct.** Set
`blocker` automatically when `outcome=partial`/`failed`: scan the
last assistant message for signatures ("queued for approval",
"cancel", "close this session"). Surface in `/sessions` list so
trends are queryable.
- Done: migration `021_session_blocker_and_closed_at.up.sql` adds the
two columns + backfills `closed_at` for existing terminal sessions
+ adds a partial-index on `closed_at DESC WHERE status IN
('done','failed')`. `cmd/nomos/store.go` `completeTask` sets
`closed_at = now()` and derives `blocker` from the last assistant
message via `deriveBlocker`. The blocker patterns table covers
approval_timeout, user_abandoned, classifier_overreach,
model_refusal, model_empty_response, missing_knowledge,
missing_capability, tool_error.
### P2 — Cosmetic / API ergonomics ✅
6. ✅ **Add aggregates to `/sessions` list.** `message_count`,
`tool_call_count`, `duration_seconds`. Computed server-side at list
time (single SQL pass with LEFT JOINs to `agent_messages` and
`agent_activity`). Eliminates the N+1 transcript fetch I had to do.
- Done: `session` struct in `cmd/nomos/store.go` carries the three
new fields; `listSessionsFiltered`, `getSession`, and
`recentPartialSessions` all populate them.
7. ✅ **Single endpoint that returns both metadata and messages.** Either
enrich `/sessions/{id}` with the full `session` struct, or add
`?include=messages` on the list endpoint. The split-persistence is a
leaky abstraction called out in `eval/main.go:302-303`.
- Done: `GET /sessions/{id}` in `cmd/nomos/main.go` now returns
`{session_id, session, messages}` — the `session` field carries
the full metadata (title, goal, outcome, summary, blocker,
pending_approvals, message_count, tool_call_count, etc.). The
`messages` field is unchanged. Clients that only read `messages`
keep working.
8. ✅ **Filtering & pagination on `/sessions`.** `?outcome=partial&entity_id=...&since=...&limit=20&cursor=...`.
Removes the "fetch everything, filter client-side" pattern in the
skill's own script.
- Done: `cmd/nomos/main.go` `handleSessionsList` parses
`outcome`/`status`/`entity_id`/`blocker`/`since`/`cursor`/`limit`
query params. `listFilter` + `listSessionsFiltered` in
`cmd/nomos/store.go` build a dynamic WHERE + LIMIT. `since`
accepts both RFC3339 timestamps and Go durations ("24h", "7d" →
parsed as hours). The response includes `next_cursor` for paging.
9. ✅ **Auto-title from `goal` (when set), not from the first assistant
text.** Fall back to the assistant text only if no goal. The greeting
session `438ec8bd` has `goal=""` and a useless title; `fefa4fa3` has
goal "Fix the rclone backup so it completes successfully instead of
OOM-killing" — that's the right title.
- Done: `setGoal` in `cmd/nomos/store.go` now sets
`title = goal` on the same UPDATE that sets the goal. The
title-from-first-assistant-text path in `cmd/nomos/main.go`
preserves the goal title when one exists (falls back to
`truncate(finalText, 80)` only when no goal is set). Truncates the
goal title to 120 chars.
10. ✅ **Add `/sessions/{id}/tool_calls` flat view.** Returns
`[{id, name, args, result, error, type, message_id, role, seq,
created_at}]` without the message-shell nesting. Makes jq one-liners
and trend scripts trivial.
- Done: new route in `cmd/nomos/main.go` `handleSessionDetail`;
`getSessionToolCalls` in `cmd/nomos/store.go` walks messages and
flattens `tool_calls[]` into a chronological flat list. Each
tool_use/tool_result pair is emitted as two rows sharing an id
(preserving the persisted shape) — clients that want the merged
shape can group by ID.
---
## Suggested order
If only two land: **P0.1** (parse the inner command for `pct exec` /
`curl` classification) and **P2.6** (aggregates on `/sessions`). The
first eliminates the most visible user-facing friction in this batch
(three duplicate rclone sessions); the second makes future audits like
this one a single `curl | jq` instead of a Python script.
---
## Verification commands
```bash
# Re-pull any session for follow-up
curl -s http://localhost:8092/sessions | jq '.sessions[:10]'
curl -s http://localhost:8092/sessions/a51e2086-a816-4206-a556-dbca362cdda6 | jq .
curl -s http://localhost:8092/sessions/8acea2e3-fc4d-4953-b9df-8e58e59a549a | jq .
curl -s http://localhost:8092/sessions/cb8c8a4a-14a5-4dff-8393-6ed1e7ea7c30 | jq .
curl -s http://localhost:8092/sessions/fefa4fa3-5414-4633-8e5a-51aa4a76609c | jq .
# After P0.1 lands: confirm read-only commands classify as reversible_low
# (whatever the preflight surface becomes — TBC when the tool is added)
```
---
## Related files
- `cmd/nomos/main.go` — `/sessions` and `/sessions/{id}` handlers
(`handleSessionsList` line 363, `handleSessionDetail` line 383)
- `cmd/nomos/store.go` — `session` struct (line 89), `message` struct
(line 103), `listSessions` (line 317), `getMessages` (line 377),
`hasPendingApprovals` (line 962)
- `cmd/nomos/agent.go` — agent loop, retry behavior, goal state
- `cmd/nomos/eval/main.go:302` — comment calling out the
`/sessions/{id}` "only session_id + messages" gap
- `internal/policy/*` — risk-class classifier (target of P0.1)
- `internal/mcp/server.go` — `run` tool, `preflight` (entity-scoped), all
MCP tool implementations
- `nomos/SOUL.md` — agent persona, tool-selection rules
- `.agents/skills/session-review/SKILL.md` — the audit protocol
- `plans/2026-07-18-session-review-three-sessions.md` — prior review;
three sessions overlap with this one
---
## Relationship to the 2026-07-18 review
That review's P0.1 (retry cap), P0.2 (investigate-before-retry SOUL
guidance), P1.3 (runbook capture), P1.5 (bulk inspection tool),
P1.6 (`vm:` target support), P1.8 (ask-before-migrate) all landed or
are tracked separately. This review does **not** re-open them. The
remaining open items from that review are P1.7 (approval window
auto-extends on execution timeout) and P2.9 (long-running command
PENDING detection), both deferred there with rationale; this review
found no new evidence that would change that deferral.

View File

@@ -0,0 +1,79 @@
# 2026-07-21 Chat window full polish
## Context
After fixing the streaming reactivity bug and merging the double thinking
indicator, the chat window still has structural UX gaps: no streaming
affordance while text flows, tools never rendered inline, no timestamps,
no code copy, cross-session store leaks in floating windows, and minor
overflow/style holes.
## Decisions
| Question | Answer |
|---|---|
| Streaming feel | Typing cursor (blinking ▍) + inline indicator |
| Tool calls | Expandable inline tool cards in message flow |
| Empty state | Minimal — title + tagline, no suggestions |
| Dark theme | Keep neutral (skip) |
| Scope | Full polish — everything |
## Changes
### P0.1 Streaming cursor
- **File:** `web/src/lib/components/ChatThread.svelte`
- Add a blinking block-cursor (▍) appended after rendered markdown when
`streaming` is true and the last assistant message has text.
- Keep the inline spinner + activity label for the empty-text state.
- CSS: `@keyframes` blink, `0.8s` cycle, `primary` color, `inline-block`.
### P0.2 Tool call cards
- **New:** `web/src/lib/components/ToolCallCard.svelte`
- **Modify:** `ChatThread.svelte`
- Render `msg.tools` as collapsible cards between text blocks.
- Collapsed: tool icon + name + status (running/done/error).
- Expanded: pretty-printed args + result/error in `pre` blocks.
- Keep it minimal — one card per tool call, no grouping.
- Wire `pendingApprovals` from `msg.pendingApprovals` as approval
cards below the tool list.
### P1.3 Timestamps + role labels
- **Modify:** `ChatThread.svelte`, `ChatMessage` interface
- Add `created_at?: string` to `ChatMessage` (populated from `Message.created_at`).
- Show small muted timestamp (HH:MM) on hover or inline next to role label.
- Add tiny "You" / "Nomos" labels above bubbles (subtle, muted).
### P1.4 Code copy button
- **Modify:** `ChatThread.svelte` prose styles
- Wrap `pre` blocks in a relative container; add a copy button
(clipboard icon, top-right, opacity-0 → visible on hover).
- Use `navigator.clipboard.writeText`.
### P1.5 Table overflow + user bubble fix
- **Modify:** `ChatThread.svelte` prose styles
- Wrap tables in `overflow-x-auto` container.
- Add `overflow-wrap: break-word` to user bubbles.
### P2.6 Cross-session fixes
- **Modify:** `web/src/lib/stores/chat.ts`, `SessionChatWindow.svelte`
- `chatErrors`: keep global for now (session-scoped errors are rare
and the dismiss is manual anyway).
- `activityLog`: **per-session** — the store in `activity.ts` already
derives from messages; make `computeActivityLog` session-scoped
so each floating window only sees its own activity.
### P2.7 Min window size
- **Modify:** `web/src/lib/stores/windows.ts` (openTaskWindow)
- Add `minWidth: 600, minHeight: 400` to chat window open call.
### P2.8 Cleanup
- Delete `web/src/lib/components/AgentIndicator.svelte` (dead code).
- Update stale comments in `SessionChatWindow.svelte` and
`TaskContextPanel.svelte` that reference a "main Chat page."
- Fix prose heading hierarchy: h1 = 1.15em, h2 = 1.1em, h3 = 1.05em.
## Verification
- `npx eslint` on all changed files
- `go vet ./cmd/nomos/...`
- `go build -o /dev/null ./cmd/nomos/...`

View File

@@ -0,0 +1,410 @@
# Plan: Make health reflect reality + complete the knowledge graph
Status: ready for implementation · Created 2026-07-29
## Context
`ws:mac-mini` reports health `down` despite being the healthy control-plane host.
Investigation showed the problem is systemic, not local: **49 enabled checks report
`down`**, almost all `ssh-script`, because the resource/updates probes assume
**scripts are deployed at `/opt/oikos/checks/` AND root SSH works on every target**
both false for macOS, non-enrolled LXCs, and mesh-only entities. The knowledge graph
also has real gaps (unmodeled TLS certs, empty `skills` table, seed drift, a capped
topology view).
The DB is the source of truth; live state was verified via the REST API
(`Authorization: Bearer $OIKOS_MCP_BEARER_TOKEN`, token in `oikos-api-1` container env)
and `docker exec oikos-postgres-1 psql`. Direct psql access is available for cleanup.
## Decisions (confirmed with operator)
1. **Monitoring philosophy: make checks work everywhere** — via the proven `pct exec`/
`qm guest exec` host-routing the MCP `run` tool already uses (no per-guest SSH keys),
plus deploy the check scripts INTO each guest and make them macOS-aware. Hosts/workstations
use direct SSH with the correct per-target user.
2. **Canonical host-hop access**`pct exec`/`qm guest exec` through the proxmox host is
the ONLY execution path for any LXC/VM command (scheduler + MCP `run` + agent). Direct
guest SSH is retired for execution; `lan_ip` stays for network probes only. (A1.)
3. **Auto-provision monitoring for new entities** — wire script-deploy + the
`health-check-answering` lifecycle gate into entity creation so any entity Nomos creates
becomes monitorable with zero manual steps (Track E).
4. **Lifecycle gate: skip monitoring for `deprecated`/`destroyed` targets** — no
permanent false alarms from retired things.
5. **Knowledge graph: address ALL gaps** — model TLS certificates, fix dns-zone gap,
re-export seeds, seed skills, raise graph cap.
6. **Read-only audit skill** — a `read_only` operator skill discovers live infra and diffs
it against the DB graph, producing a ranked drift report; the operator acts on findings
via existing lifecycle runbooks. No auto-fix. (Track F.)
## Findings (evidence)
### A. Health-check reality gaps (49 checks `down`)
**Root cause is a routing mismatch, verified live (tests use the scheduler's own key
`-i /etc/oikos/ssh_key`, not a default-key test):**
The MCP `run` tool already reaches every guest correctly via
`resolveExecTarget` (`internal/mcp/server.go:582`): resolve the proxmox host
(`attributes.host``hosts` edge → hubris default), SSH there, run
`pct exec <pve_id> -- bash -c 'echo <b64> | base64 -d | bash'` (VMs: `qm guest exec`).
That path needs **no per-guest lan_ip, no per-guest authorized_keys, no per-guest sshd**.
The **scheduler's `checkSSHScript` does not use it** — it SSHes directly to each
entity's own resolved address (`internal/scheduler/scheduler.go:758`,
`internal/checkdefaults/defaults.go:376 resolveHost`) and runs
`/opt/oikos/checks/<script>`. That is the bug. Decomposed by class:
| Class | Targets (verified) | Root cause |
|---|---|---|
| **Guests reached wrong** | `lxc:rclone` (mesh-only, no lan_ip), `lxc:nfs-export` (192.168.8.200: **ssh port 22 timeout** — no sshd), `lxc:teddycloud` (**key not authorized** — "not a homelab client"), `lxc:grimmory/romm/seanime` (strong: pct-exec reachable, **scripts not inside**) | scheduler SSHes the guest directly; should route via proxmox host `pct exec` like `resolveExecTarget`. rclone is correctly parented on hubris (`hosts` edge verified) and IS reachable via `pct exec 132` — the mesh fqdn is a red herring. |
| macOS host | `ws:mac-mini` (5 resource/updates checks `down`) | root SSH disabled (macOS); `user: dtoro` never read by resolver (`defaults.go:406` reads `attrs["ssh"]["user"]` only); scripts not deployed; scripts Linux-only |
| External / mesh-only | `host:netbird-vps` (no lan_ip; mesh unreachable from container) | `resolveHost` picks mesh IP over `public_ipv4` (`defaults.go:376`); sshd also "locked to hubris pubkey" |
| Dead route | `ingress:secrets.hubris.network` http `down` | `service:secrets-issuance` is `deprecated` but its ingress check still enabled — no lifecycle gate |
| ICMP-blocked | `vm:haos` ping `down` while up | HAOS blocks ICMP |
**Working** (prove the host-SSH model is sound): `host:hubris`, `host:strong` SSH with
the scheduler key → **SCRIPTS_PRESENT**; `lxc:gitea` direct-SSH → **SCRIPTS_PRESENT**
(it's a homelab client with root key + scripts). So the host hop is the reliable path.
**Parentage verified correct** (all `hosts` edges checked in DB): strong guests on
strong, hubris guests on hubris. No misplaced parents — the gap is routing + in-guest
script deployment, not topology.
Health aggregation itself is correct: `WorstHealthForTarget`
(`internal/db/sqlcgen/operations.sql.go:1472`) = worst enabled check. One failing
ssh-script drags an otherwise-healthy entity to `down`.
### B. Dead/stale data
- **24 orphan check_defs** + check entities, slugs `^check:(ping|ssh-script|disk):[0-9a-f]{8}$`
(e.g. `check:ssh-script:0d31fdd1`), `enabled=false`, `last_health=NULL`, `state=NULL`.
Leftover from the old `shortSlug()` collision bug (fixed in `defaults.go:263`).
- `service:secrets-issuance` = `deprecated`; `ingress:secrets.hubris.network` still
routes to it and alarms permanently.
### C. Knowledge-graph gaps
- **TLS certificates unmodeled**: `certificate` type + `uses-certificate` edge + `cert-expiry`
checker all exist, but **0** certificate entities. Cert expiry is invisible.
- **`dns-zone` declares `monitoring: [dns]`** (`seeds/ontology.yaml:382`) but no `dns`
checker exists → every zone is an `unmonitored` signal.
- **Seed drift**: 23 `dns-record` entities in DB, 0 in `seeds/inventory.yaml`.
- **`skills` table = 0** despite `.agents/skills/*/SKILL.md` on disk (runbooks = 15).
- **Graph capped at 500 nodes** (`internal/httpapi/impl.go:27 graphNodeCap = 500`);
299 `execution` + 87 `task` rows dominate, so `/graph` is not a faithful topology view.
---
## Work breakdown
### Track A — Make ssh-script checks work everywhere (route through the proxmox host)
Core idea: stop having the scheduler SSH each guest directly. Reuse the MCP `run`
tool's proven `resolveExecTarget` pattern — reach every LXC/VM **through its proxmox
host** via `pct exec`/`qm guest exec`. This fixes rclone (no lan_ip), nfs-export
(no sshd), teddycloud (no key), and every strong guest in one stroke, because the host
hop already has working root SSH. Hosts/workstations keep direct SSH.
**A1. Canonicalize host-hop as the ONLY execution path for LXC/VM (the real fix + simplification).**
Principle: **never SSH directly into a guest to run a command.** Every LXC/VM command
execution — scheduler checks, the MCP `run` tool, and the agent — routes through the
owning proxmox host via `pct exec <pve_id> -- ...` (VMs: `qm guest exec`). One SSH
credential per host (root key, already authorized on hubris/strong), no per-guest keys,
sshd, or lan_ip needed for execution. Verified this works: `pct exec 132` reaches rclone;
the MCP `run` tool already does it for every guest (`internal/mcp/server.go:582`).
- Network probes (http/ping) keep hitting the guest's `lan_ip`/URL directly — they don't
execute inside the guest, so they're unaffected. For LXCs all checks are ssh-script, so
they all route via the host; `lan_ip` becomes optional metadata, not a monitoring prereq.
- Extract `resolveExecTarget`/`resolveProxmoxHostSlug` out of `internal/mcp` into a shared
package (e.g. `internal/remote`) so the scheduler's `checkSSHScript`
(`internal/scheduler/scheduler.go:710`) and `checkBackupFreshness` (`backup.go:79`, the
other direct-SSH path) and the MCP `run` tool share ONE resolver. Today they diverge —
the scheduler SSHes guests directly (broken), MCP host-hops (works).
- `checkSSHScript`/`checkBackupFreshness`: when the target is `lxc:`/`vm:`, resolve the
proxmox host and wrap the invocation as `pct exec <pve_id> -- bash -c 'echo <b64> |
base64 -d | bash'` (VMs: the `qm guest exec` form at `server.go:625`). For `host:`/`ws:`
keep direct SSH (they ARE the host).
- **Risk class:** `config_mutation` (changes how probes reach every guest) → operator
approval. Verify one LXC end-to-end (rclone) before fanning out.
**A2. Deploy check scripts INTO guests (via `pct push`), not just to the host.**
- Verified: scripts exist on hubris/strong (the hosts) but `NO_SCRIPTS` inside grimmory,
romm, seanime, rclone. A `pct exec`-routed check still runs inside the guest, so the
scripts must live in the guest.
- Add a fleet-deploy tool (`tools/deploy-checks.sh`): for each LXC, from its proxmox
host, `pct push <id> checks/<script> /opt/oikos/checks/<script>` + chmod 755 (loop the
`checks/*.sh` set). For VMs, scp/agent; for hosts/workstations, run `checks/install.sh`.
- Backfill once now (all guests + mac-mini). See Track E for the automated version.
**A3. Fix per-target SSH user + resolver (hosts/workstations only).**
- `internal/checkdefaults/defaults.go:406 resolveSSHUser`: also read top-level
`attrs["user"]` (workstations carry `user: dtoro`, not `ssh.user`). Returns `dtoro`
for mac-mini. Re-derive mac-mini's check_defs so config carries the user.
- **Do NOT enable root SSH on mac-mini** — use `dtoro` (keeps macOS hardening).
**A4. macOS-aware check scripts.**
- `checks/cpu_check.sh:5` `top -bn1` (Linux) → branch on `uname -s == Darwin`
(`top -l 1`/`sysctl`). Same for `memory_check.sh`, `load_check.sh`, `disk_usage_check.sh`
(`df` differs), `updates_check.sh` (already apt-guarded; on Darwin report `healthy`
with `security_updates=0` or read `softwareupdate --list`).
- Each must still emit `{"health":..,"metrics":{..}}` JSON
(`internal/scheduler/scheduler.go:767`).
**A5. Reachability for external/mesh-only hosts.**
- `internal/checkdefaults/defaults.go:376 resolveHost`: prefer `public_ipv4` over mesh IP
for `standalone-server`/external so `host:netbird-vps` (82.165.190.79) is probeable.
Note sshd is "locked to hubris pubkey" (`inventory.yaml:89`) — either add the scheduler
key or proxy via hubris. Confirm before assuming direct SSH works.
- `ws:republic-laptop`: roving laptop on mesh only. ping-`down` when asleep is real;
keep ping-only and accept transient `down`, or set `monitoring: none`. (Decision in
Open Questions.)
- `lxc:rclone` no longer a special case — handled by A1's pct routing.
**A6. ICMP-blocked VMs.**
- `vm:haos` ping `down` while up: optional `tcp`-ping fallback in `checkPing`
(`internal/scheduler/scheduler.go:604`) for VMs that block ICMP, gated by an attribute.
Lower priority — confirm haos blocks ICMP before building.
### Track B — Lifecycle monitoring gate
**B1. Skip monitoring for deprecated/destroyed targets.**
- Disable (set `enabled=false`) and skip-scheduling `check_defs` whose `target` entity
`state` ∈ {`deprecated`,`destroyed`}.
- Implement by joining target state in `ListEnabledCheckDefs`
(`internal/db/sqlcgen/operations.sql.go`, the `ListEnabledCheckDefs` query) — exclude rows
whose target is retired — **or** in a `housekeeping` sweep
(`internal/scheduler/scheduler.go:302`) that disables them. Prefer the query filter
(no write needed at runtime).
- Matches `policy.yaml` lifecycle philosophy (`destroyed.refuse: all`); extend the comment.
- Effect: dead `ingress:secrets.hubris.network` alarm goes silent automatically.
### Track C — Dead-data cleanup
**C1. Delete 24 orphan check_defs + check entities.**
- Direct SQL (have psql access): delete `check_defs` then `entities` matching
`slug ~ '^check:(ping|ssh-script|disk):[0-9a-f]{8}$'`. Confirm `state IS NULL` /
`enabled=false` first (already verified).
- Wrap as a one-shot migration or `scripts/cleanup-orphan-checks.sh`. **Risk class:** read
the rows first; this is `config_mutation` → operator approval.
**C2. Retire the secrets route.**
- With B1 in place the alarm silences. Optionally set `ingress:secrets.hubris.network`
`deprecated`/`destroyed` and remove its `routes-to` edge to service:secrets-issuance
(or keep as archaeology). Decide with operator.
**C3. Destroy 7 stray test LXCs (active cruft in the graph).**
- DB shows these with live `hosts` edges on strong, never cleaned up:
`lxc:preflight-test`, `lxc:preflight-test2`, `lxc:test-autocontinue`,
`lxc:test-decompose3`, `lxc:test-livewatch`, `lxc:test-livewatch2`, `lxc:typetype`.
- First confirm they're really gone in Proxmox (`pct list` on strong); if so, set their
entity state → `destroyed` (move to archaeology) and drop the `hosts` edges. If any
container still exists, destroy via `pct destroy` first (destructive → approval).
- They currently generate checks and pollute the graph/health view.
### Track D — Knowledge graph
**D1. Model TLS certificates.**
- Seed `certificate` entities (one per `*.hubris.network` route, or per Caddy-managed
cert) + `uses-certificate` edges from each `ingress-route`.
- Source real data: read Caddy's cert store (LXC 121) expiry via the existing `cert-expiry`
checker's discovery, or seed from Caddyfile and backfill `expires` live.
- Wires the `cert-expiry` checker (`internal/scheduler/scheduler.go`, `cert-expiry` kind)
against real entities instead of nothing.
**D2. dns-zone monitoring gap.**
- `seeds/ontology.yaml:382`: change `dns-zone` `monitoring: [dns]``monitoring: none`
with a comment "no dns checker yet; revisit when implemented". Stops the per-zone
`unmonitored` noise. Re-seed.
**D3. Re-export seeds to fix drift.**
- Run `oikos export` (or the export endpoint) so the 23 runtime `dns-record` entities +
other runtime-created topology land in `seeds/inventory.yaml`. Diff, review, commit.
**D4. Seed skills from disk.**
- Ingest `.agents/skills/*/SKILL.md` as `skill` entities (mirror how runbooks seed → 15
exist). Add to the knowledge seed ingest path (`internal/db/seed.go`) or a one-shot
ingest. `get_skills()` then returns data.
**D5. Raise graph node cap.**
- `internal/httpapi/impl.go:27 graphNodeCap = 500` → raise (e.g. 5000) **and/or**
paginate `/api/v1/graph`. Ensure the query stays performant (it already limits by default;
confirm no full-table risk). Optionally exclude cognition rows (`execution`/`task`) from
the default topology view via a `?layer=infrastructure` filter so infra isn't crowded out.
### Track E — Auto-provision monitoring when a new entity is created
Goal: the operator's request — "make sure this is handled automatically in the future
when the agent creates new entities." Today `ensureDefaultChecks`
(`internal/httpapi/default_checks.go:9`) writes check_defs on entity creation but does
NOT make the target probe-ready (no script deploy, no host-routing). Its own comment
admits the gap. A new entity should become monitorable with zero manual steps.
**E1. Hook script-deploy into entity creation / provisioning.**
- Extend `ensureDefaultChecks` (called on entity create, `default_checks.go`) so that,
after writing check_defs, it also ensures the target can answer:
- **LXC/VM**: `pct push` the `checks/*.sh` set into the guest from its proxmox host
(reuse the host resolution from A1). Idempotent (skip if present + unchanged).
- **host/workstation**: ensure scripts at `/opt/oikos/checks/` (run `checks/install.sh`
over SSH; locally on mac-mini).
- Because the check itself is routed via `pct exec` (Track A), no per-guest SSH key or
sshd is needed — host hop + in-guest scripts are the only prerequisites, both now
automated. mac-mini still needs its `dtoro` key (A3) once.
**E2. Tie into the lifecycle `provisioning → active` gate.**
- The ontology already requires `health-check-answering` for `provisioning → active`
(`seeds/ontology.yaml:39`, checked by `internal/ontology/validate.go:167`).
- Make that gate actually run one check against the new entity and require a non-`down`
verdict before the transition is allowed. This closes the loop: an entity isn't "active"
(and isn't trusted for blast-radius/auto decisions) until monitoring proves it answers.
**E3. Re-run on re-seed / attribute change.**
- `checkdefaults.Ensure` already re-derives check config from the seed on re-ingest
(`internal/checkdefaults/defaults.go:301`, seed wins, `enabled` preserved). Mirror that
for script deploy: when `pve_id`/`host`/address attributes change, re-target the check
and re-deploy scripts to the new guest.
**Net effect:** a new LXC provisioned by Nomos (via `pct_create`, which registers the
entity + `hosts` edge, `internal/httpapi/actuator.go:615`) automatically gets
script-pushed + check_defs + a passing `health-check-answering` gate before going active.
### Track F — Read-only knowledge-graph audit skill
Goal: the operator's request — a skill that auto-discovers live infra and validates the
knowledge graph (entities, parentage, checks, scripts, seeds, certs) against reality,
producing a ranked drift report. **Read-only; no auto-fix** — the operator routes each
finding to the relevant lifecycle runbook.
**Precedent (reuse, don't duplicate):** existing drift/quality machinery is fragmented and
knowledge-content focused. The audit orchestrates these + fills the topology/script gaps:
- `internal/httpapi/knowledge_drift.go` — duplicate notes, orphan notes, tag splits (already endpoints).
- `internal/scheduler/coverage.go coverageSweep` — unmonitored declared types (re-use its logic/SQL).
- MCP discovery: `list_lxcs` (`internal/mcp/tools.go:478`), `get_lxc_state`, `list_entities`,
`get_relations`, `http_get`. These already enumerate live LXC/VM state from the proxmox host.
**F1. Add an on-demand audit primitive (MCP tool + endpoint).**
- New MCP tool `audit_knowledge_graph` (+ `GET /api/v1/audit/drift`) — read-only, runs the
discovery+diff in one pass and returns a ranked report. Each finding = `{category, severity,
entities, evidence, suggested_runbook}`.
- Discovery sources (all via the canonical host-hop / existing tools): `pct list` + `pct
config` on hubris & strong (guests, `net0` IP, onboot state); `qm list` (VMs); Caddy admin
API / Caddyfile (routes → certs); docker `ps` on compose hosts; the `checks/*.sh` set vs
what's deployed at `/opt/oikos/checks/` per target.
- Report categories (the gaps this investigation found):
1. **Ghost entities** — in DB but not in Proxmox (e.g. stray `lxc:test-*`).
2. **Missing entities** — in Proxmox/Caddy/docker but no DB entity.
3. **Misplaced parent** — `hosts` edge disagrees with where the guest actually runs (the
rclone class — though rclone's parent is correct; this catches real migrations).
4. **Orphan/dead checks** — `check_defs` whose target is deprecated/destroyed, or random-slug
orphans (`^check:(ping|ssh-script|disk):[0-9a-f]{8}$`).
5. **Undeployed scripts** — checks expect `/opt/oikos/checks/<script>` but it's absent in
the guest (the strong-guest/rclone class).
6. **Unmonitored declared types** — reuse `coverageSweep` SQL (dns-zone today, agents).
7. **Seed drift** — entities/edges in DB but not in `seeds/inventory.yaml` (23 dns-records),
via `oikos export` diff.
8. **Unmodeled certs** — Caddy serves a cert with no `certificate` entity + `uses-certificate` edge.
9. **Knowledge rot** — delegate to the existing `knowledge_drift` endpoints (duplicates/orphans/tags).
**F2. Author the skill.**
- `.agents/skills/knowledge-graph-audit/SKILL.md` — front-matter
`risk_class: read_only`, `inputs: [scope?]`, `verification: "drift report returns ok"`.
Body: run `audit_knowledge_graph`, read the ranked report, and for each category point at
the remediation runbook (`lifecycle-deprecate-node`, `lifecycle-destroy-node`,
`config-change-deploy` for scripts, `lifecycle-migrate-node` for parents, this plan's
tracks for cert/seed/graph-cap work). No mutating steps.
- Seed a matching `runbook:knowledge-graph-audit` entity in `seeds/knowledge.yaml`
(bound by `applies_to_type`) so `search_knowledge`/`get_skills` surface it (also fixes the
empty-skills-table gap, Track D4).
**F3. Optional: periodic sweep (later).** Wrap categories 4/6 as a scheduler housekeeping
sweep that raises `drift` signals, mirroring `coverageSweep`. Out of scope for this plan
unless the operator wants continuous drift signals; the on-demand skill is the deliverable.
**Risk class:** `read_only`. The audit only reads (pct list/config, docker ps, Caddy API,
DB selects, an `oikos export` to a temp file). No writes. Safe to run unattended.
---
## Validation
After each track, verify via API (read-only, no approval):
- `GET /api/v1/entities/ws:mac-mini` → `health` ∈ {healthy,degraded} (not `down`).
- `GET /api/v1/entities/lxc:rclone` → `health` healthy (proves pct-routing through hubris;
rclone currently unreachable because it resolves to a mesh fqdn). Verify its checks now
route via `pct exec 132` on hubris.
- Strong guests (`lxc:grimmory`, `lxc:romm`, `lxc:seanime`) → ssh-script checks healthy
after scripts pushed inside + routed via strong's `pct exec`.
- `GET /api/v1/checks?include_disabled=false` → `down` count drops from 49 to the
genuinely-down set (republic-laptop asleep, real outages only). Re-run the per-class table.
- `GET /api/v1/entities/service:secrets-issuance` + its ingress → no enabled check.
- Orphan cleanup: `SELECT count(*) FROM check_defs cd JOIN entities e ON e.id=cd.entity_id
WHERE e.slug ~ '^check:(ping|ssh-script|disk):[0-9a-f]{8}$';` → 0.
- Test LXCs (C3): `SELECT count(*) FROM entities WHERE slug IN
('lxc:preflight-test','lxc:test-livewatch',...) AND state<>'destroyed';` → 0.
- Provision a throwaway LXC via Nomos → it auto-gets scripts + check_defs + passes
`health-check-answering` before reaching `active` (E1/E2).
- `GET /api/v1/entities?type=certificate&limit=1` → >0; cert-expiry checks created.
- `GET /api/v1/entities?type=skill&limit=50` → >0.
- `GET /api/v1/graph` node count > 500 (or infra fully represented with a layer filter).
- `oikos export` diff shows dns-record entities present; `git diff seeds/inventory.yaml`.
- Scheduler logs: `checkdefaults: declared check not created` warnings gone for dns-zone.
- **Canonical access (A1):** no scheduler code path SSHes a guest directly —
`grep -rn "sshExec" internal/scheduler` shows it only for `host:`/`ws:` targets; LXC/VM
go through the shared `pct exec`/`qm guest exec` resolver.
- **Audit skill (F1/F2):** `audit_knowledge_graph` MCP tool returns a ranked report with
the 9 categories; running it against current state reproduces this plan's findings
(orphan checks, stray test LXCs, undeployed scripts, seed drift, 0 certs). The skill
is read-only — confirm it performs no DB writes (audit-log shows only reads).
Unit/integration tests to add/update:
- `internal/checkdefaults` / shared `internal/remote` resolver: LXC/VM check routes via
`pct exec`/`qm guest exec` to the resolved proxmox host; resolver reads top-level `user`;
`public_ipv4` preferred for standalone-server (`defaults_test.go`).
- `internal/scheduler`: `ListEnabledCheckDefs` excludes deprecated/destroyed targets
(new test); `coverage_test.go` still green; `sshExec` no longer called for guest slugs.
- macOS script branches: assert JSON shape unchanged on `Darwin` (shunit2 or a smoke run).
- E1: new-entity creation triggers script push (mock pct/SSH in test).
- F1: `audit_knowledge_graph` against a fixture DB+mock discovery returns the expected
category counts (ghost, missing, orphan, undeployed, drift).
## Risks
- **Canonical host-hop (A1)** makes each proxmox host the single SSH dependency for all its
guests. This is already true (pct exec requires the host up) and is a net improvement
(one credential vs many), but a host outage now fails all its guest checks together —
which is the *correct* blast radius (guests are unreachable when their host is down).
- **Routing change (A1)** alters how probes reach every guest — `config_mutation`. Verify
one LXC end-to-end (rclone via `pct exec 132`) before fanning out. Extracting
`resolveExecTarget` into a shared package keeps scheduler + MCP in lockstep.
- **Script push into guests (A2/E1)** writes to guest filesystems — `config_mutation`.
Idempotent + content-checked; never clobber a same-named operator script without diffing.
- **mac-mini root SSH**: do NOT enable root login; use `dtoro` (A3) — keeps macOS hardening.
- **`netbird-vps` sshd locked to hubris pubkey**: may need the scheduler key added or
proxying via hubris; confirm before assuming direct SSH works (A5).
- **`health-check-answering` gate (E2)** could block a legitimately-active entity whose
only working check is ICMP-blocked (haos). Allow the gate to pass on any non-`down`
reachable probe, or grant an operator override.
- **Audit skill (F1)** discovers infra via `pct`/Caddy/docker reads — keep it strictly
read_only; ensure discovery commands are in the read-only allowlist (no state change).
- **Seed re-export** can surface large diffs (cognition entities) — scope export to
topology entities, or review carefully before commit. Bump `VERSION` per repo rule.
- **Graph cap raise**: large node sets may slow the graph render; pair with a layer filter.
## Open questions (none blocking; confirm during implementation)
- republic-laptop: mesh-only roving laptop — keep ping-only (accept transient `down`) or
`monitoring: none`? (A5)
- secrets ingress: keep as archaeology or destroy the route? (C2)
- certificates: seed statically from Caddyfile, or auto-discover live from Caddy store? (D1)
- netbird-vps: add scheduler key to its sshd, or always proxy through hubris? (A5)
- Audit discovery for docker hosts/stacks: enumerate via `docker ps`, or model compose
stacks only? (F1)
## Suggested order
A1 (canonical host-hop routing — unblocks rclone + all guests) → A2 (push scripts into
guests) → A3 → A4 (mac-mini) → A5 → E1/E2 (automate for new entities) → B1 → C1 → C3 → C2
→ D2 (quick, silences dns noise) → F1/F2 (audit skill — also validates the above worked)
→ D1 → D4 → D3 → D5. Validate after each track.

View File

@@ -0,0 +1,293 @@
# 2026-07-30 — Session review: plan drift & a dead activity panel
**Status:** Done — 2026-08-03. Shipped in `467589d` (v0.14.1), deployed to
production. The two operator-reported complaints are resolved and verified on
the bug-report session itself (`398f5eda`); see [Resolution](#resolution-2026-08-03)
at the end. Items P0.1, P0.2 (fix 1+2), P1.1, P1.2 are complete; P0.2 fix 3,
P2.1, P2.2 are deferred (the reported symptoms no longer reproduce).
**Scope:** The five most-recently-active `agent:nomos` sessions by
`last_active_at`, pulled from the live Postgres on 2026-07-30, plus the
code paths they exercise (`cmd/nomos/store.go`, `cmd/nomos/tasks.go`,
`web/src/lib/stores/{activity,workspace,chat}.ts`,
`web/src/lib/components/UnifiedTimeline.svelte`).
**Trigger:** Operator report — "the plan was off, the activity sidepanel
was not kept up to date and feels off, not live."
Both complaints are real, both reproduce deterministically, and both have
a single-line root cause. They are *not* the same bug, but they compound:
the plan bug produces the exact event stream that the activity panel
silently discards.
---
## Sessions reviewed
| # | sid | goal (short) | outcome | activity rows | plan gens | re-planned? |
|---|---|---|---|---|---|---|
| 1 | `398f5eda` | hubris recurring network outage → EEE mitigation | success | 48 | 2 | **yes** |
| 2 | `0a49ba3d` | triage active signals on host:strong | success | 30 | 1 | no |
| 3 | `9368633d` | sensor temperatures on host:strong | success | 12 | 1 | no |
| 4 | `2065a29a` | temps → pivot to "fun fact about chickens" | success | 18 | 2 | **yes** |
| 5 | `bad26076` | greeting / responsiveness test | success | 6 | 1 | no |
**Score: 5 success / 0 partial / 0 failed.** The agent's *reasoning* was
fine in all five. Every defect below is in the bookkeeping and the
rendering — the parts the operator actually looks at.
**The correlation that matters: both sessions that re-planned (`398f5eda`,
`2065a29a`) recorded a corrupt plan. Neither of the three that didn't
re-plan did.** Re-planning was a 100% failure path (pre-fix).
---
## P0.1 — `update_plan_step` addresses the wrong plan generation
This is "the plan was off," and it was fully deterministic.
`proposePlan` numbered a new generation's steps *continuing* from the old
one (`store.go:937`):
```go
seq := startSeq + i + 1 // startSeq = MAX(seq) of all prior steps
```
So on generation 2 of `398f5eda`, the six new steps landed at **seq 712**.
But the tool result the model got back never mentioned those numbers
(`tasks.go:282`):
```
"Plan set (6 steps). If all steps are read-only, execute now — …"
```
…while `update_plan_step`'s schema told it (`tasks.go:81`):
```go
"seq": "1-based step number from propose_plan."
```
The model had no way to learn the real seq numbers and was explicitly told
to use 1-based ones. It did exactly that.
**What the DB recorded for `398f5eda`:**
```
20:07:40 propose_plan → gen 2 created at seq 7..12
gen 1 (seq 1..6) marked `replaced`
20:08:10 update_plan_step seq=1 running ← hits gen-1 step 1
20:08:18 update_plan_step seq=1 done ← resurrects a `replaced` row
20:08:18 update_plan_step seq=2 running
20:09:57 complete_task
```
Result — the persisted plan was a lie in three separate ways:
- **Steps 15 (the abandoned "force 1Gbps" plan) show `done`** with real
start/finish timestamps. Work that was never performed was recorded as
performed. `updatePlanStep` wrote status by seq with no guard, so it
happily flipped `replaced``running``done`.
- **Steps 712 (the actual EEE work that ran) had `started_at = NULL`**
and were bulk-closed to `done` by `completeTask`'s auto-close sweep
(`store.go:1096`) at 20:09:57 — all six sharing one timestamp.
- **The panel shows 12 steps**, because `getPlanSteps` returned every
generation unfiltered (`store.go:1356`) and the frontend never reads the
`generation` field at all (`grep generation web/src` → zero hits outside
the API type).
`2065a29a` had the identical signature: gen 2 at seq 45, gen-1 steps 1
and 2 flipped to `done`/`skipped` four seconds later.
### Fix (implemented)
1. **Make seq generation-relative.** `proposePlan` resets seq to `1..N` per
generation; `(session_id, generation, seq)` is the addressing key.
`updatePlanStep` resolves against `MAX(generation)`. This matches what
the model naturally does and what every prompt already says.
2. **Return the seq numbers to the model.** The `propose_plan` result now
enumerates them (`1=…; 2=…`).
3. **Refuse writes to superseded rows.** `updatePlanStep` addresses only
the current generation; a stale/out-of-range seq returns
`errPlanStepNotFound` (never resurrects a `replaced` row).
4. **Filter by generation on read.** `getPlanSteps` returns only
`MAX(generation)` by default; `?all=true` for the audit/eval view.
5. **Stamp `started_at` in the auto-close sweep.** `completeTask` closing
a step sets `started_at = COALESCE(started_at, now())`.
A migration (`029`) renumbers existing rows to per-generation `1..N` and
replaces the `(session_id, seq)` index with a unique
`(session_id, generation, seq)`.
---
## P0.2 — The activity panel invents its own timestamps
This is "not live / feels off," and it was worse than a staleness bug: the
times on screen were **fabricated at render time**.
`activity.ts:118` — every tool entry:
```ts
timestamp: now - ($msgs.length - mi) * 1000
```
`now` was `Date.now()` captured at the top of `computeActivityLog`. So a
tool call's displayed time was *"the moment this function last ran, minus
one second per message from the end."* Not when the call happened.
Three consequences, all of which read as "not live":
- **The clock was wrong.** `UnifiedTimeline` rendered these through
`hhmm()` / `hhmmss()`, so opening yesterday's session showed every step
timestamped *right now*, one second apart.
- **It churned every 3 seconds.** The message poller re-set `messages`
unconditionally on every tick, which re-derived `activityLog`, which
re-captured `now`. Every entry's timestamp marched forward 3s at a time,
forever. Motion with no information.
- **Real and fake timestamps sorted together.** Plan steps used the
genuine `started_at`; tool calls used the synthetic value; the final
sort mixed them. Steps with no `started_at` fell back to `now` — **97 of
339 non-pending steps in the DB (29%) had `started_at = NULL`** — so they
landed at the bottom of the timeline regardless of when they ran.
The real data already existed and was already served: `agent_activity`
holds true `ts`, `duration_ms`, `success`, and
`correlation_id = session_id`, exposed at `GET /agent-activity`. The panel
ignored it and reconstructed a worse version from the message blob.
### Fix (implemented — fix 1 + 2)
1. **Carry real timestamps on tool calls.** `computeActivityLog` uses each
tool call's message `created_at` (a true persisted time). The
`now - (len - mi) * 1000` expression is gone entirely.
2. **Only fall back to wall-clock for genuinely-live entries, and freeze
it once assigned** — a `Map<id, timestamp>` outside the derivation, so
re-deriving never moves an existing entry. This is what kills the churn.
Deferred to a later pass: backing the panel with `agent_activity` for
historical sessions (fix 3, unlocks `duration_ms`) — the two reported
symptoms (wrong clock, churn) no longer reproduce without it.
---
## P1.1 — Plan-step events for a superseded generation were silently dropped
The frontend half of P0.1, and the reason the panel *froze* rather than
merely showing wrong steps.
On `plan.proposed` with `appended: false`, the store replaced its step
list wholesale — so after the re-plan it held seq 712. Every subsequent
`plan.step.started` / `plan.step.finished` carried seq 15 and a gen-1
`step_id`, and `applyPlanStepEventTo` bailed on no match:
```ts
if (i === -1) return steps
```
So for the entire second half of `398f5eda` — the half where all the real
work happened — **the panel showed six pending steps and nothing ever
moved.** Then `completeTask` closed them in the DB while emitting only
`task.status`, no per-step events, so they stayed pending on screen even
after the session finished.
### Fix (implemented)
- Fixing P0.1 removed the cause (the events now carry the correct
generation-relative seq + the panel's current steps match). The `i === -1`
branch now `console.warn`s and increments an exported
`droppedPlanStepEvents` counter instead of returning silently, so the
next divergence is visible instead of looking like a dead UI.
- **`completeTask`'s auto-close sweep now emits `plan.step.finished` per
closed step** (scoped to the current generation). General rule enforced:
no plan-step status change without a corresponding event.
---
## P1.2 — Every plan carried a duplicate writeback step
In `398f5eda` gen 2, step 11 was the model's own writeback step and step 12
was the auto-appended one. The detector substring-matched the literal tool
names `update_entity_attributes` / `create_relationship` in the title or
detail; the model wrote a natural-language equivalent, so the match failed
and a redundant step was appended. Same pattern in `0a49ba3d` and
`9368633d`.
### Fix (implemented)
Broadened the detector to a case-insensitive check for `write back` /
`writeback` / `upsert_knowledge` in the title or detail, on top of the
existing tool-name match.
---
## P2.1 — Long unexplained stalls, invisible in the UI *(deferred)*
- `bad26076`: a greeting took **16 minutes** wall-clock with 6 activity rows.
- `2065a29a`: step 1 showed `started_at``finished_at` spanning **16 minutes**
for a `sensors` call that returned in milliseconds.
The work took under a second; the step was *open* for 16 minutes. The panel
has no way to distinguish "working" from "waiting for a nudge." Surfaces a
step's idle time: mark a `running` step *stalled* when it has had no
`agent_activity` row for >60s. Deferred — needs the `agent_activity`-backed
panel (P0.2 fix 3).
## P2.2 — `agent_activity` is a single-type table *(deferred — decision)*
All rows are `activity_type = 'tool_call'`. Either start emitting the other
types the schema anticipates (`reasoning`, `plan`, `error`) or drop the
dimension. Worth a decision, not urgent.
---
## Recommended sequence (executed)
| Order | Item | Status |
|---|---|---|
| 1 | P0.1 fix 3 + 4 (refuse superseded writes, filter on read) | done |
| 2 | P0.2 fix 1 + 2 (real timestamps, frozen fallback) | done |
| 3 | P1.1 (emit events from the auto-close sweep) | done |
| 4 | P0.1 fix 2 (generation-relative seq) + migration | done |
| 5 | P1.2, P2.1 | P1.2 done; P2.1 deferred |
| 6 | P0.2 fix 3 (back the panel with `agent_activity`) | deferred |
| 7 | P2.2 | deferred |
## Regression coverage (added)
- `store_test.go`: `TestUpdatePlanStep_GenerationRelative` — re-plan →
`update_plan_step(seq=1)` must address gen-2 and never resurrect a
superseded gen-1 `replaced` row; out-of-range seq → `errPlanStepNotFound`.
- `store_test.go`: `TestCompleteTask_AutoCloseEmitsEvents` — auto-close
emits one `plan.step.finished` per closed step and stamps `started_at`.
- `store_test.go`: `TestProposePlan_RefuseInFlight` — updated for
generation-relative seq + `?all=true`.
- `web/src/lib/stores/activity.test.ts`: `computeActivityLog` is pure w.r.t.
wall-clock (two calls 50ms apart → identical output), persisted tool calls
use real `created_at`, live entries freeze instead of churning.
---
## Resolution (2026-08-03)
Shipped in commit `467589d` (VERSION `0.14.0``0.14.1`), pushed to
`origin/main`, deployed via the Gitea webhook (`scripts/deploy.sh`):
pg_dump → pull → `docker compose build``up -d` → health check (healthy).
Verification on the bug-report session `398f5eda` post-migration:
```
gen 1: seq 1..6 (the abandoned "force 1Gbps" plan — superseded)
gen 2: seq 1..6 (the real EEE work — was seq 7..12, now normalized to 1..6)
```
- `schema_migrations` v29 applied; old `idx_plan_steps_session` dropped,
unique `idx_plan_steps_session_gen_seq` in place.
- Containers recreated; `healthz` and `/agent/sessions/:id/plan` HTTP 200.
- Full `cmd/nomos` suite (23 tests) + web suite (70 tests) green; `go vet`
clean; ESLint/Prettier clean.
Note: historical `started_at = NULL` on already-completed steps (visible on
`398f5eda` gen 2) is left as-is — backfilling would fabricate times. Going
forward `completeTask` stamps `started_at`, and the frontend freezes
NULL-started steps stably so they no longer churn.

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 — Review: nomos chat reliability/UX changes (F1F7)
**Status:** Implemented (P0, P1, P2 all done). See
[Resolution](#resolution) at the end.
A critical self-review of the uncommitted F1F7 changeset
(`plans/2026-08-03-nomos-chat-reliability-and-ux-audit.md` Resolution). The
change set is mostly sound and builds/tests green, but **F1 introduced one
real lost-work regression** by changing the contract of `resumeSession` (it can
now skip) without updating two callers that mutate state *before* calling it.
That must be fixed before this ships.
## What was changed (for orientation)
- F1 `cmd/nomos/turngate.go` (+test): per-session single-flight; `resumeSession`
acquires non-blocking and **skips** if a turn is active; `handleChat` live path
acquires with a 5s wait.
- F2/F3 `web/src/lib/stores/chat.ts`: humanized errors, `clearTurnState` on
terminal `task.status`, turn-free reconnect.
- F4 streaming in global `activityLog` + inline `ToolCallCard`.
- F5 artifact/knowledge deep links; F6 step-first headline; F7 stable layout.
---
## P0 — F1 loses finished-execution continuations (must fix before shipping)
**Bug.** `processContinuations` (`cmd/nomos/continue.go:166-167`) calls
`a.store.markContinued(ctx, p.ExecID)` **before** dispatching
`continueSession → resumeSession`. `markContinued` sets `continued_at`, and
`pendingContinuations` (`store.go:1763`) filters `WHERE continued_at IS NULL`
so a marked execution is **never re-queued**.
Before F1, `resumeSession` always ran, so marking-first was safe. F1 made
`resumeSession` skip when a turn is already active for the session. Now:
- **Two executions for one session finish near-simultaneously** (the common
multi-step case): the loop marks BOTH, spawns two goroutines; goroutine 1
acquires and runs, goroutine 2's `resumeSession` **skips** → execution 2 is
marked continued but its result is **never fed back to the agent. Lost.**
- **A live turn is streaming when an async execution finishes**: continuation
marks + dispatches; `resumeSession` skips (live turn holds the permit) →
result lost.
This silently drops auto-continuation — worse than the interleaving F1 set out
to fix.
**Fix.** Make `resumeSession` report whether it actually ran, and mark-continued
only after a successful run; on a busy-skip, leave the execution pending for the
next worker tick.
1. `cmd/nomos/continue.go` — change `resumeSession` to return `bool`:
```go
func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool {
if !a.gate.acquire(sessionID, 0) {
slog.Info("nomos: turn already active, skipping background resume", "session", sessionID)
return false
}
defer a.gate.release(sessionID)
…existing body…
return true
}
```
2. `continueSession` — mark only after a real run; on skip, leave pending:
```go
func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status)
if !a.resumeSession(ctx, p.SessionID, buildContinuationNote(p)) {
slog.Info("nomos: continuation deferred — a turn is active; will retry next tick", "session", p.SessionID, "execution", p.ExecID)
return
}
a.store.markContinued(ctx, p.ExecID)
}
```
3. `processContinuations` — **delete** the `a.store.markContinued(ctx, p.ExecID)`
line at `continue.go:166` (the dispatch `safego.Go(... continueSession ...)`
stays). The `markContinued` at `:162` (the no-assent-window branch, which
saves a note and does **not** call resumeSession) stays as-is — that path
intentionally consumes the item.
4. Update every other `resumeSession` caller to ignore the new return value
(`/resume`, `handleAnswerQuestion`, the empty-message reconnect in
`handleChat`) — they don't need the bool; a bare call discards it. No behavior
change for them (their skip semantics are already correct/desired).
**Why this preserves the original "no re-continue loop" guarantee:** a
`resumeSession` that *runs* always returns `true` (even on its internal LLM
failure path — it has already persisted a failure note), so it gets marked and
won't loop. Only a *busy-skip* returns `false` and stays pending, which is
correct (retry once the turn frees). Crash-safety also improves: a crash between
acquire and mark leaves the item un-marked → re-queued on restart.
**Validation:**
- New test: two `pendingContinuation`s for one session dispatched concurrently;
assert both are eventually processed (both `continued_at` set) and at no point
do two `resumeSession` bodies overlap (reuse the `turnGate` single-flight
pattern, or assert via a shared counter in a stubbed `chatWith`).
- Existing `cmd/nomos` suite stays green; `go vet` clean.
---
## P1 — F1 can false-auto-close a merely-busy session (low risk, fix for robustness)
**Bug.** `processIdleSweep` (`continue.go:78-89`) bumps `completion_nudges`
**before** calling `resumeSession`. If `resumeSession` skips (busy), the nudge is
counted as unanswered; the next sweep sees `CompletionNudges >= 1` and
**auto-closes** a session that was just busy.
**Likelihood is low** because `staleGoalSessions` (`store.go:1336`) filters
`last_active_at < now() - threshold` and an active turn keeps updating
`last_active_at` — so a busy session shouldn't appear stale. But the coupling is
the same shape as P0 and worth closing.
**Fix.** Gate the bump on the run, mirroring P0:
```go
safego.Go("nomos:idle-nudge:"+s.ID, func() {
note := …
if a.resumeSession(ctx, s.ID, note) {
if err := a.store.bumpCompletionNudge(ctx, s.ID); err != nil { … }
}
})
```
(If skipped, leave `completion_nudges` at 0 so a genuinely-stale sweep nudges
again later.)
---
## P2 — Minor / hygiene (optional, can ship without)
- **Redundant catch-up turn on reconnect.** When the live turn *already ended*
before a dropped-SSE reconnect fires, the empty-message path still runs a
"report your state" `resumeSession` turn the operator didn't ask for. F1 makes
it non-concurrent (good) but it's still a spare turn. Consider: in
`handleChat`'s empty-message branch, skip the `resumeSession` if the session
is already terminal (`done`/`failed`/`abandoned`) or had activity within the
last few seconds — just return 202 and let the poller catch up.
- **Top-level side-effect on import.** `chat.ts` now calls `subscribeEvents()` +
`liveEvents.subscribe(...)` at module top level. It works (and `vitest` stays
green because tests mock `./chat`), but a hidden SSE-connect-on-import is
fragile for future tests. Prefer a lazy `ensureChatEventSync()` called from
the window mount path, matching how `workspace.ts` subscribes inside
`startWorkspace` rather than at import.
- **F7 follow-up (already documented):** the `NewTaskChat → SessionChatWindow`
window-swap on first send still flashes; an in-place handoff would remove it.
- **Pre-existing, not introduced:** `a.chat` retries the LLM stream on
`ctx`-cancellation (client disconnect) up to 3×, holding the turn permit a few
extra seconds. Out of scope here.
---
## Out of scope
- F8 (ordering toggle + live background tool-delta streaming) — deferred in the
original plan; its main symptom is removed by F1.
- `run` execution deep-links (need an execution-view opener).
## Recommended order
1. **P0** (lost continuations) — blocks shipping F1.
2. **P1** (idle-sweep nudge gate) — small, same pattern.
3. P2 items as time allows.
4. Re-run `go test ./cmd/nomos/`, `go vet`, web `vitest`, `vite build`; keep
`VERSION` at `0.15.0` (these are correctness fixes to the same changeset, not
a new bump) — or bump patch to `0.15.1` if shipped as a follow-up commit.
---
## Resolution
All review items implemented. The whole batch (F1F7 + these review fixes)
remains one uncommitted changeset at `VERSION 0.15.0`.
| Item | Fix | Where |
|---|---|---|
| **P0** | `resumeSession` returns `bool` (false on busy-skip). `continueSession` marks an execution `continued` **only after** the turn ran; on a skip it defers and the next worker tick retries (item stays pending). Removed the pre-dispatch `markContinued` in `processContinuations`. Other callers (`/resume`, answer-question, reconnect) ignore the return. | `cmd/nomos/continue.go` |
| **P0 test** | `TestResumeSession_SkipsWhenBusy`, `TestContinueSession_DefersWhenBusy` — DB-free contract tests proving the skip path returns false without running the body (nil provider would panic otherwise). | `cmd/nomos/continue_test.go` |
| **P1** | Idle sweep bumps `completion_nudges` only after `resumeSession` actually runs, so a busy-skip can't be counted as an unanswered nudge → no false auto-close. | `cmd/nomos/continue.go` (`processIdleSweep`) |
| **P2.1** | Empty-message reconnect (now defensive — the frontend no longer POSTs empty messages post-F2) skips a terminal session instead of spawning a spare "report state" turn. | `cmd/nomos/main.go` (`handleChat`) |
| **P2.2** | Event subscription armed lazily from `chatFor()` (`ensureChatEventSync`) instead of at module import — no SSE-connect-on-import side-effect. | `web/src/lib/stores/chat.ts` |
**Verification:** `go test -count=1 ./cmd/nomos/` green (incl. the two new
contract tests); `go vet` clean. Web `vitest` 70/70; `vite build` succeeds; no
new `tsc`/eslint errors in any touched file.
**Note on the P0 end-to-end test:** the full "two continuations both processed,
no overlap" scenario needs a live LLM provider (chatWith isn't stubbable without
a refactor) and was therefore covered at the contract level (the skip returns
false without running the body) plus the existing `turnGate` single-flight test
for serialization, rather than as a DB integration test.

View File

@@ -0,0 +1,356 @@
# 2026-08-03 — Nomos chat: reliability & predictability audit
**Status:** Implemented (F1F7) in v0.15.0; F8 deferred. See
[Resolution](#resolution-2026-08-03) at the end.
**Scope:** The live chat/task UX across one production session, audited through
the code paths behind each operator-reported symptom —
`cmd/nomos/{main.go,agent.go,continue.go,store.go}`,
`web/src/lib/stores/{chat,activity,execstream,events,workspace}.ts`,
`web/src/lib/components/{ChatThread,AgentTrace,ToolCallCard,UnifiedTimeline,TaskContextPanel,SessionChatWindow}.svelte`.
**Trigger:** Operator report — streaming invisible in the tool card; the
activity/plan panel wrong about parallel/nested runs and timestamps with no clear
sequence; no links to artifacts/knowledge referenced in chat; agent "thinking"
flickers/overwrites itself; layout jumps when a chat goes from empty to content;
"Agent connection lost / Error in input stream" messages that aren't actionable
and don't self-resolve; overall flaky/disconnected feel where the task never
cleanly ended.
The prior round (`2026-07-30-session-review-plan-drift-and-dead-activity-panel.md`,
shipped in `467589d`) fixed the plan-seq and fabricated-timestamp rendering bugs.
This round's symptoms are a different layer: **turn orchestration, streaming
wiring, and connection-state UX**. One architectural gap (F1) is the common
cause behind several of them.
---
## The one root cause that compounds everything: F1
### F1 — No per-session turn serialization (concurrent turns corrupt the view)
`handleChat` runs `a.chat(ctx, ...)` directly in the HTTP request goroutine, and
every "resume" path (`resumeSession`, the reconnect empty-message path, the
auto-continuation worker, the idle sweep, answer-question) launches **another
goroutine** (`safego.Go`) running a full turn. There is **no mutex keyed on
`sessionID`** anywhere. The codebase already knows this is a hazard —
`agent.go:316-323` marks approved executions `continued` specifically because
"two concurrent LLM calls for the same session cause empty responses and race
conditions" — but the fix is per-path patching, not a general lock.
What this produces, deterministically:
- A network blip on the browser↔nomos stream fires `handleDisconnect`
(`chat.ts:383`), which POSTs an **empty-message reconnect**
`main.go:194-206` spawns `resumeSession` as a **new goroutine**. If the
original turn is still alive (or finishes its current tool call), **two turns
now run for one session**: interleaved `tool_use`/`text_delta` events, a
re-proposed plan, and "the agent is repeating itself."
- The activity timeline (`activity.ts:119-185`) groups tools under a plan step
by *inferring* `currentStepSeq` from `update_plan_step` calls in the message
stream. Two interleaved turns make that inference wrong → tools land under the
wrong step, steps appear to nest/parallelize that never did, the sequence
reads as garbage. This is the "parallel runs / nesting / no clear sequence"
report.
- Two turns appending to the same session's messages is also the source of the
duplicate-tool-call/empty-response class of bugs the prior plan docs keep
patching individually.
**This is why the experience "felt flaky and disconnected" and "the task didn't
end":** the panel is faithfully rendering a corrupted, interleaved event stream.
### Fix (proposed)
1. **One in-flight turn per session, server-side.** Add a per-`sessionID`
turn mutex (a `sync.Map[string]*singleflight` or a keyed `sync.Mutex`) in
`handleChat`/`resumeSession`/`continue.go`. A second attempt to start a turn
for a session that already has one running must **queue** (preferred — the
operator's message waits its turn) or **return 409 "turn in progress"** (the
frontend then just re-polls; no new goroutine). This single change removes
the interleaving that drives F2/F3/F8.
2. **Make the empty-message reconnect a no-op when a turn is already running.**
Today it *always* spawns `resumeSession`. Gate it on "is any turn active for
this session?" — if yes, return 202 and let the existing turn + the poller do
the work. A blip should never *create* work.
---
## F2 — Reconnect spawns a new turn and surfaces raw, non-actionable errors
`chat.ts:383-426` `handleDisconnect`: on a dropped SSE it sets
`connectionState='disconnected'`, starts the 3s poller, shows
`"Agent connection lost. The task is still running — retrying…"`, then calls
`streamChat('', sid, …)` up to 3× — each of which is the empty-message POST that
triggers F1's new `resumeSession` goroutine. Separately, the LLM stream errors
surface verbatim: `agent.go:388` does `emitError("llm: %v", err)`, so an
OpenRouter transport break reaches the operator as `llm: error in input stream:
…` (the openai-go SDK's SSE-reader text), shown raw in `ChatThread`'s error bar.
Combined with F1, this is the exact "messages not actionable and not
self-resolving" + "task didn't end" experience: a blip both invents a duplicate
turn and paints a scary, unfixable error that lingers.
Secondary defects in the same path:
- `streaming` stays `true` for the entire reconnect window, so the composer is
disabled and the poller's `if (streaming && connected) return` guard
(`chat.ts:177`) suppresses updates except while disconnected — fragile.
- The **per-window** error path (`sendSessionMessage`, `startTask`) does **not**
auto-reconnect at all — it only polls. Its `onReconnect` in
`SessionChatWindow.svelte:110` is `() => loadSessionChat(sessionId)`, which
just *re-fetches the transcript* and never re-attaches to a live stream. And
the global `reconnect()` (`chat.ts:428`) keys off the **global**
`currentSession`, so a floating window's Reconnect button can target the wrong
session. Two different, both-broken reconnect behaviors.
### Fix (proposed)
1. **Stop the empty-message-reconnect from creating turns** (depends on F1.2).
Reconnect should mean "catch up," not "run more."
2. **Humanize + bucket error strings.** Map known transport errors to
operator-readable, actionable copy with a single primary action:
- `llm: …input stream…` / 502/503/timeout → "The model connection dropped.
The task is still running in the background — it'll catch up
automatically." (auto-dismiss when the next event/poll lands)
- `HTTP 401/403` → "Session expired — reconnect." (action: re-auth)
- unknown → show the raw text but behind a "Details" toggle, not as the
headline.
3. **Make errors self-resolving.** Clear the error + connection-lost banner the
moment the poller sees a newer message or any live event for the session
arrives (wire `eventsConnected` / a session-scoped event into the banner's
visibility). Today the banner stays until manual dismiss even after recovery.
4. **Unify reconnect.** One `reconnect(sessionId)` that (a) re-fetches the
transcript, (b) if no turn is active, is a pure no-op refresh; used by both
the main view and windows. Drop the global-`currentSession` coupling.
---
## F3 — The UI can't tell when a turn truly ended (so it never looks "done")
When the SSE stream ends without a `done` event, `streamChat`'s `onDone`
(`chat.ts:355-368`) calls `handleDisconnect`. Even if the backend turn then
finishes and persists its final message, the frontend only learns via the 3s
poller re-setting `messages` — but nothing transitions `streaming``false` or
`connectionState``connected` from that path, so the spinner/indicator and the
"connection lost" banner can persist indefinitely. That is "the task didn't
end / backend connection was lost."
The backend does emit a terminal signal — `task.status` events on
`complete_task`/auto-complete (`workspace.ts:82-88` `STATUS_AFFECTING`) — but
nothing in the chat store reacts to a terminal `task.status` to force
`streaming=false` + clear the banner. The signal exists; the chat ignores it.
### Fix (proposed)
1. **Treat a terminal `task.status` (done/failed) for the viewed session as
authoritative end-of-turn** in `chat.ts`: set `streaming=false`,
`connectionState='connected'`, dismiss any connection-lost error. The poller
already refreshes messages; this just closes the loop on the *state* flags.
2. **Add a `task.completed` / `turn.ended` SSE event** from the backend on every
terminal path (today `done` is a chat-stream-only event; background turns
have no equivalent). The always-on events stream already reaches the panel —
route the same signal to the chat store so background-completed turns clear
the UI without waiting on a poll.
---
## F4 — Command streaming isn't shown where the operator looks
Streaming **exists** (`execstream.ts` `liveExecutionOutputFor`, fed by
`fetchExecutionLogs` via the always-on events stream) and the
`UnifiedTimeline` **does** render `tool.liveOutput` with tail-pinned scroll
(`UnifiedTimeline.svelte:451-457`). But:
- The **global** `activityLog` (`activity.ts:236`) — used by the main Chat page's
panel — never calls `withLiveOutput`. Only the **per-window**
`activityLogFor(sessionId)` (`activity.ts:271`) attaches live output. So the
main chat view's timeline shows no streaming at all.
- The **inline chat tool cards**`ToolCallCard.svelte` (rendered inside
`AgentTrace.svelte`) — show only args/result/error. They never read
`liveOutput`. Expanding a running `run` call in the transcript (the natural
place to "check the tool") shows nothing live; output appears all at once when
the `tool_result` lands.
This is the report: "I expected checking on the tool to let me see the
streaming."
### Fix (proposed)
1. **Wire live output into the global `activityLog`** so the main chat panel
streams too (call `withLiveOutput` in the `activityLog` derivation, same as
`activityLogFor`).
2. **Show streaming in the inline tool card.** Pass the session's live-output
store into `AgentTrace`/`ToolCallCard` (or attach `liveOutput` to the running
`run` tool entry the way the timeline does) and render a tail-pinned `<pre>`
while the call is `tool_use`/running. Reuse the UnifiedTimeline's scroll-pin
pattern. Gated runs (queued-for-approval) should instead show a "queued —
watch in entity detail" affordance (per `execstream.ts` header comment).
---
## F5 — Artifacts and knowledge referenced in chat aren't navigable
When the agent records knowledge, the activity panel shows `Recorded: <title>`
(`activity.ts:188-203`) but it's plain text — no link. The backend already
emits `knowledge.recorded` and links the note to the task
(`store.go:1572 linkKnowledgeToTask`, `agent.go:594`), and the Wiki reader
exists (`web/src/lib/components/knowledge/WikiReader.svelte`). Nothing connects
them. Same for `get_entity`/`run` results: slugs and execution ids appear in
tool output but aren't clickable to open the entity window or execution view.
### Fix (proposed)
1. **Make activity/tool entries link-bearing.** Add an optional
`link?: { kind: 'knowledge'|'entity'|'execution', id: string }` to
`ActivityEntry`. Populate it from `upsert_knowledge` (title→knowledge id from
the result), `get_entity` (slug), and `run` (execution id). Render a
clickable chip that opens the right surface: knowledge → Wiki reader (new tab
/ window), entity → entity detail window, execution → execution log pane
(already fetched by `EntityDetailContent.svelte`).
2. **Render entity/knowledge mentions in assistant markdown as links** when they
resolve to known slugs (lightweight: a post-process pass on rendered text, or
let the model emit explicit `[slug](entity:…)` markers it already has tools to
discover).
---
## F6 — "Thinking" is an unstable single-line headline, not a predictable trace
`ChatThread`'s `indicatorLabel` (`ChatThread.svelte:83-89`) returns the **first**
running activity entry's description; `AgentTrace`'s `headline` mirrors it. As
tools fire sequentially the running entry changes, so the one line rewrites
itself every call — "the thinking overwrites itself." There is no persistent,
additive reasoning surface, and no predictable turn structure (plan → steps →
answer) the operator can learn to read. Claude-Code-style predictability is
absent.
### Fix (proposed)
1. **A stable, additive per-turn reasoning block.** Keep the collapsed trace as
a *summary* ("Step 2 of 4 · running `run`"), but when expanded show an
**append-only** log of (a) the model's intermediate `text` (reasoning before
each tool call — already emitted at `agent.go:458-460` and persisted) and
(b) each tool call as a fixed row, instead of a single mutating headline.
2. **Predictable turn shape.** Enforce/cue a consistent sequence in the UI —
Goal → Plan → Steps (each with its tools nested) → Final answer — and render
each phase as a stable section that fills in rather than a line that
overwrites. The UnifiedTimeline already models most of this; surface the same
model in the inline trace so chat and panel tell one story.
---
## F7 — Layout jumps when a chat goes from empty to content
`SessionChatWindow.svelte:58-63` gates the right rail on `hasContext`: empty
task → `ChatThread` full-width; first activity/touched entity → switches to
`Splitpanes` with the `TaskContextPanel` rail. The swap is instant and
**reflows the chat column width** the moment the first event lands — "switching
from empty to chat with something, the layout was off." Compounded by the
`NewTaskChat` → real `SessionChatWindow` window-swap on first send
(`NewTaskChat.svelte:17-22`).
### Fix (proposed)
1. **Reserve the rail's space from the start** (collapse to a thin sliver / icon
rail when empty) instead of mounting it on demand, so adding content doesn't
change the chat column width. Or animate the rail in.
2. **Avoid the window swap on first send** — let the new-task window *become* the
session window in place once the id is assigned (same component, swap the
store source) rather than close+open.
---
## F8 — Activity/plan ordering & parallelism *(largely a symptom of F1)*
With F1 fixed (no interleaved turns) the heuristic step-grouping in
`activity.ts` becomes reliable again. Remaining standalone items:
- The timeline is **newest-first** with ts-0 goal/pending parked at the bottom
(`UnifiedTimeline.svelte:119-127`); for a long task this can read as
"sequence is off." Consider an explicit **oldest-first / seq-ordered** mode
toggle, and always show the step number prominently so order is unambiguous
regardless of sort.
- Background/auto-continued turns still rely on the 3s poller for their result
to appear; until F3's terminal event lands, the panel can lag. The
always-on events stream already carries `plan.*` and `entity.touched` live —
extend it to carry per-tool `tool.*` deltas for background turns so the panel
is live, not polled, during autonomous work.
---
## Recommended sequence
| Order | Item | Why first |
|---|---|---|
| 1 | **F1** per-session turn mutex + no-op reconnect-when-busy | Removes the interleaving that is the root cause of F2/F3/F8 symptoms; everything else is cosmetics on top of a corrupted stream. |
| 2 | **F3** terminal-event → clear chat state | Once turns can't double, make "the task ended" unambiguous so the UI stops lingering. |
| 3 | **F2** humanized/self-resolving errors + unified reconnect | Turns the scary, sticky "connection lost / input stream" into recoverable, auto-clearing UX. |
| 4 | **F4** streaming in the global log + inline tool card | Highest-visibility "I can't see what it's doing" fix; small, isolated change. |
| 5 | **F6** stable additive reasoning trace | Predictability of the interaction model (the Claude-Code feel). |
| 6 | **F5** artifact/knowledge deep links | Navigation completeness. |
| 7 | **F7** layout stability | Polish. |
| 8 | **F8** ordering mode + live background deltas | Polish, partly free after F1. |
## Verification hooks (when implementing)
- `cmd/nomos`: a test that starts two turns for the same session and asserts the
second queues/is-rejected (no interleaved `tool_use` order in persisted
messages).
- `web/src/lib/stores`: extend `activity.test.ts`/`execstream.test.ts` — global
`activityLog` now carries `liveOutput`; tool-card live output renders while
`tool_use` and clears on `tool_result`.
- A reconnect/integration test: drop the SSE mid-turn, assert (a) no duplicate
`resumeSession` goroutine, (b) banner auto-clears on next event, (c)
`streaming` returns to false on terminal `task.status`.
---
## Note on method
This audit was done against the **code paths** behind the reported symptoms, not
a single session transcript (no MCP/DB access from this session). To tie a
specific finding to a specific past session, pull the session via
`docker exec oikos-postgres-1 psql -U oikos oikos -c "select id,goal,outcome
from agent_sessions order by last_active_at desc limit 5"` and cross-reference
its `agent_activity` rows / persisted messages against the F1 interleaving
signature (two assistant turns' tool ids interleaved in one message shell).
---
## Resolution (2026-08-03)
Implemented F1F7 in v0.15.0 (`VERSION 0.14.2 → 0.15.0`). F8 deferred (its
primary symptom — interleaved/out-of-order entries — is removed by F1; the
ordering toggle and live background tool-delta streaming remain as nice-to-
haves).
| Item | What shipped | Where |
|---|---|---|
| **F1** | Per-session single-flight turn gate (`turnGate`): at most one in-flight turn per session. Background resume paths (`resumeSession` — covers the continuation worker, idle sweep, answer-question, /resume, and the empty-message reconnect) skip non-blocking when busy; the live chat path waits briefly then bails with an actionable error instead of stacking a second turn. | `cmd/nomos/turngate.go` (+`turngate_test.go`), wired in `agent.go` (struct/init), `continue.go` (`resumeSession`), `main.go` (`handleChat`). |
| **F3** | Terminal `task.status` events (done/failed/abandoned/awaiting_input) now clear a stuck chat view's `streaming`/`connectionState` and dismiss the connection-lost toasts — the authoritative "turn ended" signal the UI was ignoring. Poller safety net catches the edge where the event fired during the disconnect window. | `web/src/lib/stores/chat.ts` (`clearTurnState`, liveEvents subscription, `startSessionPolling`). |
| **F2** | Raw errors humanized ("The model connection dropped. The task keeps running…") and bucketed; one connection surface per drop (not banner+toast+raw error); errors self-clear via F3. The turn-spawning reconnect attempt loop is gone (dead global path simplified to a turn-free refresh); window "Reconnect" re-fetches + resets state. | `web/src/lib/stores/chat.ts` (`humanizeChatError`, error handlers, `loadSessionChat`, `handleDisconnect`/`reconnect`), `web/src/lib/components/ChatThread.svelte` (banner copy). |
| **F4** | Command streaming now shows (a) in the **global** activity timeline (live output wired into `activityLog`, was only per-window) and (b) in the **inline chat tool card** — expanding a running `run` shows live output auto-opened and tail-pinned. | `web/src/lib/types.ts` (`liveOutput`), `web/src/lib/stores/activity.ts` (`currentLiveOutput`), `web/src/lib/components/ChatThread.svelte` (`toolsWithLive`), `web/src/lib/components/ToolCallCard.svelte`. |
| **F6** | The "thinking" headline is now step-first (stable across a step's many tool calls) instead of rewriting per command; falls back to the current tool / "thinking…" only when no step is active. | `web/src/lib/components/ChatThread.svelte` (`indicatorLabel`). |
| **F5** | Activity entries now carry a deep link: recorded knowledge docs and `get_entity` lookups get an "open artifact" chip that opens the entity/knowledge window directly. | `web/src/lib/stores/activity.ts` (`link`, `knowledgeLinkFromResult`, `entityLinkFromArgs`), `web/src/lib/components/UnifiedTimeline.svelte`. |
| **F7** | The empty→content layout reflow is gone: `SessionChatWindow` now has one stable `Splitpanes`+`ChatThread` from open (no more destroy/remount of the thread or column reflow when the rail appears). | `web/src/lib/components/SessionChatWindow.svelte`. |
**Verification:**
- `go test ./cmd/nomos/` green (incl. new `turngate_test.go`: non-blocking skip,
blocking-waits-for-release, timeout, and a 50-goroutine single-flight
concurrency test asserting max in-flight = 1). `go vet` clean.
- Web `vitest` 70/70 green (incl. `activity.test.ts`/`execstream.test.ts`); the
`activity.test.ts` chat mock gained `currentSession` for the new
`currentLiveOutput` derivation.
- `vite build` succeeds (all Svelte components compile). Pre-existing `tsc`
strictness errors in unrelated files (`ui/*`, `oidc.ts`, `windows.ts`,
`workspace.ts`) are unchanged; no new errors in any touched file.
**Follow-ups (not in this pass):**
- F8: oldest-first ordering toggle; emit per-tool `tool.*` events on the
always-on stream during background `resumeSession` turns so the panel is live
(not 3s-polled) during autonomous work.
- F5: `run` execution deep-links (open the entity detail's execution pane) —
needs an execution-view opener; knowledge/entity links shipped first as the
explicit complaint.
- F7: the `NewTaskChat → SessionChatWindow` window-swap on first send (a
windows.ts open/close) still causes a brief flash; an in-place handoff
(same window, swap store source) would remove it.

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.