diff --git a/VERSION b/VERSION index e867cc2..a551051 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.14.2 +0.15.0 diff --git a/cmd/nomos/agent.go b/cmd/nomos/agent.go index 5fa2d85..95aaebd 100644 --- a/cmd/nomos/agent.go +++ b/cmd/nomos/agent.go @@ -57,6 +57,9 @@ type agent struct { apiBase string // oikos HTTP API base, derived from NOMOS_MCP_URL, for chat-assent approvals apiToken string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it (no dev-open bypass) httpClient *http.Client + // gate serializes turns per session (at most one in-flight turn per + // sessionID). See turngate.go and plan 2026-08-03 F1. + gate *turnGate } func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug string) (*agent, error) { @@ -117,6 +120,7 @@ func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug apiBase: apiBase, apiToken: os.Getenv("OIKOS_MCP_BEARER_TOKEN"), httpClient: &http.Client{Timeout: 15 * time.Second}, + gate: newTurnGate(), }, nil } diff --git a/cmd/nomos/continue.go b/cmd/nomos/continue.go index 32d4b82..e2eb269 100644 --- a/cmd/nomos/continue.go +++ b/cmd/nomos/continue.go @@ -76,16 +76,20 @@ func (a *agent) processIdleSweep(ctx context.Context) { s := s if s.CompletionNudges == 0 { safego.Go("nomos:idle-nudge:"+s.ID, func() { - if err := a.store.bumpCompletionNudge(ctx, s.ID); err != nil { - slog.Error("nomos: idle nudge bump failed", "session", s.ID, "error", err) - return + note := fmt.Sprintf("[System: this task ('%s') has been idle for %s with no complete_task call. "+ + "If the goal is done (or can't be completed), call complete_task now with the outcome and a "+ + "one-line summary. If you're still genuinely working through the plan, ignore this and continue.]", + s.Goal, idleTaskThreshold) + note = a.store.enrichResumeNote(ctx, s.ID, note) + // P1: only count the nudge if it actually delivered. resumeSession + // skips (returns false) when a turn is already active; bumping the + // counter anyway would make the next sweep auto-close a merely-busy + // session as "unanswered." + if a.resumeSession(ctx, s.ID, note) { + if err := a.store.bumpCompletionNudge(ctx, s.ID); err != nil { + slog.Error("nomos: idle nudge bump failed", "session", s.ID, "error", err) + } } - note := fmt.Sprintf("[System: this task ('%s') has been idle for %s with no complete_task call. "+ - "If the goal is done (or can't be completed), call complete_task now with the outcome and a "+ - "one-line summary. If you're still genuinely working through the plan, ignore this and continue.]", - s.Goal, idleTaskThreshold) - note = a.store.enrichResumeNote(ctx, s.ID, note) - a.resumeSession(ctx, s.ID, note) }) continue } @@ -163,7 +167,9 @@ func (a *agent) processContinuations(ctx context.Context) { continue } } - a.store.markContinued(ctx, p.ExecID) // stamp first: a failure here must not cause a re-continue loop + // markContinued now happens inside continueSession, AFTER resumeSession + // actually runs (P0). Pre-marking here consumed the item even when + // resumeSession skipped on a busy session, losing the result. safego.Go("nomos:continue-session:"+p.SessionID, func() { a.continueSession(ctx, p) }) } } @@ -179,7 +185,18 @@ func (a *agent) processContinuations(ctx context.Context) { // something new to poll for. func (a *agent) continueSession(ctx context.Context, p pendingContinuation) { slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status) - a.resumeSession(ctx, p.SessionID, buildContinuationNote(p)) + // P0 (plans/2026-08-03-nomos-chat-changes-review.md): mark the execution + // continued ONLY after the turn actually ran. resumeSession skips (returns + // false) when another turn is already active for this session; marking + // before that — as the old code did — consumed the item (continued_at set, + // never re-queued by pendingContinuations) and silently lost the result. + // On a skip, leave it pending so the next worker tick retries once the + // active turn frees the permit. + 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) } // resumeSession re-invokes the agent for a session with a system-injected note — @@ -187,7 +204,26 @@ func (a *agent) continueSession(ctx context.Context, p pendingContinuation) { // (handleAnswerQuestion) — persisting progress LIVE (a placeholder row updated // in place as each tool call lands) so the frontend poller sees each step, // instead of total silence until the whole resume concludes. -func (a *agent) resumeSession(ctx context.Context, sessionID, note string) { +// +// F1 (plan 2026-08-03): this is the single entry point for EVERY background +// turn — the continuation worker, idle sweep, answer-question, /resume, and the +// empty-message reconnect all funnel through here. It acquires the session's +// turn permit non-blocking and SKIPS if a turn is already running. A duplicate +// resume while a turn (live or background) is active is exactly the +// interleaving that corrupted the activity panel and made tasks feel stuck. +// +// Returns whether the turn actually ran. Callers that mutate state before +// resuming (the continuation worker's markContinued, the idle sweep's nudge +// bump) MUST gate that mutation on a true return — otherwise a busy-skip leaves +// the state changed but the work undone (lost continuation / false auto-close). +// See plans/2026-08-03-nomos-chat-changes-review.md P0/P1. +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) + placeholder, _ := json.Marshal(map[string]any{ "role": "assistant", "text": "", @@ -242,7 +278,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) { if attempt > 0 { select { case <-cctx.Done(): - return + return true // a turn ran on an earlier attempt; consume, don't re-loop case <-time.After(time.Duration(2< 0 it blocks up +// to wait for the permit, returning false on timeout. Every true return MUST +// be paired with exactly one release. +func (g *turnGate) acquire(sessionID string, wait time.Duration) bool { + ch := g.permit(sessionID) + if wait <= 0 { + select { + case <-ch: + return true + default: + return false + } + } + t := time.NewTimer(wait) + defer t.Stop() + select { + case <-ch: + return true + case <-t.C: + return false + } +} + +// release returns the session's permit. Idempotent: a release with no matching +// acquire (or a double release) is a no-op rather than a blocking send. +func (g *turnGate) release(sessionID string) { + ch := g.permit(sessionID) + select { + case ch <- struct{}{}: + default: + } +} diff --git a/cmd/nomos/turngate_test.go b/cmd/nomos/turngate_test.go new file mode 100644 index 0000000..f230c8c --- /dev/null +++ b/cmd/nomos/turngate_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestTurnGate_NonBlockingSkipsWhenBusy(t *testing.T) { + g := newTurnGate() + if !g.acquire("s1", 0) { + t.Fatal("first non-blocking acquire should succeed on a free session") + } + // A second non-blocking acquire (a background resume) must skip, not queue. + if g.acquire("s1", 0) { + t.Fatal("second non-blocking acquire should fail while a turn is active") + } + // A different session is independent. + if !g.acquire("s2", 0) { + t.Fatal("acquire on a different session should succeed") + } + g.release("s2") + g.release("s1") + // After release, the session is free again. + if !g.acquire("s1", 0) { + t.Fatal("acquire should succeed again after release") + } + g.release("s1") +} + +func TestTurnGate_BlockingAcquireWaitsForRelease(t *testing.T) { + g := newTurnGate() + if !g.acquire("s1", 0) { + t.Fatal("first acquire should succeed") + } + + got := make(chan bool, 1) + go func() { got <- g.acquire("s1", 2*time.Second) }() + + select { + case <-got: + t.Fatal("blocking acquire should wait, not return before release") + case <-time.After(50 * time.Millisecond): + // expected: still waiting + } + + g.release("s1") + select { + case ok := <-got: + if !ok { + t.Fatal("blocking acquire should succeed after release") + } + case <-time.After(time.Second): + t.Fatal("blocking acquire did not return after release") + } + g.release("s1") +} + +func TestTurnGate_BlockingAcquireTimesOut(t *testing.T) { + g := newTurnGate() + g.acquire("s1", 0) // hold the permit + + start := time.Now() + if g.acquire("s1", 60*time.Millisecond) { + t.Fatal("acquire should time out while permit is held") + } + if elapsed := time.Since(start); elapsed < 50*time.Millisecond { + t.Fatalf("acquire returned too fast (%v); expected to wait ~60ms", elapsed) + } + g.release("s1") +} + +// TestTurnGate_SingleFlightConcurrent is the core F1 guarantee: many concurrent +// background acquirers on the SAME session, exactly one runs at a time. This is +// the property that prevents two turns interleaving tool calls. +func TestTurnGate_SingleFlightConcurrent(t *testing.T) { + g := newTurnGate() + const n = 50 + var inFlight, maxInFlight int64 + var runs int64 + var wg sync.WaitGroup + wg.Add(n) + start := make(chan struct{}) + for i := 0; i < n; i++ { + go func() { + defer wg.Done() + <-start + if !g.acquire("shared", 0) { // background-style: skip if busy + return + } + defer g.release("shared") + cur := atomic.AddInt64(&inFlight, 1) + for { + m := atomic.LoadInt64(&maxInFlight) + if cur <= m || atomic.CompareAndSwapInt64(&maxInFlight, m, cur) { + break + } + } + atomic.AddInt64(&runs, 1) + time.Sleep(2 * time.Millisecond) + atomic.AddInt64(&inFlight, -1) + }() + } + close(start) + wg.Wait() + + if maxInFlight != 1 { + t.Fatalf("max in-flight turns = %d, want 1 (turns must not overlap)", maxInFlight) + } + if runs == 0 { + t.Fatal("expected at least one turn to run") + } +} diff --git a/plans/2026-08-03-nomos-chat-changes-review.md b/plans/2026-08-03-nomos-chat-changes-review.md new file mode 100644 index 0000000..6b4577d --- /dev/null +++ b/plans/2026-08-03-nomos-chat-changes-review.md @@ -0,0 +1,184 @@ +# 2026-08-03 — Review: nomos chat reliability/UX changes (F1–F7) + +**Status:** Implemented (P0, P1, P2 all done). See +[Resolution](#resolution) at the end. + +A critical self-review of the uncommitted F1–F7 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 (F1–F7 + 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. diff --git a/plans/2026-08-03-nomos-chat-reliability-and-ux-audit.md b/plans/2026-08-03-nomos-chat-reliability-and-ux-audit.md new file mode 100644 index 0000000..5e009ca --- /dev/null +++ b/plans/2026-08-03-nomos-chat-reliability-and-ux-audit.md @@ -0,0 +1,356 @@ +# 2026-08-03 — Nomos chat: reliability & predictability audit + +**Status:** Implemented (F1–F7) 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 `
`
+   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: `
+(`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 F1–F7 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.
diff --git a/plans/index.md b/plans/index.md
index 9400733..baecc17 100644
--- a/plans/index.md
+++ b/plans/index.md
@@ -22,6 +22,7 @@ went sideways, open an investigation.
 | 2026-07-20 | [Session review: past 10 sessions](2026-07-20-session-review-ten-sessions.md) | Implemented in v0.7.13 — all P0/P1/P2 items landed |
 | 2026-07-20 | [Mascot physics/window-interaction audit](2026-07-20-mascot-physics-audit.md) | P0–P2 implemented; P3 ("cool stuff") ideas open |
 | 2026-07-21 | [Frontend as OS + Apps — architecture audit & refactor](2026-07-21-frontend-os-apps-architecture.md) | Planned — Phase 1 ready |
+| 2026-08-03 | [Nomos chat: reliability & predictability audit](2026-08-03-nomos-chat-reliability-and-ux-audit.md) | In Progress — F1–F7 shipped in v0.15.0; F8 + follow-ups open |
 
 ## Done
 
diff --git a/web/src/lib/components/ChatThread.svelte b/web/src/lib/components/ChatThread.svelte
index 3275945..2f8e15d 100644
--- a/web/src/lib/components/ChatThread.svelte
+++ b/web/src/lib/components/ChatThread.svelte
@@ -17,6 +17,7 @@
   import { marked } from 'marked'
   import DOMPurify from 'dompurify'
   import type { ChatMessage } from '$lib/stores/chat'
+  import type { ToolCallResult } from '$lib/types'
   import type { SessionQuestion } from '$lib/api'
 
   let {
@@ -83,8 +84,15 @@
   const indicatorLabel = $derived.by(() => {
     if (error) return error
     if (!streaming && indicatorDone) return 'Done'
-    const running = $activityLogProp.find((e: ActivityEntry) => e.status === 'running')
-    if (running) return running.description
+    // Prefer the running PLAN STEP as the headline — it's stable across the
+    // step's many tool calls, so the line stops rewriting itself on every
+    // command (the "thinking overwrites itself" complaint, F6). Falls back to
+    // the current tool only when there's no active step (a plan-less Q&A or
+    // between steps), and to a plain "thinking…" otherwise.
+    const runningStep = $activityLogProp.find((e: ActivityEntry) => e.type === 'step_running')
+    if (runningStep) return runningStep.description
+    const runningTool = $activityLogProp.find((e: ActivityEntry) => e.type === 'tool_running')
+    if (runningTool) return runningTool.description
     return 'Agent is thinking…'
   })
 
@@ -195,6 +203,27 @@
     if (streaming) return
     onSend(q)
   }
+
+  // Merge live streaming output (from the activity log's `run` entry) onto the
+  // in-flight turn's tool calls so the inline tool card shows command output as
+  // it arrives — the place the operator naturally "checks the tool". Only the
+  // last assistant message can be streaming, so only it gets enriched; history
+  // is untouched (and has no live output anyway). (F4)
+  function toolsWithLive(
+    tools: ToolCallResult[],
+    entries: ActivityEntry[],
+    isLiveTurn: boolean
+  ): ToolCallResult[] {
+    if (!isLiveTurn) return tools
+    const liveById = new Map<string, string>()
+    for (const e of entries) {
+      if (e.liveOutput && e.id) liveById.set(e.id, e.liveOutput)
+    }
+    if (liveById.size === 0) return tools
+    return tools.map((t) =>
+      t.id && liveById.has(t.id) ? { ...t, liveOutput: liveById.get(t.id) } : t
+    )
+  }
 </script>
 
 <div class="flex h-full min-h-0 min-w-0 flex-col" bind:clientHeight={threadHeight}>
@@ -274,7 +303,7 @@
                    the text below it. -->
                   {#if msg.tools.length > 0 || traceStatus !== 'idle'}
                     <AgentTrace
-                      tools={msg.tools}
+                      tools={toolsWithLive(msg.tools, $activityLogProp, isLast && traceStatus !== 'idle')}
                       status={traceStatus}
                       label={traceStatus === 'idle' ? null : indicatorLabel}
                     />
@@ -306,9 +335,10 @@
           <div
             class="mb-2 flex items-center gap-2 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs"
           >
-            <RefreshCwIcon class="size-3 shrink-0" aria-hidden="true" />
+            <RefreshCwIcon class="size-3 shrink-0 animate-spin text-warning" aria-hidden="true" />
             <span class="text-warning-foreground flex-1"
-              >Agent connection lost. The task may still be running.</span
+              >Connection dropped — the task is still running and will catch up here automatically.
+              Reconnect to refresh now.</span
             >
             <Button size="xs" variant="outline" class="h-6 text-[11px]" onclick={onReconnect}
               >Reconnect</Button
diff --git a/web/src/lib/components/SessionChatWindow.svelte b/web/src/lib/components/SessionChatWindow.svelte
index 631e3f2..07c9daa 100644
--- a/web/src/lib/components/SessionChatWindow.svelte
+++ b/web/src/lib/components/SessionChatWindow.svelte
@@ -37,30 +37,21 @@
   const sessionActivityLog = activityLogFor(sessionId)
   // Started here (rather than left to TaskContextPanel's own onMount) so the
   // workspace is already tracking touched entities/plan/questions before the
-  // context rail ever mounts — it needs that live even while the rail stays
-  // hidden (see hasContext below).
+  // context rail mounts.
   // eslint-disable-next-line svelte/valid-compile
   const workspace = workspaceFor(sessionId)
-  // eslint-disable-next-line svelte/valid-compile
-  const touchedEntities = workspace.touched
-  // eslint-disable-next-line svelte/valid-compile
   const openQuestion = workspace.openQuestion
   let loading = $state(true)
 
-  // The context rail (Scope/Activity) is only worth its screen space once
-  // there's something in it — a brand-new task otherwise opens to an empty
-  // "entities appear here" placeholder next to an equally empty activity
-  // list. Show it the moment either has real content, and keep it shown
-  // from then on (no flicker back to hidden if e.g. touched entities later
-  // expire). An open question does NOT gate this anymore — it renders
-  // inline in the chat thread itself (see ChatThread's `question` prop
-  // below), not in this rail.
-  let hasContext = $state(false)
-  $effect(() => {
-    if (!hasContext && ($sessionActivityLog.length > 0 || $touchedEntities.length > 0)) {
-      hasContext = true
-    }
-  })
+  // F7 (plan 2026-08-03): the rail used to mount on demand (hasContext gate),
+  // which DESTROYED and remounted the ChatThread — losing the input draft and
+  // scroll position — and reflowed the chat column the moment the first
+  // activity/touched entity landed ("layout looks off when a chat goes from
+  // empty to content"). The layout is now stable from the moment the window
+  // opens: one Splitpanes, one ChatThread, the rail always present showing
+  // its own empty state ("Waiting for activity…") until there's something to
+  // show. A stable-but-initially-quiet rail is a better trade than a jumping
+  // layout.
 
   // startSessionWorkspace's cleanup is registered via onDestroy below rather
   // than returned from this callback — onMount ignores a returned function
@@ -93,7 +84,7 @@
       <p class="text-sm text-muted-foreground">Task not found.</p>
       <p class="text-xs text-muted-foreground/70">It may have been deleted.</p>
     </div>
-  {:else if hasContext}
+  {:else}
     <Splitpanes theme="oikos-theme" dblClickSplitter={false}>
       <Pane>
         <ChatThread
@@ -115,20 +106,5 @@
         <TaskContextPanel {sessionId} />
       </Pane>
     </Splitpanes>
-  {:else}
-    <ChatThread
-      messages={$chatMessages}
-      streaming={$chatStreaming}
-      connectionState={$chatConnectionState}
-      error={$chatError}
-      chatErrors={$chatErrors}
-      activityLog={sessionActivityLog}
-      {sessionId}
-      question={$openQuestion}
-      onSend={(text) => sendSessionMessage(sessionId, text)}
-      onCancel={() => cancelSessionStream(sessionId)}
-      onReconnect={() => loadSessionChat(sessionId)}
-      onDismissError={dismissError}
-    />
   {/if}
 </div>
diff --git a/web/src/lib/components/ToolCallCard.svelte b/web/src/lib/components/ToolCallCard.svelte
index 35d86a6..0420f61 100644
--- a/web/src/lib/components/ToolCallCard.svelte
+++ b/web/src/lib/components/ToolCallCard.svelte
@@ -9,6 +9,7 @@
 
   let { tool }: { tool: ToolCallResult } = $props()
   let expanded = $state(false)
+  let liveEl = $state<HTMLPreElement | null>(null)
 
   const status = $derived.by(() => {
     if (tool.type === 'tool_use') return 'running'
@@ -16,6 +17,15 @@
     return 'done'
   })
 
+  // Auto-open while a command is streaming its output, so the operator sees it
+  // without an extra click — mirrors UnifiedTimeline. Once the tool_result
+  // lands (status flips off running) liveOutput clears and the card respects
+  // the manual toggle again. (F4)
+  const open = $derived(expanded || !!tool.liveOutput)
+  $effect(() => {
+    if (tool.liveOutput && liveEl) liveEl.scrollTop = liveEl.scrollHeight
+  })
+
   const label = $derived(toolActivityLabel(tool))
 
   const argsSummary = $derived.by(() => {
@@ -36,8 +46,8 @@
   <button
     class="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/40 disabled:cursor-default"
     onclick={() => (expanded = !expanded)}
-    aria-expanded={expanded}
-    disabled={!hasDetail}
+    aria-expanded={open}
+    disabled={!hasDetail && !tool.liveOutput}
   >
     <span class="mt-px shrink-0 {status === 'error' ? 'text-destructive' : 'text-primary'}">
       {#if status === 'running'}
@@ -57,17 +67,30 @@
       {/if}
     </span>
     <span class="shrink-0 font-mono text-[10px] text-muted-foreground/50">{tool.name}</span>
-    {#if hasDetail}
+    {#if hasDetail || tool.liveOutput}
       <ChevronRight
-        class="mt-px size-3 shrink-0 text-muted-foreground/50 transition-transform {expanded
+        class="mt-px size-3 shrink-0 text-muted-foreground/50 transition-transform {open
           ? 'rotate-90'
           : ''}"
       />
     {/if}
   </button>
 
-  {#if expanded}
+  {#if open}
     <div class="space-y-2 px-2 pb-2 pl-7">
+      {#if tool.liveOutput}
+        <div>
+          <div
+            class="mb-1 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-primary"
+          >
+            <Loader2 class="size-2.5 animate-spin" />
+            Live output
+          </div>
+          <pre
+            bind:this={liveEl}
+            class="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted/60 p-2 font-mono text-[11px] text-foreground/90">{tool.liveOutput}</pre>
+        </div>
+      {/if}
       {#if tool.args}
         <div>
           <div
diff --git a/web/src/lib/components/UnifiedTimeline.svelte b/web/src/lib/components/UnifiedTimeline.svelte
index 2b67426..2291f2d 100644
--- a/web/src/lib/components/UnifiedTimeline.svelte
+++ b/web/src/lib/components/UnifiedTimeline.svelte
@@ -15,6 +15,8 @@
   import SparklesIcon from '@lucide/svelte/icons/sparkles'
   import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
   import FlagIcon from '@lucide/svelte/icons/flag'
+  import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
+  import { openEntityWindow } from '$lib/stores/windows'
 
   // Merged plan + activity timeline, designed for the narrow rail:
   // - ordered newest-first: what the agent is doing right now is at the top,
@@ -423,10 +425,23 @@
                         >
                           {tool.description}
                         </span>
-                        <span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50"
-                          >{hhmm(tool.timestamp)}</span
-                        >
+                        {#if !tool.link}
+                          <span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50"
+                            >{hhmm(tool.timestamp)}</span
+                          >
+                        {/if}
                       </button>
+                      {#if tool.link}
+                        <button
+                          type="button"
+                          class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
+                          title="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
+                          aria-label="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
+                          onclick={() => openEntityWindow(tool.link!.slug)}
+                        >
+                          <ExternalLinkIcon class="size-3" />
+                        </button>
+                      {/if}
                       {#if tOpen}
                         <div
                           transition:slide={{ duration: 120 }}
@@ -516,10 +531,23 @@
                 >
                   {e.description}
                 </span>
-                <span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
-                  >{hhmm(e.timestamp)}</span
-                >
+                {#if !e.link}
+                  <span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
+                    >{hhmm(e.timestamp)}</span
+                  >
+                {/if}
               </button>
+              {#if e.link}
+                <button
+                  type="button"
+                  class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
+                  title="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
+                  aria-label="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
+                  onclick={() => openEntityWindow(e.link!.slug)}
+                >
+                  <ExternalLinkIcon class="size-3" />
+                </button>
+              {/if}
               {#if eOpen}
                 <div
                   transition:slide={{ duration: 120 }}
diff --git a/web/src/lib/stores/activity.test.ts b/web/src/lib/stores/activity.test.ts
index e861aaf..c451499 100644
--- a/web/src/lib/stores/activity.test.ts
+++ b/web/src/lib/stores/activity.test.ts
@@ -6,6 +6,7 @@ import { writable } from 'svelte/store'
 // real store graph (workspace.ts, e.g., starts a top-level setInterval).
 vi.mock('./chat', () => ({
   messages: writable([]),
+  currentSession: writable(null),
   chatFor: vi.fn(() => ({
     messages: writable([]),
     streaming: writable(false),
diff --git a/web/src/lib/stores/activity.ts b/web/src/lib/stores/activity.ts
index c583469..ecb9f7f 100644
--- a/web/src/lib/stores/activity.ts
+++ b/web/src/lib/stores/activity.ts
@@ -1,5 +1,5 @@
 import { derived, type Readable } from 'svelte/store'
-import { messages, chatFor, type ChatMessage, type ToolCallResult } from './chat'
+import { messages, chatFor, currentSession, type ChatMessage, type ToolCallResult } from './chat'
 import { planSteps, currentTask, workspaceFor, taskFor } from './workspace'
 import { liveExecutionOutputFor, type LiveExecutionOutput } from './execstream'
 import type { PlanStep, Session } from '$lib/api'
@@ -33,6 +33,11 @@ export interface ActivityEntry {
   // Distinct from `detail`, which is only populated once the tool_result
   // arrives — for an auto-run that is the moment the command finishes.
   liveOutput?: string
+  // Deep link to an artifact this entry references — a recorded knowledge doc
+  // or a looked-up entity — so the operator can open it directly instead of
+  // having to navigate there by hand. Rendered as a clickable chip in the
+  // timeline (F5). `slug` is an entity slug (e.g. "document:nomos/…").
+  link?: { kind: 'knowledge' | 'entity'; slug: string }
 }
 
 // Detail text is kept full-length (not hard-truncated to a preview snippet)
@@ -55,6 +60,32 @@ function stringifyResult(result: unknown): string {
   return s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}\n… truncated` : s
 }
 
+// A knowledge doc slug as printed in upsert_knowledge's result text — mirrors
+// cmd/nomos/store.go's knowledgeSlugRe (e.g. "document:nomos/some-finding").
+const KNOWLEDGE_SLUG_RE = /[a-z]+:nomos\/[a-z0-9-]+/
+// An entity slug looks like "type:name" (host:strong, lxc:caddy); a bare UUID
+// or free text doesn't, so we only deep-link when it does.
+const ENTITY_SLUG_RE = /^[a-z][a-z0-9_]*:[^\s]+$/
+
+// entityLinkFromArgs pulls a navigable slug out of a get_entity-style call's
+// args so its activity entry can link straight to that entity's window (F5).
+function entityLinkFromArgs(args: unknown): ActivityEntry['link'] | undefined {
+  if (!args || typeof args !== 'object') return undefined
+  const slug = (args as Record<string, unknown>)?.slug_or_id
+  if (typeof slug === 'string' && ENTITY_SLUG_RE.test(slug)) {
+    return { kind: 'entity', slug }
+  }
+  return undefined
+}
+
+// knowledgeLinkFromResult extracts the created doc's slug from an
+// upsert_knowledge result so the "Recorded: …" entry links to the doc (F5).
+function knowledgeLinkFromResult(result: unknown): ActivityEntry['link'] | undefined {
+  const s = typeof result === 'string' ? result : JSON.stringify(result ?? '')
+  const m = s.match(KNOWLEDGE_SLUG_RE)
+  return m ? { kind: 'knowledge', slug: m[0] } : undefined
+}
+
 // Pure derivation, parameterized so it can back both the global "current
 // session" activityLog below and a per-session activityLogFor(sessionId) for
 // a floating task window.
@@ -160,6 +191,9 @@ export function computeActivityLog(
           running.type = 'tool_done'
           running.status = 'done'
           running.detail = stringifyResult(t.result)
+          if (t.name === 'get_entity' || t.name === 'get_entity_knowledge') {
+            running.link = entityLinkFromArgs(t.args)
+          }
         } else {
           // Historical/persisted tool calls arrive as one merged record (args
           // + result on the same object, see mergeToolCalls in chat.ts) rather
@@ -177,7 +211,11 @@ export function computeActivityLog(
             toolName: t.name,
             stepSeq: stepTag,
             indent: stepTag != null,
-            status: t.error ? 'failed' : 'done'
+            status: t.error ? 'failed' : 'done',
+            link:
+              t.name === 'get_entity' || t.name === 'get_entity_knowledge'
+                ? entityLinkFromArgs(t.args)
+                : undefined
           })
         }
       }
