Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
571e4e1
fix(agent-status): retire a pane's rows only after its agent's exit i…
brennanb2025 Oct 1, 2026
62c3130
test(agent-status): pin that authority-only retirement leaves nothing…
brennanb2025 Oct 1, 2026
e5fbf14
Merge remote-tracking branch 'origin/main' into brennanb2025/retireme…
brennanb2025 Oct 1, 2026
1f9440e
fix(agent-status): verify agent exit at every command end through the…
brennanb2025 Oct 1, 2026
ce44d1a
fix(agent-status): keep an agent the host cleared out of the retained…
brennanb2025 Oct 1, 2026
9e76ff1
test(agent-status): run the STA-4557 suite on the production retireme…
brennanb2025 Oct 1, 2026
bf83e5a
fix(agent-status): let an SSH pane's relay evidence prove its agent e…
brennanb2025 Oct 1, 2026
73faf3e
refactor(agent-status): build every host's agent-hook runtime deps in…
brennanb2025 Oct 1, 2026
93b607c
fix(agent-status): prove a daemon pane's agent exit from its fenced p…
brennanb2025 Oct 1, 2026
1a61366
fix(agent-status): persist a launch token only while something vouche…
brennanb2025 Oct 1, 2026
bd5665e
fix(agent-status): refuse a relay's replay of an agent session whose …
brennanb2025 Oct 1, 2026
bcc0d64
fix(agent-status): take a WSL pane's command end as its agent's exit
brennanb2025 Oct 1, 2026
1ae4b56
fix(agent-status): read every host's shell proof through one ladder
brennanb2025 Oct 1, 2026
c6507fb
Merge remote-tracking branch 'origin/main' into brennanb2025/retireme…
brennanb2025 Oct 1, 2026
5b78bae
fix(agent-status): only a WSL root makes a local pane unprovable
brennanb2025 Oct 1, 2026
8da5a3e
test(agent-status): fail when the desktop host drops its launch-autho…
brennanb2025 Oct 1, 2026
1830b6f
fix(agent-status): re-ask an unread exit proof and never trust a pre-…
brennanb2025 Oct 1, 2026
a3a38fe
fix(agent-status): prove a daemon pane's exit on the first ask
brennanb2025 Oct 1, 2026
c37fee5
fix(agent-status): prove a macOS daemon pane's exit from this machine…
brennanb2025 Oct 1, 2026
0df09a5
fix(agent-status): narrow the daemon's fence to POSIX before reading …
brennanb2025 Oct 1, 2026
05b6c07
test(agent-status): wait on the daemon pane's verdict, not a wall-clo…
brennanb2025 Oct 1, 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
35 changes: 35 additions & 0 deletions docs/reference/agent-status-store.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,41 @@ with them would be the wrong direction. So the design is not "add a store". It
is: **route the two producers that bypass the hook server through it, then
delete the copies.**

## Removing a row

Every host row removal notifies every reader in the same step: a teardown or an ended process
emits the pane clear, a dismissal emits the status drop. Launch-authority retirement is no
exception. The host clears a pane only when its agent ended or the pane went away, so a reader
drops the row on a clear and never keeps it as a finished (Done) agent.

