diff --git a/AGENTS.md b/AGENTS.md index d608c6e50..3a451cc8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,3 +17,10 @@ A good comment answers questions like: Don't bother explaining *what* the code does — that's what reading the code is for. Explain *why it exists*, *why it's shaped this way*, and *what would make it wrong*. Make agentic development and decision-making **transparent in the diff**, not in a doc that drifts. + +## Directory conventions + +Two top-level folders look superficially similar but mean very different things. Do not mix them. + +- `vendor/` — read-only **source code references** kept locally so future-you can grep the original implementation of something we wrap or interoperate with. Never imported, never built, never shipped. Examples: `claude-code-src/`, vendored upstream checkouts for bug-bisection. +- `third_party/` — pinned **runtime artifacts** that we ship inside packaged Agent Code (or that the release pipeline fetches at build time). Each tool gets its own directory with a `manifest.json` (version + per-arch sha256 + URL template), `README.md`, `LICENSE.md`, and a `.gitignore` that keeps `cache/` and `build/` out of git. Binaries are NEVER committed — the manifest plus `scripts/runtime-tools/` is the source of truth, and version bumps are one-file PRs against `manifest.json`. Examples: `third_party/mitmproxy/`, `third_party/tmux/`. See issues #119 and #120 for the full bundling plan. diff --git a/CODEX.md b/CODEX.md deleted file mode 100644 index d608c6e50..000000000 --- a/CODEX.md +++ /dev/null @@ -1,19 +0,0 @@ -# Agent Code - -This codebase is written and developed by AI. Future you will pick up where past you left off, with no shared memory beyond what's in the repo. - -## Comment policy - -Write **thick WHY comments** liberally throughout the code. Be generous — over-comment, don't under-comment. The cost of an extra paragraph is nothing; the cost of future-you re-deriving a tricky decision from scratch is real. - -A good comment answers questions like: - -- *Why this approach and not the obvious alternative?* -- *What constraint forced this shape?* -- *What did we try first that didn't work?* -- *What invariant must hold here, and what breaks if it doesn't?* -- *What's the source of truth for this value?* - -Don't bother explaining *what* the code does — that's what reading the code is for. Explain *why it exists*, *why it's shaped this way*, and *what would make it wrong*. - -Make agentic development and decision-making **transparent in the diff**, not in a doc that drifts. diff --git a/MANIFESTO.md b/MANIFESTO.md index 154921fc5..89fd1973b 100644 --- a/MANIFESTO.md +++ b/MANIFESTO.md @@ -32,10 +32,6 @@ The whole app is built for efficient development. All terminals are tmux-persist ## Structure -One of the principles I understood early is that getting open-source contributors on board with something like this is hard. So Agent Code is dependent on 4 separate projects that it imports as packages, all more broadly scoped, which will hopefully attract more open-source attention. - -## Structure - One of the principles I understood early is that getting open-source contributors on board with something like this is hard. So Agent Code is dependent on 4 separate projects that it imports as packages — all more broadly scoped, which will hopefully attract more open-source attention. **1. Headless Claude Code and Codex** diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..4f731473a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,27 @@ +# Agent Code docs + +Layout and status conventions. Superseded/closed material moves to `archive/` +and carries a `Status:`/`ARCHIVED` header — a doc without one is meant to be +live. + +## Live + +- **`rendering/`** — the current rendering engine. `rendering-rewrite-plan-2026-07.md` + is canonical; `residue-plan-2026-07.md` and `session-recording-plan-2026-07.md` + are its live follow-ons; `legacy-deletion-manifest.md` is the cutover contract; + `rendering-knowledge-dump.md` is the evidence bible. `research-2026-07/` holds + the supporting research (rewrite research at the top level, the + session-recording research under `session-recording/`). +- **`design/`** — living subsystem design (conditions, ghost system). +- **`plans_and_ideas/`** — active/near-term plans and idea notes. +- **`superpowers/`** — dated planning space: `plans/` (feature/fix plans) and + `specs/` (design specs). Accumulates by date; a plan carries a `Status:` header + once it ships or is superseded. +- **`command-style.md`** — command-authoring conventions. +- **`screenshots/`** — README assets. + +## Archived (`archive/`) + +- **`codex-rewrite-render/`** — the pre-rewrite rendering notebook (superseded). +- **`issue-investigations/`** — investigations for issues now closed. +- **`audit-plans/`** — the deep-audit roadmap and its completed execution logs. diff --git a/docs/audit-plans/deep-audit-master-roadmap.md b/docs/archive/audit-plans/deep-audit-master-roadmap.md similarity index 100% rename from docs/audit-plans/deep-audit-master-roadmap.md rename to docs/archive/audit-plans/deep-audit-master-roadmap.md diff --git a/docs/audit-plans/execution/debug-dictation-release-implementation-log.md b/docs/archive/audit-plans/execution/debug-dictation-release-implementation-log.md similarity index 100% rename from docs/audit-plans/execution/debug-dictation-release-implementation-log.md rename to docs/archive/audit-plans/execution/debug-dictation-release-implementation-log.md diff --git a/docs/audit-plans/execution/dispatch-shell-cleanup-implementation-log.md b/docs/archive/audit-plans/execution/dispatch-shell-cleanup-implementation-log.md similarity index 100% rename from docs/audit-plans/execution/dispatch-shell-cleanup-implementation-log.md rename to docs/archive/audit-plans/execution/dispatch-shell-cleanup-implementation-log.md diff --git a/docs/audit-plans/execution/feed-conditions-weirdness-implementation-log.md b/docs/archive/audit-plans/execution/feed-conditions-weirdness-implementation-log.md similarity index 100% rename from docs/audit-plans/execution/feed-conditions-weirdness-implementation-log.md rename to docs/archive/audit-plans/execution/feed-conditions-weirdness-implementation-log.md diff --git a/docs/audit-plans/execution/ipc-shared-contracts-implementation-log.md b/docs/archive/audit-plans/execution/ipc-shared-contracts-implementation-log.md similarity index 100% rename from docs/audit-plans/execution/ipc-shared-contracts-implementation-log.md rename to docs/archive/audit-plans/execution/ipc-shared-contracts-implementation-log.md diff --git a/docs/audit-plans/execution/lifecycle-orchestration-runtime-implementation-log.md b/docs/archive/audit-plans/execution/lifecycle-orchestration-runtime-implementation-log.md similarity index 100% rename from docs/audit-plans/execution/lifecycle-orchestration-runtime-implementation-log.md rename to docs/archive/audit-plans/execution/lifecycle-orchestration-runtime-implementation-log.md diff --git a/docs/audit-plans/execution/provider-headless-boundaries-implementation-log.md b/docs/archive/audit-plans/execution/provider-headless-boundaries-implementation-log.md similarity index 100% rename from docs/audit-plans/execution/provider-headless-boundaries-implementation-log.md rename to docs/archive/audit-plans/execution/provider-headless-boundaries-implementation-log.md diff --git a/docs/codex-rewrite-render/README.md b/docs/archive/codex-rewrite-render/README.md similarity index 87% rename from docs/codex-rewrite-render/README.md rename to docs/archive/codex-rewrite-render/README.md index 6617d9b94..57e5aadb5 100644 --- a/docs/codex-rewrite-render/README.md +++ b/docs/archive/codex-rewrite-render/README.md @@ -1,3 +1,10 @@ +> **ARCHIVED / SUPERSEDED (2026-07-07).** This was the first-principles working +> notebook for the feed-rendering rewrite. It has been fully absorbed into the +> canonical plan `docs/rendering/rendering-rewrite-plan-2026-07.md` and its +> research digest `docs/rendering/research-2026-07/rewrite-notebook-extraction.md`. +> Kept for history; reads present-tense but describes the OLD `deriveFeedRenderModel` +> approach, which the ownership-ledger pipeline (`src/renderer/src/rendering/`) replaced. + # Codex Render Rewrite Notes This directory is the working notebook for rebuilding feed rendering from first principles while keeping live streaming. diff --git a/docs/codex-rewrite-render/feed-render-item-plan.md b/docs/archive/codex-rewrite-render/feed-render-item-plan.md similarity index 100% rename from docs/codex-rewrite-render/feed-render-item-plan.md rename to docs/archive/codex-rewrite-render/feed-render-item-plan.md diff --git a/docs/codex-rewrite-render/feed-ui-rendering.md b/docs/archive/codex-rewrite-render/feed-ui-rendering.md similarity index 100% rename from docs/codex-rewrite-render/feed-ui-rendering.md rename to docs/archive/codex-rewrite-render/feed-ui-rendering.md diff --git a/docs/codex-rewrite-render/first-principles-render-model.md b/docs/archive/codex-rewrite-render/first-principles-render-model.md similarity index 100% rename from docs/codex-rewrite-render/first-principles-render-model.md rename to docs/archive/codex-rewrite-render/first-principles-render-model.md diff --git a/docs/codex-rewrite-render/headless-channel-model.md b/docs/archive/codex-rewrite-render/headless-channel-model.md similarity index 100% rename from docs/codex-rewrite-render/headless-channel-model.md rename to docs/archive/codex-rewrite-render/headless-channel-model.md diff --git a/docs/codex-rewrite-render/renderer-runtime-ingestion.md b/docs/archive/codex-rewrite-render/renderer-runtime-ingestion.md similarity index 100% rename from docs/codex-rewrite-render/renderer-runtime-ingestion.md rename to docs/archive/codex-rewrite-render/renderer-runtime-ingestion.md diff --git a/docs/codex-rewrite-render/submit-queue-debug.md b/docs/archive/codex-rewrite-render/submit-queue-debug.md similarity index 100% rename from docs/codex-rewrite-render/submit-queue-debug.md rename to docs/archive/codex-rewrite-render/submit-queue-debug.md diff --git a/docs/codex-rewrite-render/upstream-codex-rendering.md b/docs/archive/codex-rewrite-render/upstream-codex-rendering.md similarity index 100% rename from docs/codex-rewrite-render/upstream-codex-rendering.md rename to docs/archive/codex-rewrite-render/upstream-codex-rendering.md diff --git a/docs/issue-investigations/147-multi-instance-policy.md b/docs/archive/issue-investigations/147-multi-instance-policy.md similarity index 99% rename from docs/issue-investigations/147-multi-instance-policy.md rename to docs/archive/issue-investigations/147-multi-instance-policy.md index 3bcf8335b..cd62b6c96 100644 --- a/docs/issue-investigations/147-multi-instance-policy.md +++ b/docs/archive/issue-investigations/147-multi-instance-policy.md @@ -1,3 +1,5 @@ +> **ARCHIVED (2026-07-07).** The investigated issue is closed; kept as history. + # Issue #147: Multiple Agent Code Instances ## Issue Summary diff --git a/docs/issue-investigations/152-dispatch-terminals.md b/docs/archive/issue-investigations/152-dispatch-terminals.md similarity index 99% rename from docs/issue-investigations/152-dispatch-terminals.md rename to docs/archive/issue-investigations/152-dispatch-terminals.md index ed6855b03..301d21990 100644 --- a/docs/issue-investigations/152-dispatch-terminals.md +++ b/docs/archive/issue-investigations/152-dispatch-terminals.md @@ -1,3 +1,5 @@ +> **ARCHIVED (2026-07-07).** The investigated issue is closed; kept as history. + # Issue 152: Add terminal sessions to Dispatch Mode ## Issue summary diff --git a/docs/issue-investigations/154-undo-close.md b/docs/archive/issue-investigations/154-undo-close.md similarity index 99% rename from docs/issue-investigations/154-undo-close.md rename to docs/archive/issue-investigations/154-undo-close.md index f01e2689a..70d86c292 100644 --- a/docs/issue-investigations/154-undo-close.md +++ b/docs/archive/issue-investigations/154-undo-close.md @@ -1,3 +1,5 @@ +> **ARCHIVED (2026-07-07).** The investigated issue is closed; kept as history. + # Issue 154: Extend Undo Close History and Retention ## Issue Summary diff --git a/docs/issue-investigations/180-181-rendered-link-activation.md b/docs/archive/issue-investigations/180-181-rendered-link-activation.md similarity index 99% rename from docs/issue-investigations/180-181-rendered-link-activation.md rename to docs/archive/issue-investigations/180-181-rendered-link-activation.md index 0ec1e4710..9cb1a2d32 100644 --- a/docs/issue-investigations/180-181-rendered-link-activation.md +++ b/docs/archive/issue-investigations/180-181-rendered-link-activation.md @@ -1,3 +1,5 @@ +> **ARCHIVED (2026-07-07).** The investigated issue is closed; kept as history. + # Issues #180 and #181: Rendered Link Activation ## Issue Summaries diff --git a/docs/rendering/legacy-deletion-manifest.md b/docs/rendering/legacy-deletion-manifest.md new file mode 100644 index 000000000..a57090050 --- /dev/null +++ b/docs/rendering/legacy-deletion-manifest.md @@ -0,0 +1,92 @@ +# Legacy Rendering — Deletion Manifest + +The contract for the complete strip-out of the old rendering system. The old +code is NOT deleted early: Stage 2 shadow mode diffs the new ledger against +the live legacy renderer, so the legacy path must keep working until cutover. +This manifest is what "starting fresh" means in practice — every legacy file +is enumerated with its fate and its deletion point, so nothing survives by +accident. LOC figures from the knowledge dump's Blast Radius appendix +(measured 2026-05-22; drifted slightly since, fates unchanged). + +Legend: **DELETE** = removed entirely · **GUT** = file stays, ownership logic +removed (becomes dumb/plumbing) · **ABSORB** = behavior re-encoded in the +ledger, then source deleted · **KEEP** = not ownership code, survives as-is. + +## Cutover status — 2026-07 (the ledger is now the sole decision core) + +The cutover shipped in two slices because a late discovery split it: the +**remote phone client** (`src/remote-client/`) is a SECOND `` consumer +that was mounting the legacy `deriveFeedRenderModel` path. The manifest below +was written assuming one consumer, so "delete deriveFeedRenderModel" was +blocked until the phone also fed the ledger. + +**DONE (this cutover PR):** +- The ledger producer (`useLedgerFeedItems`) is UNCONDITIONAL. The + `AGENT_CODE_RENDER_PIPELINE` flag + its whole probe are gone; so is + `AGENT_CODE_RENDER_SHADOW` and the `renderShadowEnabled`/ + `renderPipelineEnabled` dev-debug plumbing. +- **The phone was migrated**: `remote/ui/SessionView.tsx` builds a minimal + SessionRuntime view (empty ghost plane — the phone has no optimistic echo) + and drives the SAME `useLedgerFeedItems`, so it now passes + `renderItemsOverride` like the desktop. +- `deriveFeedRenderModel` + its sort/visibility helpers + `FeedRenderModelInput` + **DELETED** from `renderModel.ts`. Feed's legacy branch **DELETED** — it maps + the ledger's items unconditionally. +- Shadow subsystem **DELETED**: `shadow/useRenderShadow.ts` and the + `shadowParity.test.ts` CI diff. `shadow/shadowDiff.ts` KEPT (pure diff util — + bundleCorpus/recordingCorpus still assert ledger output through it). + +**STILL ALIVE — the block-level un-collapse slice (next PR):** the view bridge +still COLLAPSES the ledger's block-level semantic rows back into turn-level +items so `SemanticStreamingTurn` can render them. Killing that component (and +with it ghost rule 3, `selectMergedEntries` as Feed's entries source, and +`committedClaudeMessageTurnIds`) requires the ledger to own live turns +block-by-block through the shared registry dispatch — the hardest slice, and +the one that needs live visual verification the turn-grain corpus can't give. +Until then the completeness grep below still returns `SemanticStreamingTurn` / +`selectMergedEntries` / `committedClaudeMessageTurnIds` hits BY DESIGN; +`deriveFeedRenderModel` / `buildSemanticRenderUnits` are already clean. + +## Deleted at Stage 3 cutover (the big reviewed PR) + +| file | phys LOC | fate | notes | +|---|---:|---|---| +| `features/feed/ui/semantic/StreamingTurn.tsx` | 149 | **DELETE** | atomically with ghost rule 3 (plan D6) — never one without the other | +| `features/feed/model/renderModel.ts` | 430 | **DELETE** | replaced by ledger + view model; `committedClaudeMessageTurnIds` naming lie dies with it | +| `features/feed/ui/semantic/renderUnits.ts` | 359 | **ABSORB** | committed-ownership skips → ledger reasons; collapsed-activity policy → view layer | +| `workspace/mergedEntries.ts` | ~200 | **ABSORB** | five-rule predicate → ledger ghost decisions; the entries pre-pass merge dies | +| `workspace/semantic/foldEvent.ts` | 987 | **GUT→ABSORB** | reducer stays (events→turn state); ALL ownership/suppression gates + fold policies move to ledger candidate rules; yield hatches become policy | +| `features/feed/ui/semantic/BlockRow.tsx` | 495 | **GUT** | becomes dumb rows; hardcoded provider dispatch replaced by the registry (same one committed rows use); all suppression branches deleted | +| `features/feed/ui/Feed.tsx` | 946 | **GUT** | maps view-model rows once; all plane/ownership/debug-derivation logic deleted; scroll/lazy stay per migration hazards | +| `features/feed/ui/rows/Block.tsx` | 204 | **GUT** | dispatch stays, ownership guards deleted | +| `workspace/ghosts.ts` | ~530 | **GUT** | minting/reconcile/persist stay (bookkeeping); render-side selection is ledger's | +| legacy `visible_rows`-only debug derivation | — | **DELETE** | replaced by ownership_decision serialization (visible_rows stays as aggregate) | + +## Deleted during Stage 1 (already superseded, no shadow value) + +- old fold-policy literals already replaced on main (#415) — nothing to do +- `workspace/queueInvariants.ts` — **ABSORB** into ledger queue-handoff rules + at cutover, not before (echo providers still run through it live) + +## KEEP (not ownership code) + +| file | LOC | why it lives | +|---|---:|---| +| `markdown/Prose.tsx`, `MarkdownComponents.tsx`, remark plugins | ~250 | TextProse/StreamingProse split is load-bearing (migration hazard) | +| `lib/streamingWriteInput.ts` | 206 | Write-scanner, pure detector — feeds the view layer | +| `WorkIndicator.tsx` + hints | 305 | phase surface; consumes work candidates | +| row components (ToolUseRow/ToolResultRow/ConversationRow/Claude+Codex+OpenCode rows) | ~1600 | become dumb rows; only their embedded ownership guards die (counted under GUT above) | +| `lib/helpers.ts` committed text/tool index builders | 386 | logic ABSORBED into ownership.ts; file gutted at cutover | +| headless packages | ~11k | contract-tested, never rewritten (plan §10) | + +## Verification of completeness at cutover + +FULL completeness (the final block-level un-collapse PR) is reached when: +`grep -rn "SemanticStreamingTurn\|selectMergedEntries\|deriveFeedRenderModel\|committedClaudeMessageTurnIds\|buildSemanticRenderUnits" src/` returns only +hits inside `src/renderer/src/rendering/` history comments — i.e. every entry +in the DELETE/ABSORB tables above is gone or gutted, with its fixture green. + +As of the 2026-07 cutover (see status section up top) `deriveFeedRenderModel` +and `buildSemanticRenderUnits` are already clean; the remaining three tokens +(`SemanticStreamingTurn`, `selectMergedEntries`, `committedClaudeMessageTurnIds`) +stay until the block-level un-collapse slice retires `SemanticStreamingTurn`. diff --git a/docs/rendering/rendering-rewrite-plan-2026-07.md b/docs/rendering/rendering-rewrite-plan-2026-07.md index f75442317..cd83b4315 100644 --- a/docs/rendering/rendering-rewrite-plan-2026-07.md +++ b/docs/rendering/rendering-rewrite-plan-2026-07.md @@ -11,6 +11,65 @@ wins; where this one is silent, the dump's Non-Negotiable Rules apply. Read this first. Then read `research-2026-07/` for the receipts. +## Stage 1 progress (updated 2026-07-06, integration branch) + +Landed (slices 1-11, PRs #424-#437 into `integration/rendering-pipeline`): +ledger core (types / ordering law / ownership / identity stability), committed +collector with the #338 synthetic-user filter, ghost five-rule predicate with +the 10-case matrix and failed-fix regressions, semantic collector with the +#345 compaction kill, local collectors + optimistic→committed handoff, +unknown-behavior registry with structural redaction, ghost plane wired into +the ledger pass (#435), the runtime→collector adapter — the shadow seam — +with ghost collector, optimistic partition, plane-level reference stability, +and compile-time seam assertions against real runtime types (#436), the +fold-policy yield-hatch absorption (`policy/foldPolicy.ts` — the two +proxy-literal hatches from legacy foldEvent encoded as policy data with +parity tests) and six end-to-end fixtures: buried-prompt-239, +dead-committed-channel-159, opencode-interleave-87f0eeef, the ghost matrix, +sidecar-tail-ghost, and queue-handoff-race (#437). Suite: 67 pipeline tests. + +**Stage 1 is COMPLETE.** (Unknowns plumbing note: the registry exists and +`LedgerInput.unknowns` passes through; POPULATING it from real IPC events +is a shadow-seam concern — unknowns are discovered live, not in fixtures.) + +### Stage 2/3 progress (updated 2026-07-07, through PR #447) + +Landed since the Stage 1 wrap (slices 12-17 + triage, PRs #439-#447): +- **Shadow mode** (#439): `AGENT_CODE_RENDER_SHADOW=1` runs adapter+ledger + beside the legacy renderer in TileLeaf; `shadow/shadowDiff.ts` normalizes + both to comparable units (rows by id, turns by turnId); divergences land + on `globalThis.__agentCodeRenderShadow` (ring of 50) + one console.warn + per signature. Shadow parity suite (#441) runs the same diff in CI. +- **Bundle corpus** (#444-#445, #447): 46 fixtures extracted from the + manual debug-bundle archive (`scripts/extract-rendering-fixtures.mjs`, + triage rules in `scripts/triage-rendering-fixtures.mjs`) — each is a + real captured rendering failure with the legacy renderer's ACTUAL + painted rows as ground truth. `bundleCorpus.test.ts` asserts the diff + matches checked-in triage exactly (bless via AGENT_CODE_CORPUS_BLESS=1). + State: 0 untriaged. Corpus catches so far: opencode blockless-text turns + invisible (fixed #444); system/compact_boundary rows dropped (fixed + #445); committed tool-ownership mining gap + dangling tool chip + (confirmed #447, fixes queued). +- **View bridge** (#443): `view/ledgerFeedItems.ts` maps ledger rows to + legacy FeedRenderItems — cutover swaps the decision core, row components + survive. **Cutover flag** (#446): `AGENT_CODE_RENDER_PIPELINE=1` makes + Feed paint from the ledger via `renderItemsOverride`; flag off is + byte-identical legacy. + +**Soak instructions (the current gate):** run daily work on this branch +with `AGENT_CODE_RENDER_PIPELINE=1 AGENT_CODE_RENDER_SHADOW=1` across all +three providers. Watch for `[render-shadow] divergence` and +`[render-pipeline] dropped candidates` in the console; every divergence +class gets triaged into the corpus. Exit = a week of real use with no +untriaged class. + +Remaining before the main merge: the two #447-confirmed collector fixes +(committed tool-ownership mining; dangling tool chip), extractor v2 +(codex rollout→Entry mapping unlocks the 122 extraction-gap verdicts), +the soak above, then the final cutover slice — SemanticStreamingTurn + +ghost rule 3 deleted atomically, deletion-manifest grep sweep, flag +default-on — and the reviewed integration→main PR (draft #442). + --- ## 0. Why this plan exists and what it refuses to be diff --git a/docs/rendering/research-2026-07/plan-json-tool-rows.md b/docs/rendering/research-2026-07/plan-json-tool-rows.md new file mode 100644 index 000000000..5e62e3299 --- /dev/null +++ b/docs/rendering/research-2026-07/plan-json-tool-rows.md @@ -0,0 +1,316 @@ +# Plan: Generic JSON / MCP / Custom Tool-Call Row Rendering + +Research date: 2026-07-07. Sources: 4 debug bundles under +`~/.config/agent-code/debug-bundles/manual/`, current row code on `main` +(41e0ba4), the rendering rewrite plan +(`docs/rendering/rendering-rewrite-plan-2026-07.md`), and the slice-16 +worktree (`.worktrees/rendering-slice16`, view bridge +`src/renderer/src/rendering/view/ledgerFeedItems.ts`). + +--- + +## 1. Inventory — what has NO custom UI today (from the bundles) + +### 1a. Claude MCP tools — `tool_use` blocks named `mcp____` +Bundle `2026-06-21T19-19-55-972-62432945` (note: "No rendering for JSON tool +calls… worth building a case for agent and MCP calls"). + +`mcp__agent_code__orchestration_create_agent` ×4 — payload: + +```json +{ "kind": "claude", "runId": "pr2-design-consult", + "title": "Consult: AUQ keystroke driver + dismissal", "role": "consultant", + "cwd": "/Users/juliusolsson/Desktop/Development/agent-code", + "prompt": "Design consult for Agent Code (#289 PR-2). We're making…" } +``` + +**What painted** (html-clean.html): the bare tool name +`mcp__agent_code__orchestration_create_agent` with NOTHING under it — the +generic `ToolUseRow` headline chain (`command → file_path → path → +notebook_path → pattern → query → url → description`) matches no key of any +MCP input, so the body is empty. The result painted as a raw one-line JSON +blob in a `
`: `{"ok":true,"agent":{"sessionId":"0f6a41eb-…`.
+
+### 1b. Codex MCP/orchestration tools — `function_call` blocks with bare names
+Bundles `2026-06-24T11-54-58-903-ad91e792` ("Implement just general json
+rendering for stuff like this") and `2026-06-24T12-55-50-798-7bab27ca` ("not
+rendering any custom UI for json calls, MCP or any like that").
+
+Codex-side MCP calls carry NO `mcp__` prefix — the semantic blocks have
+`kind:"function_call"`, `toolName` bare, `inputJson`/`argumentsJson` (string),
+`parsedInput` (object), `inputJsonValid`, later `resultContent`/`resultAt`.
+
+- `orchestration_read_agent` ×14: `{"sessionId":"97befa87-…","maxMessages":12}`
+- `orchestration_wait_agents`: `{"sessionIds":["97befa87-…"],"timeoutMs":30000,"pollIntervalMs":2000,"maxMessagesPerAgent":3}`
+- `orchestration_send_prompt`: `{"sessionId":"…","prompt":"Important constraint reminder: do not modify…"}`
+- `ai_workspace_create`: `{"name":"Deep Code Audit Implementation Plans","description":"All verified audit…","scope":{"project":"agent-code","runId":"deep-code-audit-2026-06-23",…}}`
+- `ai_workspace_attach_file` ×18: `{"workspaceId":"4d2cf480-…","path":"/Users/…/docs/audit-plans/commands-ui-shell.md","title":"Commands UI Shell Plan","description":"Implementation plan from Commands UI Shell audit.",…}`
+- `orchestration_read_run_outputs`, `orchestration_list_agents` (`{}` input → nothing to show at all)
+
+**What painted**: `CodexToolRow` = name + ONE headline line. For
+`orchestration_read_agent` the headline chain finds nothing → bare name only.
+For `ai_workspace_attach_file` it shows `description` ("Implementation plan
+from Commands UI Shell audit.") and HIDES the actual `path` — a real ordering
+bug: `headlineForTool` (CodexRows.tsx:164-168) checks `input.description`
+BEFORE `input.path`. Results painted as raw single-line blobs:
+`Wall time: 0.0200 seconds\nOutput:\n[{"type":"text","text":"{\"ok\":true,\"output\":{…escaped json…}"}]`.
+
+### 1c. Suppressed-row starvation — bundle `2026-06-23T12-45-50-654-1a616ae8`
+("not showing most tool calls in rendering, only occasional updates").
+Session had 9 `exec_command` + 19 `write_stdin` calls. ALL 19 `write_stdin`
+had empty `chars` (poll continuations) → `CodexWriteStdinRow` returns `null`
+by design (CodexRows.tsx:443-451). Only 7 `Run` rows painted. So during a
+long interactive-PTY stretch the feed shows almost nothing. The generic row
+doesn't fix the intentional empty-stdin suppression, but any session mixing
+MCP calls with these (bundle 11-54) shows mostly-blank tool rows — same
+perceived symptom.
+
+### 1d. What ALREADY has custom UI (do not touch)
+- Claude: `EditRow`, `MultiEditRow`, `WriteRow`, `TodoRow`
+  (`src/providers/claude/renderer/rows/ClaudeRows.tsx`, dispatch in
+  `dispatch.tsx`); `AskUserQuestionRow`, `TaskSubagentRow` (Agent/spawn_agent),
+  Read/Grep result special-casing in `ToolResultRow.tsx`.
+- Codex: `CodexExecCommandRow`, `CodexWriteStdinRow`, `CodexApplyPatchRow`
+  (+ `DiffSlab`/`PatchFileHeader` in `src/providers/shared/renderer/rows/`),
+  `CodexToolResultRow` exec/patch kinds.
+- OpenCode: `todowrite` → reused `TodoRow`; everything else falls through.
+- Cross-provider: `GitCardRow` interception for Bash/exec_command git commands
+  (Block.tsx:124-135, behind `settings.customRendering`, default **false**).
+
+**The gap in one sentence:** every tool that is not a file-op/shell/todo —
+i.e. all MCP tools on both providers, all orchestration/workspace tools, any
+future custom tool — renders as a bare name with either nothing, or one
+misleading string, and its JSON result as an unformatted single-line blob.
+
+---
+
+## 2. Current dispatch architecture (where a fallback can slot in)
+
+Committed rows — `src/renderer/src/features/feed/ui/rows/Block.tsx`:
+
+```
+case 'tool_use':
+  … GitCardRow interception … TaskSubagentRow interception …
+  const providerRow = getRendererProviderCapabilities(currentProvider).renderToolUse?.(tu)
+  return providerRow !== undefined ? providerRow :    // ← THE fallback seam
+case 'tool_result':
+  const providerRow = …renderToolResult?.(tr, { sourceTool })
+  return providerRow !== undefined ? providerRow : 
+```
+
+Provider dispatch tables live in
+`src/providers/{claude,codex,opencode}/renderer/rows/dispatch.tsx`, registered
+via `renderToolUse`/`renderToolResult` in
+`src/providers/registry.renderer.capabilities.ts`. Claude + OpenCode return
+`undefined` for unknown tools (fall to shared `ToolUseRow`); **Codex claims
+everything** (`return ` for any name), which is
+why the codex fallback and the shared fallback have drifted apart.
+
+Live rows — `src/renderer/src/features/feed/ui/semantic/BlockRow.tsx`
+(`SemanticLiveBlockRow`) is a parallel hand-rolled dispatch: `function_call`/
+`custom_tool_call` → converts the semantic block to a `ToolUseBlock` and
+reuses the committed Codex rows (fallback `CodexToolRow`); `tool_use`/
+`mcp_tool_use`/`server_tool_use` → inline JSX that dumps `block.inputJson`
+raw into a `
` and `resultContent` into another `
`. This is the
+"BlockRow bypass drift" the rewrite plan kills at Stage 3 ("live tool rows
+route through the SAME provider registry dispatch committed rows use").
+
+New pipeline bridge — slice-16 worktree
+`src/renderer/src/rendering/view/ledgerFeedItems.ts`: the ledger emits legacy
+`FeedRenderItem`s; `type:'entry'` items still render through
+`Block.tsx`, `type:'semantic-*'` still render through
+`SemanticStreamingTurn`/`BlockRow`. **Consequence: any row work done on the
+legacy components survives the Stage-3 cutover unchanged**, because Stage 3
+swaps the decision core, not the row components.
+
+---
+
+## 3. Design
+
+### 3a. One shared generic JSON tool row
+New file `src/providers/shared/renderer/rows/JsonToolRow.tsx` (next to
+`DiffSlab.tsx` — the established home for cross-provider row primitives).
+
+Committed/final shape:
+
+```
+⏺ orchestration_create_agent   MCP · agent_code        ← prettified name + muted badge
+  ⎿ title: "Consult: AUQ keystroke driver + dismissal" ← smart headline (see below)
+  ⎿ ▸ 6 params (click to expand)                       ← collapsed 
+ { ← pretty JSON, hljs json, + "kind": "claude", paths/urls linkified + "cwd": "~/Desktop/Development/agent-code", ← formatToolFilePath display, + … raw path in title tooltip +``` + +Pieces: + +1. **Name prettification** — `mcp____` → display `` with + a muted `MCP · ` badge (claude naming); bare codex names stay + as-is (codex strips the prefix upstream, nothing to detect). Pure helper, + ~15 lines. +2. **Smart headline** — unify the two existing chains (`ToolUseRow.pickString` + chain + Codex `headlineForTool`) into one, with `path`-shaped keys + (`file_path`, `path`, `notebook_path`, `url`) checked BEFORE + `title`/`name`/`prompt`, and `description` last (fixes the + `ai_workspace_attach_file` description-over-path bug). Add `title`, + `name`, `prompt`, `sessionId` as recognized keys — those are what MCP + payloads actually carry (evidence above). +3. **Collapsible JSON slab** — `
` closed by default (matches the + thinking-row and Read-result precedent), summary `▸ N params`. Body: + `JSON.stringify(input, null, 2)` through the existing `CodeBlock` + (`language="json"`, hljs engine — one-shot highlight is fine for committed + rows; pass `highlight={false}` on the live path, same O(bytes²) rationale + as the Write streaming preview in BlockRow.tsx:494-505). Cap rendered JSON + at ~200 lines / 16KB with the `TruncatedOutputRow`-style "+N lines" + expander so a 50KB prompt param can't wreck the feed. +4. **Path/URL linkification** — post-process the highlighted output is + fragile; instead render string values whose text matches + `/^(~|\/)[^\0]*/` (absolute path) via `formatToolFilePath(path, + workspaceRoot)` (already in `@shared/paths/displayPath`, used by + CodexRows) with the raw path in `title`, and `https?://` values as + ``. Simplest robust shape: a tiny custom key-value renderer for the + TOP level (key, primitive value with path/url detection) and plain + pretty-JSON `CodeBlock` for nested objects — this covers every payload in + the corpus (all are 1-2 levels deep) without writing a JSON tree widget. +5. **Result slab** — shared `tryExtractJson(text)` helper: + (a) unwrap the MCP text envelope `[{"type":"text","text":""}]` one + level, (b) unwrap codex's `Wall time: …\nOutput:\n` wrapper, + (c) `JSON.parse` the remainder; on success render collapsed pretty JSON + (summary: `ok:true` / `ok:false` / `N keys`), on failure fall through to + the existing `TruncatedOutputRow`. Error styling: keep `is_error` → + `text-danger` exactly as today; additionally style the summary line danger + when the parsed object has `ok === false` (cheap, evidence-backed — every + orchestration/workspace result in the corpus carries `ok`). + +### 3b. Dispatch rule — where the fallback slots in +**Rule: the generic JSON row is the SHARED feed fallback that fires when no +provider claims the tool.** Concretely: + +1. `Block.tsx` `tool_use` case: replace `` fallback + with ``. `JsonToolRow` internally degrades to + exactly today's ToolUseRow look (name + one headline line) when the input + has ≤1 meaningful field — so Bash rows (claude) keep their current + rendering including `truncateBashCommand` (fold ToolUseRow's Bash cap into + JsonToolRow, then delete ToolUseRow — or keep ToolUseRow as a thin wrapper; + deleting is cleaner per the opportunistic-cleanup rule). +2. `providers/codex/renderer/rows/dispatch.tsx`: `renderCodexToolUse` stops + claiming unknown names — keep `apply_patch`/`exec_command`/`write_stdin` + dispatch, `return undefined` otherwise so codex MCP/orchestration tools hit + the same shared fallback as claude's. (`CodexToolRow` stays only as the + internal degraded fallback for unparseable exec/patch inputs, or gets the + same JsonToolRow treatment.) This is exactly the shape the opencode + dispatch comment already prescribes ("everything else intentionally falls + through to the generic rows"). +3. `providers/codex/renderer/rows/dispatch.tsx` `renderCodexToolResult`: in + `CodexToolResultRow`'s final fallback (CodexRows.tsx:601-603), try + `tryExtractJson` before `TruncatedOutputRow`. +4. Shared `ToolResultRow.tsx`: same — before the final `TruncatedOutputRow` + (line 177), try `tryExtractJson`. +5. Live path `BlockRow.tsx` (glue only, all logic in the shared components): + - `function_call`/`custom_tool_call` fallback (line 261 + `return `): → `` + (live ⇒ `highlight={false}`, and only pretty-print once + `parsedInput`/parse succeeds — partial `inputJson` keeps today's raw + `
`).
+   - `tool_use`/`mcp_tool_use`/`server_tool_use` branch: the raw
+     `{block.inputJson}` `
` (line 514-518) and the `resultContent`
+     `
` (line 527-539) get the same parse-gated upgrade.
+   These BlockRow edits are DELIBERATELY thin because Stage 3 deletes this
+   bypass; anything smart must live in `JsonToolRow`/`tryExtractJson` so it
+   survives.
+
+`customRendering` flag: do NOT gate the JSON row behind it. That flag gates
+the git-widget interception (an opinionated replacement of Bash rows);
+JSON formatting is a fidelity fix to the default fallback, and the bundles
+show the default path is what users see (flag defaults to false).
+
+### 3c. Interaction with the rendering rewrite (Stage-3 flag)
+- **Lands now, against the legacy row path.** The ledger view bridge emits
+  legacy `FeedRenderItem`s whose `entry`/`semantic` items render through
+  Block.tsx / BlockRow.tsx — the exact components edited here. Nothing in
+  this plan touches `rendering/{model,adapter,policy,shadow}`.
+- Improving the committed fallback (`Block.tsx`) actually REDUCES Stage-3
+  risk: when Stage 3 routes live tool rows "through the SAME provider
+  registry dispatch committed rows use", the generic JSON fallback is already
+  the registry-blessed shared fallback — the BlockRow glue edits (step 5) are
+  the only throwaway code, ~20 lines.
+- One caution for shadow mode (Stage 2 running in the slice worktrees): the
+  shadow diff compares row OUTPUT signatures in some tests
+  (`shadowParity.test.ts`); if those snapshot row text, landing this on main
+  will need the slice branch to rebase/merge — flag it in the PR description.
+  Merge-simulate against the rendering slice branch per the tsc-gate memory.
+
+---
+
+## 4. Tests
+
+Corpus fixtures (the four bundles become the test cases):
+- Extract the 4 `proxy-semantic.json` tool blocks into
+  `testing/fixtures/rendering-bundles/`-style JSON fixtures (the slice-16
+  worktree already has that corpus dir + `bundleCorpus.test.ts` harness; on
+  main, put payload fixtures under `testing/fixtures/` beside its README).
+  Specifically pin:
+  - `mcp-claude-orchestration-create-agent.json` (bundle 06-21) — claude
+    `tool_use`, `mcp__agent_code__orchestration_create_agent`.
+  - `codex-orchestration-tools.json` (bundle 06-24 11:54) — 7 distinct
+    orchestration_* payloads incl. the `{}`-input `orchestration_list_agents`.
+  - `codex-ai-workspace.json` (bundle 06-24 12:55) — `ai_workspace_attach_file`
+    (the description-vs-path headline regression case) + `ai_workspace_create`.
+  - `codex-write-stdin-starvation.json` (bundle 06-23) — 19 empty write_stdin
+    + exec_command mix (guards that the generic row does NOT resurrect empty
+    stdin rows; suppression stays).
+
+Unit tests pinning payload→row mapping (policy note: the no-new-test-files
+rule is formally exempted for the rendering project; if this lands as a
+standalone feature PR, fold these into the existing renderer test entry
+points rather than minting new `test:*` scripts — the pattern to copy is
+`AskUserQuestionRow.feed.renderer.test.tsx`, vitest + @testing-library):
+1. `jsonToolPresentation` (pure helpers, no React): name prettify
+   (`mcp__a__b` → `b` + badge; bare names untouched), headline priority
+   (path beats description; command beats all), `tryExtractJson` (MCP text
+   envelope, codex Wall-time wrapper, plain JSON, non-JSON passthrough,
+   `ok:false` detection).
+2. `JsonToolRow` render tests: MCP payload → badge + headline + collapsed
+   details containing pretty JSON; `{}` input → name-only row (no empty
+   details); absolute path value → linkified display text with raw path in
+   title; >200-line JSON → truncation expander; `live` + unparseable partial
+   input → raw pre (no crash, no flicker).
+3. Result-side: claude tool_result with envelope JSON → collapsed slab;
+   codex `Wall time` result → collapsed slab; `is_error` → danger styling
+   preserved.
+
+---
+
+## 5. Code changes, file by file
+
+| # | File | Change | Est. size |
+|---|------|--------|-----------|
+| 1 | `src/providers/shared/renderer/rows/jsonToolPresentation.ts` (new) | Pure helpers: `prettifyToolName`, unified `toolHeadline`, `tryExtractJson` (envelope/wall-time unwrap), path/url token detection | ~140 lines w/ WHY comments |
+| 2 | `src/providers/shared/renderer/rows/JsonToolRow.tsx` (new) | `JsonToolRow` (header + headline + collapsible top-level key-value + nested `CodeBlock language="json"`), `JsonResultSlab` | ~220 lines |
+| 3 | `src/renderer/src/features/feed/ui/rows/ToolUseRow.tsx` | Fold Bash truncation into JsonToolRow and delete, or reduce to re-export; `rows/index.ts` update | −60 / +10 |
+| 4 | `src/renderer/src/features/feed/ui/rows/Block.tsx` | Fallback `` → `` | ~5 |
+| 5 | `src/providers/codex/renderer/rows/dispatch.tsx` | Unknown tool names return `undefined` (stop claiming); result fallback comment | ~10 |
+| 6 | `src/providers/codex/renderer/rows/CodexRows.tsx` | `CodexToolResultRow` final fallback tries `JsonResultSlab`; fix/remove `headlineForTool` description-before-path; `CodexToolRow` degraded fallback → JsonToolRow | ~40 diff |
+| 7 | `src/renderer/src/features/feed/ui/rows/ToolResultRow.tsx` | Final fallback tries `JsonResultSlab` before `TruncatedOutputRow` | ~15 |
+| 8 | `src/renderer/src/features/feed/ui/semantic/BlockRow.tsx` | Live glue: function_call fallback + tool_use raw-pre + result-pre parse-gated upgrade to shared components (throwaway at Stage 3, keep thin) | ~35 diff |
+| 9 | Fixtures + tests (see §4) | 4 payload fixtures + 2 test files | ~120 fixtures, ~250 tests |
+
+Total: ~2 new files, 6 edits, one worktree-branch PR (`.worktrees/` per
+convention). Verification: raw `tsc` on both projects + merge-simulate against
+`feat/rendering-slice16-triage`.
+
+**Can land now:** everything above (items 1-9) — it's all legacy-row-path and
+registry-dispatch work that Stage 3 explicitly preserves.
+**Must wait for Stage 3:** nothing in this plan; the only Stage-3-coupled
+piece is DELETING the BlockRow live glue (item 8) when SemanticStreamingTurn
+dies, and any move of rows into `rendering/view/` dumb-row form — do not
+pre-build that.
+
+## 6. Open questions for the caller
+- Should the generic row attempt result-JSON rendering for `ok:false` danger
+  highlight, or keep strictly `is_error`? (Plan says yes-cheaply; cut if it
+  smells like enforcement bloat.)
+- Empty-input tools (`orchestration_list_agents` `{}`): bare-name row (plan)
+  vs suppressing entirely like empty write_stdin? Bundles suggest bare-name
+  is right — the call is user-meaningful even with no args.
diff --git a/docs/rendering/research-2026-07/plan-opencode-rows.md b/docs/rendering/research-2026-07/plan-opencode-rows.md
new file mode 100644
index 000000000..80212d99b
--- /dev/null
+++ b/docs/rendering/research-2026-07/plan-opencode-rows.md
@@ -0,0 +1,320 @@
+# OpenCode rendering gaps — plan from the three 2026-07-06 bundles
+
+Research pass for the rendering rewrite. Sources: the three manual debug bundles under
+`~/.config/agent-code/debug-bundles/manual/`, the opencode-headless dispatcher, the
+app-side opencode mapper, and the renderer row layer. All paths absolute; worktree-only
+files (`.worktrees/rendering-slice16/`) flagged.
+
+- `2026-07-06T15-50-54-393-e8e82431` — "Rendering is not really developed properly for opencode"
+- `2026-07-06T16-52-30-584-87f0eeef` — "We are getting somewhere, got a fuck ton of rendering to handle"
+- `2026-07-06T16-54-13-861-87f0eeef` — "another glitch when writing.... agent output seems to break stuff?"
+
+Status caveat: the bundles predate several fixes ALREADY in the pipeline (blockless
+text / reasoning glue, `'tool'`→`'tool_use'` kind, duplicate-user-row capability gate,
+child-session identity theft, the stuck-"Sending" phase bridge). Those are "verify with
+fixture" items, not work items. The remaining REAL gaps are almost entirely in the ROW
+layer: opencode's lowercase tool vocabulary and raw payload formats fall through to
+name-only headers and raw `
` dumps.
+
+---
+
+## 1. Gap inventory (with bundle evidence)
+
+### Bundle 1 — 15:50 (session e8e82431) — mostly historical
+
+Captured before the current mapper: `proxy-semantic.json` log has ONLY `turn_delta`
+×199 + `turn_completed` ×1 — no `turn_started`/`block_started`/`stream_phase`;
+`state-snapshot.json` has `totalEntries: 0` (nothing ever committed).
+
+- **G1 (FIXED — verify): reasoning glued into answer text.** One prose block, no
+  separator — note the `do!I can help` join:
+
+  ```
+  **Clarifying my abilities**
+  I need to provide a simple yet skill-invoking answer ... to ensure the user
+  understands what I can do!I can help with most software work in this repo…
+  ```
+
+  Cause (documented at `packages/opencode-headless/src/dispatcher/EventDispatcher.ts:29-39,712-720`):
+  opencode reasoning deltas arrive with `field:"text"`; routing now keys on the
+  registered part-kind map. Bundles 2/3 already show a separate collapsible
+  "∴ Thinking" row.
+
+- **G2 (FIXED — verify): stuck `Sending · 41s`.** `streamPhase: "submitting"` with
+  `processActive: false`, `sessionStatus: "idle"`. Opencode emits no `stream_phase`
+  events, and the machine only advanced on them. Fixed by the provider-agnostic
+  turn-lifecycle bridge in
+  `src/renderer/src/workspace/semantic/streamPhaseMachine.ts:100-152`
+  (`turn_started` → `responding`, `turn_completed` → `idle` when nothing pending).
+  Bundle 2 (`idle`) and bundle 3 (`responding` mid-turn) confirm the bridge working.
+
+### Bundle 2 — 16:52 — the "fuck ton of rendering" bundle (turn finished cleanly, `idle`, 9 entries)
+
+- **G3 (fix landed — verify): duplicate user row.** `entry:optimistic-codex-user:1783356732236`
+  AND committed `entry:msg_f385833b1001ooVUN3uabWeINj` both paint the identical
+  prompt. The capability gate fix
+  (`src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts:1448-1461`,
+  `usesOptimisticUserEcho`) landed after capture. **Residual risk stays open**: the
+  entries-plane reconciliation matches by EXACT text `===`
+  (`useIpcSubscriptions.ts:1499-1516,1569-1576` via `entryTextContent`,
+  `src/renderer/src/workspace/entries/utils.ts:16-27`); the NFKC/whitespace-tolerant
+  comparator (`streaming.ts:85-99`) is only used for queuedMessages. Any CRLF/unicode
+  divergence in opencode's echo re-introduces the double row.
+
+- **G4: tool_use rows are bare lowercase names, no argument summary.** `skill`,
+  `todowrite`, `read` render as just `⏺ skill` etc. The inputs are rich (semantic
+  `tool_input_finalized` events):
+
+  ```json
+  {"name":"skill",     "input":{"name":"dispatching-parallel-agents"}}
+  {"name":"todowrite", "input":{"todos":[{"content":"Map repository structure…","status":"in_progress","priority":"high"}, …]}}
+  {"name":"glob",      "input":{"pattern":"**/package.json","path":"/Users/juliusolsson/Desktop/Development/agent-code"}}
+  {"name":"read",      "input":{"filePath":"…/src/renderer/src/app-state/types.ts","offset":1,"limit":220}}
+  ```
+
+  Why: `renderOpencodeToolUse` handles only `todowrite`
+  (`src/providers/opencode/renderer/rows/dispatch.tsx:24-31`); everything else falls
+  to generic `ToolUseRow`, whose headline chain
+  (`src/renderer/src/features/feed/ui/rows/ToolUseRow.tsx:48-56`) probes
+  `command → file_path → path → notebook_path → pattern → query → url → description`
+  — opencode uses camelCase `filePath` (miss → bare `read` header) and `name` for
+  skill (miss).
+
+- **G5: glob headline shows the cwd instead of the pattern.** The three parallel
+  `glob` rows each show `⎿ /Users/juliusolsson/Desktop/Development/agent-code`. Not a
+  stray result: the headline chain hits `path` (opencode's cwd arg) BEFORE `pattern`.
+  Claude's `Glob` uses `path` only as optional scope, so the ordering never hurt
+  before.
+
+- **G6: tool results dump raw opencode payload text.** Results commit as separate
+  user entries `msg_…:result:call_…` (correct Claude-mirror shape) but content is
+  opencode's native text, and `renderOpencodeToolResult` returns `undefined`
+  (dispatch.tsx:39) → generic `ToolResultRow`, whose rich branches key on
+  capitalized `sourceTool` (`Read`/`Grep`/`Edit`/`Write`/`TodoWrite`,
+  `ToolResultRow.tsx:90-170`) → opencode always lands in `TruncatedOutputRow`
+  (line 177). Observed raw paints:
+
+  - `read` → `…\nfile\n\n1: import …` (line-numbered
+    `N: ` body in XML-ish tags) shown as escaped tag soup + "+100 lines".
+  - `todowrite` → the JSON array `[{"content":…,"status":…,"priority":…}]` printed as
+    JSON, even though the INPUT already rendered as a TodoRow checklist above it —
+    should be suppressed like Claude's TodoWrite results (ToolResultRow.tsx:90-98).
+  - `skill` → raw `…` dump.
+  - `glob`/`list` → raw newline path list (tolerable, but no "N files" summary).
+
+### Bundle 3 — 16:54 — the "agent output breaks stuff" bundle (mid-turn, parallel subagents)
+
+All of bundle 2's gaps, plus:
+
+- **G7 (FIXED — verify): child-session turn spasm + mid-word text split.** 20 semantic
+  history turns, 17 EMPTY (`text:''`, `blockOrder:[]`); 30 `turn_started`/`turn_completed`
+  pairs in the last 200 log events — `task` child-session events on the server-wide
+  `/event` SSE minted ghost turns and re-opened the parent turn. The visible symptom:
+  `hist[18].text = '/src/main/session'` and `currentTurn.text = 'Manager.ts\`…'` —
+  SAME turnId `msg_f3859cd7c…` — painting a paragraph starting `Manager.ts\``
+  (html-clean.html:605). Fixed by adopt-only session filter
+  (`EventDispatcher.ts:132-142,454-466`) + `ensureBlockOpen`/`closeOpenBlocksForTurn`
+  (`EventDispatcher.ts:610-611,1028-1032`). Already pinned by the worktree test
+  `fixtures.opencodeInterleave87f0eeef.test.ts` (render-exactly-once invariant).
+
+- **G8: task/subagent activity is invisible.** Model dispatched parallel agents; the
+  27 committed entries contain NO `task` tool_use row (visible_rows: only
+  skill/todowrite/glob/read + results). Two layers: (a) Block.tsx subagent
+  interception gates on `name === 'Agent' || 'spawn_agent'`
+  (`src/renderer/src/features/feed/ui/rows/Block.tsx:137-144`) — lowercase `task`
+  misses; (b) the parent assistant message carrying the task parts commits only on
+  `info.time.completed`, which for long-running subagents is the END of the whole
+  turn — so during a subagent fan-out the feed shows nothing at all. Needs a fresh
+  probe capture to confirm the `task` part shape (`input.description`, child
+  `sessionID` in metadata?) before wiring `TaskSubagentRow`.
+
+- **G9: scary plumbing banner for an expected condition.** Footer paints
+  `transcript unavailable: Ignored JSONL from provider session ses_0c7a71f60ffe…
+  because this pane is bound to ses_0c7a7ccabffe…`
+  (`useIpcSubscriptions.ts:1230`, decision from
+  `src/renderer/src/workspace/providerSessionIdentity.ts:110`). For opencode a
+  child-session burst is EXPECTED during task fan-out, not a transcript failure —
+  it should be a silent (or debug-only) drop when the observed session is a child of
+  the bound one.
+
+- **G10: git widget + Bash niceties miss lowercase `bash`.** Block.tsx:124-135 gates
+  `GitCardRow` on `name === 'Bash' || 'exec_command'`; opencode's `bash` never gets
+  git cards (no bundle evidence of a bash call in these three captures — flagged from
+  code reading; confirm with a fresh capture).
+
+- **G11: shared fold layer misses lowercase names.**
+  `src/renderer/src/workspace/semantic/helpers.ts:358` checks
+  `block.toolName === 'TodoWrite'` — opencode's `todowrite` silently skips any
+  Claude-name-gated fold behavior (todo/task panel extraction).
+
+Wire-shape reference (bundle 3 `tool_result` semantic events):
+
+```json
+{"type":"tool_result","toolUseId":"prt_f385a0849001PGfHh5eBzBD1uF","name":"read",
+ "content":"/…/types.ts\nfile\n\n1: import type { Settings } …"}
+{"type":"tool_result","toolUseId":"prt_f385a082a001NqTys2cQBGUKmC","name":"glob",
+ "content":"/…/global-editor/store.ts"}
+```
+
+Live plane keys tool blocks by `prt_…` part ids; committed entries use `call_…`
+callIDs — mapper prefers `part.callID` so resume/live uuids align
+(`src/providers/opencode/renderer/transcript/mapper.ts:117`).
+
+---
+
+## 2. Pipeline facts the design leans on (verified in source)
+
+- **Committed entries ARE Claude-shaped.** `mapOpencodeMessageToFeedEntries`
+  (`src/providers/opencode/renderer/transcript/mapper.ts:56`) emits one
+  `{type: role, message:{role, content:[…]}}` entry (uuid = opencode `msg_…` id) plus
+  one separate `user` tool_result entry per tool part (uuid
+  `${messageId}:result:${callId}`, mapper.ts:127-138,169-186). The Claude row stack
+  can therefore render opencode entries as-is; the deltas are only (a) tool NAME
+  casing, (b) input key schemas, (c) result payload text formats.
+- Ghost gating: assistant messages without `info.time.completed` are dropped from the
+  committed plane (mapper.ts:76-83); `committedAssistant` set prevents resurrection
+  (EventDispatcher.ts:518-521).
+- Kind normalization done: `'tool'`→`'tool_use'`
+  (`packages/opencode-headless/src/channels/types.ts:73-81`,
+  `EventDispatcher.ts:1090-1110`).
+- **Tool names pass through verbatim lowercase — no normalization anywhere**
+  (mapper.ts:119; EventDispatcher.ts:589,887-890).
+- **Tool result payloads pass through raw** (`toText`, mapper.ts:125,206-214; live:
+  EventDispatcher.ts:683,1132-1137).
+- Result↔use pairing is by `tool_use_id` via Feed-level side-channel maps
+  (`ToolUseIndexContext`/`ToolResultIndexContext`,
+  `src/renderer/src/features/feed/context.tsx:42-66`) — works across separate
+  committed entries; nothing to fix there.
+- Dispatch layering (`src/renderer/src/features/feed/ui/rows/Block.tsx:105-147`):
+  git-widget intercept → subagent intercept → provider `renderToolUse` → generic
+  `ToolUseRow`. Capabilities per provider in
+  `src/providers/registry.renderer.capabilities.ts` (opencode: lines 200-216,
+  "evidence-backed rows only" — todowrite is the only rich row today).
+- Codex precedent: `renderCodexToolUse` never falls through to the shared generic row
+  — it has its own catch-all `CodexToolRow`
+  (`src/providers/codex/renderer/rows/dispatch.tsx:12-21`). Opencode can follow
+  either pattern; recommendation below keeps the shared generic as final fallback.
+
+## 3. Design — mapping table
+
+**Decision: do NOT rename tools in the mapper.** Names are evidence (the capabilities
+comment codifies this), the fold layer and dispatch can alias, and renaming would
+desync committed transcripts from opencode's own session storage on resume. All
+mapping happens in `src/providers/opencode/renderer/rows/dispatch.tsx` (+ small,
+explicitly-aliased gates in the two shared intercepts). Reuse Claude rows wherever the
+committed shape already fits; write opencode-specific parsing only for opencode-native
+payload text.
+
+| opencode tool | input evidence | tool_use row | result row | work |
+|---|---|---|---|---|
+| `todowrite` | `{todos:[{content,status,priority}]}` | Claude `TodoRow` (DONE, dispatch.tsx:27) | **suppress** non-error (mirror ToolResultRow.tsx:90-98) | result suppression only |
+| `todoread` | none captured | generic | suppress or checklist | low priority; confirm shape |
+| `read` | `{filePath,offset,limit}` | generic + headline fix (`filePath`) | parse `//` doc, strip `N: ` prefixes → "Read N lines" collapsed CodeBlock (mirror ToolResultRow.tsx:112-145) | new `OpencodeReadResultRow` (or shared parser + reuse) |
+| `glob` | `{pattern,path}` | headline = `pattern` (G5) | "N files" summary + path list (TruncatedOutputRow fine underneath) | small |
+| `grep` | not captured — probe | generic (pattern headline) | CodeBlock like Claude Grep (ToolResultRow.tsx:150-170) | confirm shape first |
+| `list` | not captured — probe | generic (`path`) | tree text, truncated | low |
+| `bash` | not captured — probe (`{command,description?}`) | generic (command headline works) + alias into git-widget gate (G10) | TruncatedOutputRow (fine) | alias only |
+| `edit` / `patch` | **not captured — MUST probe** (likely `{filePath,oldString,newString}` / patch text) | adapt keys → Claude `EditRow` (diff) | suppress non-error | blocked on probe |
+| `write` | **not captured — MUST probe** (likely `{filePath,content}`) | adapt keys → Claude `WriteRow` | suppress non-error | blocked on probe |
+| `task` | **not captured mid-flight — MUST probe** | alias into subagent intercept → `TaskSubagentRow` (G8) | subagent report styling | blocked on probe + commit-latency question |
+| `skill` | `{name}` | headline = `Skill()` | collapsed markdown (raw dump acceptable interim) | small |
+| `webfetch` | not captured | generic (`url` headline works) | TruncatedOutputRow | none |
+| `question` | not captured | AskUserQuestion-style — probe | — | post-cutover |
+
+**Needs-new-row:** only the opencode `read` result parser/row (and possibly `task`
+report styling). Everything else is aliasing + headline fixes + reuse of Claude rows.
+
+**Upstream (packages/opencode-headless) changes: none required** for the row work.
+Optional/verify items: (a) confirm `task` tool parts surface on the parent message
+with child-session linkage metadata; (b) consider emitting a lightweight progress
+part for still-running assistant messages so long subagent fan-outs aren't invisible
+(interim mitigation: the live semantic plane already shows tool blocks — verify the
+semantic card renders during fan-out). (c) `stream_phase` emission is NOT needed —
+the phase bridge covers it.
+
+**Note on the probe:** the referenced
+`scratchpad/probe-opencode.mjs` does not exist (scratchpad has analyze/sweep/triage
+tooling only). A fresh wire capture for `edit`/`write`/`task`/`grep`/`question` input
+shapes is a prerequisite for those three table rows; `~/.config/agent-code/proxy/`
+dumps are the ground-truth recording channel.
+
+## 4. Tests / fixtures
+
+Corpus infra is worktree-only today
+(`.worktrees/rendering-slice16/src/renderer/src/rendering/__tests__/bundleCorpus.test.ts`;
+fixtures in `.worktrees/rendering-slice16/testing/fixtures/rendering-bundles/`, 46
+bundles, bless via `AGENT_CODE_CORPUS_BLESS=1`).
+
+Already present there:
+- `2026-07-06T16-54-13-861-87f0eeef.json` — triage `[]` (clean parity), plus the
+  dedicated `fixtures.opencodeInterleave87f0eeef.test.ts` pinning the interleave
+  render-exactly-once invariant (covers G7).
+- `2026-07-06T16-52-30-584-87f0eeef.json` — one triaged `extraction-gap` divergence.
+
+To add:
+1. Extract `2026-07-06T15-50-54-393-e8e82431` via `scripts/extract-rendering-fixtures.mjs`
+   into the corpus (covers G1/G2 history; expect triage entries for the pre-mapper
+   semantic-only shape — bless + verdict them, likely `legacy-bug`).
+2. Row-level unit tests (colocated with the opencode dispatch, NOT new test:* scripts
+   — per repo test policy, keep additions inside the rewrite's existing test files):
+   - dispatch: each lowercase name → expected row component; unknown name → generic.
+   - `read` result parser: tagged-doc parse, `N: ` strip, line-count summary; error
+     payloads (no ``) fall back to raw.
+   - `glob` headline prefers `pattern` over `path`.
+   - `todowrite` result suppression (input-side TodoRow already tested).
+   - optimistic-echo reconciliation: committed echo differing by CRLF/NBSP still
+     supersedes (G3 residual) — extend the existing reconciliation test.
+   - fold alias: `todowrite` triggers the TodoWrite fold path (helpers.ts:358).
+3. After the probe capture: fixture a turn containing `edit`/`write`/`task` and pin
+   EditRow/WriteRow/TaskSubagentRow selection.
+
+## 5. Code changes, file-by-file
+
+### Pre-cutover (safe on main now; all files shared by legacy Feed and the rewrite's row layer)
+
+1. `src/providers/opencode/renderer/rows/dispatch.tsx`
+   - Extend `renderOpencodeToolUse`: `skill` headline row, `glob` pattern-first
+     headline (opencode-specific `ToolUseRow` wrapper passing an explicit headline,
+     or a tiny `OpencodeToolRow`), keep default → `undefined` (shared generic stays
+     the final fallback — unlike codex's own catch-all, so future opencode tools
+     still render something sane).
+   - Implement `renderOpencodeToolResult`: `read` parser row, `todowrite`/`edit`/
+     `write` non-error suppression, `glob`/`list` count summary; default `undefined`.
+2. `src/renderer/src/features/feed/ui/rows/ToolUseRow.tsx` — add `filePath` to the
+   headline chain after `file_path` (harmless for other providers; camelCase is
+   opencode-only today). Alternative: keep the chain untouched and do it in the
+   opencode wrapper — decide by taste, but ONE place only.
+3. `src/renderer/src/features/feed/ui/rows/Block.tsx` — add `'task'` to the subagent
+   intercept (lines 137-144) and `'bash'` to the git-widget gate (lines 124-135),
+   with WHY comments naming opencode's lowercase vocabulary. (Task row itself may
+   need input-key adaptation — blocked on probe.)
+4. `src/renderer/src/workspace/semantic/helpers.ts:358` — accept `todowrite` alias
+   (case table, not `.toLowerCase()` blanket — keep evidence-based).
+5. `src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts:1499-1516,1569-1576` —
+   use the normalized comparator (`codexPromptsMatchForOwnership`-style) for the
+   entries-plane optimistic reconciliation, not exact `===` (G3 residual).
+6. `src/renderer/src/workspace/providerSessionIdentity.ts` (+ banner emit at
+   `useIpcSubscriptions.ts:1230`) — downgrade `conflicting-provider-session` to a
+   silent/debug drop for opencode child-session bursts instead of the persistent
+   "transcript unavailable" footer (G9). Gate on provider capability, not kind
+   string, per the plug-and-play direction (#394).
+
+### Post-cutover (rewrite worktree / after ledger lands)
+
+7. `.worktrees/rendering-slice16/testing/fixtures/rendering-bundles/` — add the
+   15:50 bundle fixture; re-bless the two existing opencode fixtures after the
+   dispatch changes (row keys unchanged — triage should stay empty/1; any new
+   divergence is a regression signal).
+8. `src/renderer/src/rendering/` ledger — no opencode-specific changes expected
+   (committed entries are Claude-shaped and the interleave fixture already passes);
+   just keep the corpus green through items 1-6.
+9. `task` subagent lifecycle rendering (needs probe + possibly
+   `packages/opencode-headless` part-progress emission): live "subagent running"
+   affordance during fan-out, `TaskSubagentRow` on commit. This is the only item with
+   potential upstream opencode-headless scope.
+10. `question` tool → AskUserQuestion-style row (probe first).
+
+### Explicitly NOT doing
+- No tool-name renaming in `mapper.ts`/`EventDispatcher.ts` (breaks resume identity,
+  hides evidence).
+- No new test:* scripts or standalone test files outside existing suites.
diff --git a/docs/rendering/research-2026-07/plan-queue-dispatch.md b/docs/rendering/research-2026-07/plan-queue-dispatch.md
new file mode 100644
index 000000000..546891270
--- /dev/null
+++ b/docs/rendering/research-2026-07/plan-queue-dispatch.md
@@ -0,0 +1,276 @@
+# The "prompt stuck in queue" bug — root cause, fix design, regression lock
+
+Research date: 2026-07-07. Scope: the RUNTIME half of the queue bug ("a queued message never
+becomes a user message"), not feed rendering. Evidence: three manual debug bundles + on-disk
+Codex rollout files + code in `src/renderer/src/workspace/`, `src/main/`, and the
+`packages/{codex,claude-code}-headless` submodules + a live Node repro of the kill mechanism.
+
+---
+
+## TL;DR
+
+The queue never "fails to dispatch" — **the prompt reaches the model every time** (PTY write
+succeeds; the proxy records a new turn ~2–3s after submit in all three bundles). What dies is
+the **committed rollout JSONL tail**: after a pane resume through `replaceSession`, the OLD
+session's `FileTailer.close()` calls Node's `unwatchFile(path)` **without a listener argument**,
+which removes **ALL** stat-watchers for that path in the process — including the one the NEW
+session just registered on the very same rollout file. From that moment the renderer never
+ingests another committed entry. Because the committed user row never arrives:
+
+1. the queued placeholder row is never reconciled away (`useIpcSubscriptions.ts:1523`),
+2. the prompt never renders as a real user message, and
+3. `shouldQueueOptimisticCodexUserEntry` returns `true` for **every subsequent submit** (its
+   "completed semantic history still renderable" branch stays true forever because committed
+   text never takes ownership) — so every follow-up prompt also lands in the queue chip.
+
+The spawn→kill ordering in `replaceSession` makes this **deterministic, not a race**: the new
+tailer's `watchFile` is always registered before the old session's `killSession` runs
+`unwatchFile`. That is why the bug is "haunting again and again" — every in-place
+resume/reload of a long-lived Codex pane reproduces it 100% of the time.
+
+---
+
+## 1. Evidence from the bundles
+
+All three bundles are **Codex** sessions (`manifest.json "kind": "codex"`), resumed panes
+(proxy run dirs `resume-019ef574-…` and `resume-019eeec5-…`).
+
+### Shared snapshot signature (state-snapshot.json)
+
+| field | 06-24 14:52 (75fb2add) | 06-24 19:13 (75fb2add) | 06-22 11:53 (3398cdb4) |
+|---|---|---|---|
+| `queuedMessages[0].timestamp` | `1782312716779` | `1782328380038` | `1782129190752` |
+| `submittedAt` | `1782312716779` | `1782328380037` | `1782129190751` |
+| `streamPhase` | `requesting` | `responding` | `thinking` |
+| `sessionStatus` / `processStatus` | `running` / `started` | `running` / `started` | `running` / `started` |
+| `inputReady` | `true` | `true` | `true` |
+| `awaitingAssistant` | `false` | `false` | `false` |
+| `lastJsonlEntryAt` | `1782307419496` (13:23:39Z — **88 min before submit**) | `1782326068452` (18:34:28Z — 39 min before) | `1782128769873` (11:46:09Z — 7 min before) |
+
+Key reading: queue timestamp == submittedAt +1ms → the submit path itself enqueued the
+placeholder (`addOptimisticCodexUserEntry` queue branch), the turn started normally
+(`streamPhase` active, `phaseChangedAt` = submit +3–4s), but **`lastJsonlEntryAt` is frozen far
+in the past** — the committed channel is dead.
+
+### feed-debug.jsonl, bundle 1 (the smoking gun)
+
+```
+14:49:25.807 STATE session_started
+14:49:25.963 JSONL jsonl_entries        | entries +97          ← resume bootstrap (tail-lines read)
+14:49:32.036 STATE optimistic_user_add  | "Good, now come the tricky part…"   ← 1st submit: normal row
+14:50:08     SEM   text_delta …          ← full turn streams + completes via PROXY semantics
+14:51:56.779 STATE submit
+14:51:56.779 STATE optimistic_user_queue| "That is great, so my question…"    ← 2nd submit: QUEUED
+14:51:59.434 SEM   turn_started src=proxy turn=resp_02d1a      ← the "stuck" prompt DID dispatch
+```
+
+**Zero `JSONL` layer events after the bootstrap at 14:49:25.963.** Not "entries +0" — the log
+site (`useIpcSubscriptions.ts:1728`) fires even for side-effect-only bursts, so absence proves
+no burst reached the renderer at all.
+
+### The rollout file on disk proves Codex wrote everything
+
+`~/.codex/sessions/2026/06/23/rollout-2026-06-23T19-08-38-019ef574-953a-7990-a85e-36219ee37cad.jsonl`
+(the exact file for the resumed thread `019ef574`, i.e. the file the resume lookup finds):
+
+```
+line 3584  2026-06-24T13:23:39.496Z  user row  "Good, now come th…"   ← == lastJsonlEntryAt in bundle 1
+line 3588  2026-06-24T14:49:32.095Z  user row  "Good, now come th…"   ← 1st submit, 7s after attach — NEVER ingested
+line 3609  2026-06-24T14:51:56.791Z  user row  "That is great, so…"   ← the "stuck" prompt, 12ms after submit — NEVER ingested
+line 5400  2026-06-24T19:13:00.651Z  user row  "foix what you thi…"   ← bundle-2 stuck prompt — NEVER ingested
+```
+
+Bundle 3's stuck prompt is likewise present in
+`22/rollout-2026-06-22T11-59-44-019eeec5-8c1f-….jsonl`. So: right file, rows written within
+milliseconds, tailer attached to that very path (bootstrap proves it) — and the incremental
+watcher never fired again. Poll interval is 100ms; 7 seconds of silence on the first post-attach
+append means the watcher was **dead from birth** of the new pane.
+
+Bundle 3 nuance: its `lastJsonlEntryAt` (11:46:09) is *after* its 10:23:59 resume — that pane
+was healthy at first (spawned via a kill-first path, e.g. app-boot rehydrate) and died at a
+later in-place reload ~11:47–11:52 whose bootstrap newest entry was 11:46:09. Same terminal
+state, same mechanism.
+
+---
+
+## 2. The kill mechanism (verified by repro)
+
+`packages/codex-headless/src/transcript/JsonlTailer.ts:237-241` (identical in
+`packages/claude-code-headless/src/transcript/JsonlTailer.ts:254-258`):
+
+```ts
+async close(): Promise {
+  this.closed = true
+  unwatchFile(this.filePath)      // ← NO listener argument
+}
+```
+
+Node semantics: `fs.unwatchFile(filename)` without a listener **removes all listeners** for the
+path and stops the shared per-path StatWatcher. Verified with a standalone repro
+(scratchpad/unwatch-repro.mjs): two `watchFile` registrations on one path; after
+`unwatchFile(path)` from "tailer A", tailer B never fires again:
+
+```
+before close A: { aFired: 1, bFired: 1 }
+after close A + append: { aFired: 1, bFired: 1 }   ← B is dead
+```
+
+### Why two tailers share a path: replaceSession's spawn-before-kill
+
+`src/renderer/src/workspace/hook/actions/session.ts` (replaceSession — per its own comment,
+"Reload, provider switch, resume, and rewind all funnel through this path"):
+
+```ts
+const newId = await spawn(cwd, { ...spawnOpts })   // :628  NEW session up first (registers watchFile
+                                                   //        on rollout-….jsonl during
+                                                   //        CodexHeadless.start → tailFile)
+...
+await window.api.killSession(oldId)                // :640  THEN old session dies:
+                                                   //        stop → cleanup → stopRolloutTail →
+                                                   //        FileTailer.close → unwatchFile(path)
+                                                   //        → kills the NEW watcher too
+```
+
+Old and new sessions tail the SAME file whenever the pane is resumed onto the same Codex thread
+(the overwhelmingly common "reload this conversation" case). `spawn()` resolves only after
+`CodexHeadless.start()` has registered `watchFile`, so `unwatchFile` **always** lands after it.
+Deterministic kill, not a race.
+
+Bootstrap still "works" because `bootstrapTail()` is a synchronous read in the FileTailer
+constructor — hence the misleading "+97 entries then silence" signature.
+
+Note: `reloadAgentSessions` (session.ts:787→804) kills first, then spawns — safe ordering. Only
+the replaceSession family is affected, which matches the bundles (resume picker / in-place
+reload panes).
+
+### Downstream: why a dead tail turns into "stuck in queue"
+
+- `addOptimisticCodexUserEntry` (`hook/actions/streaming.ts:172-226`): a submit goes to
+  `queuedMessages` instead of an optimistic feed row when
+  `shouldQueueOptimisticCodexUserEntry` is true. Its second branch (streaming.ts:73-82, the
+  #239 fix) returns true while any completed semantic turn is still "renderable", where
+  renderability is defeated only by committed assistant text
+  (`buildCommittedAssistantText(current.entries)`). Dead tail ⇒ committed entries never grow ⇒
+  branch stays true forever ⇒ **every** submit after the first completed turn is queued.
+- Queue exit paths:
+  1. committed user row match (`useIpcSubscriptions.ts:1523-1539`) — requires the dead
+     committed channel; never fires.
+  2. `shouldClearIdleQueuedMessages` (queueInvariants.ts, edge sites at
+     useIpcSubscriptions.ts:788/1014 + idle-reconcile backstop :406) — requires
+     `streamPhase === 'idle' && !processActive`; false while the turn runs, and when it
+     eventually fires it **deletes** the row. The prompt vanishes without ever becoming a user
+     message — exactly the note: "propmt stuck in queue and does not make it to a user message."
+- Meanwhile the turn itself runs fine via PTY + proxy semantics, so the assistant *answers* a
+  prompt the feed never shows — maximally confusing.
+
+Claude panes share the same tailer bug (same class, same `unwatchFile`); there the dead tail
+freezes the whole committed transcript (user rows, queue-operation enqueue/dequeue records,
+tool results), which is plausibly the Claude-flavored sightings of the same haunting.
+
+---
+
+## 3. Fix design
+
+### 3a. The actual fix — scoped unwatch (both headless submodules)
+
+`packages/codex-headless/src/transcript/JsonlTailer.ts` and
+`packages/claude-code-headless/src/transcript/JsonlTailer.ts` (separate repos:
+Juliusolsson05/codex-headless, Juliusolsson05/claude-code-headless; one PR each + submodule
+pointer bumps in agent-code):
+
+```ts
+// store the listener so close() can unsubscribe ONLY itself
+private readonly statListener = (curr: Stats, prev: Stats): void => { …existing body… }
+
+constructor(…) {
+  …
+  watchFile(filePath, { interval: POLL_INTERVAL_MS, persistent: true }, this.statListener)
+}
+
+async close(): Promise {
+  this.closed = true
+  unwatchFile(this.filePath, this.statListener)   // scoped: sibling tailers survive
+}
+```
+
+Thick WHY comment to add at the close site: `unwatchFile(path)` without a listener removes ALL
+watchers for the path process-wide; replaceSession spawns the successor pane before killing the
+predecessor, so both tail the same rollout/JSONL during the handoff and an unscoped unwatch
+deterministically kills the successor's committed channel (the 2026-06-24 stuck-queue bundles).
+
+While in there (same PR, blast radius): `stream.on('error')` in `readNew()` resets `reading`
+but never drains `pendingRead`; drain it like the `end` handler does. Harmless today (next poll
+re-triggers) but it is the only other way the tailer can go quiet.
+
+### 3b. Self-healing + observability — tail-stall watchdog (both submodules)
+
+The unwatch fix removes the known killer, but the class of failure ("file grows, tailer
+silent") deserves a backstop that makes the next occurrence self-diagnosing AND self-repairing.
+In FileTailer: a low-frequency interval (e.g. 15s) that stats the file; if
+`size > offset` persists across two consecutive ticks with no emit in between, (1) emit a
+diagnostic through `onError`-adjacent channel — for codex-headless surface it as the existing
+`rollout-diagnostic` event (`type: 'tail-stalled'`, with `{ file, offset, size, lastEmitAt }`),
+(2) re-arm: `unwatchFile(path, listener)` + `watchFile(...)` + `readNew()`. This converts any
+future watcher death into a ≤30s hiccup with a bundle-visible breadcrumb instead of a permanent
+silent outage.
+
+### 3c. Queue-decision log — make the next bundle one-glance diagnosable (agent-code)
+
+- `src/renderer/src/workspace/hook/actions/streaming.ts` — the `optimistic_user_queue`
+  feed-debug entry already logs `streamPhase` and `activeSemanticTurn`; add the two fields that
+  would have named this bug instantly:
+  - `committedTailAgeMs: now - (current.lastJsonlEntryAt ?? 0)` — a large value at queue time
+    (88 minutes in bundle 1) is the smoking gun for a dead committed channel;
+  - `queueReason: 'live-semantic-turn' | 'completed-history-renderable'` — split
+    `shouldQueueOptimisticCodexUserEntry` so the caller knows WHICH branch queued (return a
+    reason, not a boolean). The completed-history branch firing on a visibly idle pane =
+    committed starvation.
+- `src/main/sessionManager.ts` + `src/renderer/src/features/debug/saveDebugBundle.ts` — expose
+  tail health in bundles: a `getTranscriptTailDiagnostics(sessionId)` IPC returning
+  `{ file, tailOffset, fileSize, lastEmitAt, tailedPaths }` from the headless session (needs a
+  tiny getter on CodexHeadless/ClaudeHeadless surfacing FileTailer state), included in
+  `state-snapshot.json`. `fileSize > tailOffset` in a snapshot = dead tail, no rollout grep
+  needed next time.
+
+### 3d. Optional renderer resilience (separate follow-up, NOT required once 3a lands)
+
+If we want the feed to degrade gracefully under any future committed-channel outage: when the
+proxy semantic channel reports `turn_started` while `queuedMessages` is non-empty and no other
+turn was live at submit, promote the head queued row to an optimistic user Entry (the
+`optimistic_user_add` path) instead of leaving it in the queue lane. This keeps the user's
+prompt visible even with a dead tail. Deliberately out of scope for the fix PR — it re-opens the
+#239 ordering questions and 3a+3b already remove the trigger.
+
+### What NOT to change
+
+- `replaceSession`'s spawn-before-kill ordering is intentional (flicker-free pane swap,
+  draft/runtime handoff). With a scoped unwatch the overlap is exactly the harmless
+  double-tail-then-single-close it was assumed to be. Don't reorder it.
+- `shouldQueueOptimisticCodexUserEntry`'s completed-history branch is correct given its inputs;
+  the input (committed entries) was starved. Don't weaken the #239 fix.
+
+## 4. Regression lock
+
+Per repo policy, no new test files in the agent-code fix PR; the real tests live in the
+submodule repos, which own the bug:
+
+- **codex-headless + claude-code-headless** (their own suites): unit test on FileTailer /
+  `tailSessionFile` — create a temp jsonl, open tailer A then tailer B on the SAME path, append
+  (both emit), `await A.close()`, append again, assert **B still emits** (fails on the current
+  code, passes with the scoped unwatch; my unwatch-repro.mjs is the skeleton). Second case:
+  close B, assert A unaffected. If the 3b watchdog lands, one test: stub a dead watcher (call
+  `unwatchFile(path)` externally), append, advance timers, assert re-arm + diagnostic emission.
+- **agent-code**: the existing rendering/fixture harness already locks the composer-lane
+  rendering half. For the runtime half the queue-reason split in 3c is pure-function refactor
+  covered by the existing `queueInvariants` / streaming test surface if touched; verification
+  gate is the usual raw `tsc` on node+web projects, plus an end-to-end manual: resume a codex
+  pane in-place, submit twice mid-/post-turn, confirm the second prompt commits as a user row
+  and the queue chip clears (this exact flow was 100%-reproducible before the fix, so it doubles
+  as the verify step).
+
+## 5. Rollout order
+
+1. PR to codex-headless (3a + 3b + tailer unit test), PR to claude-code-headless (same).
+2. agent-code PR: submodule pointer bumps + 3c observability (queue-reason + committedTailAgeMs
+   + tail diagnostics in bundles).
+3. Optional later: 3d promotion-on-turn-start.
diff --git a/docs/rendering/research-2026-07/plan-subagent-task-rows.md b/docs/rendering/research-2026-07/plan-subagent-task-rows.md
new file mode 100644
index 000000000..9a17d7d76
--- /dev/null
+++ b/docs/rendering/research-2026-07/plan-subagent-task-rows.md
@@ -0,0 +1,304 @@
+# Subagent / Task-tool / task-notification row UI — implementation plan
+
+Research for the rendering rewrite (7-bundle complaint cluster). Sources: debug bundles at
+`~/.config/agent-code/debug-bundles/manual/`, git/PR archaeology, HEAD code, the rendering
+pipeline on `integration/rendering-pipeline` (PR #442, slices 1–20, worktree
+`.worktrees/rendering-slice16`), and `docs/rendering/rendering-rewrite-plan-2026-07.md`.
+
+---
+
+## 1. Complaint cluster → root causes
+
+| Bundle | Note | Root cause (verified) |
+|---|---|---|
+| 2026-06-18 11:12 (21c515e9) | "Not handling codex subagents at all" | Pre-#292: `subAgents` snapshot literally `{}`; `spawn_agent` painted as a generic tool row with raw `{"agent_id":"019eda6c-…","nickname":"Cicero"}` result `
`. Wire shape (proxy-semantic): `function_call` blocks with `call_…`/`fc_…` ids, fan-in via separate `wait_agent {targets:[agent-uuid…], timeout_ms}`; **`agent_id` UUIDs are disjoint from the call ids and codex has NO task-notification** — completion truth is the `spawn_agent`/`wait_agent` result JSON. Fixed same day by PR #292 (91af76d) for the committed plane; live plane + lifecycle still open. |
+| 2026-06-21 20:14 (62432945) | "Sub agents UI is fucking horrible? did we not do a PR?" | Three stacked defects, none a wiring regression of PR #277: (1) the session spawned via **MCP orchestration tools** (`mcp__agent_code__orchestration_create_agent` / `_wait_agents`), which never match Block.tsx's `'Agent' \|\| 'spawn_agent'` dispatch → **zero cards painted** even though `state-snapshot.subAgents` had 73 entries keyed by `toolu_…`; (2) twelve `` completions (incl. full agent reports whose markdown parsed into `

`s) painted verbatim inside `bg-user-bg` user bubbles; (3) state unhealthy — 57/73 stuck `running`, `turnCount` up to 3036, `droppedToolCalls` 1742, repeated toolCalls (watcher re-reads + double-counts). Filed as #341 (OPEN). | +| 2026-06-22 09:05 (7733b0fc) | "UI for the tasks.." | Same stale state (~80 subagents, 77 "running") PLUS the paint pathology: **142 empty `min-h-[48px]` LazyEntry spacer divs** — the task activity rendered as a wall of blank placeholder rows with tiny expand toggles. | +| 2026-06-29 11:38 (42071335) | "task notifications … rendered like a retard" | Two surfaces. Committed: `` arrives as `type:"user"` entry with **no `isMeta`**; legacy renderModel only filters `isMeta===true` → raw-XML user bubble. Queue: this bundle shows the notification parked as a raw-XML **queued message** ("1 queued" in `QueueStrip`, `aria-label="queued messages"`) as if the user typed it. Also proves scope > subagents: this one was a **background Bash** completion (`tool-use-id` → a Bash tool_use). Zero handling anywhere in `src/` (no hit for `task-notification` outside `vendor/`). | +| 2026-06-29 14:13 (1b2b5e96) | "agent output is buried under task notifications" | Clearest burial: an entire subagent code-review report (`` with thousands of chars, `` 27.5k tokens/13 tools/98s) crammed into ONE raw queued-message `
  • `. Ordering half fixed since; the ROW half (what notification/queue rows should look like) is this plan. | +| 2026-06-17 11:50 (a6c70f19), 2026-06-21 18:16 (fc397785) | AskUserQuestion "not handling at all" | Forensics: a6c70f19 painted the raw input JSON as a `
    ` under a generic "AskUserQuestion / running" tool row + footer "Awaiting AskUserQuestion · 3m43s"; fc397785 was worse — bare tool NAME with no input at all, session parked in `streamPhase:"awaiting-tool"` with conditions/picker state all null. In both, the proxy had already fully parsed `questions[].{header,multiSelect,options[].{label,description}}` with the toolUseId — the renderer just had no unit for it. Both bundles predate the fix chain (#289: 37ba058+d1c17b8 on 06-21, #329/#330 conditions driver on 06-22). At HEAD the semantic-plane `AskUserQuestionRow` is a real answerable picker. Remaining gaps: committed-plane rendering (raw JSON after answer) and survival through pipeline cutover (plan §8 explicitly defers #98/#289 to "row-layer feature after cutover"). |
    +
    +## 2. Archaeology — what the prior PR did and why it "stopped working"
    +
    +**PR #277** (2026-06-14, 468f984/e46385c/2a4380b): main-process `SubAgentWatcher` polls
    +`/subagents/*.jsonl` (600ms), folds into `SubAgentState` (status, tool-call ring,
    +`startedAt/lastActivityAt`, `currentActivity`), pushes `session:sub-agents` IPC keyed **by parent
    +`Agent` tool_use id** (from `agent-.meta.json` → `meta.toolUseId`). Renderer:
    +`SubAgentsContext` → `TaskSubagentRow` (Block.tsx routes `name==='Agent'`), `SubagentGroupHeader`
    +("Spawned N agents · ◐R · ✓D") when ≥2 adjacent Agent blocks, `SubagentMiniFeed` drill-in.
    +**PR #292** (2026-06-18, issue #291) extended the same rows to codex `spawn_agent` +
    +`codexSubagentState.ts`, and filtered `` synthetic user rows in
    +`src/providers/codex/renderer/transcript/rollout.ts`.
    +
    +**It did not regress in wiring.** All components are wired at HEAD (`Block.tsx:143`,
    +`ConversationRow.tsx:107`); `git log --follow` shows no deletions; the #288 memory series
    +(4df7a77…14c19b6, PR #322 "derive-and-drop") preserved the IPC contract explicitly
    +("SubAgentState/SubAgentToolCall IPC contract is unchanged"). What the user saw on 06-21 was
    +the feature's blind spots, not a rollback:
    +
    +1. **Dispatch-name blind spot**: cards render only for tool_use `name ∈ {Agent, spawn_agent}`.
    +   The 06-21 session spawned via the built-in MCP orchestration server
    +   (`mcp__agent_code__orchestration_create_agent` / `_list_agents` / `_wait_agents`,
    +   `src/mcp/runtime/createBuiltInMcpServer.ts`) → zero cards despite 73 tracked subAgents.
    +2. **task-notification blind spot**: nothing anywhere consumes `` user
    +   entries, so every completion (including the child's full result report) leaked as raw XML —
    +   as user bubbles once committed and as raw queued lines in `QueueStrip` before delivery.
    +3. **Stale state**: `codexSubagentState.ts` flips a child to terminal ONLY on
    +   `` completed/failed or child `event_msg task_complete` — no inference
    +   from `wait_agent` completion, child session exit, or inactivity → codex children run forever.
    +   `SubAgentWatcher.metaByAgent` is never pruned except on `stop()`; `emit()` iterates every
    +   meta ever seen → resumed/long-lived sessions accumulate dozens of forever-running cards.
    +   Snapshot health also shows double-counting (`turnCount` 3036, repeated `toolCalls`,
    +   `droppedToolCalls` 1742) from re-reading growing transcripts.
    +
    +Issue map: **#178** (umbrella, OPEN), **#341** (stale-running, OPEN — the direct write-up of the
    +06-21 bundle), **#291** (CLOSED via #292), **#289** (AskUserQuestion, OPEN but the answerable row
    +shipped; needs closing criteria re-check post-cutover), **#340** (split from #178).
    +
    +## 3. Where the new pipeline stands (and why this plan fits it)
    +
    +- Integration branch PR #442 (Stages 1–2), slices 1–20 merged into it; Stage 3 cutover flag
    +  `AGENT_CODE_RENDER_PIPELINE=1` already exists (slice 17); shadow mode `AGENT_CODE_RENDER_SHADOW`
    +  (slice 12); corpus extractor covers the debug-bundle corpus incl. codex rollouts (slice 20).
    +- `RenderCandidate.toolUseId` is reserved first-class (plan §7 rule 14) exactly for this feature
    +  (`src/renderer/src/rendering/model/types.ts:96-102` on the worktree).
    +- The **committed collector is entry-level** (`observations/committed.ts`) and the view bridge
    +  (`view/ledgerFeedItems.ts`) re-emits legacy `FeedRenderItem`s → `ConversationRow`/`Block.tsx`
    +  row components **survive cutover unchanged**. Therefore: row-level Task/notification UI built in
    +  the legacy row layer is NOT throwaway; it is the post-cutover UI too.
    +- **Trap found:** the new committed collector's `isSyntheticClaudeUserRow` (claude + role user +
    +  no `permissionMode` + text starts with `<`) already swallows `` user entries
    +  as `synthetic-user-filtered`. Post-cutover the ugly bubble disappears — but so does the ONLY
    +  carrier of a background task's final result unless we join it into the parent row first.
    +  This must become its own fixture-gated reason, not an accident of the `<` predicate.
    +
    +### Verified data shapes (join keys)
    +
    +Committed Claude task-notification entry (real transcript, verified):
    +
    +```json
    +{"type":"user","message":{"role":"user","content":"\nbs07b21gl\ntoolu_01WBGTmi74x2pgobrxhMBES9\n…\ncompleted\nBackground command \"…\" completed (exit code 0)\n"}}
    +```
    +
    +No `isMeta`, no `permissionMode`. Vendor source (`vendor/claude-code-src/full/tasks/LocalAgentTask/LocalAgentTask.tsx:246-260`)
    +shows the full grammar: `task-id`, optional `tool-use-id`, `output-file`, `status`
    +(completed|failed|stopped), `summary`, optional `result`, `usage` (total_tokens/tool_uses/
    +duration_ms), optional `worktree`. `tool-use-id` points at the spawning `tool_use` — which can be
    +an `Agent` tool_use OR a background `Bash` (so the join generalizes beyond subagents).
    +Transcripts also contain `type:"queue-operation"` and `type:"attachment"` task-notification
    +entries — already dropped as `not-conversation`; only the `type:"user"` ones need work.
    +
    +`SubAgentState` (`src/shared/sessionFeed/types.ts:115`) is already keyed by `toolUseId` — the
    +watcher IPC path stays the source of live child telemetry; task-notifications are the source of
    +final results; parent `tool_result` is the source for foreground (synchronous) completion.
    +
    +## 4. Target design
    +
    +### 4.1 Parent Task row (`TaskSubagentRow` v2) — one card, three evidence sources
    +
    +**Card eligibility generalizes** (fixes the 06-21 zero-cards bug): route to the Task card when
    +tool_use `name ∈ {Agent, Task, spawn_agent, mcp__agent_code__orchestration_create_agent}` **or**
    +`subAgents[block.id]` exists (the state join is stronger evidence than the name). Keep the name
    +set in one shared predicate (`isAgentSpawnToolName`) used by Block.tsx, ConversationRow's
    +grouping, and the semantic-plane router.
    +
    +Status resolution order (strongest first), all joined by `toolUseId`:
    +
    +1. **task-notification** for this toolUseId → terminal truth: status glyph, summary, expandable
    +   `result` text, usage chips (tokens · tool uses · duration). Claude background agents AND
    +   background Bash commands (bundle 42071335 proves the same grammar covers both).
    +2. **parent `tool_result`** for this toolUseId (already indexed in Feed context) → done/error for
    +   foreground agents; codex `wait_agent` output joins here too (codex has NO task-notification —
    +   `wait_agent`'s `function_call_output` naming the `agent_id` is its completion truth).
    +3. **`SubAgentState`** from the watcher → live: `currentActivity`, tool count, elapsed.
    +4. Nothing yet → "starting…".
    +
    +New visual state **stale**: if the strongest evidence is still `running` but
    +`lastActivityAt` is older than a threshold (60s of silence with no live watcher pulse), render
    +"◐ no activity for 4m" in muted style — never a healthy spinner. This is the honest-UI half of
    +#341; the data half is fixed in the main process (4.4).
    +
    +Collapsed by default; expanding shows `SubagentMiniFeed` (live) and/or the notification `result`
    +markdown (terminal). `SubagentGroupHeader` keeps its tally line and gains `⏸ N stale`.
    +
    +### 4.2 task-notification rows — collapse, join, never a user bubble
    +
    +- Parse to `TaskNotification { taskId, toolUseId?, outputFile, status, summary, result?, usage?, worktree? }`
    +  in a shared module (used by both renderers and the ledger collector).
    +- **If the parent `tool_use` is present in the feed** (normal case): the notification entry emits
    +  NO standalone row — its content is delivered to the parent Task row via a
    +  `Map` built where `toolResultIndex` is built today
    +  (`Feed.tsx` context). The parent card badge flips (✓/✗) at the notification's position-in-time
    +  is irrelevant — status is state, not a row.
    +- **If no parent is visible** (no `tool-use-id` tag, parent scrolled out of the loaded window,
    +  cross-session notification): render a compact one-line `TaskNotificationRow`:
    +  `✓ Agent "fix flaky test" completed · 54k tok · 2m19s ▸` — expandable to summary + result
    +  markdown + output-file path. Marker-gutter style (MarkerRow), assistant-side, muted. Never a
    +  prompt bubble, never raw XML.
    +- Codex `` keeps its existing rollout-level filter; its payload already
    +  feeds `codexSubagentState`. No standalone codex notification rows either.
    +- **Queue lane twin** (bundles 42071335 + 1b2b5e96): before delivery, the notification sits in
    +  `QueueStrip.tsx` (`aria-label="queued messages"`) as a raw XML line — the burial surface.
    +  QueueStrip runs the same `parseTaskNotification`: matching items render as a one-line chip
    +  `✓ Agent "Review AppRunJournal engine" finished — delivering to agent…` (never the XML, never
    +  the embedded result). No queue-model change — display-only mapping, consistent with plan D1
    +  (queue is not feed rows).
    +- **No spacer walls** (bundle 7733b0fc): suppressed/joined notification entries must be excluded
    +  BEFORE LazyEntry virtualization so they never emit `min-h-[48px]` placeholder divs — i.e.
    +  filtered in renderModel's visibleDecision, not hidden inside the row component.
    +
    +### 4.3 AskUserQuestion — lifecycle across planes
    +
    +Live (semantic) plane is done and stays: unresolved block → answerable `AskUserQuestionRow`
    +(single/multi-select, free-text, multi-question; conditions custom-action drives the real TUI;
    +screen condition gates clickability only; row unmounts when `resultAt` lands). Remaining work:
    +
    +- **Committed plane**: an answered AskUserQuestion currently renders as generic `ToolUseRow`
    +  raw-JSON. Add `AskUserQuestionAnsweredRow` (committed): join `tool_use` input questions with the
    +  `tool_result` chosen options via the existing `toolResultIndex` → compact
    +  `? "How should X work?" → "Option B"` row, expandable for all questions/options.
    +- **Cutover guarantee**: ledger must never fold an unresolved AskUserQuestion into
    +  `collapsed_activity`/`collapsed-running` (the legacy guard in `renderUnits.ts:326-339` must have
    +  a ledger twin + fixture). Its candidate is `semantic-current`-owned until the committed
    +  tool_result exists; ownership handoff must not unmount the picker while unanswered (fixture from
    +  bundle fc397785).
    +- Post-D6 (SemanticStreamingTurn deletion): live blocks route through the provider registry —
    +  register AskUserQuestion there rather than the current `BlockRow.tsx:384` hardcode.
    +
    +### 4.4 Lifecycle fix (#341) — main process, not rendering
    +
    +`codexSubagentState.ts` terminal inference additions: (a) parent `wait_agent`
    +`function_call_output` naming the agent → done/error; (b) child rollout `task_complete`/session
    +end; (c) inactivity threshold → `stale` (distinct from done — we don't fabricate completion).
    +`SubAgentWatcher`: prune `metaByAgent`/state for agents terminal for >N minutes once painted-and-
    +committed; cap emitted map; `stop()` unchanged; fix the accumulation double-count (bundle
    +62432945: `turnCount` 3036 / repeated `toolCalls` — re-reads of a growing transcript are being
    +re-folded instead of resumed from the last offset). `SubAgentState.status` gains `'stale'`
    +(shared type + both builders + row glyphs). This is orthogonal to the rewrite and lands first.
    +
    +### 4.5 Live-plane routing (codex + claude fan-out while streaming)
    +
    +Semantic `BlockRow` has no Agent/spawn_agent routing → during a live fan-out turn the Task rows
    +are generic tool rows until the JSONL commits. Add routing: semantic block with
    +`isAgentSpawnToolName(block.toolName)` (same shared predicate as 4.1, so MCP orchestration
    +spawns get live cards too) → a thin adapter around the same Task card (block.toolUseId is the
    +join key; SubAgentsContext already has the data). Registered per-provider so cutover's
    +"live rows use the same provider registry dispatch" rule inherits it.
    +
    +## 5. Ledger changes (new pipeline)
    +
    +Small and additive — the plan's §8 deferral ("subagent surface: reserved key, own feature")
    +stays true; we only make the committed collector honest:
    +
    +- `model/types.ts`: add reason `'task-notification-joined'` (+ keep `synthetic-user-filtered`
    +  for genuine scaffolding). Optionally `contentKind: 'task-notification'` if we ever emit
    +  standalone notification candidates; not needed for v1 (entry stays suppressed, join happens in
    +  the row layer from `entriesByUuid`).
    +- `observations/committed.ts`: detect `` BEFORE `isSyntheticClaudeUserRow`;
    +  emit decision `{selected:false, reason:'task-notification-joined', evidence:[taskId, toolUseId]}`
    +  and stamp `toolUseId` on the (suppressed) candidate so bundles can answer "where did my
    +  notification go" (#344 discipline).
    +- Rule 15: the new reason merges with fixtures extracted from bundles 42071335 + 1b2b5e96.
    +- View bridge: unchanged (entry-level suppression means it never sees these rows).
    +
    +## 6. Tests & fixtures
    +
    +Per plan rule 15 and the rendering-slice convention (tests live in
    +`src/renderer/src/rendering/__tests__/` + `testing/fixtures/rendering/`; no new `test:*` scripts):
    +
    +1. **Fixtures from the bundles** via `scripts/extract-rendering-fixtures.mjs`:
    +   - `taskNotification-62432945` (committed user-entry notifications, incl. full-result reports)
    +     → asserts: suppressed with `task-notification-joined`, join map contains toolUseId, no
    +     user-text row painted, no LazyEntry spacer emitted.
    +   - `taskNotification-42071335` / `-1b2b5e96` (queue-lane notifications; background-Bash and
    +     subagent-report variants) → asserts QueueStrip chip mapping + parser handles missing
    +     `result`/`usage` and the `subagent_tokens` vs `total_tokens` drift.
    +   - `staleSubagents-62432945` (73/57 snapshot) → asserts group tally renders stale, not running
    +     (row-layer test, uses the state snapshot as SubAgentsContext input).
    +   - `codexSpawn-21c515e9` → spawn_agent + subagent_notification: notification never a user row;
    +     spawn joins child state.
    +   - `askUserQuestion-fc397785` → unresolved picker owned by semantic-current; never
    +     collapsed-running; unmount only on committed tool_result.
    +2. **Unit tests**:
    +   - `parseTaskNotification` grammar (all optional tags, malformed → null, quoting attack:
    +     real user prompt QUOTING the XML is not swallowed — require full-content match like the
    +     codex filter does).
    +   - toolUseId parent/child join: candidates for parent Task tool_use + suppressed notification →
    +     joined row model (pure function, no DOM).
    +   - codex terminal inference: wait_agent output / task_complete / inactivity → status
    +     transitions incl. `stale` (extends existing `SubAgentWatcher.test.ts` — an existing file).
    +   - Answered-question join: tool_use+tool_result → compact row model.
    +
    +## 7. Code changes, file-by-file
    +
    +### Pre-cutover (land now on main; legacy renderer; also survives cutover via the view bridge)
    +
    +| # | File | Change | ~LOC |
    +|---|---|---|---|
    +| P1 | `src/main/subagents/codexSubagentState.ts` | wait_agent/child-exit/inactivity terminal inference | +80 |
    +| P1 | `src/main/subagents/SubAgentWatcher.ts` | prune terminal metas, stale sweep | +40 |
    +| P1 | `src/shared/sessionFeed/types.ts` | `status: … \| 'stale'` | +2 |
    +| P2 | `src/shared/taskNotification.ts` (new) | parser + type + `isAgentSpawnToolName` predicate (shared: renderer, main, ledger) | 80 |
    +| P2 | `src/renderer/src/features/feed/model/renderModel.ts` | visibleDecision: task-notification user entries → hidden, reason `task_notification` (pre-LazyEntry, so no spacer divs) | +25 |
    +| P2 | `src/renderer/src/features/feed/ui/Feed.tsx` (+context) | build `taskNotificationsByToolUseId` beside `toolResultIndex`; provide via context | +35 |
    +| P2 | `src/renderer/src/features/feed/ui/rows/TaskSubagentRow.tsx` | v2: evidence-priority status, stale state, notification result/usage in expansion | +60 |
    +| P2 | `src/renderer/src/features/feed/ui/rows/TaskNotificationRow.tsx` (new) | orphan-notification compact row | 70 |
    +| P2 | `src/renderer/src/features/feed/ui/rows/SubagentGroupHeader.tsx` | stale tally | +8 |
    +| P2 | `src/renderer/src/features/feed/ui/rows/Block.tsx` + `ConversationRow.tsx` | swap hardcoded name checks for `isAgentSpawnToolName` (adds Task + MCP orchestration names) | +10 |
    +| P2 | `src/renderer/src/workspace/tile-tree/TileLeaf/QueueStrip.tsx` | queued task-notification → one-line chip, never raw XML | +25 |
    +| P3 | `src/renderer/src/features/feed/ui/semantic/BlockRow.tsx` | route live spawn tools → Task card adapter | +20 |
    +| P3 | `src/renderer/src/features/feed/ui/rows/TaskSubagentRow.tsx` | accept semantic-block shape (adapter or prop union) | +25 |
    +| P4 | `src/renderer/src/features/feed/ui/rows/AskUserQuestionAnsweredRow.tsx` (new) | committed answered-question compact row | 60 |
    +| P4 | `src/renderer/src/features/feed/ui/rows/Block.tsx` | dispatch AskUserQuestion tool_use → answered row | +10 |
    +
    +P1 ≈ 130 LOC (fixes #341's data half). P2 ≈ 315 (kills the 06-29 complaints + the 06-21
    +zero-cards blind spot). P3 ≈ 45. P4 ≈ 70.
    +Suggested PR slicing: P1 alone; P2 alone; P3+P4 together. Each is independently shippable.
    +
    +### On the integration branch (pre-cutover in pipeline terms, post-P2)
    +
    +| File (worktree `integration/rendering-pipeline`) | Change | ~LOC |
    +|---|---|---|
    +| `src/renderer/src/rendering/model/types.ts` | reason `'task-notification-joined'` | +2 |
    +| `src/renderer/src/rendering/observations/committed.ts` | detect via shared parser, stamp toolUseId, emit reason | +30 |
    +| `src/renderer/src/rendering/__tests__/fixtures.taskNotification.test.ts` (new) | rule-15 fixtures (above) | 120 |
    +| `scripts/extract-rendering-fixtures.mjs` | nothing structural — bundles already in corpus | 0 |
    +
    +### Post-cutover (D6, when SemanticStreamingTurn dies)
    +
    +- Move `BlockRow` AskUserQuestion + Agent routing into the provider registry dispatch (the
    +  cutover already plans this unification); delete the `renderUnits.ts` belt-and-suspenders guard
    +  once the ledger fixture proves the ledger twin.
    +- Optional: standalone `contentKind:'task-notification'` candidates if we ever want notification
    +  rows to be ledger-ordered artifacts instead of joined state (not needed while the join covers
    +  all observed bundles).
    +
    +## 8. What this does NOT do
    +
    +- No rewrite of the watcher IPC contract (#277's `SubAgentState` keyed by toolUseId is exactly
    +  the shape the ledger's rule-14 join wants).
    +- No conditions changes (AskUserQuestion answer driver #329/#330 already works).
    +- No new test scripts; fixtures follow the existing rendering-corpus machinery.
    +- #290/#193 provider tailing/lineage stay headless scope (a dead committed channel just renders
    +  honestly as stale).
    +
    +## Appendix — bundle evidence quick reference
    +
    +- Rendered DOM has NO semantic class names (all Tailwind utilities); tool rows are
    +  `⏺ Name` + `⎿ 
    raw
    `; + user bubbles `bg-user-bg -mx-8 px-8 py-3`; queue `aria-label="queued messages"`. +- `state-snapshot.subAgents` shape (all bundles): keyed by `toolu_…`, values + `{toolUseId, agentId, agentType, description, status, startedAt, lastActivityAt, turnCount, + toolCalls[{name,headline,status}], droppedToolCalls, currentActivity}` — exactly the + `SubAgentState` contract; the rows can trust it once lifecycle is fixed. +- task-notification XML observed fields: `task-id`, `tool-use-id` (optional), `output-file`, + `status`, `summary`, `note` (seen in the wild — ADD to the parser beyond the vendor grammar), + `result`, `usage{subagent_tokens|total_tokens, tool_uses, duration_ms}`, `worktree`. Parser + must accept both `subagent_tokens` (observed) and `total_tokens` (vendor source) — + the tag drifted between versions. +- AskUserQuestion bundles: only runtime signal besides the semantic block is + `streamPhase:"awaiting-tool"` + `streamPhasePendingToolName:"AskUserQuestion"`; conditions/ + picker snapshots are null — reaffirms that the semantic `parsedInput` path (not screen or + conditions state) is the right render source, as the shipped row already does. +- Codex wire: `function_call` blocks, `call_…`/`fc_…` ids, `agent_id` UUID disjoint from both; + `wait_agent {targets, timeout_ms}` is fan-in; no notification mechanism. diff --git a/docs/rendering/research-2026-07/session-recording/rec-research-precedent.md b/docs/rendering/research-2026-07/session-recording/rec-research-precedent.md new file mode 100644 index 000000000..34ad56dc8 --- /dev/null +++ b/docs/rendering/research-2026-07/session-recording/rec-research-precedent.md @@ -0,0 +1,275 @@ +# Recording/Journaling/Replay Precedent — Reuse Map for "Session Recording" + +Goal: a new continuous append-only JSONL of pipeline INPUTS, debug-gated, replayable in tests. +This catalogs every existing machine so the recorder EXTENDS proven patterns. + +--- + +## 1. feed-debug.jsonl — the per-session decision log + +**Type**: `FeedDebugEntry` — `src/renderer/src/workspace/workspaceState.ts:244-252` +``` +{ id:number; ts:number; tMs:number; layer:'STATE'|'JSONL'|'SEM'|'RENDER'|'GHOST'; kind:string; summary:string; data?:unknown } +``` +`FeedDebugLayer` at :242. Lives on the runtime as `feedDebugLog: FeedDebugEntry[]` (:519), plus +`feedDebugNextId` and `feedDebugEpochMs` (:520-521). `tMs` = offset from the session's first-entry epoch. + +**Append path (renderer)**: `appendFeedDebugLog(current, input)` — +`src/renderer/src/workspace/runtime/feedDebug.ts:35-60`. Pure reducer: returns a new runtime ref, +stamps id/ts/tMs, in-memory ring capped at `FEED_DEBUG_LOG_CAP = 500` (:22). Wrapped by the +workspace hook `appendFeedDebug` (`hook/helpers.ts:59`), surfaced to components as +`workspace.appendFeedDebug` / `appendRenderDebug` (`TileLeaf.tsx:270`). + +**Callers / what gets logged**: +- STATE layer: `hook/actions/streaming.ts:146,202,254,297` (submit, optimistic user queue, + session_started, process/status transitions), `hook/actions/initialHistory.ts:220` (bootstrap). +- JSONL layer: jsonl-entries ingest bursts. +- SEM layer: semantic summaries — `workspace/semantic/summarize.ts:69`. +- RENDER layer: `Feed.tsx:891-892` **`kind:'visible_rows'`** — records the ACTUAL painted row + list `{rows, added, removed}` only when the row set changed (signature-diffed at :882). + +**Persistence (renderer→main)**: `useFeedDebugPersist` — +`src/renderer/src/workspace/hook/persistence/useFeedDebugPersist.ts`. Ships id-monotone batches +every runtime tick via `window.api.appendFeedDebugLog`. Two cursors: +`persistedFeedDebugIdRef` (advanced ONLY after IPC resolves) + `inFlightFeedDebugIdRef` +(one unresolved append per session — backpressure). `selectFeedDebugAppendBatch` (:26) picks +`entry.id > lastPersistedId`. Retry-safe by (sessionId, id). + +**Main writer**: `queueFeedDebugAppend` — `src/main/storage/feedDebugLog.ts:136`. +Writes `STATE_DIR/feed-debug/.jsonl` via `writeFile(..., {flag:'a'})`. +Per-session serialized Promise queue (`feedDebugWriteQueues`) so overlapping batches can't +interleave/tear lines. Process-local id dedupe (`lastWrittenFeedDebugId`) — fresh app run +re-appends a new timeline even though renderer ids restart at 1. + +**Rotation/retention**: +- Per-file HARD cap `MAX_FEED_DEBUG_FILE_BYTES = 128 MiB` (:35). NO rotation — deliberate + (rotation would force the bundle collector to glob shards + hide a flooding session). On cap + crossing it writes the admitted line-prefix + a **tombstone row** `{__feedDebugCapped:true, + reason:'per-file-cap', droppedEntriesSoFar, ...}` (:110) in one append, then latches and + drops. Tombstone is REFRESHED each time drop-count doubles (log-bounded honest count). +- Bucket cap via `scheduleDebugStoragePrune` → `debugRetention.ts` (feed-debug bucket ~3 GiB). +- `forgetFeedDebugSession` (:347) drops in-memory cursors at session end; file left on disk. + +**In the bundle**: `saveDebugBundle.ts:330 serializeFeedDebugJsonl` re-serializes the current +in-memory 500-entry slice into `feed-debug.jsonl` with an added `tsIso`. + +### INPUT-vs-OUTPUT verdict — feed-debug is DECISION-OUTPUT, not a recorder input +feed-debug records *what the pipeline decided/did* — visible_rows is the painted output, STATE/SEM +lines are post-hoc summaries (`summary` string + a lossy `data`). It is NOT a faithful capture of +the pipeline INPUT (entries + semantic + ghosts + scalars) at each tick. Crucially, the +corpus-extraction script (below) has to RECONSTRUCT the input from *other* sources and only mines +feed-debug for the `RENDER visible_rows` line as the expected OUTPUT ground-truth. So feed-debug is +~80% of a recorder's *output/expected* channel and the plumbing template (cursor persistence, per- +session serialized JSONL, cap+tombstone, retention hook), but ~0% of the *input* channel. The +recorder should reuse feed-debug's transport/retention shape and add a NEW input-plane stream. + +--- + +## 2. Debug-bundle system — the single-snapshot precedent + +**Assembler (renderer)**: `src/renderer/src/features/debug/saveDebugBundle.ts`. +`assembleAndSaveDebugBundle` (:457). Runs in renderer because every source is renderer-local +(runtime store, DOM, sanitizeHtml). `BUNDLE_SCHEMA_VERSION = 1` (:42). +File set (`FILE_NAMES` :47-74): manifest.json, state-snapshot.json (heavy planes stripped: +entries/maps/ghosts/feedDebugLog/semantic removed, screen tail-capped to `SCREEN_TAIL_LINES=200`, +`_counts` + `_tailHealth` added), feed-debug.jsonl, work-context.json, render-diagnostics.json +(committed-vs-semantic ownership join table, `snippet()` capped at `DIAGNOSTIC_SNIPPET_CHARS=360`), +proxy-semantic.json (full `SemanticRuntimeState` dump + ISO mirror), html-raw.html, html-clean.html +(sanitized), and CONDITIONAL proxy-events.jsonl + proxy-session-meta.json (manual saves only). +Auto vs manual: `autosaveActiveAgentDebugBundles` (:584) every `AUTO_DEBUG_BUNDLE_INTERVAL_MS=60_000`; +autosave SKIPS the proxy tail (a 108 GB disk-hog multiplier — comment :488). + +**Main writer**: `src/main/storage/debugBundle.ts` `saveDebugBundle` (:121). Thin byte-mover. +Path safety `isSafeRelativePath` (:108). Manual saves → timestamped folder +`-` (`buildBundleFolderName` :74) + ledger append +(`appendDebugBundleSaved` → saved-debug-bundles.jsonl) + incident-journal enrichment +(`enrichBundleWithIncidentJournal` :229 stamps appRunId, copies events/incidents/heartbeat tails). +Autosave → STABLE per-session folder (`buildAutosaveFolderName` :99, no timestamp) written to a +`.tmp--
    ] + +import { execFileSync } from 'node:child_process' +import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { join, basename } from 'node:path' + +const BUNDLES = join(homedir(), '.config/agent-code/debug-bundles/manual') +const ROOT_BUNDLES = join(homedir(), '.config/agent-code/debug-bundles') +const GHOST_LOGS = join(homedir(), 'Library/Application Support/agent-code/ghost-logs') +const LEDGER = join(BUNDLES, 'saved-debug-bundles.jsonl') + +const args = process.argv.slice(2) +const onlyIdx = args.indexOf('--only') +const only = onlyIdx >= 0 ? args[onlyIdx + 1] : null +const outIdx = args.indexOf('--out') +const OUT = outIdx >= 0 ? args[outIdx + 1] : join(process.cwd(), 'testing/fixtures/rendering-bundles') + +const MAX_ENTRIES = 80 +const TOOL_RESULT_CAP = 600 +const TEXT_CAP = 8000 + +function readJson(path) { + return JSON.parse(readFileSync(path, 'utf8')) +} +function readJsonl(path) { + const out = [] + for (const line of readFileSync(path, 'utf8').split('\n')) { + if (!line.trim()) continue + try { + out.push(JSON.parse(line)) + } catch { + /* torn tail line — bundle saved mid-write; skip */ + } + } + return out +} + +// providerSessionId + cwd per bundle, from the save ledger. Some ledger +// lines are partial (note-added events) — keep the richest per path. +const ledgerByPath = new Map() +for (const rec of readJsonl(LEDGER)) { + if (!rec.bundlePath) continue + const prev = ledgerByPath.get(rec.bundlePath) ?? {} + ledgerByPath.set(rec.bundlePath, { ...prev, ...rec }) +} + +function transcriptPathFor(cwd, providerSessionId, kind) { + if (!providerSessionId) return null + if (kind === 'claude') { + const slug = ('-' + cwd.replaceAll('/', '-')).replace(/^--/, '-') + const p = join(homedir(), '.claude/projects', slug.replace(/^-/, '-'), `${providerSessionId}.jsonl`) + // Claude Code's project slug replaces '/' AND '.' with '-'; try both. + const candidates = [ + p, + join(homedir(), '.claude/projects', cwd.replaceAll('/', '-').replaceAll('.', '-'), `${providerSessionId}.jsonl`), + join(homedir(), '.claude/projects', '-' + cwd.slice(1).replaceAll('/', '-').replaceAll('.', '-'), `${providerSessionId}.jsonl`), + ] + return candidates.find(existsSync) ?? null + } + if (kind === 'codex') { + // Rollouts live under ~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl. + // Walk the date tree; the providerSessionId is embedded in the filename. + const root = join(homedir(), '.codex/sessions') + if (!existsSync(root)) return null + const stack = [root] + while (stack.length) { + const dir = stack.pop() + for (const name of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, name.name) + if (name.isDirectory()) stack.push(full) + else if (name.name.includes(providerSessionId) && name.name.endsWith('.jsonl')) return full + } + } + return null + } + return null +} + +/** + * Codex entries come from the REAL in-app mapper + * (mapCodexRolloutToFeedEntries) executed via tsx — reimplementing ~500 + * lines of provider mapping in this script would mint a second mapper + * that drifts, and the recorded visible_rows keys use the app mapper's + * uuid scheme, so only the real mapper reproduces comparable ids. + */ +function codexEntriesFor(rolloutPath, cutoffMs) { + const out = execFileSync( + 'npx', + ['tsx', '--tsconfig', 'tsconfig.web.json', 'scripts/extract-codex-entries.mts', rolloutPath, String(cutoffMs)], + { cwd: process.cwd(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, + ) + return JSON.parse(out) +} + +function truncateEntry(entry) { + const msg = entry.message + if (!msg || !Array.isArray(msg.content)) return entry + const content = msg.content.map(block => { + if (block?.type === 'tool_result') { + const c = block.content + if (typeof c === 'string' && c.length > TOOL_RESULT_CAP) { + return { ...block, content: c.slice(0, TOOL_RESULT_CAP) + `…[truncated ${c.length}]` } + } + if (Array.isArray(c)) { + return { + ...block, + content: c.map(part => + typeof part?.text === 'string' && part.text.length > TOOL_RESULT_CAP + ? { ...part, text: part.text.slice(0, TOOL_RESULT_CAP) + `…[truncated ${part.text.length}]` } + : part, + ), + } + } + return block + } + if (typeof block?.text === 'string' && block.text.length > TEXT_CAP) { + return { ...block, text: block.text.slice(0, TEXT_CAP) + `…[truncated ${block.text.length}]` } + } + return block + }) + return { ...entry, message: { ...msg, content } } +} + +function extractBundle(dir) { + const id = basename(dir) + const manifest = readJson(join(dir, 'manifest.json')) + const note = existsSync(join(dir, 'note.json')) ? readJson(join(dir, 'note.json')).note : null + const snapshot = readJson(join(dir, 'state-snapshot.json')) + const semantic = readJson(join(dir, 'proxy-semantic.json')) + const capturedAt = manifest.capturedAt + + // ---- expected rows: last RENDER visible_rows before capture ---- + let visible = null + for (const ev of readJsonl(join(dir, 'feed-debug.jsonl'))) { + if (ev.layer === 'RENDER' && ev.kind === 'visible_rows' && ev.data?.rows) visible = ev + } + if (!visible) return { id, skipped: 'no visible_rows event in feed-debug window' } + // Align input planes to the EXPECTED moment: the visible_rows event's own + // epoch ts. Cutting at capturedAt instead produced whole divergence + // classes that were pure skew (entries landing between the last RENDER + // emission and the save click showed as extra-in-next; their semantic + // twins as missing-in-next). Semantic state cannot be rewound (it is a + // capture-time dump), so residual current-turn skew is triaged per + // fixture instead. + const cutoffMs = typeof visible.ts === 'number' ? visible.ts : capturedAt + + // ---- entries from transcript ---- + const ledger = ledgerByPath.get(dir) ?? {} + const tPath = transcriptPathFor(ledger.cwd ?? manifest.projectDir ?? '', ledger.providerSessionId, manifest.kind) + let entries = [] + let entriesSource = 'none' + if (tPath) { + entriesSource = tPath + if (manifest.kind === 'codex') { + entries = codexEntriesFor(tPath, cutoffMs) + } else { + // Seen-uuid dedupe mirroring the runtime's ingest (seenUuidsRef, + // workspace/hook/refs.ts): compaction rewrites the transcript window + // and the same uuid can appear twice in the file; the runtime ingests + // each uuid once (first wins), so replaying without the dedupe lands + // duplicate rows the real feed never had — the 14-02-05 a8ad1ebb + // order-mismatch was exactly this artifact, not a renderer difference. + const seenUuids = new Set() + for (const rec of readJsonl(tPath)) { + const ts = typeof rec.timestamp === 'string' ? Date.parse(rec.timestamp) : rec.timestamp + if (typeof ts !== 'number' || ts > cutoffMs) continue + if (typeof rec.uuid === 'string') { + if (seenUuids.has(rec.uuid)) continue + seenUuids.add(rec.uuid) + } + entries.push(rec) + } + } + entries = entries.slice(-MAX_ENTRIES).map(truncateEntry) + } + + // ---- ghosts from journal ---- + const ghostPath = join(GHOST_LOGS, `${manifest.sessionId}.ghost.jsonl`) + const ghosts = {} + if (existsSync(ghostPath)) { + for (const rec of readJsonl(ghostPath)) { + if (rec?._atp?.updatedAt != null && rec._atp.updatedAt <= cutoffMs) { + ghosts[rec.uuid] = rec + } + } + } + + // ---- expected rows, trimmed to the kept entry window ---- + const keptUuids = new Set(entries.map(e => e.uuid).filter(Boolean)) + const rows = visible.data.rows + .map(r => ({ key: r.key, slot: r.slot, itemType: r.itemType ?? r.slot })) + .filter(r => { + if (r.slot !== 'entry') return true + const uuid = r.key.replace(/^entry:/, '') + // Ghost/optimistic rows fold into entries in the legacy path but are + // NOT in the transcript — keep them (they come from ghosts/semantic). + if (uuid.startsWith('g-') || uuid.startsWith('optimistic-')) return true + return entriesSource === 'none' ? true : keptUuids.has(uuid) + }) + + return { + id, + fixture: { + meta: { + bundleId: id, + note, + kind: manifest.kind, + sessionId: manifest.sessionId, + capturedAt, + capturedAtIso: manifest.capturedAtIso, + entriesSource, + entriesTruncatedTo: entries.length, + visibleRowsAtTMs: visible.tMs ?? null, + }, + input: { + provider: manifest.kind, + // (history trimmed below when entries were truncated) + streamPhase: snapshot.streamPhase, + streamPhasePendingToolName: snapshot.streamPhasePendingToolName ?? null, + streamPhasePendingToolUseId: snapshot.streamPhasePendingToolUseId ?? null, + lastJsonlEntryAt: snapshot.lastJsonlEntryAt ?? null, + queuedMessages: snapshot.queuedMessages ?? [], + entries, + semanticCurrent: semantic.currentTurn ?? null, + semanticHistory: (semantic.history ?? []).filter(t => { + // Drop history turns older than the kept entry window — their + // committed owners were truncated away, so keeping them would + // manufacture suppression misses that never happened in the app. + if (entriesSource === 'none' || entries.length === 0) return true + const windowStart = Date.parse(entries[0]?.timestamp ?? 0) || 0 + const turnEnd = t?.endedAt ?? t?.startedAt ?? 0 + return turnEnd >= windowStart + }), + ghosts, + }, + expected: { + rows, + semanticTurnId: visible.data.semanticTurnId ?? null, + semanticHistoryTurnIds: visible.data.semanticHistoryTurnIds ?? [], + streamPhase: visible.data.streamPhase ?? null, + }, + // Triage state: [] means "new pipeline must match the recorded legacy + // rows exactly at the shadow-unit grain". Divergences discovered by + // the corpus test get triaged HERE with class+unit+verdict+why — + // verdict 'legacy-bug' keeps the divergence (asserted explicitly), + // 'new-bug' means fix the pipeline, 'skew' means input/expected were + // captured ticks apart (documented, tolerated). + triage: [], + }, + } +} + +mkdirSync(OUT, { recursive: true }) +const dirs = [ + ...readdirSync(BUNDLES) + .filter(d => /^\d{4}-/.test(d)) + .map(d => join(BUNDLES, d)), + ...readdirSync(ROOT_BUNDLES) + .filter(d => /^\d{4}-/.test(d)) + .map(d => join(ROOT_BUNDLES, d)), +] +const summary = [] +for (const dir of dirs) { + const id = basename(dir) + if (only && id !== only) continue + try { + const result = extractBundle(dir) + if (result.skipped) { + summary.push({ id, skipped: result.skipped }) + continue + } + const outPath = join(OUT, `${id}.json`) + writeFileSync(outPath, JSON.stringify(result.fixture)) + const f = result.fixture + summary.push({ + id, + kind: f.meta.kind, + entries: f.input.entries.length, + ghosts: Object.keys(f.input.ghosts).length, + hist: f.input.semanticHistory.length, + cur: f.input.semanticCurrent ? 1 : 0, + expectedRows: f.expected.rows.length, + kb: Math.round(JSON.stringify(f).length / 1024), + }) + } catch (err) { + summary.push({ id, error: String(err).slice(0, 120) }) + } +} +console.table(summary) +console.log(`wrote ${summary.filter(s => !s.skipped && !s.error).length}/${summary.length} fixtures to ${OUT}`) diff --git a/scripts/extract-rendering-recordings.mjs b/scripts/extract-rendering-recordings.mjs new file mode 100644 index 000000000..f2004bfd7 --- /dev/null +++ b/scripts/extract-rendering-recordings.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +// Extract check-in-safe rendering fixtures from LOCAL session recordings. +// +// The successor to scripts/extract-rendering-fixtures.mjs (which mined frozen +// debug bundles). A session recording is the full rendering INPUT STREAM of a +// live session — a folder `session-recordings//` with meta.json + an +// append-only events.jsonl of the 9 SessionFeed channels (plan §§2-6). This +// script turns one (or many) local recording folders into redacted, minimized +// fixtures under testing/fixtures/rendering-recordings/, which the golden +// replay suite (recordingCorpus.test.ts) then replays through the real +// pipeline. +// +// The heavy lifting — redaction, structure-only minimization, windowing around +// __note markers, and the HARD GATE that refuses to emit a fixture still +// carrying a SENSITIVE_KEY value — lives in the PURE core +// (src/renderer/src/rendering/replay/redact.ts), reached through the tsx bridge +// scripts/extract-recording-core.mts. This file is only the file-IO wrapper: +// enumerate folders, invoke the core, write outputs, print a summary. Keeping +// the safety logic in one TS module (also exercised by redact.test.ts) is why +// there is no second copy of the regex/caps here to drift. +// +// Redaction mode (plan §5 two-tier model): +// --mode structure-only (default) drop free-text bodies, keep the +// identity/structure the pipeline keys on. The +// innovation — makes most recordings check-in-safe. +// --mode full-text-capped keep prose truncated to the corpus tiers. +// +// Windowing: with --window , a recording that has __note markers emits one +// fixture per reserved note, each ±N events around the reaction tick, with the +// note's fill text as the fixture description. Without it (or with no notes), +// the whole recording becomes one fixture. +// +// Usage: +// node scripts/extract-rendering-recordings.mjs [--in ] [--out ] +// [--only ] [--mode structure-only|full-text-capped] +// [--window ] +// +// After extracting, populate each fixture's golden `expected` rows by blessing: +// AGENT_CODE_RECORDING_BLESS=1 NODE_ENV=test npx vitest run --project unit \ +// src/renderer/src/rendering/__tests__/recordingCorpus.test.ts + +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +const args = process.argv.slice(2) +function flag(name, fallback = null) { + const i = args.indexOf(name) + return i >= 0 ? args[i + 1] : fallback +} + +// Local recordings live in userData (macOS: ~/Library/Application Support/ +// agent-code/session-recordings). Overridable so the script can run over an +// exported recording dir. Mirrors extract-rendering-fixtures.mjs's default +// paths. +const IN = + flag('--in') ?? + join(homedir(), 'Library/Application Support/agent-code/session-recordings') +const OUT = flag('--out') ?? join(process.cwd(), 'testing/fixtures/rendering-recordings') +const ONLY = flag('--only') +const MODE = flag('--mode', 'structure-only') +const WINDOW = flag('--window') + +if (!['structure-only', 'full-text-capped'].includes(MODE)) { + console.error(`invalid --mode ${MODE} (structure-only | full-text-capped)`) + process.exit(2) +} + +if (!existsSync(IN)) { + console.log(`no recordings dir at ${IN} — nothing to extract (this is normal until a session is recorded).`) + process.exit(0) +} + +// A recording folder is any child dir with an events.jsonl. +const recordingDirs = readdirSync(IN) + .map(name => join(IN, name)) + .filter(p => { + try { + return statSync(p).isDirectory() && existsSync(join(p, 'events.jsonl')) + } catch { + return false + } + }) + +mkdirSync(OUT, { recursive: true }) +const summary = [] + +for (const dir of recordingDirs) { + const id = dir.split('/').pop() + if (ONLY && id !== ONLY) continue + try { + // The core throws (non-zero exit) if the hard gate trips; execFileSync + // then throws here and we record the skip loudly — a recording that can't + // be scrubbed is NEVER written. + const coreArgs = [ + 'tsx', + '--tsconfig', + 'tsconfig.web.json', + 'scripts/extract-recording-core.mts', + dir, + MODE, + ] + if (WINDOW) coreArgs.push(WINDOW) + const out = execFileSync('npx', coreArgs, { + cwd: process.cwd(), + encoding: 'utf8', + maxBuffer: 128 * 1024 * 1024, + }) + const fixtures = JSON.parse(out) + + fixtures.forEach((fixture, i) => { + // One fixture per note-window gets a suffix; a whole-session extraction + // is just `.json`. + const suffix = fixtures.length > 1 ? `-w${i}` : '' + const outPath = join(OUT, `${id}${suffix}.json`) + writeFileSync(outPath, JSON.stringify(fixture)) + summary.push({ + id: `${id}${suffix}`, + mode: MODE, + events: fixture.events.length, + note: (fixture.meta.note ?? '').slice(0, 40), + kb: Math.round(JSON.stringify(fixture).length / 1024), + }) + }) + } catch (err) { + // execFileSync surfaces the core's stderr (gate reason) in err.stderr. + const reason = err?.stderr ? String(err.stderr).trim() : String(err).slice(0, 160) + summary.push({ id, error: reason.slice(0, 160) }) + } +} + +console.table(summary) +const ok = summary.filter(s => !s.error).length +console.log(`wrote ${ok}/${summary.length} fixtures to ${OUT}`) +console.log( + `next: AGENT_CODE_RECORDING_BLESS=1 NODE_ENV=test npx vitest run --project unit ` + + `src/renderer/src/rendering/__tests__/recordingCorpus.test.ts (fills golden expected rows)`, +) diff --git a/scripts/triage-rendering-fixtures.mjs b/scripts/triage-rendering-fixtures.mjs new file mode 100644 index 000000000..5117d3d3c --- /dev/null +++ b/scripts/triage-rendering-fixtures.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node +// Apply SYSTEMATIC triage verdicts to bundle-corpus fixtures. +// +// Runs AFTER a bless pass (AGENT_CODE_CORPUS_BLESS=1 vitest …) and upgrades +// 'untriaged' entries whose divergence matches a mechanically-checkable +// rule. Rules live here — in a reviewable script, not in heads — so a +// re-extraction can replay the whole triage instead of losing it. +// +// Verdicts assigned: +// +// equivalent-content — the SAME visible content painted via different +// planes. The recorded visible_rows predates ingestion of the committed +// rows for a finished turn: legacy painted the semantic turn, the +// pipeline (which HAS those rows in its input) paints the committed rows +// and suppresses the turn — claude's message.id === turnId makes the +// pairing mechanical. Includes the burst's tool_result user rows (they +// parent onto paired assistant rows). Neither renderer is wrong; the +// fixture's input and expectation straddle an ingestion boundary. +// +// skew-ingestion-lag — extra-in-next committed rows that carry producer +// timestamps <= the visible_rows moment but were not yet INGESTED when +// it fired (JSONL batch drain lag). No missing-turn twin exists. +// +// legacy-bug:scaffolding-echo — extra-in-next user rows with no +// permissionMode whose text starts '<' (environment_context / +// command-name / local-command-stdout echoes). The legacy renderer +// painted these as user prompts — the exact complaint in the bundle +// notes ("spitting out commands in to the user prompts"); the #338 +// filter hiding them is the fix working. +// +// legacy-bug:prompt-not-shown — extra-in-next REAL user prompts +// (permissionMode present) in fixtures whose note complains about +// prompts not rendering. The pipeline painting them is the fix. +// +// extraction-gap:history-window — missing-in-next turns absent from the +// fixture's semanticHistory input: the runtime caps history at 20 turns, +// the recorded rows are older than the surviving window. +// +// Everything not matched stays 'untriaged' — visible debt, never silently +// blessed away. + +import { readFileSync, writeFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +const DIR = join(process.cwd(), 'testing/fixtures/rendering-bundles') +const counts = {} +const bump = k => (counts[k] = (counts[k] ?? 0) + 1) + +for (const file of readdirSync(DIR).filter(f => f.endsWith('.json'))) { + const path = join(DIR, file) + const f = JSON.parse(readFileSync(path, 'utf8')) + const triage = f.triage ?? [] + if (triage.length === 0) continue + + const entsByUuid = new Map(f.input.entries.map(e => [e.uuid, e])) + const hasCommittedPlane = f.meta.entriesSource !== 'none' && f.input.entries.length > 0 + // Every tool identity present in the committed plane — the codex pairing + // key (codex suppression is unit-level: a semantic turn vanishes when all + // its blocks are owned by committed tool/text rows). + const committedToolIds = new Set() + const committedTexts = new Set() + for (const e of f.input.entries) { + const c = e?.message?.content + if (!Array.isArray(c)) continue + for (const b of c) { + if (b?.type === 'tool_use' && typeof b.id === 'string') committedToolIds.add(b.id) + if (b?.type === 'tool_result' && typeof b.tool_use_id === 'string') committedToolIds.add(b.tool_use_id) + if (b?.type === 'text' && typeof b.text === 'string') committedTexts.add(b.text.normalize('NFKC').replace(/\s+/g, ' ').trim()) + } + } + const turnsById = new Map(f.input.semanticHistory.map(t => [t.turnId, t])) + const histIds = new Set(f.input.semanticHistory.map(t => t.turnId)) + const note = (f.meta.note ?? '').toLowerCase() + + const missingTurnIds = new Set( + triage + .filter(t => t.divergence.class === 'missing-in-next' && t.divergence.unit?.startsWith('turn:')) + .map(t => t.divergence.unit.slice(5)), + ) + // Assistant rows whose message.id pairs with a missing turn. + const pairedRowUuids = new Set() + for (const e of f.input.entries) { + if (e?.type === 'assistant' && missingTurnIds.has(e.message?.id)) pairedRowUuids.add(e.uuid) + } + + const textOf = e => { + const c = e?.message?.content + if (typeof c === 'string') return c + if (Array.isArray(c)) { + const t = c.find(b => b?.type === 'text') + return typeof t?.text === 'string' ? t.text : '' + } + return '' + } + + let changed = false + for (const t of triage) { + if (t.verdict !== 'untriaged') continue + const d = t.divergence + const unit = d.unit ?? '' + + if (d.class === 'extra-in-next' && unit.startsWith('turn:') && !hasCommittedPlane) { + t.verdict = 'extraction-gap' + t.why = 'no committed plane reconstructed for this bundle (source missing or empty window); without committed ownership these turns cannot be suppressed' + bump(t.verdict); changed = true + } else if (d.class === 'extra-in-next' && unit.startsWith('row:')) { + const uuid = unit.slice(4) + const e = entsByUuid.get(uuid) + if (!e) continue + const parentPaired = pairedRowUuids.has(e.parentUuid) + if (pairedRowUuids.has(uuid) || (e.type === 'user' && parentPaired)) { + t.verdict = 'equivalent-content' + t.why = `same content as missing turn ${e.message?.id ?? '(via parent tool_use)'} — committed rows not yet ingested at the recorded visible_rows moment; pipeline paints committed, legacy painted the semantic turn` + bump(t.verdict); changed = true + } else if (e.type === 'user' && e.permissionMode === undefined && textOf(e).trimStart().startsWith('<')) { + t.verdict = 'legacy-bug:scaffolding-echo' + t.why = 'legacy painted a scaffolding echo ( row) as a user prompt — the exact complaint in the corpus notes; the #338 synthetic-user filter hiding it is the fix' + bump(t.verdict); changed = true + } else if (e.type === 'user' && e.permissionMode !== undefined && /prompt/.test(note) && /show|render|regist/.test(note)) { + t.verdict = 'legacy-bug:prompt-not-shown' + t.why = `real user prompt legacy failed to paint — the bundle note is the bug report: "${f.meta.note}"` + bump(t.verdict); changed = true + } else if (e.type === 'assistant' || e.type === 'user') { + t.verdict = 'skew-ingestion-lag' + t.why = 'row timestamp precedes the visible_rows moment (producer clock) but ingestion had not drained it yet; legacy could not paint what it had not seen' + bump(t.verdict); changed = true + } + } else if (d.class === 'missing-in-next' && unit.startsWith('row:optimistic-')) { + // Optimistic rows live only in renderer state (runtime.entries) — + // no transcript or rollout ever contains them, so a reconstruction + // from disk cannot produce them regardless of provider. + t.verdict = 'extraction-gap:optimistic-rows-renderer-local' + t.why = 'optimistic rows exist only in renderer state; no on-disk source reconstructs them' + bump(t.verdict); changed = true + } else if (d.class === 'missing-in-next' && unit.startsWith('turn:')) { + const turnId = unit.slice(5) + const turn = turnsById.get(turnId) + if (turn && hasCommittedPlane) { + const blocks = Object.values(turn.blocks ?? {}) + const ids = blocks.map(b => b?.callId ?? b?.toolUseId ?? b?.itemId).filter(Boolean) + const owned = ids.length > 0 && ids.every(id => committedToolIds.has(id)) + const textOwned = + ids.length === 0 && + typeof turn.text === 'string' && + committedTexts.has(turn.text.normalize('NFKC').replace(/\s+/g, ' ').trim()) + if (owned || textOwned) { + t.verdict = 'equivalent-content' + t.why = 'unit-level suppression: every block identity (or the finalized turn text) is owned by committed rows reconstructed from the rollout — legacy painted the turn only because those rows had not been ingested at the recorded moment' + bump(t.verdict); changed = true + continue + } + } + if (!histIds.has(turnId)) { + t.verdict = 'extraction-gap:history-window' + t.why = 'turn absent from the semanticHistory input — the runtime caps history at 20 turns; the recorded rows are older than the surviving window' + bump(t.verdict); changed = true + } else if ( + [...pairedRowUuids].some(u => entsByUuid.get(u)?.message?.id === turnId) + ) { + t.verdict = 'equivalent-content' + t.why = 'suppressed in favor of its committed twin (message.id === turnId) which the pipeline paints as entry rows' + bump(t.verdict); changed = true + } + } + } + if (changed) writeFileSync(path, JSON.stringify(f)) +} +console.log(counts) diff --git a/src/main/index.ts b/src/main/index.ts index 8d7c62599..048b1da99 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -33,6 +33,9 @@ import { cleanupClaudeImageCacheDir } from '@main/storage/claudeImageCache.js' import { acquireStateProcessLock, type StateProcessLock } from '@main/storage/processLock.js' import { createMainWindow, focusMainWindow, sendToMainWindow } from '@main/window/mainWindow.js' import { wireSessionForwarder } from '@main/sessions/forwarder.js' +import { SessionRecorderManager } from '@main/recording/SessionRecorderManager.js' +import { setOutboundObserver } from '@main/window/mainWindow.js' +import { isSessionRecordingEnabled, isSessionRecordingAutoStart } from '@main/ipc/devDebug.js' import { registerAllIpc } from '@main/ipc/index.js' import { cleanupDictationIpcResources } from '@main/ipc/dictation.js' import { performanceService } from '@main/performance/PerformanceService.js' @@ -80,6 +83,18 @@ const lspManager = new LspManager() // See `./ghostJournal.ts` for the full rationale; see // `src/renderer/src/workspace/ghosts.ts` for the renderer side. const ghostJournals = new GhostJournalRegistry() +// Session recorder — one folder per recording under session-recordings/. +// Constructed and installed as the outbound-IPC observer ONLY when +// AGENT_CODE_DEV_DEBUG=1 AND AGENT_CODE_SESSION_RECORD=1, so a normal build +// pays nothing (no observer installed, sendToMainWindow's hook stays null). +// plan: docs/rendering/session-recording-plan-2026-07.md (#467). +// Construct the recorder manager whenever the CAPABILITY is on (dev-debug), +// so the Start Recording command works. autoRecord (the env flag) stays OFF +// by default — nothing records until the command starts a session. +const sessionRecorders = isSessionRecordingEnabled() + ? new SessionRecorderManager(undefined, undefined, isSessionRecordingAutoStart()) + : null +if (sessionRecorders) setOutboundObserver(sessionRecorders.observe) // Per-dictation-session debug-dump registry. Mirrors `ghostJournals`: // constructed before IPC handlers register, flushed on before-quit. See // `src/main/dictationJournal.ts` for the on-disk shape and the @@ -518,6 +533,7 @@ async function startApp(): Promise { ghostJournals, dictationDebugJournals, pasteDebugJournals, + sessionRecorders, worktreeActivityIndex, orchestrationBridge, aiWorkspaceRegistry, @@ -572,6 +588,9 @@ app.on('before-quit', () => { // worst-case; in practice drains are empty at quit time because // streaming is idle. void ghostJournals.flushAll() + // Same one-tick-before-teardown rationale as ghostJournals; recordings are + // usually mid-stream at quit, so this drain matters more than the ghost one. + void sessionRecorders?.flushAll() // Same rationale as ghostJournals — Electron gives us one tick before // teardown. 100 ms queue depth is the worst case; in practice the // dictation journal is idle at quit unless the user is pressing Fn diff --git a/src/main/ipc/devDebug.ts b/src/main/ipc/devDebug.ts index 935cf6243..4b1d69dda 100644 --- a/src/main/ipc/devDebug.ts +++ b/src/main/ipc/devDebug.ts @@ -1,9 +1,16 @@ import { ipcMain } from 'electron' import { readRecentPasteSessions } from '../pasteDebugJournal.js' +import type { SessionRecorderManager } from '@main/recording/SessionRecorderManager.js' export type DevDebugConfig = { enabled: boolean + /** AGENT_CODE_SESSION_RECORD=1 (AND dev-debug on) — continuously record + * each session's rendering-input stream to session-recordings//, + * replayable in tests. plan: docs/rendering/session-recording-plan-2026-07.md, + * issue #467. Debug-gated because a recording captures full conversation + * input. */ + sessionRecordingEnabled: boolean } function envFlag(name: string): boolean { @@ -15,7 +22,31 @@ function isDevDebugEnabled(): boolean { return envFlag('AGENT_CODE_DEV_DEBUG') } -export function registerDevDebugIpc(): void { +/** Session recording is doubly gated: it is a diagnostic (dev-debug) AND + * opt-in (its own flag), because it records full session input. */ +// Recording CAPABILITY: the Start/Stop/Attach-Note commands and IPC are +// available whenever dev-debug is on. This does NOT record anything by +// itself — recording is command-driven per session (plan §7). Kept named +// isSessionRecordingEnabled for the DevDebugConfig field the renderer reads +// to decide whether to SHOW the commands. +export function isSessionRecordingEnabled(): boolean { + return isDevDebugEnabled() +} + +// AUTO-START power path (plan §7, OPTIONAL, OFF by default): only with the +// explicit env flag does the manager auto-record every session from launch — +// for unattended soak. The normal path records nothing until the Start +// Recording command fires, so a day of work never silently fills disk. +export function isSessionRecordingAutoStart(): boolean { + return isDevDebugEnabled() && envFlag('AGENT_CODE_SESSION_RECORD') +} + +// `sessionRecorders` is null in a normal build (the manager is only +// constructed when AGENT_CODE_DEV_DEBUG + AGENT_CODE_SESSION_RECORD are both +// on — see main/index.ts). It is threaded in from the IPC deps rather than +// imported as a singleton so the wiring stays visible at the registerAllIpc +// call site, exactly like every other manager here. +export function registerDevDebugIpc(sessionRecorders: SessionRecorderManager | null): void { ipcMain.handle('dev-debug:get-config', (): DevDebugConfig => { return { // WHY this flag lives in main instead of import.meta.env: @@ -25,6 +56,7 @@ export function registerDevDebugIpc(): void { // runtime switch that works in Electron dev without requiring a // Vite-prefixed renderer variable or rebuild-time config. enabled: isDevDebugEnabled(), + sessionRecordingEnabled: isSessionRecordingEnabled(), } }) @@ -43,4 +75,62 @@ export function registerDevDebugIpc(): void { if (!isDevDebugEnabled()) return [] return readRecentPasteSessions(typeof limit === 'number' ? limit : 30) }) + + // Attach-Recording-Note (plan §7b). The recording-era equivalent of "save + // debug logs": drop a timestamped bookmark into the LIVE recording stream so + // a soak operator can flag the exact tick they reacted to without stopping + // the session. Two phases, on purpose: + // reserve → writes the `reserved` marker INSTANTLY (before the user types) + // so the timestamp pins the reaction moment, not the moment they + // finished typing (several ticks later). Returns the noteId. + // fill → updates that noteId with the typed text on submit. + // A crash between the two still leaves the reserved line, which alone flags + // "something was wrong here" — the same two-phase crash-safety the ghost + // journal uses. + // + // Both handlers are gated by isSessionRecordingEnabled(): the flag is the + // trust boundary (a recording captures full session input, so a renderer + // with preload access must not reach the recorder unless the operator opted + // in), AND sessionRecorders is null unless recording was gated on at + // construction — so these are double-safe no-ops in a normal build. A null + // recorder (or a sessionId with no active recording) returns null from + // reserve; the renderer treats that as "nothing to annotate" and shows a + // toast rather than opening the note input. + ipcMain.handle( + 'record-session:reserve-note', + (_evt, sessionId: string): string | null => { + if (!isSessionRecordingEnabled()) return null + return sessionRecorders?.reserveNote(sessionId) ?? null + }, + ) + ipcMain.handle( + 'record-session:fill-note', + (_evt, sessionId: string, noteId: string, text: string): void => { + if (!isSessionRecordingEnabled()) return + sessionRecorders?.fillNote(sessionId, noteId, text) + }, + ) + // Start/stop a single session's recording on demand (plan §7). This is the + // PRIMARY control — recording is command-driven, not auto. Returns whether + // the session is recording after the call so the renderer can label the + // toggle correctly. + ipcMain.handle( + 'record-session:start', + (_evt, sessionId: string, provider?: string): boolean => { + if (!isSessionRecordingEnabled() || !sessionRecorders) return false + // The command knows the pane's provider (workspace meta.kind); pass it + // so a mid-session start still records the right provider (the + // session:started event that carries it has usually already fired). + sessionRecorders.startRecording(sessionId, provider ? { kind: provider } : undefined) + return true + }, + ) + ipcMain.handle('record-session:stop', (_evt, sessionId: string): Promise | boolean => { + if (!isSessionRecordingEnabled() || !sessionRecorders) return false + return sessionRecorders.stopRecording(sessionId).then(() => false) + }) + ipcMain.handle('record-session:is-recording', (_evt, sessionId: string): boolean => { + if (!isSessionRecordingEnabled() || !sessionRecorders) return false + return sessionRecorders.isRecording(sessionId) + }) } diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index a4e4d02c5..101bc5c4c 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -3,6 +3,7 @@ import type { LspManager } from '@main/lspManager.js' import type { GhostJournalRegistry } from '@main/ghostJournal.js' import type { DictationDebugJournalRegistry } from '@main/dictationJournal.js' import type { PasteDebugJournalRegistry } from '@main/pasteDebugJournal.js' +import type { SessionRecorderManager } from '@main/recording/SessionRecorderManager.js' import { registerSessionIpc } from '@main/ipc/session.js' import { registerProviderIpc } from '@main/ipc/provider.js' @@ -49,6 +50,10 @@ export type IpcDeps = { ghostJournals: GhostJournalRegistry dictationDebugJournals: DictationDebugJournalRegistry pasteDebugJournals: PasteDebugJournalRegistry + // Null in a normal build — only constructed when session recording is gated + // on (main/index.ts). The dev-debug IPC needs it for the Attach-Recording- + // Note handlers (plan §7b). + sessionRecorders: SessionRecorderManager | null worktreeActivityIndex: WorktreeActivityIndex orchestrationBridge: OrchestrationBridge aiWorkspaceRegistry: AiWorkspaceRegistry @@ -74,7 +79,7 @@ export function registerAllIpc(deps: IpcDeps): void { registerSetupIpc() registerDictationIpc({ dictationDebugJournals: deps.dictationDebugJournals }) registerPasteDebugIpc({ pasteDebugJournals: deps.pasteDebugJournals }) - registerDevDebugIpc() + registerDevDebugIpc(deps.sessionRecorders) registerOrchestrationIpc(deps.orchestrationBridge) registerAiWorkspaceIpc(deps.aiWorkspaceRegistry) registerRenderedContentIpc() diff --git a/src/main/recording/SessionRecorder.ts b/src/main/recording/SessionRecorder.ts new file mode 100644 index 000000000..376dd9239 --- /dev/null +++ b/src/main/recording/SessionRecorder.ts @@ -0,0 +1,277 @@ +import { appendFile, mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { SESSION_RECORDING_DIR } from '@main/storage/paths.js' + +// One SessionRecorder = ONE self-contained recording folder. +// +// docs/rendering/session-recording-plan-2026-07.md §2-§4. Each recording +// gets its own directory `session-recordings//` holding: +// meta.json — provider/session identity + start/end/counters +// events.jsonl — the append-only event stream (one line per event) +// The per-folder shape is a deliberate cleanup affordance: `rm -rf` one +// folder removes exactly one recording, nothing else (the user's ask). +// +// The writer is cloned from GhostJournal (src/main/ghostJournal.ts) — one +// file, JSON+'\n', 100 ms batched drain, overlapping-drain guard, dir +// created lazily on first write — because that writer already absorbed the +// #388 OOM lessons. Added here on top of the ghost pattern, because a +// CONTINUOUS recorder re-opens the exact #388 crash vector that a +// per-turn ghost log did not: +// - BACKPRESSURE: a hard queue cap with drop-oldest + a counter, so a +// runaway stream can never unbounded-buffer in memory (the thing that +// took the heap 39MB→2554MB in incident #388). +// - SIZE CAP + TOMBSTONE: once the events file passes a byte cap the +// recorder stops appending events and writes a single tombstone line, +// matching feedDebugLog's cap-not-rotate policy. +// Both are non-negotiable per the plan; retention (a budgeted +// debugRetention bucket) is the sibling slice. + +// 100 ms mirrors GhostJournal / upstream Claude FLUSH_INTERVAL_MS — one +// append per 100 ms per recording, not per event. +const FLUSH_INTERVAL_MS = 100 +// Drop-oldest threshold. 2000 pending lines is the journal-plan rulebook's +// cap; a healthy recorder drains every 100 ms so the queue is near-empty, +// and hitting 2000 means the disk can't keep up — shed the oldest rather +// than grow the heap. +const MAX_QUEUE = 2000 +// Per-recording events-file byte cap. 128 MiB mirrors feedDebugLog's cap; +// past it we stop appending and tombstone (cap, never rotate). +const MAX_BYTES = 128 * 1024 * 1024 + +export type RecordingMeta = { + v: 1 + /** Discriminator so a bare meta.json is self-identifying (plan §3). */ + kind: 'session-recording' + /** Redaction state of THIS on-disk recording. Live local recordings are + * unredacted ('none'); the extraction script writes 'full-text-capped' / + * 'structure-only' into the derived fixture's meta (plan §3/§5). */ + redaction: 'none' + recordingId: string + sessionId: string + provider: string + providerSessionId: string | null + cwd: string | null + appVersion: string | null + startedAtWall: number + endedAtWall?: number + eventCount?: number + droppedCount?: number + capped?: boolean +} + +export class SessionRecorder { + private readonly dir: string + private readonly eventsPath: string + private readonly startMono: number + + private queue: string[] = [] + private timer: ReturnType | null = null + private draining = false + private ensuredDir = false + + private bytesWritten = 0 + private eventCount = 0 + private dropped = 0 + private capped = false + private closed = false + private tombstoned = false + + constructor( + private meta: RecordingMeta, + private readonly nowWall: () => number, + private readonly nowMono: () => number, + ) { + this.dir = join(SESSION_RECORDING_DIR, meta.recordingId) + this.eventsPath = join(this.dir, 'events.jsonl') + this.startMono = this.nowMono() + // Write meta.json eagerly so the recording FOLDER appears the instant + // recording starts (the user sees it exists), and so a crash before any + // event still leaves an identifiable recording. Fire-and-forget; the + // events path lazily re-ensures the dir anyway. + void this.writeMeta() + } + + /** + * Enqueue one event line. `t` is monotonic ms since recording start + * (drives replay ordering); `wall` is Date.now() (injected as the fold + * clock during replay so wall-clock-dependent fold behavior is + * deterministic — plan §3). Returns immediately; the disk write is up to + * 100 ms later. + */ + record(channel: string, payload: unknown): void { + if (this.closed || this.capped) return + const line = + JSON.stringify({ + t: Math.round(this.nowMono() - this.startMono), + wall: this.nowWall(), + ch: channel, + payload, + }) + '\n' + this.enqueue(line) + this.eventCount += 1 + } + + /** + * Attach-Recording-Note marker (plan §7b). `__note` is a synthetic + * channel outside the 9 real SessionFeed channels, so replay ignores it + * for pipeline input while the triage/extraction tooling reads it. Two + * phases: `reserved` (written instantly on invoke to pin the reaction + * tick) then `filled` (the typed text). The reserved line alone survives + * a crash and still flags the moment. + */ + note(note: { id: string; status: 'reserved' | 'filled'; text?: string }): void { + if (this.closed) return + const line = + JSON.stringify({ + t: Math.round(this.nowMono() - this.startMono), + wall: this.nowWall(), + ch: '__note', + note, + }) + '\n' + // Notes bypass the size cap — they are tiny and are the whole point of a + // long recording (you must be able to mark the bug even in a capped + // file). They still respect the queue backpressure. + this.enqueue(line) + } + + private enqueue(line: string): void { + this.queue.push(line) + if (this.queue.length > MAX_QUEUE) { + // Drop-oldest: shed the front so the heap can't grow unbounded when + // the disk can't keep up. Count it (→ meta.json droppedCount) AND drop + // a __truncated marker at the gap position so a replay sees exactly + // where the stream has a hole (plan §4 — the counter must be visible in + // the file, not only in meta). + const shed = this.queue.length - MAX_QUEUE + this.queue.splice(0, shed) + this.dropped += shed + this.queue.unshift( + JSON.stringify({ + t: Math.round(this.nowMono() - this.startMono), + wall: this.nowWall(), + ch: '__truncated', + reason: 'queue-drop', + dropped: shed, + }) + '\n', + ) + } + this.scheduleDrain() + } + + private scheduleDrain(): void { + if (this.timer) return + this.timer = setTimeout(() => { + this.timer = null + void this.drain() + }, FLUSH_INTERVAL_MS) + this.timer.unref?.() + } + + private async drain(): Promise { + if (this.draining || this.queue.length === 0) return + this.draining = true + try { + const batch = this.queue.splice(0).join('') + if (this.bytesWritten + batch.length > MAX_BYTES && !this.tombstoned) { + // Cross the cap: write a final tombstone once and stop appending + // events. Cap, never rotate (feedDebugLog policy). + this.tombstoned = true + this.capped = true + const mark = + JSON.stringify({ + t: Math.round(this.nowMono() - this.startMono), + wall: this.nowWall(), + ch: '__truncated', + reason: 'size-cap', + bytes: this.bytesWritten, + }) + '\n' + await this.appendRaw(mark) + } else if (!this.capped) { + await this.appendRaw(batch) + this.bytesWritten += batch.length + } + } finally { + this.draining = false + } + if (this.queue.length > 0 && !this.timer && !this.capped) this.scheduleDrain() + } + + private async appendRaw(content: string): Promise { + try { + await appendFile(this.eventsPath, content, { mode: 0o600 }) + } catch { + if (!this.ensuredDir) { + await mkdir(this.dir, { recursive: true, mode: 0o700 }) + this.ensuredDir = true + await appendFile(this.eventsPath, content, { mode: 0o600 }) + } else { + throw new Error(`session recording append failed for ${this.eventsPath}`) + } + } + } + + private async writeMeta(): Promise { + try { + await mkdir(this.dir, { recursive: true, mode: 0o700 }) + this.ensuredDir = true + } catch { + /* re-ensured lazily on first event append */ + } + try { + await writeFile(join(this.dir, 'meta.json'), JSON.stringify(this.meta, null, 1), { + mode: 0o600, + }) + } catch { + /* non-fatal: the events stream is the source of truth; meta is a convenience */ + } + } + + /** + * Fill identity fields from a later event (plan §3/§2: providerSessionId / + * cwd / provider belong in the header, but a command-started recording + * begins before session:started is seen). Only fills EMPTY/unknown fields — + * never overwrites a known value — and rewrites meta.json when something + * changed. Cheap and idempotent. + */ + refreshIdentity(id: { provider?: string; providerSessionId?: string; cwd?: string }): void { + let changed = false + if (id.provider && (this.meta.provider === 'unknown' || !this.meta.provider)) { + this.meta = { ...this.meta, provider: id.provider } + changed = true + } + if (id.providerSessionId && !this.meta.providerSessionId) { + this.meta = { ...this.meta, providerSessionId: id.providerSessionId } + changed = true + } + if (id.cwd && !this.meta.cwd) { + this.meta = { ...this.meta, cwd: id.cwd } + changed = true + } + if (changed) void this.writeMeta() + } + + /** Force a drain and wait — used on shutdown and by tests. */ + async flush(): Promise { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + await this.drain() + } + + /** Finalize: flush, then rewrite meta.json with end stats. Idempotent. */ + async close(): Promise { + if (this.closed) return + this.closed = true + await this.flush() + this.meta = { + ...this.meta, + endedAtWall: this.nowWall(), + eventCount: this.eventCount, + droppedCount: this.dropped, + capped: this.capped, + } + await this.writeMeta() + } +} diff --git a/src/main/recording/SessionRecorderManager.test.ts b/src/main/recording/SessionRecorderManager.test.ts new file mode 100644 index 000000000..2b3af6a6b --- /dev/null +++ b/src/main/recording/SessionRecorderManager.test.ts @@ -0,0 +1,174 @@ +import { mkdtempSync, readFileSync, readdirSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// SESSION_RECORDING_DIR is resolved from STATE_DIR at import time, so point +// STATE_DIR's root at a temp dir BEFORE importing the recorder. The path +// module reads homedir()/.config/; we can't easily reroute that, so +// instead we mock the paths module. +const TMP = mkdtempSync(join(tmpdir(), 'rec-test-')) + +vi.mock('@main/storage/paths.js', () => ({ + SESSION_RECORDING_DIR: TMP, +})) +vi.mock('electron', () => ({ app: { getVersion: () => '0.0.0-test' } })) + +const { SessionRecorderManager } = await import('./SessionRecorderManager.js') + +// Deterministic clocks so `t`/`wall` are assertable and monotonic advances +// on demand. +let mono = 0 +let wall = 1_700_000_000_000 +const nowMono = () => mono +const nowWall = () => wall + +function mgr(): InstanceType { + return new SessionRecorderManager(nowWall, nowMono) +} + +// TMP is shared across tests, so select the recording folder by its +// sessionId suffix (recordingId is `-`) instead of "last +// folder", which would race with other tests' recordings. Polls because +// meta.json is written eagerly-but-async on recorder construction (the +// folder appears "very soon", not synchronously with the first event). +async function readRecordingDir(sessionId: string): Promise { + for (let i = 0; i < 50; i++) { + const dirs = readdirSync(TMP).filter( + d => d.endsWith(`-${sessionId}`) && existsSync(join(TMP, d, 'meta.json')), + ) + if (dirs.length > 0) return join(TMP, dirs[dirs.length - 1]) + await new Promise(r => setTimeout(r, 10)) + } + throw new Error(`no recording folder for ${sessionId}`) +} + +beforeEach(() => { + mono = 0 + wall = 1_700_000_000_000 +}) +afterEach(() => { + vi.clearAllMocks() +}) + +describe('SessionRecorderManager', () => { + it('records only allowlisted channels, keyed by sessionId, into a per-recording folder', async () => { + const m = mgr() + m.startRecording('s1') // command-driven: record only after explicit start + // Non-allowlisted channels and PTY channels are ignored. + m.observe('lsp:diagnostics', [{ sessionId: 's1' }]) + m.observe('session:terminal-data', [{ sessionId: 's1', data: 'raw pty' }]) + // Allowlisted. + mono = 10 + m.observe('session:semantic-event', [{ sessionId: 's1', event: { kind: 'turn_started' } }]) + mono = 25 + m.observe('session:jsonl-entries', [{ sessionId: 's1', entries: [{ uuid: 'u1' }], file: '/x.jsonl' }]) + await m.stop('s1') + + const dir = await readRecordingDir('s1') + const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8')) + expect(meta.sessionId).toBe('s1') + expect(meta.eventCount).toBe(2) // the two allowlisted, not the pty/lsp + expect(meta.endedAtWall).toBe(wall) + + const lines = readFileSync(join(dir, 'events.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l)) + expect(lines.map(l => l.ch)).toEqual(['session:semantic-event', 'session:jsonl-entries']) + // t is relative to recording START (explicit startRecording at mono=0); + // the two events at mono 10 and 25 are t=10 and t=25. + expect(lines[0].t).toBe(10) + expect(lines[1].t).toBe(25) + expect(lines[0].payload.event.kind).toBe('turn_started') + }) + + it('ignores payloads without a sessionId', async () => { + const m = mgr() + m.observe('session:screen', [{ screen: 'no id here' }]) + expect(m.isRecording('anything')).toBe(false) + }) + + it('finalizes a recording on session:exit', async () => { + const m = mgr() + m.startRecording('s2', { kind: 'claude' }) // command passes provider hint + m.observe('session:started', [{ sessionId: 's2', kind: 'claude' }]) + expect(m.isRecording('s2')).toBe(true) + m.observe('session:exit', [{ sessionId: 's2', code: 0 }]) + // session:exit synchronously drops the session from the active map + // (stop() deletes BEFORE awaiting close()); the recording folder + its + // eagerly-written meta.json already exist from the first event. End + // stats (endedAtWall) are covered by the explicit `await m.stop` test — + // asserting them here would race the void'd async close under load. + expect(m.isRecording('s2')).toBe(false) + const dir = await readRecordingDir('s2') + const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8')) + expect(meta.provider).toBe('claude') + }) + + it('captures reserve-then-fill notes as __note lines', async () => { + const m = mgr() + m.startRecording('s3') + m.observe('session:started', [{ sessionId: 's3', kind: 'codex' }]) + mono = 5 + const noteId = m.reserveNote('s3') + expect(noteId).not.toBeNull() + mono = 40 + m.fillNote('s3', noteId!, 'reads vanished right here') + await m.stop('s3') + + const dir = await readRecordingDir('s3') + const notes = readFileSync(join(dir, 'events.jsonl'), 'utf8') + .trim() + .split('\n') + .map(l => JSON.parse(l)) + .filter(l => l.ch === '__note') + expect(notes.map(n => n.note.status)).toEqual(['reserved', 'filled']) + expect(notes[0].note.id).toBe(noteId) + expect(notes[1].note.text).toBe('reads vanished right here') + // The reserved marker was written at the reaction tick (t=5), not the + // fill tick (t=40) — the whole point of reserve-first. + expect(notes[0].t).toBe(5) + }) + + it('reserveNote returns null when no recording is active for the session', () => { + const m = mgr() + expect(m.reserveNote('never-started')).toBeNull() + }) + + it('flushAll finalizes every open recording', async () => { + const m = mgr() + m.startRecording('a') + m.startRecording('b') + await m.flushAll() + expect(m.isRecording('a')).toBe(false) + expect(m.isRecording('b')).toBe(false) + }) + + it('does NOT record until a session is explicitly started (default: autoRecord off)', async () => { + const m = mgr() // autoRecord defaults false + m.observe('session:started', [{ sessionId: 'nope', kind: 'claude' }]) + m.observe('session:semantic-event', [{ sessionId: 'nope', event: {} }]) + // No recorder was created, nothing on disk for this session. + expect(m.isRecording('nope')).toBe(false) + }) + + it('records a session once startRecording is called, and stops on stopRecording', async () => { + const m = mgr() + m.startRecording('go') + expect(m.isRecording('go')).toBe(true) + mono = 10 + m.observe('session:semantic-event', [{ sessionId: 'go', event: { kind: 'turn_started' } }]) + await m.stopRecording('go') + expect(m.isRecording('go')).toBe(false) + const dir = await readRecordingDir('go') + const lines = readFileSync(join(dir, 'events.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l)) + expect(lines.some(l => l.ch === 'session:semantic-event')).toBe(true) + }) + + it('auto-records every session ONLY when autoRecord is on (the power flag)', async () => { + const m = new SessionRecorderManager(nowWall, nowMono, true) + m.observe('session:started', [{ sessionId: 'auto', kind: 'codex' }]) + expect(m.isRecording('auto')).toBe(true) + await m.stopRecording('auto') + }) + +}) diff --git a/src/main/recording/SessionRecorderManager.ts b/src/main/recording/SessionRecorderManager.ts new file mode 100644 index 000000000..5a31e7ea1 --- /dev/null +++ b/src/main/recording/SessionRecorderManager.ts @@ -0,0 +1,171 @@ +import { app } from 'electron' + +import { SessionRecorder } from '@main/recording/SessionRecorder.js' + +// Owns one SessionRecorder per live session and routes the outbound IPC +// stream to it. plan §2. +// +// TAP: registered as the outbound observer on sendToMainWindow (the ONE +// funnel every rendering event crosses — mainWindow.ts). We record the +// exact payloads the renderer receives, including the BULK +// session:jsonl-entries the coalescer sends (a ledger-input recorder tapped +// below the fold would miss fold-layer bugs like the #469 queue desync — +// the whole reason the tap is here and not at the ledger seam). +// +// ALLOWLIST, not a prefix: the `session:` prefix also carries +// terminal-data / agent-pty-data (raw PTY, not feed events). We record only +// the 9 channels that map 1:1 to the SessionFeed methods the renderer's +// rendering pipeline consumes. +const RECORDED_CHANNELS: ReadonlySet = new Set([ + 'session:started', + 'session:screen', + 'session:jsonl-entries', + 'session:jsonl-error', + 'session:process-state', + 'session:conditions', + 'session:semantic-event', + 'session:sub-agents', + 'session:exit', +]) + +function extractSessionId(payload: unknown): string | null { + if (typeof payload !== 'object' || payload === null) return null + const id = (payload as { sessionId?: unknown }).sessionId + return typeof id === 'string' && id.length > 0 ? id : null +} + +/** Best-effort provider hint from a payload (only session:started reliably + * carries it; filled 'unknown' otherwise — the events themselves carry + * everything replay needs, meta is a convenience). */ +function extractProvider(payload: unknown): string | null { + if (typeof payload !== 'object' || payload === null) return null + const p = payload as { kind?: unknown; provider?: unknown; sessionKind?: unknown } + for (const v of [p.kind, p.provider, p.sessionKind]) { + if (typeof v === 'string' && v.length > 0) return v + } + return null +} + +export class SessionRecorderManager { + private readonly recorders = new Map() + + constructor( + // Injected so tests can drive deterministic time and the recorder stays + // wall-clock-honest (Date.now for `wall`, a monotonic source for `t`). + private readonly nowWall: () => number = () => Date.now(), + private readonly nowMono: () => number = () => performance.now(), + // AUTO-RECORD is opt-in and OFF by default. Plan §7: recording is + // command-driven per session; a user does NOT want every session of a + // long day silently written to disk (that is how you bury yourself in + // tens of GB). Only when AGENT_CODE_SESSION_RECORD=1 does the manager + // auto-start every session — the unattended-soak power path, never the + // default. Without it, NOTHING records until `startRecording` is called + // by the Start Recording command. + private readonly autoRecord: boolean = false, + ) {} + + /** The outbound observer registered on sendToMainWindow. The critical + * gate: an event is only written if its session has been EXPLICITLY + * started (or auto-record is on). A session the user never chose to + * record produces nothing on disk — no burial. */ + observe = (channel: string, args: readonly unknown[]): void => { + if (!RECORDED_CHANNELS.has(channel)) return + const payload = args[0] + const sessionId = extractSessionId(payload) + if (!sessionId) return + + let recorder = this.recorders.get(sessionId) + if (!recorder) { + // Not recording this session. Auto-start it ONLY under the opt-in + // power flag; otherwise ignore the event entirely (the default — + // recording waits for the Start Recording command). + if (!this.autoRecord) return + recorder = this.startRecording(sessionId, payload) + } + recorder.record(channel, payload) + // session:started carries the header identity (kind→provider, projectDir + // →cwd) a command-started recording didn't have yet — fill it in (plan + // §2/§3). providerSessionId is provisional (upgraded later) so it stays + // best-effort/null. + if (channel === 'session:started') { + const p = payload as { kind?: string; projectDir?: string } + recorder.refreshIdentity({ provider: p.kind, cwd: p.projectDir }) + } + // session:exit finalizes the recording (end stats in meta.json). + if (channel === 'session:exit') void this.stop(sessionId) + } + + /** + * Begin recording ONE session on demand (the Start Recording command, + * plan §7). Idempotent — starting an already-recording session returns + * the live recorder. `firstPayload` is optional provider-hint metadata + * when the start is triggered by an incoming event (auto-record). + */ + startRecording(sessionId: string, firstPayload?: unknown): SessionRecorder { + let recorder = this.recorders.get(sessionId) + if (!recorder) { + const startedAtWall = this.nowWall() + recorder = new SessionRecorder( + { + v: 1, + kind: 'session-recording', + redaction: 'none', + // recordingId sorts chronologically and is filesystem-safe; the + // sessionId suffix keeps concurrent tiled sessions distinct and + // greppable. Colons stripped for Windows/paths. + recordingId: `${new Date(startedAtWall).toISOString().replace(/[:.]/g, '-')}-${sessionId}`, + sessionId, + provider: extractProvider(firstPayload) ?? 'unknown', + providerSessionId: null, + cwd: null, + appVersion: app?.getVersion?.() ?? null, + startedAtWall, + }, + this.nowWall, + this.nowMono, + ) + this.recorders.set(sessionId, recorder) + } + return recorder + } + + /** Attach-Recording-Note: reserve a marker instantly (plan §7b). Returns + * the noteId, or null if no active recording for the session. */ + reserveNote(sessionId: string): string | null { + const recorder = this.recorders.get(sessionId) + if (!recorder) return null + const id = `n-${Math.round(this.nowMono())}` + recorder.note({ id, status: 'reserved' }) + return id + } + + /** Attach-Recording-Note: fill a previously reserved marker with text. */ + fillNote(sessionId: string, noteId: string, text: string): void { + this.recorders.get(sessionId)?.note({ id: noteId, status: 'filled', text }) + } + + isRecording(sessionId: string): boolean { + return this.recorders.has(sessionId) + } + + async stop(sessionId: string): Promise { + const recorder = this.recorders.get(sessionId) + if (!recorder) return + this.recorders.delete(sessionId) + await recorder.close() + } + + /** Public stop for the Stop Recording command (plan §7). Alias of stop; + * named to pair with startRecording at the call sites. */ + stopRecording(sessionId: string): Promise { + return this.stop(sessionId) + } + + /** Drain + finalize every recording. Called on before-quit (mirrors + * ghostJournals.flushAll). */ + async flushAll(): Promise { + const all = [...this.recorders.values()] + this.recorders.clear() + await Promise.all(all.map(r => r.close())) + } +} diff --git a/src/main/storage/debugRetention.test.ts b/src/main/storage/debugRetention.test.ts new file mode 100644 index 000000000..d2bd398f3 --- /dev/null +++ b/src/main/storage/debugRetention.test.ts @@ -0,0 +1,74 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { collectSessionRecordingDirs } from './debugRetention.js' + +// Folder-atomic session-recording retention (plan §4, #388/#467). +// +// The one property that MUST hold: retention treats a recording folder as a +// single deletable unit, never as a bag of files. If the collector ever +// emitted per-file artifacts, a prune pass could shed events.jsonl and orphan +// meta.json — a half-recording that no longer loads and no longer counts +// against the bucket. These tests pin the collection layer where that bug +// would live (kind:'dir', one artifact per folder), which is what routes the +// prune to removeArtifact's `rm -rf` branch downstream. + +let root: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'rec-retention-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function makeRecording(id: string, eventsBytes: number): void { + const dir = join(root, id) + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, 'meta.json'), + JSON.stringify({ v: 1, recordingId: id, sessionId: id, startedAtWall: Date.now() }), + ) + // A non-trivial events file so byte accounting is observable — the folder's + // reported size must include BOTH files, proving whole-folder accounting. + writeFileSync(join(dir, 'events.jsonl'), 'x'.repeat(eventsBytes)) +} + +describe('collectSessionRecordingDirs', () => { + it('emits exactly one dir-kind artifact per recording folder', async () => { + makeRecording('2026-07-07T00-00-00-000-s1', 1000) + makeRecording('2026-07-07T00-01-00-000-s2', 2000) + + const artifacts = await collectSessionRecordingDirs(root) + + expect(artifacts).toHaveLength(2) + // Every artifact is a directory: this is the whole-folder-atomic guarantee. + // removeArtifact(kind:'dir') is the only branch that does rm -rf, so a + // recording is deleted as one unit iff it arrives here as kind:'dir'. + for (const artifact of artifacts) { + expect(artifact.kind).toBe('dir') + expect(artifact.bucket).toBe('session-recordings') + } + }) + + it('accounts the whole folder (meta.json + events.jsonl), not a single file', async () => { + makeRecording('2026-07-07T00-00-00-000-s1', 5000) + + const [artifact] = await collectSessionRecordingDirs(root) + + // The events file alone is 5000 bytes; meta.json adds more. If the + // collector were per-file (the bug we guard against) it could report just + // one file's size. Whole-folder accounting must exceed the events size. + expect(artifact.bytes).toBeGreaterThan(5000) + }) + + it('returns nothing when the recordings root does not exist', async () => { + // First app run, or recording never enabled: the dir is absent. Retention + // must treat that as an empty bucket, not throw and abort the whole sweep. + const artifacts = await collectSessionRecordingDirs(join(root, 'does-not-exist')) + expect(artifacts).toEqual([]) + }) +}) diff --git a/src/main/storage/debugRetention.ts b/src/main/storage/debugRetention.ts index 0eec179f0..10b69840d 100644 --- a/src/main/storage/debugRetention.ts +++ b/src/main/storage/debugRetention.ts @@ -10,6 +10,7 @@ import { MANUAL_DEBUG_BUNDLE_DIR, PERFORMANCE_RUNS_DIR, PROXY_EVENTS_DIR, + SESSION_RECORDING_DIR, STATE_DIR, } from '@main/storage/paths.js' import { ghostLogDir } from '@main/ghostJournal.js' @@ -37,6 +38,7 @@ export type DebugStorageBucket = | 'incidents' | 'heap-snapshots' | 'ghost-logs' + | 'session-recordings' type Artifact = { path: string @@ -295,12 +297,23 @@ function bucketCaps(totalBudget: number): Record { // directory outside the normal debug-retention accounting. Compaction // keeps live logs small; this cap handles abandoned session files. 'ghost-logs': Math.floor(totalBudget * 0.08), + // Session recordings are the biggest continuous producer of the bunch: a + // continuous per-session input-stream capture, up to 128 MiB PER + // recording, one recording per session per app run. Left uncapped this is + // exactly the "small files forever" / uncapped-growth directory that OOM'd + // the app on 2026-07-04 (#388) and that #467 flagged as a must-fix before + // the recorder sees heavy use. 12% gives room for a soak's worth of + // recordings while the global budget + active-grace guards keep a live + // recording safe. Not protected like manual bundles: a recording is a + // diagnostic you replay locally, not a user-authored artifact to preserve + // forever — once it ages out under pressure, dropping it whole is correct. + 'session-recordings': Math.floor(totalBudget * 0.12), } } async function collectArtifacts(): Promise { const manualLegacyBundlePaths = await loadManualLegacyBundlePaths() - const [feed, manualBundles, autosaveBundles, legacyBundles, proxy, performance, incidents, heapSnapshots, ghostLogs] = await Promise.all([ + const [feed, manualBundles, autosaveBundles, legacyBundles, proxy, performance, incidents, heapSnapshots, ghostLogs, sessionRecordings] = await Promise.all([ collectFiles(FEED_DEBUG_DIR, 'feed-debug', name => name.endsWith('.jsonl')), collectImmediateDirs(MANUAL_DEBUG_BUNDLE_DIR, 'debug-bundles-manual'), collectImmediateDirs(AUTOSAVE_DEBUG_BUNDLE_DIR, 'debug-bundles-autosave'), @@ -310,6 +323,7 @@ async function collectArtifacts(): Promise { collectIncidentRunDirs(), collectFiles(HEAP_SNAPSHOT_DIR, 'heap-snapshots', name => name.endsWith('.heapsnapshot')), collectFiles(ghostLogDir(), 'ghost-logs', name => name.endsWith('.ghost.jsonl')), + collectSessionRecordingDirs(), ]) return [ ...feed, @@ -321,9 +335,46 @@ async function collectArtifacts(): Promise { ...incidents, ...heapSnapshots, ...ghostLogs, + ...sessionRecordings, ] } +// Collect each session-recording FOLDER as one dir-kind artifact. +// +// WHY a whole-folder dir artifact and NOT per-file (collectFiles): +// a recording is `session-recordings//` = { meta.json, +// events.jsonl }. It MUST be evicted atomically — deleting events.jsonl while +// keeping meta.json (or vice-versa) leaves a half-recording the replay harness +// can't load and that no longer counts toward any bucket. Because we emit ONE +// `kind:'dir'` Artifact per folder, removeArtifact() takes the `rm -rf` branch +// and the whole folder is the unit of deletion by construction. Using +// collectFiles here would be the bug this shape exists to prevent — it would +// let a prune pass shed events.jsonl and orphan meta.json. +// +// WHY order by folder mtime (inherited from dirStats via collectImmediateDirs) +// and NOT meta.json.startedAtWall: a CONTINUOUS recorder appends for the whole +// life of a session, so a live recording's folder mtime is "just now" — which +// is exactly what we want, because the active-grace guard in the prune passes +// keys on mtimeMs and must protect a recording that is still being written. +// Ordering by startedAtWall would make a long-lived active recording look +// "old" and expose it to cap/budget eviction mid-write. Folder mtime ages +// closed recordings correctly (their last write == when they ended) while +// keeping live ones safe — the plan explicitly permits "the folder's mtime". +// +// `root` is a parameter (defaulting to the real dir) purely so the colocated +// test can point this at a temp dir without mocking the paths module — the +// prune framework's fs calls are otherwise hard-wired to STATE_DIR. +// +// Retention closes the #388/#467 disk-panic vector: a continuous stream +// re-opens the exact uncapped-growth wound that OOM'd the app on 2026-07-04. +// The per-recording 128 MiB cap bounds one file; this bucket bounds the whole +// directory so recordings can't accrete forever across app runs. +export function collectSessionRecordingDirs( + root: string = SESSION_RECORDING_DIR, +): Promise { + return collectImmediateDirs(root, 'session-recordings') +} + async function collectFiles( dir: string, bucket: DebugStorageBucket, diff --git a/src/main/storage/paths.ts b/src/main/storage/paths.ts index 634615a36..321a42873 100644 --- a/src/main/storage/paths.ts +++ b/src/main/storage/paths.ts @@ -65,3 +65,14 @@ export const INCIDENT_RUNS_DIR = join(STATE_DIR, 'incidents', 'runs') // `.heapsnapshot` somewhere else, that writer is opting out of the disk budget // and should justify it in the diff. export const HEAP_SNAPSHOT_DIR = join(STATE_DIR, 'heap-snapshots') + +// Session recordings — continuous debug-gated capture of a session's +// rendering-pipeline input stream, replayable in the test suite (see +// docs/rendering/session-recording-plan-2026-07.md, issue #467). Written +// ONLY when AGENT_CODE_DEV_DEBUG=1 AND AGENT_CODE_SESSION_RECORD=1. Each +// recording is a self-contained folder (`/` holding meta.json +// + events.jsonl) so a single recording can be deleted wholesale with one +// `rm -rf` — the per-folder shape is a deliberate cleanup affordance. Like +// every other debug root here it must register with debugRetention as a +// budgeted bucket (a continuous recorder re-opens the #388 OOM/disk vector). +export const SESSION_RECORDING_DIR = join(STATE_DIR, 'session-recordings') diff --git a/src/main/subagents/SubAgentWatcher.ts b/src/main/subagents/SubAgentWatcher.ts index 8ed730071..d3bcc701e 100644 --- a/src/main/subagents/SubAgentWatcher.ts +++ b/src/main/subagents/SubAgentWatcher.ts @@ -5,6 +5,7 @@ import { accumulateSubAgentEntry, buildSubAgentStateFromAccumulator, createAccumulator, + SUBAGENT_PRUNE_AFTER_MS, type SubAgentAccumulator, type SubAgentMeta, } from './subagentState.js' @@ -202,7 +203,7 @@ export class SubAgentWatcher { // accumulator → the same "header only" state the old empty-array path gave. const acc = this.accByAgent.get(agentId) ?? createAccumulator() const { done, error } = this.parentResult(toolUseId) - out[toolUseId] = buildSubAgentStateFromAccumulator( + const state = buildSubAgentStateFromAccumulator( acc, toolUseId, agentId, @@ -210,6 +211,25 @@ export class SubAgentWatcher { done, error, ) + // #341 pruning: terminal for longer than the prune window ⇒ the + // parent transcript owns the durable truth (tool_result committed); + // drop the fold state so resumed/long-lived sessions stop + // accumulating every child ever spawned (metaByAgent was previously + // never pruned — 73 cards, 57 stuck, in the 06-21 bundle). Terminal + // only — stale children stay tracked for late revival. + const idleMs = state.lastActivityAt !== null ? Date.now() - state.lastActivityAt : null + if ( + (state.status === 'done' || state.status === 'error') && + idleMs !== null && + idleMs > SUBAGENT_PRUNE_AFTER_MS + ) { + this.metaByAgent.delete(agentId) + this.accByAgent.delete(agentId) + this.offsets.delete(agentId) + this.partialByAgent.delete(agentId) + continue + } + out[toolUseId] = state } this.onChange(out) } diff --git a/src/main/subagents/codexSubagentState.test.ts b/src/main/subagents/codexSubagentState.test.ts index 873a1c9cd..846932fc0 100644 --- a/src/main/subagents/codexSubagentState.test.ts +++ b/src/main/subagents/codexSubagentState.test.ts @@ -1,12 +1,17 @@ import { describe, expect, it } from 'vitest' import { - buildCodexSubAgentState, + accumulateCodexSubAgentEntry, + buildCodexSubAgentStateFromAccumulator, + createCodexAccumulator, extractCodexSpawnCall, extractCodexSpawnOutput, extractCodexSubagentNotification, textFromCodexOutput, + extractCodexWaitStatuses, + isCodexWaitAgentCall, } from './codexSubagentState' +import type { JsonlEntry } from '@preload/api/types.js' describe('Codex subagent state', () => { it('extracts the parent spawn call and output join keys', () => { @@ -128,7 +133,59 @@ describe('Codex subagent state', () => { }) it('builds Claude-compatible SubAgentState from a Codex child rollout', () => { - const state = buildCodexSubAgentState({ + // Ported from the deleted buildCodexSubAgentState (entries-array) oracle to + // the live accumulator path: fold each rollout line exactly the way + // CodexSubAgentTracker.readAppendedChild does, then project. The former + // array builder was a parallel derivation nothing but this test used, so it + // was removed (see the header comment mirroring the claude twin's decision). + const childEntries: JsonlEntry[] = [ + { + type: 'session_meta', + timestamp: '2026-06-18T11:09:32.000Z', + payload: { + type: 'session_meta', + id: 'child-thread', + source: { + subagent: { + thread_spawn: { + parent_thread_id: 'parent-thread', + agent_nickname: 'Cicero', + agent_role: 'explorer', + }, + }, + }, + }, + }, + { + type: 'response_item', + timestamp: '2026-06-18T11:09:35.000Z', + payload: { + type: 'function_call', + name: 'exec_command', + call_id: 'call_exec', + arguments: JSON.stringify({ command: 'rg subagent src' }), + }, + }, + { + type: 'response_item', + timestamp: '2026-06-18T11:09:36.000Z', + payload: { + type: 'function_call_output', + call_id: 'call_exec', + output: 'src/main/subagents/index.ts', + }, + }, + { + type: 'event_msg', + timestamp: '2026-06-18T11:09:40.000Z', + payload: { + type: 'task_complete', + }, + }, + ] + const acc = createCodexAccumulator() + for (const entry of childEntries) accumulateCodexSubAgentEntry(acc, entry) + const state = buildCodexSubAgentStateFromAccumulator({ toolUseId: 'call_spawn', agentId: 'child-thread', spawn: { @@ -138,51 +195,11 @@ describe('Codex subagent state', () => { }, output: { callId: 'call_spawn', agentId: 'child-thread', nickname: 'Cicero' }, notification: { agentId: 'child-thread', status: 'completed' }, - childEntries: [ - { - type: 'session_meta', - timestamp: '2026-06-18T11:09:32.000Z', - payload: { - type: 'session_meta', - id: 'child-thread', - source: { - subagent: { - thread_spawn: { - parent_thread_id: 'parent-thread', - agent_nickname: 'Cicero', - agent_role: 'explorer', - }, - }, - }, - }, - }, - { - type: 'response_item', - timestamp: '2026-06-18T11:09:35.000Z', - payload: { - type: 'function_call', - name: 'exec_command', - call_id: 'call_exec', - arguments: JSON.stringify({ command: 'rg subagent src' }), - }, - }, - { - type: 'response_item', - timestamp: '2026-06-18T11:09:36.000Z', - payload: { - type: 'function_call_output', - call_id: 'call_exec', - output: 'src/main/subagents/index.ts', - }, - }, - { - type: 'event_msg', - timestamp: '2026-06-18T11:09:40.000Z', - payload: { - type: 'task_complete', - }, - }, - ], + acc, + // Pin nowMs so the staleness branch is deterministic; taskComplete already + // forces 'done' here, but an explicit clock keeps the test independent of + // wall time relative to the (fixed) rollout timestamps. + nowMs: Date.parse('2026-06-18T11:09:41.000Z'), }) expect(state).toMatchObject({ @@ -206,3 +223,43 @@ describe('Codex subagent state', () => { expect(state.lastActivityAt).toBe(Date.parse('2026-06-18T11:09:40.000Z')) }) }) + +describe('#341 lifecycle: wait_agent terminal inference + staleness', () => { + it('parses the wait_agent status output shape (verified against real rollouts)', () => { + const waitCall = { + timestamp: '2026-06-18T11:14:00.000Z', + type: 'response_item', + payload: { type: 'function_call', name: 'wait_agent', call_id: 'call_w1', arguments: '{}' }, + } + expect(isCodexWaitAgentCall(waitCall)).toBe('call_w1') + const output = { + timestamp: '2026-06-18T11:14:37.000Z', + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: 'call_w1', + output: JSON.stringify({ + status: { + 'agent-a': { completed: 'all done' }, + 'agent-b': { failed: 'crashed' }, + }, + }), + }, + } + const statuses = extractCodexWaitStatuses(output, new Set(['call_w1'])) + expect(statuses?.get('agent-a')).toBe('done') + expect(statuses?.get('agent-b')).toBe('error') + }) + + it('ignores outputs of non-wait calls', () => { + const output = { + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: 'call_other', + output: JSON.stringify({ status: { x: { completed: 'y' } } }), + }, + } + expect(extractCodexWaitStatuses(output, new Set(['call_w1']))).toBeNull() + }) +}) diff --git a/src/main/subagents/codexSubagentState.ts b/src/main/subagents/codexSubagentState.ts index 1bda63257..a6c7e3970 100644 --- a/src/main/subagents/codexSubagentState.ts +++ b/src/main/subagents/codexSubagentState.ts @@ -13,37 +13,29 @@ import { const CODEX_SUBAGENT_NOTIFICATION_OPEN = '' const CODEX_SUBAGENT_NOTIFICATION_CLOSE = '' -// Match SubAgentWatcher's cap name/value (PR #300) on purpose: this is the -// Codex twin of the Claude subagent retention leak that #300 fixed. Keeping the -// constant identical means a future reader who knows one watcher's bound knows -// the other's, and a single value to tune covers both providers. +// This module is the Codex twin of the Claude subagent retention fix +// (SubAgentWatcher, PR #300, root-caused in #288): a child rollout is folded +// incrementally into a tiny accumulator and its raw lines are dropped the +// instant they are consumed, so main-process heap never grows with transcript +// length. The dominator-tree analysis that pinned the Claude watcher at 263 MB +// / 88% of the heap (see SubAgentWatcher.ts) showed retained raw entries are +// the multi-MB liability — each pins its full tool-result / Read body — which +// is why nothing here holds a tail of raw entries. The dirty signal is the +// child rollout byte offset, matching the Claude watcher and avoiding full-file +// re-reads. // -// REVIEW NOTE (PR #317): the original #288 fix tail-sliced the RETAINED entries -// to this many and then fed that capped slice straight into the emit path. Both -// reviewers (Claude + Codex) caught that this corrupts every value derived from -// the HEAD of the rollout: `childMetaFromEntries` finds session_meta at the -// front (so role/nickname/parentThreadId and the startedAt anchor vanish once -// the head is dropped), `startedAt` jumps forward to the oldest *surviving* -// entry, and `turnCount` / tool-call counts undercount because earlier turns are -// gone. The fix below SPLITS the responsibility: the emitted tool-call timeline -// is capped, while the SubAgentState fields that depend on the HEAD of the file -// are derived from a running accumulator that has seen every complete line. -// -// #288 ROOT-CAUSE FOLLOW-UP: PR #317 also retained a bounded 500-entry TAIL of -// raw rollout entries in `childEntriesByAgentId`, justified ONLY as a buffer for -// a "future live mini-feed" that nothing ever read. The dominator-tree analysis -// that pinned the Claude SubAgentWatcher as 263 MB / 88% of the heap (see -// SubAgentWatcher.ts) showed retained raw entries are exactly the multi-MB -// liability — each entry pins its full tool-result/Read body. Since that tail -// was write-only dead weight here, the cleanest fix is to DROP it entirely -// rather than add per-field truncation to bytes nothing displays. The derived -// SubAgentState accumulator already carries everything emit() ships, so -// removing the tail is invisible. If a mini-feed is ever built, it should -// re-derive small previews from the on-disk rollout (the durable source), not -// pin raw entries in main-process heap. The former -// MAX_RETAINED_ENTRIES_PER_AGENT constant is gone with the tail it bounded; the -// dirty signal is now the child rollout byte offset, matching the Claude watcher -// and avoiding full-file re-reads. +// INVARIANT (#288 / #317 root cause, stated forward): every head-of-file +// SubAgentState field — role / nickname / parentThreadId, the startedAt anchor, +// turnCount, and the tool-call counts — MUST derive from the running +// accumulator that has seen every complete line, NOT from a capped tail slice. +// Deriving any head-derived value from the slice corrupts it: session_meta +// lives at the FRONT of the rollout (so role/nickname/parentThreadId and the +// startedAt anchor vanish once the head is dropped), startedAt jumps forward to +// the oldest surviving entry, and turn/tool counts undercount because earlier +// turns are gone. The split below honors this — the emitted tool-call timeline +// is capped for DISPLAY, while every head-derived field reads from the +// accumulator. A prior fix tail-sliced retained entries and fed that slice +// straight into emit, which is exactly what this invariant forbids. // Codex child sessions do not use Claude's `/subagents/agent-*.jsonl` // layout. They are normal rollout files whose session_meta.source says they @@ -217,6 +209,55 @@ export function extractCodexSpawnOutput(entry: JsonlEntry): SpawnOutput | null { } } +/** Parent `wait_agent` function_call tracking: outputs carry the fan-in + * truth `{"status":{"":{"completed"|"failed"|"error"| + * "interrupted": }}}` (shape verified against on-disk + * rollouts 2026-06-18). Codex has NO subagent notification on this path — + * for MCP-spawned children this output is the ONLY terminal signal the + * parent ever records (#341's stuck-running root cause). */ +export function isCodexWaitAgentCall(entry: JsonlEntry): string | null { + const payload = asRecord(entry.payload) + if (!payload || entry.type !== 'response_item') return null + if (payload.type !== 'function_call' && payload.type !== 'custom_tool_call') return null + const name = stringField(payload, 'name') + if (name !== 'wait_agent' && name !== 'wait_agents') return null + return stringField(payload, 'call_id') +} + +export function extractCodexWaitStatuses( + entry: JsonlEntry, + waitCallIds: ReadonlySet, +): Map | null { + const payload = asRecord(entry.payload) + if (!payload || entry.type !== 'response_item') return null + if (payload.type !== 'function_call_output' && payload.type !== 'custom_tool_call_output') { + return null + } + const callId = stringField(payload, 'call_id') + if (!callId || !waitCallIds.has(callId)) return null + const text = textFromCodexOutput(payload.output) ?? (typeof payload.output === 'string' ? payload.output : null) + if (!text) return null + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + return null + } + const statusMap = asRecord(asRecord(parsed)?.status) + if (!statusMap) return null + const out = new Map() + for (const [agentId, v] of Object.entries(statusMap)) { + const state = asRecord(v) + if (!state) continue + const keys = Object.keys(state) + if (keys.includes('completed')) out.set(agentId, 'done') + else if (keys.some(k => k === 'failed' || k === 'error' || k === 'interrupted')) { + out.set(agentId, 'error') + } + } + return out.size > 0 ? out : null +} + export function extractCodexSubagentNotification(entry: JsonlEntry): Notification | null { if (entry.type !== 'response_item') return null const payload = asRecord(entry.payload) @@ -260,14 +301,6 @@ export function extractCodexChildMeta(entry: JsonlEntry): ChildMeta | null { } } -function childMetaFromEntries(entries: CodexRolloutEntry[]): ChildMeta | null { - for (const entry of entries) { - const meta = extractCodexChildMeta(entry) - if (meta) return meta - } - return null -} - function toolInputFromPayload(payload: Record): Record | null { if (typeof payload.arguments === 'string') { return parseJsonObject(payload.arguments) ?? { arguments: payload.arguments } @@ -278,131 +311,25 @@ function toolInputFromPayload(payload: Record): Record() - let currentActivity: string | null = null - - for (const entry of entries) { - const payload = asRecord(entry.payload) - if (!payload) continue - if ( - entry.type === 'response_item' && - (payload.type === 'function_call' || payload.type === 'custom_tool_call') && - typeof payload.call_id === 'string' - ) { - const name = stringField(payload, 'name') ?? 'tool' - calls.push({ - id: payload.call_id, - name, - headline: headlineFromInput( - toolInputFromPayload(payload), - CODEX_SUBAGENT_HEADLINE_KEYS, - '...', - ), - status: 'running', - }) - currentActivity = `running ${name}` - } else if ( - entry.type === 'response_item' && - (payload.type === 'function_call_output' || payload.type === 'custom_tool_call_output') && - typeof payload.call_id === 'string' - ) { - done.add(payload.call_id) - currentActivity = null - } else if (entry.type === 'response_item' && payload.type === 'web_search_call') { - const callId = stringField(payload, 'call_id') ?? stringField(payload, 'id') - const action = asRecord(payload.action) - calls.push({ - id: callId, - name: 'web_search', - headline: headlineFromInput(action, CODEX_SUBAGENT_HEADLINE_KEYS, '...'), - status: 'running', - }) - currentActivity = 'running web_search' - } else if (entry.type === 'event_msg' && payload.type === 'agent_message') { - const phase = stringField(payload, 'phase') - currentActivity = phase === 'final_answer' ? 'finalizing' : 'responding' - } - } - - for (const call of calls) { - call.status = call.id && done.has(call.id) ? 'done' : 'running' - } - - const { kept, dropped } = capToolCalls(calls) - return { - calls: kept.map(({ name, headline, status }) => ({ name, headline, status })), - dropped, - currentActivity, - } -} - -export function buildCodexSubAgentState(params: { - toolUseId: string - agentId: string - spawn: SpawnCall | null - output: SpawnOutput | null - notification: Notification | null - childEntries: CodexRolloutEntry[] -}): SubAgentState { - const childMeta = childMetaFromEntries(params.childEntries) - const timestamps = params.childEntries - .map(entry => (typeof entry.timestamp === 'string' ? tsToMs(entry.timestamp) : null)) - .filter((value): value is number => value !== null) - const startedAt = - tsToMs(childMeta?.timestamp) ?? - (timestamps.length > 0 ? Math.min(...timestamps) : null) - const lastActivityAt = timestamps.length > 0 ? Math.max(...timestamps) : startedAt - const taskComplete = params.childEntries.some(entry => { - const payload = asRecord(entry.payload) - return entry.type === 'event_msg' && payload?.type === 'task_complete' - }) - const status: SubAgentState['status'] = - params.notification?.status === 'failed' || params.notification?.status === 'error' - ? 'error' - : params.notification?.status === 'completed' || taskComplete - ? 'done' - : 'running' - const tools = buildToolCalls(params.childEntries) - const turnCount = params.childEntries.reduce((count, entry) => { - const payload = asRecord(entry.payload) - if (entry.type === 'event_msg' && payload?.type === 'agent_message') return count + 1 - if ( - entry.type === 'response_item' && - payload?.type === 'message' && - payload.role === 'assistant' - ) { - return count + 1 - } - return count - }, 0) - - return { - toolUseId: params.toolUseId, - agentId: params.agentId, - agentType: childMeta?.role ?? params.spawn?.agentType ?? 'agent', - description: - params.spawn?.description ?? - childMeta?.nickname ?? - params.output?.nickname ?? - '', - status, - startedAt, - lastActivityAt, - turnCount, - toolCalls: tools.calls, - droppedToolCalls: tools.dropped, - currentActivity: status === 'running' ? tools.currentActivity : null, - } -} - -function createCodexAccumulator(): CodexSubAgentAccumulator { +/** Mirrors SUBAGENT_STALE_AFTER_MS on the claude side (#341): a child + * with no rollout activity for 5 minutes while nominally running is + * presumed dead/hung — 'stale', never fabricated 'done'. */ +export const CODEX_SUBAGENT_STALE_AFTER_MS = 5 * 60_000 + +/** Terminal children older than this get pruned from the tracker (#341): + * their truth is durable in the parent rollout by then. 10 minutes keeps + * the completed card interactive well past reading time without letting + * resumed sessions accumulate every child ever spawned. */ +export const CODEX_SUBAGENT_PRUNE_AFTER_MS = 10 * 60_000 + +// Exported for the unit test, which exercises the accumulator exactly the way +// CodexSubAgentTracker.readAppendedChild does (fold each rollout line, then +// build). These three are the live emit path — the former entries-based +// builders (childMetaFromEntries/buildToolCalls/buildCodexSubAgentState) were +// a parallel oracle that nothing but the test used and were deleted, mirroring +// the claude twin's decision documented in subagentState.ts (dead parity +// oracles are removed, not kept in sync). +export function createCodexAccumulator(): CodexSubAgentAccumulator { return { childMeta: null, minTimestampMs: null, @@ -450,7 +377,7 @@ function resolveCodexToolCall( } } -function accumulateCodexSubAgentEntry( +export function accumulateCodexSubAgentEntry( acc: CodexSubAgentAccumulator, entry: CodexRolloutEntry, ): void { @@ -513,25 +440,36 @@ function accumulateCodexSubAgentEntry( } } -function buildCodexSubAgentStateFromAccumulator(params: { +export function buildCodexSubAgentStateFromAccumulator(params: { toolUseId: string agentId: string spawn: SpawnCall | null output: SpawnOutput | null notification: Notification | null + /** Terminal state from the parent's wait_agent output (#341) — for + * MCP-spawned codex children this is the only terminal signal. */ + waitTerminal?: 'done' | 'error' | null acc: CodexSubAgentAccumulator | null + nowMs?: number }): SubAgentState { const acc = params.acc ?? createCodexAccumulator() const childMeta = acc.childMeta const startedAt = tsToMs(childMeta?.timestamp) ?? acc.minTimestampMs + const nowMs = params.nowMs ?? Date.now() const status: SubAgentState['status'] = - params.notification?.status === 'failed' || params.notification?.status === 'error' + params.notification?.status === 'failed' || + params.notification?.status === 'error' || + params.waitTerminal === 'error' ? 'error' - : params.notification?.status === 'completed' || acc.taskComplete + : params.notification?.status === 'completed' || + acc.taskComplete || + params.waitTerminal === 'done' ? 'done' - : 'running' + : acc.maxTimestampMs !== null && nowMs - acc.maxTimestampMs > CODEX_SUBAGENT_STALE_AFTER_MS + ? 'stale' + : 'running' const { kept } = capToolCalls(acc.toolCalls) return { @@ -603,6 +541,8 @@ export class CodexSubAgentTracker { // mirror the Claude SubAgentWatcher lifetime: append-only bytes are folded // into a tiny accumulator, complete lines are dropped immediately, and emit() // derives from the accumulator plus the parent correlation maps. + private readonly waitCallIds = new Set() + private readonly waitTerminalByAgentId = new Map() private readonly childOffsetByAgentId = new Map() private readonly childPartialByAgentId = new Map() private readonly childAccByAgentId = new Map() @@ -630,6 +570,28 @@ export class CodexSubAgentTracker { // the accumulator remains the source of truth for child-derived fields. this.dirty = true } + const waitCallId = isCodexWaitAgentCall(entry) + if (waitCallId) this.waitCallIds.add(waitCallId) + const waitStatuses = extractCodexWaitStatuses(entry, this.waitCallIds) + if (waitStatuses) { + // The wait_agent output is the parent's fan-in truth (#341): for + // MCP-spawned children no notification ever arrives, so without this + // the card runs forever. done/error recorded per agent uuid. + for (const [agentId, terminal] of waitStatuses) { + this.waitTerminalByAgentId.set(agentId, terminal) + } + // Prune the consumed wait call id. A wait_agent call produces exactly ONE + // output, and extractCodexWaitStatuses only returns non-null once it has + // matched (and thus consumed) that output — so this id will never match a + // future entry again. Without this delete, waitCallIds grew unbounded for + // the tracker's whole lifetime (it was only ever cleared in stop()), the + // Codex twin of the retention leaks #300/#317 fixed elsewhere. The output + // entry's call_id IS the id we tracked at the wait call, so re-reading it + // here is the exact key to drop. + const consumedCallId = stringField(asRecord(entry.payload), 'call_id') + if (consumedCallId) this.waitCallIds.delete(consumedCallId) + this.dirty = true + } const notification = extractCodexSubagentNotification(entry) if (notification) { this.notificationsByAgentId.set(notification.agentId, notification) @@ -663,6 +625,8 @@ export class CodexSubAgentTracker { // session must release its memory; the durable source stays the on-disk // rollout if the tracker is ever re-created for the same parent. this.spawnsByCallId.clear() + this.waitCallIds.clear() + this.waitTerminalByAgentId.clear() this.outputsByCallId.clear() this.callIdByAgentId.clear() this.notificationsByAgentId.clear() @@ -766,15 +730,43 @@ export class CodexSubAgentTracker { private emit(): void { const out: Record = {} + const nowMs = Date.now() for (const [agentId, callId] of this.callIdByAgentId) { - out[callId] = buildCodexSubAgentStateFromAccumulator({ + const state = buildCodexSubAgentStateFromAccumulator({ toolUseId: callId, agentId, spawn: this.spawnsByCallId.get(callId) ?? null, output: this.outputsByCallId.get(callId) ?? null, notification: this.notificationsByAgentId.get(agentId) ?? null, + waitTerminal: this.waitTerminalByAgentId.get(agentId) ?? null, acc: this.childAccByAgentId.get(agentId) ?? null, + nowMs, }) + // #341 pruning: a child terminal for longer than the prune window + // has its durable truth in the parent rollout (wait output / + // notification) — the tracker's job is done. Dropping the fold + // state caps long-lived resumed sessions that used to accumulate + // dozens of dead cards; the row keeps rendering from committed + // rows. Terminal-only: stale children stay tracked so a late + // revival can still flip them back to running. + const idleMs = state.lastActivityAt !== null ? nowMs - state.lastActivityAt : null + if ( + (state.status === 'done' || state.status === 'error') && + idleMs !== null && + idleMs > CODEX_SUBAGENT_PRUNE_AFTER_MS + ) { + this.callIdByAgentId.delete(agentId) + this.spawnsByCallId.delete(callId) + this.outputsByCallId.delete(callId) + this.notificationsByAgentId.delete(agentId) + this.waitTerminalByAgentId.delete(agentId) + this.childPathByAgentId.delete(agentId) + this.childOffsetByAgentId.delete(agentId) + this.childPartialByAgentId.delete(agentId) + this.childAccByAgentId.delete(agentId) + continue + } + out[callId] = state } this.onChange(out) } diff --git a/src/main/subagents/subagentState.ts b/src/main/subagents/subagentState.ts index b9eb3703b..f4091e9af 100644 --- a/src/main/subagents/subagentState.ts +++ b/src/main/subagents/subagentState.ts @@ -235,6 +235,16 @@ export function accumulateSubAgentEntry( * parentResult), NOT stored in the accumulator — so a late parentResult flip * re-renders correctly the next time emit() builds from the live accumulator. */ +/** No transcript activity for this long while nominally running ⇒ 'stale'. + * 5 minutes: an order of magnitude past the longest legitimate quiet + * window observed in real transcripts (a 5000-line Read ≈ tens of + * seconds), far below the hours-long zombies in the #341 bundle. */ +export const SUBAGENT_STALE_AFTER_MS = 5 * 60_000 + +/** Terminal children older than this are pruned from the watcher (#341) — + * the parent transcript's committed tool_result owns the truth by then. */ +export const SUBAGENT_PRUNE_AFTER_MS = 10 * 60_000 + export function buildSubAgentStateFromAccumulator( acc: SubAgentAccumulator, toolUseId: string, @@ -242,6 +252,7 @@ export function buildSubAgentStateFromAccumulator( meta: SubAgentMeta, parentDone: boolean, parentError: boolean, + nowMs: number = Date.now(), ): SubAgentState { // The ring already holds at most SUBAGENT_RING_MAX (60) most-recent calls; the // renderer shows at most SUBAGENT_TOOL_CALLS_MAX (40). Slice the tail of the @@ -260,11 +271,17 @@ export function buildSubAgentStateFromAccumulator( // bounded display ring. max(0, …) guards the under-cap case. const droppedToolCalls = Math.max(0, acc.totalToolUses - SUBAGENT_TOOL_CALLS_MAX) + // #341 staleness: a running child whose transcript has gone quiet past + // the window is presumed dead/hung. Terminal signals always win — this + // only rescues the eternal-spinner case (57/73 stuck "running" in the + // 2026-06-21 bundle). nowMs is a parameter so tests don't fake timers. const status: SubAgentState['status'] = parentError ? 'error' : parentDone ? 'done' - : 'running' + : acc.lastActivityAt !== null && nowMs - acc.lastActivityAt > SUBAGENT_STALE_AFTER_MS + ? 'stale' + : 'running' return { toolUseId, diff --git a/src/main/window/mainWindow.ts b/src/main/window/mainWindow.ts index ac1463e49..f56bb1659 100644 --- a/src/main/window/mainWindow.ts +++ b/src/main/window/mainWindow.ts @@ -70,10 +70,31 @@ export function focusMainWindow(): void { * This is the ONE place the rest of main/ should reach into the * BrowserWindow; everything else goes through here. */ +// Optional passive observer of the outbound IPC stream. Session recording +// (src/main/recording/, plan §2) registers here to capture the exact +// payloads the renderer receives, WITHOUT coupling this window helper to +// the recorder — it just calls the hook if one is installed. Null (and +// therefore zero cost) unless recording is gated on. +type OutboundObserver = (channel: string, args: readonly unknown[]) => void +let outboundObserver: OutboundObserver | null = null + +export function setOutboundObserver(observer: OutboundObserver | null): void { + outboundObserver = observer +} + export function sendToMainWindow(channel: string, ...args: unknown[]): void { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(channel, ...args) } + // Observe AFTER the send so recording can never delay or break delivery. + // The observer must not throw; it is a try/caught diagnostic sink. + if (outboundObserver) { + try { + outboundObserver(channel, args) + } catch { + /* recording is a diagnostic; never let it break IPC */ + } + } } export function zoomMainWindow(direction: 'in' | 'out' | 'reset'): void { diff --git a/src/preload/api/devDebug.ts b/src/preload/api/devDebug.ts index 31a8112c1..e37088cdc 100644 --- a/src/preload/api/devDebug.ts +++ b/src/preload/api/devDebug.ts @@ -10,4 +10,26 @@ export const devDebugApi = { // module (#90). Newest first; `limit` caps how many submits we hydrate. readPasteEvents: (limit?: number): Promise => ipcRenderer.invoke('dev-debug:read-paste-events', limit), + + // Attach-Recording-Note (plan §7b). reserve is the "claim the tick" gesture: + // call it the instant the operator invokes the command, BEFORE showing the + // note input, so the marker's timestamp pins the reaction moment. Returns + // the noteId, or null when there is no active recording for the session (or + // recording isn't gated on) — the caller uses null to skip the input. + reserveRecordingNote: (sessionId: string): Promise => + ipcRenderer.invoke('record-session:reserve-note', sessionId), + // Fill the previously reserved marker with the typed text on submit. + fillRecordingNote: (sessionId: string, noteId: string, text: string): Promise => + ipcRenderer.invoke('record-session:fill-note', sessionId, noteId, text), + + // Start/stop recording ONE session on demand (plan §7 — the primary + // control; recording is command-driven, not auto). start returns true + // (recording), stop resolves false. isSessionRecording lets the command + // label itself Start vs Stop for the focused pane. + startSessionRecording: (sessionId: string, provider?: string): Promise => + ipcRenderer.invoke('record-session:start', sessionId, provider), + stopSessionRecording: (sessionId: string): Promise => + ipcRenderer.invoke('record-session:stop', sessionId), + isSessionRecording: (sessionId: string): Promise => + ipcRenderer.invoke('record-session:is-recording', sessionId), } diff --git a/src/preload/api/types.ts b/src/preload/api/types.ts index 983df00ad..2742ce239 100644 --- a/src/preload/api/types.ts +++ b/src/preload/api/types.ts @@ -47,6 +47,9 @@ export type { export type DevDebugConfig = { enabled: boolean + /** AGENT_CODE_SESSION_RECORD=1 (+ dev-debug) — session recording active. + * Mirrors src/main/ipc/devDebug.ts (the source of truth). */ + sessionRecordingEnabled: boolean } // Caffeinate payloads now live in @shared/types/caffeinate so main can own diff --git a/src/providers/codex/renderer/rows/CodexRows.tsx b/src/providers/codex/renderer/rows/CodexRows.tsx index f0a00b8f7..95c7de128 100644 --- a/src/providers/codex/renderer/rows/CodexRows.tsx +++ b/src/providers/codex/renderer/rows/CodexRows.tsx @@ -6,6 +6,9 @@ import { CodeRenderContext } from '@renderer/features/feed/context' import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' import { formatToolFilePath } from '@shared/paths/displayPath' import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' + +import { JsonResultSlab } from '@providers/shared/renderer/rows/JsonResultSlab' +import { tryExtractJson } from '@providers/shared/renderer/rows/jsonToolPresentation' import { asRecord } from '@shared/lib/asRecord' import { DiffSlab } from '@providers/shared/renderer/rows/DiffSlab' // WHY the import switch matters here: the local copy this replaced @@ -162,8 +165,12 @@ function headlineForTool(block: ToolUseBlock): string | null { } if (typeof input.command === 'string') return input.command - if (typeof input.description === 'string') return input.description + // Path BEFORE description (corpus bug, plan-json-tool-rows §1b): + // ai_workspace_attach_file showed its description gloss while hiding + // the actual file path. A path is an identifier; a description is + // commentary. if (typeof input.path === 'string') return input.path + if (typeof input.description === 'string') return input.description if (typeof input.arguments === 'string') return input.arguments.slice(0, 160) if (typeof input.raw === 'string' && block.name !== 'apply_patch') return input.raw.slice(0, 160) return null @@ -600,5 +607,14 @@ export const CodexToolResultRow = memo(function CodexToolResultRow({ if (!text && !isError) return null + // JSON-shaped fallthrough (wall-time wrapper / MCP envelope / plain + // JSON) gets the shared collapsed pretty rendering; anything else keeps + // the truncated text path (residue plan P1 — one result behavior across + // providers). + const parsedJson = tryExtractJson(text) + if (parsedJson !== null && typeof parsedJson === 'object') { + return + } + return }) diff --git a/src/providers/codex/renderer/rows/dispatch.tsx b/src/providers/codex/renderer/rows/dispatch.tsx index 328c4f269..96914202e 100644 --- a/src/providers/codex/renderer/rows/dispatch.tsx +++ b/src/providers/codex/renderer/rows/dispatch.tsx @@ -17,7 +17,13 @@ export function renderCodexToolUse(block: ToolUseBlock): ReactNode | undefined { if (block.name === 'apply_patch') return if (block.name === 'exec_command') return if (block.name === 'write_stdin') return - return + // Unknown names deliberately fall through to the SHARED fallback + // (JsonToolRow via Block.tsx) — the residue-plan P1 convergence. Codex + // used to claim everything with CodexToolRow, which is why its MCP / + // orchestration tools drifted into "name + one headline over a raw + // blob" while claude's fell to a different fallback with different + // gaps. One fallback, one behavior, all providers. + return undefined } export function renderCodexToolResult( diff --git a/src/providers/opencode/renderer/rows/OpencodeReadResult.tsx b/src/providers/opencode/renderer/rows/OpencodeReadResult.tsx new file mode 100644 index 000000000..8c7e3a1c3 --- /dev/null +++ b/src/providers/opencode/renderer/rows/OpencodeReadResult.tsx @@ -0,0 +1,62 @@ +import type { ReactNode } from 'react' + +import type { ToolResultBlock } from '@shared/types/transcript' +import { asRecord } from '@shared/lib/asRecord' + +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { CodeBlock } from '@renderer/lib/code/CodeBlock' + +/* ---------- opencode `read` result ---------- */ +// +// opencode's read tool returns a tagged text document — +// /abs/file.ts … \n1: line\n… +// — which the generic TruncatedOutputRow shows as literal tag soup (the +// 2026-07-06 "fuck ton of rendering to handle" bundle). Parse the two +// tags we need and present the content as a code slab keyed by the real +// path so language detection works. +// +// DELIBERATELY tolerant: any deviation from the expected shape returns +// undefined and the caller falls through to the generic row — a renderer +// that guesses wrong must degrade to verbatim text, never invent +// structure. (No committed fixture carries a full read result yet; shape +// sourced from the bundle's rendered HTML. Tighten with a fixture when +// one is captured.) + +function resultText(block: ToolResultBlock): string | null { + const content = (block as { content?: unknown }).content + if (typeof content === 'string') return content + if (Array.isArray(content)) { + const t = content.find(b => asRecord(b)?.type === 'text') + const text = asRecord(t)?.text + return typeof text === 'string' ? text : null + } + return null +} + +export function renderOpencodeReadResult(block: ToolResultBlock): ReactNode | undefined { + const text = resultText(block) + if (!text) return undefined + const path = /\s*([^<\n]+?)\s*<\/path>/.exec(text)?.[1] ?? null + const body = /\n?([\s\S]*?)\n?<\/content>/.exec(text)?.[1] ?? null + if (!path || body === null) return undefined + + // Strip the "N: " line-number gutter opencode prepends — CodeBlock has + // its own gutter and doubled numbers read as corrupted output. + const stripped = body + .split('\n') + .map(l => l.replace(/^\s*\d+:\s?/, '')) + .join('\n') + + return ( + +
    + + {path} + +
    + +
    +
    +
    + ) +} diff --git a/src/providers/opencode/renderer/rows/dispatch.tsx b/src/providers/opencode/renderer/rows/dispatch.tsx index de7a0c424..95e432b56 100644 --- a/src/providers/opencode/renderer/rows/dispatch.tsx +++ b/src/providers/opencode/renderer/rows/dispatch.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react' import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' import { TodoRow } from '@providers/claude/renderer/rows/ClaudeRows' +import { renderOpencodeReadResult } from '@providers/opencode/renderer/rows/OpencodeReadResult' // OpenCode committed/live tool rows. // @@ -31,10 +32,20 @@ export function renderOpencodeToolUse(block: ToolUseBlock): ReactNode | undefine } export function renderOpencodeToolResult( - _block: ToolResultBlock, + block: ToolResultBlock, + context: { sourceTool?: ToolUseBlock | null }, ): ReactNode | undefined { - // No opencode-specific result rows yet; generic ToolResultRow presents - // read/glob/bash results acceptably (the read result is a tagged text - // document the generic row shows verbatim). + const source = context.sourceTool?.name + // todowrite results echo the JSON checklist that TodoRow ALREADY + // rendered from the tool_use above — painting it again is the raw-blob + // noise the 07-06 bundle shows. State lives in the row; drop the echo. + if (source === 'todowrite') return null + // read results are a tagged text document (// + // soup). Parse and present as a code slab with the real path; fall + // through to the generic row when the shape doesn't match. + if (source === 'read') { + const row = renderOpencodeReadResult(block) + if (row !== undefined) return row + } return undefined } diff --git a/src/providers/registry.renderer.capabilities.ts b/src/providers/registry.renderer.capabilities.ts index 2a2593871..bc342e315 100644 --- a/src/providers/registry.renderer.capabilities.ts +++ b/src/providers/registry.renderer.capabilities.ts @@ -71,6 +71,22 @@ export type RendererProviderCapabilities = { block: ToolResultBlock, context: { sourceTool?: ToolUseBlock | null }, ) => ReactNode | undefined + /** + * Does this tool_use name spawn a subagent the fleet row should own? + * Provider-owned because the spawn vocabulary is provider-specific and + * used to live as a hardcoded global name set in feed/lib/agentSpawnTools — + * exactly the kind of "which provider says what" knowledge #394 pulls into + * the registry. Claude records fanout as an `Agent` tool_use and (for + * MCP-orchestrated sessions) `mcp____orchestration_create_agent`; + * Codex as a `spawn_agent` function_call and the bare + * `orchestration_create_agent`; opencode has no spawn tool yet. The shared + * feed predicate (isAgentSpawnToolName) unions these so the committed and + * grouping planes route identically (the P2c blind spot: 73 tracked + * children, zero cards, because only 'Agent'/'spawn_agent' were known). + * wait/list/read orchestration tools are deliberately excluded — they are + * queries about the fleet, not spawns. + */ + isSpawnTool: (name: string) => boolean /** * Provider-owned transcript-line → feed-entry mapping (#394 phase * 2b). One fresh mapper per ingestion stream; see the @@ -164,6 +180,14 @@ export type ComposerSubmitIo = { getScreen: () => string | undefined } +// Does a tool name reduce to the MCP orchestration spawn verb? The built-in +// orchestration MCP server names its spawn tool `orchestration_create_agent`; +// claude keeps the `mcp____` prefix on the wire, so only the prefixed +// form is claude's. `[^]*` (not `.*`) so an embedded newline in a server name +// still matches — the same tolerance the old shared predicate used. +const isMcpOrchestrationCreateAgent = (name: string): boolean => + /^mcp__[^]*__orchestration_create_agent$/.test(name) + const claudeCapabilities: RendererProviderCapabilities = { id: 'claude', name: 'Claude Code', @@ -171,6 +195,10 @@ const claudeCapabilities: RendererProviderCapabilities = { conditionViews: CLAUDE_VIEWS, renderToolUse: renderClaudeToolUse, renderToolResult: renderClaudeToolResult, + // Claude fanout: `Agent` tool_use, plus MCP-orchestrated spawns that arrive + // prefixed. The bare `orchestration_create_agent` is codex's (see below), so + // the fleet-row union still covers it. + isSpawnTool: (name) => name === 'Agent' || isMcpOrchestrationCreateAgent(name), createTranscriptEntryMapper: () => createClaudeTranscriptEntryMapper(), extractProviderSessionId: extractClaudeProviderSessionId, composerSubmit: claudeComposerSubmit, @@ -187,6 +215,9 @@ const codexCapabilities: RendererProviderCapabilities = { conditionViews: CODEX_VIEWS, renderToolUse: renderCodexToolUse, renderToolResult: renderCodexToolResult, + // Codex fanout: `spawn_agent` function_call, plus the MCP orchestration spawn + // whose `mcp__` prefix codex strips on the wire (so it arrives bare). + isSpawnTool: (name) => name === 'spawn_agent' || name === 'orchestration_create_agent', createTranscriptEntryMapper: (initialTurnCursor) => createCodexTranscriptEntryMapper(initialTurnCursor ?? null), extractProviderSessionId: extractCodexProviderSessionId, @@ -206,6 +237,8 @@ const opencodeCapabilities: RendererProviderCapabilities = { // a real todo list; everything else falls through to the generic rows. renderToolUse: renderOpencodeToolUse, renderToolResult: renderOpencodeToolResult, + // opencode has no subagent-spawn tool yet (no fleet fanout on this backend). + isSpawnTool: () => false, createTranscriptEntryMapper: () => createOpencodeTranscriptEntryMapper(), extractProviderSessionId: extractOpencodeProviderSessionId, composerSubmit: opencodeComposerSubmit, diff --git a/src/providers/shared/renderer/rows/JsonResultSlab.tsx b/src/providers/shared/renderer/rows/JsonResultSlab.tsx new file mode 100644 index 000000000..77785adba --- /dev/null +++ b/src/providers/shared/renderer/rows/JsonResultSlab.tsx @@ -0,0 +1,54 @@ +import { memo } from 'react' + +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { CodeBlock } from '@renderer/lib/code/CodeBlock' +import { jsonResultSummary } from '@providers/shared/renderer/rows/jsonToolPresentation' + +/* ---------- Collapsed pretty-JSON tool RESULT ---------- */ +// +// Companion of JsonToolRow for the result side (residue plan P1): tool +// results that parse as JSON (after tryExtractJson unwraps the MCP text +// envelope / codex wall-time wrapper) render as a collapsed
    +// with a one-line summary instead of an escaped single-line blob. +// +// The summary keys off `ok` when present — every orchestration/workspace +// result payload in the corpus carries it — and styles danger on +// ok:false even when the transport-level is_error flag was not set +// (MCP errors often ride inside a "successful" envelope). + +const RESULT_MAX_CHARS = 16 * 1024 + +export const JsonResultSlab = memo(function JsonResultSlab({ + value, + isError, +}: { + value: unknown + isError: boolean +}) { + const summary = jsonResultSummary(value) + const danger = isError || summary.isError + const json = (() => { + try { + const s = JSON.stringify(value, null, 2) + return s.length > RESULT_MAX_CHARS ? `${s.slice(0, RESULT_MAX_CHARS)}\n…` : s + } catch { + return null + } + })() + if (json === null) return null + + return ( + +
    + + {summary.label} + +
    + +
    +
    +
    + ) +}) diff --git a/src/providers/shared/renderer/rows/JsonToolRow.tsx b/src/providers/shared/renderer/rows/JsonToolRow.tsx new file mode 100644 index 000000000..7346ad83a --- /dev/null +++ b/src/providers/shared/renderer/rows/JsonToolRow.tsx @@ -0,0 +1,154 @@ +import { memo } from 'react' + +import type { ToolUseBlock } from '@shared/types/transcript' +import { formatToolFilePath } from '@shared/paths/displayPath' + +import { truncateBashCommand } from '@renderer/features/feed/lib/helpers' +import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' +import { CodeBlock } from '@renderer/lib/code/CodeBlock' +import { + isAbsolutePathLike, + isHttpUrl, + prettifyToolName, + slabEntries, + smartHeadline, +} from '@providers/shared/renderer/rows/jsonToolPresentation' + +/* ---------- Generic JSON tool row: the shared feed fallback ---------- */ +// +// Fires when NO provider claims a tool name (Block.tsx fallback seam). The +// corpus proved the previous fallback starved exactly the tools users +// complained about (plan-json-tool-rows.md): claude MCP tools painted a +// bare name with nothing under it (no input key matched the old headline +// chain), codex orchestration/workspace tools dumped raw JSON
     blobs.
    +//
    +// Degrades to EXACTLY the old ToolUseRow look (name + one muted headline)
    +// when the input has nothing beyond its headline — so Bash rows keep their
    +// current rendering, including the upstream 2-line/160-char command cap.
    +//
    +// `live` opts out of highlighting: one-shot hljs is fine for committed
    +// rows, but per-delta re-highlighting on the live path is the O(bytes²)
    +// trap documented at the Write streaming preview.
    +
    +// Hard cap on any pretty-printed JSON slab we hand to the DOM / highlighter.
    +// WHY 16KB: a single tool payload can carry a multi-megabyte blob (a whole
    +// file's contents, an orchestration graph, a base64 image). Stringifying and
    +// mounting that verbatim is the O(bytes²) highlight trap and a memory spike on
    +// the live path — the slab is a *preview*, not a viewer, so we truncate. The
    +// exact number is a readability/safety trade, not a semantic boundary; 16KB is
    +// far more context than any collapsed preview needs while staying cheap to
    +// paint. Exported so the live BlockRow path caps identically instead of
    +// re-deriving its own magic number (they used to drift).
    +export const SLAB_MAX_CHARS = 16 * 1024
    +
    +function ParamValue({ value }: { value: unknown }) {
    +  if (typeof value === 'string') {
    +    if (isHttpUrl(value)) {
    +      return (
    +        
    +          {value}
    +        
    +      )
    +    }
    +    if (isAbsolutePathLike(value)) {
    +      return (
    +        
    +          {formatToolFilePath(value, null)}
    +        
    +      )
    +    }
    +    // 400-char clamp: a scalar param is a one-line hint in a collapsed slab,
    +    // not a document. Some tools smuggle a whole prompt / diff into a single
    +    // string param; past ~400 chars it stops being a glanceable value and
    +    // starts pushing the row height (and paint cost) unboundedly. The full
    +    // value is still available on the underlying entry — this is preview text.
    +    return {value.length > 400 ? `${value.slice(0, 400)}…` : value}
    +  }
    +  if (value === null || typeof value !== 'object') {
    +    return {String(value)}
    +  }
    +  // Nested objects render as pretty JSON below — the top-level key just
    +  // shows a shape hint here.
    +  return {Array.isArray(value) ? `[${value.length}]` : '{…}'}
    +}
    +
    +export const JsonToolRow = memo(function JsonToolRow({
    +  block,
    +  live = false,
    +}: {
    +  block: ToolUseBlock
    +  live?: boolean
    +}) {
    +  const input = (block.input ?? null) as Record | null
    +  const { display, mcpServer } = prettifyToolName(block.name)
    +  const headline = smartHeadline(input)
    +
    +  const headlineText = (() => {
    +    if (!headline) return null
    +    if (block.name === 'Bash' && headline.key === 'command') {
    +      return truncateBashCommand(headline.value)
    +    }
    +    if (isAbsolutePathLike(headline.value)) return formatToolFilePath(headline.value, null)
    +    return headline.value
    +  })()
    +
    +  const params = slabEntries(input, headline?.key ?? null)
    +  const nested = params.filter(([, v]) => typeof v === 'object' && v !== null)
    +  const nestedJson = (() => {
    +    if (nested.length === 0) return null
    +    try {
    +      const json = JSON.stringify(Object.fromEntries(nested), null, 2)
    +      return json.length > SLAB_MAX_CHARS ? `${json.slice(0, SLAB_MAX_CHARS)}\n…` : json
    +    } catch {
    +      return null
    +    }
    +  })()
    +
    +  return (
    +    
    +      
    +
    + {display} + {mcpServer && ( + + MCP · {mcpServer} + + )} +
    + {headlineText && ( + +
    +              {headlineText}
    +            
    +
    + )} + {params.length > 0 && ( + +
    + + {params.length} param{params.length === 1 ? '' : 's'} + +
    + {params + .filter(([, v]) => typeof v !== 'object' || v === null) + .map(([k, v]) => ( +
    + {k}: +
    + ))} + {nestedJson && ( + + )} +
    +
    +
    + )} +
    +
    + ) +}) diff --git a/src/providers/shared/renderer/rows/jsonToolPresentation.test.ts b/src/providers/shared/renderer/rows/jsonToolPresentation.test.ts new file mode 100644 index 000000000..adc3077c6 --- /dev/null +++ b/src/providers/shared/renderer/rows/jsonToolPresentation.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' + +import { + jsonResultSummary, + prettifyToolName, + smartHeadline, + tryExtractJson, +} from '@providers/shared/renderer/rows/jsonToolPresentation' + +// Payload shapes below are lifted from the debug-bundle corpus +// (docs/rendering/research-2026-07/plan-json-tool-rows.md §1) — the exact +// tools users filed bundles about. + +describe('prettifyToolName', () => { + it('splits the claude MCP naming convention', () => { + expect(prettifyToolName('mcp__agent_code__orchestration_create_agent')).toEqual({ + display: 'orchestration_create_agent', + mcpServer: 'agent_code', + }) + }) + it('passes bare codex names through', () => { + expect(prettifyToolName('orchestration_read_agent')).toEqual({ + display: 'orchestration_read_agent', + mcpServer: null, + }) + }) +}) + +describe('smartHeadline', () => { + it('prefers path over description (the ai_workspace_attach_file bug)', () => { + const h = smartHeadline({ + description: 'Implementation plan from the fold reviewer', + path: '/Users/x/plans/fold.md', + }) + expect(h).toEqual({ key: 'path', value: '/Users/x/plans/fold.md' }) + }) + it('surfaces MCP payload keys the legacy chain missed (title)', () => { + const h = smartHeadline({ kind: 'claude', title: 'Consult: AUQ keystroke driver' }) + expect(h?.value).toBe('Consult: AUQ keystroke driver') + }) + it('keeps command first for Bash-shaped inputs', () => { + expect(smartHeadline({ command: 'ls -la', description: 'list' })?.key).toBe('command') + }) + it('null on empty input', () => { + expect(smartHeadline(null)).toBeNull() + expect(smartHeadline({})).toBeNull() + }) +}) + +describe('tryExtractJson', () => { + it('unwraps the MCP text envelope one level', () => { + const wire = JSON.stringify([{ type: 'text', text: '{"ok":true,"agent_id":"a1"}' }]) + expect(tryExtractJson(wire)).toEqual({ ok: true, agent_id: 'a1' }) + }) + it('unwraps the codex wall-time wrapper', () => { + const wire = 'Wall time: 2.1 s\nOutput:\n[{"type":"text","text":"{\\"ok\\":false}"}]' + expect(tryExtractJson(wire)).toEqual({ ok: false }) + }) + it('keeps the envelope when the inner text is not JSON', () => { + const wire = JSON.stringify([{ type: 'text', text: 'plain prose result' }]) + expect(tryExtractJson(wire)).toEqual([{ type: 'text', text: 'plain prose result' }]) + }) + it('returns null for non-JSON text (falls through to truncated rendering)', () => { + expect(tryExtractJson('total 48\ndrwxr-xr-x 12 …')).toBeNull() + expect(tryExtractJson('')).toBeNull() + }) +}) + +describe('jsonResultSummary', () => { + it('keys off ok and styles danger on ok:false', () => { + expect(jsonResultSummary({ ok: false, error: 'no such agent' })).toEqual({ + label: 'ok: false', + isError: true, + }) + expect(jsonResultSummary({ ok: true })).toEqual({ label: 'ok: true', isError: false }) + }) + it('counts keys and items otherwise', () => { + expect(jsonResultSummary({ a: 1, b: 2 }).label).toBe('2 keys') + expect(jsonResultSummary([1, 2, 3]).label).toBe('3 items') + }) +}) diff --git a/src/providers/shared/renderer/rows/jsonToolPresentation.ts b/src/providers/shared/renderer/rows/jsonToolPresentation.ts new file mode 100644 index 000000000..b22159ff6 --- /dev/null +++ b/src/providers/shared/renderer/rows/jsonToolPresentation.ts @@ -0,0 +1,150 @@ +// Pure presentation helpers for the generic JSON tool row (JsonToolRow). +// Split from the component so the payload→presentation mapping is unit- +// testable without rendering (the corpus bundles are the test inputs: +// docs/rendering/research-2026-07/plan-json-tool-rows.md). + +/** `mcp____` → tool display name + MCP badge. Claude names + * MCP tools with the double-underscore convention; codex strips the + * prefix upstream so bare names pass through untouched. */ +export function prettifyToolName(name: string): { + display: string + mcpServer: string | null +} { + const m = /^mcp__([^_]+(?:_[^_]+)*)__(.+)$/.exec(name) + if (!m) return { display: name, mcpServer: null } + return { display: m[2], mcpServer: m[1] } +} + +/** + * Unified smart headline for tool inputs — merges the two chains that had + * drifted apart (shared ToolUseRow.pickString and codex headlineForTool). + * + * ORDER MATTERS and is evidence-driven: + * - `command` first (Bash-shaped tools; the identifier IS the action) + * - path-shaped keys BEFORE `title`/`description` — the corpus caught + * `ai_workspace_attach_file` showing its `description` gloss while + * hiding the actual `path` (plan §1b); a path is an identifier, a + * description is commentary. + * - MCP-payload keys (`title`, `name`, `prompt`, `sessionId`) next — the + * orchestration/workspace tools carry these as their subject line. + * - `description` LAST: if we reach it we've exhausted every specific + * identifier. + */ +const HEADLINE_KEYS = [ + 'command', + 'file_path', + // opencode camelCase twin (probe-verified: read/edit pass filePath). + 'filePath', + 'notebook_path', + 'url', + // pattern BEFORE path: for Glob/Grep-shaped inputs the pattern is the + // SUBJECT and path is the scope — the opencode glob bundle showed the + // cwd as headline while the pattern (what the user cares about) hid in + // the params slab. Tools whose subject is a path (Read/Edit) carry it + // as file_path/filePath, which already ranked above. + 'pattern', + 'query', + 'path', + 'title', + 'name', + 'prompt', + 'sessionId', + 'description', +] as const + +export function smartHeadline(input: Record | null | undefined): { + key: string + value: string +} | null { + if (!input) return null + for (const key of HEADLINE_KEYS) { + const v = input[key] + if (typeof v === 'string' && v.length > 0) return { key, value: v } + } + return null +} + +/** Params worth a collapsed slab = everything except the one already shown + * as the headline. */ +export function slabEntries( + input: Record | null | undefined, + headlineKey: string | null, +): [string, unknown][] { + if (!input) return [] + return Object.entries(input).filter(([k]) => k !== headlineKey) +} + +export function isAbsolutePathLike(s: string): boolean { + // Length cap guards the classifier, not the value: a "path" longer than a + // real filesystem limit (~512 chars is already generous vs PATH_MAX) is + // almost certainly prose that merely starts with `/` or `~/`, and mis- + // classifying it as a path would route it through path-shortening display + // that mangles the text. Bounding the regex input also keeps it linear. + return /^(~\/|\/)[^\0\n]*$/.test(s) && s.length < 512 +} + +export function isHttpUrl(s: string): boolean { + // Same guard as isAbsolutePathLike: past ~2KB a "URL" is far more likely a + // token/blob that happens to start with http(s):// than a real link, and we + // don't want to wrap 2KB of text in an
    . 2KB matches the de-facto + // browser URL-length ceiling, so anything longer wouldn't navigate anyway. + return /^https?:\/\/\S+$/.test(s) && s.length < 2048 +} + +/** + * Best-effort JSON extraction from tool RESULT text. Handles, in order: + * 1. the MCP text envelope `[{"type":"text","text":""}]` (one level) + * 2. codex's `Wall time: …\nOutput:\n` wrapper + * 3. plain JSON + * Returns null when the text is not JSON-shaped — callers fall through to + * the existing truncated-text rendering, so this can never make a result + * LESS readable than today. + */ +export function tryExtractJson(text: string): unknown | null { + const trimmed = text.trim() + if (trimmed.length === 0 || trimmed.length > 256 * 1024) return null + + const parse = (s: string): unknown | null => { + const t = s.trim() + if (!t.startsWith('{') && !t.startsWith('[')) return null + try { + return JSON.parse(t) + } catch { + return null + } + } + + // Codex wall-time wrapper. + const wallTime = /^Wall time: [^\n]*\nOutput:\n([\s\S]*)$/.exec(trimmed) + const candidate = wallTime ? wallTime[1] : trimmed + + const parsed = parse(candidate) + if (parsed === null) return null + + // MCP text envelope: [{type:'text', text:''}]. + if ( + Array.isArray(parsed) && + parsed.length === 1 && + typeof parsed[0] === 'object' && + parsed[0] !== null && + (parsed[0] as { type?: unknown }).type === 'text' && + typeof (parsed[0] as { text?: unknown }).text === 'string' + ) { + const inner = parse((parsed[0] as { text: string }).text) + return inner ?? parsed + } + return parsed +} + +/** One-line summary for a collapsed JSON result. `ok` is the universal + * status key across the orchestration/workspace corpus payloads. */ +export function jsonResultSummary(value: unknown): { label: string; isError: boolean } { + if (Array.isArray(value)) return { label: `${value.length} items`, isError: false } + if (typeof value === 'object' && value !== null) { + const rec = value as Record + if (rec.ok === false) return { label: 'ok: false', isError: true } + if (rec.ok === true) return { label: 'ok: true', isError: false } + return { label: `${Object.keys(rec).length} keys`, isError: false } + } + return { label: String(value).slice(0, 60), isError: false } +} diff --git a/src/remote-client/src/ui/SessionView.tsx b/src/remote-client/src/ui/SessionView.tsx index ae3095486..e9a08f9ba 100644 --- a/src/remote-client/src/ui/SessionView.tsx +++ b/src/remote-client/src/ui/SessionView.tsx @@ -2,6 +2,9 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from import { Feed } from '@renderer/features/feed/ui/Feed' import { SessionFeedProvider } from '@renderer/features/sessionFeed/SessionFeedContext' +import { useLedgerFeedItems } from '@renderer/rendering/view/useLedgerFeedItems' +import type { SessionRuntime } from '@renderer/workspace/workspaceState' +import type { Entry } from '@shared/types/transcript' import { conditionStateByKind, type ClaudeAskUserQuestionState, @@ -10,6 +13,13 @@ import { import type { WebSocketSessionFeed, ConnectionState } from '../WebSocketSessionFeed' import type { TranscriptStore } from '../transcript/store' +// The phone has no optimistic-echo plane: it renders committed + semantic +// state streamed from the desktop, never a locally-minted ghost (see the +// "deliberately skipped subsystems" note below). A single frozen empty map +// keeps the ledger's ghost plane cache stable across renders — a fresh map +// each render would defeat the adapter's by-reference plane memoization. +const NO_GHOSTS: ReadonlyMap = new Map() + // One session, desktop-grade: this mounts the REAL desktop Feed component // (see the alias table in ../vite.config.ts — the phone renders the same // component tree the desktop does, with four documented stub @@ -66,6 +76,40 @@ export function SessionView({ const provider = store.getKind(sessionId) + // Stage 3 cutover: the phone paints from the SAME ownership-ledger pipeline + // the desktop does. It used to rely on Feed's legacy deriveFeedRenderModel + // path (the only remaining consumer of it after the desktop flip); that + // path is deleted, so the phone must produce renderItemsOverride too. The + // ledger reads a minimal SessionRuntime view — the seven fields + // useLedgerFeedItems/ledgerFeedContextFromRuntime touch — assembled from + // the wire transcript. Only entries + semantic + phase differ across + // renders; ghosts is the frozen empty map (no optimistic plane on the + // phone) and lastJsonlEntryAt is irrelevant with no ghosts to invalidate. + const runtimeView = useMemo( + () => + ({ + entries: transcript.entries, + semantic: { + currentTurn: transcript.semanticTurn, + history: transcript.semanticHistory, + }, + ghosts: NO_GHOSTS, + streamPhase: transcript.phase.streamPhase, + streamPhasePendingToolName: transcript.phase.streamPhasePendingToolName, + streamPhasePendingToolUseId: transcript.phase.streamPhasePendingToolUseId, + lastJsonlEntryAt: 0, + }) as unknown as SessionRuntime, + [ + transcript.entries, + transcript.semanticTurn, + transcript.semanticHistory, + transcript.phase.streamPhase, + transcript.phase.streamPhasePendingToolName, + transcript.phase.streamPhasePendingToolUseId, + ], + ) + const ledgerFeedItems = useLedgerFeedItems(runtimeView, provider, sessionId) + const askUserQuestionState = useMemo( () => transcript.conditions @@ -173,6 +217,7 @@ export function SessionView({ void closeDebugBundleNotePrompt: () => void + openRecordingNotePrompt: (payload: { + sessionId: SessionId + noteId: string + title: string + }) => void + closeRecordingNotePrompt: () => void openViewPrompts: (sessionId: SessionId) => void closeViewPrompts: () => void openNewAgentPlacement: () => void diff --git a/src/renderer/src/app-state/uiShell/slice.ts b/src/renderer/src/app-state/uiShell/slice.ts index 476dd9e9b..77d0e7788 100644 --- a/src/renderer/src/app-state/uiShell/slice.ts +++ b/src/renderer/src/app-state/uiShell/slice.ts @@ -18,6 +18,7 @@ export const createUiShellSlice: StateCreator< settingsPageOpen: false, buryPromptSessionId: null, debugBundleNotePrompt: null, + recordingNotePrompt: null, viewPromptsSessionId: null, newAgentPlacementOpen: false, tiledDispatchPromptOpen: false, @@ -95,6 +96,17 @@ export const createUiShellSlice: StateCreator< closeDebugBundleNotePrompt: () => set({ debugBundleNotePrompt: null }, false, 'uiShell/closeDebugBundleNotePrompt'), + // Recording-note prompt (plan §7b). Opened AFTER the reserve IPC has already + // written the `reserved` marker and returned the noteId — the payload just + // carries what the fill step needs (sessionId + noteId) plus a title for the + // modal chrome. Closing WITHOUT submitting is fine and intentional: the + // reserved marker stays in the recording as a bare "something happened here" + // flag even if the operator never types a description. + openRecordingNotePrompt: payload => + set({ recordingNotePrompt: payload }, false, 'uiShell/openRecordingNotePrompt'), + closeRecordingNotePrompt: () => + set({ recordingNotePrompt: null }, false, 'uiShell/closeRecordingNotePrompt'), + openViewPrompts: sessionId => set({ viewPromptsSessionId: sessionId }, false, 'uiShell/openViewPrompts'), closeViewPrompts: () => diff --git a/src/renderer/src/app-state/uiShell/types.ts b/src/renderer/src/app-state/uiShell/types.ts index 7a44d408a..2b28f7f6d 100644 --- a/src/renderer/src/app-state/uiShell/types.ts +++ b/src/renderer/src/app-state/uiShell/types.ts @@ -37,6 +37,14 @@ export type UiShellState = { title: string description: string } | null + // Non-null while the Attach-Recording-Note input modal is open (plan §7b). + // The `reserved` marker is already written by the time this is set; noteId + // is the handle the fill step updates. + recordingNotePrompt: { + sessionId: SessionId + noteId: string + title: string + } | null viewPromptsSessionId: SessionId | null newAgentPlacementOpen: boolean /** diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index 009c54fc9..98bb93fcb 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -83,6 +83,7 @@ export default function App() { const settingsPageOpen = useAppStore(state => state.settingsPageOpen) const buryPromptSessionId = useAppStore(state => state.buryPromptSessionId) const debugBundleNotePrompt = useAppStore(state => state.debugBundleNotePrompt) + const recordingNotePrompt = useAppStore(state => state.recordingNotePrompt) const viewPromptsSessionId = useAppStore(state => state.viewPromptsSessionId) const newAgentPlacementOpen = useAppStore(state => state.newAgentPlacementOpen) const tiledDispatchPromptOpen = useAppStore(state => state.tiledDispatchPromptOpen) @@ -122,6 +123,7 @@ export default function App() { const closeSettingsPage = useAppStore(state => state.closeSettingsPage) const closeBuryPrompt = useAppStore(state => state.closeBuryPrompt) const closeDebugBundleNotePrompt = useAppStore(state => state.closeDebugBundleNotePrompt) + const closeRecordingNotePrompt = useAppStore(state => state.closeRecordingNotePrompt) const openViewPrompts = useAppStore(state => state.openViewPrompts) const closeViewPrompts = useAppStore(state => state.closeViewPrompts) const closeNewAgentPlacement = useAppStore(state => state.closeNewAgentPlacement) @@ -176,6 +178,10 @@ export default function App() { const openRewindPrompt = useAppStore(state => state.openRewindPrompt) const closeRewindPrompt = useAppStore(state => state.closeRewindPrompt) const [devDebugEnabled, setDevDebugEnabled] = useState(false) + // Mirrors DevDebugConfig.sessionRecordingEnabled so the command registry can + // gate Attach-Recording-Note (plan §7b). Read once alongside the dev-debug + // flag below. + const [sessionRecordingEnabled, setSessionRecordingEnabled] = useState(false) const [caffeinateStatus, setCaffeinateStatus] = useState(null) const [caffeinateMessage, setCaffeinateMessage] = useState(null) @@ -195,10 +201,14 @@ export default function App() { let cancelled = false void window.api.getDevDebugConfig() .then(config => { - if (!cancelled) setDevDebugEnabled(config.enabled) + if (cancelled) return + setDevDebugEnabled(config.enabled) + setSessionRecordingEnabled(config.sessionRecordingEnabled) }) .catch(() => { - if (!cancelled) setDevDebugEnabled(false) + if (cancelled) return + setDevDebugEnabled(false) + setSessionRecordingEnabled(false) }) return () => { cancelled = true @@ -892,6 +902,7 @@ export default function App() { proxyDebugPanelOpen={proxyDebugPanelOpen} htmlDebugPanelOpen={htmlDebugPanelOpen} devDebugEnabled={devDebugEnabled} + sessionRecordingEnabled={sessionRecordingEnabled} devDebugPanelOpen={devDebugPanelOpen} agentStatusPanelOpen={agentStatusPanelOpen} performancePanelOpen={performancePanelOpen} @@ -1013,6 +1024,36 @@ export default function App() { }} /> + {/* Attach-Recording-Note input (plan §7b). Reuses the debug-bundle note + modal with recording-specific labels. The `reserved` marker is + already written by the time this opens; onConfirm fills it. Cancel + leaves the reserved marker in place — a bare timestamp is still a + useful flag, so we do NOT delete it on skip. */} + { + const prompt = recordingNotePrompt + if (!prompt) return + const trimmed = note.trim() + closeRecordingNotePrompt() + if (!trimmed) return + void window.api.fillRecordingNote(prompt.sessionId, prompt.noteId, trimmed).then( + () => workspace.showPaneToast(prompt.sessionId, 'recording note attached', 3000), + err => { + const message = err instanceof Error ? err.message : String(err) + workspace.showPaneToast(prompt.sessionId, `recording note failed: ${message}`, 5000) + }, + ) + }} + /> + { + const sessionId = commandTargetSessionId(workspace) + if (!sessionId) return + + const meta = workspace.state.sessions[sessionId] + const kind = meta?.kind ?? DEFAULT_PROVIDER + + // Reserve INSTANTLY — this is the tick-pinning write. Returns null when + // there is no active recording for this session (recording not gated on, or + // the session never produced a recorded event). In that case there is + // nothing to annotate: toast and bail rather than opening a dead input. + let noteId: string | null = null + try { + noteId = await window.api.reserveRecordingNote(sessionId) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + workspace.showPaneToast(sessionId, `recording note failed: ${message}`, 4000) + return + } + + if (!noteId) { + workspace.showPaneToast(sessionId, 'no active recording for this pane', 3000) + return + } + + // Marker is now claimed. Open the input; the fill step (App.tsx onConfirm) + // updates this noteId with the typed text. If the operator cancels, the + // reserved marker stays — a bare timestamp is still a useful flag. + useAppStore.getState().openRecordingNotePrompt({ + sessionId, + noteId, + title: `${kind} · ${meta?.cwd?.split('/').filter(Boolean).pop() ?? sessionId}`, + }) +} + +// Toggle Session Recording command body (plan §7 — the primary control). +// Recording is command-driven: this starts the focused pane recording if it +// isn't, or stops+finalizes it if it is. Kept beside the note command since +// both operate the focused pane's recorder and share the toast pattern. +export async function runToggleSessionRecordingCommand(workspace: Workspace): Promise { + const sessionId = commandTargetSessionId(workspace) + if (!sessionId) return + try { + const recording = await window.api.isSessionRecording(sessionId) + if (recording) { + await window.api.stopSessionRecording(sessionId) + workspace.showPaneToast(sessionId, 'recording stopped — saved to session-recordings/', 3500) + } else { + const provider = workspace.state.sessions[sessionId]?.kind + await window.api.startSessionRecording(sessionId, provider) + workspace.showPaneToast(sessionId, 'recording started for this pane', 3000) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + workspace.showPaneToast(sessionId, `recording toggle failed: ${message}`, 4000) + } +} diff --git a/src/renderer/src/features/debug/saveDebugBundle.ts b/src/renderer/src/features/debug/saveDebugBundle.ts index b69a9eaf4..09072d17e 100644 --- a/src/renderer/src/features/debug/saveDebugBundle.ts +++ b/src/renderer/src/features/debug/saveDebugBundle.ts @@ -307,6 +307,17 @@ function buildStateSnapshot(runtime: SessionRuntime): Record { semanticLog: runtime.semantic.log.length, semanticErrors: runtime.semantic.errors.length, }, + // Committed-tail health at capture. The tailer unwatch bug (residue + // plan P0) produced bundles whose ONLY on-board symptom was a stale + // lastJsonlEntryAt that nobody thought to subtract from capturedAt — + // the 06-24 stuck-queue bundle sat at 88 minutes and it took rollout + // forensics to notice. Pre-computed age makes tail death readable at + // a glance in every future bundle. + _tailHealth: { + lastJsonlEntryAt: runtime.lastJsonlEntryAt, + committedTailAgeMs: + runtime.lastJsonlEntryAt !== null ? Date.now() - runtime.lastJsonlEntryAt : null, + }, } } diff --git a/src/renderer/src/features/debug/ui/DebugBundleNotePrompt.tsx b/src/renderer/src/features/debug/ui/DebugBundleNotePrompt.tsx index 0468b5a68..dc663a493 100644 --- a/src/renderer/src/features/debug/ui/DebugBundleNotePrompt.tsx +++ b/src/renderer/src/features/debug/ui/DebugBundleNotePrompt.tsx @@ -7,6 +7,15 @@ type Props = { bundlePath: string onCancel: () => void onConfirm: (note: string) => void + // Optional label overrides so this same modal can back BOTH the debug-bundle + // note and the Attach-Recording-Note flow (plan §7b) instead of forking a + // second near-identical component. Defaults preserve the original + // debug-bundle wording, so existing callers are unaffected. The footer path + // line is only rendered when `bundlePath` is non-empty — a recording note + // has no path to show. + heading?: string + placeholder?: string + fieldLabel?: string } export function DebugBundleNotePrompt({ @@ -16,6 +25,9 @@ export function DebugBundleNotePrompt({ bundlePath, onCancel, onConfirm, + heading = 'Add Debug Bundle Note', + placeholder = 'Why did you save this debug bundle?', + fieldLabel = 'Optional note', }: Props) { const [note, setNote] = useState('') const inputRef = useRef(null) @@ -37,14 +49,14 @@ export function DebugBundleNotePrompt({ >
    -
    Add Debug Bundle Note
    +
    {heading}
    {title}
    {description}