fix(terminal): carry command-lifecycle facts through parked watchers (C1 follow-up)

Parked fact-mode watchers omitted onCommandFinished/onCommandCode*, so
OSC 133;D and Command Code scrape signals went dark while parked. New
parked-terminal-command-status.ts ports the store-level subset: git-UI
nudge on every command finish, same-turn status-row drop for SSH PTYs
(exact mounted-path parity — the foreground tracker refuses SSH ids),
and the Command Code working seed / 1500ms done settle. Byte mode scans
the same shared parsers for authority-off parity. Local-PTY status drops
stay with the mounted pane: they need pty-connection's process-confirm
ladder to tell a leaked nested-shell 133;D from a real agent exit.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-07-28 13:21:54 -07:00
co-authored by Orca
parent 41050eda4f
commit b8f08f8750
4 changed files with 509 additions and 0 deletions
@@ -43,6 +43,18 @@ vi.mock('./use-notification-dispatch', () => ({
dispatchTerminalNotification
}))
// Why: command-status semantics are covered in parked-terminal-command-status.test.ts;
// these tests only prove the watcher wires bytes/facts into the policy.
const commandStatusPolicy = {
onCommandFinished: vi.fn(),
onCommandCodeWorking: vi.fn(),
onCommandCodeDone: vi.fn(),
dispose: vi.fn()
}
vi.mock('./parked-terminal-command-status', () => ({
createParkedTerminalCommandStatusPolicy: vi.fn(() => commandStatusPolicy)
}))
vi.mock('@/lib/terminal-theme', () => ({
getSystemPrefersDark: () => true
}))
@@ -110,6 +122,10 @@ describe('startParkedTerminalByteWatcher', () => {
vi.resetModules()
vi.useFakeTimers()
dispatchTerminalNotification.mockClear()
commandStatusPolicy.onCommandFinished.mockClear()
commandStatusPolicy.onCommandCodeWorking.mockClear()
commandStatusPolicy.onCommandCodeDone.mockClear()
commandStatusPolicy.dispose.mockClear()
onData = null
mockStoreState = createMockStoreState()
;(globalThis as { window: typeof window }).window = {
@@ -372,6 +388,26 @@ describe('startParkedTerminalByteWatcher', () => {
dispose()
})
it('scans OSC 133;D bytes into the parked command policy and disposes it with the watcher', async () => {
const { dispose } = await startWatcher()
emit('\x1b]133;D;0\x07')
expect(commandStatusPolicy.onCommandFinished).toHaveBeenCalledWith(0)
dispose()
expect(commandStatusPolicy.dispose).toHaveBeenCalledTimes(1)
})
it('feeds Command Code output through the parked byte detector', async () => {
const { dispose } = await startWatcher()
emit('# Command Code v0.27.2')
emit('⌘ Parsing...')
expect(commandStatusPolicy.onCommandCodeWorking).toHaveBeenCalledTimes(1)
dispose()
})
it('fires completion when seeded with a working title and the agent goes idle while parked', async () => {
// Why: the pane was working at park time; the watcher's fresh tracker
// must be seeded or this working→idle transition can never fire.
@@ -575,6 +611,32 @@ describe('startParkedTerminalByteWatcher', () => {
dispose()
})
it('routes command lifecycle facts into the parked command policy', async () => {
enableMainAuthority()
const { dispose } = await startWatcher()
await dispatchFacts([
{ kind: 'command-finished', exitCode: 0 },
{ kind: 'command-code-working', prompt: 'Fix the spinner' },
{ kind: 'command-code-done', prompt: 'Fix the spinner' }
])
expect(commandStatusPolicy.onCommandFinished).toHaveBeenCalledWith(0)
expect(commandStatusPolicy.onCommandCodeWorking).toHaveBeenCalledWith('Fix the spinner')
expect(commandStatusPolicy.onCommandCodeDone).toHaveBeenCalledWith('Fix the spinner')
dispose()
})
it('ignores replayed command lifecycle facts (no-attention-replay rule)', async () => {
enableMainAuthority()
const { dispose } = await startWatcher()
await dispatchFacts([{ kind: 'command-finished', exitCode: 0 }], { seq: 5, replay: true })
expect(commandStatusPolicy.onCommandFinished).not.toHaveBeenCalled()
dispose()
})
it('does not request a title snapshot for an ordinary parked watcher', async () => {
enableMainAuthority()
const getSideEffectSnapshot = vi.fn()
@@ -16,6 +16,9 @@ import {
isAgentTaskCompleteOsNotificationEnabledFromState,
isAgentTaskCompleteTrackingEnabledFromState
} from './agent-task-complete-policy'
import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status'
import { createOsc133CommandFinishedScanner } from '../../../../shared/terminal-osc133-command-finished'
import { createParkedTerminalCommandStatusPolicy } from './parked-terminal-command-status'
import { startParkedTerminalMode2031Responder } from './parked-terminal-mode2031-responder'
import { subscribeToPtyData } from './pty-data-sidecar-subscriptions'
import { createPtyOutputProcessor } from './pty-transport'
@@ -187,6 +190,16 @@ export function startParkedTerminalByteWatcher(
}
}
// Why: command-lifecycle signals drive store-level policy only (git nudge, SSH same-turn
// status drop, Command Code seed/settle); the pane-coupled parts stay with the mounted pane.
const commandStatusPolicy = createParkedTerminalCommandStatusPolicy({
ptyId,
worktreeId,
tabId,
paneId,
paneKey
})
// Why: with the authority switch on, the fact consumer is the single policy consumer — registering byte parsers too would double-fire bells.
const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({
settings: useAppStore.getState().settings,
@@ -214,12 +227,26 @@ export function startParkedTerminalByteWatcher(
const observeTerminalGitHubPRLink = mainSideEffectAuthority
? null
: createTerminalGitHubPRLinkDetector()
// Why (byte-parser mode only): mode parity — main's tracker emits these as facts; the byte
// path scans the same shared parsers the mounted kill-switch-off pane uses.
const commandFinishedScanner = mainSideEffectAuthority
? null
: createOsc133CommandFinishedScanner(commandStatusPolicy.onCommandFinished)
const commandCodeOutputStatusDetector = mainSideEffectAuthority
? null
: createCommandCodeOutputStatusDetector({
onWorking: commandStatusPolicy.onCommandCodeWorking,
onDone: commandStatusPolicy.onCommandCodeDone
})
const unregisterFactConsumer = mainSideEffectAuthority
? registerTerminalSideEffectFactConsumer({
ptyId,
// Why: ordinary park already has a pane-owned title; the flag below requests a snapshot only when no pane did.
callbacks: {
...sideEffectCallbacks,
onCommandFinished: commandStatusPolicy.onCommandFinished,
onCommandCodeWorking: commandStatusPolicy.onCommandCodeWorking,
onCommandCodeDone: commandStatusPolicy.onCommandCodeDone,
onPrLink: (link) =>
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link),
// Why (gate mode only): the 2031 subscribe arrives as a fact, but the reply stays here — query authority stays with the view/watcher (invariant 6).
@@ -247,6 +274,8 @@ export function startParkedTerminalByteWatcher(
: subscribeToPtyData(ptyId, (data) => {
// Why: empty pane callbacks — no xterm to deliver bytes to, the watcher wants only the parser side effects.
processor.processData(data, {})
commandFinishedScanner?.scan(data)
commandCodeOutputStatusDetector?.observe(data)
if (observeTerminalGitHubPRLink) {
for (const link of observeTerminalGitHubPRLink(data)) {
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link)
@@ -266,6 +295,8 @@ export function startParkedTerminalByteWatcher(
unregisterFactConsumer?.()
// Why: clears tracker/timer/detector state so the watcher can't fire after the revealed pane's live parsers take over.
processor?.clearAccumulatedState()
commandFinishedScanner?.reset()
commandStatusPolicy.dispose()
clearBellNotificationTimer()
clearAgentTaskCompleteTimer()
pendingBellNotification = false
@@ -0,0 +1,236 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
const PTY_ID_LOCAL = 'pty-1'
const PTY_ID_SSH = 'ssh:target-1@@pty-9'
const TAB_ID = 'tab-1'
const WORKTREE_ID = 'repo-1::/tmp/wt-1'
const PANE_KEY = `${TAB_ID}:11111111-1111-4111-8111-111111111111`
const PANE_ID = 1
// Mirrors COMMAND_CODE_OUTPUT_DONE_SETTLE_MS.
const DONE_SETTLE_MS = 1500
const ROUTING = { connectionId: null }
type MockStoreState = {
agentStatusByPaneKey: Record<string, AgentStatusEntry | undefined>
runtimePaneTitlesByTabId: Record<string, Record<number, string | undefined>>
setAgentStatus: ReturnType<typeof vi.fn>
dropAgentStatus: ReturnType<typeof vi.fn>
clearAgentLaunchConfig: ReturnType<typeof vi.fn>
}
let mockStoreState: MockStoreState
const dispatchTerminalCommandFinishedEvent = vi.fn()
const resolveLiveAgentStatusConnectionRouting = vi.fn()
const getConnectionIdFromState = vi.fn()
vi.mock('@/store', () => ({
useAppStore: { getState: () => mockStoreState }
}))
vi.mock('@/hooks/terminal-command-finished-event', () => ({
dispatchTerminalCommandFinishedEvent
}))
vi.mock('@/lib/agent-status-connection-ownership', () => ({
resolveLiveAgentStatusConnectionRouting
}))
vi.mock('@/lib/connection-owner-resolution', () => ({
getConnectionIdFromState
}))
function makeStatusEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
return {
state: 'working',
prompt: 'build the feature',
agentType: 'claude',
updatedAt: 1000,
stateStartedAt: 1000,
...overrides
} as AgentStatusEntry
}
async function createPolicy(ptyId: string) {
const { createParkedTerminalCommandStatusPolicy } =
await import('./parked-terminal-command-status')
return createParkedTerminalCommandStatusPolicy({
ptyId,
worktreeId: WORKTREE_ID,
tabId: TAB_ID,
paneId: PANE_ID,
paneKey: PANE_KEY
})
}
describe('createParkedTerminalCommandStatusPolicy', () => {
beforeEach(() => {
vi.resetModules()
vi.useFakeTimers()
dispatchTerminalCommandFinishedEvent.mockClear()
resolveLiveAgentStatusConnectionRouting.mockReset().mockReturnValue(ROUTING)
getConnectionIdFromState.mockReset().mockReturnValue(null)
mockStoreState = {
agentStatusByPaneKey: {},
runtimePaneTitlesByTabId: { [TAB_ID]: { [PANE_ID]: '✳ Build feature' } },
setAgentStatus: vi.fn(),
dropAgentStatus: vi.fn(),
clearAgentLaunchConfig: vi.fn()
}
})
afterEach(() => {
vi.useRealTimers()
})
it('seeds a Command Code working row with the current pane title', async () => {
const policy = await createPolicy(PTY_ID_LOCAL)
policy.onCommandCodeWorking('Fix the spinner')
expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith(
PANE_KEY,
{ state: 'working', prompt: 'Fix the spinner', agentType: 'command-code' },
'✳ Build feature',
undefined,
ROUTING
)
policy.dispose()
})
it('writes nothing when connection routing does not resolve', async () => {
resolveLiveAgentStatusConnectionRouting.mockReturnValue(undefined)
const policy = await createPolicy(PTY_ID_LOCAL)
policy.onCommandCodeWorking('Fix the spinner')
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
policy.dispose()
})
it('leaves a settled done row alone when working repeats the same prompt', async () => {
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
state: 'done',
prompt: 'Fix the spinner',
agentType: 'command-code'
})
const policy = await createPolicy(PTY_ID_LOCAL)
policy.onCommandCodeWorking('Fix the spinner')
policy.onCommandCodeWorking('')
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
policy.dispose()
})
it('settles a still-working Command Code row to done after the settle window', async () => {
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
state: 'working',
prompt: 'Fix the spinner',
agentType: 'command-code'
})
const policy = await createPolicy(PTY_ID_LOCAL)
policy.onCommandCodeDone('Fix the spinner')
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
vi.advanceTimersByTime(DONE_SETTLE_MS)
expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith(
PANE_KEY,
{ state: 'done', prompt: 'Fix the spinner', agentType: 'command-code' },
'✳ Build feature',
undefined,
ROUTING
)
policy.dispose()
})
it('abandons the done settle when another agent owns the row by then', async () => {
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ agentType: 'claude' })
const policy = await createPolicy(PTY_ID_LOCAL)
policy.onCommandCodeDone('Fix the spinner')
vi.advanceTimersByTime(DONE_SETTLE_MS)
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
policy.dispose()
})
it('cancels a pending done settle when a working repaint arrives', async () => {
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
state: 'working',
prompt: 'Fix the spinner',
agentType: 'command-code'
})
const policy = await createPolicy(PTY_ID_LOCAL)
policy.onCommandCodeDone('Fix the spinner')
policy.onCommandCodeWorking('Fix the spinner')
vi.advanceTimersByTime(DONE_SETTLE_MS * 2)
const states = mockStoreState.setAgentStatus.mock.calls.map(([, payload]) => payload.state)
expect(states).toEqual(['working'])
policy.dispose()
})
it('dispose cancels a pending done settle', async () => {
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
state: 'working',
prompt: 'Fix the spinner',
agentType: 'command-code'
})
const policy = await createPolicy(PTY_ID_LOCAL)
policy.onCommandCodeDone('Fix the spinner')
policy.dispose()
vi.advanceTimersByTime(DONE_SETTLE_MS * 2)
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
})
it('nudges git UI on command finished for every PTY class', async () => {
const local = await createPolicy(PTY_ID_LOCAL)
local.onCommandFinished(0)
local.dispose()
const ssh = await createPolicy(PTY_ID_SSH)
ssh.onCommandFinished(0)
ssh.dispose()
expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledTimes(2)
expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledWith(WORKTREE_ID)
})
it('drops a same-turn status row on command finished for SSH PTYs only', async () => {
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry()
const local = await createPolicy(PTY_ID_LOCAL)
local.onCommandFinished(0)
// Why: local drops need the mounted pane's foreground process-confirm ladder
// (leaked nested-shell 133;D protection), so the watcher must not drop them.
expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled()
local.dispose()
const ssh = await createPolicy(PTY_ID_SSH)
ssh.onCommandFinished(0)
expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(PANE_KEY)
ssh.dispose()
})
it('clears the launch registry on SSH command finished when no status row exists', async () => {
const ssh = await createPolicy(PTY_ID_SSH)
ssh.onCommandFinished(0)
expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(PANE_KEY)
expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled()
ssh.dispose()
})
it('does nothing after dispose', async () => {
const ssh = await createPolicy(PTY_ID_SSH)
ssh.dispose()
ssh.onCommandFinished(0)
ssh.onCommandCodeWorking('Fix the spinner')
expect(dispatchTerminalCommandFinishedEvent).not.toHaveBeenCalled()
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,180 @@
/**
* Parked-pane command-lifecycle status policy.
* Why: parking unmounts TerminalPane, so OSC 133;D and Command Code scrape signals went dark.
* This ports the store-level subset of pty-connection's handlers; the pane-coupled parts
* (foreground process-confirm ladder, key-intent interrupt inference) stay with the mounted pane.
*/
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event'
import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership'
import { getConnectionIdFromState } from '@/lib/connection-owner-resolution'
import { useAppStore } from '@/store'
// Mirrors pty-connection.ts COMMAND_CODE_OUTPUT_DONE_SETTLE_MS — the settle window must be
// identical whether the pane is mounted or parked, or park/reveal changes completion timing.
const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500
export type ParkedTerminalCommandStatusPolicy = {
onCommandFinished: (bestEffortExitCode: number | null) => void
onCommandCodeWorking: (prompt: string) => void
onCommandCodeDone: (prompt: string) => void
dispose: () => void
}
export function createParkedTerminalCommandStatusPolicy(options: {
ptyId: string
worktreeId: string
tabId: string
/** PaneManager pane id whose runtime-title slot the watcher writes; read for status title pairing. */
paneId: number
paneKey: string
}): ParkedTerminalCommandStatusPolicy {
const { ptyId, worktreeId, tabId, paneId, paneKey } = options
let disposed = false
let commandCodeOutputDoneTimer: ReturnType<typeof setTimeout> | null = null
const clearCommandCodeOutputDoneTimer = (): void => {
if (commandCodeOutputDoneTimer !== null) {
clearTimeout(commandCodeOutputDoneTimer)
commandCodeOutputDoneTimer = null
}
}
const resolveRouting = (): ReturnType<typeof resolveLiveAgentStatusConnectionRouting> => {
if (disposed) {
return undefined
}
const state = useAppStore.getState()
return resolveLiveAgentStatusConnectionRouting({
state,
paneKey,
ptyId,
expectedConnectionId: getConnectionIdFromState(state, worktreeId)
})
}
// Port of pty-connection's dropCommandFinishedStatusIfSameTurn, minus the interrupt-inference
// option: parked panes receive no key events, so inference never has evidence here.
const dropCommandFinishedStatusIfSameTurn = (entry: AgentStatusEntry | undefined): void => {
const state = useAppStore.getState()
if (!entry) {
// Why: an Orca-started agent can exit before its first hook status; clear the launch
// registry on command exit like the mounted path does.
state.clearAgentLaunchConfig(paneKey)
return
}
const current = state.agentStatusByPaneKey[paneKey]
if (!current) {
state.clearAgentLaunchConfig(paneKey)
return
}
const unchanged =
current.state === entry.state &&
current.prompt === entry.prompt &&
current.updatedAt === entry.updatedAt &&
current.stateStartedAt === entry.stateStartedAt &&
current.agentType === entry.agentType
if (!unchanged) {
return
}
state.dropAgentStatus(paneKey)
}
return {
onCommandFinished: (): void => {
if (disposed) {
return
}
// Why: the finished command may have moved HEAD or the index (an agent running
// `git checkout` in a parked worktree); nudge git UI now instead of waiting for a poll.
dispatchTerminalCommandFinishedEvent(worktreeId)
// Why: drop the same-turn status row only for SSH PTYs — exact parity with the mounted
// path, whose foreground tracker refuses SSH ids and drops un-probed. Local PTYs need
// pty-connection's process-confirm ladder to tell a leaked nested-shell 133;D from a
// real agent exit, so their drop stays with the mounted pane.
if (parseAppSshPtyId(ptyId) === null) {
return
}
dropCommandFinishedStatusIfSameTurn(useAppStore.getState().agentStatusByPaneKey[paneKey])
},
// Port of pty-connection's seedCommandCodeOutputWorkingStatus (store-level only).
onCommandCodeWorking: (prompt: string): void => {
clearCommandCodeOutputDoneTimer()
const routing = resolveRouting()
if (!routing) {
return
}
const currentState = useAppStore.getState()
const currentEntry = currentState.agentStatusByPaneKey[paneKey]
const currentTitle = currentState.runtimePaneTitlesByTabId?.[tabId]?.[paneId]
const normalizedPrompt = prompt.trim()
if (
currentEntry?.agentType === 'command-code' &&
currentEntry.state === 'done' &&
(!normalizedPrompt || normalizedPrompt === currentEntry.prompt.trim())
) {
return
}
currentState.setAgentStatus(
paneKey,
{
state: 'working',
prompt:
normalizedPrompt || (currentEntry?.state === 'working' ? currentEntry.prompt : ''),
agentType: 'command-code'
},
currentTitle,
undefined,
routing
)
},
// Port of pty-connection's scheduleCommandCodeOutputDoneStatus: Command Code keeps rendering
// the composer while tools run, so only complete the row if no active repaint arrives.
onCommandCodeDone: (prompt: string): void => {
clearCommandCodeOutputDoneTimer()
const normalizedPrompt = prompt.trim()
if (!normalizedPrompt) {
return
}
commandCodeOutputDoneTimer = setTimeout(() => {
commandCodeOutputDoneTimer = null
if (disposed) {
return
}
const routing = resolveRouting()
if (!routing) {
return
}
const currentState = useAppStore.getState()
const currentEntry = currentState.agentStatusByPaneKey[paneKey]
if (currentEntry?.agentType !== 'command-code' || currentEntry.state !== 'working') {
return
}
const currentPrompt = currentEntry.prompt.trim()
if (currentPrompt && currentPrompt !== normalizedPrompt) {
return
}
const currentTitle = currentState.runtimePaneTitlesByTabId?.[tabId]?.[paneId]
currentState.setAgentStatus(
paneKey,
{
state: 'done',
prompt: currentPrompt || normalizedPrompt,
agentType: 'command-code'
},
currentTitle,
undefined,
routing
)
}, COMMAND_CODE_OUTPUT_DONE_SETTLE_MS)
},
dispose: (): void => {
disposed = true
clearCommandCodeOutputDoneTimer()
}
}
}