A command end (OSC 133;D) ends a pane's launch authority at once, because every later process in
that shell inherits the token. It does not prove the agent exited: a full-screen agent's nested
shells leak their own 133;D. So at every command end of a PTY whose panes hold a live row, the
runtime asks the execution host again, and reads its answer in this order:
1. The hook's own agent process still running keeps the row.
2. The pane's own shell proven back in front clears it as an ended process, keeping the resume
identity. A local node-pty, and the terminal daemon (which is on the same machine), prove that
from a fresh read of this machine's process table: the pane's shell, under its login(1) wrapper
on macOS, in the foreground and no job stopped. An SSH relay proves it from its fenced foreground
evidence (the shell's own process group in front, no agent named), which cannot see a stopped
job (temporary), and only from a capture that began after the command end.
3. Something else proven in front keeps the row; that command ends with its own 133;D.
4. No trustworthy answer (a host that cannot be reached, an unreadable process table, a capture
older than the command end) keeps the row, and is asked again a bounded number of times: loss
of contact is never evidence of an exit.
5. A host that answered but cannot tell (a WSL guest, a Windows daemon or relay, a daemon that
predates foreground evidence) leaves the mark as the only evidence, so it stands as the exit.
That is a presentation choice, not a proven exit. Temporary: there a nested shell's 133;D
under a live TUI drops its row, until those hosts report shell-owns-the-foreground evidence.

A kept row is not latched: the PTY's next command end asks again, and a confirmed PTY exit clears
it. A verdict acts only on the row it checked. After a proven exit, a row rewritten meanwhile is
checked again (a new agent then reads as in front, the exiting one's late hook clears); where the
host cannot tell, only the same session's own rewrite is, and another session's row is left
alone. The clear also records which session ended, so a reconnecting SSH relay's replay of that
session's cached status is refused; any newer evidence (a live event, a new turn, another
session) is admitted and drops the record.

## PR 1a: structured sessions publish into the store

No renderer behavior changes. The sidebar keeps receiving the same IPC events
Expand Down
61 changes: 61 additions & 0 deletions src/main/agent-hooks/agent-hook-runtime-deps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest'
import {
agentHookLaunchAuthorityRuntimeDeps,
agentHookStatusStoreRuntimeDeps,
wireRuntimeLaunchAuthorityReader
} from './agent-hook-runtime-deps'

// Every host builds its runtime's agent-hook deps here. Both options are optional, so a builder
// that dropped one would still typecheck: a command end would then fully retire a live agent's
// pane, and a verified exit would clear a session that started meanwhile.

type LaunchAuthorityReader = (paneKey: string) => { launchTokenHash: string | null } | null

function fakeServer() {
const wired: { reader: LaunchAuthorityReader | null } = { reader: null }
return {
wired,
getStatusSnapshot: vi.fn(() => []),
getStatusSnapshotForPane: vi.fn(() => []),
checkAgentPresence: vi.fn(async () => null),
reconcileEndedProcessForPaneKeys: vi.fn(() => 0),
attestCompatibilityAuthority: vi.fn(() => null),
retirePaneAuthority: vi.fn(),
setPaneLaunchAuthorityReader: vi.fn((reader: LaunchAuthorityReader | null) => {
wired.reader = reader
})
}
}

describe('agent-hook runtime deps', () => {
it('forward the command-end retirement options', () => {
const server = fakeServer()

agentHookLaunchAuthorityRuntimeDeps(server).retireAgentHookCompatibilityAuthority('pane', {
authorityOnly: true
})

expect(server.retirePaneAuthority).toHaveBeenCalledWith('pane', undefined, {
authorityOnly: true
})
})

it('forward the ended-process options', () => {
const server = fakeServer()
const options = { preserveResumeIdentity: true, armedRowReceivedAt: 42 }

agentHookStatusStoreRuntimeDeps(server).reconcileAgentStatusForEndedProcess(['pane'], options)

expect(server.reconcileEndedProcessForPaneKeys).toHaveBeenCalledWith(['pane'], options)
})

it('hand the hook server the runtime as its launch-authority reader', () => {
const server = fakeServer()
const runtime = { readPaneLaunchAuthority: vi.fn(() => ({ launchTokenHash: 'hash' })) }

wireRuntimeLaunchAuthorityReader(server, runtime)

expect(server.wired.reader?.('pane')).toEqual({ launchTokenHash: 'hash' })
expect(runtime.readPaneLaunchAuthority).toHaveBeenCalledWith('pane')
})
})
56 changes: 56 additions & 0 deletions src/main/agent-hooks/agent-hook-runtime-deps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { AgentHookServer } from './server'

type Server = Pick<
AgentHookServer,
| 'getStatusSnapshot'
| 'getStatusSnapshotForPane'
| 'checkAgentPresence'
| 'reconcileEndedProcessForPaneKeys'
| 'attestCompatibilityAuthority'
| 'retirePaneAuthority'
| 'setPaneLaunchAuthorityReader'
>

/** How every host's runtime reads and clears the agent-hook status store. One copy, so the desktop,
* orcad and the specs cannot drift on what a command end or an ended process passes through. */
export function agentHookStatusStoreRuntimeDeps(server: Server) {
return {
// Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at
// query time so mobile shows the same agents.
getAgentStatusSnapshot: () =>
server.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true),
// Why: the filter above hides resume-identity rows from the live-agent views, but those rows
// carry the provider session mobile native chat addresses transcripts by — Pi publishes
// identity that way and would otherwise be unreachable.
getAgentProviderSessionSnapshot: () => server.getStatusSnapshot(),
getAgentProviderSessionRowsForPane: (paneKey: string) =>
server.getStatusSnapshotForPane(paneKey),
checkHookAgentPresence: (paneKey: string) => server.checkAgentPresence(paneKey),
reconcileAgentStatusForEndedProcess: (
...args: Parameters<AgentHookServer['reconcileEndedProcessForPaneKeys']>
) => {
server.reconcileEndedProcessForPaneKeys(...args)
}
}
}

