Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
7b1c388
fix(agent-status): a cancel never hides live work
brennanb2025 Sep 23, 2026
0f4ecdb
fix(agent-status): keep a cancel's verdict and clock on every settle …
brennanb2025 Sep 24, 2026
4ae4a15
fix(agent-status): keep the shell fact on an inferred cancel so resta…
brennanb2025 Sep 24, 2026
5bbb191
fix(agent-status): a Ctrl+C at an idle main agent's prompt cancels no…
brennanb2025 Sep 24, 2026
1db9675
fix(agent-status): fold a relayed pane's cancel from its row, not the…
brennanb2025 Sep 24, 2026
bf47c27
fix(agent-status): hold a cancel verdict in the store until a new tur…
brennanb2025 Sep 24, 2026
a380c21
test(agent-status): pin Codex's evidence guard beside the main agent …
brennanb2025 Sep 24, 2026
d302b38
fix(agent-status): a prompt submission ends the cancel verdict latch
brennanb2025 Sep 24, 2026
8e90d14
fix(agent-status): derive a Codex row's interrupted flag from its mai…
brennanb2025 Sep 24, 2026
8599dea
docs(agent-status): describe cancel admission for every provider and …
brennanb2025 Sep 24, 2026
239992f
Merge remote-tracking branch 'origin/main' into brennanb2025/lead-sta…
brennanb2025 Sep 24, 2026
bff608c
docs(agent-status): correct the idle-prompt Ctrl+C claim to the measu…
brennanb2025 Sep 24, 2026
397d22f
fix(agent-status): preserve waiting relay children on cancel
brennanb2025 Sep 25, 2026
d3eb919
fix(agent-status): resolve the cancel hold before a child's permissio…
brennanb2025 Sep 25, 2026
0c5e474
test(agent-status): pin that a cancelled turn's drained subagent sett…
brennanb2025 Sep 25, 2026
130f162
fix(agent-status): keep a cancel through a restarted relay's child ho…
brennanb2025 Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 40 additions & 13 deletions docs/reference/agent-status-store.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,16 +94,16 @@ The structured feed keeps its job of projecting a session's journal into a
summary and streaming it to subscribers. On every publish it additionally
ingests the summary into the hook server as a status row:

| Row field | From |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it |
| `tabId` | `structuredAgentSessionTabId(sessionId)` |
| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) |
| `state` | `structuredAgentSessionAgentStatus(summary).state`: the lead's own status folded with its live `backgroundTasks`, so a settled lead whose subagent still runs reads `working` |
| `workingMode` | `'monitoring'` from the same fold when watch loops are the only live child work; omitted otherwise, which clears it on the row |
| `mainAgent` | the main agent's own state before the fold, its last-turn verdict (`summary.turnOutcome`, present only while idle) and its own clock; see "The main agent fact" below |
| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it |
| prompt, tool, last message, model, provider session | the summary's fields |
| Row field | From |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it |
| `tabId` | `structuredAgentSessionTabId(sessionId)` |
| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) |
| `state` | `structuredAgentSessionAgentStatus(summary).state`: the lead's own status folded with its live `backgroundTasks`, so a settled lead whose subagent still runs reads `working` |
| `workingMode` | `'monitoring'` from the same fold when watch loops are the only live child work; omitted otherwise, which clears it on the row |
| `mainAgent` | the main agent's own state before the fold, its last-turn verdict (`summary.turnOutcome`, present only while idle) and its own clock; see "The main agent fact" below |
| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it |
| prompt, tool, last message, model, provider session | the summary's fields |

Sessions with no persisted turn (`status === null`) produce no row, matching
what the chat shows. When the host revokes live ownership the row is re-set
Expand Down Expand Up @@ -243,9 +243,6 @@ divergences, pinned by name in the parity table
(`src/shared/main-agent-status-parity.test.ts`) where they are reachable, so a
reader does not mistake them for drift:

- A cancelled turn with a still-running shell reads `done` in the hook lane
and `monitoring` in the structured lane; the cancel policy that removes it
flips that row.
- The Claude hook lane holds a child's permission wait in one slot on the
displaced main agent record (`waitingAgentId`, `stateBeforeWait`), not on
the child. It publishes the displaced state as `mainAgent`, but the next
Expand All @@ -258,6 +255,36 @@ reader does not mistake them for drift:
child transcripts, so a still-running or still-asking child stops holding
the row.