@@ -196,7 +234,8 @@ export function computeActivityLog(
           type: 'knowledge',
           description: title ? `Recorded: ${title.slice(0, 60)}` : 'Recorded knowledge',
           timestamp: freeze(kid, msgTs),
-          status: 'done'
+          status: 'done',
+          link: knowledgeLinkFromResult(t.result)
         })
       }
     }
@@ -233,8 +272,28 @@ export function computeActivityLog(
 // re-derivation can't march it forward. Owned here, outside the derivation,
 // so it survives re-runs. The per-session path has its own Map keyed by id.
 const frozenTimestamps = new Map<string, number>()
-export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) =>
-  computeActivityLog($msgs, $steps, $task, frozenTimestamps)
+
+// Live execution output for whichever session the global "current session"
+// view is on — used to attach streaming `run` output to the global activityLog
+// (the per-window activityLogFor has its own). Follows currentSession via a
+// derived setup function so the subscription moves to the right session's
+// store when the operator switches tasks.
+const currentLiveOutput = derived(
+  currentSession,
+  ($sid, set) => {
+    if (!$sid) {
+      set(null)
+      return
+    }
+    return liveExecutionOutputFor($sid).subscribe(set)
+  },
+  null as LiveExecutionOutput | null
+)
+
+export const activityLog = derived(
+  [messages, planSteps, currentTask, currentLiveOutput],
+  ([$msgs, $steps, $task, $live]) =>
+    withLiveOutput(computeActivityLog($msgs, $steps, $task, frozenTimestamps), $live)
 )
 
 // Attach streaming output to the `run` entry that is currently executing.
