fix(notifications): a pane with hooks gets its finished banner from its hook turn only (#30620)

* fix(notifications): tie a completion banner to the hook turn it announces, not a 10 s window

A title-only working->idle edge (Claude's /btw side question fires no hook) raised a
generic "Task complete" banner because the pane's hook row was older than 10 s. On a
hook-reporting pane the hook row now decides: a row not yet announced supplies the
banner content however old it is; an already-announced row means no turn ended, so
no banner. Hookless agents keep the generic title fallback.

* fix(notifications): a pane with hooks gets its completion banner from its hook turn only

The title lanes (mounted pane and parked watcher) announced a finished task on every
working->idle title edge, even on panes whose agent reports turns through hooks. A title
spin that ends no hook turn (Claude's /btw side question, Codex's /compact) raised an extra
banner that could only be filled with the workspace name and tab title.

On a pane the hook lane is tracking, with a settled Done row from the same agent, the title
lane now defers before it claims the turn, so the hook lane's banner (with the last message)
is the only one. A BEL in the same burst waits on the hook lane's pending Done. Hookless
agents, restored rows, hooks switched off and the post-restart re-arm keep the title banner.
This replaces the previous commit's announcement memory in the dispatcher.

* fix(notifications): hook-owned turn ends settle pane bookkeeping and announce re-entered Stops

- A title idle edge that defers to the hook lane now settles the pane's turn, so quitting the agent after a /btw no longer raises a generic "Task complete" banner.
- A BEL held while the hook lane's Done is pending is superseded by that Done at fire time instead of being held until the next turn start.
- A Stop re-entered after a blocking Stop hook carries its new final message in its identity, so the hook lane announces it instead of deduping it against the first Stop.
- Title lanes ask the hook lane whether it can announce the turn end (live, agent evidence, not re-arming) instead of copying its conditions, and fall back when the agent's own hooks are switched off.
- The hook lane's non-quiet Done forwards its own snapshot, so its banner content no longer depends on the stored row's age.
- The whole-flow test now drives the real pane wiring, the real hook lane and the real dispatcher.

* fix(notifications): the host marks each real turn end, so a repeated status can't raise a second banner

The renderer told a Stop re-entered after a blocking Stop hook from a
restatement by its final message. The message also changes when the
listener's cache resets (a session reset, then an idle reminder), so a
restated finished turn raised a second, empty "finished" banner.

The execution host now classifies each provider's own turn-end event by
name and stamps an optional `turnEndedAt` on the status row. Idle
reminders, session events, replays, child re-emits and result retries
restate it; a turn-end event on an already finished row is a new end only
when the agent says it re-entered (`stop_hook_active`). Local hosts and
SSH hosts (orcad) mint it in `attachStatusTiming`; the WSL relay mints it
at admission and sends it on its envelope. The field is persisted,
forwarded over IPC and the paired-server session projection, and the
renderer's done identity is `turnEndedAt ?? stateStartedAt` (after the
existing `turnCompletedAt` precedence). `stateStartedAt` and every other
clock are unchanged.

Paired servers that predate the stamp advertise no
`agent-status.turn-ended-at.v1` capability; their panes keep the
terminal-title completion fallback exactly as before.

* test(notifications): type the old-server pane setup in the completion flow test

* fix(notifications): a blocking Stop's final banner reaches the user; /compact and quitting an idle agent stay quiet

Real-agent QA on the hook-only completion design found three gaps:

- A Stop re-entered after a blocking Stop hook could be dropped twice
  over: inside 1 s of the first banner by the replay guard (a new host
  turn-end stamp is now a real end), and by main's 5 s per-workspace
  desktop cooldown. A banner with the same notification id and changed
  text now replaces the one already shown instead of being dropped;
  every other cooldown rule is unchanged.
- On a mounted pane, a hook-owned idle edge (Codex /compact) no longer
  claimed a completion, so the burst's BEL went out as a Bell banner. The
  deferred idle edge now absorbs that BEL like a claimed completion did.
- Quitting an idle agent after a title spin raised a generic process-exit
  banner. On a pane whose hook lane owns turn ends and whose last turn is
  done, an exit ends no turn; a crash mid-turn still gets its banner.

* test(runtime): the projection reference keys on the host turn-end stamp too

* test(relay): restore the Copilot retry test while CI isolates a runner error

* test: key the hot-path projection reference on the turn-end stamp; relay retry restates the end

* fix(notifications): host turn identity per agent session; keep later blocking-Stop banners past main's cooldown

- The execution host (local main and the WSL relay, one shared reducer) now tracks which turn an
  agent session is in: Codex by its own turn id, Command Code by its transcript prompt position,
  every other agent by its prompt-opening hook. A turn end mints a new end when its turn differs
  from the last ended one, so a Codex turn woken by agent mail and a text-only Command Code turn
  get their banner; duplicates, replays, tool reopens and result retries restate it. The record
  is saved with the status row (validated on restore) and never published.
- Main's desktop and mobile notification cooldowns are back to main's code exactly.
- A later end in the same done segment (a Stop re-entered after a blocking Stop hook) that lands
  inside main's cooldown is held, latest only, and sent once after that cooldown through main's
  unchanged gates; the renderer learns main's answer to anchor it.
- One pane check gates every changed pane behavior: the pane's bound PTY runs on a host that
  stamps turn ends and its agent's continuation signal is verified (Claude, Codex). Every other
  pane, and an unbound PTY, keeps main's behavior.
- A hook-owned idle edge absorbs its burst's BEL for main's full 1.5 s; exits and replacements
  ask the owner check with the exiting agent; a title spin on a hook-owned pane opens no turn.

* test(notifications): run the whole-pane harness through main's shared desktop cooldown, not main's service module

* fix(notifications): decide hook ownership from how the host identified each turn end

The execution host now labels every classified turn end with one optional
disposition beside its end stamp, by the same rule for every agent:
identified (an unused prompt opening, a new native turn id, or a verified
re-entered Stop) mints a new end; a same-turn repeat is a proven duplicate
only when the agent supplies both a turn id and a verified continuation
flag; anything else is ambiguous and never mints. Replays and retries
restate the decided answer, and local and relay hosts carry an end only
within a done segment.

The renderer replaces the Claude/Codex pane gate with one evidence
decision: identified ends are hook-owned, ambiguous ends keep the title
lane's role, and rows without a disposition (older hosts, unclassified
agents) keep the previous behavior exactly. A title working edge on a
hook-owned pane keeps its turn bookkeeping and only the hook receipt; an
owned idle edge settles after the existing max wait, or announces through
the title path if the end turned out ambiguous. Idle and working title
edges resolve the same owner (the title's agent, else the foreground one).
The held later banner for identified continuations is unchanged.

* test(notifications): give the whole-pane harness's host event its local connection id

* fix(notifications): a settled cancellation ends the open turn, so its late Stop folds as on main

A cancel Orca inferred from Esc or Ctrl+C writes a done row with no turn-end event. The host reducer now completes the current turn key on that settled cancellation, so the same turn's late Stop is a repeat (ambiguous, no new stamp) instead of a second identified end that requested a second banner.

* fix(notifications): a settled cancellation ends the open turn on every lane, so a relay's late cancel end restates none

The desktop restates a WSL relay's end as decided. The relay never learns a
cancel the desktop inferred for a non-Claude agent, so Grok's own late
stop_cancelled reached the desktop as an identified end and gave a second
banner. One predicate now decides both lanes: after a settled cancellation
that carried no end, a restated end keeps the shown one, and a child event
carrying the cancellation also ends the open turn. The relay's own cancel
row is the main agent's, never a child's.

* fix(notifications): a relayed done segment shown without its own end keeps showing none

The restatement fold tested the previous row's cancel verdict, so the row it left behind (a late
plain stop) no longer matched and the relay's next restatement brought its end back. Fold on the
row's turn-end fact instead, which the folded row satisfies again.
This commit is contained in:
Brennan Benson
2026-10-20 22:59:57 -07:00
committed by GitHub
parent 6de974ca5b
commit fc8439e05f
58 changed files with 1741 additions and 138 deletions
@@ -0,0 +1,271 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { AgentHookServer, _internals } from './server'
import { buildBody, postHookEvent, PANE } from './server.test-fixtures'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) }))
// The execution host labels each turn end by what it can prove now: an identified end (an unused
// opening or a new turn id) or revision (a verified re-entered Stop) mints; a proven repeat keeps
// the end; anything else is ambiguous, never mints, and leaves main's title behavior to the renderer.
describe('the host-stamped turn end', () => {
let server: AgentHookServer
let directory: string
beforeEach(async () => {
_internals.resetCachesForTests()
directory = mkdtempSync(join(tmpdir(), 'orca-turn-ended-at-'))
server = new AgentHookServer()
await server.start({ env: 'production', userDataPath: directory })
})
afterEach(() => {
server.stop()
rmSync(directory, { recursive: true, force: true })
})
async function post(payload: Record<string, unknown>, path = '/hook/claude'): Promise<void> {
expect((await postHookEvent(server, buildBody(payload), path)).status).toBe(204)
// Distinct host stamps: each event lands on a later millisecond.
await new Promise((resolve) => setTimeout(resolve, 5))
}
const row = () => server.getStatusSnapshot()[0]
it('mints identified ends and revisions; an unproven repeat or a lost opening is ambiguous', async () => {
await post({ hook_event_name: 'UserPromptSubmit', prompt: 'Lint the repo' })
await post({ hook_event_name: 'Stop', last_assistant_message: 'Done.' })
const first = row()
expect(first).toMatchObject({ turnEndDisposition: 'identified' })
// Without a turn id, a repeat can't be told from a turn whose prompt hook never arrived.
await post({ hook_event_name: 'Stop', last_assistant_message: 'Done.' })
await post({ hook_event_name: 'Notification', notification_type: 'idle_prompt' })
expect(row()).toMatchObject({
turnEndedAt: first?.turnEndedAt,
turnEndDisposition: 'ambiguous'
})
// A blocking Stop hook made Claude continue; the same text still ends a new attempt.
await post({ hook_event_name: 'Stop', stop_hook_active: true, last_assistant_message: 'Done.' })
expect(row()?.turnEndedAt).toBeGreaterThan(first!.turnEndedAt!)
expect(row()).toMatchObject({ turnEndDisposition: 'identified' })
expect(row()?.stateStartedAt).toBe(first?.stateStartedAt)
// The next prompt's hook was lost: its tool work and Stop reuse no earlier end.
await post({ hook_event_name: 'PreToolUse', tool_name: 'Bash' })
await post({ hook_event_name: 'Stop', last_assistant_message: 'Second.' })
expect(row()).toMatchObject({ state: 'done', turnEndDisposition: 'ambiguous' })
expect(row()).not.toHaveProperty('turnEndedAt')
// A cancel Orca inferred from Ctrl+C ends the turn, so the turn's late Stop is no second end.
await post({ hook_event_name: 'UserPromptSubmit', prompt: 'Fix it' })
const working = row()!
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: working.receivedAt,
baselineStateStartedAt: working.stateStartedAt,
baselinePrompt: working.prompt,
baselineAgentType: 'claude',
intent: 'ctrl-c'
})
).toBe(true)
const cancelled = row()
await post({ hook_event_name: 'Stop', last_assistant_message: 'Fixed.' })
expect(row()).toMatchObject({
state: 'done',
stateStartedAt: cancelled?.stateStartedAt,
turnEndDisposition: 'ambiguous'
})
expect(row()).not.toHaveProperty('turnEndedAt')
})
it('keeps a Grok end across a session reset and the idle reminder that follows it', async () => {
const turn = { sessionId: 'session-1', promptId: 'prompt-1' }
await post({ hookEventName: 'user_prompt_submit', ...turn, prompt: 'go' }, '/hook/grok')
await post({ hookEventName: 'stop', ...turn, lastAssistantMessage: 'Shipped.' }, '/hook/grok')
const ended = row()?.turnEndedAt
expect(ended).toEqual(expect.any(Number))
await post({ hookEventName: 'session_start', sessionId: 'session-2' }, '/hook/grok')
await post(
{ hookEventName: 'notification', sessionId: 'session-2', notificationType: 'idle_prompt' },
'/hook/grok'
)
expect(row()).toMatchObject({
state: 'done',
turnEndedAt: ended,
turnEndDisposition: 'identified'
})
})
it('ends Codex turns by turn id, with or without a prompt hook in between', async () => {
const codex = (payload: Record<string, unknown>) => post(payload, '/hook/codex')
await codex({ hook_event_name: 'UserPromptSubmit', turn_id: 'turn-a', prompt: 'Plan it' })
await codex({ hook_event_name: 'Stop', turn_id: 'turn-a', last_assistant_message: 'Planned.' })
const first = row()?.turnEndedAt
expect(first).toEqual(expect.any(Number))
// Its turn id and continuation flag prove an unflagged same-turn Stop is a duplicate, even
// after a tool reopen.
await codex({ hook_event_name: 'Stop', turn_id: 'turn-a', last_assistant_message: 'Planned.' })
await codex({ hook_event_name: 'PreToolUse', turn_id: 'turn-a', tool_name: 'Bash' })
await codex({ hook_event_name: 'Stop', turn_id: 'turn-a', last_assistant_message: 'Planned.' })
expect(row()).toMatchObject({
state: 'done',
turnEndedAt: first,
turnEndDisposition: 'identified'
})
// A turn woken by agent mail runs no prompt hook and stays `done`; its own turn id ends it.
await codex({ hook_event_name: 'Stop', turn_id: 'turn-b', last_assistant_message: 'Replied.' })
const mailTurn = row()?.turnEndedAt
expect(mailTurn).toBeGreaterThan(first!)
// Codex re-fires Stop with the flag when a blocking Stop hook continues the same turn.
await codex({ hook_event_name: 'Stop', turn_id: 'turn-b', stop_hook_active: true })
expect(row()?.turnEndedAt).toBeGreaterThan(mailTurn!)
expect(row()).not.toHaveProperty('hostTurn')
})
it('ends Command Code turns by transcript position; a same-position repeat is ambiguous', async () => {
const transcriptPath = join(directory, 'command-code.jsonl')
const prompt = `${JSON.stringify({ role: 'user', content: 'Status?' })}\n`
const stop = { hook_event_name: 'Stop', transcript_path: transcriptPath }
writeFileSync(transcriptPath, prompt)
await post(stop, '/hook/command-code')
const first = row()?.turnEndedAt
expect(row()).toMatchObject({ turnEndDisposition: 'identified' })
// Its key alone doesn't prove a repeat (no verified continuation flag).
await post(stop, '/hook/command-code')
expect(row()).toMatchObject({ turnEndedAt: first, turnEndDisposition: 'ambiguous' })
writeFileSync(transcriptPath, prompt.repeat(2))
await post(stop, '/hook/command-code')
expect(row()?.turnEndedAt).toBeGreaterThan(first!)
expect(row()).toMatchObject({ turnEndDisposition: 'identified' })
})
it('restates the end when a result retry adds the late transcript text', async () => {
const transcriptPath = join(directory, 'events.jsonl')
writeFileSync(transcriptPath, '')
await post({ hook_event_name: 'UserPromptSubmit', prompt: 'Fix it' }, '/hook/copilot')
// The Stop's text isn't flushed yet; its retry re-reads the same Stop body.
const stop = { hook_event_name: 'Stop', transcript_path: transcriptPath }
await post(stop, '/hook/copilot')
const ended = row()?.turnEndedAt
expect(row()).toMatchObject({ turnEndDisposition: 'identified' })
const line = { type: 'assistant.message', data: { content: 'Tests pass.' } }
writeFileSync(transcriptPath, `${JSON.stringify(line)}\n`)
await vi.waitFor(() => expect(row()?.lastAssistantMessage).toBe('Tests pass.'))
expect(row()).toMatchObject({ turnEndedAt: ended, turnEndDisposition: 'identified' })
})
it('reaches the live push and survives a host restart, so a restatement stays the same end', async () => {
const pushed: (number | undefined)[] = []
server.setListener((status) => pushed.push(status.turnEndedAt))
const stop = { hook_event_name: 'Stop', turn_id: 'turn-a', last_assistant_message: 'Done.' }
await post(stop, '/hook/codex')
const ended = row()?.turnEndedAt
expect(pushed.at(-1)).toBe(ended)
server.flushStatusPersistSync()
server.stop()
server = new AgentHookServer()
await server.start({ env: 'production', userDataPath: directory })
expect(row()).toMatchObject({ turnEndedAt: ended, turnEndDisposition: 'identified' })
// The saved turn record still knows turn-a ended, through a working reopen too.
await post(
{ hook_event_name: 'PreToolUse', turn_id: 'turn-a', tool_name: 'Bash' },
'/hook/codex'
)
await post(stop, '/hook/codex')
expect(row()).toMatchObject({ state: 'done', turnEndedAt: ended })
})
it("keeps a relay's own end, never mints an older relay's, and restates none over an inferred cancel", () => {
const grok = { paneKey: PANE, tabId: 'tab-1', source: 'grok' as const, hasExplicitPrompt: true }
const working = {
state: 'working' as const,
prompt: 'Fix it',
agentType: 'grok' as const,
mainAgent: { state: 'working' as const, stateStartedAt: 1 }
}
const stop = { ...grok, hookEventName: 'stop', payload: { ...working, state: 'done' as const } }
server.ingestRemote(stop, 'wsl-old')
expect(row()).not.toHaveProperty('turnEndedAt')
server.ingestRemote({ ...grok, hookEventName: 'user_prompt_submit', payload: working }, 'wsl')
server.ingestRemote({ ...stop, turnEndedAt: 42, turnEndDisposition: 'ambiguous' }, 'wsl')
expect(row()).toMatchObject({ turnEndedAt: 42, turnEndDisposition: 'ambiguous' })
// The relay never learns a non-Claude cancel inferred here, so its late cancel hook carries an end.
server.ingestRemote({ ...grok, hookEventName: 'user_prompt_submit', payload: working }, 'wsl')
const turn = row()!
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: turn.receivedAt,
baselineStateStartedAt: turn.stateStartedAt,
baselinePrompt: turn.prompt,
baselineAgentType: 'grok',
intent: 'ctrl-c'
})
).toBe(true)
const cancelled = row()
const mainAgent = {
state: 'done' as const,
outcome: 'cancellation' as const,
stateStartedAt: 2
}
server.ingestRemote(
{
...grok,
hookEventName: 'stop_cancelled',
turnEndedAt: 50,
turnEndDisposition: 'identified',
payload: { ...working, state: 'done', mainAgent }
},
'wsl'
)
expect(row()).toMatchObject({ state: 'done', stateStartedAt: cancelled?.stateStartedAt })
expect(row()).not.toHaveProperty('turnEndedAt')
// A late plain stop carries no cancel, yet it and every relay restatement of it keep none.
const lateStop = {
...stop,
turnEndedAt: 50,
turnEndDisposition: 'identified' as const,
payload: {
...working,
state: 'done' as const,
mainAgent: { state: 'done' as const, stateStartedAt: 3 }
}
}
server.ingestRemote(lateStop, 'wsl')
server.ingestRemote(
{ ...lateStop, payload: { ...lateStop.payload, lastAssistantMessage: 'Done.' } },
'wsl'
)
server.ingestRemote({ ...lateStop, hookEventName: 'notification' }, 'wsl')
expect(row()).toMatchObject({ state: 'done', stateStartedAt: cancelled?.stateStartedAt })
expect(row()).not.toHaveProperty('turnEndedAt')
// The next turn reaches main as working first, so its end is the relay's again.
const next = { ...working, prompt: 'Again' }
server.ingestRemote({ ...grok, hookEventName: 'user_prompt_submit', payload: next }, 'wsl')
server.ingestRemote(
{
...stop,
turnEndedAt: 60,
turnEndDisposition: 'identified',
payload: { ...next, state: 'done' }
},
'wsl'
)
expect(row()).toMatchObject({ turnEndedAt: 60, turnEndDisposition: 'identified' })
})
})
@@ -1,7 +1,11 @@
import { normalizeHostTurnRevision } from '../../../shared/agent-hook-interrupt-reconciliation'
import { readAgentProcessPresence } from '../../../shared/agent-process-presence'
import { track } from '../../telemetry/client'
import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types'
import {
isTurnEndDisposition,
normalizeAgentStatusPayload
} from '../../../shared/agent-status-types'
import { isTurnEndedAt } from '../../../shared/agent-hook-turn-end-timing'
import { restoreShedStatusFields } from '../../../shared/agent-hook-relay'
import {
MAX_PANE_KEY_LEN,
@@ -279,6 +283,10 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS
providerSession,
providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined,
isReplay: envelope.isReplay === true ? true : undefined,
...(isTurnEndedAt(envelope.turnEndedAt) ? { turnEndedAt: envelope.turnEndedAt } : {}),
...(isTurnEndDisposition(envelope.turnEndDisposition)
? { turnEndDisposition: envelope.turnEndDisposition }
: {}),
claudeRunningNonAgentTask:
typeof envelope.claudeRunningNonAgentTask === 'boolean'
? envelope.claudeRunningNonAgentTask
@@ -3,11 +3,15 @@ import { createHash } from 'node:crypto'
import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume'
import {
isTurnEndDisposition,
normalizeAgentStatusPayload,
type AgentMainAgentStatus,
type ParsedAgentStatusPayload
} from '../../../shared/agent-status-types'
import { isAgentHookSource } from '../../../shared/agent-hook-relay'
import type { HostTurnRecord } from '../../../shared/agent-hook-listener/listener-event'
import { isTurnEndedAt } from '../../../shared/agent-hook-turn-end-timing'
import { isRecord } from '../../../shared/agent-status-child-work-value-guards'
import {
normalizeClaudePromptId,
normalizeGrokPromptId
@@ -151,6 +155,8 @@ export function sanitizeHydratedEntry(
? record.compactTrigger
: undefined
const turnStartedAt = record.turnStartedAt
const turnEndedAt = record.turnEndedAt
const hostTurn = readHostTurnRecord(record.hostTurn)
return {
paneKey,
agentPresence: readAgentProcessPresence(record.agentPresence),
@@ -176,10 +182,37 @@ export function sanitizeHydratedEntry(
stateStartedAt,
...(typeof turnStartedAt === 'number' && Number.isFinite(turnStartedAt) && turnStartedAt > 0
? { turnStartedAt }
: {})
: {}),
...(isTurnEndedAt(turnEndedAt) ? { turnEndedAt } : {}),
...(isTurnEndDisposition(record.turnEndDisposition)
? { turnEndDisposition: record.turnEndDisposition }
: {}),
...(hostTurn ? { hostTurn } : {})
}
}
function readHostTurnRecord(value: unknown): HostTurnRecord | undefined {
if (!isRecord(value)) {
return undefined
}
const { owner, currentTurnKey, completed } = value
if (typeof owner !== 'string' || typeof currentTurnKey !== 'string') {
return undefined
}
if (completed === undefined) {
return { owner, currentTurnKey }
}
return isRecord(completed) &&
typeof completed.turnKey === 'string' &&
isTurnEndedAt(completed.endedAt)
? {
owner,
currentTurnKey,
completed: { turnKey: completed.turnKey, endedAt: completed.endedAt }
}
: undefined
}
export function readPersistedLaunchTokenHash(rawEntry: unknown): string | null {
if (typeof rawEntry !== 'object' || rawEntry === null) {
return null
@@ -33,6 +33,8 @@ export type RemoteAgentStatusEnvelope = {
providerSession?: unknown
providerSessionOnly?: unknown
isReplay?: boolean
turnEndedAt?: unknown
turnEndDisposition?: unknown
/** Payload fields the relay dropped to fit an oversized frame; validated below. */
shedFields?: unknown
claudeRunningNonAgentTask?: unknown
@@ -5,6 +5,7 @@ import { getCohortAtEmit } from '../../telemetry/cohort-classifier'
import { track } from '../../telemetry/client'
import { isCommandCodeNewTurnWhileWorking } from '../../../shared/command-code-turn-boundary'
import { isNewTurnEvent } from '../../../shared/agent-hook-listener/provider-event-routing'
import { reduceHostTurnEnd } from '../../../shared/agent-hook-turn-end-timing'
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import type {
AgentStatusObservation,
@@ -157,14 +158,31 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt
? previous.stateStartedAt
: (observedAt ?? now)
const turnStartedAt = resolveTurnStartedAt(previous, payload, observedAt ?? now)
// Why: a relay's row was minted on the relay, which saw the event; main only restates it.
const { hostTurn, ...turnEnd } = reduceHostTurnEnd(
previous,
payload,
observedAt ?? now,
payload.connectionId === null
)
const {
stopHookActive: _stopHookActive,
nativeTurnKey: _nativeTurnKey,
turnEndedAt: _turnEndedAt,
turnEndDisposition: _turnEndDisposition,
hostTurn: _hostTurn,
...event
} = payload
// Why: `stateStartedAt` tracks the current state, while `receivedAt` tracks every arrival.
return {
...payload,
...event,
receivedAt: now,
evidenceObservedAt: observedAt ?? this.resolveEvidenceObservedAt(payload, previous, now),
stateStartedAt,
// Always written here, so a producer can never declare it.
turnStartedAt
turnStartedAt,
...turnEnd,
...(hostTurn ? { hostTurn } : {})
}
}
@@ -4,7 +4,11 @@ import {
type AgentProviderSessionMetadata
} from '../../../shared/agent-session-resume'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
import type { AgentStatusIpcPayload, AgentType } from '../../../shared/agent-status-types'
import {
pickHostTurnEnd,
type AgentStatusIpcPayload,
type AgentType
} from '../../../shared/agent-status-types'
import type { EnrichedAgentHookEventPayload } from './server-types'
import { AGENT_PROMPT_SENT_AGENT_KINDS } from './server-constants'
import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits'
@@ -61,6 +65,7 @@ export function toAgentStatusIpcPayload(
: {}),
stateStartedAt: entry.stateStartedAt,
...(entry.turnStartedAt !== undefined ? { turnStartedAt: entry.turnStartedAt } : {}),
...pickHostTurnEnd(entry),
...(entry.providerSession ? { providerSession: entry.providerSession } : {}),
...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}),
...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}),
@@ -5,6 +5,7 @@ import {
preparePendingGrokResultDiscovery
} from '../../../shared/agent-hook-listener/grok-result-discovery'
import type { AgentHookSource } from '../../../shared/agent-hook-relay'
import { pickHostTurnEnd } from '../../../shared/agent-status-types'
import {
shouldPollHookTranscript,
hookTranscriptWatchPath,
@@ -163,6 +164,10 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus
return
}
// Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed.
this.applyNormalizedStatus(normalized.event, normalized.onAccepted)
// The retry only adds text to the same turn end, so it restates that end instead of minting one.
this.applyNormalizedStatus(
{ ...normalized.event, ...pickHostTurnEnd(current) },
normalized.onAccepted
)
}
}
@@ -1,9 +1,11 @@
import {
AGENT_STATUS_STALE_AFTER_MS,
pickHostTurnEnd,
pickParsedAgentStatusPayload,
type AgentStatusEntry,
type AgentStatusIpcPayload,
type ParsedAgentStatusPayload
type ParsedAgentStatusPayload,
type TurnEndDisposition
} from '../../shared/agent-status-types'
import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume'
import type { ProgramStatusFacet } from '../../shared/program-status/facet'
@@ -18,6 +20,8 @@ export type RuntimeAgentRowSnapshot = {
connectionId: string | null
payload: ParsedAgentStatusPayload
stateStartedAt: number
turnEndedAt?: number
turnEndDisposition?: TurnEndDisposition
updatedAt: number
evidenceObservedAt?: number
providerSession?: AgentProviderSessionMetadata
@@ -134,6 +138,7 @@ export function selectFreshAgentRowForMobileTab(args: {
...(match.tabId ? { tabId: match.tabId } : {}),
payload: pickParsedAgentStatusPayload(match),
stateStartedAt: match.stateStartedAt ?? match.receivedAt,
...pickHostTurnEnd(match),
updatedAt: match.receivedAt,
...(match.providerSession ? { providerSession: match.providerSession } : {}),
...(match.programStatus ? { programStatus: match.programStatus } : {}),
@@ -3,7 +3,11 @@ import {
normalizeCompatibleAgentTitleForOwner
} from '../../shared/agent-title-owner'
import { resolvePaneAgentOwnerRecord } from '../../shared/pane-agent-owner'
import type { AgentStatusEntry, AgentStatusIpcPayload } from '../../shared/agent-status-types'
import {
pickHostTurnEnd,
type AgentStatusEntry,
type AgentStatusIpcPayload
} from '../../shared/agent-status-types'
import type { RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types'
import {
renewRuntimeMobileAgentStatusFromPtyTitle,
@@ -115,6 +119,7 @@ export function buildRuntimeMobileAgentStatus(
? { evidenceObservedAt: liveRow.evidenceObservedAt }
: {}),
stateStartedAt: liveRow.stateStartedAt,
...pickHostTurnEnd(liveRow),
stateHistory: [],
...(terminalHandle ? { terminalHandle } : {}),
...((pty?.worktreeId ?? liveRow.worktreeId)
@@ -2,6 +2,7 @@ import {
AGENT_STATUS_HYDRATE_MAX_AGE_MS,
AGENT_STATUS_STALE_AFTER_MS,
agentStatusAuthorityObservedAt,
pickHostTurnEnd,
pickParsedAgentStatusPayload,
type AgentStatusEntry,
type AgentStatusIpcPayload
@@ -194,6 +195,7 @@ function toHookLiveAgentRow(row: AgentStatusIpcPayload): HookLiveAgentRow {
updatedAt: row.receivedAt,
...(row.evidenceObservedAt !== undefined ? { evidenceObservedAt: row.evidenceObservedAt } : {}),
stateStartedAt: row.stateStartedAt ?? row.receivedAt,
...pickHostTurnEnd(row),
...(row.worktreeId ? { worktreeId: row.worktreeId } : {}),
...(row.programStatus ? { programStatus: row.programStatus } : {})
}
@@ -200,7 +200,14 @@ export type RuntimeTerminalProgramStatusFacetEvent = {
export type HookLiveAgentRow = Pick<
RuntimeAgentRowSnapshot,
'payload' | 'updatedAt' | 'evidenceObservedAt' | 'stateStartedAt' | 'worktreeId' | 'programStatus'
| 'payload'
| 'updatedAt'
| 'evidenceObservedAt'
| 'stateStartedAt'
| 'turnEndedAt'
| 'turnEndDisposition'
| 'worktreeId'
| 'programStatus'
>
export type TerminalHandleRecord = {
@@ -4,6 +4,7 @@ import { setMigrationUnsupportedPtyListener } from '../agent-hooks/migration-uns
import { getDashboardPopoutWindow } from '../window/dashboard-popout-window'
import { stopSyntheticTitleSpinnerTimer } from './synthetic-title-runtime'
import { mainProcessState as state } from './main-process-state'
import { pickHostTurnEnd } from '../../shared/agent-status-types'
export type MainWindowAgentStatusOptions = {
window: BrowserWindow
@@ -22,6 +23,8 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt
evidenceObservedAt,
stateStartedAt,
turnStartedAt,
turnEndedAt,
turnEndDisposition,
launchToken,
providerSession,
providerSessionOnly,
@@ -76,6 +79,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt
...(evidenceObservedAt !== undefined ? { evidenceObservedAt } : {}),
stateStartedAt,
...(turnStartedAt !== undefined ? { turnStartedAt } : {}),
...pickHostTurnEnd({ turnEndedAt, turnEndDisposition }),
...(providerSession ? { providerSession } : {}),
...(promptInteractionKey ? { promptInteractionKey } : {}),
...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
@@ -12,7 +12,10 @@
*/
import { playDesktopNotificationSound } from '@/lib/desktop-notification-sound'
import { showBlockedNotificationFallbackToast } from '@/lib/blocked-notification-fallback'
import type { NotificationDispatchRequest } from '../../../shared/notification-settings-types'
import type {
NotificationDispatchRequest,
NotificationDispatchResult
} from '../../../shared/notification-settings-types'
export type AgentAttentionNotificationSound = {
customSoundId: string
@@ -21,11 +24,13 @@ export type AgentAttentionNotificationSound = {
export function deliverAgentAttentionNotification(
request: NotificationDispatchRequest,
sound: AgentAttentionNotificationSound
sound: AgentAttentionNotificationSound,
onResult?: (result: NotificationDispatchResult | null) => void
): void {
void window.api.notifications
.dispatch(request)
.then((result) => {
onResult?.(result)
if (result.delivered) {
void playDesktopNotificationSound(sound.customSoundId, sound.customSoundVolume)
return
@@ -37,6 +42,7 @@ export function deliverAgentAttentionNotification(
}
})
.catch((err) => {
onResult?.(null)
console.warn('Failed to dispatch notification:', err)
})
}
@@ -1,9 +1,16 @@
import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types'
import type {
ParsedAgentStatusPayload,
TurnEndDisposition
} from '../../../../shared/agent-status-types'
import type { RecognizedAgentProcess } from '../../../../shared/agent-process-recognition'
import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection'
export type AgentCompletionStatusSnapshot = ParsedAgentStatusPayload & {
stateStartedAt?: number
/** The host's stamp for the turn end this `done` reports; restatements keep it. */
turnEndedAt?: number
/** How the host identified that end (`TurnEndDisposition`). */
turnEndDisposition?: TurnEndDisposition
/** Renderer-local boundary used only to reject a delayed cross-host completion. */
localStateStartedAt?: number
}
@@ -42,6 +49,16 @@ export type AgentCompletionCoordinatorOptions = {
/** Fired once per confirmed exit (settled local absence, or the host's `exited` verdict). */
onForegroundAgentExited?: (exited: RecognizedAgentProcess) => void
isLive: () => boolean
/** How the hook lane identified the turn end a title edge (null: no title known) would announce:
* `identified` it announces it; `ambiguous` the title lane keeps main's role; null not hook-owned. */
titleTurnEndEvidence?: (
title: string | null,
foregroundAgent: RecognizedAgentProcess['agent'] | null
) => 'identified' | 'ambiguous' | null
/** An idle edge whose turn end the hook lane announces still ends this output burst. */
onHookOwnedTitleIdle?: () => void
/** Whether a hook snapshot's end is hook-owned on this pane (else main's behavior exactly). */
usesHookTurnEnds?: (snapshot: AgentCompletionStatusSnapshot) => boolean
shouldPollProcessCadence?: () => boolean
// Why: a host that publishes foreground evidence with its inventory lets a
// pane without agent evidence stay push-driven instead of scheduling
@@ -67,6 +84,8 @@ export type AgentCompletionCoordinator = {
/** Another reader saw this agent in the foreground; lets the monitor confirm its exit. */
observeForegroundAgentProcess: (process: RecognizedAgentProcess) => void
hasPendingHookDoneCompletion: () => boolean
/** Whether a turn end observed now could be announced (live, with agent evidence, not re-arming). */
canAnnounceCompletion: () => boolean
resetCompletionState: (options?: { requireFreshWorking?: boolean }) => void
dispose: () => void
}
@@ -10,6 +10,7 @@ import {
getAgentCompletionCoordinatorIdentityCountForTest,
resetAgentCompletionCoordinatorIdentitiesForTest
} from './agent-completion-identity-store'
import { createTitleCompletionClaim } from './agent-completion-title-claim'
import { createAgentCompletionProcessMonitor } from './agent-completion-process-monitor'
import { createPendingTitleController } from './agent-completion-pending-title'
import {
@@ -60,6 +61,14 @@ export function createAgentCompletionCoordinator(
lastForegroundAgent: null as RecognizedAgentProcess | null,
processSession: 0
}
const titleClaim = createTitleCompletionClaim({
options,
state: completionState,
processState,
identityScope,
titleAgentIdentity: (title) => titleObserver.titleCompletionAgentIdentity(title),
dispatchTitle: (title) => titleObserver.dispatchTitle(title)
})
let processMonitor!: ReturnType<typeof createAgentCompletionProcessMonitor>
let pendingTitle!: ReturnType<typeof createPendingTitleController>
let notification!: ReturnType<typeof createAgentCompletionNotificationController>
@@ -181,7 +190,7 @@ export function createAgentCompletionCoordinator(
holdTitleCompletionPending,
hasPendingTitle: () => pendingTitle.get() !== null,
dropPendingTitle,
markTitleCompletionNotified,
claimTitleCompletion: (title) => titleClaim.claim(title),
dispatchTitleCompletion: (title) =>
dispatchCompletion('title', title, {
completionIdentity: {
@@ -194,6 +203,7 @@ export function createAgentCompletionCoordinator(
function dropPendingTitle(): void {
pendingTitle.drop()
titleClaim.cancelOwnedIdle()
}
function dispatchPendingTitleIfEligible(): void {
@@ -208,14 +218,7 @@ export function createAgentCompletionCoordinator(
}
const title = currentPendingTitle.title
dropPendingTitle()
markTitleCompletionNotified(title)
dispatchCompletion('title', title, {
completionIdentity: {
source: 'title',
identity: title,
agentIdentity: titleObserver.titleCompletionAgentIdentity(title)
}
})
titleObserver.dispatchTitle(title)
}
function holdTitleCompletionPending(title: string): void {
@@ -230,7 +233,7 @@ export function createAgentCompletionCoordinator(
recordPaneActivity()
}
function recordTitleWorking(): boolean {
function recordTitleWorking(title: string | null): boolean {
// Why: hooks can report `done` before title tracking notices the next milestone, so the working title must cancel that provisional done.
clearPendingHookDone()
if (
@@ -241,7 +244,8 @@ export function createAgentCompletionCoordinator(
}
completionState.workingStatusObserved = true
completionState.requiresFreshWorking = false
if (!hasUnconsumedStampedTail()) {
// Why: the turn is tracked even on a hook-owned pane, in case its end turns out ambiguous.
if (!titleClaim.keepsHookReceipt(title) && !hasUnconsumedStampedTail()) {
identityScope.deleteLast()
}
completionState.currentTurn += 1
@@ -250,15 +254,7 @@ export function createAgentCompletionCoordinator(
}
function observeTitleWorking(): void {
recordTitleWorking()
}
function markTitleCompletionNotified(title: string): void {
completionState.lastCompletionIdentity = {
source: 'title',
identity: title,
agentIdentity: titleObserver.titleCompletionAgentIdentity(title)
}
recordTitleWorking(null)
}
hookObserver = createAgentCompletionHookObserver({
@@ -319,6 +315,7 @@ export function createAgentCompletionCoordinator(
startProcessTracking: () => processMonitor.start(),
observeForegroundAgentProcess: processMonitor.observeRecognizedProcess,
hasPendingHookDoneCompletion: lifecycle.hasPendingHookDoneCompletion,
canAnnounceCompletion: notification.canAnnounceCompletion,
resetCompletionState: lifecycle.resetCompletionState,
dispose: lifecycle.dispose
}
@@ -179,7 +179,8 @@ export function createAgentCompletionHookObserver({
!state.workingStatusObserved &&
state.lastCompletionSource === 'hook' &&
state.lastCompletedTurn === state.currentTurn &&
Date.now() - state.lastCompletionAt >= 1_000
// Why: a new host turn-end stamp is a real end even right after a banner; replays restate it.
(Date.now() - state.lastCompletionAt >= 1_000 || payload.turnEndedAt !== undefined)
) {
state.currentTurn += 1
}
@@ -73,9 +73,13 @@ export function createAgentCompletionNotificationController({
function hookCompletionIdentity(payload: AgentCompletionStatusSnapshot): string | null {
// Why: `stateStartedAt` is pinned while the reported state does not change. A Claude pane held at `working` by background inventory would otherwise give every turn in the run the same identity.
// Why: `stateStartedAt` is also pinned across done→done, so a later turn end in one done
// segment is told from a restatement only by the host's turn-end stamp.
const timestamp = isFiniteTurnCompletedAt(payload.turnCompletedAt)
? payload.turnCompletedAt
: payload.stateStartedAt
: payload.state === 'done' && isFiniteTurnCompletedAt(payload.turnEndedAt)
? payload.turnEndedAt
: payload.stateStartedAt
if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) {
return null
}
@@ -142,6 +146,10 @@ export function createAgentCompletionNotificationController({
)
}
function canAnnounceCompletion(): boolean {
return !state.requiresFreshWorking && options.isLive() && processState.hasAgentRunEvidence
}
function dispatchCompletion(
source: CompletionSource,
title: string,
@@ -157,10 +165,7 @@ export function createAgentCompletionNotificationController({
if (source !== 'hook' && state.pendingHookDoneTimer !== null) {
return false
}
if (state.requiresFreshWorking || state.lastCompletedTurn === state.currentTurn) {
return false
}
if (!options.isLive() || !processState.hasAgentRunEvidence) {
if (!canAnnounceCompletion() || state.lastCompletedTurn === state.currentTurn) {
return false
}
const now = Date.now()
@@ -204,8 +209,12 @@ export function createAgentCompletionNotificationController({
...(optionsOverride.terminalIdleConfirmed === true ? { terminalIdleConfirmed: true } : {}),
...(optionsOverride.agentStatus ? { agentStatus: optionsOverride.agentStatus } : {})
})
} else if (optionsOverride.notifyWithoutLifecycle === true && optionsOverride.agentStatus) {
// Why: the pane is still `working`; the synthetic done must carry its own snapshot or the notification would read the pinned working row.
} else if (
optionsOverride.agentStatus &&
(optionsOverride.notifyWithoutLifecycle === true ||
options.usesHookTurnEnds?.(optionsOverride.agentStatus) === true)
) {
// Why: carry the hook's own snapshot; the stored row may be a newer state (a pinned `working`) or too old to lend content.
options.dispatchCompletion(title, {
source,
quietedHookDone: false,
@@ -284,6 +293,7 @@ export function createAgentCompletionNotificationController({
dispatchCompletion,
dispatchAttention,
scheduleHookDoneCompletion,
hasPendingHookDone: () => state.pendingHookDoneTimer !== null
hasPendingHookDone: () => state.pendingHookDoneTimer !== null,
canAnnounceCompletion
}
}
@@ -2,7 +2,7 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types'
export function isSupersededAgentCompletionSnapshot(
storedAgentStatus: Pick<AgentStatusEntry, 'state' | 'stateStartedAt'> | undefined,
storedAgentStatus: Pick<AgentStatusEntry, 'state' | 'stateStartedAt' | 'turnEndedAt'> | undefined,
snapshot: AgentCompletionStatusSnapshot | undefined
): boolean {
if (!storedAgentStatus || !snapshot) {
@@ -19,9 +19,12 @@ export function isSupersededAgentCompletionSnapshot(
}
const hasStampedTurn =
typeof snapshot.turnCompletedAt === 'number' && Number.isFinite(snapshot.turnCompletedAt)
return (
storedAgentStatus.stateStartedAt === comparableStateStartedAt &&
storedAgentStatus.state !== snapshot.state &&
!hasStampedTurn
)
if (storedAgentStatus.stateStartedAt !== comparableStateStartedAt) {
return false
}
// Why: within one done segment, a later host turn end supersedes the delayed earlier one.
return storedAgentStatus.state !== snapshot.state
? !hasStampedTurn
: snapshot.turnEndedAt !== undefined &&
(storedAgentStatus.turnEndedAt ?? 0) > snapshot.turnEndedAt
}
@@ -0,0 +1,86 @@
import type { RecognizedAgentProcess } from '../../../../shared/agent-process-recognition'
import type { AgentCompletionCoordinatorOptions } from './agent-completion-coordinator-types'
import type {
AgentCompletionIdentityScope,
LastCompletionIdentity
} from './agent-completion-identity-store'
import type { CompletionState } from './agent-completion-notification-controller'
import { AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS } from './agent-task-complete-policy'
type TitleClaimDeps = {
options: AgentCompletionCoordinatorOptions
state: CompletionState
processState: { lastForegroundAgent: RecognizedAgentProcess | null }
identityScope: AgentCompletionIdentityScope
titleAgentIdentity: (title: string) => string | null
dispatchTitle: (title: string) => void
}
/**
* The title lane's claim on a pane's turn ends. Where the hook lane owns an identified end, a
* title working edge keeps the hook's receipt (a /btw or quit spin ends no hook turn) and the idle
* edge is not claimed; it settles after main's max wait unless the host meanwhile calls the end
* ambiguous (a turn whose opening hook was lost), which main's title completion then announces.
*/
export function createTitleCompletionClaim(deps: TitleClaimDeps) {
const { options, state, identityScope } = deps
let keptHookReceipt: LastCompletionIdentity | undefined
let ownedIdleTimer: ReturnType<typeof setTimeout> | null = null
function evidence(title: string | null): 'identified' | 'ambiguous' | null {
const foreground = deps.processState.lastForegroundAgent?.agent ?? null
return options.titleTurnEndEvidence?.(title, foreground) ?? null
}
function cancelOwnedIdle(): void {
if (ownedIdleTimer !== null) {
clearTimeout(ownedIdleTimer)
ownedIdleTimer = null
}
}
function settleOwnedIdle(title: string): void {
cancelOwnedIdle()
const turn = state.currentTurn
ownedIdleTimer = setTimeout(() => {
ownedIdleTimer = null
if (state.currentTurn !== turn) {
return
}
if (evidence(title) === 'ambiguous') {
deps.dispatchTitle(title)
return
}
state.lastCompletedTurn = turn
state.workingStatusObserved = false
}, AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS)
}
return {
/** A working edge's turn: true when the hook's receipt is kept instead of deleted. */
keepsHookReceipt(title: string | null): boolean {
keptHookReceipt = evidence(title) === 'identified' ? identityScope.getLast() : undefined
return keptHookReceipt !== undefined
},
claim(title: string): boolean {
// Why: the hook lane announces this turn end; a claim would swallow its Done.
if (evidence(title) === 'identified') {
options.onHookOwnedTitleIdle?.()
settleOwnedIdle(title)
return false
}
// Why: once this turn's end is ambiguous, the receipt its working edge kept is obsolete.
if (keptHookReceipt && identityScope.getLast() === keptHookReceipt) {
identityScope.deleteLast()
}
keptHookReceipt = undefined
state.lastCompletionIdentity = {
source: 'title',
identity: title,
agentIdentity: deps.titleAgentIdentity(title)
}
return true
},
cancelOwnedIdle
}
}
@@ -9,11 +9,11 @@ type TitleObserverOptions = {
hasAgentEvidence: () => boolean
establishAgentEvidence: () => void
recordPaneActivity: () => void
recordTitleWorking: () => boolean
recordTitleWorking: (title: string) => boolean
holdTitleCompletionPending: (title: string) => void
hasPendingTitle: () => boolean
dropPendingTitle: () => void
markTitleCompletionNotified: (title: string) => void
claimTitleCompletion: (title: string) => boolean
dispatchTitleCompletion: (title: string) => void
}
@@ -27,7 +27,7 @@ export function createAgentCompletionTitleObserver({
holdTitleCompletionPending,
hasPendingTitle,
dropPendingTitle,
markTitleCompletionNotified,
claimTitleCompletion,
dispatchTitleCompletion
}: TitleObserverOptions) {
function titleCompletionAgentIdentity(title: string): string | null {
@@ -63,8 +63,9 @@ export function createAgentCompletionTitleObserver({
}
function dispatchTitle(title: string): void {
markTitleCompletionNotified(title)
dispatchTitleCompletion(title)
if (claimTitleCompletion(title)) {
dispatchTitleCompletion(title)
}
}
function observeTitle(title: string): void {
@@ -79,7 +80,7 @@ export function createAgentCompletionTitleObserver({
if (status === 'working') {
// Why: a rejected working title is a post-hook-completion replay; leave lastStatus untouched so it can't open a completion branch.
if (!recordTitleWorking()) {
if (!recordTitleWorking(title)) {
return
}
} else if (getLastStatus() === 'working') {
@@ -116,6 +117,7 @@ export function createAgentCompletionTitleObserver({
}
return {
dispatchTitle,
observeTitle,
observeClassifiedTitleCompletion,
titleCompletionAgentIdentity
@@ -0,0 +1,313 @@
import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { flushAsyncTicks } from './pty-connection-test-async'
import {
LEAF_1,
createMockTransport,
createPane,
createManager,
type MockTransport
} from './pty-connection-test-pane-fixtures'
import { buildPaneConnectionDeps } from './pty-connection-test-deps'
import { connectMockPanePty } from './pty-connection-test-connect-mock-pane'
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
import type { StoreState } from './pty-connection-test-store-state'
import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types'
import type { NotificationDispatchRequest } from '../../../../shared/notification-settings-types'
import { reserveNotificationCooldown } from '../../../../shared/notification-burst-cooldown'
import { reduceHostTurnEnd } from '../../../../shared/agent-hook-turn-end-timing'
import { pickHostTurnEnd } from '../../../../shared/agent-status-types'
import type { AgentHookEventPayload } from '../../../../shared/agent-hook-listener/listener-event'
import {
installTerminalTestGlobals,
restoreTerminalTestGlobals
} from './pty-connection-test-environment'
const PANE_KEY = makePaneKey('tab-1', LEAF_1)
const SEA = "The sea's waves carry the endless rhythm of tides."
type TimelineEvent =
| { at: number; title: string; bell?: true }
| { at: number; bell: true }
| { at: number; foreground: string | null }
| { at: number; hook: HookName; message?: string; flagged?: true }
type HookName = 'UserPromptSubmit' | 'PreToolUse' | 'Stop' | 'Notification'
// Recorded from Claude Code 2.1.280 over a real PTY (ms from launch): one prompt, then a
// `/btw` side question 45 s later. The side question spins the title and fires no hook.
// The BEL beside each idle title is the burst agents commonly emit at a turn end; the one 600 ms
// after the side question's idle title stands in for Codex /compact's (QA F5b).
const SIDE_QUESTION: TimelineEvent[] = [
{ at: 0, title: '✳ Claude Code' },
{ at: 3_416, hook: 'UserPromptSubmit' },
{ at: 3_686, title: '◐ Claude Code' },
{ at: 4_647, title: '◑ Claude Code' },
{ at: 7_050, title: '◑ The sea' },
{ at: 7_552, title: '◐ The sea' },
{ at: 8_447, hook: 'Stop', message: SEA },
{ at: 8_760, title: '✳ The sea', bell: true },
{ at: 53_328, title: '◑ The sea' },
{ at: 54_299, title: '◐ The sea' },
{ at: 55_146, title: '✳ The sea' },
{ at: 55_746, bell: true },
// Added: a title spin as the agent quits, with no idle edge before it exits (QA Q2, Codex /quit).
{ at: 60_000, title: '◐ The sea' },
// Added: goose (no hooks, no idle title) replaces Claude in the pane, works, and exits.
{ at: 62_000, foreground: 'goose' },
{ at: 80_000, title: '◐ Working' },
{ at: 90_000, foreground: null }
]
// A Stop hook that blocks twice: Stop #1's banner goes out at 6.5 s, Claude continues text-only and
// re-enters Stop with the same text, then again with the final text 3 s after that banner, both
// inside main's 5 s cooldown. A minute on an idle reminder restates the turn. Then a second turn
// whose re-entered Stop is overtaken by the user's next prompt before its cooldown passes.
const BLOCKED_STOP: TimelineEvent[] = [
{ at: 0, title: '✳ Claude Code' },
{ at: 1_000, hook: 'UserPromptSubmit' },
{ at: 1_200, title: '◐ Claude Code' },
{ at: 5_000, hook: 'Stop', message: 'Draft answer.' },
{ at: 6_000, title: '◑ Claude Code' },
{ at: 8_000, hook: 'Stop', message: 'Draft answer.', flagged: true },
{ at: 9_500, hook: 'Stop', message: 'Final answer.', flagged: true },
{ at: 9_590, title: '✳ Claude Code' },
{ at: 72_000, hook: 'Notification' },
{ at: 80_000, hook: 'UserPromptSubmit' },
{ at: 85_000, hook: 'Stop', message: 'Second turn.' },
{ at: 87_000, hook: 'Stop', message: 'Second turn, revised.', flagged: true },
{ at: 88_000, hook: 'UserPromptSubmit' },
{ at: 93_000, hook: 'Stop', message: 'Third turn.' }
]
// Prompt hooks the host never received (a POST past its 1.5 s limit). Each such end is ambiguous,
// so the pane keeps main's banners whichever of the title and the hook comes first; a verified
// tool-using continuation still gets its final banner, and a later unflagged repeat stays quiet.
const LOST_OPENINGS: TimelineEvent[] = [
{ at: 0, title: '✳ Claude Code' },
{ at: 1_000, hook: 'UserPromptSubmit' },
{ at: 1_200, title: '◐ Claude Code' },
{ at: 5_000, hook: 'Stop', message: 'First.' },
{ at: 5_300, title: '✳ Claude Code' },
// Text-only, the idle title before the Stop.
{ at: 20_000, title: '◐ Claude Code' },
{ at: 24_000, title: '✳ Claude Code' },
{ at: 24_800, hook: 'Stop', message: 'Second.' },
{ at: 40_000, hook: 'UserPromptSubmit' },
{ at: 40_200, title: '◐ Claude Code' },
{ at: 44_000, hook: 'Stop', message: 'Draft.' },
{ at: 46_000, hook: 'PreToolUse' },
{ at: 52_000, hook: 'Stop', message: 'Final.', flagged: true },
{ at: 52_300, title: '✳ Claude Code' },
// Text-only, the working title before the missing prompt hook.
{ at: 70_000, title: '◐ Claude Code' },
{ at: 74_000, hook: 'Stop', message: 'Third.' },
{ at: 74_300, title: '✳ Claude Code' },
// Tool-using, the working title first.
{ at: 90_000, title: '◐ Claude Code' },
{ at: 90_500, hook: 'PreToolUse' },
{ at: 95_000, hook: 'Stop', message: 'Fourth.' },
{ at: 95_300, title: '✳ Claude Code' },
{ at: 110_000, hook: 'Stop', message: 'Fourth.' }
]
describe('Claude completion banners through the real pane and hook lanes', () => {
let mockStoreState: StoreState
let transportQueue: MockTransport[] = []
let transportOptions: Record<string, unknown>[] = []
let storeSubscribers: ((state: StoreState) => void)[] = []
let foreground: string | null = 'claude'
let banners: NotificationDispatchRequest[] = []
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
transportQueue = []
transportOptions = []
storeSubscribers = []
foreground = 'claude'
mockStoreState = createInitialStoreState(() => mockStoreState)
bindPtyConnectionTestMocks({
getState: () => mockStoreState,
getTransportQueue: () => transportQueue,
getTransportOptions: () => transportOptions,
getStoreSubscribers: () => storeSubscribers,
setStoreSubscribers: (subscribers) => {
storeSubscribers = subscribers
}
})
await installTerminalTestGlobals()
vi.useFakeTimers()
vi.mocked(window.api.pty.getForegroundProcess).mockImplementation(async () => foreground)
vi.mocked(window.api.pty.hasChildProcesses).mockImplementation(async () => foreground !== null)
// Main's desktop cooldown, keyed as main's delivery service keys it, decides what the user sees.
banners = []
const recentDesktop = new Map<string, number>()
vi.mocked(window.api.notifications.dispatch).mockImplementation(async (request) => {
const key = request.attentionKey ?? request.worktreeId ?? 'global'
if (!reserveNotificationCooldown(recentDesktop, key, Date.now())) {
return { delivered: false, reason: 'cooldown' }
}
banners.push(request)
return { delivered: true }
})
})
afterEach(async () => {
await restoreTerminalTestGlobals()
})
async function play(timeline: TimelineEvent[], servedBy?: string): Promise<void> {
const { dispatchTerminalNotification } = await import('./use-notification-dispatch')
const { observeAgentHookCompletionForNotification } =
await import('@/hooks/agent-hook-completion-notifications')
const transport = createMockTransport('tab-pty')
transport.connect.mockImplementation(async () => null)
transportQueue.push(transport)
const deps = buildPaneConnectionDeps(() => mockStoreState, {
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' },
dispatchNotification: (event: Parameters<typeof dispatchTerminalNotification>[1]) =>
dispatchTerminalNotification('wt-1', event)
})
await connectMockPanePty(createPane(1), createManager(1), deps)
await vi.advanceTimersByTimeAsync(20)
await flushAsyncTicks(20)
if (servedBy) {
// A pane of a paired server that predates the turn-end stamp (it advertises no capability).
const ptyId = `remote:${servedBy}@@pty-1`
const layout = mockStoreState.terminalLayoutsByTabId?.['tab-1']
if (!layout) {
throw new Error('Expected the pane layout')
}
vi.mocked(transport.getPtyId).mockReturnValue(ptyId)
mockStoreState.ptyIdsByTabId = { 'tab-1': [ptyId] }
mockStoreState.terminalLayoutsByTabId = {
'tab-1': { ...layout, ptyIdsByLeafId: { [LEAF_1]: ptyId } }
}
mockStoreState.runtimeStatusByEnvironmentId.set(servedBy, {
checkedAt: Date.now(),
status: { capabilities: [] }
})
}
// Stand in for the transport's title tracker: every title, plus its working/idle edge.
const transportHandler = (name: string) => {
const handler: unknown = transportOptions[0]?.[name]
if (typeof handler !== 'function') {
throw new Error(`Expected the pane to register ${name}`)
}
return handler
}
let working = false
let elapsed = 0
let hostRow: (AgentHookEventPayload & { stateStartedAt?: number }) | undefined
for (const event of timeline) {
await vi.advanceTimersByTimeAsync(event.at - elapsed)
elapsed = event.at
if ('foreground' in event) {
foreground = event.foreground
continue
}
if (!('title' in event) && 'bell' in event) {
transportHandler('onBell')()
continue
}
if ('title' in event) {
transportHandler('onTitleChange')(event.title, event.title)
const nowWorking = !event.title.startsWith('✳')
if (nowWorking !== working) {
transportHandler(nowWorking ? 'onAgentBecameWorking' : 'onAgentBecameIdle')(event.title)
}
working = nowWorking
if (event.bell) {
transportHandler('onBell')()
}
continue
}
// The host's own derivation: its state clock, end stamp and disposition.
const hookEvent: AgentHookEventPayload = {
paneKey: PANE_KEY,
connectionId: null,
source: 'claude',
hookEventName: event.hook,
...(event.flagged ? { stopHookActive: true } : {}),
payload: {
state: event.hook === 'Stop' || event.hook === 'Notification' ? 'done' : 'working',
prompt: 'Reply with one short sentence.',
agentType: 'claude',
...(event.message ? { lastAssistantMessage: event.message } : {})
}
}
const stateStartedAt =
hostRow?.payload.state === hookEvent.payload.state ? hostRow.stateStartedAt : Date.now()
hostRow = { ...hookEvent, ...reduceHostTurnEnd(hostRow, hookEvent, Date.now(), true) }
hostRow.stateStartedAt = stateStartedAt
const payload: AgentCompletionStatusSnapshot = {
...hookEvent.payload,
stateStartedAt,
...pickHostTurnEnd(hostRow)
}
mockStoreState.agentStatusByPaneKey[PANE_KEY] = {
...payload,
paneKey: PANE_KEY,
updatedAt: Date.now(),
stateHistory: []
}
observeAgentHookCompletionForNotification({ paneKey: PANE_KEY, worktreeId: 'wt-1', payload })
}
await vi.advanceTimersByTimeAsync(5_000)
}
const rich = (message: string) =>
expect.objectContaining({ source: 'agent-task-complete', agentLastAssistantMessage: message })
it('announces the turn once; a /btw, its late BEL and quitting stay quiet, a hookless successor does not', async () => {
await play(SIDE_QUESTION)
// Claude is replaced in the pane by goose (no hooks), which later exits.
foreground = 'goose'
await vi.advanceTimersByTimeAsync(15_000)
foreground = null
await vi.advanceTimersByTimeAsync(15_000)
expect(banners).toEqual([
expect.objectContaining({ agentType: 'claude', agentLastAssistantMessage: SEA }),
expect.objectContaining({ source: 'agent-task-complete', terminalTitle: 'goose' })
])
})
it("keeps main's banners for a /btw on a pane whose paired server predates the stamp", async () => {
await play(SIDE_QUESTION, 'old-server')
// The hook banner wins the burst; the /btw gets main's generic title banner, which absorbs its BEL.
expect(banners).toEqual([
rich(SEA),
expect.objectContaining({ source: 'agent-task-complete', terminalTitle: '✳ The sea' })
])
expect(banners[1]).not.toHaveProperty('agentLastAssistantMessage')
})
it("keeps the latest end of a blocking Stop hook turn past main's cooldown, once", async () => {
await play(BLOCKED_STOP)
expect(banners).toEqual([
rich('Draft answer.'),
rich('Final answer.'),
rich('Second turn.'),
rich('Third turn.')
])
})
const LOST_OPENING_BANNERS = ['First.', 'Second.', 'Draft.', 'Final.', 'Third.', 'Fourth.']
it('keeps every banner when the host missed a prompt hook, whichever title edge comes first', async () => {
await play(LOST_OPENINGS)
expect(banners).toEqual(LOST_OPENING_BANNERS.map(rich))
})
it('matches the banners of a paired server that predates the stamp', async () => {
await play(LOST_OPENINGS, 'old-server')
expect(banners).toEqual(LOST_OPENING_BANNERS.map(rich))
})
})
@@ -34,6 +34,7 @@ type MockStoreState = {
setCacheTimerStartedAt: ReturnType<typeof vi.fn>
observeTerminalGitHubPullRequestLink: ReturnType<typeof vi.fn>
agentStatusByPaneKey: Record<string, { state: string; prompt: string; agentType?: string }>
paneForegroundAgentByPaneKey: Record<string, { agent: string | null }>
}
const dispatchTerminalNotification = vi.fn()
@@ -81,7 +82,8 @@ function createMockStoreState(): MockStoreState {
markTerminalBell: vi.fn(),
setCacheTimerStartedAt: vi.fn(),
observeTerminalGitHubPullRequestLink: vi.fn(),
agentStatusByPaneKey: {}
agentStatusByPaneKey: {},
paneForegroundAgentByPaneKey: {}
}
}
@@ -11,6 +11,7 @@ import { useAppStore } from '@/store'
import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector'
import {
AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS,
AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS,
isAgentTaskCompleteOsNotificationEnabledFromState,
isAgentTaskCompleteTrackingEnabledFromState
} from './agent-task-complete-policy'
@@ -28,6 +29,8 @@ import {
registerTerminalSideEffectFactConsumer
} from './terminal-side-effect-facts-handler'
import { dispatchTerminalNotification } from './use-notification-dispatch'
import { hasPendingHookLaneCompletion } from '@/hooks/agent-hook-completion-notifications'
import { hookLaneTitleTurnEndEvidence } from '@/hooks/agent-hook-turn-end-ownership'
import { acquireHiddenRendererPtyDeliveryClaim } from './pty-renderer-delivery-claims'
import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id'
@@ -84,6 +87,7 @@ export function startParkedTerminalByteWatcher(
let wroteRuntimeTitleSlot = false
let bellNotificationTimer: ReturnType<typeof setTimeout> | null = null
let agentTaskCompleteTimer: ReturnType<typeof setTimeout> | null = null
let ownedIdleTimer: ReturnType<typeof setTimeout> | null = null
const clearBellNotificationTimer = (): void => {
if (bellNotificationTimer !== null) {
@@ -97,6 +101,10 @@ export function startParkedTerminalByteWatcher(
clearTimeout(agentTaskCompleteTimer)
agentTaskCompleteTimer = null
}
if (ownedIdleTimer !== null) {
clearTimeout(ownedIdleTimer)
ownedIdleTimer = null
}
}
// Why: a BEL OS notification only yields when the pending completion would itself produce an OS notification (live-path parity).
@@ -118,6 +126,13 @@ export function startParkedTerminalByteWatcher(
return
}
pendingBellNotification = false
// Why: the hook lane's pending Done announces this burst's turn end, superseding the BEL as a pane completion does.
if (
hasPendingHookLaneCompletion(paneKey, ptyId) &&
isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState())
) {
return
}
dispatchTerminalNotification(worktreeId, {
source: 'terminal-bell',
paneKey,
@@ -171,13 +186,30 @@ export function startParkedTerminalByteWatcher(
// Why: completion supersedes a concurrent BEL so each burst yields exactly one OS notification (live-path parity).
pendingBellNotification = false
clearBellNotificationTimer()
dispatchTerminalNotification(worktreeId, {
source: 'agent-task-complete',
terminalTitle: title,
paneKey,
ptyId,
workspaceOwner
})
const evidence = (): 'identified' | 'ambiguous' | null => {
const foreground = useAppStore.getState().paneForegroundAgentByPaneKey[paneKey]?.agent
return hookLaneTitleTurnEndEvidence(paneKey, ptyId, title, foreground ?? null)
}
const dispatchIdleCompletion = (): void => {
dispatchTerminalNotification(worktreeId, {
source: 'agent-task-complete',
terminalTitle: title,
paneKey,
ptyId,
workspaceOwner
})
}
if (evidence() !== 'identified') {
dispatchIdleCompletion()
return
}
// Why: as on a mounted pane, an end the host then calls ambiguous keeps main's title completion.
ownedIdleTimer = setTimeout(() => {
ownedIdleTimer = null
if (!disposed && evidence() === 'ambiguous') {
dispatchIdleCompletion()
}
}, AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS - PARKED_NOTIFICATION_GRACE_MS)
}, PARKED_NOTIFICATION_GRACE_MS)
},
onAgentBecameWorking: (): void => {
@@ -20,6 +20,22 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session'
import { installAgentIdleWorkingHandlers } from './agent-idle-working-handlers'
/** A hook-owned idle edge still ends its output burst, so like a claimed title completion it
* absorbs that burst's BEL: one held now, or one that lands within the completion's max wait. */
export function absorbBurstBellForHookOwnedIdle(session: ConnectPanePtySession): void {
if (session.hasPendingAgentTaskCompleteNotification()) {
return
}
session.pendingTerminalBellNotification = false
session.clearTerminalBellNotificationTimer()
session.hookOwnedIdleAbsorbsBell = true
// Why: main's claimed completion stayed pending on a `done` row for its whole max wait.
session.agentTaskCompleteNotificationMaxTimer = setTimeout(() => {
session.agentTaskCompleteNotificationMaxTimer = null
session.hookOwnedIdleAbsorbsBell = false
}, AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS)
}
export function installAgentTaskCompleteNotify(session: ConnectPanePtySession): void {
session.scheduleAgentTaskCompleteNotification = (
title: string,
@@ -84,6 +84,7 @@ export function connectPanePty(
session.startupInjectTimer = null
session.agentTaskCompleteNotificationGraceTimer = null
session.agentTaskCompleteNotificationMaxTimer = null
session.hookOwnedIdleAbsorbsBell = false
session.agentTaskCompleteStatusUnsubscribe = null
session.agentTaskCompleteSettingsUnsubscribe = null
session.agentTaskCompleteNotificationGeneration = 0
@@ -8,6 +8,7 @@ import { notifyCodexPaneBoundForStaleSweep } from '@/lib/codex-stale-pane-sweep'
import { createTerminalGitHubPRLinkDetector } from '../../../../../shared/terminal-github-pr-link-detector'
import { setRendererPtyVisibilityClaim } from '../pty-renderer-delivery-claims'
import { AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS } from '../agent-task-complete-policy'
import { hasPendingHookLaneCompletion } from '@/hooks/agent-hook-completion-notifications'
import {
isAgentTaskCompleteNotificationEnabled,
@@ -212,6 +213,10 @@ export function installPanePtyVisibilityBind(session: ConnectPanePtySession): vo
// Why: agent CLIs often emit BEL in the same completion burst as their
// working->idle title change. Delay only the OS notification so the richer
// agent-complete notification can win the main-process worktree cooldown.
// Why: this BEL ends a hook-owned turn the hook lane announces; held, it would ring on the next working edge.
if (session.hookOwnedIdleAbsorbsBell === true) {
return
}
session.pendingTerminalBellNotification = true
if (!session.hasPendingAgentTaskCompleteNotification()) {
session.scheduleTerminalBellNotification()
@@ -239,6 +244,13 @@ export function installPanePtyVisibilityBind(session: ConnectPanePtySession): vo
return
}
session.pendingTerminalBellNotification = false
// Why: the hook lane's pending Done announces this burst's turn end, superseding the BEL as a pane completion does.
if (
isAgentTaskCompleteNotificationEnabled() &&
hasPendingHookLaneCompletion(session.cacheKey, session.transport.getPtyId())
) {
return
}
session.deps.dispatchNotification({
source: 'terminal-bell',
paneKey: session.cacheKey,
@@ -264,6 +276,7 @@ export function installPanePtyVisibilityBind(session: ConnectPanePtySession): vo
clearTimeout(session.agentTaskCompleteNotificationMaxTimer)
session.agentTaskCompleteNotificationMaxTimer = null
}
session.hookOwnedIdleAbsorbsBell = false
if (session.agentTaskCompleteStatusUnsubscribe !== null) {
session.agentTaskCompleteStatusUnsubscribe()
session.agentTaskCompleteStatusUnsubscribe = null
@@ -8,6 +8,11 @@ import { isFreshNonDoneAgentStatus } from '../../../../../shared/agent-status-ty
import { isCtrlCKeyEvent, isPlainEscapeKeyEvent } from '../agent-interrupt-inference'
import { createAgentCompletionCoordinator } from '../agent-completion-coordinator'
import { dispatchAgentHookTerminalLifecycle } from '../agent-hook-terminal-lifecycle'
import {
hookLaneOwnsAgentTurnEnds,
hookLaneTitleTurnEndEvidence
} from '@/hooks/agent-hook-turn-end-ownership'
import { absorbBurstBellForHookOwnedIdle } from './agent-task-complete-notify'
import { resolveCompatibleAgentTypeForOwner } from '../../../../../shared/agent-title-owner'
import { registerTerminalSideEffectFactConsumer } from '../terminal-side-effect-facts-handler'
@@ -176,7 +181,19 @@ export function installTerminalKeydownFit(session: ConnectPanePtySession): void
inspectProcess: inspectRuntimeTerminalProcess,
dispatchHookLifecycle: (payload) =>
dispatchAgentHookTerminalLifecycle(session.cacheKey, payload),
shouldSuppressProcessReplacementCompletion: (_exited, replacement) => {
titleTurnEndEvidence: (title, foregroundAgent) =>
hookLaneTitleTurnEndEvidence(
session.cacheKey,
session.transport.getPtyId(),
title,
foregroundAgent
),
onHookOwnedTitleIdle: () => absorbBurstBellForHookOwnedIdle(session),
shouldSuppressProcessReplacementCompletion: (exited, replacement) => {
// Why: the hook lane owns this pane's turn ends and its last turn ended, so quitting ends none.
if (hookLaneOwnsAgentTurnEnds(session.cacheKey, session.transport.getPtyId(), exited.agent)) {
return true
}
const currentStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
const currentAgentForReplacement = resolveCompatibleAgentTypeForOwner(
currentStatus?.agentType,
@@ -187,6 +204,9 @@ export function installTerminalKeydownFit(session: ConnectPanePtySession): void
)
},
shouldSuppressConfirmedProcessExitCompletion: (exited) => {
if (hookLaneOwnsAgentTurnEnds(session.cacheKey, session.transport.getPtyId(), exited.agent)) {
return true
}
const currentStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
const currentAgentForExited = resolveCompatibleAgentTypeForOwner(
currentStatus?.agentType,
@@ -21,6 +21,7 @@ import type {
AgentCompletionDispatchMeta,
AgentCompletionStatusSnapshot
} from './agent-completion-coordinator-types'
import type { NotificationDispatchResult } from '../../../../shared/notification-settings-types'
import { getNotificationWorkspaceLabels } from './terminal-notification-state'
import { createTerminalAttentionSurface } from './terminal-attention-surface'
import {
@@ -61,6 +62,8 @@ export type TerminalNotificationEvent = TerminalNotificationBinding & {
terminalTitle?: string
agentStatusSnapshot?: AgentCompletionStatusSnapshot
agentCompletionSource?: AgentCompletionDispatchMeta['source']
/** Main's answer to the delivery request this event sends (null: no answer). */
onDispatchResult?: (result: NotificationDispatchResult | null) => void
}
/**
@@ -73,7 +76,7 @@ export type TerminalNotificationEvent = TerminalNotificationBinding & {
export function dispatchTerminalNotification(
worktreeId: string,
event: TerminalNotificationEvent
): void {
): boolean {
const state = useAppStore.getState()
// Why: the completion title is the live identity. If it explicitly names an
// agent, any snapshot from another agent is stale pane-reuse residue and must
@@ -105,7 +108,7 @@ export function dispatchTerminalNotification(
) {
// Why: a title-only idle signal can race behind active hook state; a
// confirmed process exit is independent authority that the turn ended.
return
return false
}
// Why: a process can die before its hook emits done; do not label the
// resulting completion notification with that stale active state or prompt.
@@ -120,7 +123,7 @@ export function dispatchTerminalNotification(
event.source === 'agent-task-complete' &&
isSupersededAgentCompletionSnapshot(storedAgentStatus, eventAgentStatusSnapshot)
) {
return
return false
}
const agentNotificationStateStartedAt =
eventAgentStatusSnapshot?.stateStartedAt ?? freshStoredAgentStatus?.stateStartedAt
@@ -137,7 +140,7 @@ export function dispatchTerminalNotification(
createTerminalAttentionSurface(state)
)
if (!attentionDecision.admitted) {
return
return false
}
// Desktop settings are applied in main after independent mobile delivery.
@@ -168,7 +171,9 @@ export function dispatchTerminalNotification(
})
: null
let requested = false
const requestDelivery = (request: AgentAttentionDeliveryRequest): void => {
requested = true
const owner = resolveTerminalNotificationOwner(state, worktreeId, event)
const executionHost = notificationExecutionHostForOwner(owner)
deliverAgentAttentionNotification(
@@ -184,7 +189,8 @@ export function dispatchTerminalNotification(
isActiveWorktree: request.workspaceIsActive,
...agentSnapshot
},
sound
sound,
event.onDispatchResult
)
}
@@ -192,13 +198,16 @@ export function dispatchTerminalNotification(
unread: state.markAgentAttentionUnread,
requestDelivery
})
return requested
}
export function useNotificationDispatch(
worktreeId: string
): (event: TerminalNotificationEvent) => void {
return useCallback(
(event: TerminalNotificationEvent) => dispatchTerminalNotification(worktreeId, event),
(event: TerminalNotificationEvent) => {
dispatchTerminalNotification(worktreeId, event)
},
[worktreeId]
)
}
@@ -8,12 +8,18 @@ import type {
import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection'
import { dispatchTerminalNotification } from '@/components/terminal-pane/use-notification-dispatch'
import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization'
import { getPtyIdForPaneKey } from './pane-key-pty-id'
import { dispatchAgentHookTerminalLifecycle } from '@/components/terminal-pane/agent-hook-terminal-lifecycle'
import {
isAgentHookCompletionTrackingEnabled,
shouldSyncAgentHookCompletionForStoreUpdate,
type AgentHookCompletionStoreSnapshot
} from './agent-hook-completion-store-sync'
import { hookTurnEndEvidence } from './agent-hook-turn-end-capability'
import {
discardHookLaneCompletionRequests,
submitHookLaneCompletion
} from './agent-hook-completion-revision-hold'
type CoordinatorEntry = {
worktreeId: string
@@ -40,6 +46,7 @@ let lastPrunedLivenessSnapshot: PaneCoordinatorLivenessSnapshot | null = null
function disposeCoordinatorForPaneKey(paneKey: string): void {
coordinatorsByPaneKey.get(paneKey)?.coordinator.dispose()
coordinatorsByPaneKey.delete(paneKey)
discardHookLaneCompletionRequests(paneKey)
paneKeysRequiringFreshWorking.delete(paneKey)
}
@@ -140,44 +147,6 @@ export function syncAgentHookCompletionNotificationsForStoreUpdate(
return true
}
function getPtyIdForPaneKey(paneKey: string): string | null {
const parsed = parsePaneKey(paneKey)
if (!parsed) {
return null
}
const state = useAppStore.getState()
const tabPtyIds = state.ptyIdsByTabId?.[parsed.tabId]
if (!tabPtyIds || tabPtyIds.length === 0) {
return null
}
// Why: split-pane leaves share one tab-level pty list, so a tab-level lookup
// would return a sibling's pty for an already-closed leaf and let a late
// 'done' hook event fire a spurious notification. Resolve liveness through
// the leaf-keyed binding maintained by syncPanePtyLayoutBinding, which
// deletes the entry when the leaf closes.
const layout = state.terminalLayoutsByTabId?.[parsed.tabId]
const ptyIdsByLeafId = layout?.ptyIdsByLeafId
if (ptyIdsByLeafId) {
const leafPtyId = ptyIdsByLeafId[parsed.leafId]
if (leafPtyId && tabPtyIds.includes(leafPtyId)) {
return leafPtyId
}
if (!layout?.root) {
// Why: inactive worktree switches can temporarily preserve only tab-level
// PTY liveness; do not drop hook completions just because layout metadata
// is at the empty snapshot.
return tabPtyIds[0] ?? null
}
// Why: switching worktrees can unmount the terminal pane and clear the
// leaf binding before the hook completion arrives, while the tab PTY is
// still live. Keep closed leaves suppressed by requiring the leaf in layout.
return collectLeafIdsInOrder(layout.root).includes(parsed.leafId)
? (tabPtyIds[0] ?? null)
: null
}
return tabPtyIds[0] ?? null
}
function paneHasLivePty(paneKey: string): boolean {
return getPtyIdForPaneKey(paneKey) !== null
}
@@ -231,29 +200,48 @@ function paneCanReceiveHookCompletion(paneKey: string, tabIndex?: TabIndex): boo
return paneKeyHasUnsuppressedPtyHint(state, paneKey, tabIndex) || paneHasLivePty(paneKey)
}
function hookLaneDispatchEnabled(paneKey: string): boolean {
return isAgentTaskCompleteTrackingEnabled() && !paneKeysRequiringFreshWorking.has(paneKey)
}
function hookLaneOwnsTurnEnd(paneKey: string, snapshot: AgentCompletionStatusSnapshot): boolean {
return hookTurnEndEvidence(getPtyIdForPaneKey(paneKey), snapshot) === 'identified'
}
function createCoordinator(paneKey: string, worktreeId: string): AgentCompletionCoordinator {
return createAgentCompletionCoordinator({
paneKey,
statusLane: 'hook',
getPtyId: () => getPtyIdForPaneKey(paneKey),
usesHookTurnEnds: (snapshot) => hookLaneOwnsTurnEnd(paneKey, snapshot),
inspectProcess: async (): Promise<RuntimeTerminalProcessInspection> => ({
foregroundProcess: null,
hasChildProcesses: false
}),
dispatchHookLifecycle: (payload) => dispatchAgentHookTerminalLifecycle(paneKey, payload),
dispatchCompletion: (title, meta) => {
if (!isAgentTaskCompleteTrackingEnabled() || paneKeysRequiringFreshWorking.has(paneKey)) {
if (!hookLaneDispatchEnabled(paneKey)) {
return
}
dispatchTerminalNotification(worktreeId, {
source: 'agent-task-complete',
terminalTitle: title,
paneKey,
...(meta?.agentStatus ? { agentStatusSnapshot: meta.agentStatus } : {})
const snapshot = meta?.agentStatus
submitHookLaneCompletion(paneKey, snapshot, {
keepsAuthority: () =>
hookLaneDispatchEnabled(paneKey) &&
coordinatorsByPaneKey.has(paneKey) &&
snapshot !== undefined &&
hookLaneOwnsTurnEnd(paneKey, snapshot),
submit: (onDispatchResult) =>
dispatchTerminalNotification(worktreeId, {
source: 'agent-task-complete',
terminalTitle: title,
paneKey,
...(snapshot ? { agentStatusSnapshot: snapshot } : {}),
onDispatchResult
})
})
},
dispatchAttention: (title, meta) => {
if (!isAgentTaskCompleteTrackingEnabled() || paneKeysRequiringFreshWorking.has(paneKey)) {
if (!hookLaneDispatchEnabled(paneKey)) {
return
}
// Why: native notification settings still label this channel as "agent
@@ -298,6 +286,7 @@ export function observeAgentHookCompletionForNotification({
let entry = coordinatorsByPaneKey.get(paneKey)
if (!entry || entry.worktreeId !== worktreeId) {
entry?.coordinator.dispose()
discardHookLaneCompletionRequests(paneKey)
entry = {
worktreeId,
coordinator: createCoordinator(paneKey, worktreeId)
@@ -312,16 +301,35 @@ export function observeAgentHookCompletionForNotification({
if (payload.state === 'working' && payload.turnCompletedAt === undefined && trackingEnabled) {
paneKeysRequiringFreshWorking.delete(paneKey)
}
// Why: on a legacy pane the host's end facts must not change main's done identity or replay guard.
const { turnEndedAt: _turnEndedAt, turnEndDisposition: _disposition, ...legacyPayload } = payload
const observed =
hookTurnEndEvidence(getPtyIdForPaneKey(paneKey), payload) === 'legacy' ? legacyPayload : payload
if (seedOnly === true) {
entry.coordinator.seedHookStatus(payload)
entry.coordinator.seedHookStatus(observed)
} else {
entry.coordinator.observeHookStatus(payload)
entry.coordinator.observeHookStatus(observed)
}
}
// Why: asked of the lane itself so title lanes defer only where it would announce: live, agent evidence, not re-arming.
export function hookLaneCanAnnouncePaneTurnEnd(paneKey: string): boolean {
const coordinator = coordinatorsByPaneKey.get(paneKey)?.coordinator
return coordinator?.canAnnounceCompletion() === true && hookLaneDispatchEnabled(paneKey)
}
export function hasPendingHookLaneCompletion(paneKey: string, ptyId: string | null): boolean {
return (
coordinatorsByPaneKey.get(paneKey)?.coordinator.hasPendingHookDoneCompletion() === true &&
hookTurnEndEvidence(ptyId, useAppStore.getState().agentStatusByPaneKey[paneKey]) ===
'identified'
)
}
export function resetAgentHookCompletionNotificationCoordinators(): void {
for (const entry of coordinatorsByPaneKey.values()) {
for (const [paneKey, entry] of coordinatorsByPaneKey) {
entry.coordinator.dispose()
discardHookLaneCompletionRequests(paneKey)
}
coordinatorsByPaneKey.clear()
paneKeysRequiringFreshWorking.clear()
@@ -0,0 +1,163 @@
import { useAppStore } from '@/store'
import type { AgentCompletionStatusSnapshot } from '@/components/terminal-pane/agent-completion-coordinator-types'
import { NOTIFICATION_COOLDOWN_MS } from '../../../shared/notification-burst-cooldown'
import type { NotificationDispatchResult } from '../../../shared/notification-settings-types'
/** Sends one request and reports main's answer (null: none); returns false when nothing was sent. */
export type HookLaneCompletionSubmit = (
onResult: (result: NotificationDispatchResult | null) => void
) => boolean
type HeldRevision = {
submit: HookLaneCompletionSubmit
isCurrent: () => boolean
expiresAt: number
}
type PaneRequests = {
/** The done segment (`stateStartedAt`) of the pane's latest request. */
segment: number | undefined
requestSeq: number
/** When main's cooldown from the latest request has surely passed; null while it is unanswered. */
cooldownEndsAt: number | null
held: HeldRevision | null
timer: ReturnType<typeof setTimeout> | null
}
// Why: these answers mean a later request would be refused too, so a held revision is dropped.
const TERMINAL_REFUSALS: ReadonlySet<NotificationDispatchResult['reason']> = new Set([
'disabled',
'source-disabled',
'host-muted',
'not-supported',
'blocked-by-system',
'invalid-request'
])
const requestsByPaneKey = new Map<string, PaneRequests>()
function clearTimer(requests: PaneRequests): void {
if (requests.timer !== null) {
clearTimeout(requests.timer)
requests.timer = null
}
}
function armHeldTimer(requests: PaneRequests, submitAfterCooldown: () => void): void {
const held = requests.held
if (!held) {
return
}
clearTimer(requests)
const cooldownEndsAt = requests.cooldownEndsAt
if (cooldownEndsAt !== null && cooldownEndsAt > held.expiresAt) {
requests.held = null
return
}
// Why: a fixed expiry, never extended by later revisions, also drops one whose answer never came.
const dueAt = cooldownEndsAt ?? held.expiresAt
requests.timer = setTimeout(
() => {
requests.timer = null
if (cooldownEndsAt === null) {
requests.held = null
return
}
submitAfterCooldown()
},
Math.max(0, dueAt - Date.now())
)
}
function submitNow(
paneKey: string,
requests: PaneRequests,
segment: number | undefined,
submit: HookLaneCompletionSubmit
): void {
const seq = ++requests.requestSeq
requests.segment = segment
requests.cooldownEndsAt = null
const sent = submit((result) => {
if (requestsByPaneKey.get(paneKey) !== requests || requests.requestSeq !== seq) {
return
}
// Why: main reserved its cooldown before answering, so counting from the answer is conservative.
requests.cooldownEndsAt = Date.now() + NOTIFICATION_COOLDOWN_MS
if (result && !result.delivered && TERMINAL_REFUSALS.has(result.reason)) {
requests.held = null
clearTimer(requests)
return
}
armHeldTimer(requests, () => submitHeld(paneKey, requests))
})
if (!sent && requests.requestSeq === seq) {
requests.cooldownEndsAt = 0
}
}
function submitHeld(paneKey: string, requests: PaneRequests): void {
const held = requests.held
requests.held = null
// One attempt: owner, state and capability are rechecked here, focus and settings by main.
if (held?.isCurrent()) {
submitNow(paneKey, requests, requests.segment, held.submit)
}
}
/**
* The hook lane's completion requests for one pane. A later turn end in the same done segment
* (a Stop re-entered after a blocking Stop hook) that arrives while main's cooldown from the
* preceding request still runs would be dropped there; the latest one is held and sent once,
* after that cooldown, through main's unchanged gates. Everything else is sent at once.
*/
export function submitHookLaneCompletion(
paneKey: string,
snapshot: AgentCompletionStatusSnapshot | undefined,
lane: { keepsAuthority: () => boolean; submit: HookLaneCompletionSubmit }
): void {
const done = snapshot?.state === 'done' ? snapshot : undefined
const segment = done?.stateStartedAt
// Why: sent only while the pane still shows this end and the lane keeps hook authority.
const isCurrent = (): boolean => {
const row = useAppStore.getState().agentStatusByPaneKey[paneKey]
return (
row?.state === 'done' &&
row.stateStartedAt === segment &&
row.turnEndedAt === done?.turnEndedAt &&
lane.keepsAuthority()
)
}
let requests = requestsByPaneKey.get(paneKey)
if (!requests) {
requests = { segment: undefined, requestSeq: 0, cooldownEndsAt: 0, held: null, timer: null }
requestsByPaneKey.set(paneKey, requests)
}
const coolingDown = requests.cooldownEndsAt === null || Date.now() < requests.cooldownEndsAt
if (
done?.turnEndedAt !== undefined &&
segment !== undefined &&
segment === requests.segment &&
coolingDown
) {
requests.held = {
submit: lane.submit,
isCurrent,
expiresAt: requests.held?.expiresAt ?? Date.now() + 2 * NOTIFICATION_COOLDOWN_MS
}
const paneRequests = requests
armHeldTimer(requests, () => submitHeld(paneKey, paneRequests))
return
}
clearTimer(requests)
requests.held = null
submitNow(paneKey, requests, segment, lane.submit)
}
export function discardHookLaneCompletionRequests(paneKey: string): void {
const requests = requestsByPaneKey.get(paneKey)
if (requests) {
clearTimer(requests)
requestsByPaneKey.delete(paneKey)
}
}
@@ -0,0 +1,44 @@
import { useAppStore } from '@/store'
import {
isRemoteRuntimePtyId,
parseRemoteRuntimePtyId
} from '../../../shared/remote-runtime-pty-id'
import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status'
import { AGENT_STATUS_TURN_END_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import type { HostTurnEndFacts } from '../../../shared/agent-status-types'
// Why: a paired server (incl. SSH) that predates the turn-end stamp can't tell a later turn end
// from a restatement. Local and WSL-relay PTYs always run this build; an unbound PTY is unknown.
function ptyHostStampsTurnEnds(ptyId: string | null | undefined): boolean {
if (!ptyId) {
return false
}
if (!isRemoteRuntimePtyId(ptyId)) {
return true
}
const environmentId = parseRemoteRuntimePtyId(ptyId)?.environmentId
const entry = environmentId
? useAppStore.getState().runtimeStatusByEnvironmentId.get(environmentId)
: undefined
return (
lastVerifiedRuntimeStatus(entry)?.capabilities?.includes(
AGENT_STATUS_TURN_END_RUNTIME_CAPABILITY
) === true
)
}
export type HookTurnEndEvidence = 'identified' | 'ambiguous' | 'legacy'
/**
* The one decision for every pane behavior hook-owned turn ends changed, the same for every agent:
* the pane's bound PTY runs on a host that stamps turn ends, and how that host identified the
* current end. `identified`: the hook lane owns it. `ambiguous`: main's title role stays. `legacy`
* (old host, unbound PTY, no disposition): main's behavior exactly.
*/
export function hookTurnEndEvidence(
ptyId: string | null | undefined,
row: Pick<HostTurnEndFacts, 'turnEndDisposition'> | undefined
): HookTurnEndEvidence {
const disposition = row?.turnEndDisposition
return disposition && ptyHostStampsTurnEnds(ptyId) ? disposition : 'legacy'
}
@@ -0,0 +1,57 @@
import { useAppStore } from '@/store'
import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence'
import { resolveCompatibleAgentTypeForOwner } from '../../../shared/agent-title-owner'
import { isAgentStatusHooksEnabledForAgent } from '../../../shared/agent-status-hooks-setting'
import { isTuiAgent } from '../../../shared/tui-agent-config'
import type { TerminalAgent } from '../../../shared/terminal-agent'
import { hookLaneCanAnnouncePaneTurnEnd } from './agent-hook-completion-notifications'
import { hookTurnEndEvidence } from './agent-hook-turn-end-capability'
/**
* The evidence of the hook-lane turn end a title or process naming `agent` (null: none) would
* otherwise announce: `identified` (the hook lane announces it), `ambiguous` (main's title role
* stays), or null (not a hook-lane turn end). A title spin (Claude's /btw, Codex's /compact) or
* quitting the agent after an identified end ends no hook turn, so it must not announce one.
*/
export function hookLaneTurnEndEvidence(
paneKey: string,
ptyId: string | null | undefined,
agent: TerminalAgent | null
): 'identified' | 'ambiguous' | null {
const { agentStatusByPaneKey, settings } = useAppStore.getState()
const row = agentStatusByPaneKey[paneKey]
const owner = row?.agentType
const evidence = hookTurnEndEvidence(ptyId, row)
// Why: the title lane stays the fallback for an unfinished turn, and once the agent's hooks are
// switched off (they are uninstalled, so the hook lane never hears another turn end).
if (
row?.state !== 'done' ||
evidence === 'legacy' ||
!isTuiAgent(owner) ||
!isAgentStatusHooksEnabledForAgent(settings, owner) ||
!hookLaneCanAnnouncePaneTurnEnd(paneKey)
) {
return null
}
return !agent || resolveCompatibleAgentTypeForOwner(owner, agent) === agent ? evidence : null
}
export function hookLaneOwnsAgentTurnEnds(
paneKey: string,
ptyId: string | null | undefined,
agent: TerminalAgent | null
): boolean {
return hookLaneTurnEndEvidence(paneKey, ptyId, agent) === 'identified'
}
/** A title edge's turn belongs to the title's agent, else the pane's foreground agent (goose
* after Claude), the same on idle and working edges. */
export function hookLaneTitleTurnEndEvidence(
paneKey: string,
ptyId: string | null | undefined,
title: string | null,
foregroundAgent: TerminalAgent | null
): 'identified' | 'ambiguous' | null {
const agent = (title ? resolveCommittedTitleAgentType(title) : null) ?? foregroundAgent
return hookLaneTurnEndEvidence(paneKey, ptyId, agent)
}
@@ -1,6 +1,6 @@
import { sameAgentStatusTerminalOwner } from '../../../../shared/agent-status-terminal-owner'
import { isWslHookRelayConnectionId } from '../../../../shared/wsl-hook-relay-contract'
import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types'
import { pickHostTurnEnd, type AgentStatusIpcPayload } from '../../../../shared/agent-status-types'
import {
resolveAgentStatusIdentity,
shouldSuppressInheritedTerminalStatus
@@ -240,7 +240,8 @@ export function createAgentStatusEventApplicator(args: {
? { evidenceObservedAt: data.evidenceObservedAt }
: {}),
stateStartedAt: data.stateStartedAt,
...(Number.isFinite(data.turnStartedAt) ? { turnStartedAt: data.turnStartedAt } : {})
...(Number.isFinite(data.turnStartedAt) ? { turnStartedAt: data.turnStartedAt } : {}),
...pickHostTurnEnd(data)
},
routing: {
tabId: ownerTabId,
@@ -255,10 +256,11 @@ export function createAgentStatusEventApplicator(args: {
options?.replay === true || isSeenHostReplay(data, resolvedPayload.state, existingStatus)
const applyPostCommitNotification = (): void => {
if (statusWorktreeId && (!replayed || resolvedPayload.state === 'working')) {
const notificationPayload =
typeof data.stateStartedAt === 'number'
? { ...resolvedPayload, stateStartedAt: data.stateStartedAt }
: resolvedPayload
const notificationPayload = {
...resolvedPayload,
stateStartedAt: data.stateStartedAt,
...pickHostTurnEnd(data)
}
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: statusWorktreeId,
@@ -31,9 +31,14 @@ export function isAgentStatusForRecentlyClosedTab(
/** A host replay the renderer has already seen; a replayed `done` that differs from the current row
* ended while the host was unreachable, so it is not a replay of anything this renderer saw. */
export function isSeenHostReplay(
data: Pick<AgentStatusIpcPayload, 'isReplay' | 'stateStartedAt' | 'turnCompletedAt'>,
data: Pick<
AgentStatusIpcPayload,
'isReplay' | 'stateStartedAt' | 'turnCompletedAt' | 'turnEndedAt'
>,
state: string,
existing: Pick<AppState['agentStatusByPaneKey'][string], 'state' | 'stateStartedAt'> | undefined
existing:
| Pick<AppState['agentStatusByPaneKey'][string], 'state' | 'stateStartedAt' | 'turnEndedAt'>
| undefined
): boolean {
if (data.isReplay !== true) {
return false
@@ -46,9 +51,12 @@ export function isSeenHostReplay(
if (state === 'working' && Number.isFinite(data.turnCompletedAt)) {
return false
}
// Why: the same done segment can hold a later turn end (a Stop re-entered while unreachable).
return (
state !== 'done' ||
(existing.state === 'done' && existing.stateStartedAt === data.stateStartedAt)
(existing.state === 'done' &&
existing.stateStartedAt === data.stateStartedAt &&
existing.turnEndedAt === data.turnEndedAt)
)
}
+41
View File
@@ -0,0 +1,41 @@
import { useAppStore } from '@/store'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization'
export function getPtyIdForPaneKey(paneKey: string): string | null {
const parsed = parsePaneKey(paneKey)
if (!parsed) {
return null
}
const state = useAppStore.getState()
const tabPtyIds = state.ptyIdsByTabId?.[parsed.tabId]
if (!tabPtyIds || tabPtyIds.length === 0) {
return null
}
// Why: split-pane leaves share one tab-level pty list, so a tab-level lookup
// would return a sibling's pty for an already-closed leaf and let a late
// 'done' hook event fire a spurious notification. Resolve liveness through
// the leaf-keyed binding maintained by syncPanePtyLayoutBinding, which
// deletes the entry when the leaf closes.
const layout = state.terminalLayoutsByTabId?.[parsed.tabId]
const ptyIdsByLeafId = layout?.ptyIdsByLeafId
if (ptyIdsByLeafId) {
const leafPtyId = ptyIdsByLeafId[parsed.leafId]
if (leafPtyId && tabPtyIds.includes(leafPtyId)) {
return leafPtyId
}
if (!layout?.root) {
// Why: inactive worktree switches can temporarily preserve only tab-level
// PTY liveness; do not drop hook completions just because layout metadata
// is at the empty snapshot.
return tabPtyIds[0] ?? null
}
// Why: switching worktrees can unmount the terminal pane and clear the
// leaf binding before the hook completion arrives, while the tab PTY is
// still live. Keep closed leaves suppressed by requiring the leaf in layout.
return collectLeafIdsInOrder(layout.root).includes(parsed.leafId)
? (tabPtyIds[0] ?? null)
: null
}
return tabPtyIds[0] ?? null
}
@@ -31,6 +31,8 @@ function referenceProjection(map: AppState['agentStatusByPaneKey']): string {
stateStartedAt: entry.stateStartedAt,
// A new turn can keep the state (child work held it open); its stamp alone must republish.
turnStartedAt: entry.turnStartedAt ?? null,
turnEndedAt: entry.turnEndedAt ?? null,
turnEndDisposition: entry.turnEndDisposition ?? null,
agentType: entry.agentType ?? null,
terminalTitle: entry.terminalTitle ?? null,
stateHistory: entry.stateHistory.map((history) => ({
@@ -112,6 +112,8 @@ function referenceAgentStatusProjection(map: AppState['agentStatusByPaneKey']):
updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS),
stateStartedAt: entry.stateStartedAt,
turnStartedAt: entry.turnStartedAt ?? null,
turnEndedAt: entry.turnEndedAt ?? null,
turnEndDisposition: entry.turnEndDisposition ?? null,
agentType: entry.agentType ?? null,
terminalTitle: entry.terminalTitle ?? null,
stateHistory: entry.stateHistory.map((history) => ({
@@ -33,6 +33,8 @@ function serializeAgentStatusEntry(
updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS),
stateStartedAt: entry.stateStartedAt,
turnStartedAt: entry.turnStartedAt ?? null,
turnEndedAt: entry.turnEndedAt ?? null,
turnEndDisposition: entry.turnEndDisposition ?? null,
agentType: entry.agentType ?? null,
terminalTitle: entry.terminalTitle ?? null,
stateHistory: entry.stateHistory.map((history) => ({
@@ -50,6 +50,8 @@ export function agentStatusEntryEqual(
a.updatedAt === b.updatedAt &&
a.stateStartedAt === b.stateStartedAt &&
a.turnStartedAt === b.turnStartedAt &&
a.turnEndedAt === b.turnEndedAt &&
a.turnEndDisposition === b.turnEndDisposition &&
a.agentType === b.agentType &&
a.paneKey === b.paneKey &&
a.worktreeId === b.worktreeId &&
@@ -1,7 +1,11 @@
import type { AppState } from '../../store'
import { useAppStore } from '../../store'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { pickParsedAgentStatusPayload } from '../../../../shared/agent-status-types'
import {
pickHostTurnEnd,
pickParsedAgentStatusPayload,
type TurnEndDisposition
} from '../../../../shared/agent-status-types'
import { normalizeTurnCompletedAtField } from '../../../../shared/agent-status-field-normalization'
import { isSeenHostReplay } from '@/hooks/ipc-events/agent-status-routing'
import { isClientAuthoritativeAgentStatusPane } from '@/components/terminal-pane/renderer-owned-agent-status-registry'
@@ -35,6 +39,8 @@ export function applyWebSessionTabsStorePatch(
seedOnly?: true
payload: ReturnType<typeof pickParsedAgentStatusPayload> & {
stateStartedAt: number
turnEndedAt?: number
turnEndDisposition?: TurnEndDisposition
localStateStartedAt?: number
}
}[] = []
@@ -135,7 +141,8 @@ export function applyWebSessionTabsStorePatch(
{
isReplay: true,
stateStartedAt: notificationStatus.stateStartedAt,
turnCompletedAt
turnCompletedAt,
turnEndedAt: notificationStatus.turnEndedAt
},
notificationStatus.state,
state.agentStatusByPaneKey[notificationStatus.paneKey]
@@ -153,6 +160,7 @@ export function applyWebSessionTabsStorePatch(
...(turnCompletedAt !== undefined ? { turnCompletedAt } : {})
}),
stateStartedAt: notificationStatus.stateStartedAt,
...pickHostTurnEnd(notificationStatus),
...(clientOwnedNotification ? { localStateStartedAt } : {})
}
})
@@ -5,7 +5,8 @@ import type {
AgentStatusOrchestrationContext,
AgentType,
MigrationUnsupportedPtyEntry,
ParsedAgentStatusPayload
ParsedAgentStatusPayload,
TurnEndDisposition
} from '../../../../shared/agent-status-types'
import type { AgentStatusObservation } from '../../../../shared/agent-status-observation'
import type { ProgramStatusFacet } from '../../../../shared/program-status/facet'
@@ -95,6 +96,9 @@ export type AgentStatusTiming = {
stateStartedAt?: number
/** The host's turn start; see `AgentStatusEntry.turnStartedAt`. */
turnStartedAt?: number
/** The host's turn end; see `AgentStatusEntry.turnEndedAt`. */
turnEndedAt?: number
turnEndDisposition?: TurnEndDisposition
}
export type AgentStatusRouting = {
@@ -2,9 +2,10 @@ import { sameAgentStatusTerminalOwner } from '../../../../shared/agent-status-te
import type { AppState } from '../types'
import { resolveAgentStatusLiveEntryMainAgent } from './agent-status-live-entry-main-agent'
import { resolveAgentStatusLiveEntryStateHistory } from '../../../../shared/agent-status-live-entry-state-history'
import type {
MigrationUnsupportedPtyEntry,
AgentStatusEntry
import {
pickHostTurnEnd,
type MigrationUnsupportedPtyEntry,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import {
agentProviderSessionsEqual,
@@ -127,6 +128,13 @@ export function buildAgentStatusLiveEntry(
const turnStartedAt =
timing?.turnStartedAt ??
(existing && existing.state === payload.state ? existing.turnStartedAt : undefined)
// Why: the end stamp and its disposition are one host fact, inherited together.
const turnEnd =
timing?.turnEndedAt !== undefined || timing?.turnEndDisposition !== undefined
? pickHostTurnEnd(timing)
: existing && existing.state === payload.state
? pickHostTurnEnd(existing)
: {}
if (
existing &&
shouldSuppressInheritedTerminalStatus({
@@ -216,6 +224,7 @@ export function buildAgentStatusLiveEntry(
stateStartedAt,
stateObservedAt,
...(turnStartedAt !== undefined ? { turnStartedAt } : {}),
...turnEnd,
agentType: identity.agentType,
model:
payload.model ?? (existing?.agentType === identity.agentType ? existing.model : undefined),
+1
View File
@@ -329,6 +329,7 @@ export function normalizeHookPayload(
: {}),
...(providerSession ? { providerSession } : {}),
...(providerSessionOnly ? { providerSessionOnly: true } : {}),
...dispatched.hostTurnSignals,
payload: transportPayload
}
}
@@ -1,9 +1,20 @@
import type { AgentProcessPresence } from '../agent-process-presence'
import type { ParsedAgentStatusPayload } from '../agent-status-types'
import type { ParsedAgentStatusPayload, TurnEndDisposition } from '../agent-status-types'
import type { AgentHookSource } from '../agent-hook-relay'
import type { AgentProviderSessionMetadata } from '../agent-session-resume'
import type { ClaudeSettledRequest } from './claude-settled-permission-requests'
/**
* The execution host's private record of which turn a pane's agent session is in and which turn
* last ended. Saved with the status row; never published (the public fact is `turnEndedAt`).
*/
export type HostTurnRecord = {
/** The provider session the turn keys belong to; another session starts a new record. */
owner: string
currentTurnKey: string
completed?: { turnKey: string; endedAt: number }
}
export type AgentHookEventPayload = {
paneKey: string
agentPresence?: AgentProcessPresence
@@ -48,6 +59,20 @@ export type AgentHookEventPayload = {
providerSessionOnly?: boolean
/** True when this event is a relay cache replay rather than a live hook. */
isReplay?: boolean
/** When the main agent's latest turn ended, minted only by the execution host's turn-end
* derivation (agent-hook-turn-end-timing.ts); a relay forwards it and a result retry restates it. */
turnEndedAt?: number
/** How the host identified that end (agent-hook-turn-end-timing.ts); forwarded and restated with it. */
turnEndDisposition?: TurnEndDisposition
/** The agent's turn-end event re-entered after a blocking Stop hook (`stop_hook_active`), read
* only for agents whose signal is verified. In-process only; never stored or sent. */
stopHookActive?: true
/** The agent's own turn id (Codex `turn_id`, Command Code's transcript prompt key). In-process
* only: the host folds it into `hostTurn`; never stored or sent. */
nativeTurnKey?: string
/** The execution host's private turn record (agent-hook-turn-end-timing.ts). Saved with the row,
* never published: snapshots, IPC payloads and relay envelopes are built from allow-lists. */
hostTurn?: HostTurnRecord
/** Claude live work the child list does not show (a shell, a cron, an owed task notification); rejects false input-based interrupts. */
claudeRunningNonAgentTask?: boolean
/** Row projected from a structured session the host holds: `owned` while its provider child
@@ -2,6 +2,8 @@ import { normalizeCompatibleLifecycleEvent } from './providers/compatible-lifecy
import { normalizeQoderEvent } from './providers/qoder-events'
import type { AgentMainAgentStatus, ParsedAgentStatusPayload } from '../agent-status-types'
import type { AgentHookSource } from '../agent-hook-relay'
import { readHostTurnSignals } from '../agent-hook-turn-end-timing'
import type { AgentHookEventPayload } from './listener-event'
import { readLastCommandCodeUserPromptEntryFromTranscript } from './command-code-transcript'
import { readGrokHomeEnvelope } from './grok-result-discovery'
import { readFirstString } from './interactive-tool'
@@ -38,6 +40,7 @@ export type ProviderDispatchResult = {
hasTranscriptPromptEvidence: boolean
/** The event the payload was normalized as, which a provider may restate (Codex ended turns). */
eventName: unknown
hostTurnSignals: Pick<AgentHookEventPayload, 'stopHookActive' | 'nativeTurnKey'>
}
/** Exhaustive provider routing with provider-specific transcript locality and attribution. */
@@ -223,6 +226,7 @@ export function normalizeProviderEvent(input: {
resolvedPromptText,
promptInteractionKey,
hasTranscriptPromptEvidence,
eventName
eventName,
hostTurnSignals: readHostTurnSignals(source, hookPayload, promptInteractionKey)
}
}
@@ -94,6 +94,55 @@ export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boo
}
}
/** The per-provider answer to "is this the main agent's own turn-end event?". Idle reminders,
* session events and late enrichment restate a finished turn, so they are never one. */
export function isTurnEndEvent(source: AgentHookSource, eventName: unknown): boolean {
// Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of falling through to false.
switch (source) {
case 'claude':
case 'qoder':
case 'qoder-cn':
case 'qwen-code':
case 'codebuddy':
case 'kimi':
case 'muse':
return eventName === 'Stop' || eventName === 'StopFailure'
case 'dsh':
case 'zcode':
case 'codex':
case 'antigravity':
case 'droid':
case 'devin':
return eventName === 'Stop'
case 'gemini':
return eventName === 'AfterAgent'
case 'amp':
return eventName === 'agent.end'
case 'command-code':
// Why: its only done event; repeats within one prompt share the transcript key, so they restate.
return eventName === 'Stop'
case 'cursor':
case 'kiro':
return eventName === 'stop'
case 'grok':
return isGrokEvent(eventName, 'stop', 'stop_failure', 'stop_cancelled')
case 'copilot':
return normalizeCopilotEventName(eventName) === 'Stop'
case 'hermes':
return eventName === 'post_llm_call'
case 'jcode':
return eventName === 'turn_end'
case 'opencode':
case 'opencode2':
case 'mimo-code':
case 'pi':
case 'omp':
case 'prime-agent':
// Why: their done events are status snapshots or milestones that can repeat within one turn.
return false
}
}
export function hasExplicitUserPrompt(
source: AgentHookSource,
eventName: unknown,
+9 -1
View File
@@ -26,7 +26,11 @@ import type { ClaudeSettledRequest } from './agent-hook-listener/claude-settled-
import { createHash } from 'node:crypto'
import type { AgentSubagentSnapshot, ParsedAgentStatusPayload } from './agent-status-types'
import type {
AgentSubagentSnapshot,
ParsedAgentStatusPayload,
TurnEndDisposition
} from './agent-status-types'
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import type { AgentHookTarget } from './agent-hook-types'
@@ -122,6 +126,10 @@ export type AgentHookRelayEnvelope = {
providerSessionOnly?: boolean
/** True when the relay is replaying its cache after Orca reconnects. */
isReplay?: boolean
/** The relay's turn-end stamp (see AgentHookEventPayload.turnEndedAt); older relays omit it. */
turnEndedAt?: number
/** How the relay identified that end; older relays omit it (legacy). */
turnEndDisposition?: TurnEndDisposition
/** Claude live work the child list does not show (a shell, a cron, an owed task notification), for input-interrupt inference on the receiving host. */
claudeRunningNonAgentTask?: boolean
/** Forwarded from the agent CLI POST body. The relay default is `remote`,
+165
View File
@@ -0,0 +1,165 @@
import type { AgentHookEventPayload, HostTurnRecord } from './agent-hook-listener/listener-event'
import { isNewTurnEvent, isTurnEndEvent } from './agent-hook-listener/provider-event-routing'
import { readString } from './agent-hook-listener/tool-input-preview'
import type { AgentHookSource } from './agent-hook-relay'
import { agentMainAgentVerdict } from './agent-main-agent-verdict'
import { agentReportsStopHookContinuation } from './agent-stop-hook-continuation'
import { pickHostTurnEnd, type HostTurnEndFacts } from './agent-status-types'
type TurnEndTimingRow = Pick<
AgentHookEventPayload,
'turnEndedAt' | 'turnEndDisposition' | 'payload' | 'hostTurn'
>
type HostTurnEnd = HostTurnEndFacts & { hostTurn?: HostTurnRecord }
// Why: a record with no verified opening or native key yet (a new session, a pre-record row).
const UNOPENED_TURN = 'seed'
/** The hook body's turn facts the host folds into its turn record; in-process only. */
export function readHostTurnSignals(
source: AgentHookSource,
hookPayload: Record<string, unknown>,
promptInteractionKey: string | undefined
): Pick<AgentHookEventPayload, 'stopHookActive' | 'nativeTurnKey'> {
// Why: the agent's own turn id tells a new turn from a re-delivery even when no prompt hook
// ran (Codex turns woken by agent mail, every Command Code turn).
const nativeTurnKey =
source === 'codex'
? readString(hookPayload, 'turn_id')
: source === 'command-code'
? promptInteractionKey
: undefined
return {
...(hookPayload.stop_hook_active === true && agentReportsStopHookContinuation(source)
? { stopHookActive: true }
: {}),
...(nativeTurnKey ? { nativeTurnKey } : {})
}
}
// Why: a settled cancellation ends the open turn, so that turn's late end is a repeat, not an end.
function endsTurnByCancellation(row: TurnEndTimingRow | undefined): boolean {
return (
row?.payload.state === 'done' &&
row.turnEndDisposition === undefined &&
agentMainAgentVerdict(row.payload) === 'cancellation'
)
}
export function isTurnEndedAt(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0
}
function resolveOwner(
previous: HostTurnRecord | undefined,
event: AgentHookEventPayload & { source: NonNullable<AgentHookEventPayload['source']> }
): { owner: string; replaced: boolean } {
const anonymous = `${event.source}:`
const sessionId = event.providerSession?.id
if (!sessionId) {
// Why: an event that names no session belongs to whichever session of this agent is current.
return previous?.owner.startsWith(anonymous)
? { owner: previous.owner, replaced: false }
: { owner: anonymous, replaced: previous !== undefined }
}
const owner = `${anonymous}${sessionId}`
return {
owner,
replaced: previous !== undefined && previous.owner !== owner && previous.owner !== anonymous
}
}
/**
* The one turn-end derivation every execution host runs (local main and the WSL relay). A turn
* advances on the agent's native turn key (Codex `turn_id`, Command Code's transcript position)
* or, without one, only on a prompt-opening event. A live root turn-end event is an identified
* end, minted, when its turn is unused or a verified continuation re-entered after a blocking Stop
* hook. A same-turn repeat is a proven duplicate only for an agent with both a native key and a
* verified continuation flag; otherwise it is ambiguous (a repeat, or a turn whose opening never
* arrived) and never mints. Restatements (replay, retry, idle reminder, enrichment) keep the end
* shown in the current done segment. Keys are opaque: only compared for equality.
*/
export function reduceHostTurnEnd(
previous: TurnEndTimingRow | undefined,
event: AgentHookEventPayload,
at: number,
canMint: boolean
): HostTurnEnd {
const shown = previous?.payload.state === 'done' ? pickHostTurnEnd(previous) : {}
const done = event.payload.state === 'done'
// Why: a relay, a result retry or a replay already decided this event's end; the host restates it,
// except over a done segment it shows with no end of its own (a cancel it inferred), which keeps none.
if (!canMint || event.isReplay === true || event.turnEndDisposition !== undefined) {
const shownWithoutEnd =
previous?.payload.state === 'done' && previous.turnEndDisposition === undefined
return {
...(done
? event.turnEndDisposition !== undefined && !shownWithoutEnd
? pickHostTurnEnd(event)
: shown
: {}),
...(previous?.hostTurn ? { hostTurn: previous.hostTurn } : {})
}
}
let record = previous?.hostTurn
const source = event.source
const isLiveRoot =
source !== undefined && event.toolAgentId === undefined && event.providerSessionOnly !== true
if (isLiveRoot) {
const { owner, replaced } = resolveOwner(record, { ...event, source })
if (!record || replaced) {
record = { owner, currentTurnKey: UNOPENED_TURN }
} else if (record.owner !== owner) {
record = { ...record, owner }
}
if (event.nativeTurnKey) {
record = { ...record, currentTurnKey: `turn:${event.nativeTurnKey}` }
} else if (
isNewTurnEvent(source, event.hookEventName) &&
event.payload.sessionBoundary !== true
) {
record = { ...record, currentTurnKey: `opened:${at}` }
}
}
const kept = record ? { hostTurn: record } : {}
if (!done) {
return kept
}
if (
!record ||
!isLiveRoot ||
event.payload.sessionBoundary === true ||
!isTurnEndEvent(source, event.hookEventName)
) {
if (
record &&
record.completed?.turnKey !== record.currentTurnKey &&
endsTurnByCancellation(event)
) {
const completed = { turnKey: record.currentTurnKey, endedAt: at }
return { ...shown, hostTurn: { ...record, completed } }
}
return { ...shown, ...kept }
}
const completed = record.completed
const sameTurn = completed?.turnKey === record.currentTurnKey
if (event.stopHookActive === true || (!sameTurn && record.currentTurnKey !== UNOPENED_TURN)) {
const endedAt = Math.max(at, (completed?.endedAt ?? 0) + 1)
return {
turnEndedAt: endedAt,
turnEndDisposition: 'identified',
hostTurn: { ...record, completed: { turnKey: record.currentTurnKey, endedAt } }
}
}
// Why: the agent's own turn id plus a verified continuation flag mean an unflagged repeat of
// this turn's Stop is a duplicate delivery (Codex re-fires it flagged for a continuation).
if (completed && sameTurn && event.nativeTurnKey && agentReportsStopHookContinuation(source)) {
return { turnEndedAt: completed.endedAt, turnEndDisposition: 'identified', ...kept }
}
return {
...(shown.turnEndedAt !== undefined ? { turnEndedAt: shown.turnEndedAt } : {}),
turnEndDisposition: 'ambiguous',
...kept
}
}
+7 -1
View File
@@ -14,7 +14,8 @@ import type {
} from './agent-status-run'
import type {
AgentStatusOrchestrationContext,
ParsedAgentStatusPayload
ParsedAgentStatusPayload,
TurnEndDisposition
} from './agent-status-types'
/** A PTY the pane-key migration could not move, reported for operator triage. */
@@ -60,6 +61,11 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
* own turn-opening event. Optional: old hosts and turns opened unseen omit it, and readers fall
* back to `stateStartedAt`. */
turnStartedAt?: number
/** When the main agent's latest turn ended, stamped by the execution host from the agent's own
* turn-end event and kept through restatements. Absent from old hosts and unclassified agents. */
turnEndedAt?: number
/** How the host identified that end; absent from old hosts (legacy). */
turnEndDisposition?: TurnEndDisposition
orchestration?: AgentStatusOrchestrationContext
providerSession?: AgentProviderSessionMetadata
/** Resume identity update only; the status-shaped fields are transport placeholders. */
+23
View File
@@ -98,6 +98,10 @@ export type AgentStatusEntry = {
/** When the main agent's current turn began, as the hook server stamped it. Absent from old hosts
* and writers without a turn clock; readers fall back to `stateStartedAt`. */
turnStartedAt?: number
/** The host's turn-end stamp; see `AgentStatusIpcPayload.turnEndedAt`. */
turnEndedAt?: number
/** How the host identified that end; see `TurnEndDisposition`. */
turnEndDisposition?: TurnEndDisposition
agentType?: AgentType
/** Provider model currently used by this session. */
model?: string
@@ -275,6 +279,25 @@ export {
// Why: ReadonlySet<string> so .has() accepts any string without a cast here; the narrowing cast stays on the return line where it's proven safe.
const VALID_STATES: ReadonlySet<string> = new Set<string>(AGENT_STATUS_STATES)
/** How the execution host identified the current turn end: `identified` (a verified new turn or
* continuation, or a proven repeat of one) or `ambiguous` (a repeat it can't tell from a turn whose
* opening it missed). Absent: legacy (old hosts, replays, unclassified agents). */
export type TurnEndDisposition = 'identified' | 'ambiguous'
export type HostTurnEndFacts = { turnEndedAt?: number; turnEndDisposition?: TurnEndDisposition }
export function isTurnEndDisposition(value: unknown): value is TurnEndDisposition {
return value === 'identified' || value === 'ambiguous'
}
/** The host's turn-end facts of a row, for the copies that carry them. */
export function pickHostTurnEnd(row: HostTurnEndFacts): HostTurnEndFacts {
return {
...(row.turnEndedAt !== undefined ? { turnEndedAt: row.turnEndedAt } : {}),
...(row.turnEndDisposition !== undefined ? { turnEndDisposition: row.turnEndDisposition } : {})
}
}
export function isAgentStatusState(value: unknown): value is AgentStatusState {
return typeof value === 'string' && VALID_STATES.has(value)
}
@@ -0,0 +1,8 @@
// Why: only these agents' re-entered Stop (`stop_hook_active`) is verified first-hand (Claude by
// lane capture, Codex by source). The host reads no other agent's flag, so their repeats stay
// ambiguous ends.
const STOP_HOOK_CONTINUATION_AGENTS: ReadonlySet<string> = new Set(['claude', 'codex'])
export function agentReportsStopHookContinuation(agent: string | null | undefined): boolean {
return agent !== null && agent !== undefined && STOP_HOOK_CONTINUATION_AGENTS.has(agent)
}
+1 -1
View File
@@ -1,4 +1,4 @@
const NOTIFICATION_COOLDOWN_MS = 5000
export const NOTIFICATION_COOLDOWN_MS = 5000
const MAX_RECENT_NOTIFICATION_KEYS = 50
function pruneRecentNotifications(recentNotifications: Map<string, number>, now: number): void {
@@ -11,6 +11,9 @@ export const TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY = 'terminal.prompt-deli
// Why: paired clients may unmount xterm only when the host can return a
// bounded, sequenced scrollback snapshot for lossless reveal.
export const TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY = 'terminal.paired-parking.v1' as const
// Why: a host without it can't tell a later turn end from a restatement, so clients keep main's
// terminal-title completion behavior for its panes.
export const AGENT_STATUS_TURN_END_RUNTIME_CAPABILITY = 'agent-status.turn-ended-at.v1' as const
// Why: terminal.ensureAgentSession params are strict, so an older host rejects `spawnIntent`; a
// host that advertises this also may answer a declared restore with `terminal_spawn_deferred`.
export const TERMINAL_SPAWN_INTENT_RUNTIME_CAPABILITY = 'terminal.spawn-intent.v1' as const
@@ -30,6 +33,7 @@ export const TERMINAL_RUNTIME_CAPABILITIES = [
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY,
TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY,
TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY,
AGENT_STATUS_TURN_END_RUNTIME_CAPABILITY,
TERMINAL_SPAWN_INTENT_RUNTIME_CAPABILITY,
...TERMINAL_QUICK_COMMAND_RUNTIME_CAPABILITIES,
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
@@ -3,6 +3,7 @@
// redelivery of the same envelope.
import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event'
import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay'
import { pickHostTurnEnd } from '../shared/agent-status-types'
// Why: cap metadata to prevent a misbehaving CLI growing the cache unboundedly.
const MAX_HOOK_META_LEN = 64
@@ -40,6 +41,7 @@ export function buildRelayHookEnvelope(
...(event.providerSession ? { providerSession: event.providerSession } : {}),
...(event.providerSessionOnly ? { providerSessionOnly: true } : {}),
isReplay: options.isReplay === true ? true : undefined,
...pickHostTurnEnd(event),
env,
version,
payload: event.payload
+15 -1
View File
@@ -6,6 +6,7 @@ import type { HookListenerState } from '../shared/agent-hook-listener/listener-s
import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event'
import type { AgentHookSource } from '../shared/agent-hook-relay'
import { retainClaudeSettledRequests } from '../shared/agent-hook-listener/claude-settled-permission-requests'
import { reduceHostTurnEnd } from '../shared/agent-hook-turn-end-timing'
import { buildRelayHookEnvelope } from './agent-hook-envelope-build'
import type { RelayHookForward } from './agent-hook-server-contract'
import { MAX_CACHED_PANES, type CachedPaneEnvelopeMeta } from './agent-hook-cached-pane-status'
@@ -53,13 +54,26 @@ export function applyRelayHookEvent(
if (!transitioned) {
return undefined
}
const event = withRelayClaudeTurnRevision(
const revised = withRelayClaudeTurnRevision(
previous,
transitioned.agentPresence?.ended
? { ...transitioned, providerSessionOnly: true }
: transitioned,
source
)
// Why: the relay is this pane's execution host, the only side that sees the event name.
const {
stopHookActive: _stopHookActive,
nativeTurnKey: _nativeTurnKey,
turnEndedAt: _turnEndedAt,
turnEndDisposition: _turnEndDisposition,
hostTurn: _hostTurn,
...unflagged
} = revised
const event: AgentHookEventPayload = {
...unflagged,
...reduceHostTurnEnd(previous, revised, Date.now(), options.isReplay !== true)
}
// Why: this post came from a process still running inside a pane whose tab the user closed.
// Caching or forwarding it makes every connected client advertise a live, resumable agent pane
// that no tab owns — the advertisement that ends up auto-typing a second `--resume` onto a
@@ -77,6 +77,7 @@ export function applyRelayClaudeInterrupt(
{
...row,
hookEventName: undefined,
toolAgentId: undefined,
hasExplicitPrompt: undefined,
hostEvidenceObservedAt: Date.now(),
claudeRunningNonAgentTask: claudeRowHasUnlistedLiveWork(host.state, row.paneKey),
@@ -11,6 +11,7 @@ import { normalizeHookPayload } from '../shared/agent-hook-listener'
import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event'
import type { HookListenerState } from '../shared/agent-hook-listener/listener-state'
import type { AgentHookSource } from '../shared/agent-hook-relay'
import { pickHostTurnEnd } from '../shared/agent-status-types'
import {
shouldPollHookTranscript,
hookTranscriptWatchPath,
@@ -240,6 +241,7 @@ export class AgentHookResultRetryScheduler {
)
return
}
this.host.applyEvent(event, source, env, version)
// Why: the retry only adds text to the same turn end, so it restates that end instead of minting one.
this.host.applyEvent({ ...event, ...pickHostTurnEnd(current) }, source, env, version)
}
}
+44
View File
@@ -583,6 +583,50 @@ describe('RelayAgentHookServer', () => {
expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBe(
'Relay transcript completed.'
)
// A Stop whose opening the relay never saw is ambiguous; SessionEnd and the retry keep that.
const ends = forward.mock.calls.map(([env]) => `${env.turnEndedAt}:${env.turnEndDisposition}`)
expect(new Set(ends)).toEqual(new Set(['undefined:ambiguous']))
} finally {
server.stop()
}
})
it('marks a verified re-entered Stop as a later end, an unproven repeat ambiguous, and replays both unchanged', async () => {
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
await server.start()
try {
const { port, token } = server.getCoordinates()
const post = (payload: Record<string, unknown>) =>
fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Orca-Agent-Hook-Token': token },
body: JSON.stringify({
paneKey: PANE_KEY,
tabId: 'tab-1',
env: 'remote',
version: '1',
payload
})
})
await post({ hook_event_name: 'UserPromptSubmit', prompt: 'Lint it' })
await post({ hook_event_name: 'Stop', last_assistant_message: 'Draft.' })
await new Promise((resolve) => setTimeout(resolve, 5))
// Claude re-fires Stop with the flag when a blocking Stop hook continues the same turn.
await post({
hook_event_name: 'Stop',
stop_hook_active: true,
last_assistant_message: 'Final.'
})
const [draft, final] = forward.mock.calls.slice(-2).map(([env]) => env.turnEndedAt)
expect(final).toBeGreaterThan(draft!)
await post({ hook_event_name: 'Stop', last_assistant_message: 'Final.' })
const repeat = { turnEndedAt: final, turnEndDisposition: 'ambiguous' }
expect(forward.mock.calls.at(-1)?.[0]).toMatchObject(repeat)
expect(server.replayCachedPayloadsForPanes()).toBe(1)
expect(forward.mock.calls.at(-1)?.[0]).toMatchObject({ isReplay: true, ...repeat })
expect(forward.mock.calls.at(-1)?.[0]).not.toHaveProperty('hostTurn')
} finally {
server.stop()
}