How the main agent's turn ended is not a fold input. A cancel is a verdict on
the main agent, carried as `mainAgent.outcome: 'cancellation'` (and, for
readers that predate `mainAgent`, as the row's `interrupted` flag on a `done`
row); it never retires a shell, scheduled check or subagent the turn left
running. That work leaves the row only when its own inventory omits it or the
session ends, so a cancelled turn with a still-running shell reads
`monitoring` in every lane, and the parity table in
`src/shared/main-agent-status-parity.test.ts` drives that story through all of
them. The same rule governs the cancel Orca infers from Ctrl+C: for any row
that publishes `mainAgent`, the inference is admitted only when
`mainAgent.state` is `working`, so Orca does not treat a Ctrl+C at the idle
prompt of a row held open by child work as a turn cancel (Codex also keeps the
child-evidence guard, and a row without `mainAgent` keeps only that guard).
The keypress itself is not inert, though: measured live, Claude 2.1.280 stops
its background subagents on a single idle-prompt Ctrl+C (shells survive) and
Codex 0.156.1 quits outright, so refusing the inference can leave the row
showing a subagent its CLI already stopped. The synthesized row is the fold
of the cancelled main agent with the child work the pane's owner can see: the
local listener's roster for a local pane, the row's own subagents and shell fact
for a relayed one, whose provider records live on the relay.

The store holds that verdict against restatements that predate it
(`server-cancel-verdict-latch.ts`), because a relay never learns of a cancel
the desktop infers and some TUIs emit late same-turn hooks. The hold is read
off the row (`mainAgent.outcome: 'cancellation'`), never stored beside it, and
dies on a new turn (a main agent prompt submission, a changed or explicit
prompt, a session start) or the provider's own settled `mainAgent`. Child and
replayed events under the hold keep the cancelled main agent and are re-folded
with their own child evidence.

## PR 1b: the runtime's retained row store is deleted

Landed. `RuntimeAgentRowStore` is gone, and with it the retained-versus-hook
Expand Down
69 changes: 69 additions & 0 deletions src/main/agent-hooks/claude-cancel-capture.test-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Loads the Claude Code 2.1.280 cancel captures (src/shared/__fixtures__/claude-cancel-*-hooks.jsonl,
// sidecars beside them): hook payloads recorded over a real PTY, merged in time order with the
// driver's cancel and kill markers.
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { AGENT_INTERRUPT_SETTLE_MS } from '../../shared/agent-interrupt-intent'

export type CapturedHook = {
kind: 'hook'
t: number
index: number
/** `ps` rows for the rig's sleep processes, taken inside the hook. */
sleep_procs: string[]
payload: Record<string, unknown>
}
export type CapturedCancel = {
kind: 'cancel'
t: number
label: string
interrupted_painted: boolean
/** Hook indices between the cancel key and the next prompt the driver typed. */
hooks_before_next_typed_prompt: number[]
}
export type CapturedKill = { kind: 'kill'; t: number; needle: string }
export type CapturedRecord = CapturedHook | CapturedCancel | CapturedKill

export function loadCapture(name: string): CapturedRecord[] {
return readFileSync(
join(__dirname, '..', '..', 'shared', '__fixtures__', `${name}.jsonl`),
'utf8'
)
.trim()
.split('\n')
.map((line) => {
// JSON.parse returns any; the kind check below is what proves the record shape.
const parsed: CapturedRecord = JSON.parse(line)
if (parsed.kind !== 'hook' && parsed.kind !== 'cancel' && parsed.kind !== 'kill') {
throw new Error(`Unknown capture record: ${line}`)
}
return parsed
})
}

export function hookAt(records: CapturedRecord[], index: number): CapturedHook {
const hook = records.find((record) => record.kind === 'hook' && record.index === index)
if (hook?.kind !== 'hook') {
throw new Error(`Captured hook ${index} not found`)
}
return hook
}

export function cancelLabelled(records: CapturedRecord[], label: string): CapturedCancel {
const cancel = records.find((record) => record.kind === 'cancel' && record.label === label)
if (cancel?.kind !== 'cancel') {
throw new Error(`Captured cancel ${label} not found`)
}
return cancel
}

/** Whether a hook landed inside the settle window on the capture's own clock, which is when the
* renderer's baseline check drops the inference instead of sending it. */
export function hookSupersedesCancel(records: CapturedRecord[], cancel: CapturedCancel): boolean {
return records.some(
(record) =>
record.kind === 'hook' &&
record.t > cancel.t &&
(record.t - cancel.t) * 1000 < AGENT_INTERRUPT_SETTLE_MS
)
}
5 changes: 3 additions & 2 deletions src/main/agent-hooks/ended-process-reconciliation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ describe('reconcileEndedProcessForPaneKeys', () => {
})

it('clears Claude latches even when the stored row already reads done', async () => {
// An interrupted lead suppresses the gate while leaving the latch set, so a row can read `done`
// with a latch that would re-gate `working` on the pane's very next event.
// A latch can outlive the row it gated (a restored row, or one written before the inventory
// arrived), so a row can read `done` with a latch that would re-gate `working` on the pane's
// very next event.
const server = await startServer()
try {
claudeRow(server, 'done')
Expand Down
Loading
Loading