diff --git a/docs/agent-dashboard-retention-restart.md b/docs/agent-dashboard-retention-restart.md new file mode 100644 index 00000000000..df8bb92ccc9 --- /dev/null +++ b/docs/agent-dashboard-retention-restart.md @@ -0,0 +1,291 @@ +# Per-Workspace Agent Activity: Preserve Across Restart + +## Goal + +When a user has the experimental agent dashboard toggle on (`settings.experimentalAgentDashboard === true`), the per-workspace agent activity they see on each worktree card should survive an Orca app restart. + +The user-visible problem is broader than "done rows vanish": any agent whose status was visible before quit and which does not re-emit a hook event on its own — `done` agents (process gone, no future event), `blocked` Claudes (idle at a permission prompt, no event until the user acts), and `working` agents that happen to be quiet at quit time — disappears from the dashboard until something happens to make them speak again. The most-cited symptom is `done` rows winking out, but the same restart-erasure hits `blocked` and quiet `working` for exactly the same reason: nothing replays the last-known status across the boundary. + +Persisting the hook server's existing per-pane status cache fixes all three at once, with bounded renderer changes (a bootstrap queue in `useIpcEvents.ts` and IPC fan-outs in `dropAgentStatus` and `dismissRetainedAgentsByWorktree`) and no changes to the dashboard memos or `useRetainedAgentsSync`. + +## Scope + +In scope: + +- Persist `lastStatusByPaneKey` (the module-level `Map` in `src/main/agent-hooks/server.ts:65` that already caches the latest hook payload per `paneKey`) to a JSON file under `userData/agent-hooks/`. Hydrate it on `start()` before the HTTP server begins accepting requests. +- Mirror cache mutations to disk through a debounced trailing write so a burst of hook events does not produce N file writes. Force a synchronous final flush on `stop()` so quit-time state is captured. +- Update the on-disk file when `clearPaneState(paneKey)` is called from PTY teardown (`src/main/ipc/pty.ts:349`), so a pane that closes during a session does not resurrect on the next launch. +- Gate disk *writes* on `experimentalAgentDashboard === true` so users who have not opted in do not accumulate on-disk hook payloads. Skip hydration when the flag is off at launch. + +Out of scope: + +- Renderer-side persistence (`retainedAgentsByPaneKey`, `acknowledgedAgentsByPaneKey`, `retentionSuppressedPaneKeys`). The renderer's retention slice and `useRetainedAgentsSync` continue to operate purely in memory; their "stay until dismissed" contract is upheld by the live entry being replayed into the renderer through the existing `setListener` path on every window registration. +- Daemon-side persistence. The daemon owns PTY lifetime and scrollback, not hook events. The hook server (in the Electron main process) is the right home. +- Cross-device sync. The on-disk file lives in `userData`; running Orca on a second machine does not carry retained activity. +- Changing the experimental gate itself. The gate stays; this proposal makes the feature behave correctly when it is on. +- A staleness TTL or visible "completed N days ago" indicator. The `done`-only retention case where staleness was sharpest in the prior renderer-side draft is unchanged here (`done` rows still auto-evict on dismiss; users who quit mid-task and return next morning see what they saw last). If long-tail staleness becomes a real complaint, a TTL on hydration is a small follow-up — see Follow-ups. + +## Background + +### How agent status flows from hook → renderer today + +The Electron main process runs a loopback HTTP server (`AgentHookServer` in `src/main/agent-hooks/server.ts`) that receives `POST /hook/` from Claude/Codex/Gemini/OpenCode/Cursor hook scripts and from the OpenCode plugin. On every successful POST it does two things (server.ts:1177-1181): + +1. `lastStatusByPaneKey.set(paneKey, payload)` — caches the latest normalized payload per pane (server.ts:65). +2. `this.onAgentStatus?.(payload)` — fires the registered listener. + +`src/main/index.ts:277-305` registers the listener on window creation. The listener forwards the payload to the renderer via `webContents.send('agentStatus:set', …)`, gated on `experimentalAgentDashboard === true` (index.ts:285). The renderer's `agentStatusByPaneKey` slice receives the IPC and writes the entry. From there, `useRetainedAgentsSync` produces `retainedAgentsByPaneKey` (sticky `done` snapshots), and the rest of the dashboard reads from those two maps. + +The cache exists for a reason that already overlaps with this proposal: hook events arrive while Orca is windowless (common on macOS when the user closes the window but leaves the app running), so re-registering the listener replays everything cached so far. `setListener()` at server.ts:1103-1117 walks `lastStatusByPaneKey.values()` and re-fires the listener for each entry. **This is the seam this design rides on.** Disk hydration writes into the same map; the next window registration replays everything to the renderer through the path already in production. + +### What survives restart today, indirectly + +The persistent-terminal daemon survives Orca quit (it is spawned `detached`, see `src/main/daemon/daemon-init.ts:115-116`, and the IPC channel is disconnected, not killed, on quit). PTYs stay alive. The renderer reconnects to the daemon on launch and replays scrollback. Hook stdout from agents that *do something post-launch* repopulates `agentStatusByPaneKey` naturally — but the hook server is fresh because: + +- `agentHookServer.stop()` runs at `src/main/index.ts:624` during `will-quit`, clearing `lastStatusByPaneKey` (server.ts:1248). +- `agentHookServer.start()` runs at `src/main/index.ts:560` during the next launch with a brand-new `Map`. + +So on relaunch the cache is empty, and any pane whose agent does not emit a fresh hook event is invisible: + +- `done` agents (process is gone — no future event possible). +- `blocked` Claudes (idle waiting on permission prompt — no event until the user acts). +- Quiet `working` agents (active but mid-step, not currently emitting). + +Persisting the cache fixes all three. Live agents that *do* emit an event post-launch overwrite the hydrated entry through the same `lastStatusByPaneKey.set` path that updates them today (server.ts:1179) — no special collision handling needed. + +## Proposed Shape + +Persist `lastStatusByPaneKey` to a JSON file alongside the existing endpoint coordinate file under `userData/agent-hooks/`. The hook server already takes `userDataPath` in `start()` (server.ts:1119, called from `src/main/index.ts:560-567`) and already manages `userData/agent-hooks/` for the endpoint file. + +Filename: `last-status.json`. Same directory permissions as the endpoint file (0o700 on the dir, 0o600 on the file — see server.ts:1349, 1395) so the cached payloads are not readable by other local users on multi-user POSIX hosts. + +On-disk shape: + +```jsonc +{ + // Why: a tiny version field is cheap insurance against future reshapes + // (e.g. trimming oversized lastAssistantMessage, dropping stateHistory). + // Mismatched versions are silently dropped on hydrate — no migration code. + "version": 1, + // Why: paneKey is `${tabId}:${paneId}`; mirrors the in-memory Map shape + // exactly so hydrate is a single Object.entries → map.set loop. + "entries": { + "tab-abc:pane-1": { + "paneKey": "tab-abc:pane-1", + "tabId": "tab-abc", + "worktreeId": "wt-…", + "payload": { /* ParsedAgentStatusPayload */ } + } + } +} +``` + +The shape is the structural mirror of the existing `AgentHookEventPayload` type (server.ts:44-49), wrapped in a `{ version, entries }` envelope. The payload is parsed on hydrate by the existing `parseAgentStatusPayload` / `normalizeAgentStatusPayload` from `src/shared/agent-status-types.ts:259, 268`, which already enforces the schema invariants the renderer assumes. + +Why this file and not `PersistedUIState` (the prior renderer-side draft): + +- `PersistedUIState` holds scalar UI prefs (widths, sort orders, dismiss flags). The hook payload is structured event data — `AgentStatusEntry` carries `stateHistory[]`, `toolInput`, and `lastAssistantMessage`, any of which can be multi-KB. Bloating the prefs file with churning event data is a category mistake. +- `userData/agent-hooks/` already exists as the home for hook-server on-disk coordination state (the endpoint file). Adding a cache file there keeps "hook-server-owned, survives-restart artifacts" co-located. +- The renderer is downstream of `lastStatusByPaneKey`; persisting upstream lets the renderer's retention slice and `useRetainedAgentsSync` operate unchanged off the live map. The only renderer change is a small bootstrap-window queue in `useIpcEvents.ts` so hydrated events that arrive before `workspaceSessionReady` are not dropped (see Hydration Rules). Persisting downstream would fork persistence across processes and only solve the `done` slice of the user complaint. + +Why a `Record` (and not an array): matches the in-memory `Map` shape, so the read and write paths are one-line transforms. + +Why no field-level cap on payload size: the same payloads are already accepted into memory unbounded by the running hook server. If memory pressure ever forces a cap, it should be applied at the cache write site (server.ts:1179) and inherited by both the in-memory and on-disk path. Capping only on disk would diverge the two and surprise anyone reading the cache directly. + +## Write Rules + +`lastStatusByPaneKey` (the in-memory map) is the source of truth. Persistence is a write-through mirror that hooks into the three existing mutation sites in `src/main/agent-hooks/server.ts`: + +| Site | What changes | Persistence action | +|---|---|---| +| `lastStatusByPaneKey.set(...)` at HTTP handler (server.ts:1179) | New or updated payload for a pane | Schedule debounced write | +| `lastStatusByPaneKey.delete(...)` in `clearPaneState()` (server.ts:1262) | PTY for the pane has torn down | Schedule debounced write | +| `lastStatusByPaneKey.clear()` in `stop()` (server.ts:1248) | Server is shutting down | Schedule write, then synchronously flush before returning | + +A single helper inside the class — `private scheduleStatusPersist()` — sets a 250 ms trailing timer, captured per-instance. Each scheduled write reads the current `lastStatusByPaneKey` and the current `experimentalAgentDashboard` setting, then: + +- If the gate is on, writes `{ version: 1, entries: {…} }` to `last-status.json` (atomic via tmp + rename — same pattern as `writeEndpointFile()` at server.ts:1310). +- If the gate is off, the write is a no-op for users who never opted in (the file does not exist; do not create one). For users who previously opted in, the write deletes the existing file once (one filesystem op, idempotent on subsequent attempts) and stops scheduling further writes until the gate flips back on. Deletion is preferred to "write empty" so a flag-off user never has hook-payload data on disk after the next sync. + +A `flushStatusPersistSync()` method runs the pending write synchronously. `stop()` calls it before clearing the map so quit-time state is captured even if a debounced write was pending. `will-quit` already has the synchronous order it needs (`agentHookServer.stop()` at index.ts:624 runs before `app.quit()` resolves), so the synchronous flush slots in cleanly. + +Specifics: + +- Gate writes on `experimentalAgentDashboard === true`. Read the setting at write time via the same `store?.getSettings()` accessor `src/main/index.ts:285` already uses for the IPC forwarding gate. Importing the store handle into the hook server is the cleanest seam; the alternative (passing the gate as a callback into `start()`) works too and keeps the server class agnostic of the store. Pick whichever is closer to the existing dependency direction in the file at implementation time. (Today, the server class has zero direct references to the renderer-side store; pushing a `getSettings`-style callback through `start()` preserves that and is the recommended path.) +- Debounce 250 ms trailing. A burst of hook events from a multi-agent run otherwise produces a write per event. The existing PTY-data path uses similar trailing batching for renderer IPC, so the latency budget is consistent. +- When the gate flips from on → off, delete the existing `last-status.json` once (no-op if it does not exist) and skip subsequent writes until the flag flips back on. Use deletion rather than "write empty" so a flag-off user has no hook-payload data on disk afterwards. The in-memory cache stays populated — the gate only controls IPC forwarding to the renderer (index.ts:285) and disk persistence. Cursor-agent's title-driving at index.ts:302-304 operates on the live event payload (not on `lastStatusByPaneKey.get(...)`), so leaving the cache populated is not strictly required for cursor; we leave it because (a) the cache is already populated by every hook event regardless of gate state and pruning it adds complexity for no gain, and (b) flipping the gate back on later should resume from the same state without re-collecting events. +- When the gate flips from off → on, do not eagerly write. The next `set`/`delete` will pick up persistence naturally on its scheduled trigger, and any payload already in the in-memory cache will be persisted on the first write after the flip. +- Identity check before write: if the JSON-stringified payload is byte-identical to the last persisted payload (cached on the instance as `lastWrittenJson`), skip the write. Cheap protection against re-firing trailing timers when nothing actually changed. + +`clearPaneState(paneKey)` (called from `src/main/ipc/pty.ts:349` on PTY teardown) already handles the within-session "pane is gone" case by deleting from the in-memory map. Mirroring it to disk through the same scheduled write means a pane closed in the previous session is absent from the on-disk file the next time it's read, so hydration cannot resurrect it. + +### Dismiss propagation + +Today, when a user clicks the X on a retained row, `WorktreeCardAgents.tsx` calls `dropAgentStatus(paneKey)` and `dismissRetainedAgent(paneKey)` (`src/renderer/src/store/slices/agent-status.ts:316, 537`). Both are pure-renderer mutations — they do not touch the hook server. Without persistence, this works because the next launch starts with an empty cache anyway. + +With persistence, the dismissed paneKey is still in `lastStatusByPaneKey` and would be re-hydrated on the next launch, defeating the dismiss. We close this gap by adding a small IPC: when `dropAgentStatus(paneKey)` runs in the renderer, it also fires `window.api.agentStatus.drop(paneKey)`, which lands in the main process and calls `agentHookServer.clearPaneState(paneKey)`. The same scheduled write helper that handles PTY teardown then evicts the entry from disk on the next debounce tick. + +This means: + +- IPC channel: a new `agentStatus:drop` request (renderer → main). Single argument: `paneKey: string`. Fire-and-forget; no response needed. +- Renderer wiring: extend `dropAgentStatus` in `agent-status.ts:316` to invoke the IPC at the end of its zustand `set(...)` block. The existing intra-renderer behavior is unchanged; the IPC is a write-through mirror, exactly like the disk persistence side of the hook server. +- Main wiring: add a tiny IPC handler in `src/main/index.ts` (or a dedicated handler file) that calls `agentHookServer.clearPaneState(paneKey)`. Keep it gated on the same `experimentalAgentDashboard === true` check used elsewhere in main — even though `clearPaneState` is itself idempotent, the gate prevents a non-opted-in user's renderer (which still calls `dropAgentStatus` for non-dashboard reasons like cursor-agent OSC fallback) from churning the hook server's persistence path. +- `dismissRetainedAgent` does not need its own IPC — `dropAgentStatus` covers the "remove this paneKey from everything" intent, and the dismiss-from-retained-only case is rare enough (and benign enough) to ride on the same path. If we later separate the two, add `agentStatus:drop` symmetry then. +- `dismissRetainedAgentsByWorktree` (`agent-status.ts:578`, the worktree-scoped bulk dismissal action) DOES need to propagate. Currently no production code path calls it — the live worktree-archive flow goes through `dropAgentStatusByTabPrefix` per-tab and PTY teardown then propagates `clearPaneState` (see below). We add the fan-out defensively so a future caller cannot reintroduce a disk leak. Wiring: in the slice action's set callback, collect the paneKeys being removed, then after the set completes, fire one `window.api.agentStatus.drop(paneKey)` per key. The IPC surface stays at a single channel; only the call count fans out. We do NOT add a separate `agentStatus:dropByWorktree` IPC because per-paneKey overhead is not a concern at the call rates this action would be invoked, and a single channel keeps the main-process handler shape simple. +- `dropAgentStatusByTabPrefix` (`agent-status.ts:423`, called from `terminals.ts:550` on tab close and `terminals.ts:1080` on worktree shutdown/sleep) does NOT need IPC fan-out. Every caller subsequently kills the affected PTYs via `window.api.pty.kill(...)`, and the existing main-process PTY-onExit chain calls `clearProviderPtyState` → `agentHookServer.clearPaneState(paneKey)` (`src/main/ipc/pty.ts:349`), which evicts the persisted entry through the same scheduled-write helper that handles every other eviction. The disk follows naturally. + +The `agentStatus:drop` channel is the only IPC surface change introduced by this design. + +## Hydration Rules + +Hydration runs inside `start()` after the userDataPath is set up but before `server.listen()` is called (server.ts:1221) — so the in-memory map is fully populated before the first hook POST can arrive. The existing `setListener()` replay loop (server.ts:1110) then naturally walks every hydrated entry the first time the renderer registers a listener, which today happens at window creation (index.ts:277). + +Order of operations on launch: + +1. `agentHookServer.start({ userDataPath })` is called from `src/main/index.ts:560` before window creation. +2. Inside `start()`, before binding the HTTP server: read `userData/agent-hooks/last-status.json`, parse, sanitize, and `lastStatusByPaneKey.set(...)` each surviving entry. Failure modes (missing file, parse error, schema mismatch) all degrade gracefully — log once, leave the in-memory map empty. +3. The HTTP server binds and starts accepting POSTs. New hook events go through the same `lastStatusByPaneKey.set` write site (server.ts:1179), overwriting hydrated entries naturally. +4. Window creation registers the renderer listener via `setListener()` (index.ts:277), which replays every cached entry — hydrated or just-arrived — into the renderer through the existing `webContents.send('agentStatus:set', …)` IPC. + +Gate hydration on `experimentalAgentDashboard === true` at launch. When the flag is off, skip reading the file (the IPC forwarding gate at index.ts:285 already drops anything that arrives anyway, and we want to avoid loading event payloads into memory the user has not opted into). Do not delete the file in this case — toggling the flag back on later should not have to start clean. + +Sanitization is field-by-field rather than whole-entry rejection so a single bad pane cannot tank the whole hydration: + +- Top-level: must be `{ version, entries }` with `version === 1` and `entries` an object. Anything else → empty hydration, single console warn. +- For each `[paneKey, entry]` pair: + - `paneKey` must be a string matching `${tabId}:${paneId}` shape (non-empty `tabId` segment, non-empty `paneId` segment, exactly one `:`). Drop on mismatch. + - `entry.paneKey` must equal the map key. Drop on mismatch — the key is the trusted form, but a divergence indicates corruption. + - `entry.payload` must round-trip through `normalizeAgentStatusPayload` (`src/shared/agent-status-types.ts:259`) — this is the same validator the HTTP handler uses on every live POST, so the on-disk path inherits the same schema invariants without duplicating rules. + - `entry.tabId` and `entry.worktreeId` must be either undefined or non-empty strings. Drop entries with empty-string values for these (they are typed optional but should never appear blank in practice). + +Note what we do *not* sanitize against: current worktrees, tab IDs, or daemon PTY liveness. Those checks belong downstream: + +- The renderer already prunes `retainedAgentsByPaneKey` against current worktrees in `useRetainedAgentsSync` (`pruneRetainedAgents(existingWorktreeIds)`). Hydrated live entries that map to dead worktrees will show up briefly in the renderer's live map, then disappear from the inline list when the next dashboard read filters by current worktrees. The same per-tab filter the dashboard already does for `agentStatusByPaneKey` will hide entries whose tab is not in `tabsByWorktree`. +- Daemon PTY liveness is not the hook server's knowledge. Adding a cross-process check here would couple the server to the daemon and slow down `start()`. The brief render of a "ghost" status that disappears on first sync is acceptable — strictly better than dropping live `blocked`/`working` entries because of a transient mismatch, and equivalent in practice for `done` entries (which are about something that already happened). + +Hydration timing relative to window creation: + +- The hook server starts before the window (`src/main/index.ts:560` vs. `src/main/index.ts:577`). Hydration is therefore complete before any renderer code can register a listener. +- `setListener()` is registered synchronously inside `openMainWindow()` (`src/main/index.ts:277`) — which fires the replay loop immediately, while the renderer bundle has not yet loaded. +- The renderer's IPC handler at `src/renderer/src/hooks/useIpcEvents.ts:816-849` drops payloads whose tab is not yet in `tabsByWorktree` (via `resolvePaneKey`), and the tab map is populated only after the async `App.tsx` init effect awaits `fetchRepos`/`fetchAllWorktrees`/`session.get()` and calls `hydrateTabsSession` (`src/renderer/src/App.tsx:217-237`). So even if Electron buffers the synchronous `webContents.send` calls until the renderer is ready, the in-renderer drop-on-unknown-tab guard would silently throw away every hydrated entry. + +To make hydration actually visible after launch, we add **one small renderer change** plus a deferred replay trigger: + +1. **Renderer queue for unknown-tab events.** Modify `useIpcEvents.ts:816-849` so that when `resolvePaneKey(...).exists === false` AND `workspaceSessionReady === false`, the payload is pushed into a small in-memory queue (a module-level `Array<{paneKey, payload, title?}>`) instead of dropped. The drain trigger is a `useAppStore.subscribe` registered inside the existing `useIpcEvents` mount effect (so its lifetime matches the rest of `useIpcEvents`'s subscriptions and the cleanup unsubscribes it) — on attach it reads the current `workspaceSessionReady`; if already `true`, drains immediately, otherwise waits for the next `true` transition. The drain reprocesses each entry: re-resolve `paneKey`, drop any whose tab is still unknown (closed-in-prior-session orphan, silently hidden — same as the "Tab closed between sessions" edge case), `setAgentStatus(...)` the rest, then clear the queue and set a `drained = true` flag so subsequent unknown-tab events post-drain go through the existing drop path. The queue is bounded at 200 entries with a drop-newest-on-overflow policy (preserve the order of the early entries — they are the ones with the strongest claim on dashboard state). On every subscribe attach, reset the queue array to empty so HMR and Strict Mode double-mount cannot accumulate stale entries across remounts. + +2. **No main-process changes** beyond what's already specified. `setListener()` keeps its current synchronous-replay semantics; the renderer is now resilient to early-arriving events. + +This is the only renderer code change introduced by this design. Rationale for putting the queue in the renderer rather than deferring `setListener`: + +- Deferring `setListener` until `webContents.did-finish-load` adds an `app/main` ↔ `renderer` handshake (a "renderer-ready" IPC ping) plus changes the lifetime guarantees of the existing windowless-replay path that the comment at `src/main/index.ts:260-264` already depends on. +- A renderer-side queue is contained, easy to reason about, and matches the existing `hydratePersistedUI` / `hydrateTabsSession` pattern of "renderer is the system that knows when its world is ready." + +The renderer slice's `setAgentStatus` is otherwise unchanged, and `useRetainedAgentsSync` continues to operate purely in memory off the live map. + +## Edge Cases + +**Live agent re-emits a hook event for a hydrated paneKey.** The HTTP handler's `lastStatusByPaneKey.set(payload.paneKey, payload)` (server.ts:1179) overwrites the hydrated entry. The renderer receives a fresh `agentStatus:set` IPC, replacing the hydrated state in `agentStatusByPaneKey`. No collision logic is needed — the live event is unconditionally newer. + +**Reused paneKey with a fresh agent run after restart.** A newly spawned agent on the same paneKey emits `working` (or whatever its first hook event is). That event runs through the normal write path and overwrites the hydrated entry the same way an in-session reuse does today. The renderer's `useRetainedAgentsSync` continues to do its diff against `agentStatusByPaneKey`; if the previous `done` was retained from the hydrated state, the new live entry knocks it out on the next sync. + +**PTY died between sessions (race window: pane closed during the previous Orca's quit, after the final write).** The hydrated cache contains an entry for a pane whose tab no longer exists. The renderer's existing per-tab filter on `agentStatusByPaneKey` (the dashboard reads only entries whose `tabId` is in the current `tabsByWorktree` for the worktree it's rendering) silently hides the orphan. Acceptable: at most one momentary stale row that never visually appears, and only on the narrow window where `clearPaneState` did not run before quit. + +**Worktree archived between sessions.** Hydration does not check worktree liveness. The renderer's existing `pruneRetainedAgents(existingWorktreeIds)` (and the dashboard's worktree-scoped reads) ensures the entry never reaches the rendered inline list. Same outcome as the tab-closed case. + +**On-disk corruption.** Sanitize field-by-field; never throw. A corrupt file yields an empty hydration result with a single console warn. The dashboard renders normally — the new behavior matches today's "no persistence at all" baseline, so any corruption mode is strictly no-worse than current state. + +**Stale file from a prior Orca version with a different schema.** The `version: 1` envelope rejects mismatches at parse time. An older Orca that wrote `version: 0` (or no version field) is treated like a corrupt file → empty hydration. No migration code, no explosion. + +**Dual instances of Orca pointing at the same `userData`.** Two processes race on `last-status.json`. Last write wins. The existing endpoint file at the same path has the same property (server.ts:1232-1240 explicitly accepts last-write-wins for the endpoint file). Hook payloads are a UX surface, not a correctness-critical store, so the trade-off matches. + +**Concurrent write while reading on launch.** The atomic-rename pattern (write to `.tmp`, `renameSync` into place — same as `writeEndpointFile()` at server.ts:1395-1397) means a reader at the moment of a write sees either the prior file or the new one, never a partially-written file. POSIX rename atomicity holds; on Windows, `renameSync` is also atomic for same-volume moves. + +**SSH worktrees.** Hook payloads for panes whose worktree happens to be SSH-mounted are persisted exactly like local panes — the hook server only sees the paneKey and the payload, not the worktree's transport. If the SSH connection drops post-restart, the hydrated entry remains visible until the renderer's existing tab/worktree filters take it out. Same behavior as in-session today. + +**Settings flag flipped off after a session populated the file, then quit.** The flag flip itself triggered a one-shot delete of `last-status.json` (see Write Rules). The next launch with the gate off skips hydration regardless. Flipping the flag back on later starts from no on-disk state; the dashboard is empty until the next hook event repopulates the cache and the next scheduled write rewrites the file. + +**Settings flag flipped off mid-session.** The next debounced write deletes `last-status.json` (idempotent) and subsequent writes are skipped while the flag stays off. The in-memory cache is *not* cleared so a later flip back on resumes from the same state. + +## Implementation Notes + +Primary file: + +- `src/main/agent-hooks/server.ts` — all changes land here: + - Add a private `lastStatusFilePath: string | null` set in `start()` from `userDataPath`. + - Add a private `getDashboardEnabled: (() => boolean) | null` callback set from `start({ getDashboardEnabled })`. The hook server stays decoupled from the store — `index.ts` passes a closure that reads `store?.getSettings().experimentalAgentDashboard === true`. + - Add `private hydrateLastStatusFromDisk()` invoked at the top of `start()` (gated on `getDashboardEnabled?.() === true`). Reads file, `JSON.parse`s the envelope, sanitizes field-by-field, validates each `entry.payload` via `normalizeAgentStatusPayload` (`src/shared/agent-status-types.ts:259` — accepts `unknown`, returns parsed `ParsedAgentStatusPayload | null`), and calls `lastStatusByPaneKey.set(...)` for survivors. Use `normalizeAgentStatusPayload`, not `parseAgentStatusPayload` — the latter takes a JSON string only and would force a wasteful stringify/parse round-trip. + - Add `private scheduleStatusPersist()` and `private flushStatusPersistSync()`. Wire them into the three mutation sites: HTTP handler `set` (server.ts:1179), `clearPaneState` `delete` (server.ts:1262), and `stop()` `clear` (server.ts:1248). The `stop()` path uses the synchronous flush before `lastStatusByPaneKey.clear()`. + - Add atomic write helper that mirrors the existing `writeEndpointFile()` pattern (tmp + rename, 0o600 mode, owner-only directory at 0o700 — server.ts:1349, 1395). Tmp filename: `.last-status--.tmp` (distinct prefix so a future filename change to one writer does not affect the other). Extend the existing orphan-tmp sweep at server.ts:1372 to match either prefix (`.endpoint-` OR `.last-status-`) so a crash mid-write of either file is cleaned up by either subsequent start. + - Wire `setListener()` unchanged: the existing replay loop at server.ts:1110 is exactly what's needed for the renderer to receive hydrated entries. + +- `src/main/index.ts` — pass `getDashboardEnabled` to `agentHookServer.start({...})` at index.ts:560-567. Add an IPC handler for `agentStatus:drop` that calls `agentHookServer.clearPaneState(paneKey)` (gated on `experimentalAgentDashboard === true`). Existing `setListener` registration at index.ts:277 remains unchanged. + +- `src/renderer/src/hooks/useIpcEvents.ts` — extend the `agentStatus.onSet` handler at line 816-849 with a bootstrap queue. When the dashboard gate is on, `resolvePaneKey(...).exists === false`, AND `workspaceSessionReady === false`, push `{ paneKey, payload, title }` into a module-level array (capped at ~200 entries) instead of dropping. Add a single subscription elsewhere in the same file (or in `useIpcEvents`'s mount effect) that observes `workspaceSessionReady` flipping to `true` and drains the queue: re-call `resolvePaneKey` for each entry, dropping any whose tab is still unknown (a closed-in-prior-session orphan), `setAgentStatus(...)` the rest, then clear the queue. + +- `src/renderer/src/store/slices/agent-status.ts` — extend `dropAgentStatus` at line 316 to fire `window.api.agentStatus.drop(paneKey)` after its existing zustand `set(...)` block completes. Extend `dismissRetainedAgentsByWorktree` at line 578 to collect the dismissed paneKeys inside the `set(...)` block and fire one `window.api.agentStatus.drop(paneKey)` per removed key after the set returns. No other slice changes. + +- `src/preload/index.ts` and `src/preload/api-types.ts` — expose `agentStatus.drop(paneKey: string): void` (fire-and-forget). Mirror the existing `agentStatus.onSet` shape. + +Other renderer files (`src/renderer/src/components/dashboard/useRetainedAgents.ts`, `src/renderer/src/components/dashboard/useDashboardData.ts`, `src/renderer/src/App.tsx`): **no changes**. The retention slice and dashboard memos see the hydrated entries through the existing `setAgentStatus` path once the queue drains. + +`src/shared/types.ts`, `src/shared/constants.ts`, `src/shared/agent-status-types.ts`: **no changes**. (We reuse `normalizeAgentStatusPayload` from `shared/agent-status-types.ts` but do not modify it.) + +Tests: + +- `src/main/agent-hooks/server.test.ts` — extend with: + - Sanitization unit tests: bad version, missing entries, malformed paneKey, key/embedded paneKey mismatch, payload that fails `normalizeAgentStatusPayload`. + - Persistence unit tests: a `set` schedules a write; the debounce coalesces two rapid `set`s into one write; identical-payload writes are skipped; `stop()` flushes synchronously; flipping the gate from on → off deletes the file (or no-ops if absent) and skips subsequent writes. + - Hydration unit tests: starting with a populated file populates `lastStatusByPaneKey` before the HTTP listener binds; missing file is a no-op; corrupt file is a no-op + warn. + - Listener replay test: after hydrate, registering a listener replays the hydrated entries (this exercises the contract that hydration → setListener → renderer just works). + - Gate test: with `getDashboardEnabled` returning false, `start()` skips hydration and `set` skips writes. +- Existing `useRetainedAgents.test.ts` runs unchanged. +- New renderer test in the existing agent-status slice test file: `dropAgentStatus` fires `window.api.agentStatus.drop(paneKey)` exactly once after its zustand mutation; double-drop on the same paneKey only fires once (idempotent). +- New IPC handler test in the existing main-process test suite: receiving `agentStatus:drop` with the gate on calls `clearPaneState`; with the gate off, the call is a no-op. +- New bootstrap-queue test in `useIpcEvents.test.ts` (or equivalent renderer test): an `agentStatus:set` event for an unknown paneKey while `workspaceSessionReady === false` is queued, not dropped; flipping `workspaceSessionReady` to `true` drains the queue; entries whose tab is still unknown after the drain are silently discarded; queueing past the 200-entry cap drops the new entry, not the head. Test setup must reset the module-level queue between tests (export a `__resetBootstrapQueueForTests` helper from `useIpcEvents.ts` and call it in `beforeEach`). +- New worktree-archive IPC fan-out test on the slice: calling `dismissRetainedAgentsByWorktree(worktreeId)` fires `window.api.agentStatus.drop(paneKey)` once for each retained paneKey under that worktree. +- Integration sanity check (manual or in the dashboard suite): a `done` agent finishes → quit → file is written → relaunch → renderer's `agentStatusByPaneKey` is populated within the first listener-registration tick → the inline list renders the row → dismiss → re-quit → relaunch → no row. + +Telemetry: + +- No new events. Hook server already runs unconditionally; we do not add observability for the persistence path beyond a single `console.warn` on file corruption (which would be silent today since corruption can't happen if the file does not exist). + +Follow-ups (explicitly out of this design): + +- Visible "completed N hours/days ago" indicator on hydrated rows. Today's UI shows the relative `updatedAt` which works inside a session and reads as "ages ago" cross-session. If users complain that a 3-day-old `done` row looks identical to a fresh one, the smallest fix is a tiny "completed at quit time" affordance, but that is a UX iteration on a renderer component and is not blocked by this design. +- TTL-on-hydrate (drop entries older than N days). Same rationale — easy to add inside `hydrateLastStatusFromDisk` if needed. +- Explicit cap on file size or per-pane count. The existing in-memory cache has no cap and we have not seen runaway growth in production; persisting the same shape inherits the same risk profile. + +## Non-Goals + +- Persisting `retainedAgentsByPaneKey`, `acknowledgedAgentsByPaneKey`, or `retentionSuppressedPaneKeys`. The renderer's retention slice is computed downstream from the live cache; the live cache is now persistent, so retention falls out for free. +- Daemon-side persistence. The daemon owns PTY lifetime and does not see hook events. The hook server (in the Electron main process) is the right home. +- Cross-device sync. The persisted file lives in local `userData/agent-hooks/`; running Orca on a second machine does not carry status over. +- A dedicated settings UI. The behavior is implicit when the experimental dashboard is on; no new toggle is added. +- Backfill or migration for users on prior builds. Absence of the file is the same as an empty cache. + +## Why this beats the renderer-side alternative + +An earlier draft of this design proposed mirroring `retainedAgentsByPaneKey` to `PersistedUIState`. We considered that approach and a variant that mirrored to `WorkspaceSessionState`. Both were rejected for three reasons: + +1. **Wrong source of truth.** The hook server's `lastStatusByPaneKey` is upstream of every renderer-side surface. Persisting upstream means the existing in-process replay path (`setListener`) handles the renderer-facing work for free; persisting downstream forks responsibility across processes and only addresses the `done` slice of the user-visible problem. +2. **Wrong scope.** A renderer-side retention persister, by definition, cannot resurrect a `blocked` Claude or a quiet `working` agent — those entries never made it into `retainedAgentsByPaneKey` in the first place (retention is gated on `state === 'done'`). Persisting the upstream cache covers all three states with one mechanism. +3. **Wrong file.** `PersistedUIState` holds scalar prefs and small bounded metadata; structured event payloads with multi-KB `lastAssistantMessage` strings do not belong there. `WorkspaceSessionState` is closer in spirit but inherits problem (1) and (2) above. `userData/agent-hooks/` already exists for hook-server-owned cross-restart artifacts (the endpoint file); the cache file is the natural neighbor. + +The renderer-side approach was strictly smaller code; the upstream approach is strictly more correct. The diff size for both is roughly the same once tests are factored in (one main-process file with bounded changes vs. one renderer slice + one shared type + one App.tsx init effect + sanitization), so we picked correctness. + +## Acceptance Criteria + +- **Done row preservation.** Toggle the experimental agent dashboard on. Run an agent to completion. Quit and relaunch Orca. The completion row appears in the worktree's inline agents list as soon as the renderer registers the listener (within the first frame of dashboard render). +- **Blocked row preservation.** Same flow, but quit while a Claude is sitting at a permission prompt (`blocked`). After relaunch, the blocked row is visible with the same prompt text and tool name. +- **Quiet working preservation.** Same flow, but quit while an agent is in `working` state and not currently emitting events. After relaunch, the row shows `working` with the last-known tool/prompt context. As soon as the agent emits its next event, the row updates naturally. +- **Live overwrite.** A hydrated row is overwritten when a new hook event fires for the same paneKey post-restart. No UI artifacts (no flicker, no double-rendered row, no orphan retention). +- **Reused paneKey, fresh run.** A `done` row is hydrated. The same paneKey starts a fresh agent run. The new live `working` row replaces the hydrated `done` immediately. On completion, the retention behavior matches in-session reuse — the new completion supersedes the hydrated one. +- **Dismiss is sticky across restart.** Dismiss the retained row from the inline list (X button). Quit and relaunch. The row does not reappear. See "Dismiss propagation" in Write Rules above for the IPC wiring this requires. +- **Worktree archive is sticky across restart.** Archive a worktree that has retained agents. Quit and relaunch. None of those rows reappear and `last-status.json` no longer contains them (verified by inspecting the file on disk). +- **Worktree archived between sessions.** A row is hydrated for a paneKey whose worktree was archived during the previous session. The row does not appear in the dashboard (the renderer's per-worktree filter hides it). No errors thrown. +- **Tab closed between sessions (race window).** A row is hydrated for a paneKey whose tab was closed in the gap between final write and quit. The row does not appear in the dashboard (the renderer's per-tab filter hides it). +- **Flag off the entire time.** With `experimentalAgentDashboard` off, the file is never written. `userData/agent-hooks/last-status.json` does not appear on disk. The hook server still runs (cursor-agent title-driving still works at index.ts:302-304). +- **Flag flipped off mid-session.** A populated cache exists in memory; the user toggles the flag off. The next scheduled write deletes `last-status.json` (idempotent if already absent) and subsequent scheduled writes are skipped. Memory state is unchanged. +- **Flag flipped off, then quit, then on.** With the flag off, the file was deleted by the gate-flip-off path (or never created if the user never opted in). Quit. Relaunch with the flag back on; the file is absent → hydration is a no-op → the dashboard starts clean. The first new hook event after launch will recreate the file from a fresh write. +- **Corrupt file.** Hand-edit `last-status.json` to invalid JSON. Relaunch. Single console warn, dashboard renders normally, no rows from the corrupt file. The first new event repopulates the file from a clean state. +- **Bounded renderer changes.** `src/renderer/src/components/dashboard/useRetainedAgents.ts`, `src/renderer/src/components/dashboard/useDashboardData.ts`, and `src/renderer/src/App.tsx` are not modified. Existing tests in `useRetainedAgents.test.ts` pass unchanged. The only renderer changes are: (a) the bootstrap queue in `useIpcEvents.ts`, (b) firing `window.api.agentStatus.drop(paneKey)` from `dropAgentStatus` in `agent-status.ts`, and (c) fanning out the same IPC over each removed paneKey in `dismissRetainedAgentsByWorktree`. diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index ba321dc1799..4b8b1baab09 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -1054,3 +1054,369 @@ describe('Endpoint file lifecycle', () => { } }) }) + +describe('Last-status persistence', () => { + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-laststatus-')) + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + function lastStatusPath(): string { + return join(userDataPath, 'agent-hooks', 'last-status.json') + } + + async function postHookEvent( + server: AgentHookServer, + body: Body, + path: string = '/hook/claude' + ): Promise { + const env = server.buildPtyEnv() + return fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(body) + }) + } + + it('writes last-status.json after a hook event when the gate is on', async () => { + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath, + getDashboardEnabled: () => true + }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'persist me' }) + ) + // Synchronous flush via stop() captures the trailing-debounced write. + server.flushStatusPersistSync() + expect(existsSync(lastStatusPath())).toBe(true) + const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(file.version).toBe(1) + expect(file.entries[PANE]).toMatchObject({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: expect.objectContaining({ state: 'working', prompt: 'persist me' }) + }) + } finally { + server.stop() + } + }) + + it('skips writing when the gate is off', async () => { + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath, + getDashboardEnabled: () => false + }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'gated off' }) + ) + server.flushStatusPersistSync() + expect(existsSync(lastStatusPath())).toBe(false) + } finally { + server.stop() + } + }) + + it('deletes last-status.json once after the gate flips on -> off', async () => { + let enabled = true + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath, + getDashboardEnabled: () => enabled + }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'before flip' }) + ) + server.flushStatusPersistSync() + expect(existsSync(lastStatusPath())).toBe(true) + enabled = false + // Trigger another event; the next debounced/synchronous write should + // observe the gate-off state and delete the file. + await postHookEvent( + server, + buildBody({ + hook_event_name: 'PostToolUse', + tool_name: 'Read', + tool_input: { file_path: '/x' } + }) + ) + server.flushStatusPersistSync() + expect(existsSync(lastStatusPath())).toBe(false) + } finally { + server.stop() + } + }) + + it('hydrates last-status.json into the cache before listener registration', async () => { + // Pre-populate the file directly to simulate a prior session. + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + const fileContents = { + version: 1, + entries: { + [PANE]: { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'done', + prompt: 'survived restart', + agentType: 'claude' + } + } + } + } + writeFileSync(lastStatusPath(), JSON.stringify(fileContents), 'utf8') + + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath, + getDashboardEnabled: () => true + }) + try { + const listener = vi.fn() + server.setListener(listener) + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: expect.objectContaining({ + state: 'done', + prompt: 'survived restart', + agentType: 'claude' + }) + }) + } finally { + server.stop() + } + }) + + it('skips hydration when the gate is off', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 1, + entries: { + [PANE]: { + paneKey: PANE, + payload: { state: 'done', prompt: 'should not load', agentType: 'claude' } + } + } + }), + 'utf8' + ) + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath, + getDashboardEnabled: () => false + }) + try { + const listener = vi.fn() + server.setListener(listener) + expect(listener).not.toHaveBeenCalled() + } finally { + server.stop() + } + }) + + it('treats a corrupt file as empty hydration without throwing', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync(lastStatusPath(), 'not-json{{', 'utf8') + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath, + getDashboardEnabled: () => true + }) + try { + const listener = vi.fn() + server.setListener(listener) + expect(listener).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalled() + } finally { + server.stop() + warnSpy.mockRestore() + } + }) + + it('rejects a stale version mismatch on hydrate', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 0, + entries: { + [PANE]: { + paneKey: PANE, + payload: { state: 'done', prompt: 'old version', agentType: 'claude' } + } + } + }), + 'utf8' + ) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath, + getDashboardEnabled: () => true + }) + try { + const listener = vi.fn() + server.setListener(listener) + expect(listener).not.toHaveBeenCalled() + } finally { + server.stop() + warnSpy.mockRestore() + } + }) + + it('drops entries with malformed paneKeys but keeps valid ones', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 1, + entries: { + // Missing colon — drop. + 'no-colon': { + paneKey: 'no-colon', + payload: { state: 'done', prompt: 'bad', agentType: 'claude' } + }, + // Embedded paneKey mismatch — drop. + [PANE]: { + paneKey: 'tab-x:99', + payload: { state: 'done', prompt: 'mismatch', agentType: 'claude' } + }, + // Valid. + 'tab-good:0': { + paneKey: 'tab-good:0', + tabId: 'tab-good', + payload: { state: 'done', prompt: 'survived', agentType: 'claude' } + } + } + }), + 'utf8' + ) + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath, + getDashboardEnabled: () => true + }) + try { + const listener = vi.fn() + server.setListener(listener) + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: 'tab-good:0', + payload: expect.objectContaining({ prompt: 'survived' }) + }) + ) + } finally { + server.stop() + } + }) + + it('clearPaneState evicts the entry from the on-disk file', async () => { + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath, + getDashboardEnabled: () => true + }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'about to drop' }) + ) + server.flushStatusPersistSync() + let parsed = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(parsed.entries[PANE]).toBeTruthy() + + server.clearPaneState(PANE) + server.flushStatusPersistSync() + parsed = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(parsed.entries[PANE]).toBeUndefined() + } finally { + server.stop() + } + }) + + it('skips a write when the serialized contents are byte-identical to the previous write', async () => { + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath, + getDashboardEnabled: () => true + }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'first' }) + ) + server.flushStatusPersistSync() + const firstMtime = statSync(lastStatusPath()).mtimeMs + + // Why: a no-op clearPaneState on a paneKey not in the cache is a + // mutation site that should NOT trigger a redundant write. (clear was + // designed to bail when nothing was evicted.) + server.clearPaneState('non-existent:0') + server.flushStatusPersistSync() + // Touch back to the same mtime would let the test pass spuriously, so + // assert no rewrite happened by checking that mtime is unchanged after + // a forced sync flush. + const secondMtime = statSync(lastStatusPath()).mtimeMs + expect(secondMtime).toBe(firstMtime) + } finally { + server.stop() + } + }) + + it('stop() flushes pending debounced writes synchronously', async () => { + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath, + getDashboardEnabled: () => true + }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'flush me' }) + ) + // Note: do NOT call flushStatusPersistSync explicitly — let stop() do it. + } finally { + server.stop() + } + // Why: file written even though we never explicitly flushed before stop — + // stop() must synchronously drain the pending trailing-debounced timer. + expect(existsSync(lastStatusPath())).toBe(true) + const parsed = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(parsed.entries[PANE]?.payload?.prompt).toBe('flush me') + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 92db2823b2b..215dc7dd448 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -7,6 +7,7 @@ import { mkdirSync, openSync, readdirSync, + readFileSync, readSync, renameSync, statSync, @@ -15,6 +16,7 @@ import { } from 'fs' import { join } from 'path' import { + normalizeAgentStatusPayload, parseAgentStatusPayload, type ParsedAgentStatusPayload } from '../../shared/agent-status-types' @@ -1062,6 +1064,75 @@ function getEndpointFileName(): string { return process.platform === 'win32' ? 'endpoint.cmd' : 'endpoint.env' } +// Why: name of the on-disk cache that survives Orca restart. Lives next to +// the endpoint file in userData/agent-hooks/ so all hook-server-owned cross- +// restart artifacts stay co-located. +const LAST_STATUS_FILE_NAME = 'last-status.json' + +// Why: bumping this rejects on-disk files written by older shapes — see the +// "Stale file from a prior Orca version" edge case in the design doc. A +// mismatched version is treated as a corrupt file (silent empty hydration). +const LAST_STATUS_FILE_VERSION = 1 + +// Why: trailing-edge debounce so a burst of hook events from a multi-agent +// run produces one disk write instead of N. The latency budget matches other +// hook-server batching; quit-time uses flushStatusPersistSync() for the +// guaranteed final flush. +const STATUS_PERSIST_DEBOUNCE_MS = 250 + +type LastStatusFile = { + version: number + entries: Record +} + +// Why: paneKey is `${tabId}:${paneId}` — exactly one ':' with non-empty +// segments on either side. Used both at write time (defensive) and at +// hydrate time (drop on mismatch). +function isValidPaneKey(value: unknown): value is string { + if (typeof value !== 'string' || value.length === 0) { + return false + } + const colon = value.indexOf(':') + if (colon <= 0 || colon === value.length - 1) { + return false + } + // Why: exactly one colon. ParseAgentStatusPayload elsewhere is generous, + // but the file is downstream of internal callers — anything weirder is + // corruption. + return !value.includes(':', colon + 1) +} + +function sanitizeHydratedEntry(paneKey: string, rawEntry: unknown): AgentHookEventPayload | null { + if (!isValidPaneKey(paneKey)) { + return null + } + if (typeof rawEntry !== 'object' || rawEntry === null) { + return null + } + const record = rawEntry as Record + if (record.paneKey !== paneKey) { + return null + } + const tabId = record.tabId + if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) { + return null + } + const worktreeId = record.worktreeId + if (worktreeId !== undefined && (typeof worktreeId !== 'string' || worktreeId.length === 0)) { + return null + } + const payload = normalizeAgentStatusPayload(record.payload) + if (!payload) { + return null + } + return { + paneKey, + tabId: typeof tabId === 'string' ? tabId : undefined, + worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined, + payload + } +} + // Why: every value in the endpoint file is sourced as shell. Reject any // value that contains shell/cmd metacharacters so a future field whose // value is not shell-safe-by-construction cannot command-inject via the @@ -1099,6 +1170,28 @@ export class AgentHookServer { // ENDPOINT env var on a successful write preserves the // fail-open-to-fresh-env guarantee. private endpointFileWritten = false + // Why: full path to the on-disk last-status cache. Set in start() from + // userDataPath. Null when the server runs without a userDataPath (e.g. + // tests that skip the userDataPath option) — in that case, persistence is + // a no-op and only in-memory replay applies. + private lastStatusFilePath: string | null = null + // Why: closure that reads the experimentalAgentDashboard setting at write + // time. Passed in from index.ts so the hook server stays decoupled from + // the Store class. Null when unset → persistence runs (back-compat for + // callers that haven't wired the gate yet); the design doc gates on the + // flag, so production callers always pass a closure. + private getDashboardEnabled: (() => boolean) | null = null + // Why: trailing-edge debounce timer. captured per-instance so multiple + // server instances in the same process (tests) don't share state. + private statusPersistTimer: ReturnType | null = null + // Why: identity check — skip writes when the JSON-stringified contents + // exactly match the last successful disk write. Cheap protection against + // re-firing trailing timers when nothing changed. + private lastWrittenJson: string | null = null + // Why: when the gate flips on → off, we delete the file once and skip + // subsequent scheduled writes until the gate flips back on. Tracking + // delete-attempted on the instance avoids re-stat'ing on every tick. + private deletedOnDisable = false setListener(listener: ((payload: AgentHookEventPayload) => void) | null): void { this.onAgentStatus = listener @@ -1116,7 +1209,11 @@ export class AgentHookServer { } } - async start(options?: { env?: string; userDataPath?: string }): Promise { + async start(options?: { + env?: string + userDataPath?: string + getDashboardEnabled?: () => boolean + }): Promise { if (this.server) { return } @@ -1127,9 +1224,23 @@ export class AgentHookServer { if (options?.userDataPath) { this.endpointDir = join(options.userDataPath, 'agent-hooks') this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName()) + this.lastStatusFilePath = join(this.endpointDir, LAST_STATUS_FILE_NAME) + } + if (options?.getDashboardEnabled) { + this.getDashboardEnabled = options.getDashboardEnabled } this.token = randomUUID() this.endpointFileWritten = false + this.lastWrittenJson = null + this.deletedOnDisable = false + // Why: hydrate before binding the HTTP listener so any new hook POST + // (which goes through lastStatusByPaneKey.set) runs against an already- + // populated map. The setListener() replay loop on the next window + // creation then fans hydrated entries into the renderer through the + // existing IPC path. + if (this.lastStatusFilePath && this.isDashboardEnabled()) { + this.hydrateLastStatusFromDisk() + } this.server = createServer(async (req: IncomingMessage, res: ServerResponse) => { if (req.method !== 'POST') { res.writeHead(404) @@ -1177,6 +1288,7 @@ export class AgentHookServer { const payload = normalizeHookPayload(source, body, this.env) if (payload) { lastStatusByPaneKey.set(payload.paneKey, payload) + this.scheduleStatusPersist() this.onAgentStatus?.(payload) } @@ -1223,6 +1335,12 @@ export class AgentHookServer { } stop(): void { + // Why: flush any pending debounced write to disk BEFORE we clear the + // in-memory map. Quit-time state must be captured even if the trailing + // timer was scheduled but had not yet fired; otherwise a multi-agent + // run that ended its last hook event <250 ms before quit would lose + // that final delta on relaunch. + this.flushStatusPersistSync() this.server?.close() this.server = null this.port = 0 @@ -1240,6 +1358,10 @@ export class AgentHookServer { this.endpointDir = null this.endpointFilePathCache = null this.endpointFileWritten = false + this.lastStatusFilePath = null + this.getDashboardEnabled = null + this.lastWrittenJson = null + this.deletedOnDisable = false // Why: drop all per-pane cache entries on shutdown so a subsequent start() // in the same process (e.g. during tests or a settings-driven restart) // does not inherit stale prompt/tool state from the previous run. @@ -1257,9 +1379,17 @@ export class AgentHookServer { // accumulate entries for dead panes over the process lifetime. Without // this, every closed pane leaves its prompt + tool snapshot pinned in // memory for the life of the main process. + const hadStatus = lastStatusByPaneKey.has(paneKey) lastPromptByPaneKey.delete(paneKey) lastToolByPaneKey.delete(paneKey) lastStatusByPaneKey.delete(paneKey) + // Why: only schedule a write when we actually evicted a status entry — + // dropping prompt/tool caches for a pane that never produced a hook + // event does not change the on-disk file, and skipping the write avoids + // re-stat'ing on every dead-pane teardown. + if (hadStatus) { + this.scheduleStatusPersist() + } } buildPtyEnv(): Record { @@ -1369,7 +1499,13 @@ export class AgentHookServer { const entries = readdirSync(this.endpointDir) const cutoff = Date.now() - 5 * 60 * 1000 for (const entry of entries) { - if (!entry.startsWith('.endpoint-') || !entry.endsWith('.tmp')) { + // Why: sweep both endpoint-file and last-status-file orphan tmps — + // either writer can crash mid-rename. Matching the same prefix + // pattern keeps the dir bounded without needing a separate sweep + // pass per file type. + const isEndpointTmp = entry.startsWith('.endpoint-') && entry.endsWith('.tmp') + const isLastStatusTmp = entry.startsWith('.last-status-') && entry.endsWith('.tmp') + if (!isEndpointTmp && !isLastStatusTmp) { continue } const entryPath = join(this.endpointDir, entry) @@ -1415,6 +1551,174 @@ export class AgentHookServer { } } } + + // Why: callers that haven't wired the gate get persistence enabled by + // default — matches the back-compat posture for fields like getDashboardEnabled + // being optional. Production main always passes the closure; tests opt + // out by passing a closure that returns false. + private isDashboardEnabled(): boolean { + return this.getDashboardEnabled ? this.getDashboardEnabled() === true : true + } + + private hydrateLastStatusFromDisk(): void { + if (!this.lastStatusFilePath) { + return + } + let raw: string + try { + raw = readFileSync(this.lastStatusFilePath, 'utf8') + } catch (err) { + // Why: missing file is the common case (first launch with the gate on). + // Other errors (EACCES, etc.) degrade to empty hydration with a single + // warn so the dashboard renders normally. + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn('[agent-hooks] failed to read last-status file:', err) + } + return + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + console.warn('[agent-hooks] last-status file is not valid JSON; ignoring') + return + } + if (typeof parsed !== 'object' || parsed === null) { + console.warn('[agent-hooks] last-status file is not an object; ignoring') + return + } + const file = parsed as Partial + if (file.version !== LAST_STATUS_FILE_VERSION) { + console.warn( + `[agent-hooks] last-status file version mismatch (${String( + file.version + )} != ${LAST_STATUS_FILE_VERSION}); ignoring` + ) + return + } + const entries = file.entries + if (typeof entries !== 'object' || entries === null) { + console.warn('[agent-hooks] last-status file entries missing or wrong shape; ignoring') + return + } + let hydrated = 0 + for (const [paneKey, rawEntry] of Object.entries(entries)) { + const entry = sanitizeHydratedEntry(paneKey, rawEntry) + if (entry) { + lastStatusByPaneKey.set(paneKey, entry) + hydrated += 1 + } + } + if (hydrated > 0) { + // Why: prime lastWrittenJson so an immediate scheduleStatusPersist() + // (e.g. from a hook event that arrives before any change) does not + // re-write the file with byte-identical contents. + this.lastWrittenJson = this.serializeStatusFile() + } + } + + private serializeStatusFile(): string { + const entries: Record = {} + for (const [paneKey, payload] of lastStatusByPaneKey) { + // Why: defensive — never persist invalid keys even if they slipped + // into the in-memory map somehow. Same invariant the hydrate path + // enforces. + if (!isValidPaneKey(paneKey)) { + continue + } + entries[paneKey] = payload + } + const file: LastStatusFile = { version: LAST_STATUS_FILE_VERSION, entries } + return JSON.stringify(file) + } + + private scheduleStatusPersist(): void { + if (!this.lastStatusFilePath) { + return + } + if (this.statusPersistTimer) { + return + } + this.statusPersistTimer = setTimeout(() => { + this.statusPersistTimer = null + this.runStatusPersist() + }, STATUS_PERSIST_DEBOUNCE_MS) + // Why: don't keep the event loop alive just for a status flush — quit + // already triggers flushStatusPersistSync(). On Node 12+ unref() is a + // no-op when called on an already-unref'd timer. + if (typeof this.statusPersistTimer.unref === 'function') { + this.statusPersistTimer.unref() + } + } + + flushStatusPersistSync(): void { + if (this.statusPersistTimer) { + clearTimeout(this.statusPersistTimer) + this.statusPersistTimer = null + } + if (!this.lastStatusFilePath) { + return + } + this.runStatusPersist() + } + + private runStatusPersist(): void { + if (!this.lastStatusFilePath || !this.endpointDir) { + return + } + if (!this.isDashboardEnabled()) { + // Why: when the gate flips off, delete the existing file once so the + // user has no hook-payload data on disk. Subsequent ticks no-op until + // the gate flips back on; tracking deletedOnDisable on the instance + // avoids re-stat'ing every tick. + if (this.deletedOnDisable) { + return + } + try { + unlinkSync(this.lastStatusFilePath) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn('[agent-hooks] failed to delete last-status file:', err) + } + } + this.deletedOnDisable = true + this.lastWrittenJson = null + return + } + // Why: the gate is back on after being off — clear the suppression + // flag so the next on→off transition deletes the freshly-written file + // again instead of being skipped. + this.deletedOnDisable = false + const json = this.serializeStatusFile() + if (json === this.lastWrittenJson) { + return + } + const tmpPath = join(this.endpointDir, `.last-status-${process.pid}-${randomUUID()}.tmp`) + let tmpWritten = false + try { + mkdirSync(this.endpointDir, { recursive: true, mode: 0o700 }) + if (process.platform !== 'win32') { + try { + chmodSync(this.endpointDir, 0o700) + } catch { + // best-effort + } + } + writeFileSync(tmpPath, json, { mode: 0o600 }) + tmpWritten = true + renameSync(tmpPath, this.lastStatusFilePath) + this.lastWrittenJson = json + } catch (err) { + console.warn('[agent-hooks] failed to write last-status file:', err) + if (tmpWritten) { + try { + unlinkSync(tmpPath) + } catch { + // tmp already gone + } + } + } + } } export const agentHookServer = new AgentHookServer() diff --git a/src/main/index.ts b/src/main/index.ts index 790f4cb2123..6eeb3589c90 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -563,7 +563,12 @@ app.whenReady().then(async () => { // file (PORT/TOKEN/ENV/VERSION) to a stable location. Hook scripts // source that file at invocation time so they reach the current Orca // even when the PTY's env was frozen under a prior instance. - userDataPath: app.getPath('userData') + userDataPath: app.getPath('userData'), + // Why: gate the on-disk last-status cache on the experimental dashboard + // setting. Read at write time (closure) so the gate flip takes effect + // without restarting the server. When off: hydration is skipped and + // any existing file is deleted on the next scheduled write. + getDashboardEnabled: () => store?.getSettings().experimentalAgentDashboard === true }) } catch (error) { // Why: Claude/Codex/Gemini/OpenCode/Cursor hook callbacks are sidebar diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts new file mode 100644 index 00000000000..b8445d9fd58 --- /dev/null +++ b/src/main/ipc/agent-hooks.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why: cover the agentStatus:drop IPC handler — it must propagate the +// renderer dismissal to clearPaneState so the on-disk last-status file +// evicts the entry. Gated on experimentalAgentDashboard so a non-opted-in +// renderer cannot churn the persistence path. + +const clearPaneState = vi.fn() +const onHandlers = new Map void>() +const removeHandler = vi.fn() +const removeAllListeners = vi.fn() + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + on: (channel: string, handler: (event: unknown, ...args: unknown[]) => void) => { + onHandlers.set(channel, handler) + }, + removeHandler, + removeAllListeners + } +})) + +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { + clearPaneState + } +})) + +vi.mock('../claude/hook-service', () => ({ + claudeHookService: { getStatus: vi.fn(() => ({ agent: 'claude', state: 'absent' })) } +})) +vi.mock('../codex/hook-service', () => ({ + codexHookService: { getStatus: vi.fn(() => ({ agent: 'codex', state: 'absent' })) } +})) +vi.mock('../gemini/hook-service', () => ({ + geminiHookService: { getStatus: vi.fn(() => ({ agent: 'gemini', state: 'absent' })) } +})) +vi.mock('../cursor/hook-service', () => ({ + cursorHookService: { getStatus: vi.fn(() => ({ agent: 'cursor', state: 'absent' })) } +})) + +beforeEach(() => { + clearPaneState.mockReset() + onHandlers.clear() + removeHandler.mockReset() + removeAllListeners.mockReset() +}) + +afterEach(() => { + vi.resetModules() +}) + +describe('agentStatus:drop IPC', () => { + it('forwards drop to clearPaneState when the experimental dashboard is on', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + const store = { + getSettings: () => ({ experimentalAgentDashboard: true }) + } as { getSettings: () => { experimentalAgentDashboard: boolean } } + registerAgentHookHandlers(store as unknown as Parameters[0]) + + const handler = onHandlers.get('agentStatus:drop') + expect(handler).toBeDefined() + handler!({}, 'tab-1:0') + expect(clearPaneState).toHaveBeenCalledWith('tab-1:0') + }) + + it('no-ops when the experimental dashboard is off (the gate prevents persistence churn)', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + const store = { + getSettings: () => ({ experimentalAgentDashboard: false }) + } as { getSettings: () => { experimentalAgentDashboard: boolean } } + registerAgentHookHandlers(store as unknown as Parameters[0]) + + onHandlers.get('agentStatus:drop')!({}, 'tab-1:0') + expect(clearPaneState).not.toHaveBeenCalled() + }) + + it('rejects non-string paneKey (defensive against a malformed renderer message)', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + const store = { + getSettings: () => ({ experimentalAgentDashboard: true }) + } as { getSettings: () => { experimentalAgentDashboard: boolean } } + registerAgentHookHandlers(store as unknown as Parameters[0]) + + const handler = onHandlers.get('agentStatus:drop')! + handler({}, 123) + handler({}, undefined) + handler({}, '') + expect(clearPaneState).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 1ad935ac9d1..a6d0ea57378 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -1,15 +1,17 @@ import { ipcMain } from 'electron' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { agentHookServer } from '../agent-hooks/server' import { claudeHookService } from '../claude/hook-service' import { codexHookService } from '../codex/hook-service' import { geminiHookService } from '../gemini/hook-service' import { cursorHookService } from '../cursor/hook-service' +import type { Store } from '../persistence' // Why: install/remove are intentionally not exposed to the renderer. Orca // auto-installs managed hooks at app startup (see src/main/index.ts), so a // renderer-triggered remove would be silently reverted on the next launch // and mislead the user. -export function registerAgentHookHandlers(): void { +export function registerAgentHookHandlers(store: Store): void { // Why: matches the defensive pattern in src/main/ipc/pty.ts so re-registration // never throws "Attempted to register a second handler..." if this function is // ever invoked more than once (e.g. the macOS app re-activation path that @@ -20,6 +22,23 @@ export function registerAgentHookHandlers(): void { ipcMain.removeHandler('agentHooks:codexStatus') ipcMain.removeHandler('agentHooks:geminiStatus') ipcMain.removeHandler('agentHooks:cursorStatus') + // Why: agentStatus:drop is sent fire-and-forget from the renderer via + // ipcRenderer.send(); we listen with ipcMain.on (not handle) so we don't + // round-trip a response. Removing first keeps re-registration safe even + // though the module-level registered guard already prevents re-entry today. + ipcMain.removeAllListeners('agentStatus:drop') + ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => { + if (typeof paneKey !== 'string' || paneKey.length === 0) { + return + } + // Why: gate on the same experimentalAgentDashboard flag used everywhere + // else in main. clearPaneState is itself idempotent, but the gate keeps + // a non-opted-in renderer from churning the persistence path. + if (store.getSettings().experimentalAgentDashboard !== true) { + return + } + agentHookServer.clearPaneState(paneKey) + }) // Why: errors from getStatus() (fs permission denied, homedir resolution // failure, etc.) must be reported inline via state:'error' so the sidebar can diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 2213476ca7f..348d580e4d9 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -72,7 +72,7 @@ export function registerCoreHandlers( registerClaudeUsageHandlers(claudeUsage) registerCodexUsageHandlers(codexUsage) registerCodexAccountHandlers(codexAccounts) - registerAgentHookHandlers() + registerAgentHookHandlers(store) registerAgentTrustHandlers() registerClaudeAccountHandlers(claudeAccounts) registerRateLimitHandlers(rateLimits) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c099c4afc1a..3c4a7f6ef48 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1162,6 +1162,9 @@ export type PreloadApi = { interrupted?: boolean }) => void ) => () => void + /** Drop a paneKey from the main-process hook cache and the on-disk + * last-status file. Fire-and-forget. */ + drop: (paneKey: string) => void } mobile: { listNetworkInterfaces: () => Promise<{ diff --git a/src/preload/index.ts b/src/preload/index.ts index 8075d3daf16..e694c2d932f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2180,6 +2180,13 @@ const api = { ) => callback(data) ipcRenderer.on('agentStatus:set', listener) return () => ipcRenderer.removeListener('agentStatus:set', listener) + }, + /** Drop the cached hook status for a paneKey on both sides — main-process + * cache (lastStatusByPaneKey) and on-disk last-status file. Fired from + * the renderer when the user dismisses a retained row so a relaunch + * cannot resurrect it. Fire-and-forget; no response. */ + drop: (paneKey: string): void => { + ipcRenderer.send('agentStatus:drop', paneKey) } } } diff --git a/src/renderer/src/hooks/agent-status-bootstrap-queue.test.ts b/src/renderer/src/hooks/agent-status-bootstrap-queue.test.ts new file mode 100644 index 00000000000..4ef7d175c01 --- /dev/null +++ b/src/renderer/src/hooks/agent-status-bootstrap-queue.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + __getAgentStatusBootstrapQueueLengthForTests as queueLength, + BOOTSTRAP_QUEUE_MAX, + drainAgentStatusBootstrapQueue, + enqueueAgentStatusBootstrap, + isBootstrapQueueDrained, + resetAgentStatusBootstrapQueue +} from './agent-status-bootstrap-queue' +import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' + +const PAYLOAD: ParsedAgentStatusPayload = { + state: 'done', + prompt: 'p', + agentType: 'claude' +} + +beforeEach(() => { + resetAgentStatusBootstrapQueue() +}) + +describe('agent-status bootstrap queue', () => { + it('enqueues until drained, then drops post-drain entries', () => { + expect( + enqueueAgentStatusBootstrap({ paneKey: 'tab-a:0', payload: PAYLOAD, title: undefined }) + ).toBe(true) + expect(queueLength()).toBe(1) + + const visitor = vi.fn() + drainAgentStatusBootstrapQueue(visitor) + expect(visitor).toHaveBeenCalledTimes(1) + expect(visitor).toHaveBeenCalledWith({ + paneKey: 'tab-a:0', + payload: PAYLOAD, + title: undefined + }) + expect(isBootstrapQueueDrained()).toBe(true) + + // Post-drain enqueue is a no-op so the renderer's normal "unknown tab" + // drop path takes over for genuine orphans (closed tabs). + expect( + enqueueAgentStatusBootstrap({ paneKey: 'tab-b:0', payload: PAYLOAD, title: undefined }) + ).toBe(false) + expect(queueLength()).toBe(0) + }) + + it('drain is idempotent', () => { + enqueueAgentStatusBootstrap({ paneKey: 'tab-a:0', payload: PAYLOAD, title: undefined }) + const visitor = vi.fn() + drainAgentStatusBootstrapQueue(visitor) + drainAgentStatusBootstrapQueue(visitor) + expect(visitor).toHaveBeenCalledTimes(1) + }) + + it('drops new entries when capped (preserves the head — earliest entries win under overflow)', () => { + for (let i = 0; i < BOOTSTRAP_QUEUE_MAX; i++) { + expect( + enqueueAgentStatusBootstrap({ paneKey: `tab-${i}:0`, payload: PAYLOAD, title: undefined }) + ).toBe(true) + } + expect(queueLength()).toBe(BOOTSTRAP_QUEUE_MAX) + expect( + enqueueAgentStatusBootstrap({ paneKey: 'tab-overflow:0', payload: PAYLOAD, title: undefined }) + ).toBe(false) + + const seen: string[] = [] + drainAgentStatusBootstrapQueue((e) => seen.push(e.paneKey)) + expect(seen).toContain('tab-0:0') + expect(seen).not.toContain('tab-overflow:0') + }) + + it('reset clears state including the drained flag', () => { + enqueueAgentStatusBootstrap({ paneKey: 'tab-a:0', payload: PAYLOAD, title: undefined }) + drainAgentStatusBootstrapQueue(() => {}) + expect(isBootstrapQueueDrained()).toBe(true) + resetAgentStatusBootstrapQueue() + expect(isBootstrapQueueDrained()).toBe(false) + // After reset, enqueue is allowed again. + expect( + enqueueAgentStatusBootstrap({ paneKey: 'tab-a:0', payload: PAYLOAD, title: undefined }) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/hooks/agent-status-bootstrap-queue.ts b/src/renderer/src/hooks/agent-status-bootstrap-queue.ts new file mode 100644 index 00000000000..886f32645d9 --- /dev/null +++ b/src/renderer/src/hooks/agent-status-bootstrap-queue.ts @@ -0,0 +1,73 @@ +// Why: hook events forwarded by setListener() during the main-process hook +// server start() arrive synchronously during window creation, before App.tsx +// finishes its async hydration of repos + worktrees + tabs (which is what +// populates tabsByWorktree). Without a queue, resolvePaneKey() returns +// exists=false and the event is dropped — every hydrated entry from the on- +// disk last-status cache would be invisible. Buffer "tab not yet known" +// events while workspaceSessionReady is false and drain them on the +// false→true transition. + +import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' + +export type QueuedAgentStatusEvent = { + paneKey: string + payload: ParsedAgentStatusPayload + title: string | undefined +} + +// Why: cap to keep memory bounded if something pathological holds +// workspaceSessionReady at false (e.g. a hydration failure in App.tsx). 200 is +// well above the realistic worst case of "agents per Orca user" and keeps the +// arrival order of the earliest entries — the ones with the strongest claim on +// dashboard state — intact under overflow (drop new entries, not head). +export const BOOTSTRAP_QUEUE_MAX = 200 + +let queue: QueuedAgentStatusEvent[] = [] +let drained = false + +export function enqueueAgentStatusBootstrap(entry: QueuedAgentStatusEvent): boolean { + if (drained) { + return false + } + if (queue.length >= BOOTSTRAP_QUEUE_MAX) { + return false + } + queue.push(entry) + return true +} + +export function isBootstrapQueueDrained(): boolean { + return drained +} + +/** Drain the queue exactly once. Subsequent calls no-op. The visitor receives + * each queued event and decides whether to apply it (returning a truthy + * value isn't required — the visitor handles its own application). */ +export function drainAgentStatusBootstrapQueue( + visitor: (event: QueuedAgentStatusEvent) => void +): void { + if (drained) { + return + } + drained = true + // Why: snapshot then clear before iterating so a re-entrant push during + // visitor() (race with a new live hook event landing on the same paneKey) + // is silently rejected rather than processed twice. + const snapshot = queue + queue = [] + for (const entry of snapshot) { + visitor(entry) + } +} + +/** Reset between renders/tests. Called from useIpcEvents's mount effect so + * HMR / Strict Mode double-mount cannot accumulate stale entries; also + * exported for direct test access. */ +export function resetAgentStatusBootstrapQueue(): void { + queue = [] + drained = false +} + +export function __getAgentStatusBootstrapQueueLengthForTests(): number { + return queue.length +} diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 748098a3922..dcbefabf249 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -88,6 +88,7 @@ describe('useIpcEvents updater integration', () => { vi.doMock('../store', () => ({ useAppStore: { + subscribe: vi.fn(() => () => {}), getState: () => ({ setUpdateStatus, fetchRepos: vi.fn(), @@ -311,6 +312,7 @@ describe('useIpcEvents updater integration', () => { vi.doMock('../store', () => ({ useAppStore: { + subscribe: vi.fn(() => () => {}), getState: () => storeState, setState: vi.fn((updater: (state: typeof storeState) => typeof storeState) => updater(storeState) @@ -473,6 +475,7 @@ describe('useIpcEvents updater integration', () => { vi.doMock('../store', () => ({ useAppStore: { + subscribe: vi.fn(() => () => {}), getState: () => ({ setUpdateStatus: vi.fn(), createTab, @@ -672,6 +675,7 @@ describe('useIpcEvents browser tab close routing', () => { vi.doMock('../store', () => ({ useAppStore: { + subscribe: vi.fn(() => () => {}), getState: () => ({ setUpdateStatus: vi.fn(), fetchRepos: vi.fn(), @@ -865,6 +869,7 @@ describe('useIpcEvents browser tab close routing', () => { vi.doMock('../store', () => ({ useAppStore: { + subscribe: vi.fn(() => () => {}), getState: () => ({ setUpdateStatus: vi.fn(), fetchRepos: vi.fn(), @@ -1057,6 +1062,7 @@ describe('useIpcEvents browser tab close routing', () => { vi.doMock('../store', () => ({ useAppStore: { + subscribe: vi.fn(() => () => {}), getState: () => ({ setUpdateStatus: vi.fn(), fetchRepos: vi.fn(), @@ -1251,6 +1257,7 @@ describe('useIpcEvents shortcut hint clearing', () => { vi.doMock('../store', () => ({ useAppStore: { + subscribe: vi.fn(() => () => {}), getState: () => ({ toggleSidebar, toggleRightSidebar: vi.fn(), @@ -1470,6 +1477,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { vi.doMock('../store', () => ({ useAppStore: { + subscribe: vi.fn(() => () => {}), getState: () => ({ fetchRepos: vi.fn(), fetchWorktrees, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 141cc6186b0..38570da368e 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -27,8 +27,15 @@ import { setFitOverride, hydrateOverrides } from '@/lib/pane-manager/mobile-fit- import { setDriverForPty } from '@/lib/pane-manager/mobile-driver-state' import { destroyPersistentWebview } from '@/components/browser-pane/webview-registry' +export { resetAgentStatusBootstrapQueue as __resetAgentStatusBootstrapQueueForTests } from './agent-status-bootstrap-queue' export { resolveZoomTarget } from './resolve-zoom-target' +import { + drainAgentStatusBootstrapQueue, + enqueueAgentStatusBootstrap, + resetAgentStatusBootstrapQueue +} from './agent-status-bootstrap-queue' + const ZOOM_STEP = 0.5 export function useIpcEvents(): void { @@ -812,6 +819,9 @@ export function useIpcEvents(): void { // unconditional so flipping the experimental dashboard setting takes // effect without re-running this App-mount effect; the per-event guard // inside the handler drops payloads when the setting is off. + // Why: reset the queue every effect mount so HMR / Strict Mode double-mount + // cannot accumulate stale entries across remounts. + resetAgentStatusBootstrapQueue() unsubs.push( window.api.agentStatus.onSet((data) => { const store = useAppStore.getState() @@ -844,12 +854,49 @@ export function useIpcEvents(): void { // from accumulating in agentStatusByPaneKey. const { exists, title } = resolvePaneKey(store, data.paneKey) if (!exists) { + // Why: events for unknown tabs that arrive before workspaceSessionReady + // are most likely hydrated entries from the on-disk last-status cache + // replayed by the main-process setListener() during window creation + // (which runs synchronously before App.tsx's async hydration completes). + // Buffer them and drain on the false→true transition so the dashboard + // sees them once tabsByWorktree is populated. Post-drain, fall back to + // the original drop-on-unknown-tab behavior — those are real orphans + // (closed tabs) that must not pin entries in the store. + if (!store.workspaceSessionReady) { + enqueueAgentStatusBootstrap({ paneKey: data.paneKey, payload, title }) + } return } store.setAgentStatus(data.paneKey, payload, title) }) ) + // Why: drain the bootstrap queue on the workspaceSessionReady false→true + // transition. Re-resolve each paneKey at drain time — a tab that was closed + // in the prior session won't be in the new tabsByWorktree, and dropping + // those silently is the right behavior (matches the "Tab closed between + // sessions" edge case in the design doc). + const drainNow = (): void => { + const store = useAppStore.getState() + drainAgentStatusBootstrapQueue((entry) => { + const { exists, title } = resolvePaneKey(store, entry.paneKey) + if (!exists) { + return + } + store.setAgentStatus(entry.paneKey, entry.payload, title ?? entry.title) + }) + } + if (useAppStore.getState().workspaceSessionReady) { + drainNow() + } else { + const unsubscribe = useAppStore.subscribe((state) => { + if (state.workspaceSessionReady) { + drainNow() + } + }) + unsubs.push(unsubscribe) + } + // Why: hydrate mobile-fit overrides before terminal panes run their first // attach/fit logic, so a renderer reload doesn't undo active mobile fits. void window.api.runtime.getTerminalFitOverrides().then((overrides) => { diff --git a/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts b/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts new file mode 100644 index 00000000000..dbdb02459c3 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { type AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/types' +import type { RetainedAgentEntry } from './agent-status' +import { createTestStore } from './store-test-helpers' + +// Why: dropAgentStatus and dismissRetainedAgentsByWorktree mirror the renderer- +// side dismissal to the main-process hook cache via window.api.agentStatus.drop +// so the on-disk last-status file evicts dismissed paneKeys. Without this, a +// dismissed row would re-appear after Orca restart from the hydrated cache. + +const originalWindow = (globalThis as { window?: unknown }).window + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() + if (originalWindow === undefined) { + delete (globalThis as { window?: unknown }).window + } else { + ;(globalThis as { window?: unknown }).window = originalWindow + } +}) + +function stubWindowApi(): { drop: ReturnType } { + const drop = vi.fn() + ;(globalThis as { window?: unknown }).window = { + api: { agentStatus: { drop } } + } + return { drop } +} + +describe('dropAgentStatus → IPC fan-out', () => { + it('fires window.api.agentStatus.drop exactly once after a live-entry drop', () => { + const { drop } = stubWindowApi() + const store = createTestStore() + store + .getState() + .setAgentStatus('tab-1:0', { state: 'working', prompt: 'p', agentType: 'claude' }) + + store.getState().dropAgentStatus('tab-1:0') + expect(drop).toHaveBeenCalledTimes(1) + expect(drop).toHaveBeenCalledWith('tab-1:0') + }) + + it('still fires drop on a no-op dismissal so a stale on-disk entry is cleaned up defensively', () => { + // Why: the renderer cannot know whether the main-process cache has an + // entry for this paneKey (it might have one from a hydrated session). A + // no-op renderer dismissal must still propagate so stale on-disk state + // is purged; the main-process clearPaneState is itself idempotent. + const { drop } = stubWindowApi() + const store = createTestStore() + store.getState().dropAgentStatus('tab-missing:0') + expect(drop).toHaveBeenCalledTimes(1) + expect(drop).toHaveBeenCalledWith('tab-missing:0') + }) + + it('idempotent: repeated drops on the same paneKey fire the IPC each time (main-side gate is the dedupe)', () => { + // Why: the renderer keeps drop() side-effect free relative to its own + // state — sending an extra IPC for an already-dropped paneKey is safe + // because main-side clearPaneState is a no-op when the entry is gone. + // Asserting this contract documents the renderer's hands-off posture. + const { drop } = stubWindowApi() + const store = createTestStore() + store + .getState() + .setAgentStatus('tab-1:0', { state: 'working', prompt: 'p', agentType: 'claude' }) + store.getState().dropAgentStatus('tab-1:0') + store.getState().dropAgentStatus('tab-1:0') + expect(drop).toHaveBeenCalledTimes(2) + }) +}) + +describe('dismissRetainedAgentsByWorktree → IPC fan-out', () => { + it('fires drop once per dismissed paneKey under the worktree', () => { + const { drop } = stubWindowApi() + const store = createTestStore() + const now = Date.now() + function makeRetained(paneKey: string, worktreeId: string): RetainedAgentEntry { + const entry: AgentStatusEntry = { + state: 'done', + prompt: '', + updatedAt: now, + stateStartedAt: now, + paneKey, + stateHistory: [] + } + return { + entry, + worktreeId, + tab: { id: paneKey.split(':')[0], title: 'claude' } as unknown as TerminalTab, + agentType: 'claude', + startedAt: now + } + } + store + .getState() + .retainAgents([ + makeRetained('tab-a:0', 'wt-target'), + makeRetained('tab-b:0', 'wt-target'), + makeRetained('tab-c:0', 'wt-other') + ]) + + store.getState().dismissRetainedAgentsByWorktree('wt-target') + expect(drop).toHaveBeenCalledTimes(2) + const calls = drop.mock.calls.map((c) => c[0]).sort() + expect(calls).toEqual(['tab-a:0', 'tab-b:0']) + // Why: the other worktree's retained row must remain. + expect(store.getState().retainedAgentsByPaneKey['tab-c:0']).toBeDefined() + }) + + it('does not fire IPC when no retained entries match the worktree', () => { + const { drop } = stubWindowApi() + const store = createTestStore() + store.getState().dismissRetainedAgentsByWorktree('wt-empty') + expect(drop).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 41203950b65..df4a5111d3c 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -418,6 +418,16 @@ export const createAgentStatusSlice: StateCreator freshness.schedule()) } + // Why: propagate the dismissal to the main-process hook cache so the + // on-disk last-status file evicts this paneKey on the next debounced + // write. Without this, the main process would re-hydrate the dismissed + // entry on the next launch and the row would re-appear. Fire-and-forget; + // main gates on the experimentalAgentDashboard flag. + // Why: the typeof window guard keeps the slice usable from the + // node test environment, where window is undefined. + if (typeof window !== 'undefined') { + window.api?.agentStatus?.drop?.(paneKey) + } }, dropAgentStatusByTabPrefix: (tabIdPrefix) => { @@ -576,6 +586,13 @@ export const createAgentStatusSlice: StateCreator { + // Why: collect inside set so we capture the exact paneKeys removed + // (worktree filter is applied here). After the synchronous set() + // returns, fan out a window.api.agentStatus.drop per removed key so + // the main-process hook cache (and on-disk last-status file) eviction + // matches the renderer's removal. Without this, the on-disk cache + // would resurrect the dismissed rows on the next launch. + const dismissedPaneKeys: string[] = [] set((s) => { let changed = false const next: Record = {} @@ -594,6 +611,7 @@ export const createAgentStatusSlice: StateCreator {