diff --git a/web/src/lib/stores/chat.ts b/web/src/lib/stores/chat.ts
index 38d08ce..c35be65 100644
--- a/web/src/lib/stores/chat.ts
+++ b/web/src/lib/stores/chat.ts
@@ -8,6 +8,7 @@ import {
 } from '$lib/api'
 import type { ChatEvent, Session, Message } from '$lib/api'
 import type { ToolCallResult } from '$lib/types'
+import { liveEvents, subscribeEvents } from './events'
 
 export type { ToolCallResult }
 
@@ -78,14 +79,90 @@ export const currentSession = writable<string | null>(null)
 export const sessions = writable<Session[]>([])
 export const sessionMessages = writable<Message[]>([])
 export const error = writable<string | null>(null)
-export const chatErrors = writable<{ id: string; message: string; action?: string }[]>([])
+export const chatErrors = writable<{ id: string; message: string; action?: string; tag?: string }[]>([])
 
 export function dismissError(id: string) {
   chatErrors.update((e) => e.filter((x) => x.id !== id))
 }
 
-export function addChatError(message: string, action?: string) {
-  chatErrors.update((e) => [...e, { id: crypto.randomUUID(), message, action }])
+export function addChatError(message: string, action?: string, tag?: string) {
+  chatErrors.update((e) => [...e, { id: crypto.randomUUID(), message, action, tag }])
+}
+
+// Session statuses where no turn is running — the agent reached a terminal
+// state (done/failed/abandoned) or paused for operator input (awaiting_input).
+// A task.status event landing in one of these is an authoritative "the turn
+// ended" signal, used by clearTurnState (F3) to unstick a chat view that lost
+// its SSE stream mid-turn.
+const TURN_ENDED_STATUS = new Set(['done', 'failed', 'abandoned', 'awaiting_input'])
+
+// humanizeChatError turns raw transport/SDK error strings into operator-
+// readable, non-alarming copy. The raw forms ("llm: error in input stream:
+// …", "Failed to fetch", "HTTP 502") read as catastrophic and unactionable;
+// most are transient model-connection drops where the task itself is fine.
+// Used for both the inline error box (LLM error events) and the connection
+// toast (F2).
+function humanizeChatError(raw: string): string {
+  const s = raw.toLowerCase()
+  if (
+    s.includes('input stream') ||
+    s.includes('llm:') ||
+    s.includes('failed to fetch') ||
+    s.includes('network') ||
+    s.includes('econnreset') ||
+    s.includes('timeout') ||
+    /http 5\d\d/.test(s)
+  ) {
+    return 'The model connection dropped. The task keeps running in the background — it will catch up here automatically.'
+  }
+  if (/http 401|http 403|unauthor|forbidden/.test(s)) {
+    return 'Your session expired. Reconnect to continue.'
+  }
+  return raw
+}
+
+// clearTurnState resets a session's chat view to a clean "connected, idle"
+// state — the recovery action when a dropped SSE left it stuck showing
+// streaming/disconnected after the turn had already ended. Clears the window
+// bundle, the global bundle (if that's the viewed session), and any
+// connection-lost toasts tagged 'connection' (F2/F3).
+function clearTurnState(sessionId: string) {
+  const win = sessionChats.get(sessionId)
+  if (win) {
+    win.streaming.set(false)
+    win.connectionState.set('connected')
+  }
+  if (get(currentSession) === sessionId) {
+    streaming.set(false)
+    connectionState.set('connected')
+  }
+  chatErrors.update((errs) => errs.filter((e) => e.tag !== 'connection'))
+}
+
+// One app-lifetime subscription to the always-on event stream: a terminal
+// task.status for a session we have open is the authoritative end-of-turn
+// signal, and recovers a chat view whose SSE dropped without a 'done' event
+// (the "task never ended" symptom). Ref-counted by subscribeEvents, so this
+// shares the single connection the rest of the app already keeps open.
+//
+// Lazily armed from chatFor() (P2.2) rather than at module import, so
+// importing this module — e.g. in a test — doesn't open an SSE connection as
+// an import side-effect.
+let chatEventSyncArmed = false
+function ensureChatEventSync() {
+  if (chatEventSyncArmed) return
+  chatEventSyncArmed = true
+  subscribeEvents()
+  liveEvents.subscribe((events) => {
+    const ev = events[0]
+    if (!ev || ev.type !== 'task.status') return
+    const sid = ev.correlation_id
+    if (!sid) return
+    const status = (ev.data as { status?: string } | null)?.status
+    if (typeof status === 'string' && TURN_ENDED_STATUS.has(status)) {
+      clearTurnState(sid)
+    }
+  })
 }
 
 // Per-session controller tracking. Multiple tasks can stream concurrently
@@ -378,80 +455,35 @@ export function sendMessage(text: string) {
   }
 }
 
