diff --git a/docs/agent-dashboard-retention-restart.md b/docs/agent-dashboard-retention-restart.md index 8cae44ab259..dab7e22a70d 100644 --- a/docs/agent-dashboard-retention-restart.md +++ b/docs/agent-dashboard-retention-restart.md @@ -12,9 +12,9 @@ Persisting the hook server's existing per-pane status cache fixes all three at o 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. +- Persist `lastStatusByPaneKey` (the module-level `Map` in `src/main/agent-hooks/server.ts` 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. +- Update the on-disk file when `clearPaneState(paneKey)` is called from PTY teardown (`src/main/ipc/pty.ts`), 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: @@ -29,21 +29,21 @@ Out of scope: ### 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): +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 in the HTTP handler in `server.ts`: -1. `lastStatusByPaneKey.set(paneKey, payload)` — caches the latest normalized payload per pane (server.ts:65). +1. `lastStatusByPaneKey.set(paneKey, payload)` — caches the latest normalized payload per pane (the module-level `Map` in `server.ts`). 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. +`src/main/index.ts` registers the listener on window creation. The listener forwards the payload to the renderer via `webContents.send('agentStatus:set', …)`, gated on `experimentalAgentDashboard === true` in `index.ts`. 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. Disk hydration writes into the same map. The renderer now also has a pull-based `agentStatus:getSnapshot` path, which lets startup restore wait until renderer settings and workspace tabs are ready instead of relying on early replay timing. ### 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: +The persistent-terminal daemon survives Orca quit (it is spawned `detached`, see `src/main/daemon/daemon-init.ts`, 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`. +- `agentHookServer.stop()` runs in `src/main/index.ts` during `will-quit`, clearing `lastStatusByPaneKey` (in `stop()` in `server.ts`). +- `agentHookServer.start()` runs in `src/main/index.ts` 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: @@ -51,13 +51,13 @@ So on relaunch the cache is empty, and any pane whose agent does not emit a fres - `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. +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 (in the HTTP handler in `server.ts`) — 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. +Persist `lastStatusByPaneKey` to a JSON file alongside the existing endpoint coordinate file under `userData/agent-hooks/`. The hook server already takes `userDataPath` in `start()` (in `server.ts`, called from `src/main/index.ts`) 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. +Filename: `last-status.json`. Same directory permissions as the endpoint file (0o700 on the dir, 0o600 on the file — see the directory and atomic-write helpers in `server.ts`) so the cached payloads are not readable by other local users on multi-user POSIX hosts. On-disk shape: @@ -84,7 +84,7 @@ On-disk shape: } ``` -The shape is the structural mirror of the existing `AgentHookEventPayload` type (server.ts:44-49), wrapped in a `{ version, entries }` envelope. The timing fields preserve the real age of restored rows so relaunch does not make old statuses look fresh. 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. +The shape is the structural mirror of the existing `AgentHookEventPayload` type in `server.ts`, wrapped in a `{ version, entries }` envelope. The timing fields preserve the real age of restored rows so relaunch does not make old statuses look fresh. The payload is parsed on hydrate by the existing `parseAgentStatusPayload` / `normalizeAgentStatusPayload` from `src/shared/agent-status-types.ts`, which already enforces the schema invariants the renderer assumes. Why this file and not `PersistedUIState` (the prior renderer-side draft): @@ -94,7 +94,7 @@ Why this file and not `PersistedUIState` (the prior renderer-side draft): 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. +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 (the HTTP handler in `server.ts`) 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 @@ -102,57 +102,57 @@ Why no field-level cap on payload size: the same payloads are already accepted i | 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 | +| `lastStatusByPaneKey.set(...)` in the HTTP handler in `server.ts` | New or updated payload for a pane | Schedule debounced write | +| `lastStatusByPaneKey.delete(...)` in `clearPaneState()` in `server.ts` | PTY for the pane has torn down | Schedule debounced write | +| `lastStatusByPaneKey.clear()` in `stop()` in `server.ts` | 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: 2, entries: {…} }` to `last-status.json` (atomic via tmp + rename — same pattern as `writeEndpointFile()` at server.ts:1310). +- If the gate is on, writes `{ version: 2, entries: {…} }` to `last-status.json` (atomic via tmp + rename — same pattern as `writeEndpointFile()` in `server.ts`). - 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. +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()` in `index.ts` 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.) +- Gate writes on `experimentalAgentDashboard === true`. Read the setting at write time via the same `store?.getSettings()` accessor `src/main/index.ts` 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 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 (in `index.ts`) and disk persistence. Cursor-agent's title-driving in `index.ts` 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. +`clearPaneState(paneKey)` (called from `src/main/ipc/pty.ts` 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. +Today, when a user clicks the X on a retained row, `WorktreeCardAgents.tsx` calls `dropAgentStatus(paneKey)` and `dismissRetainedAgent(paneKey)` (in `src/renderer/src/store/slices/agent-status.ts`). 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. +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.dropStatusEntry(paneKey)` (not `clearPaneState`, which would also wipe the prompt/tool caches a still-live pane needs for its next hook event). 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. +- Renderer wiring: extend the `dropAgentStatus` action in `agent-status.ts` 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.dropStatusEntry(paneKey)`. Keep it gated on the same `experimentalAgentDashboard === true` check used elsewhere in main — even though `dropStatusEntry` 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. +- `dismissRetainedAgentsByWorktree` (the worktree-scoped bulk dismissal action in `agent-status.ts`) 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` (in `agent-status.ts`, called from `terminals.ts` on tab close and 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)` (in `src/main/ipc/pty.ts`), 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. +The `agentStatus:drop` and `agentStatus:getSnapshot` channels are the IPC surface changes 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 renderer reads that hydrated map through `agentStatus:getSnapshot` after settings and workspace tabs are ready. +Hydration runs inside `start()` after the userDataPath is set up but before `server.listen()` is called (in `server.ts`) — so the in-memory map is fully populated before the first hook POST can arrive. The renderer reads that hydrated map through `agentStatus:getSnapshot` after settings and workspace tabs are ready. Order of operations on launch: -1. `agentHookServer.start({ userDataPath })` is called from `src/main/index.ts:560` before window creation. +1. `agentHookServer.start({ userDataPath })` is called from `src/main/index.ts` 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). Live pushes remain best-effort, but the startup restore no longer depends on these early sends being accepted by the renderer. +3. The HTTP server binds and starts accepting POSTs. New hook events go through the same `lastStatusByPaneKey.set` write site (in the HTTP handler in `server.ts`), overwriting hydrated entries naturally. +4. Window creation registers the renderer listener via `setListener()` in `index.ts`. Live pushes remain best-effort, but the startup restore no longer depends on these early sends being accepted by the renderer. 5. `useIpcEvents.ts` waits until `settings.experimentalAgentDashboard === true` and `workspaceSessionReady === true`, then invokes `window.api.agentStatus.getSnapshot()` and applies each surviving entry through `setAgentStatus(...)`. -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). The next persistence pass deletes any existing file so a flag-off user does not keep hook payloads on disk. +Gate hydration on `experimentalAgentDashboard === true` at launch. When the flag is off, skip reading the file (the IPC forwarding gate in `index.ts` already drops anything that arrives anyway, and we want to avoid loading event payloads into memory the user has not opted into). The next persistence pass deletes any existing file so a flag-off user does not keep hook payloads on disk. Sanitization is field-by-field rather than whole-entry rejection so a single bad pane cannot tank the whole hydration: @@ -160,7 +160,7 @@ Sanitization is field-by-field rather than whole-entry rejection so a single bad - 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.payload` must round-trip through `normalizeAgentStatusPayload` (in `src/shared/agent-status-types.ts`) — 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). - `entry.receivedAt` and `entry.stateStartedAt` must be positive finite numbers. Drop on mismatch so restored rows keep their real age instead of being restamped. @@ -171,8 +171,8 @@ Note what we do _not_ sanitize against: current worktrees, tab IDs, or daemon PT 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`) and may replay before the renderer bundle has loaded or before `tabsByWorktree` is hydrated. +- The hook server starts before the window (both calls live in `src/main/index.ts`). Hydration is therefore complete before any renderer code can register a listener. +- `setListener()` is registered synchronously inside `openMainWindow()` (in `src/main/index.ts`) and may replay before the renderer bundle has loaded or before `tabsByWorktree` is hydrated. - The renderer treats those early pushes as disposable: if settings or workspace state is not ready, `useIpcEvents.ts` ignores the push. Once both are ready, it pulls the authoritative snapshot from main and applies entries whose paneKeys still resolve to live tabs. To make hydration visible after launch, we add **one small renderer change** plus a deferred snapshot trigger: @@ -183,14 +183,14 @@ To make hydration visible after launch, we add **one small renderer change** plu This keeps `setListener()`'s synchronous replay semantics for windowless app sessions while making restart restore independent of renderer boot order. Rationale for using a snapshot instead of a renderer queue: -- 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. +- 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 in `src/main/index.ts` already depends on. - A renderer-side queue still depends on early IPC delivery and requires a bounded buffer policy. The main process already owns the durable map, so a snapshot is simpler and cannot lose entries before the renderer knows settings or tabs. The renderer slice's persistence model 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. +**Live agent re-emits a hook event for a hydrated paneKey.** The HTTP handler's `lastStatusByPaneKey.set(payload.paneKey, payload)` (in `server.ts`) 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. @@ -202,9 +202,9 @@ The renderer slice's persistence model is otherwise unchanged, and `useRetainedA **Stale file from a prior Orca version with a different schema.** The `version: 2` envelope rejects mismatches at parse time. An older Orca that wrote `version: 1` (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. +**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` 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. +**Concurrent write while reading on launch.** The atomic-rename pattern (write to `.tmp`, `renameSync` into place — same as `writeEndpointFile()` in `server.ts`) 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. @@ -219,14 +219,14 @@ 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. + - 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` (in `src/shared/agent-status-types.ts` — 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`, `clearPaneState` `delete`, and `stop()` `clear` (all in `server.ts`). 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 — see the directory and atomic-write helpers in `server.ts`). 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 in `server.ts` to match either prefix (`.endpoint-` OR `.last-status-`) so a crash mid-write of either file is cleaned up by either subsequent start. - Add `getStatusSnapshot()` so the renderer can pull the current hydrated map after its own settings/session state is ready. `setListener()` remains unchanged for the existing windowless-window recreation path. -- `src/main/index.ts` — pass `getDashboardEnabled` to `agentHookServer.start({...})` at index.ts:560-567. Forward live `agentStatus:set` payloads with `receivedAt` and `stateStartedAt`. Existing `setListener` registration at index.ts:277 remains unchanged. +- `src/main/index.ts` — pass `getDashboardEnabled` to `agentHookServer.start({...})`. Forward live `agentStatus:set` payloads with `receivedAt` and `stateStartedAt`. Existing `setListener` registration remains unchanged. -- `src/main/ipc/agent-hooks.ts` — add `agentStatus:getSnapshot`, gated on `experimentalAgentDashboard === true`, returning `agentHookServer.getStatusSnapshot()`. The existing `agentStatus:drop` handler continues to call `agentHookServer.clearPaneState(paneKey)`. +- `src/main/ipc/agent-hooks.ts` — add `agentStatus:getSnapshot`, gated on `experimentalAgentDashboard === true`, returning `agentHookServer.getStatusSnapshot()`. The existing `agentStatus:drop` handler continues to call `agentHookServer.dropStatusEntry(paneKey)`. `clearPaneState` is reserved for PTY-teardown, where wiping prompt/tool caches alongside the status entry is correct. - `src/renderer/src/hooks/useIpcEvents.ts` — make the `agentStatus.onSet` handler require both the dashboard gate and `workspaceSessionReady`. Add a store subscription that calls `window.api.agentStatus.getSnapshot()` once when those conditions become true, re-resolves each paneKey, drops unknown-tab entries, and applies the rest through `setAgentStatus` with timing metadata. @@ -248,7 +248,7 @@ Tests: - 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 tests in the existing main-process test suite: `agentStatus:drop` with the gate on calls `clearPaneState`; with the gate off, the call is a no-op. `agentStatus:getSnapshot` returns cached entries with the gate on and an empty array with the gate off. +- New IPC handler tests in the existing main-process test suite: `agentStatus:drop` with the gate on calls `dropStatusEntry`; with the gate off, the call is a no-op. `agentStatus:getSnapshot` returns cached entries with the gate on and an empty array with the gate off. - New snapshot tests in `useIpcEvents.test.ts`: early `agentStatus:set` pushes before settings/session readiness are ignored, `getSnapshot()` is pulled once after readiness, entries whose tabs are still unknown are discarded, and no snapshot is requested while the dashboard gate is off. - 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 after settings/session hydration and snapshot pull → the inline list renders the row → dismiss → re-quit → relaunch → no row. @@ -295,7 +295,7 @@ The renderer-side approach was strictly smaller code; the upstream approach is s - **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 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 in `index.ts` still works). - **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. diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index cefea11ff0b..c8ab3814b88 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1102,7 +1102,7 @@ type LastStatusFile = { // 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 { +export function isValidPaneKey(value: unknown): value is string { if (typeof value !== 'string' || value.length === 0) { return false } @@ -1434,6 +1434,21 @@ export class AgentHookServer { warnedEnvs.clear() } + /* Why: invoked from the renderer-driven agentStatus:drop IPC when a user + * dismisses a still-active pane's status row. We must NOT wipe lastPromptByPaneKey + * or lastToolByPaneKey here — the pane's agent may still be alive, and the next + * hook event would otherwise arrive with an empty prompt and missing tool + * snapshot until a fresh UserPromptSubmit lands. clearPaneState (which wipes + * all three caches) is the right shape only for PTY-teardown. + */ + dropStatusEntry(paneKey: string): void { + if (!lastStatusByPaneKey.has(paneKey)) { + return + } + lastStatusByPaneKey.delete(paneKey) + this.scheduleStatusPersist() + } + clearPaneState(paneKey: string): void { // Why: callers invoke this on PTY teardown so the per-pane caches do not // accumulate entries for dead panes over the process lifetime. Without @@ -1707,8 +1722,10 @@ export class AgentHookServer { if (!this.lastStatusFilePath) { return } + // Why: each call resets the timer; the disk write fires + // STATUS_PERSIST_DEBOUNCE_MS after the LAST event in the burst. if (this.statusPersistTimer) { - return + clearTimeout(this.statusPersistTimer) } this.statusPersistTimer = setTimeout(() => { this.statusPersistTimer = null diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index 7ef236f6e58..8cbe1b2acb7 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -1,11 +1,11 @@ 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 +// renderer dismissal to dropStatusEntry 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 dropStatusEntry = vi.fn() const getStatusSnapshot = vi.fn() const onHandlers = new Map void>() const handleHandlers = new Map unknown>() @@ -27,8 +27,17 @@ vi.mock('electron', () => ({ vi.mock('../agent-hooks/server', () => ({ agentHookServer: { - clearPaneState, + dropStatusEntry, getStatusSnapshot + }, + // Why: matches the real isValidPaneKey shape (exactly one ':' with non-empty + // halves). The IPC handler imports this validator alongside agentHookServer. + isValidPaneKey: (value: unknown): value is string => { + if (typeof value !== 'string') { + return false + } + const colon = value.indexOf(':') + return colon > 0 && colon < value.length - 1 && !value.includes(':', colon + 1) } })) @@ -46,7 +55,7 @@ vi.mock('../cursor/hook-service', () => ({ })) beforeEach(() => { - clearPaneState.mockReset() + dropStatusEntry.mockReset() getStatusSnapshot.mockReset() onHandlers.clear() handleHandlers.clear() @@ -97,7 +106,7 @@ describe('agentStatus:getSnapshot IPC', () => { }) describe('agentStatus:drop IPC', () => { - it('forwards drop to clearPaneState when the experimental dashboard is on', async () => { + it('forwards drop to dropStatusEntry when the experimental dashboard is on', async () => { const { registerAgentHookHandlers } = await import('./agent-hooks') const store = { getSettings: () => ({ experimentalAgentDashboard: true }) @@ -107,7 +116,7 @@ describe('agentStatus:drop IPC', () => { const handler = onHandlers.get('agentStatus:drop') expect(handler).toBeDefined() handler!({}, 'tab-1:0') - expect(clearPaneState).toHaveBeenCalledWith('tab-1:0') + expect(dropStatusEntry).toHaveBeenCalledWith('tab-1:0') }) it('no-ops when the experimental dashboard is off (the gate prevents persistence churn)', async () => { @@ -118,7 +127,7 @@ describe('agentStatus:drop IPC', () => { registerAgentHookHandlers(store as unknown as Parameters[0]) onHandlers.get('agentStatus:drop')!({}, 'tab-1:0') - expect(clearPaneState).not.toHaveBeenCalled() + expect(dropStatusEntry).not.toHaveBeenCalled() }) it('rejects non-string paneKey (defensive against a malformed renderer message)', async () => { @@ -129,10 +138,21 @@ describe('agentStatus:drop IPC', () => { registerAgentHookHandlers(store as unknown as Parameters[0]) const handler = onHandlers.get('agentStatus:drop')! - const bad: unknown[] = [123, undefined, '', null, {}, []] + const bad: unknown[] = [ + 123, + undefined, + '', + null, + {}, + [], + 'no-colon', // missing colon — rejected by isValidPaneKey + ':leading', // empty tabId half + 'trailing:', // empty paneId half + 'a:b:c' // multiple colons + ] for (const value of bad) { expect(() => handler({}, value)).not.toThrow() } - expect(clearPaneState).not.toHaveBeenCalled() + expect(dropStatusEntry).not.toHaveBeenCalled() }) }) diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 6e1d6688961..fdf79e0fc28 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' -import { agentHookServer } from '../agent-hooks/server' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' import { claudeHookService } from '../claude/hook-service' import { codexHookService } from '../codex/hook-service' import { geminiHookService } from '../gemini/hook-service' @@ -29,34 +30,33 @@ export function registerAgentHookHandlers(store: Store): void { // 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) { + if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { return } // Why: gate on the same experimentalAgentDashboard flag used everywhere - // else in main. clearPaneState is itself idempotent, but the gate keeps + // else in main. dropStatusEntry is itself idempotent, but the gate keeps // a non-opted-in renderer from churning the persistence path. if (store.getSettings().experimentalAgentDashboard !== true) { return } try { - agentHookServer.clearPaneState(paneKey) + // Why: dropStatusEntry (not clearPaneState) is correct here — the user is + // dismissing a status row, not tearing down a PTY. clearPaneState would also + // wipe the per-pane prompt/tool caches, which the next hook event for that + // (still-alive) pane needs to render a coherent row. + agentHookServer.dropStatusEntry(paneKey) } catch (err) { - console.warn('[agent-hooks] clearPaneState failed:', err) + console.warn('[agent-hooks] dropStatusEntry failed:', err) } }) - ipcMain.handle('agentStatus:getSnapshot', () => { + ipcMain.handle('agentStatus:getSnapshot', (): AgentStatusIpcPayload[] => { // Why: the renderer pulls this after settings + workspace hydration, so // startup cannot lose replayed statuses while its local store is still // empty. Keep the same opt-in gate as push delivery and disk writes. - try { - if (store.getSettings().experimentalAgentDashboard !== true) { - return [] - } - return agentHookServer.getStatusSnapshot() - } catch (err) { - console.warn('[agent-hooks] getStatusSnapshot failed:', err) + if (store.getSettings().experimentalAgentDashboard !== true) { return [] } + return agentHookServer.getStatusSnapshot() }) // Why: errors from getStatus() (fs permission denied, homedir resolution diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 70455743a9d..c6b8ebadd39 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -876,7 +876,14 @@ export function useIpcEvents(): void { } }) .catch((err) => { - snapshotRequestedForReadyWindow = false + // Why: keep snapshotRequestedForReadyWindow latched on failure. The + // store subscriber below fires on every update (including high-rate + // PTY ticks), so resetting the flag here would turn a persistent IPC + // failure into an unbounded retry storm. One warning per ready + // window is sufficient; the flag still clears via the gate-flip + // path above when experimentalAgentDashboard or + // workspaceSessionReady toggles off, so a fresh opt-in or workspace + // re-ready cycle will retry. console.warn('[agent-status] failed to load startup snapshot:', err) }) } 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 index dbdb02459c3..707a261af22 100644 --- a/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts +++ b/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts @@ -49,7 +49,7 @@ describe('dropAgentStatus → IPC fan-out', () => { // 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. + // is purged; the main-process dropStatusEntry is itself idempotent. const { drop } = stubWindowApi() const store = createTestStore() store.getState().dropAgentStatus('tab-missing:0') @@ -60,7 +60,7 @@ describe('dropAgentStatus → IPC fan-out', () => { 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. + // because main-side dropStatusEntry 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() diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index a7875b44183..2b4fabf4123 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -126,9 +126,9 @@ export const createAgentStatusSlice: StateCreator { const existing = s.agentStatusByPaneKey[paneKey] - // Why: startup snapshots are pulled asynchronously. If a newer live - // push already landed for this pane, ignore the older snapshot entry - // instead of rolling the visible status backward. + // Why: snapshots and live pushes share receivedAt from the same main-side + // lastStatusByPaneKey.set, so equal timestamps carry identical data. Strict < + // preserves live-after-live updates that land in the same millisecond. if (existing && updatedAt < existing.updatedAt) { return s } @@ -166,14 +166,14 @@ export const createAgentStatusSlice: StateCreator & { prompt: string } +/** + * Wire shape for agent-status IPC. Both the push channel `agentStatus:set` and the + * pull channel `agentStatus:getSnapshot` produce this shape so renderer call sites + * can apply entries through a single `setAgentStatus` path. Flattens the parsed + * payload onto pane identity + timing because the renderer's slice expects them + * destructured. + */ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { paneKey: string tabId?: string