/** Orchestration launch authority: the hook server attests it, the runtime retires it. */
export function agentHookLaunchAuthorityRuntimeDeps(server: Server) {
return {
attestAgentHookCompatibilityAuthority: (
candidate: Parameters<AgentHookServer['attestCompatibilityAuthority']>[0]
) => server.attestCompatibilityAuthority(candidate),
retireAgentHookCompatibilityAuthority: (
paneKey: string,
options?: Parameters<AgentHookServer['retirePaneAuthority']>[2]
) => server.retirePaneAuthority(paneKey, undefined, options)
}
}

/** Why: the runtime owns which launch token a pane still honours; the hook server derives from it. */
export function wireRuntimeLaunchAuthorityReader(
server: Server,
runtime: { readPaneLaunchAuthority(paneKey: string): { launchTokenHash: string | null } | null }
): void {
server.setPaneLaunchAuthorityReader((paneKey) => runtime.readPaneLaunchAuthority(paneKey))
}
117 changes: 117 additions & 0 deletions src/main/agent-hooks/server-ended-session-replay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { afterEach, describe, expect, it } from 'vitest'
import { makePaneKey } from '../../shared/stable-pane-id'
import { AgentHookServer } from './server'

// A reconnecting SSH relay re-sends the status it cached for every pane. Once a pane's agent was
// proven to have exited, a replay of that same session must not bring its row back; a new turn or
// another session is newer evidence and must.

const PANE = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
const CONNECTION = 'conn-a'

const servers: AgentHookServer[] = []

afterEach(() => {
for (const server of servers.splice(0)) {
server.stop()
}
})

async function startServer(): Promise<AgentHookServer> {
const server = new AgentHookServer()
servers.push(server)
await server.start({ env: 'production' })
return server
}

function event(
agentType: 'claude' | 'codex',
hookEventName: string,
options: {
state?: 'working' | 'done'
sessionId?: string
prompt?: string
replay?: boolean
} = {}
) {
return {
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
source: agentType,
hookEventName,
...(options.sessionId ? { providerSession: { key: 'session_id', id: options.sessionId } } : {}),
...(options.replay ? { isReplay: true } : {}),
payload: { state: options.state ?? 'working', prompt: options.prompt ?? 'task', agentType }
}
}

function liveRow(server: AgentHookServer) {
return server.getStatusSnapshotForPane(PANE).find((row) => row.providerSessionOnly !== true)
}

/** The agent finished its turn, then its exit was verified at a command end. */
function doneThenVerifiedExit(
server: AgentHookServer,
agentType: 'claude' | 'codex',
sessionId?: string
) {
server.ingestRemote(event(agentType, 'UserPromptSubmit', { sessionId }), CONNECTION)
server.ingestRemote(event(agentType, 'Stop', { state: 'done', sessionId }), CONNECTION)
expect(liveRow(server)?.state).toBe('done')
server.reconcileEndedProcessForPaneKeys([PANE], {
preserveResumeIdentity: true,
armedRowReceivedAt: liveRow(server)!.receivedAt
})
expect(liveRow(server)).toBeUndefined()
}

describe('a relay replay after a verified agent exit', () => {
for (const [agentType, sessionId] of [
['claude', 'claude-session'],
['codex', undefined]
] as const) {
it(`does not bring back the ended ${agentType} session`, async () => {
const server = await startServer()
doneThenVerifiedExit(server, agentType, sessionId)

server.ingestRemote(
event(agentType, 'Stop', { state: 'done', sessionId, replay: true }),
CONNECTION
)

expect(liveRow(server)).toBeUndefined()
})
}

it('admits a replay of another session, started while the relay was away', async () => {
const server = await startServer()
doneThenVerifiedExit(server, 'claude', 'claude-session')

server.ingestRemote(
event('claude', 'Stop', { state: 'done', sessionId: 'next-session', replay: true }),
CONNECTION
)

expect(liveRow(server)?.state).toBe('done')
})

it('admits a new turn, after which a replay is ordinary evidence again', async () => {
const server = await startServer()
doneThenVerifiedExit(server, 'codex')

server.ingestRemote(event('codex', 'UserPromptSubmit', { prompt: 'next task' }), CONNECTION)
server.ingestRemote(event('codex', 'Stop', { state: 'done', replay: true }), CONNECTION)

expect(liveRow(server)?.state).toBe('done')
})

it('admits a live, non-replayed event from the ended session (it may still be alive)', async () => {
const server = await startServer()
doneThenVerifiedExit(server, 'codex')

server.ingestRemote(event('codex', 'PostToolUse'), CONNECTION)

expect(liveRow(server)?.state).toBe('working')
})
})
Loading
Loading