-// handleDisconnect is called when the SSE stream drops mid-turn without
-// receiving a 'done' event. Falls back to polling and attempts reconnection.
+// handleDisconnect is called when the global SSE stream drops mid-turn
+// without a 'done' event. NOTE: the global single-session chat action path
+// (sendMessage → this) is not currently wired to any UI — only the
+// per-session window path (sendSessionMessage/startTask) is live, which has
+// its own inline equivalent. This is kept safe and turn-free in case the
+// global path is re-wired: it falls back to polling and surfaces one
+// connection toast; recovery is driven by the poller + the terminal
+// task.status subscription (clearTurnState), NEVER by POSTing an empty
+// message that would spawn a duplicate background turn (F1/F2).
 function handleDisconnect(sessionId: string) {
-  const MAX_RECONNECT = 3
   connectionState.set('disconnected')
   startPolling(sessionId)
-  addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
-
-  let attempts = 0
-  let delay = 1000
-
-  const attemptReconnect = () => {
-    if (get(currentSession) !== sessionId || attempts >= MAX_RECONNECT) {
-      connectionState.set('disconnected')
-      streaming.set(false)
-      return
-    }
-    if (attempts > 0) {
-      connectionState.set('reconnecting')
-      addChatError(`Reconnecting to agent (attempt ${attempts + 1}/${MAX_RECONNECT})…`, 'Dismiss')
-    }
-    attempts++
-    const controller = streamChat(
-      '',
-      sessionId,
-      (_ev: ChatEvent) => {},
-      (_err: string) => {
-        delay = Math.min(delay * 2, 8000)
-        setTimeout(attemptReconnect, delay)
-      },
-      () => {
-        if (get(currentSession) === sessionId) {
-          connectionState.set('connected')
-          streaming.set(false)
-          loadSessionMessages(sessionId)
-        }
-      }
-    )
-    if (activeControllers.get(sessionId)) {
-      activeControllers.get(sessionId)?.abort()
-    }
-    activeControllers.set(sessionId, controller)
-  }
-
-  setTimeout(attemptReconnect, delay)
+  addChatError(
+    'Connection to the agent dropped. The task keeps running — it will catch up here automatically.',
+    'Dismiss',
+    'connection'
+  )
 }
 
+// Manual reconnect for the global view (currently unused — windows use
+// loadSessionChat via their onReconnect). Re-fetches the transcript and
+// resets state; does NOT start a new turn.
 export function reconnect() {
   const sid = get(currentSession)
   if (!sid) return
-  connectionState.set('reconnecting')
-  const controller = streamChat(
-    '',
-    sid,
-    (_ev: ChatEvent) => {},
-    (_err: string) => {
-      connectionState.set('disconnected')
-      addChatError(
-        'Reconnect failed. The task may still be running — try sending a message to wake the agent.',
-        'Dismiss'
-      )
-    },
-    () => {
-      if (get(currentSession) === sid) {
-        connectionState.set('connected')
-        streaming.set(false)
-        loadSessionMessages(sid)
-      }
-    }
-  )
-  if (activeControllers.get(sid)) {
-    activeControllers.get(sid)?.abort()
-  }
-  activeControllers.set(sid, controller)
+  streaming.set(false)
+  connectionState.set('connected')
+  chatErrors.update((errs) => errs.filter((e) => e.tag !== 'connection'))
+  loadSessionMessages(sid)
 }
 
 export function newChat() {
@@ -524,6 +556,7 @@ const sessionPollers = new Map<string, ReturnType<typeof setInterval>>()
 // Lazily creates (and memoizes) the store bundle for a session — call this to
 // get the stores to subscribe to; it does not fetch anything.
 export function chatFor(sessionId: string): SessionChatState {
+  ensureChatEventSync() // arm the terminal task.status → clearTurnState recovery (P2.2)
   let c = sessionChats.get(sessionId)
   if (!c) {
     c = {
@@ -549,6 +582,16 @@ function startSessionPolling(sessionId: string) {
       const msgs = await fetchMessages(sessionId)
       if (get(chat.streaming)) return // re-check: the fetch itself takes time
       chat.messages.set(toChatMessages(msgs))
+      // F3 safety net: if we're recovering from a dropped SSE but the
+      // session's task has already reached a turn-ended status, clear the
+      // stuck disconnected/streaming flags. Catches the edge where the
+      // terminal task.status event fired during the brief disconnect window.
+      if (get(chat.connectionState) !== 'connected') {
+        const s = get(sessions).find((x) => x.id === sessionId)
+        if (s?.status && TURN_ENDED_STATUS.has(s.status)) {
+          clearTurnState(sessionId)
+        }
+      }
     }, 3000)
   )
 }
@@ -566,7 +609,15 @@ export function stopSessionPolling(sessionId: string) {
 // equivalent of loadSessionMessages, for a window rather than the main view.
 export async function loadSessionChat(sessionId: string): Promise<void> {
   const chat = chatFor(sessionId)
+  // A fresh (re)load is a clean view: not streaming, connected, no stale
+  // error. This also serves the window's manual "Reconnect" button —
+  // re-fetching the transcript and resetting state, never spawning a new
+  // turn (the old reconnect path POSTed an empty message that started a
+  // duplicate background turn; F1/F2 removed that).
   chat.streaming.set(false)
+  chat.connectionState.set('connected')
+  chat.error.set(null)
+  chatErrors.update((errs) => errs.filter((e) => e.tag !== 'connection'))
   const msgs = await fetchMessagesOrNotFound(sessionId)
   if (msgs === null) {
     chat.notFound.set(true)
@@ -668,7 +719,7 @@ export function sendSessionMessage(sessionId: string, text: string) {
         })
         startSessionPolling(sessionId)
       } else if (ev.type === 'error') {
-        chat.error.set(ev.data)
+        chat.error.set(humanizeChatError(ev.data))
       }
     },
     (err: string) => {
@@ -676,12 +727,21 @@ export function sendSessionMessage(sessionId: string, text: string) {
         chat.streaming.set(false)
         return
       }
-      chat.error.set(err)
+      // Network drop (no 'done' received): show ONE connection-lost surface
+      // and recover via the poller + terminal task.status event (F2/F3).
+      // Don't also set chat.error — the banner+toast convey it, and a raw
+      // "Failed to fetch" alongside would just be noise.
       if (!receivedDone) {
         chat.connectionState.set('disconnected')
         startSessionPolling(sessionId)
-        addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
+        addChatError(
+          'Connection to the agent dropped. The task keeps running — it will catch up here automatically.',
+          'Dismiss',
+          'connection'
+        )
       } else {
+        // Stream ended cleanly but fetch reported an error tail — surface it.
+        chat.error.set(humanizeChatError(err))
         chat.streaming.set(false)
       }
     },
@@ -793,7 +853,7 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
       })
       startSessionPolling(sessionId)
     } else if (ev.type === 'error') {
-      c.error.set(ev.data)
+      c.error.set(humanizeChatError(ev.data))
     }
   }
 
@@ -823,12 +883,16 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
         chat.streaming.set(false)
         return
       }
-      chat.error.set(err)
       if (!receivedDone && sessionId) {
         chat.connectionState.set('disconnected')
         startSessionPolling(sessionId)
-        addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
+        addChatError(
+          'Connection to the agent dropped. The task keeps running — it will catch up here automatically.',
+          'Dismiss',
+          'connection'
+        )
       } else {
+        chat.error.set(humanizeChatError(err))
         chat.streaming.set(false)
       }
     },
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 3b4b0e7..62b01dd 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -57,6 +57,11 @@ export interface ToolCallResult {
   args?: Record<string, unknown>
   result?: unknown
   error?: string
+  // Streaming command output for an in-flight `run` call — attached live from
+  // the execution.output event stream (execstream.ts) while the call is still
+  // running, so the tool card can show output as it arrives instead of all at
+  // once when the tool_result lands. Not present on persisted/historical calls.
+  liveOutput?: string
 }
 
 // ---- Message content (persisted messages from /agent/sessions/:id) ----