From 151cb5ee3beec6e40aed3269241e98db34e6754e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:16:56 -0700 Subject: [PATCH] feat(agents): distinguish Claude background monitoring Adds an optional `workingMode: 'monitoring'` discriminator for a Claude session whose lead turn finished but which still has background shell tasks or session crons registered. The wire state stays `working`, so older peers that never read the field keep rendering Working. (cherry picked from commit d5d54b4bdd6e1c18d8e8c0a7de43b1c030d73413) Rebased onto current main (554 commits of drift) by Brennan Benson; conflicts resolved by keeping both sides where main and this branch made independent additions to the same construct. --- mobile/src/components/AgentSpinner.tsx | 23 +- mobile/src/components/AgentStateDot.tsx | 14 +- mobile/src/components/WorktreeListRow.test.ts | 19 +- mobile/src/components/WorktreeListRow.tsx | 4 +- .../agent-monitoring-indicators.test.ts | 80 + .../use-mobile-native-chat-controller.test.ts | 14 + .../use-mobile-native-chat-controller.ts | 9 +- mobile/src/worktree/agent-row-display.test.ts | 9 + mobile/src/worktree/agent-row-display.ts | 19 +- mobile/src/worktree/workspace-list-types.ts | 2 + .../worktree/worktree-list-snapshot.test.ts | 14 + mobile/src/worktree/worktree-list-snapshot.ts | 2 + ...k-status-session-tabs-invalidation.test.ts | 1 + .../hook-status-session-tabs-invalidation.ts | 1 + src/main/agent-hooks/server.test.ts | 8861 +++++++++++++++++ src/main/agent-hooks/server.ts | 35 +- .../ipc/dashboard-payload-validation.test.ts | 25 + src/main/ipc/dashboard-payload-validation.ts | 2 + src/main/runtime/orca-runtime.test.ts | 255 + src/main/runtime/orca-runtime.ts | 191 +- ...ay-session-agent-hooks.integration.test.ts | 38 + src/relay/agent-hook-server.test.ts | 2 +- .../src/components/AgentStateDot.test.ts | 9 + src/renderer/src/components/AgentStateDot.tsx | 16 +- .../activity/ActivityPrototypePage.test.ts | 21 + .../activity/ActivityPrototypePage.tsx | 41 +- .../cmd-j/palette-live-status.test.tsx | 55 +- .../components/cmd-j/palette-live-status.tsx | 48 +- .../dashboard-popout/AgentKanbanCard.test.tsx | 24 +- .../dashboard-popout/AgentKanbanCard.tsx | 1 + .../AgentMapProjectLabel.test.tsx | 1 + .../AgentMapWorktreeRingNode.tsx | 5 +- .../dashboard-popout/agent-map-filter.ts | 3 + .../agent-map-label-declutter.ts | 7 +- .../dashboard-popout/agent-map-layout.test.ts | 3 + .../agent-map-node-metadata.ts | 5 +- .../agent-map-worktree-active-status.test.ts | 5 + .../components/dashboard-popout/agent-map.css | 4 + .../dashboard-agent-status-patch.test.ts | 13 + .../dashboard-agent-status-patch.ts | 7 +- .../useDashboardSnapshot.test.tsx | 34 +- .../dashboard/DashboardAgentRow.test.tsx | 20 + .../dashboard/DashboardAgentRow.tsx | 18 +- .../build-dashboard-snapshot.test.ts | 17 + .../dashboard/build-dashboard-snapshot.ts | 9 +- .../dashboard/dashboard-card-bucket.ts | 8 +- .../editor/ReviewNotesSendMenuContent.tsx | 8 +- .../use-native-chat-hook-status.ts | 7 +- .../use-native-chat-live-session.test.ts | 26 + .../components/pet/pet-agent-state.test.ts | 8 + .../src/components/pet/pet-agent-state.ts | 2 +- .../sidebar/StatusIndicator.test.ts | 9 + .../components/sidebar/StatusIndicator.tsx | 13 + .../sidebar/use-worktree-activity-status.ts | 12 +- .../sidebar/use-worktree-activity-statuses.ts | 2 + .../worktree-agent-activity-summary.test.ts | 26 + .../worktree-agent-activity-summary.ts | 11 +- .../worktree-card-agent-summary.test.ts | 42 + .../sidebar/worktree-card-agent-summary.ts | 10 +- .../worktree-card-compact-agent-row.tsx | 6 +- .../terminal-tab-activity-status.test.ts | 26 + .../tab-bar/terminal-tab-activity-status.ts | 19 +- .../workspace-cleanup-facets.ts | 15 +- ...ts-agent-status-snapshot-hydration.test.ts | 8 +- src/renderer/src/hooks/useIpcEvents.ts | 1 + .../src/lib/recent-workspace-tab-rows.test.ts | 23 + .../src/lib/recent-workspace-tab-rows.ts | 18 +- .../src/lib/worktree-activity-state.test.ts | 37 + .../src/lib/worktree-activity-state.ts | 16 +- src/renderer/src/lib/worktree-status.test.ts | 35 + src/renderer/src/lib/worktree-status.ts | 16 +- ...time-graph-agent-status-projection.test.ts | 2 + .../src/runtime/sync-runtime-graph.ts | 1 + ...tabs-sync-remote-status-title-flap.test.ts | 56 + .../src/runtime/web-session-tabs-sync.ts | 8 +- ...agent-status-tool-assistant-fields.test.ts | 24 + src/renderer/src/store/slices/agent-status.ts | 7 +- src/shared/agent-hook-listener.ts | 54 +- src/shared/agent-hook-relay.test.ts | 2 + src/shared/agent-status-ipc-payload.ts | 56 + src/shared/agent-status-types.test.ts | 22 +- src/shared/agent-status-types.ts | 56 +- .../agent-terminal-status-equivalence.test.ts | 31 + .../agent-terminal-status-equivalence.ts | 32 + .../claude-background-task-status.test.ts | 45 +- src/shared/dashboard-snapshot.ts | 12 +- src/shared/runtime-types.ts | 4 + 87 files changed, 10580 insertions(+), 222 deletions(-) create mode 100644 mobile/src/components/agent-monitoring-indicators.test.ts create mode 100644 src/main/agent-hooks/server.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts create mode 100644 src/shared/agent-status-ipc-payload.ts create mode 100644 src/shared/agent-terminal-status-equivalence.test.ts create mode 100644 src/shared/agent-terminal-status-equivalence.ts diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx index 673bbff049e..a5e81ec07a9 100644 --- a/mobile/src/components/AgentSpinner.tsx +++ b/mobile/src/components/AgentSpinner.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef } from 'react' +import { Radio } from 'lucide-react-native' import { Animated, Easing, StyleSheet, View } from 'react-native' +import type { AgentWorkingMode } from '../../../src/shared/agent-status-types' type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive' @@ -17,11 +19,18 @@ const STATUS_COLORS: Record = { inactive: 'rgba(115,115,115,0.4)' } -export function AgentSpinner({ status }: { status: WorktreeStatus }) { +export function AgentSpinner({ + status, + workingMode +}: { + status: WorktreeStatus + workingMode?: AgentWorkingMode +}) { const spinValue = useRef(new Animated.Value(0)).current + const monitoring = status === 'working' && workingMode === 'monitoring' useEffect(() => { - if (status === 'working') { + if (status === 'working' && !monitoring) { const animation = Animated.loop( Animated.timing(spinValue, { toValue: 1, @@ -34,10 +43,18 @@ export function AgentSpinner({ status }: { status: WorktreeStatus }) { return () => animation.stop() } spinValue.setValue(0) - }, [status, spinValue]) + }, [monitoring, status, spinValue]) const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive + if (monitoring) { + return ( + + + + ) + } + if (status === 'working') { const rotate = spinValue.interpolate({ inputRange: [0, 1], diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index 916525ef366..4d596b7e094 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react' +import { Radio } from 'lucide-react-native' import { Animated, Easing, StyleSheet, View } from 'react-native' import type { AgentDotState } from '../worktree/agent-row-display' @@ -7,13 +8,14 @@ import type { AgentDotState } from '../worktree/agent-row-display' // emerald for 'done', red for blocked/waiting/interrupted (attention), neutral // for idle. Distinct from the worktree-level AgentSpinner, which collapses the // agent vocabulary into the 5-state rollup the sidebar dot uses. -const DOT_COLORS: Record, string> = { +const DOT_COLORS: Record, string> = { done: '#10b981', blocked: '#ef4444', waiting: '#ef4444', interrupted: '#ef4444', idle: 'rgba(115,115,115,0.4)' } +const WORKING_COLOR = '#eab308' export function AgentStateDot({ state }: { state: AgentDotState }) { const spinValue = useRef(new Animated.Value(0)).current @@ -44,6 +46,14 @@ export function AgentStateDot({ state }: { state: AgentDotState }) { ) } + if (state === 'monitoring') { + return ( + + + + ) + } + return ( @@ -59,7 +69,7 @@ const styles = StyleSheet.create({ height: 6, borderRadius: 3, borderWidth: 1.5, - borderColor: '#eab308', + borderColor: WORKING_COLOR, borderTopColor: 'transparent' } }) diff --git a/mobile/src/components/WorktreeListRow.test.ts b/mobile/src/components/WorktreeListRow.test.ts index d0825412636..01e0128cdc1 100644 --- a/mobile/src/components/WorktreeListRow.test.ts +++ b/mobile/src/components/WorktreeListRow.test.ts @@ -199,7 +199,7 @@ describe('memoized worktree rows', () => { await act(async () => { renderer!.update( createElement(WorktreeAgentRow, { - agent: agent({ state: 'done', updatedAt: 2_000 }), + agent: agent({ state: 'working', workingMode: 'monitoring', updatedAt: 2_000 }), depth: 0, now: 2_000, unvisited: false @@ -207,5 +207,22 @@ describe('memoized worktree rows', () => { ) }) expect(agentStateDotRender).toHaveBeenCalledTimes(2) + expect(agentStateDotRender).toHaveBeenLastCalledWith({ state: 'monitoring' }) + }) + + it('passes workspace monitoring mode to the status indicator', async () => { + await act(async () => { + renderer = create( + createElement(ListRowHarness, { + item: { ...baseItem, status: 'working', workingMode: 'monitoring' }, + now: 2_000 + }) + ) + }) + + expect(agentSpinnerRender).toHaveBeenLastCalledWith({ + status: 'working', + workingMode: 'monitoring' + }) }) }) diff --git a/mobile/src/components/WorktreeListRow.tsx b/mobile/src/components/WorktreeListRow.tsx index 74b716c2b30..ba1c3865fc7 100644 --- a/mobile/src/components/WorktreeListRow.tsx +++ b/mobile/src/components/WorktreeListRow.tsx @@ -2,6 +2,7 @@ import { memo } from 'react' import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native' import { Pressable, StyleSheet, Text, View } from 'react-native' import type { RepoIcon } from '../../../src/shared/repo-icon' +import type { AgentWorkingMode } from '../../../src/shared/agent-status-types' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' import { triggerMediumImpact } from '../platform/haptics' import { colors, radii, spacing, typography } from '../theme/mobile-theme' @@ -39,6 +40,7 @@ export type WorktreeListRowItem = { lineageChildCount?: number lineageCollapsed?: boolean agents?: RuntimeWorktreeAgentRow[] + workingMode?: AgentWorkingMode } type WorktreeRollupStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive' @@ -97,7 +99,7 @@ function WorktreeListRowComponent({ delayLongPress={400} > - + {item.unread && ( ({ + animationLoop: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), + animationTiming: vi.fn(() => ({})), + setValue: vi.fn() +})) + +vi.mock('lucide-react-native', () => ({ Radio: 'Radio' })) +vi.mock('react-native', () => ({ + Animated: { + Value: function Value() { + return { interpolate: vi.fn(() => 'rotation'), setValue } + }, + View: 'AnimatedView', + loop: animationLoop, + timing: animationTiming + }, + Easing: { linear: 'linear' }, + StyleSheet: { create: (styles: T) => styles }, + View: 'View' +})) + +describe('mobile monitoring indicators', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + animationLoop.mockClear() + animationTiming.mockClear() + setValue.mockClear() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('renders a static Radio for a monitoring agent', async () => { + await act(async () => { + renderer = create(createElement(AgentStateDot, { state: 'monitoring' })) + }) + + expect(renderer.root.findByType('Radio').props).toMatchObject({ + color: DESKTOP_WORKING_COLOR, + size: 10 + }) + expect(animationTiming).not.toHaveBeenCalled() + expect(animationLoop).not.toHaveBeenCalled() + }) + + it('renders a static Radio for an all-monitoring workspace', async () => { + await act(async () => { + renderer = create( + createElement(AgentSpinner, { status: 'working', workingMode: 'monitoring' }) + ) + }) + + expect(renderer.root.findByType('Radio').props).toMatchObject({ + color: DESKTOP_WORKING_COLOR, + size: 12 + }) + expect(animationTiming).not.toHaveBeenCalled() + expect(animationLoop).not.toHaveBeenCalled() + }) + + it('keeps the spinner fallback when workingMode is absent', async () => { + await act(async () => { + renderer = create(createElement(AgentSpinner, { status: 'working' })) + }) + + expect(animationTiming).toHaveBeenCalledOnce() + expect(animationLoop).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index a6308d08be5..3f4c0b7907b 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -697,7 +697,9 @@ describe('useMobileNativeChatController streaming scope', () => { launchAgent: 'claude', agentStatus: { state: 'working', + workingMode: undefined as 'monitoring' | undefined, agentType: 'claude', + lastAssistantMessage: 'Partial reply', providerSession: { id: 'session-1' } }, isActive: true @@ -723,6 +725,7 @@ describe('useMobileNativeChatController streaming scope', () => { beforeEach(() => { viewMode.isTabChatView = () => true + workingTab.agentStatus.workingMode = undefined act(() => { renderer = create(createElement(Harness)) }) @@ -737,6 +740,7 @@ describe('useMobileNativeChatController streaming scope', () => { it('holds the stream scope and liveness while the user peeks at the terminal', () => { expect(controller?.showNativeChat).toBe(true) + expect(controller?.nativeChatAgentWorking).toBe(true) const scopeKey = controller?.nativeChatStreamScopeKey expect(scopeKey).toContain('session-1') expect(controller?.nativeChatStreamLive).toBe(true) @@ -750,6 +754,16 @@ describe('useMobileNativeChatController streaming scope', () => { expect(controller?.nativeChatStreamLive).toBe(true) }) + it('treats passive monitoring as outside the foreground turn', () => { + workingTab.agentStatus.workingMode = 'monitoring' + act(() => renderer?.update(createElement(Harness))) + + expect(controller?.nativeChatAgentWorking).toBe(false) + expect(controller?.nativeChatStreamLive).toBe(false) + expect(controller?.nativeChatStreamingText).toBeUndefined() + expect(controller?.nativeChatSessionOptions?.isWorking).toBe(false) + }) + it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => { const before = answerSendArgs.at(-1)?.streamIdentity expect(before).toBe(controller?.nativeChatStreamScopeKey) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 27ad8049c1d..ab1319c7847 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -115,11 +115,14 @@ export function useMobileNativeChatController(args: { transcriptSettled: nativeChatSession.status === 'ready' }) - const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null - const nativeChatAgentWorking = nativeChatStatus?.state === 'working' + const activeTabStatus = activeSessionTab?.agentStatus + const activeTabAgentWorking = + activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring' + const nativeChatStatus = activeChatResolution ? activeTabStatus : null + const nativeChatAgentWorking = activeChatResolution != null && activeTabAgentWorking // Deliberately not gated on the chat view being visible: the streaming gate // has to tell "hidden mid-turn" from "the turn ended". - const nativeChatStreamLive = activeSessionTab?.agentStatus?.state === 'working' + const nativeChatStreamLive = activeTabAgentWorking // Throttle the streaming bubble: OpenCode emits a status frame per streamed // part, and each one re-renders and re-parses the whole accumulated markdown. const nativeChatStreamingText = useThrottledLatestValue( diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index 80889753def..ee872e40f26 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -28,6 +28,9 @@ function row(overrides: Partial = {}): RuntimeWorktreeA describe('agentDotState', () => { it('maps known states through and unknown to idle', () => { expect(agentDotState(row({ state: 'working', updatedAt: 0 }), 0)).toBe('working') + expect( + agentDotState(row({ state: 'working', workingMode: 'monitoring', updatedAt: 0 }), 0) + ).toBe('monitoring') expect(agentDotState(row({ state: 'blocked', updatedAt: 0 }), 0)).toBe('blocked') expect(agentDotState(row({ state: 'waiting', updatedAt: 0 }), 0)).toBe('waiting') expect(agentDotState(row({ state: 'done', updatedAt: 0 }), 0)).toBe('done') @@ -65,6 +68,12 @@ describe('agentDisplayLabel', () => { expect(agentDisplayLabel(row({ state: 'working', prompt: '', updatedAt: 0 }), 0)).toBe( 'Working' ) + expect( + agentDisplayLabel( + row({ state: 'working', workingMode: 'monitoring', prompt: '', updatedAt: 0 }), + 0 + ) + ).toBe('Monitoring background tasks') }) it('falls back to the decayed state label when stale', () => { diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index 05391b88b89..c678b522a15 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -9,23 +9,34 @@ export const AGENT_STATUS_STALE_AFTER_MS = 30 * 60 * 1000 // Mirrors the desktop AgentStateDot vocabulary. The wire `state` is the agent // status state; 'blocked'/'waiting' read as attention states, 'done' as // complete, everything else idle. -export type AgentDotState = 'working' | 'blocked' | 'waiting' | 'done' | 'idle' | 'interrupted' +export type AgentDotState = + | 'working' + | 'monitoring' + | 'blocked' + | 'waiting' + | 'done' + | 'idle' + | 'interrupted' export function agentDotState( - row: Pick, + row: Pick, now: number ): AgentDotState { if (row.interrupted) { return 'interrupted' } switch (row.state) { - case 'working': case 'blocked': case 'waiting': // Why: an agent that exits without a final report would otherwise read as // active forever. Decay a stale active state to idle, matching desktop's // renderer-side staleness decay (worktree-agent-rows.ts). return now - row.updatedAt > AGENT_STATUS_STALE_AFTER_MS ? 'idle' : row.state + case 'working': + if (now - row.updatedAt > AGENT_STATUS_STALE_AFTER_MS) { + return 'idle' + } + return row.workingMode === 'monitoring' ? 'monitoring' : 'working' case 'done': return 'done' } @@ -37,6 +48,8 @@ export function agentStateLabel(state: AgentDotState): string { switch (state) { case 'working': return 'Working' + case 'monitoring': + return 'Monitoring background tasks' case 'blocked': return 'Blocked' case 'waiting': diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index 4ddb135affe..afa937f8baa 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -1,4 +1,5 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host' +import type { AgentWorkingMode } from '../../../src/shared/agent-status-types' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' export type Worktree = { @@ -45,6 +46,7 @@ export type Worktree = { linkedGitLabIssue?: number | null comment?: string status?: 'working' | 'active' | 'permission' | 'done' | 'inactive' + workingMode?: AgentWorkingMode agents?: RuntimeWorktreeAgentRow[] } diff --git a/mobile/src/worktree/worktree-list-snapshot.test.ts b/mobile/src/worktree/worktree-list-snapshot.test.ts index d6d3af1930c..c7c5756160f 100644 --- a/mobile/src/worktree/worktree-list-snapshot.test.ts +++ b/mobile/src/worktree/worktree-list-snapshot.test.ts @@ -160,6 +160,20 @@ describe('areWorktreeListsEqual', () => { expect(areWorktreeListsEqual(first, second)).toBe(false) }) + it('detects monitoring mode changes within working', () => { + const first = [worktree({ agents: [agent({ state: 'working' })] })] + const second = [worktree({ agents: [agent({ state: 'working', workingMode: 'monitoring' })] })] + + expect(areWorktreeListsEqual(first, second)).toBe(false) + }) + + it('detects workspace monitoring mode changes within working', () => { + const first = [worktree({ status: 'working' })] + const second = [worktree({ status: 'working', workingMode: 'monitoring' })] + + expect(areWorktreeListsEqual(first, second)).toBe(false) + }) + it('treats missing and empty agent arrays as equivalent for rendering', () => { const first = [worktree({ agents: undefined })] const second = [worktree({ agents: [] })] diff --git a/mobile/src/worktree/worktree-list-snapshot.ts b/mobile/src/worktree/worktree-list-snapshot.ts index bb9df6cf9af..8fa27c2a197 100644 --- a/mobile/src/worktree/worktree-list-snapshot.ts +++ b/mobile/src/worktree/worktree-list-snapshot.ts @@ -55,6 +55,7 @@ function areWorktreesEqual(left: Worktree, right: Worktree): boolean { (left.linkedGitLabIssue ?? null) === (right.linkedGitLabIssue ?? null) && (left.comment ?? '') === (right.comment ?? '') && (left.status ?? null) === (right.status ?? null) && + (left.workingMode ?? null) === (right.workingMode ?? null) && arePullRequestsEqual(left.linkedPR, right.linkedPR) && areAgentRowsEqual(left.agents ?? [], right.agents ?? []) ) @@ -102,6 +103,7 @@ function areAgentRowsEqual( a.paneKey !== b.paneKey || a.parentPaneKey !== b.parentPaneKey || a.state !== b.state || + a.workingMode !== b.workingMode || a.agentType !== b.agentType || a.prompt !== b.prompt || a.lastAssistantMessage !== b.lastAssistantMessage || diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts index d554c6e4e00..5f28a40c039 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts @@ -38,6 +38,7 @@ describe('createHookStatusSessionTabsInvalidator', () => { it.each([ ['state', { state: 'waiting' as const }], + ['workingMode', { workingMode: 'monitoring' as const }], ['prompt', { prompt: 'ship it' }], ['agentType', { agentType: 'codex' }], ['toolName', { toolName: 'Bash' }], diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts index 61d9fe00f0e..0a36eef0f56 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts @@ -33,6 +33,7 @@ export function createHookStatusSessionTabsInvalidator(): { return ( !previous || previous.payload.state !== next.state || + previous.payload.workingMode !== next.workingMode || previous.payload.prompt !== next.prompt || (previous.payload.agentType ?? null) !== (next.agentType ?? null) || (previous.payload.toolName ?? null) !== (next.toolName ?? null) || diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts new file mode 100644 index 00000000000..142b28325e0 --- /dev/null +++ b/src/main/agent-hooks/server.test.ts @@ -0,0 +1,8861 @@ +/* eslint-disable max-lines -- Why: this suite exercises the full hook HTTP surface (Claude/Codex/Gemini parsing, transcript chunked scan, paneKey dispatch) and keeping the scenarios co-located avoids fixture drift across files. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + AgentHookServer, + agentHookServer, + CLOSED_AGENT_STATUS_TAB_IDS_MAX, + _internals +} from './server' +import { + AGENT_STATUS_MAX_FIELD_LENGTH, + AGENT_STATUS_STALE_AFTER_MS, + parseAgentStatusPayload +} from '../../shared/agent-status-types' +import { + createHookListenerState, + normalizeHookPayload, + type HookListenerState +} from '../../shared/agent-hook-listener' +import { makePaneKey } from '../../shared/stable-pane-id' +import { createShedSubagentsField } from '../../shared/agent-hook-relay' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ + track: trackMock +})) + +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: getCohortAtEmitMock +})) + +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' +const LEAF_3 = '33333333-3333-4333-8333-333333333333' +const LEAF_4 = '44444444-4444-4444-8444-444444444444' +const LEAF_5 = '55555555-5555-4555-8555-555555555555' +const PANE = makePaneKey('tab-1', LEAF_1) +const GOOD_PANE = makePaneKey('tab-good', LEAF_2) +const OLD_PANE = makePaneKey('tab-old', LEAF_3) +const FRESH_PANE = makePaneKey('tab-fresh', LEAF_4) +const TAB_A_PANE = makePaneKey('tab-A', LEAF_5) +const RUNNING_SHELL = { id: 'shell-1', type: 'shell', status: 'running' } + +type Body = { + paneKey: string + launchToken?: string + tabId?: string + worktreeId?: string + env?: string + version?: string + payload: Record +} + +type AgentHookServerCacheInternals = { + assistantMessageRetryTimers: Map> + promptSentDedupeByPaneKey: Map + runtimeObservedStatusPaneKeys: Set + scheduleStatusPersist: () => void +} + +function buildBody(payload: Record, overrides: Partial = {}): Body { + return { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + env: 'production', + payload, + ...overrides + } +} + +beforeEach(() => { + _internals.resetCachesForTests() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('AgentHookServer listener replay', () => { + it('preserves Codex sibling and lead state across relay listener restarts', () => { + const server = new AgentHookServer() + const send = (state: HookListenerState, payload: Record): void => { + const event = normalizeHookPayload(state, 'codex', buildBody(payload), 'production') + if (!event) { + throw new Error('normalizeHookPayload rejected a known-good Codex fixture') + } + server.ingestRemote(event, 'conn-1') + } + + const initialRelay = createHookListenerState() + send(initialRelay, { + hook_event_name: 'UserPromptSubmit', + session_id: 'root-session', + prompt: 'coordinate reviewers', + model: 'gpt-5.4' + }) + for (const id of ['child-a', 'child-b']) { + send(initialRelay, { + hook_event_name: 'SubagentStart', + agent_id: id, + agent_type: 'reviewer', + model: 'gpt-5.4-mini' + }) + } + + const restartedRelay = createHookListenerState() + send(restartedRelay, { hook_event_name: 'SubagentStop', agent_id: 'child-a' }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + model: 'gpt-5.4', + providerSession: { key: 'session_id', id: 'root-session' }, + subagents: [expect.objectContaining({ id: 'child-b' })] + }) + + send(restartedRelay, { + hook_event_name: 'Stop', + session_id: 'root-session', + model: 'gpt-5.4' + }) + const restartedAgain = createHookListenerState() + send(restartedAgain, { hook_event_name: 'SubagentStop', agent_id: 'child-b' }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + model: 'gpt-5.4', + providerSession: { key: 'session_id', id: 'root-session' }, + subagents: undefined + }) + }) + + it('does not carry a remote Codex roster across connection cleanup', () => { + const server = new AgentHookServer() + const child = (id: string) => ({ + id, + state: 'working' as const, + startedAt: 1 + }) + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'SubagentStart', + toolAgentId: 'old-child', + payload: { + state: 'working', + prompt: '', + agentType: 'codex', + subagents: [child('old-child')] + } + }, + 'conn-1' + ) + + server.clearStatusEntriesForConnection('conn-1') + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'SubagentStart', + toolAgentId: 'new-child', + payload: { + state: 'working', + prompt: '', + agentType: 'codex', + subagents: [child('new-child')] + } + }, + 'conn-2' + ) + + expect(server.getStatusSnapshot()[0]?.subagents).toEqual([child('new-child')]) + }) + + it('restores a subagent roster the relay shed to fit the frame', () => { + const server = new AgentHookServer() + const roster = [ + { id: 'reviewer-1', agentType: 'reviewer', state: 'working' as const, startedAt: 1 } + ] + server.ingestRemote( + { + paneKey: PANE, + payload: { state: 'working', prompt: 'review', agentType: 'claude', subagents: roster } + }, + 'conn-1' + ) + // The relay dropped the roster to fit an oversized frame and said so on the wire. + server.ingestRemote( + { + paneKey: PANE, + shedFields: ['lastAssistantMessage', createShedSubagentsField(roster)], + payload: { state: 'done', prompt: 'review', agentType: 'claude' } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', subagents: roster }) + }) + + it('lets an unmarked absent roster clear, so a finished team still retires', () => { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + payload: { + state: 'working', + prompt: 'review', + agentType: 'claude', + subagents: [{ id: 'reviewer-1', agentType: 'reviewer', state: 'working', startedAt: 1 }] + } + }, + 'conn-1' + ) + server.ingestRemote( + { paneKey: PANE, payload: { state: 'done', prompt: 'review', agentType: 'claude' } }, + 'conn-1' + ) + expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() + }) + + it('does not carry Claude background work across connection cleanup', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + claudeRunningNonAgentTask: true, + payload: { state: 'working', prompt: 'old host', agentType: 'claude' } + }, + 'conn-1' + ) + + server.clearStatusEntriesForConnection('conn-1') + server.ingestRemote( + { + paneKey: PANE, + payload: { state: 'working', prompt: 'new host', agentType: 'claude' } + }, + 'conn-2' + ) + const baseline = server.getStatusSnapshot()[0] + vi.setSystemTime(1_500) + + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'new host', + baselineAgentType: 'claude', + intent: 'plain-escape' + }) + ).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('retains root Codex identity when relay child events omit it', () => { + const server = new AgentHookServer() + const providerSession = { key: 'session_id' as const, id: 'root-session' } + + server.ingestRemote( + { + paneKey: PANE, + providerSession, + payload: { + state: 'working', + prompt: 'coordinate reviewers', + agentType: 'codex', + model: 'gpt-5.4' + } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + toolAgentId: 'child-session', + payload: { state: 'waiting', prompt: 'coordinate reviewers', agentType: 'codex' } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + model: 'gpt-5.4', + providerSession + }) + + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'SubagentStop', + toolAgentId: 'child-session', + payload: { state: 'done', prompt: 'coordinate reviewers', agentType: 'codex' } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + model: 'gpt-5.4', + providerSession + }) + }) + + it('keeps Codex lead state terminal after an inferred interrupt', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, + hookEventName: 'UserPromptSubmit', + payload: { + state: 'working', + prompt: 'long task', + agentType: 'codex', + model: 'gpt-5.6-sol' + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'long task', + baselineAgentType: 'codex', + intent: 'plain-escape' + }) + + expect(applied).toBe(true) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'done', + prompt: 'long task', + agentType: 'codex', + providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, + interrupted: true, + receivedAt: 1_500, + stateStartedAt: 1_500 + }) + ]) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + paneKey: PANE, + providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, + payload: expect.objectContaining({ state: 'done', interrupted: true }) + }) + ) + + vi.setSystemTime(17_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'SubagentStop', + toolAgentId: 'delayed-child', + payload: { state: 'done', prompt: 'long task', agentType: 'codex' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + model: 'gpt-5.6-sol', + prompt: 'long task' + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not infer an interrupt while a subagent child is still working', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'review loop', + agentType: 'claude', + // Why: a working pane can be child-driven while the lead is idle; Ctrl+C doesn't stop children, so no terminal-done may be inferred here. + subagents: [{ id: 'a1', state: 'working', startedAt: 900 }] + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'review loop', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + + expect(applied).toBe(false) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + } finally { + vi.useRealTimers() + } + }) + + it('does not infer an interrupt while Claude reports a background shell', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + claudeRunningNonAgentTask: true, + payload: { state: 'working', prompt: 'run in background', agentType: 'claude' } + }, + 'conn-1' + ) + let baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'run in background', + baselineAgentType: 'claude', + intent: 'plain-escape' + }) + ).toBe(false) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + + vi.setSystemTime(2_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + claudeRunningNonAgentTask: false, + payload: { state: 'working', prompt: 'run in background', agentType: 'claude' } + }, + 'conn-1' + ) + baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(2_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'run in background', + baselineAgentType: 'claude', + intent: 'plain-escape' + }) + ).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('blocks local HTTP interrupt inference for provider-owned work without exposing transport metadata', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await expect( + postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'run in background' }) + ).resolves.toMatchObject({ status: 204 }) + await expect( + postHook({ hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }) + ).resolves.toMatchObject({ status: 204 }) + const baseline = server.getStatusSnapshot()[0] + + expect(baseline).not.toHaveProperty('claudeRunningNonAgentTask') + expect(baseline).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'run in background', + baselineAgentType: 'claude', + intent: 'plain-escape' + }) + ).toBe(false) + + await expect( + postHook({ + hook_event_name: 'Stop', + background_tasks: [], + session_crons: [{ id: 'cron-1' }] + }) + ).resolves.toMatchObject({ status: 204 }) + const cronBaseline = server.getStatusSnapshot()[0] + expect(server._getStateForTests().claudeActiveSessionCronPaneKeys.has(PANE)).toBe(true) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: cronBaseline.receivedAt, + baselineStateStartedAt: cronBaseline.stateStartedAt, + baselinePrompt: 'run in background', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(false) + } finally { + server.stop() + } + }) + + it('uses replayed Claude background metadata only before a live observation', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + isReplay: true, + claudeRunningNonAgentTask: true, + payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'stale replay', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(false) + + vi.setSystemTime(1_500) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + claudeRunningNonAgentTask: false, + payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' } + }, + 'conn-1' + ) + vi.setSystemTime(2_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + isReplay: true, + claudeRunningNonAgentTask: true, + payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' } + }, + 'conn-1' + ) + const liveBaseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(2_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: liveBaseline.receivedAt, + baselineStateStartedAt: liveBaseline.stateStartedAt, + baselinePrompt: 'stale replay', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('does not apply Claude background metadata from a rejected remote status', () => { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'PermissionRequest', + claudeRunningNonAgentTask: true, + payload: { + state: 'waiting', + prompt: 'approve shell', + agentType: 'claude', + toolName: 'Bash' + } + }, + 'conn-1' + ) + const waiting = server.getStatusSnapshot()[0] + + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'PreToolUse', + claudeRunningNonAgentTask: false, + payload: { + state: 'working', + prompt: 'approve shell', + agentType: 'claude', + toolName: 'OtherTool' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()[0]).toEqual(waiting) + expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(true) + }) + + it('carries idle subagent rows through an inferred interrupt', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'wrap up', + agentType: 'claude', + subagents: [{ id: 'a1', state: 'idle', startedAt: 900, agentType: 'probe1' }] + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'wrap up', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + + expect(applied).toBe(true) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + interrupted: true, + subagents: [expect.objectContaining({ id: 'a1', state: 'idle' })] + }) + } finally { + vi.useRealTimers() + } + }) + + it('preserves an inferred interrupted row when OpenCode immediately reports SessionIdle', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType: 'opencode' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'long task', + baselineAgentType: 'opencode', + intent: 'plain-escape', + inputCount: 2 + }) + ).toBe(true) + + vi.setSystemTime(1_501) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'done', prompt: 'long task', agentType: 'opencode' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'done', + prompt: 'long task', + agentType: 'opencode', + interrupted: true, + receivedAt: 1_500, + stateStartedAt: 1_500 + }) + ]) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + paneKey: PANE, + payload: expect.objectContaining({ state: 'done', interrupted: true }) + }) + ) + } finally { + vi.useRealTimers() + } + }) + + it('rejects inferred interrupts when a same-millisecond prompt update changed the row', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'first task', agentType: 'codex' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'second task', agentType: 'codex' } + }, + 'conn-1' + ) + + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'first task', + baselineAgentType: 'codex', + intent: 'plain-escape' + }) + + expect(applied).toBe(false) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'second task', + agentType: 'codex' + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it.each(['opencode', 'copilot'] as const)( + 'rejects single plain Escape inference for %s', + (agentType) => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'long task', + baselineAgentType: agentType, + intent: 'plain-escape' + }) + + expect(applied).toBe(false) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'long task', + agentType + }) + ]) + } finally { + vi.useRealTimers() + } + } + ) + + it.each(['opencode', 'copilot'] as const)( + 'accepts double plain Escape inference for %s', + (agentType) => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'long task', + baselineAgentType: agentType, + intent: 'plain-escape', + inputCount: 2 + }) + + expect(applied).toBe(true) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + prompt: 'long task', + agentType, + interrupted: true + }) + ]) + } finally { + vi.useRealTimers() + } + } + ) + + it('rejects Ctrl+C inference for Droid', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType: 'droid' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'long task', + baselineAgentType: 'droid', + intent: 'ctrl-c' + }) + + expect(applied).toBe(false) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'long task', + agentType: 'droid' + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('does not let late same-turn working hooks resurrect an inferred interrupt', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType: 'pi' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'long task', + baselineAgentType: 'pi', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(6_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'long task', + agentType: 'pi', + toolName: 'bash', + toolInput: '/bin/sleep 90' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + prompt: 'long task', + agentType: 'pi', + interrupted: true, + receivedAt: 1_500, + stateStartedAt: 1_500 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('does not let late Claude tool hooks with explicit prompt resurrect an inferred interrupt', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + hookEventName: 'UserPromptSubmit', + payload: { + state: 'working', + prompt: 'Do I have gpu acceleration on on my terminal?', + agentType: 'claude' + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'Do I have gpu acceleration on on my terminal?', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(2_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + hookEventName: 'PostToolUse', + payload: { + state: 'working', + prompt: 'Do I have gpu acceleration on on my terminal?', + agentType: 'claude', + toolName: 'Read', + toolInput: 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + prompt: 'Do I have gpu acceleration on on my terminal?', + agentType: 'claude', + interrupted: true, + receivedAt: 1_500, + stateStartedAt: 1_500 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('allows a new prompt after an inferred interrupt', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'first task', agentType: 'pi' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'first task', + baselineAgentType: 'pi', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(2_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'second task', agentType: 'pi' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'second task', + agentType: 'pi', + receivedAt: 2_000, + stateStartedAt: 2_000 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('allows a Claude follow-up prompt after an inferred interrupt to keep working', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + hookEventName: 'UserPromptSubmit', + payload: { state: 'working', prompt: 'first Claude turn', agentType: 'claude' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'first Claude turn', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(2_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + hookEventName: 'UserPromptSubmit', + payload: { state: 'working', prompt: 'second queued Claude turn', agentType: 'claude' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'second queued Claude turn', + agentType: 'claude', + interrupted: undefined, + receivedAt: 2_000, + stateStartedAt: 2_000 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('allows an immediate same-prompt retry after an inferred interrupt', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'retryable task', agentType: 'pi' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'retryable task', + baselineAgentType: 'pi', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(2_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'retryable task', agentType: 'pi' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'retryable task', + agentType: 'pi', + receivedAt: 2_000, + stateStartedAt: 2_000 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('suppresses same-turn Claude tool progress after the stale suppression window', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + hookEventName: 'UserPromptSubmit', + payload: { state: 'working', prompt: 'repeat task', agentType: 'claude' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'repeat task', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(16_501) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + hookEventName: 'PostToolUse', + payload: { + state: 'working', + prompt: 'repeat task', + agentType: 'claude', + toolName: 'bash', + toolInput: '/bin/sleep 90' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + prompt: 'repeat task', + agentType: 'claude', + interrupted: true, + receivedAt: 1_500, + stateStartedAt: 1_500 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('allows generic non-explicit same-prompt working after the stale suppression window', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'repeat task', agentType: 'pi' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'repeat task', + baselineAgentType: 'pi', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(16_501) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'repeat task', agentType: 'pi' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'repeat task', + agentType: 'pi', + interrupted: undefined, + receivedAt: 16_501, + stateStartedAt: 16_501 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('allows non-Claude tool-context working after the stale suppression window', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'repeat task', agentType: 'pi' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'repeat task', + baselineAgentType: 'pi', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(16_501) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'repeat task', + agentType: 'pi', + toolName: 'bash', + toolInput: '/bin/sleep 90' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'repeat task', + agentType: 'pi', + interrupted: undefined, + toolName: 'bash', + toolInput: '/bin/sleep 90', + receivedAt: 16_501, + stateStartedAt: 16_501 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('rejects malformed inferred interrupt requests without throwing', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType: 'codex' } + }, + 'conn-1' + ) + const malformed: unknown[] = [ + { + paneKey: 'tab-1:0', + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 1_000, + baselinePrompt: 'long task', + baselineAgentType: 'codex', + intent: 'ctrl-c' + }, + { + paneKey: PANE, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 1_000, + baselinePrompt: 'long task', + baselineAgentType: 'codex', + intent: 'sigint' + }, + { + paneKey: PANE, + baselineUpdatedAt: '1_000', + baselineStateStartedAt: 1_000, + baselinePrompt: 'long task', + baselineAgentType: 'codex', + intent: 'ctrl-c' + }, + { + paneKey: PANE, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 1_000, + baselinePrompt: 123, + baselineAgentType: 'codex', + intent: 'ctrl-c' + } + ] + + for (const request of malformed) { + expect(() => + server.inferInterrupt(request as Parameters[0]) + ).not.toThrow() + expect( + server.inferInterrupt(request as Parameters[0]) + ).toBe(false) + } + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'long task', + agentType: 'codex' + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('allows an immediate same-prompt retry that carries cached turn detail', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'retryable task', + agentType: 'opencode', + lastAssistantMessage: 'partial answer' + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'retryable task', + baselineAgentType: 'opencode', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(2_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { + state: 'working', + prompt: 'retryable task', + agentType: 'opencode', + lastAssistantMessage: 'partial answer' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'retryable task', + agentType: 'opencode', + lastAssistantMessage: 'partial answer', + receivedAt: 2_000, + stateStartedAt: 2_000 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('suppresses replayed same-prompt working events after an inferred interrupt', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { + state: 'working', + prompt: 'retryable task', + agentType: 'opencode', + lastAssistantMessage: 'partial answer' + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'retryable task', + baselineAgentType: 'opencode', + intent: 'ctrl-c' + }) + ).toBe(true) + + vi.setSystemTime(20_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + isReplay: true, + payload: { + state: 'working', + prompt: 'retryable task', + agentType: 'opencode', + lastAssistantMessage: 'partial answer' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + prompt: 'retryable task', + agentType: 'opencode', + interrupted: true, + receivedAt: 1_500, + stateStartedAt: 1_500 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('matches renderer unknown sentinel to an omitted hook agent type', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'custom hook' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'custom hook', + baselineAgentType: 'unknown', + intent: 'ctrl-c' + }) + ).toBe(true) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + prompt: 'custom hook', + interrupted: true + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('rejects inferred interrupts for stale and non-working rows', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'waiting', prompt: 'permission', agentType: 'codex' } + }, + 'conn-1' + ) + const waiting = server.getStatusSnapshot()[0] + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: waiting.receivedAt, + baselineStateStartedAt: waiting.stateStartedAt, + baselinePrompt: 'permission', + baselineAgentType: 'codex', + intent: 'plain-escape' + }) + ).toBe(false) + + server.ingestRemote( + { + paneKey: FRESH_PANE, + tabId: 'tab-fresh', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'old task', agentType: 'codex' } + }, + 'conn-1' + ) + const stale = server.getStatusSnapshot().find((entry) => entry.paneKey === FRESH_PANE)! + vi.setSystemTime(stale.receivedAt + 30 * 60 * 1000 + 1) + expect( + server.inferInterrupt({ + paneKey: FRESH_PANE, + baselineUpdatedAt: stale.receivedAt, + baselineStateStartedAt: stale.stateStartedAt, + baselinePrompt: 'old task', + baselineAgentType: 'codex', + intent: 'plain-escape' + }) + ).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('applies inferred interrupts for arbitrary agent types and Ctrl+C intent', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: GOOD_PANE, + tabId: 'tab-good', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'custom task', agentType: 'custom-agent' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot().find((entry) => entry.paneKey === GOOD_PANE)! + + vi.setSystemTime(1_250) + expect( + server.inferInterrupt({ + paneKey: GOOD_PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'custom task', + baselineAgentType: 'custom-agent', + intent: 'ctrl-c' + }) + ).toBe(true) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: GOOD_PANE, + state: 'done', + prompt: 'custom task', + agentType: 'custom-agent', + interrupted: true + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('allows multiple status-change subscribers to observe the same update', () => { + const server = new AgentHookServer() + const first = vi.fn() + const second = vi.fn() + server.subscribeStatusChanges(first) + server.subscribeStatusChanges(second) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', agentType: 'claude' } + }, + 'conn-1' + ) + + expect(first).toHaveBeenCalledWith([ + expect.objectContaining({ + state: 'working', + receivedAt: expect.any(Number), + observedInCurrentRuntime: true + }) + ]) + expect(second).toHaveBeenCalledWith([ + expect.objectContaining({ + state: 'working', + receivedAt: expect.any(Number), + observedInCurrentRuntime: true + }) + ]) + }) + + it('notifies provider-session subscribers without changing status listener arguments', () => { + const server = new AgentHookServer() + const statuses = vi.fn() + const sessions = vi.fn() + server.subscribeStatusChanges(statuses) + server.subscribeProviderSessionChanges(sessions) + + server.ingestRemote( + { + paneKey: PANE, + worktreeId: 'wt-1', + providerSession: { + key: 'session_id', + id: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.jsonl' + }, + providerSessionOnly: true, + payload: { state: 'done', agentType: 'pi' } + }, + 'conn-1' + ) + + expect(statuses).toHaveBeenCalledWith([]) + expect(sessions).toHaveBeenCalledWith([ + { + paneKey: PANE, + sessionId: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.jsonl', + worktreeId: 'wt-1' + } + ]) + }) + + it('keeps status-change subscribers when renderer fanout listener is cleared', () => { + const server = new AgentHookServer() + const statusChangeListener = vi.fn() + const rendererListener = vi.fn() + server.subscribeStatusChanges(statusChangeListener) + server.setListener(rendererListener) + server.setListener(null) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', agentType: 'claude' } + }, + 'conn-1' + ) + + expect(statusChangeListener).toHaveBeenCalledTimes(1) + expect(rendererListener).not.toHaveBeenCalled() + }) + + it('marks listener replay callbacks as replayed', () => { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'cached task', agentType: 'codex' } + }, + 'conn-1' + ) + + const listener = vi.fn() + server.setListener(listener) + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + isReplay: true, + payload: expect.objectContaining({ state: 'working', prompt: 'cached task' }) + }) + ) + }) + + it('unsubscribes status-change listeners without removing the remaining listeners', () => { + const server = new AgentHookServer() + const removed = vi.fn() + const remaining = vi.fn() + const unsubscribe = server.subscribeStatusChanges(removed) + server.subscribeStatusChanges(remaining) + + unsubscribe() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', agentType: 'claude' } + }, + 'conn-1' + ) + + expect(removed).not.toHaveBeenCalled() + expect(remaining).toHaveBeenCalledWith([ + expect.objectContaining({ + state: 'working', + observedInCurrentRuntime: true + }) + ]) + }) + + it('notifies status-change subscribers when a working status is dropped or cleared', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.subscribeStatusChanges(listener) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', agentType: 'claude' } + }, + 'conn-1' + ) + server.dropStatusEntry(PANE) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', agentType: 'claude' } + }, + 'conn-1' + ) + server.clearPaneState(PANE) + + expect(listener).toHaveBeenNthCalledWith(2, []) + expect(listener).toHaveBeenNthCalledWith(4, []) + }) + + it('notifies pane-status-clear listener when pane teardown evicts a cached status', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setPaneStatusClearListener(listener) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', agentType: 'claude' } + }, + 'conn-1' + ) + server.clearPaneState(PANE) + server.clearPaneState(PANE) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith({ paneKey: PANE }) + }) + + it('batches connection cleanup and retains sibling and local statuses', () => { + const server = new AgentHookServer() + const paneKeyAt = (prefix: string, index: number): string => + makePaneKey( + `${prefix}-tab-${index}`, + `00000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, '0')}` + ) + const targetPaneKeys = Array.from({ length: 100 }, (_, index) => paneKeyAt('target', index)) + const siblingPaneKeys = Array.from({ length: 100 }, (_, index) => + paneKeyAt('sibling', index + 100) + ) + const unstampedPaneKey = paneKeyAt('legacy', 250) + const statusListener = vi.fn() + const clearListener = vi.fn() + const internals = server as unknown as AgentHookServerCacheInternals + const persistSpy = vi.spyOn(internals, 'scheduleStatusPersist') + server.subscribeStatusChanges(statusListener) + server.setPaneStatusClearListener(clearListener) + for (const paneKey of targetPaneKeys) { + server.ingestRemote({ paneKey, payload: { state: 'working', agentType: 'claude' } }, 'ssh-a') + } + for (const paneKey of siblingPaneKeys) { + server.ingestRemote({ paneKey, payload: { state: 'working', agentType: 'claude' } }, 'ssh-b') + } + server.ingestTerminalStatus({ + paneKey: unstampedPaneKey, + payload: { state: 'working', prompt: '', agentType: 'codex' } + }) + statusListener.mockClear() + persistSpy.mockClear() + + server.clearStatusEntriesForConnection('ssh-a') + + expect(statusListener).toHaveBeenCalledOnce() + expect(persistSpy).toHaveBeenCalledOnce() + expect(clearListener).toHaveBeenCalledOnce() + expect(clearListener).toHaveBeenCalledWith({ + transient: true, + connectionId: 'ssh-a', + clearedAt: expect.any(Number) + }) + expect(server.getStatusSnapshot().map((entry) => entry.paneKey)).toEqual([ + ...siblingPaneKeys, + unstampedPaneKey + ]) + }) + + it('emits a connection cutoff after a pane-key collision and orders replay after it', () => { + vi.useFakeTimers() + vi.setSystemTime(1_700_000_000_000) + const server = new AgentHookServer() + const clearListener = vi.fn() + server.setPaneStatusClearListener(clearListener) + server.ingestRemote( + { paneKey: PANE, payload: { state: 'working', agentType: 'claude' } }, + 'ssh-a' + ) + server.ingestRemote( + { paneKey: PANE, payload: { state: 'working', agentType: 'codex' } }, + 'ssh-b' + ) + + server.clearStatusEntriesForConnection('ssh-a') + const clear = clearListener.mock.calls[0]?.[0] as { + transient: true + connectionId: string + clearedAt: number + } + server.ingestRemote( + { paneKey: GOOD_PANE, payload: { state: 'working', agentType: 'claude' }, isReplay: true }, + 'ssh-a' + ) + + expect(clear).toMatchObject({ transient: true, connectionId: 'ssh-a' }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, connectionId: 'ssh-b' }), + expect.objectContaining({ + paneKey: GOOD_PANE, + connectionId: 'ssh-a', + receivedAt: clear.clearedAt + 1 + }) + ]) + vi.useRealTimers() + }) + + it('drops cached statuses and pane-scoped listener caches under one tab prefix', () => { + vi.useFakeTimers() + try { + const server = new AgentHookServer() + const internals = server as unknown as AgentHookServerCacheInternals + const sameTabPane = makePaneKey('tab-1', LEAF_2) + const siblingPrefixPane = makePaneKey('tab-10', LEAF_3) + const statusListener = vi.fn() + const aliasPersist = vi.fn() + const sameTabRetry = vi.fn() + const siblingRetry = vi.fn() + server.subscribeStatusChanges(statusListener) + server.setPaneKeyAliasPersistenceListener(aliasPersist) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'first', agentType: 'claude' } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: sameTabPane, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'done', prompt: 'second', agentType: 'codex' } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: siblingPrefixPane, + tabId: 'tab-10', + worktreeId: 'wt-2', + payload: { state: 'working', prompt: 'sibling', agentType: 'claude' } + }, + 'conn-1' + ) + server.registerPaneKeyAlias('tab-1:0', sameTabPane, 'pty-1') + const state = server._getStateForTests() + state.lastPromptByPaneKey.set(PANE, 'cached prompt') + state.lastToolByPaneKey.set(`${sameTabPane}\0tool`, {} as never) + state.antigravityCompletedTranscriptByPaneKey.set(`${sameTabPane}\0done`, 'cached') + state.ampCompletedCacheKeys.add(`${sameTabPane}\0amp`) + state.lastPromptByPaneKey.set(siblingPrefixPane, 'sibling prompt') + internals.assistantMessageRetryTimers.set(PANE, setTimeout(sameTabRetry, 1_000)) + internals.assistantMessageRetryTimers.set(siblingPrefixPane, setTimeout(siblingRetry, 1_000)) + internals.promptSentDedupeByPaneKey.set(PANE, { promptHash: 'same-tab' }) + internals.promptSentDedupeByPaneKey.set(siblingPrefixPane, { promptHash: 'sibling' }) + const scheduleStatusPersist = vi.spyOn(internals, 'scheduleStatusPersist') + statusListener.mockClear() + aliasPersist.mockClear() + scheduleStatusPersist.mockClear() + + server.dropStatusEntriesByTabPrefix('tab-1') + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: siblingPrefixPane, prompt: 'sibling' }) + ]) + expect(state.lastPromptByPaneKey.has(PANE)).toBe(false) + expect(state.lastToolByPaneKey.has(`${sameTabPane}\0tool`)).toBe(false) + expect(state.antigravityCompletedTranscriptByPaneKey.has(`${sameTabPane}\0done`)).toBe(false) + expect(state.ampCompletedCacheKeys.has(`${sameTabPane}\0amp`)).toBe(false) + expect(state.lastPromptByPaneKey.get(siblingPrefixPane)).toBe('sibling prompt') + expect(internals.assistantMessageRetryTimers.has(PANE)).toBe(false) + expect(internals.assistantMessageRetryTimers.has(siblingPrefixPane)).toBe(true) + expect(internals.promptSentDedupeByPaneKey.has(PANE)).toBe(false) + expect(internals.promptSentDedupeByPaneKey.get(siblingPrefixPane)).toEqual({ + promptHash: 'sibling' + }) + expect(internals.runtimeObservedStatusPaneKeys.has(PANE)).toBe(false) + expect(internals.runtimeObservedStatusPaneKeys.has(sameTabPane)).toBe(false) + expect(internals.runtimeObservedStatusPaneKeys.has(siblingPrefixPane)).toBe(true) + expect(statusListener).toHaveBeenCalledTimes(1) + expect(statusListener).toHaveBeenCalledWith([ + expect.objectContaining({ state: 'working', observedInCurrentRuntime: true }) + ]) + expect(aliasPersist).toHaveBeenCalledTimes(1) + expect(aliasPersist).toHaveBeenCalledWith([]) + expect(scheduleStatusPersist).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(1_000) + expect(sameTabRetry).not.toHaveBeenCalled() + expect(siblingRetry).toHaveBeenCalledTimes(1) + } finally { + vi.clearAllTimers() + vi.useRealTimers() + } + }) + + it('suppresses late writes for a closed tab for the rest of the server session', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'before close', agentType: 'codex' } + }, + 'conn-1' + ) + + server.dropStatusEntriesByTabPrefix('tab-1') + listener.mockClear() + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'done', prompt: 'late remote', agentType: 'codex' } + }, + 'conn-1' + ) + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'done', prompt: 'late terminal', agentType: 'codex' } + }) + + vi.setSystemTime(16_001) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'future reuse', agentType: 'codex' } + }, + 'conn-1' + ) + + expect(listener).not.toHaveBeenCalled() + expect(server.getStatusSnapshot()).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + + it('accepts statuses for unrelated tabs while another tab is recently closed', () => { + const server = new AgentHookServer() + server.dropStatusEntriesByTabPrefix('tab-1') + server.ingestRemote( + { + paneKey: GOOD_PANE, + tabId: 'tab-good', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'unrelated', agentType: 'claude' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: GOOD_PANE, state: 'working', prompt: 'unrelated' }) + ]) + }) + + it('suppresses local HTTP hook writes for a recently closed tab', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postHook = (prompt: string): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody({ hook_event_name: 'UserPromptSubmit', prompt })) + }) + + await expect(postHook('before close')).resolves.toMatchObject({ status: 204 }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, prompt: 'before close' }) + ]) + + server.dropStatusEntriesByTabPrefix('tab-1') + await expect(postHook('late local')).resolves.toMatchObject({ status: 204 }) + + expect(server.getStatusSnapshot()).toEqual([]) + } finally { + server.stop() + } + }) + + it('accepts a new local prompt after launch authority retires in a reusable pane', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload, { launchToken: 'retired-launch-token' })) + }) + + await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'first launch' }) + server.retirePaneAuthority(PANE) + await postHook({ hook_event_name: 'Stop', last_assistant_message: 'late prior hook' }) + expect(server.getStatusSnapshot()).toEqual([]) + + await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'manual restart' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'working', prompt: 'manual restart' }) + ]) + expect( + server.attestCompatibilityAuthority({ + paneKey: PANE, + launchTokenHash: createHash('sha256').update('retired-launch-token').digest('hex'), + connectionId: null, + terminalProvenance: 'current_runtime' + }) + ).toBeNull() + } finally { + server.stop() + } + }) + + it('accepts a resumed-session SessionStart after launch authority retires in a reusable pane', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload, { launchToken: 'retired-launch-token' })) + }) + + await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'first launch' }) + server.retirePaneAuthority(PANE) + + // Why: `claude --resume` in the reused shell pane emits only SessionStart while idle; + // without the un-retire the resumed session stays rowless until a prompt (STA-3386). + await postHook({ hook_event_name: 'SessionStart', source: 'resume' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'done', sessionBoundary: true }) + ]) + } finally { + server.stop() + } + }) + + it('accepts a live remote prompt but not replay after launch authority retires', () => { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'UserPromptSubmit', + launchToken: 'retired-remote-launch-token', + payload: { state: 'working', prompt: 'first launch', agentType: 'claude' } + }, + 'conn-1' + ) + server.retirePaneAuthority(PANE) + + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'UserPromptSubmit', + launchToken: 'retired-remote-launch-token', + isReplay: true, + payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()).toEqual([]) + + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'UserPromptSubmit', + launchToken: 'retired-remote-launch-token', + payload: { state: 'working', prompt: 'remote manual restart', agentType: 'claude' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + prompt: 'remote manual restart', + connectionId: 'conn-1' + }) + ]) + expect( + server.attestCompatibilityAuthority({ + paneKey: PANE, + launchTokenHash: createHash('sha256').update('retired-remote-launch-token').digest('hex'), + connectionId: 'conn-1', + terminalProvenance: 'current_runtime' + }) + ).toBeNull() + }) + + it('hydrates cached statuses as not observed in the current runtime', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-agent-hooks-')) + const firstServer = new AgentHookServer() + const secondServer = new AgentHookServer() + try { + await firstServer.start({ env: 'production', userDataPath: dir }) + firstServer.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', agentType: 'claude' } + }, + 'conn-1' + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + await secondServer.start({ env: 'production', userDataPath: dir }) + + expect(secondServer.getStatusChangeSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + observedInCurrentRuntime: false + }) + ]) + } finally { + firstServer.stop() + secondServer.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('replays the latest retained pane status when a listener attaches after windowless events', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + expect(env.ORCA_AGENT_HOOK_PORT).toBeTruthy() + expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy() + + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody({ + hook_event_name: 'UserPromptSubmit', + prompt: 'replay me' + }) + ) + }) + expect(response.status).toBe(204) + + const listener = vi.fn() + server.setListener(listener) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + receivedAt: expect.any(Number), + stateStartedAt: expect.any(Number), + payload: expect.objectContaining({ + state: 'working', + prompt: 'replay me', + agentType: 'claude' + }) + }) + ) + } finally { + server.stop() + } + }) + + it('keeps Claude permission visible when another subagent reports tool activity', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await expect( + postClaudeHook({ + hook_event_name: 'PermissionRequest', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' } + }) + ).resolves.toMatchObject({ status: 204 }) + await expect( + postClaudeHook({ + hook_event_name: 'PreToolUse', + tool_name: 'Read', + tool_input: { file_path: '/tmp/other-subagent.txt' } + }) + ).resolves.toMatchObject({ status: 204 }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'waiting', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'rm -rf /tmp/orca-subagent-repro' + }) + ]) + } finally { + server.stop() + } + }) + + it('keeps Claude permission visible when matching tool activity has no execution id', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' } + }) + await postClaudeHook({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' } + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'waiting', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'rm -rf /tmp/orca-subagent-repro' + }) + ]) + } finally { + server.stop() + } + }) + + it('keeps Claude permission visible when approved tool execution has no identity', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' } + }) + await postClaudeHook({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' }, + tool_use_id: 'toolu-approved-1' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'waiting', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'rm -rf /tmp/orca-subagent-repro' + }) + ]) + } finally { + server.stop() + } + }) + + it('lets Claude permission clear when approved PostToolUse matches the preceding tool use id', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' }, + tool_use_id: 'toolu-approved-by-claude' + }) + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' } + }) + await postClaudeHook({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' }, + tool_use_id: 'toolu-approved-by-claude' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'rm -rf /tmp/orca-2824-permission-target' + }) + ]) + } finally { + server.stop() + } + }) + + it('lets Claude permission clear by tool use id when tool input is not previewable', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PreToolUse', + tool_name: 'BespokeTool', + tool_input: { opaque: 'request-a' }, + tool_use_id: 'toolu-approved-opaque' + }) + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + tool_name: 'BespokeTool', + tool_input: { opaque: 'request-a' } + }) + await postClaudeHook({ + hook_event_name: 'PostToolUse', + tool_name: 'BespokeTool', + tool_input: { opaque: 'request-a' }, + tool_use_id: 'toolu-approved-opaque' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + agentType: 'claude', + toolName: 'BespokeTool' + }) + ]) + } finally { + server.stop() + } + }) + + it('keeps Claude permission visible for unpreviewable tool input with another tool use id', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PreToolUse', + tool_name: 'BespokeTool', + tool_input: { opaque: 'request-a' }, + tool_use_id: 'toolu-permission-owner-opaque' + }) + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + tool_name: 'BespokeTool', + tool_input: { opaque: 'request-a' } + }) + await postClaudeHook({ + hook_event_name: 'PostToolUse', + tool_name: 'BespokeTool', + tool_input: { opaque: 'request-b' }, + tool_use_id: 'toolu-other-opaque' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'waiting', + agentType: 'claude', + toolName: 'BespokeTool' + }) + ]) + } finally { + server.stop() + } + }) + + it('keeps Claude permission visible when another tool use completes after permission', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' }, + tool_use_id: 'toolu-permission-owner' + }) + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' } + }) + await postClaudeHook({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' }, + tool_use_id: 'toolu-other-subagent' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'waiting', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + }) + ]) + } finally { + server.stop() + } + }) + + it('keeps Claude permission visible when an explicit agent type reports another tool use id', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PreToolUse', + agent_type: 'main', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' }, + tool_use_id: 'toolu-permission-owner-type' + }) + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + agent_type: 'main', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' } + }) + await postClaudeHook({ + hook_event_name: 'PostToolUse', + agent_type: 'main', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' }, + tool_use_id: 'toolu-other-type' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'waiting', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + }) + ]) + } finally { + server.stop() + } + }) + + it('lets Claude permission clear when same explicit agent type starts the approved tool', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + agent_type: 'main', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' } + }) + await postClaudeHook({ + hook_event_name: 'PreToolUse', + agent_type: 'main', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' }, + tool_use_id: 'toolu-approved-1' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'rm -rf /tmp/orca-subagent-repro' + }) + ]) + } finally { + server.stop() + } + }) + + it('lets Claude subagent permission clear when the same agent starts the approved tool', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + agent_id: 'agent-subagent-a', + agent_type: 'Review', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' } + }) + await postClaudeHook({ + hook_event_name: 'PreToolUse', + agent_id: 'agent-subagent-a', + agent_type: 'Review', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' }, + tool_use_id: 'toolu-approved-subagent' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + }) + ]) + } finally { + server.stop() + } + }) + + it('lets same Claude subagent clear an unknown approved tool without an input preview', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + agent_id: 'agent-custom-tool', + agent_type: 'Review', + tool_name: 'BespokeTool', + tool_input: { request_id: 'pending-1' } + }) + await postClaudeHook({ + hook_event_name: 'PreToolUse', + agent_id: 'agent-custom-tool', + agent_type: 'Review', + tool_name: 'BespokeTool', + tool_input: { request_id: 'pending-1' }, + tool_use_id: 'toolu-custom-approved' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + agentType: 'claude', + toolName: 'BespokeTool', + toolInput: undefined + }) + ]) + } finally { + server.stop() + } + }) + + it('keeps Claude permission visible when another same-type subagent reports the same tool execution', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + agent_id: 'agent-subagent-a', + agent_type: 'Review', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' } + }) + await postClaudeHook({ + hook_event_name: 'PreToolUse', + agent_id: 'agent-subagent-b', + agent_type: 'Review', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' }, + tool_use_id: 'toolu-other-subagent' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'waiting', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + }) + ]) + } finally { + server.stop() + } + }) + + it('keeps Claude permission visible when unknown tool previews collide', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + tool_name: 'BespokeTool', + tool_input: { request_id: 'pending-1' } + }) + await postClaudeHook({ + hook_event_name: 'PreToolUse', + tool_name: 'BespokeTool', + tool_input: { request_id: 'other-subagent' } + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'waiting', + agentType: 'claude', + toolName: 'BespokeTool', + toolInput: undefined + }) + ]) + } finally { + server.stop() + } + }) + + it('lets Claude permission clear when a new explicit prompt starts', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /tmp/orca-subagent-repro' } + }) + await postClaudeHook({ + hook_event_name: 'UserPromptSubmit', + prompt: 'start a new task' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + agentType: 'claude', + prompt: 'start a new task', + toolName: undefined, + toolInput: undefined + }) + ]) + } finally { + server.stop() + } + }) + + it('does not replay cleared pane state to a newly attached listener', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody({ + hook_event_name: 'UserPromptSubmit', + prompt: 'clear me' + }) + ) + }) + + server.clearPaneState(PANE) + const listener = vi.fn() + server.setListener(listener) + + expect(listener).not.toHaveBeenCalled() + } finally { + server.stop() + } + }) + + it('ignores local nested Claude Stop while a parent Codex hook status is active', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const listener = vi.fn() + server.setListener(listener) + const postHook = async ( + source: 'codex' | 'claude', + payload: Record + ): Promise => { + const response = await fetch( + `http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/${source}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + } + ) + expect(response.status).toBe(204) + } + + await postHook('codex', { + hook_event_name: 'UserPromptSubmit', + prompt: 'parent codex' + }) + await postHook('claude', { + hook_event_name: 'Stop', + last_assistant_message: 'child finished' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + prompt: 'parent codex', + agentType: 'codex' + }) + ]) + const snapshot = server.getStatusSnapshot()[0] + expect(snapshot.lastAssistantMessage).toBeUndefined() + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + state: 'working', + prompt: 'parent codex', + agentType: 'codex' + }) + }) + ) + } finally { + server.stop() + } + }) + + it('does not apply Claude background evidence from a rejected local status', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => { + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + expect(response.status).toBe(204) + } + + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + prompt: 'approve shell', + tool_name: 'Bash', + background_tasks: [{ id: 'shell-1', type: 'shell', status: 'running' }], + session_crons: [{ id: 'cron-1', status: 'running' }] + }) + const waiting = server.getStatusSnapshot()[0] + + await postClaudeHook({ + hook_event_name: 'PreToolUse', + prompt: 'approve shell', + tool_name: 'OtherTool', + background_tasks: [], + session_crons: [] + }) + + expect(server.getStatusSnapshot()[0]).toEqual(waiting) + expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(true) + expect(server._getStateForTests().claudeActiveSessionCronPaneKeys.has(PANE)).toBe(true) + } finally { + server.stop() + } + }) + + it('maps registered legacy numeric HTTP pane keys to stable pane keys', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + server.registerPaneKeyAlias('tab-1:0', PANE) + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody( + { + hook_event_name: 'UserPromptSubmit', + prompt: 'legacy pane' + }, + { paneKey: 'tab-1:0' } + ) + ) + }) + expect(response.status).toBe(204) + + const listener = vi.fn() + server.setListener(listener) + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: expect.objectContaining({ + state: 'working', + prompt: 'legacy pane', + agentType: 'claude' + }) + }) + ) + } finally { + server.stop() + } + }) + + it('tracks hook posts with an empty paneKey before dropping them', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody( + { + hook_event_name: 'UserPromptSubmit', + prompt: 'missing pane' + }, + { paneKey: '' } + ) + ) + }) + const listener = vi.fn() + server.setListener(listener) + + expect(response.status).toBe(204) + expect(listener).not.toHaveBeenCalled() + expect(trackMock).toHaveBeenCalledWith('agent_hook_unattributed', { + reason: 'empty_pane_key' + }) + } finally { + server.stop() + } + }) + + // Why (agent-status-over-SSH §3): ingestRemote must run the same warn-once diagnostics as the local HTTP path so stale remote hooks signal locally. + it('runs warn-once env/version diagnostics on relay-forwarded events', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const listener = vi.fn() + server.setListener(listener) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + env: 'development', + version: '999', + payload: { + state: 'working', + paneKey: PANE, + updatedAt: Date.now(), + agentType: 'claude' + } + }, + 'conn-1' + ) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + connectionId: 'conn-1', + payload: expect.objectContaining({ state: 'working', agentType: 'claude' }) + }) + ) + + const warnCalls = warn.mock.calls.map((c) => String(c[0])) + expect(warnCalls.some((m) => m.includes('v999'))).toBe(true) + expect(warnCalls.some((m) => m.includes('development') && m.includes('production'))).toBe( + true + ) + + const warnsAfterFirst = warn.mock.calls.length + const secondPane = makePaneKey('tab-2', LEAF_2) + server.ingestRemote( + { + paneKey: secondPane, + env: 'development', + version: '999', + payload: { + state: 'working', + paneKey: secondPane, + updatedAt: Date.now(), + agentType: 'claude' + } + }, + 'conn-1' + ) + expect(warn.mock.calls.length).toBe(warnsAfterFirst) + // Why: assert fanout still fires on the second event too, else a refactor that drops it would pass on warn-count alone. + expect(listener).toHaveBeenCalledTimes(2) + } finally { + server.stop() + } + }) + + it('treats remote env as normal relay traffic and normalizes payload at the trust boundary', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const listener = vi.fn() + server.setListener(listener) + + const oversizedPrompt = 'x'.repeat(AGENT_STATUS_MAX_FIELD_LENGTH + 50) + const remotePane = makePaneKey('tab-3', LEAF_3) + server.ingestRemote( + { + paneKey: ` ${remotePane} `, + tabId: ' tab-3 ', + worktreeId: ' wt-3 ', + env: 'remote', + version: '1', + payload: { + state: 'done', + prompt: oversizedPrompt, + agentType: 'codex' + } + }, + ' conn-9 ' + ) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: remotePane, + tabId: 'tab-3', + worktreeId: 'wt-3', + connectionId: 'conn-9', + payload: expect.objectContaining({ + state: 'done', + agentType: 'codex', + prompt: 'x'.repeat(AGENT_STATUS_MAX_FIELD_LENGTH) + }) + }) + ) + expect(warn).not.toHaveBeenCalled() + } finally { + server.stop() + } + }) + + it('accepts form-encoded hook posts from Unix managed scripts', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const params = new URLSearchParams({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'repo::/tmp/worktree with "quotes"', + env: 'production', + version: env.ORCA_AGENT_HOOK_VERSION ?? '', + payload: JSON.stringify({ + hook_event_name: 'UserPromptSubmit', + prompt: 'form encoded' + }) + }) + + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: params + }) + expect(response.status).toBe(204) + + const listener = vi.fn() + server.setListener(listener) + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'repo::/tmp/worktree with "quotes"', + connectionId: null, + receivedAt: expect.any(Number), + stateStartedAt: expect.any(Number), + payload: expect.objectContaining({ + state: 'working', + prompt: 'form encoded', + agentType: 'claude' + }) + }) + ) + } finally { + server.stop() + } + }) + + it('tracks Codex agent statuses from form-encoded managed hook posts', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const listener = vi.fn() + server.setListener(listener) + const postCodexHook = async (payload: Record): Promise => { + const params = new URLSearchParams({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + env: 'production', + version: env.ORCA_AGENT_HOOK_VERSION ?? '', + payload: JSON.stringify(payload) + }) + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: params + }) + expect(response.status).toBe(204) + } + + await postCodexHook({ + hook_event_name: 'UserPromptSubmit', + prompt: 'ship codex hook status' + }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + state: 'working', + agentType: 'codex', + prompt: 'ship codex hook status', + toolName: undefined, + toolInput: undefined + }) + ]) + + await postCodexHook({ + hook_event_name: 'PreToolUse', + tool_name: 'exec_command', + tool_input: { cmd: 'pnpm test', workdir: '/repo' } + }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + agentType: 'codex', + prompt: 'ship codex hook status', + toolName: 'exec_command', + toolInput: 'pnpm test' + }) + ]) + + await postCodexHook({ + hook_event_name: 'PermissionRequest', + tool_name: 'exec_command', + tool_input: { cmd: 'git push', workdir: '/repo' } + }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'waiting', + agentType: 'codex', + prompt: 'ship codex hook status', + toolName: 'exec_command', + toolInput: 'git push' + }) + ]) + + await postCodexHook({ + hook_event_name: 'Stop', + last_assistant_message: 'done' + }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + agentType: 'codex', + prompt: 'ship codex hook status', + lastAssistantMessage: 'done' + }) + ]) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: expect.objectContaining({ + state: 'done', + agentType: 'codex', + prompt: 'ship codex hook status', + lastAssistantMessage: 'done' + }) + }) + ) + } finally { + server.stop() + } + }) + + it('accepts Hermes plugin hook posts on /hook/hermes', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/hermes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody({ + hook_event_name: 'pre_llm_call', + user_message: 'verify Hermes route' + }) + ) + }) + expect(response.status).toBe(204) + + const listener = vi.fn() + server.setListener(listener) + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + payload: expect.objectContaining({ + state: 'working', + prompt: 'verify Hermes route', + agentType: 'hermes' + }) + }) + ) + } finally { + server.stop() + } + }) + + it('accepts Amp plugin hook posts on /hook/amp', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const listener = vi.fn() + server.setListener(listener) + + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/amp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody({ + hook_event_name: 'agent.start', + message: 'verify Amp route' + }) + ) + }) + expect(response.status).toBe(204) + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + payload: expect.objectContaining({ + state: 'working', + prompt: 'verify Amp route', + agentType: 'amp' + }) + }) + ) + } finally { + server.stop() + } + }) +}) + +describe('Amp hook normalization', () => { + it('maps agent lifecycle events to working and done states', () => { + const start = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.start', + message: 'wire Amp hooks' + }), + 'production' + ) + + expect(start?.payload).toMatchObject({ + state: 'working', + prompt: 'wire Amp hooks', + agentType: 'amp' + }) + + const done = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.end', + message: 'wire Amp hooks', + status: 'done' + }), + 'production' + ) + + expect(done?.payload).toMatchObject({ + state: 'done', + prompt: 'wire Amp hooks', + agentType: 'amp' + }) + }) + + it('surfaces Amp tool call and result context while preserving the prompt', () => { + _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.start', + message: 'run tests' + }), + 'production' + ) + + const toolCall = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'tool.call', + tool: 'shell_command', + input: { command: 'pnpm test --run src/main/amp/hook-service.test.ts' } + }), + 'production' + ) + + expect(toolCall?.payload).toMatchObject({ + state: 'working', + prompt: 'run tests', + agentType: 'amp', + toolName: 'shell_command', + toolInput: 'pnpm test --run src/main/amp/hook-service.test.ts' + }) + + const result = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'tool.result', + tool: 'shell_command', + input: { command: 'pnpm test --run src/main/amp/hook-service.test.ts' }, + status: 'done', + output: 'tests passed' + }), + 'production' + ) + + expect(result?.payload).toMatchObject({ + state: 'working', + prompt: 'run tests', + agentType: 'amp', + toolName: 'shell_command', + toolInput: 'pnpm test --run src/main/amp/hook-service.test.ts', + lastAssistantMessage: 'tests passed' + }) + }) + + it('does not let Amp tool result messages overwrite the cached prompt', () => { + _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.start', + message: 'run tests' + }), + 'production' + ) + + const result = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'tool.result', + tool: 'shell_command', + input: { command: 'pnpm test' }, + message: 'tests passed' + }), + 'production' + ) + + expect(result?.payload).toMatchObject({ + state: 'working', + prompt: 'run tests', + agentType: 'amp', + lastAssistantMessage: 'tests passed' + }) + + const done = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.end', + status: 'done' + }), + 'production' + ) + + expect(done?.payload).toMatchObject({ + state: 'done', + prompt: 'run tests', + agentType: 'amp' + }) + }) + + it('keeps Amp prompt and tool caches isolated by thread id within one pane', () => { + _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.start', + threadId: 'thread-a', + message: 'first task' + }), + 'production' + ) + + _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.start', + threadId: 'thread-b', + message: 'second task' + }), + 'production' + ) + + const threadAResult = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'tool.result', + threadId: 'thread-a', + tool: 'shell_command', + input: { command: 'pnpm test:a' }, + output: 'first done' + }), + 'production' + ) + + expect(threadAResult?.payload).toMatchObject({ + state: 'working', + prompt: 'first task', + agentType: 'amp', + toolName: 'shell_command', + toolInput: 'pnpm test:a', + lastAssistantMessage: 'first done' + }) + + const threadBDone = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.end', + threadId: 'thread-b', + status: 'done' + }), + 'production' + ) + + expect(threadBDone?.payload).toMatchObject({ + state: 'done', + prompt: 'second task', + agentType: 'amp' + }) + }) + + it('drops stale Amp tool events that arrive after the thread ended', () => { + _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.start', + threadId: 'thread-a', + message: 'run tests' + }), + 'production' + ) + + const done = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.end', + threadId: 'thread-a', + status: 'done' + }), + 'production' + ) + + expect(done?.payload).toMatchObject({ + state: 'done', + prompt: 'run tests', + agentType: 'amp' + }) + + const staleToolResult = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'tool.result', + threadId: 'thread-a', + tool: 'shell_command', + input: { command: 'pnpm test' }, + message: 'tests passed' + }), + 'production' + ) + + expect(staleToolResult).toBeNull() + }) + + it('does not mark Amp tool result messages as explicit prompts', () => { + _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.start', + threadId: 'thread-a', + message: 'run tests' + }), + 'production' + ) + + const result = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'tool.result', + threadId: 'thread-a', + tool: 'shell_command', + input: { command: 'pnpm test' }, + message: 'tests passed' + }), + 'production' + ) + + expect(result?.payload).toMatchObject({ + state: 'working', + prompt: 'run tests', + agentType: 'amp', + lastAssistantMessage: 'tests passed' + }) + expect(result?.hasExplicitPrompt).toBeUndefined() + }) + + it('marks cancelled Amp turns as interrupted done states', () => { + const cancelled = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.end', + message: 'stop this run', + status: 'cancelled' + }), + 'production' + ) + + expect(cancelled?.payload).toMatchObject({ + state: 'done', + prompt: 'stop this run', + agentType: 'amp', + interrupted: true + }) + }) + + it('treats session.start as cache reset without creating a visible row', () => { + _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'agent.start', + message: 'old prompt' + }), + 'production' + ) + + const sessionStart = _internals.normalizeHookPayload( + 'amp', + buildBody({ hook_event_name: 'session.start', threadId: 'thread-1' }), + 'production' + ) + expect(sessionStart).toBeNull() + + const nextTool = _internals.normalizeHookPayload( + 'amp', + buildBody({ + hook_event_name: 'tool.call', + tool: 'Read', + input: { file_path: '/tmp/file.ts' } + }), + 'production' + ) + + expect(nextTool?.payload).toMatchObject({ + state: 'working', + prompt: '', + agentType: 'amp', + toolName: 'Read', + toolInput: '/tmp/file.ts' + }) + }) +}) + +describe('AgentHookServer prompt-sent telemetry', () => { + it('tracks a live local hook explicit prompt with conservative attribution', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody({ + hook_event_name: 'UserPromptSubmit', + prompt: ' fix the spinner ' + }) + ) + }) + + expect(response.status).toBe(204) + expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', { + agent_kind: 'claude-code', + launch_source: 'unknown', + request_kind: 'followup', + nth_repo_added: 2 + }) + } finally { + server.stop() + } + }) + + it('tracks a live SSH hook explicit prompt through ingestRemote', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'remote prompt', agentType: 'codex' } + }, + 'conn-1' + ) + + expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', { + agent_kind: 'codex', + launch_source: 'unknown', + request_kind: 'followup', + nth_repo_added: 2 + }) + }) + + it('dedupes adjacent same-turn reports without considering hook state', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'same turn', agentType: 'gemini' } + }, + 'conn-1' + ) + vi.setSystemTime(1_500) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'done', prompt: 'same turn', agentType: 'gemini' } + }, + 'conn-1' + ) + + expect(trackMock).toHaveBeenCalledTimes(1) + expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', { + agent_kind: 'gemini', + launch_source: 'unknown', + request_kind: 'followup', + nth_repo_added: 2 + }) + } finally { + vi.useRealTimers() + } + }) + + it('tracks the same prompt again after a completed turn starts over', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'continue', agentType: 'codex' } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'done', prompt: 'continue', agentType: 'codex' } + }, + 'conn-1' + ) + vi.setSystemTime(1_500) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'continue', agentType: 'codex' } + }, + 'conn-1' + ) + + expect(trackMock).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('dedupes duplicate Command Code stop hooks but tracks same-prompt reruns', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + promptInteractionKey: 'command-code-transcript-user-1', + payload: { state: 'done', prompt: 'rerun', agentType: 'command-code' } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + promptInteractionKey: 'command-code-transcript-user-1', + payload: { state: 'done', prompt: 'rerun', agentType: 'command-code' } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + promptInteractionKey: 'command-code-transcript-user-2', + payload: { state: 'done', prompt: 'rerun', agentType: 'command-code' } + }, + 'conn-1' + ) + + expect(trackMock).toHaveBeenCalledTimes(2) + }) + + it('includes Command Code prompt interaction keys in the IPC snapshot', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + promptInteractionKey: 'command-code-transcript-user-1', + payload: { state: 'working', prompt: 'rerun', agentType: 'command-code' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + paneKey: PANE, + promptInteractionKey: 'command-code-transcript-user-1', + state: 'working', + prompt: 'rerun', + agentType: 'command-code' + }) + }) + + it('dedupes Command Code direct prompt hooks followed by transcript-backed stop hooks', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'same command', agentType: 'command-code' } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + promptInteractionKey: 'command-code-transcript-a-1', + payload: { state: 'done', prompt: 'same command', agentType: 'command-code' } + }, + 'conn-1' + ) + + expect(trackMock).toHaveBeenCalledTimes(1) + }) + + it('does not let a reused interaction key suppress different prompt text', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + promptInteractionKey: 'command-code-transcript-reused', + payload: { state: 'done', prompt: 'first command', agentType: 'command-code' } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + promptInteractionKey: 'command-code-transcript-reused', + payload: { state: 'done', prompt: 'second command', agentType: 'command-code' } + }, + 'conn-1' + ) + + expect(trackMock).toHaveBeenCalledTimes(2) + }) + + it('does not treat Command Code cached prompts as explicit prompt evidence', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'done', prompt: 'cached prompt', agentType: 'command-code' } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: false, + payload: { state: 'done', prompt: 'cached prompt', agentType: 'command-code' } + }, + 'conn-1' + ) + + expect(trackMock).toHaveBeenCalledTimes(1) + }) + + it('preserves prompt dedupe when a live status row is dismissed', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'long turn', agentType: 'codex' } + }, + 'conn-1' + ) + server.dropStatusEntry(PANE) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'long turn', agentType: 'codex' } + }, + 'conn-1' + ) + + expect(trackMock).toHaveBeenCalledTimes(1) + }) + + it('lets a dismissed completed row start the same prompt again', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'done', prompt: 'rerun after done', agentType: 'codex' } + }, + 'conn-1' + ) + server.dropStatusEntry(PANE) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'rerun after done', agentType: 'codex' } + }, + 'conn-1' + ) + + expect(trackMock).toHaveBeenCalledTimes(2) + }) + + it('dedupes the same prompt until a completed turn boundary is observed', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'repeat later', agentType: 'codex' } + }, + 'conn-1' + ) + vi.setSystemTime(32_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'repeat later', agentType: 'codex' } + }, + 'conn-1' + ) + + expect(trackMock).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('does not track replays, empty prompts, or inherited prompt snapshots', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + isReplay: true, + payload: { state: 'working', prompt: 'replayed prompt', agentType: 'codex' } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: GOOD_PANE, + tabId: 'tab-good', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: ' ', agentType: 'codex' } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: FRESH_PANE, + tabId: 'tab-fresh', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'inherited prompt', agentType: 'codex' } + }, + 'conn-1' + ) + + expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) + }) + + it('does not track hook status messages that preserve a cached prompt', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'real prompt', agentType: 'droid' } + }, + 'conn-1' + ) + trackMock.mockClear() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: false, + payload: { state: 'waiting', prompt: 'real prompt', agentType: 'droid' } + }, + 'conn-1' + ) + + expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) + }) + + it('tracks OpenCode user MessagePart hooks once per message id', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/opencode`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody({ + hook_event_name: 'MessagePart', + role: 'user', + text: 'fix', + messageID: 'msg-1' + }) + ) + }) + const updatedResponse = await fetch( + `http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/opencode`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody({ + hook_event_name: 'MessagePart', + role: 'user', + text: 'fix tests', + messageID: 'msg-1' + }) + ) + } + ) + + expect(response.status).toBe(204) + expect(updatedResponse.status).toBe(204) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + prompt: 'fix tests', + agentType: 'opencode' + }) + expect(trackMock).toHaveBeenCalledTimes(1) + expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', { + agent_kind: 'opencode', + launch_source: 'unknown', + request_kind: 'followup', + nth_repo_added: 2 + }) + } finally { + server.stop() + } + }) + + it('maps custom hook agent types to other', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'custom prompt', agentType: 'my-local-agent' } + }, + 'conn-1' + ) + + expect(trackMock).toHaveBeenCalledWith('agent_prompt_sent', { + agent_kind: 'other', + launch_source: 'unknown', + request_kind: 'followup', + nth_repo_added: 2 + }) + }) + + it('does not block status cache mutation or listener fanout when telemetry throws', () => { + const server = new AgentHookServer() + const listener = vi.fn() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + trackMock.mockImplementationOnce(() => { + throw new Error('telemetry unavailable') + }) + server.setListener(listener) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'keep status moving', agentType: 'codex' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + prompt: 'keep status moving', + agentType: 'codex' + }) + ]) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + payload: expect.objectContaining({ prompt: 'keep status moving' }) + }) + ) + errorSpy.mockRestore() + }) +}) + +describe('Claude hook normalization', () => { + it('PostToolUse for Edit surfaces toolName + file_path preview', () => { + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUse', + tool_name: 'Edit', + tool_input: { file_path: '/src/config.ts', old_string: 'a', new_string: 'b' }, + tool_response: {} + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('Edit') + expect(result?.payload.toolInput).toBe('/src/config.ts') + }) + + it('PostToolUse for Bash surfaces the command string', () => { + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'pnpm test --run' }, + tool_response: { content: [{ type: 'text', text: 'tests passed' }] } + }), + 'production' + ) + expect(result?.payload.toolName).toBe('Bash') + expect(result?.payload.toolInput).toBe('pnpm test --run') + expect(result?.payload.lastAssistantMessage).toBe('tests passed') + }) + + it('PostToolUse for Grep surfaces the search pattern', () => { + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'foo.*bar', path: '/src' } + }), + 'production' + ) + expect(result?.payload.toolName).toBe('Grep') + expect(result?.payload.toolInput).toBe('foo.*bar') + }) + + it('PostToolUse for an unknown tool surfaces the name without input', () => { + // Why: a per-tool allowlist picks the preview field; unrecognized tools render name-only to avoid guessing a misleading field (e.g. an opaque ID). + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUse', + tool_name: 'BespokeTool', + tool_input: { irrelevantFlag: true, summary: 'doing the thing' } + }), + 'production' + ) + expect(result?.payload.toolName).toBe('BespokeTool') + expect(result?.payload.toolInput).toBeUndefined() + }) + + it('PostToolUse for TaskUpdate does not produce a misleading input preview', () => { + // Why: TaskUpdate's tool_input has no meaningful preview (rendering "3" is confusing), so the allowlist leaves toolInput undefined. + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUse', + tool_name: 'TaskUpdate', + tool_input: { task_id: '3', status: 'in_progress' } + }), + 'production' + ) + expect(result?.payload.toolName).toBe('TaskUpdate') + expect(result?.payload.toolInput).toBeUndefined() + }) + + it('PostToolUseFailure clears stale tool fields until the next PreToolUse', () => { + _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Edit', + tool_input: { file_path: '/src/config.ts' } + }), + 'production' + ) + const failed = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUseFailure', + tool_name: 'Edit', + tool_input: { file_path: '/src/config.ts' }, + error: 'file is read-only' + }), + 'production' + ) + expect(failed?.payload).toMatchObject({ + state: 'working', + lastAssistantMessage: 'file is read-only' + }) + expect(failed?.payload.toolName).toBeUndefined() + expect(failed?.payload.toolInput).toBeUndefined() + + const retry = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Read', + tool_input: { file_path: '/src/config.ts' } + }), + 'production' + ) + expect(retry?.payload).toMatchObject({ + state: 'working', + toolName: 'Read', + toolInput: '/src/config.ts' + }) + }) + + it('PreToolUse normalizes to working + tool fields', () => { + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Read', + tool_input: { file_path: '/src/index.ts' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('Read') + expect(result?.payload.toolInput).toBe('/src/index.ts') + }) + + it('PermissionRequest normalizes to waiting + tool fields', () => { + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PermissionRequest', + tool_name: 'Bash', + tool_input: { command: 'rm -rf build' } + }), + 'production' + ) + expect(result?.payload.state).toBe('waiting') + expect(result?.payload.toolName).toBe('Bash') + expect(result?.payload.toolInput).toBe('rm -rf build') + }) + + it('PermissionRequest for a fresh tool without input preview clears cached tool input', () => { + _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' } + }), + 'production' + ) + + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PermissionRequest', + tool_name: 'BespokeTool', + tool_input: { request_id: 'approval-1' } + }), + 'production' + ) + + expect(result?.payload.state).toBe('waiting') + expect(result?.payload.toolName).toBe('BespokeTool') + expect(result?.payload.toolInput).toBeUndefined() + }) + + it('PermissionRequest for the same tool without input preview clears cached tool input', () => { + _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'BespokeTool', + tool_input: 'old preview' + }), + 'production' + ) + + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PermissionRequest', + tool_name: 'BespokeTool', + tool_input: { request_id: 'approval-1' } + }), + 'production' + ) + + expect(result?.payload.state).toBe('waiting') + expect(result?.payload.toolName).toBe('BespokeTool') + expect(result?.payload.toolInput).toBeUndefined() + }) + + it('PermissionRequest without a tool name does not inherit stale tool details when input is explicit', () => { + _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' } + }), + 'production' + ) + + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PermissionRequest', + tool_input: { request_id: 'approval-1' } + }), + 'production' + ) + + expect(result?.payload.state).toBe('waiting') + expect(result?.payload.toolName).toBeUndefined() + expect(result?.payload.toolInput).toBeUndefined() + }) + + it('UserPromptSubmit clears the cached tool state from the prior turn', () => { + _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUse', + tool_name: 'Edit', + tool_input: { file_path: '/src/stale.ts' } + }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'UserPromptSubmit', + prompt: 'Do the next thing' + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.prompt).toBe('Do the next thing') + expect(result?.payload.toolName).toBeUndefined() + expect(result?.payload.toolInput).toBeUndefined() + }) + + it('Stop carries last_assistant_message directly when present', () => { + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'Stop', + last_assistant_message: 'what is up my dude' + }), + 'production' + ) + expect(result?.payload.state).toBe('done') + expect(result?.payload.lastAssistantMessage).toBe('what is up my dude') + }) + + it('StopFailure maps to done without copying provider error text', () => { + _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'UserPromptSubmit', + prompt: 'say hi' + }), + 'production' + ) + + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'StopFailure', + error: 'invalid_request', + error_details: 'model is not supported', + last_assistant_message: 'API Error: model is not supported' + }), + 'production' + ) + + expect(result?.payload.state).toBe('done') + expect(result?.payload.prompt).toBe('say hi') + expect(result?.payload.lastAssistantMessage).toBeUndefined() + }) + + describe('Stop transcript scan', () => { + let tmpDir: string + let transcriptPath: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-hook-test-')) + transcriptPath = join(tmpDir, 'transcript.jsonl') + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('surfaces the most recent assistant text entry', () => { + const lines = [ + { role: 'user', content: 'hi' }, + { role: 'assistant', message: { role: 'assistant', content: 'earlier reply' } }, + { role: 'user', content: 'do it' }, + { + role: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'final reply' }] } + } + ] + writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`) + + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }), + 'production' + ) + expect(result?.payload.lastAssistantMessage).toBe('final reply') + }) + + it('skips tool_use-only assistant entries to find the previous text reply', () => { + const lines = [ + { role: 'assistant', message: { role: 'assistant', content: 'the answer is 42' } }, + { + role: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' } }] + } + } + ] + writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`) + + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }), + 'production' + ) + expect(result?.payload.lastAssistantMessage).toBe('the answer is 42') + }) + + it('finds an assistant reply that sits past the first chunk boundary', () => { + // Why: large tool_result entries push the final reply past the first 64 KB chunk; the scan must keep reading backward to find it. + const filler = 'x'.repeat(70_000) + const lines = [ + { role: 'assistant', message: { role: 'assistant', content: 'deeply buried reply' } }, + // 70 KB of tool_result content straddling the first chunk boundary. + { + role: 'user', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 't1', content: filler }] + } + }, + { + role: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' } }] + } + } + ] + writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`) + + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }), + 'production' + ) + expect(result?.payload.lastAssistantMessage).toBe('deeply buried reply') + }) + + it('returns undefined when the transcript has no assistant text at all', () => { + const lines = [ + { role: 'user', content: 'hi' }, + { + role: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' } }] + } + } + ] + writeFileSync(transcriptPath, `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`) + + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }), + 'production' + ) + expect(result?.payload.lastAssistantMessage).toBeUndefined() + }) + }) + + it('merges tool fields across consecutive events in the same turn', () => { + _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls -la' } + }), + 'production' + ) + // Stop event has no tool fields, so the merged snapshot must keep the earlier PreToolUse values. + const stop = _internals.normalizeHookPayload( + 'claude', + buildBody({ hook_event_name: 'Stop' }), + 'production' + ) + expect(stop?.payload.state).toBe('done') + expect(stop?.payload.toolName).toBe('Bash') + expect(stop?.payload.toolInput).toBe('ls -la') + }) +}) + +describe('Codex hook normalization', () => { + it('tracks nested subagents with their role, model, and lifecycle state', () => { + const root = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'UserPromptSubmit', + prompt: 'Coordinate the review', + model: 'gpt-5.4' + }), + 'production' + ) + expect(root?.payload.model).toBe('gpt-5.4') + + const started = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'SubagentStart', + session_id: 'child-session', + agent_id: 'child-session', + agent_type: 'reviewer', + model: 'gpt-5.4-mini' + }), + 'production' + ) + expect(started?.providerSession).toBeUndefined() + expect(started?.payload).toMatchObject({ + state: 'working', + prompt: 'Coordinate the review', + model: 'gpt-5.4', + subagents: [ + { + id: 'child-session', + agentType: 'reviewer', + model: 'gpt-5.4-mini', + state: 'working' + } + ] + }) + + const waiting = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'PermissionRequest', + agent_id: 'child-session', + agent_type: 'reviewer', + model: 'gpt-5.4-mini', + tool_name: 'exec_command', + tool_input: { cmd: 'git fetch' } + }), + 'production' + ) + expect(waiting?.payload.state).toBe('waiting') + expect(waiting?.payload.subagents?.[0].state).toBe('waiting') + + const workingAgain = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'PreToolUse', + agent_id: 'child-session', + agent_type: 'reviewer', + model: 'gpt-5.4-mini', + tool_name: 'exec_command', + tool_input: { cmd: 'git fetch' } + }), + 'production' + ) + expect(workingAgain?.payload.state).toBe('working') + expect(workingAgain?.payload.subagents?.[0].state).toBe('working') + + const rootStop = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'Stop', model: 'gpt-5.4' }), + 'production' + ) + expect(rootStop?.payload.state).toBe('done') + expect(rootStop?.payload.subagents).toBeUndefined() + + const resumedChild = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'PreToolUse', + agent_id: 'child-session', + agent_type: 'reviewer', + model: 'gpt-5.4-mini', + tool_name: 'exec_command', + tool_input: { cmd: 'pnpm test' } + }), + 'production' + ) + expect(resumedChild?.payload.state).toBe('working') + expect(resumedChild?.payload.subagents?.[0]).toMatchObject({ + id: 'child-session', + state: 'working' + }) + + const stopped = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'SubagentStop', agent_id: 'child-session' }), + 'production' + ) + expect(stopped?.payload.state).toBe('done') + expect(stopped?.payload.subagents).toBeUndefined() + }) + + it('Stop carries last_assistant_message into lastAssistantMessage', () => { + const result = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'Stop', + last_assistant_message: 'Summary of what I did.' + }), + 'production' + ) + expect(result?.payload.state).toBe('done') + expect(result?.payload.lastAssistantMessage).toBe('Summary of what I did.') + }) + + it('PreToolUse surfaces tool name + input preview and stays in working state', () => { + // Why: Codex's PreToolUse fires for every tool call (not an approval), so map it to `working` not `waiting`; approvals flow through PermissionRequest. + const result = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'exec_command', + tool_input: { cmd: 'git status', workdir: '/tmp' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('exec_command') + expect(result?.payload.toolInput).toBe('git status') + }) + + it('PermissionRequest maps to waiting and surfaces the pending tool input', () => { + // Why: PermissionRequest must map to `waiting` (sidebar red dot); treating it like PreToolUse would leave the pane looking busy while blocked on the user. + const result = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'PermissionRequest', + tool_name: 'exec_command', + tool_input: { cmd: 'rm -rf build', workdir: '/tmp' } + }), + 'production' + ) + expect(result?.payload.state).toBe('waiting') + expect(result?.payload.agentType).toBe('codex') + expect(result?.payload.toolName).toBe('exec_command') + expect(result?.payload.toolInput).toBe('rm -rf build') + }) + + it('UserPromptSubmit does not extract tool fields even when the payload carries them', () => { + // Why: UserPromptSubmit is a turn boundary; tool extraction is gated to Pre/PostToolUse so stray tool_name can't leak into the preview. + const result = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'UserPromptSubmit', + prompt: 'Hello', + tool_name: 'Edit', + tool_input: { file_path: '/ignored.ts' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBeUndefined() + expect(result?.payload.toolInput).toBeUndefined() + }) + + it('SessionStart clears cached tool state from a prior session', () => { + // Seed a Stop snapshot with an assistant message. + _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'Stop', + last_assistant_message: 'Previous run finished' + }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'SessionStart' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.lastAssistantMessage).toBeUndefined() + }) + + it('SessionStart clears the cached prompt from a prior session until a new prompt arrives', () => { + _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'UserPromptSubmit', + prompt: 'stale prompt' + }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'SessionStart' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.prompt).toBe('') + }) +}) + +describe('Gemini hook normalization', () => { + it('BeforeTool surfaces toolName + toolInput', () => { + const result = _internals.normalizeHookPayload( + 'gemini', + buildBody({ + hook_event_name: 'BeforeTool', + tool_name: 'read_file', + tool_input: { path: '/src/index.ts' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('read_file') + expect(result?.payload.toolInput).toBe('/src/index.ts') + }) + + it('falls back to args when tool_input is absent', () => { + const result = _internals.normalizeHookPayload( + 'gemini', + buildBody({ + hook_event_name: 'BeforeTool', + tool_name: 'run_shell_command', + args: { command: 'git status' } + }), + 'production' + ) + expect(result?.payload.toolName).toBe('run_shell_command') + expect(result?.payload.toolInput).toBe('git status') + }) + + it('BeforeAgent clears the cached tool state from a prior turn', () => { + _internals.normalizeHookPayload( + 'gemini', + buildBody({ + hook_event_name: 'BeforeTool', + tool_name: 'read_file', + tool_input: { path: '/stale.ts' } + }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'gemini', + buildBody({ hook_event_name: 'BeforeAgent' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBeUndefined() + expect(result?.payload.toolInput).toBeUndefined() + }) + + it('AfterAgent reports done without introducing tool fields on its own', () => { + const result = _internals.normalizeHookPayload( + 'gemini', + buildBody({ hook_event_name: 'AfterAgent' }), + 'production' + ) + expect(result?.payload.state).toBe('done') + expect(result?.payload.toolName).toBeUndefined() + }) + + it('AfterAgent carries prompt_response into lastAssistantMessage', () => { + const result = _internals.normalizeHookPayload( + 'gemini', + buildBody({ + hook_event_name: 'AfterAgent', + prompt: 'what did you do', + prompt_response: 'I ran the tests and they passed.', + stop_hook_active: false + }), + 'production' + ) + expect(result?.payload.state).toBe('done') + expect(result?.payload.lastAssistantMessage).toBe('I ran the tests and they passed.') + }) +}) + +describe('OpenCode hook normalization', () => { + it('SessionBusy maps to working', () => { + const result = _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'SessionBusy' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.agentType).toBe('opencode') + }) + + it('SessionBusy does NOT clear the cached user prompt', () => { + // Why: OpenCode caches the user's MessagePart before SessionBusy fires, so the cached prompt is this turn's; clearing it would clobber the dashboard. + _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'MessagePart', role: 'user', text: 'new prompt' }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'SessionBusy' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.prompt).toBe('new prompt') + }) + + it('SessionIdle maps to done', () => { + const result = _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'SessionIdle' }), + 'production' + ) + expect(result?.payload.state).toBe('done') + expect(result?.payload.agentType).toBe('opencode') + }) + + it('PermissionRequest maps to waiting', () => { + const result = _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'PermissionRequest' }), + 'production' + ) + expect(result?.payload.state).toBe('waiting') + }) + + it('AskUserQuestion maps to waiting', () => { + // Why: AskUserQuestion leaves the agent idle-but-waiting on a human, so it must map to `waiting` (red dot) like permission.asked, not stay `working`. + const result = _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'AskUserQuestion' }), + 'production' + ) + expect(result?.payload.state).toBe('waiting') + expect(result?.payload.agentType).toBe('opencode') + }) + + it('unknown event name returns null', () => { + const result = _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'SomeOtherEvent' }), + 'production' + ) + expect(result).toBeNull() + }) + + it('MessagePart with role=user surfaces text as the prompt and stays working', () => { + const result = _internals.normalizeHookPayload( + 'opencode', + buildBody({ + hook_event_name: 'MessagePart', + role: 'user', + text: 'hi there', + messageID: 'msg-1' + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.prompt).toBe('hi there') + expect(result?.hasExplicitPrompt).toBe(true) + expect(result?.promptInteractionKey).toBe('opencode-message-msg-1') + }) + + it('MessagePart with role=assistant populates lastAssistantMessage', () => { + const result = _internals.normalizeHookPayload( + 'opencode', + buildBody({ + hook_event_name: 'MessagePart', + role: 'assistant', + text: 'Hello! How can I help?' + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.lastAssistantMessage).toBe('Hello! How can I help?') + }) + + it('caps oversized MessagePart text from stale (pre-throttle) plugin builds', () => { + // Why: stale plugin builds re-post the full reply on every part update, so the listener must cap the text to keep per-event work O(cap). + const assistant = _internals.normalizeHookPayload( + 'opencode', + buildBody({ + hook_event_name: 'MessagePart', + role: 'assistant', + text: 'a'.repeat(500_000) + }), + 'production' + ) + expect(assistant?.payload.lastAssistantMessage?.length).toBe(8_000) + + // Why: prompt is capped at 200 by normalizeAgentStatusObject; assert oversized input still stays within that bound. + const user = _internals.normalizeHookPayload( + 'opencode', + buildBody({ + hook_event_name: 'MessagePart', + role: 'user', + text: 'u'.repeat(500_000), + messageID: 'msg-cap' + }), + 'production' + ) + expect(user?.payload.prompt?.length).toBe(200) + }) + + it('subsequent SessionIdle preserves cached prompt + assistant message', () => { + _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'MessagePart', role: 'user', text: 'hi' }), + 'production' + ) + _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'MessagePart', role: 'assistant', text: 'hello back' }), + 'production' + ) + const done = _internals.normalizeHookPayload( + 'opencode', + buildBody({ hook_event_name: 'SessionIdle' }), + 'production' + ) + expect(done?.payload.state).toBe('done') + expect(done?.payload.prompt).toBe('hi') + expect(done?.payload.lastAssistantMessage).toBe('hello back') + }) +}) + +describe('Cursor hook normalization', () => { + it('beforeSubmitPrompt maps to working and captures the prompt', () => { + const result = _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'beforeSubmitPrompt', prompt: 'add a README' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.agentType).toBe('cursor') + expect(result?.payload.prompt).toBe('add a README') + }) + + it('stop maps to done', () => { + const result = _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'stop', status: 'completed' }), + 'production' + ) + expect(result?.payload.state).toBe('done') + expect(result?.payload.agentType).toBe('cursor') + expect(result?.payload.interrupted).toBeUndefined() + }) + + it('stop with non-completed status marks the turn interrupted', () => { + const result = _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'stop', status: 'cancelled' }), + 'production' + ) + expect(result?.payload.state).toBe('done') + expect(result?.payload.interrupted).toBe(true) + }) + + it('beforeShellExecution maps to working with the pending command as toolInput', () => { + const result = _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'beforeShellExecution', command: 'rm -rf /tmp/foo' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('Shell') + expect(result?.payload.toolInput).toBe('rm -rf /tmp/foo') + }) + + it('beforeMCPExecution maps to working', () => { + const result = _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'beforeMCPExecution', tool_name: 'fetch', url: 'https://x' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('fetch') + }) + + it('preToolUse surfaces tool name + input preview and stays working', () => { + const result = _internals.normalizeHookPayload( + 'cursor', + buildBody({ + hook_event_name: 'preToolUse', + tool_name: 'Read', + tool_input: { file_path: '/repo/src/app.ts' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('Read') + expect(result?.payload.toolInput).toBe('/repo/src/app.ts') + }) + + it('postToolUseFailure surfaces the error and clears stale tool fields', () => { + _internals.normalizeHookPayload( + 'cursor', + buildBody({ + hook_event_name: 'preToolUse', + tool_name: 'Read', + tool_input: { file_path: '/repo/src/app.ts' } + }), + 'production' + ) + const failed = _internals.normalizeHookPayload( + 'cursor', + buildBody({ + hook_event_name: 'postToolUseFailure', + tool_name: 'Read', + tool_input: { file_path: '/repo/src/app.ts' }, + error_message: 'file not found' + }), + 'production' + ) + // Why: keeping toolName would let the compact sidebar show the tool instead of the failure text, hiding the error. + expect(failed?.payload).toMatchObject({ + state: 'working', + lastAssistantMessage: 'file not found' + }) + expect(failed?.payload.toolName).toBeUndefined() + expect(failed?.payload.toolInput).toBeUndefined() + }) + + it('afterAgentResponse carries text into lastAssistantMessage', () => { + const result = _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'afterAgentResponse', text: 'Done — wrote the README.' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.lastAssistantMessage).toBe('Done — wrote the README.') + }) + + it('late afterAgentResponse after stop keeps Cursor done instead of resurrecting working', () => { + const submit = _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'beforeSubmitPrompt', prompt: 'add tests' }), + 'production' + ) + expect(submit).not.toBeNull() + if (!submit) { + throw new Error('expected Cursor beforeSubmitPrompt to normalize') + } + agentHookServer.ingestRemote( + { + paneKey: submit.paneKey, + tabId: submit.tabId, + worktreeId: submit.worktreeId, + payload: submit.payload + }, + 'conn-1' + ) + + const stop = _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'stop', status: 'completed' }), + 'production' + ) + expect(stop).not.toBeNull() + if (!stop) { + throw new Error('expected Cursor stop to normalize') + } + agentHookServer.ingestRemote( + { + paneKey: stop.paneKey, + tabId: stop.tabId, + worktreeId: stop.worktreeId, + payload: stop.payload + }, + 'conn-1' + ) + + const response = _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'afterAgentResponse', text: 'All set.' }), + 'production' + ) + expect(response?.payload.state).toBe('done') + expect(response?.payload.lastAssistantMessage).toBe('All set.') + if (!response) { + throw new Error('expected Cursor afterAgentResponse to normalize') + } + + agentHookServer.ingestRemote( + { + paneKey: response.paneKey, + tabId: response.tabId, + worktreeId: response.worktreeId, + payload: response.payload + }, + 'conn-1' + ) + expect(agentHookServer.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'done', + agentType: 'cursor', + prompt: 'add tests', + lastAssistantMessage: 'All set.' + }) + ]) + }) + + it('tool-heavy turn keeps working across shell and generic tool hooks until stop', () => { + _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'beforeSubmitPrompt', prompt: 'run checks' }), + 'production' + ) + const shell = _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'beforeShellExecution', command: 'pnpm test' }), + 'production' + ) + expect(shell?.payload.state).toBe('working') + const tool = _internals.normalizeHookPayload( + 'cursor', + buildBody({ + hook_event_name: 'preToolUse', + tool_name: 'Read', + tool_input: { file_path: '/repo/src/app.ts' } + }), + 'production' + ) + expect(tool?.payload.state).toBe('working') + const stop = _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'stop', status: 'completed' }), + 'production' + ) + expect(stop?.payload.state).toBe('done') + expect(stop?.payload.prompt).toBe('run checks') + }) + + it('beforeSubmitPrompt clears the cached tool state from a prior turn', () => { + _internals.normalizeHookPayload( + 'cursor', + buildBody({ + hook_event_name: 'preToolUse', + tool_name: 'Edit', + tool_input: { file_path: '/stale.ts' } + }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'beforeSubmitPrompt', prompt: 'new turn' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.prompt).toBe('new turn') + expect(result?.payload.toolName).toBeUndefined() + expect(result?.payload.toolInput).toBeUndefined() + }) + + it('subsequent stop preserves the cached prompt from beforeSubmitPrompt', () => { + _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'beforeSubmitPrompt', prompt: 'add tests' }), + 'production' + ) + const stop = _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'stop', status: 'completed' }), + 'production' + ) + expect(stop?.payload.state).toBe('done') + expect(stop?.payload.prompt).toBe('add tests') + }) + + it('unknown event name returns null', () => { + const result = _internals.normalizeHookPayload( + 'cursor', + buildBody({ hook_event_name: 'somethingElse' }), + 'production' + ) + expect(result).toBeNull() + }) +}) + +describe('Droid hook normalization', () => { + it('UserPromptSubmit maps to working and captures the prompt', () => { + const result = _internals.normalizeHookPayload( + 'droid', + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'ship this fix' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.agentType).toBe('droid') + expect(result?.payload.prompt).toBe('ship this fix') + }) + + it('Notification maps permission prompts to waiting and idle prompts to done', () => { + const waiting = _internals.normalizeHookPayload( + 'droid', + buildBody({ + hook_event_name: 'Notification', + message: 'Droid needs your permission to use Execute' + }), + 'production' + ) + expect(waiting?.payload.state).toBe('waiting') + + const done = _internals.normalizeHookPayload( + 'droid', + buildBody({ + hook_event_name: 'Notification', + message: 'Droid is waiting for your input' + }), + 'production' + ) + expect(done?.payload.state).toBe('done') + + const ignored = _internals.normalizeHookPayload( + 'droid', + buildBody({ + hook_event_name: 'Notification', + message: 'Task completed successfully' + }), + 'production' + ) + expect(ignored).toBeNull() + }) + + it('Notification preserves the cached user prompt instead of using status text as prompt', () => { + _internals.normalizeHookPayload( + 'droid', + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'write tests' }), + 'production' + ) + + const done = _internals.normalizeHookPayload( + 'droid', + buildBody({ + hook_event_name: 'Notification', + message: 'Droid is waiting for your input' + }), + 'production' + ) + + expect(done?.payload.state).toBe('done') + expect(done?.payload.prompt).toBe('write tests') + expect(done?.hasExplicitPrompt).toBe(false) + }) + + it('Notification ignores confirmation status text rather than treating it as permission', () => { + const result = _internals.normalizeHookPayload( + 'droid', + buildBody({ + hook_event_name: 'Notification', + message: 'Confirmed configuration loaded' + }), + 'production' + ) + + expect(result).toBeNull() + }) + + it('SubagentStop does not mark Droid done for mission progress', () => { + const result = _internals.normalizeHookPayload( + 'droid', + buildBody({ + hook_event_name: 'SubagentStop', + last_assistant_message: '# Completed Wrote the requested validation assertions' + }), + 'production' + ) + + expect(result).toBeNull() + }) + + it('PreToolUse maps to working and surfaces the tool name and input preview', () => { + const result = _internals.normalizeHookPayload( + 'droid', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Read', + tool_input: { file_path: '/tmp/example.ts' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('Read') + expect(result?.payload.toolInput).toBe('/tmp/example.ts') + }) + + it('PreToolUse falls back to the `name` and `input` fields when tool_name/tool_input are absent', () => { + const result = _internals.normalizeHookPayload( + 'droid', + buildBody({ + hook_event_name: 'PreToolUse', + name: 'Bash', + input: { command: 'pnpm typecheck' } + }), + 'production' + ) + expect(result?.payload.toolName).toBe('Bash') + expect(result?.payload.toolInput).toBe('pnpm typecheck') + }) + + it('PreToolUse AskUser maps to waiting for human input', () => { + const result = _internals.normalizeHookPayload( + 'droid', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'AskUser', + tool_input: { question: 'Which permission-requiring action should I perform?' } + }), + 'production' + ) + + expect(result?.payload.state).toBe('waiting') + expect(result?.payload.toolName).toBe('AskUser') + }) + + it('PreToolUse high-risk Execute maps to waiting for approval', () => { + const result = _internals.normalizeHookPayload( + 'droid', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Execute', + tool_input: { + command: 'echo "test modification" >> ~/.claude/config.json', + riskLevel: 'high', + riskLevelReason: "This command modifies the user's Claude Code config file." + } + }), + 'production' + ) + + expect(result?.payload.state).toBe('waiting') + expect(result?.payload.toolName).toBe('Execute') + expect(result?.payload.toolInput).toBe('echo "test modification" >> ~/.claude/config.json') + }) + + it('PermissionRequest maps low-impact Edit approvals to waiting and carries cached tool', () => { + _internals.normalizeHookPayload( + 'droid', + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'edit it to none' }), + 'production' + ) + _internals.normalizeHookPayload( + 'droid', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Edit', + tool_input: { + file_path: '/Users/thebr/.claude/settings.json', + old_str: '"preferredNotifChannel": "terminal_bell"', + new_str: '"preferredNotifChannel": "none"' + } + }), + 'production' + ) + + const result = _internals.normalizeHookPayload( + 'droid', + buildBody({ hook_event_name: 'PermissionRequest' }), + 'production' + ) + + expect(result?.payload.state).toBe('waiting') + expect(result?.payload.prompt).toBe('edit it to none') + expect(result?.payload.toolName).toBe('Edit') + expect(result?.payload.toolInput).toBe('/Users/thebr/.claude/settings.json') + }) + + it('SessionStart resets turn caches without marking Droid working', () => { + _internals.normalizeHookPayload( + 'droid', + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'old prompt' }), + 'production' + ) + _internals.normalizeHookPayload( + 'droid', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Read', + tool_input: { file_path: '/tmp/old.ts' } + }), + 'production' + ) + + const sessionStart = _internals.normalizeHookPayload( + 'droid', + buildBody({ hook_event_name: 'SessionStart' }), + 'production' + ) + expect(sessionStart).toBeNull() + + const nextTool = _internals.normalizeHookPayload( + 'droid', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Execute', + tool_input: { command: 'pwd' } + }), + 'production' + ) + expect(nextTool?.payload.state).toBe('working') + expect(nextTool?.payload.prompt).toBe('') + expect(nextTool?.payload.toolName).toBe('Execute') + expect(nextTool?.payload.toolInput).toBe('pwd') + }) + + it('SubagentStop does not close the primary session row', () => { + const result = _internals.normalizeHookPayload( + 'droid', + buildBody({ hook_event_name: 'SubagentStop' }), + 'production' + ) + expect(result).toBeNull() + }) + + it('Stop maps to done and preserves the cached prompt', () => { + _internals.normalizeHookPayload( + 'droid', + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'write tests' }), + 'production' + ) + const stop = _internals.normalizeHookPayload( + 'droid', + buildBody({ hook_event_name: 'Stop' }), + 'production' + ) + expect(stop?.payload.state).toBe('done') + expect(stop?.payload.prompt).toBe('write tests') + }) +}) + +describe('Pi hook normalization', () => { + it('before_agent_start maps to working and captures the prompt', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'before_agent_start', prompt: 'rename this fn' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.agentType).toBe('pi') + expect(result?.payload.prompt).toBe('rename this fn') + }) + + it('OMP uses Pi-compatible events but keeps OMP agent attribution', () => { + const started = _internals.normalizeHookPayload( + 'omp', + buildBody({ hook_event_name: 'before_agent_start', prompt: 'status for omp' }), + 'production' + ) + expect(started?.payload).toMatchObject({ + state: 'working', + prompt: 'status for omp', + agentType: 'omp' + }) + + const done = _internals.normalizeHookPayload( + 'omp', + buildBody({ hook_event_name: 'agent_end' }), + 'production' + ) + expect(done?.payload).toMatchObject({ + state: 'done', + prompt: 'status for omp', + agentType: 'omp' + }) + }) + + it('agent_start without a prompt keeps the cached prompt from the current turn', () => { + _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'before_agent_start', prompt: 'first prompt' }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'agent_start' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.prompt).toBe('first prompt') + }) + + it('before_agent_start clears the previous turn’s tool cache', () => { + _internals.normalizeHookPayload( + 'pi', + buildBody({ + hook_event_name: 'tool_call', + tool_name: 'bash', + tool_input: { command: 'ls' } + }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'before_agent_start', prompt: 'next' }), + 'production' + ) + expect(result?.payload.toolName).toBeUndefined() + expect(result?.payload.toolInput).toBeUndefined() + }) + + it('tool_call surfaces tool_name + tool_input preview', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ + hook_event_name: 'tool_call', + tool_name: 'bash', + tool_input: { command: 'pnpm test' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('bash') + expect(result?.payload.toolInput).toBe('pnpm test') + }) + + it('tool_execution_start also populates the tool preview', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ + hook_event_name: 'tool_execution_start', + tool_name: 'read', + tool_input: { path: 'src/main/index.ts' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('read') + expect(result?.payload.toolInput).toBe('src/main/index.ts') + }) + + it('message_end (assistant) stays in working but captures lastAssistantMessage', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ + hook_event_name: 'message_end', + role: 'assistant', + text: 'Done — I refactored the helper.' + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.lastAssistantMessage).toBe('Done — I refactored the helper.') + }) + + it('message_end (user) is ignored', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'message_end', role: 'user', text: 'hi' }), + 'production' + ) + // Why: pi captures the user prompt via before_agent_start, so a user-role message_end must not flip lastAssistantMessage. + expect(result?.payload.lastAssistantMessage).toBeUndefined() + }) + + it('agent_end maps to done', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'agent_end' }), + 'production' + ) + expect(result?.payload.state).toBe('done') + expect(result?.payload.agentType).toBe('pi') + }) + + it('session_shutdown leaves a running Pi status intact', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'session_shutdown' }), + 'production' + ) + // Why: Pi emits shutdown on reload/replace while the PTY stays alive; only agent_end proves turn completion. + expect(result).toBeNull() + }) + + it('done preserves the cached lastAssistantMessage from a prior message_end', () => { + _internals.normalizeHookPayload( + 'pi', + buildBody({ + hook_event_name: 'message_end', + role: 'assistant', + text: 'final reply' + }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'agent_end' }), + 'production' + ) + expect(result?.payload.lastAssistantMessage).toBe('final reply') + }) + + it('unknown event names are dropped', () => { + const result = _internals.normalizeHookPayload( + 'pi', + buildBody({ hook_event_name: 'never_heard_of_it' }), + 'production' + ) + expect(result).toBeNull() + }) +}) + +describe('Copilot hook normalization', () => { + it('UserPromptSubmit maps to working and captures the prompt', () => { + const result = _internals.normalizeHookPayload( + 'copilot', + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'add a migration' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.agentType).toBe('copilot') + expect(result?.payload.prompt).toBe('add a migration') + }) + + it('accepts camelCase Copilot event names from older hook configs', () => { + const result = _internals.normalizeHookPayload( + 'copilot', + buildBody({ hook_event_name: 'userPromptSubmitted', prompt: 'camel event' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.prompt).toBe('camel event') + }) + + it('infers Copilot user prompt payloads that omit hook_event_name', () => { + const result = _internals.normalizeHookPayload( + 'copilot', + buildBody({ prompt: 'raw prompt payload' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.prompt).toBe('raw prompt payload') + }) + + it('captures initialPrompt from Copilot sessionStart payloads', () => { + const result = _internals.normalizeHookPayload( + 'copilot', + buildBody({ initialPrompt: 'first prompt' }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.prompt).toBe('first prompt') + }) + + it('PreToolUse stays working and surfaces tool context', () => { + const result = _internals.normalizeHookPayload( + 'copilot', + buildBody({ + hook_event_name: 'PreToolUse', + toolName: 'bash', + toolInput: { command: 'pnpm test' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('bash') + expect(result?.payload.toolInput).toBe('pnpm test') + }) + + it('PostToolUseFailure surfaces the error and clears stale tool fields', () => { + _internals.normalizeHookPayload( + 'copilot', + buildBody({ + hook_event_name: 'PreToolUse', + toolName: 'bash', + toolInput: { command: 'pnpm test' } + }), + 'production' + ) + const failed = _internals.normalizeHookPayload( + 'copilot', + buildBody({ + hook_event_name: 'PostToolUseFailure', + toolName: 'bash', + toolInput: { command: 'pnpm test' }, + error_message: 'command not found' + }), + 'production' + ) + // Why: keeping toolName would let the compact sidebar show the tool instead of the failure text, hiding the error. + expect(failed?.payload).toMatchObject({ + state: 'working', + lastAssistantMessage: 'command not found' + }) + expect(failed?.payload.toolName).toBeUndefined() + expect(failed?.payload.toolInput).toBeUndefined() + }) + + it('PreToolUse ask_user maps to blocked and surfaces the question', () => { + _internals.normalizeHookPayload( + 'copilot', + buildBody({ prompt: 'ask me a question' }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'copilot', + buildBody({ + toolCalls: [ + { + name: 'ask_user', + args: JSON.stringify({ question: 'Which deployment target should I use?' }) + } + ] + }), + 'production' + ) + expect(result?.payload.state).toBe('blocked') + expect(result?.payload.prompt).toBe('ask me a question') + expect(result?.payload.toolName).toBe('ask_user') + expect(result?.payload.toolInput).toBe('Which deployment target should I use?') + expect(result?.payload.lastAssistantMessage).toBe('Which deployment target should I use?') + }) + + it('PermissionRequest stays working and preserves tool context', () => { + const result = _internals.normalizeHookPayload( + 'copilot', + buildBody({ + hook_event_name: 'PermissionRequest', + tool_name: 'bash', + tool_input: { command: 'rm -rf /tmp/orca-test' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.toolName).toBe('bash') + expect(result?.payload.toolInput).toBe('rm -rf /tmp/orca-test') + }) + + it('surfaces lowercase Copilot file tool input previews', () => { + const result = _internals.normalizeHookPayload( + 'copilot', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'edit', + tool_input: { path: '/repo/src/app.ts' } + }), + 'production' + ) + expect(result?.payload.toolName).toBe('edit') + expect(result?.payload.toolInput).toBe('/repo/src/app.ts') + }) + + it('Notification(permission_prompt) maps to blocked and surfaces message text', () => { + const result = _internals.normalizeHookPayload( + 'copilot', + buildBody({ + hook_event_name: 'Notification', + notification_type: 'permission_prompt', + title: 'Approval needed', + message: 'Allow Bash to run?' + }), + 'production' + ) + expect(result?.payload.state).toBe('blocked') + expect(result?.payload.lastAssistantMessage).toBe('Allow Bash to run?') + }) + + it('Notification(elicitation_dialog) preserves the cached prompt', () => { + _internals.normalizeHookPayload( + 'copilot', + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'deploy the app' }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'copilot', + buildBody({ + hook_event_name: 'Notification', + notification_type: 'elicitation_dialog', + message: 'Which environment?' + }), + 'production' + ) + expect(result?.payload.state).toBe('blocked') + expect(result?.payload.prompt).toBe('deploy the app') + expect(result?.payload.lastAssistantMessage).toBe('Which environment?') + expect(result?.hasExplicitPrompt).toBe(false) + }) + + it('Notification(elicitation_dialog) accepts camelCase type and surfaces the question', () => { + const result = _internals.normalizeHookPayload( + 'copilot', + buildBody({ + hook_event_name: 'Notification', + notificationType: 'elicitation_dialog', + message: 'Which deployment target should I use?' + }), + 'production' + ) + expect(result?.payload.state).toBe('blocked') + expect(result?.payload.lastAssistantMessage).toBe('Which deployment target should I use?') + }) + + it('later progress clears a prior blocked state for the same pane', () => { + _internals.normalizeHookPayload( + 'copilot', + buildBody({ + hook_event_name: 'PermissionRequest', + tool_name: 'bash', + tool_input: { command: 'pnpm build' } + }), + 'production' + ) + const result = _internals.normalizeHookPayload( + 'copilot', + buildBody({ + hook_event_name: 'PostToolUse', + tool_name: 'bash', + tool_input: { command: 'pnpm build' }, + tool_result: { text_result_for_llm: 'build passed' } + }), + 'production' + ) + expect(result?.payload.state).toBe('working') + expect(result?.payload.lastAssistantMessage).toBe('build passed') + }) + + it('Stop reads the final assistant message from Copilot transcript events', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-copilot-transcript-')) + const transcriptPath = join(tmpDir, 'events.jsonl') + try { + const lines = [ + { + type: 'assistant.message', + data: { + content: '', + toolRequests: [{ name: 'bash', arguments: { command: 'pnpm test' } }] + } + }, + { + type: 'assistant.message', + data: { content: 'Done - tests pass now.', toolRequests: [] } + } + ] + writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`) + + const result = _internals.normalizeHookPayload( + 'copilot', + buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }), + 'production' + ) + + expect(result?.payload.state).toBe('done') + expect(result?.payload.lastAssistantMessage).toBe('Done - tests pass now.') + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('unknown event name returns null', () => { + const result = _internals.normalizeHookPayload( + 'copilot', + buildBody({ hook_event_name: 'somethingElse' }), + 'production' + ) + expect(result).toBeNull() + }) + + it('accepts authenticated HTTP posts on /hook/copilot', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const listener = vi.fn() + server.setListener(listener) + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/copilot`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody({ hook_event_name: 'Notification', notificationType: 'permission_prompt' }) + ) + }) + + expect(response.status).toBe(204) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + payload: expect.objectContaining({ state: 'blocked', agentType: 'copilot' }) + }) + ) + } finally { + server.stop() + } + }) + + it('updates Copilot Stop with final transcript text after a non-blocking retry', async () => { + const server = new AgentHookServer() + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-copilot-transcript-retry-')) + const transcriptPath = join(tmpDir, 'events.jsonl') + writeFileSync(transcriptPath, '') + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const listener = vi.fn() + server.setListener(listener) + + await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/copilot`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody({ + hook_event_name: 'PostToolUse', + tool_result: { text_result_for_llm: 'stale tool output' } + }) + ) + }) + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/copilot`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody({ hook_event_name: 'Stop', transcript_path: transcriptPath }) + ) + }) + + expect(response.status).toBe(204) + await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/copilot`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody({ hook_event_name: 'SessionEnd', reason: 'complete' })) + }) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + state: 'done', + lastAssistantMessage: undefined + }) + }) + ) + + // Let the first 50ms retry miss so continuation across SessionEnd is proven. + await new Promise((resolve) => setTimeout(resolve, 70)) + writeFileSync( + transcriptPath, + `${JSON.stringify({ + type: 'assistant.message', + data: { content: 'Done after transcript flush.' } + })}\n` + ) + await new Promise((resolve) => setTimeout(resolve, 120)) + + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + state: 'done', + lastAssistantMessage: 'Done after transcript flush.' + }) + }) + ) + } finally { + server.stop() + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('updates Grok Stop with final chat-history text after a non-blocking retry', async () => { + const server = new AgentHookServer() + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-grok-chat-history-retry-')) + const sessionId = '019e37f4-5135-7b63-a4ab-6d13aa6bf528' + const cwd = join(tmpDir, 'workspace') + const sessionDir = join(tmpDir, '.grok', 'sessions', encodeURIComponent(cwd), sessionId) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync(join(sessionDir, 'chat_history.jsonl'), '') + vi.stubEnv('HOME', tmpDir) + vi.stubEnv('USERPROFILE', tmpDir) + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const listener = vi.fn() + server.setListener(listener) + + await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/grok`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody({ hookEventName: 'user_prompt_submit', prompt: 'hihi' })) + }) + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/grok`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody({ hookEventName: 'Stop', sessionId, cwd })) + }) + + expect(response.status).toBe(204) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + state: 'done', + lastAssistantMessage: undefined + }) + }) + ) + + writeFileSync( + join(sessionDir, 'chat_history.jsonl'), + `${JSON.stringify({ type: 'assistant', content: 'Hi! How can I help you today?' })}\n` + ) + await new Promise((resolve) => setTimeout(resolve, 120)) + + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + state: 'done', + lastAssistantMessage: 'Hi! How can I help you today?' + }) + }) + ) + } finally { + server.stop() + vi.unstubAllEnvs() + rmSync(tmpDir, { recursive: true, force: true }) + } + }) +}) + +describe('Endpoint file lifecycle', () => { + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-endpoint-')) + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('writes the endpoint file with the expected shell-sourceable shape', async () => { + const server = new AgentHookServer() + await server.start({ env: 'development', userDataPath }) + try { + const filePath = server.endpointFilePath + expect(filePath).toBeTruthy() + expect(existsSync(filePath!)).toBe(true) + const contents = readFileSync(filePath!, 'utf8') + const expectedPort = server.buildPtyEnv().ORCA_AGENT_HOOK_PORT + const expectedToken = server.buildPtyEnv().ORCA_AGENT_HOOK_TOKEN + const prefix = process.platform === 'win32' ? 'set ' : '' + expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_PORT=${expectedPort}`) + expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_TOKEN=${expectedToken}`) + expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_ENV=development`) + expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_VERSION=1`) + } finally { + server.stop() + } + }) + + it('writes the endpoint file with owner-only permissions on POSIX', async () => { + if (process.platform === 'win32') { + return + } + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const filePath = server.endpointFilePath! + // Why: mask to the rwx octet so we assert only the file's mode:0o600, not umask-leaked bits on the parent dir. + const mode = statSync(filePath).mode & 0o777 + expect(mode).toBe(0o600) + } finally { + server.stop() + } + }) + + it('rewrites the endpoint file with a new port after restart on the same path', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + const firstPath = server.endpointFilePath + const firstToken = server.buildPtyEnv().ORCA_AGENT_HOOK_TOKEN + server.stop() + + await server.start({ env: 'production', userDataPath }) + try { + const secondPath = server.endpointFilePath + const secondPort = server.buildPtyEnv().ORCA_AGENT_HOOK_PORT + const secondToken = server.buildPtyEnv().ORCA_AGENT_HOOK_TOKEN + // Path is stable (so PTYs stamped before restart can still find the file) + expect(secondPath).toBe(firstPath) + // Contents refresh with a new token so stale-env survivors reach the live server. + expect(secondToken).toBeTruthy() + expect(secondToken).not.toBe(firstToken) + const contents = readFileSync(secondPath!, 'utf8') + // Why: assert on token (randomUUID, can't collide), not port — listen(0) may legitimately reuse the ephemeral port and flake a port check. + expect(contents).toContain(`ORCA_AGENT_HOOK_PORT=${secondPort}`) + expect(contents).toContain(`ORCA_AGENT_HOOK_TOKEN=${secondToken}`) + expect(contents).not.toContain(`ORCA_AGENT_HOOK_TOKEN=${firstToken}`) + } finally { + server.stop() + } + }) + + it('leaves the endpoint file in place on stop()', async () => { + // Why: stop() leaves the file (stale = fail-open); unlinking would race a concurrent Orca instance rewriting it between token-check and unlink (TOCTOU). + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + const filePath = server.endpointFilePath! + expect(existsSync(filePath)).toBe(true) + server.stop() + expect(existsSync(filePath)).toBe(true) + }) + + it('buildPtyEnv includes ORCA_AGENT_HOOK_ENDPOINT when the server is running', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const env = server.buildPtyEnv() + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBe(server.endpointFilePath) + } finally { + server.stop() + } + }) + + it('buildPtyEnv includes namespaced ORCA_AGENT_HOOK_ENDPOINT for development servers', async () => { + const server = new AgentHookServer() + await server.start({ + env: 'development', + userDataPath, + endpointNamespace: 'com.stablyai.orca.dev.test123' + }) + try { + const env = server.buildPtyEnv() + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBe(server.endpointFilePath) + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toContain('com.stablyai.orca.dev.test123') + expect(env.ORCA_AGENT_HOOK_PORT).toBeTruthy() + expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy() + } finally { + server.stop() + } + }) + + it('keeps endpoint files separate for parallel dev namespaces', async () => { + const firstServer = new AgentHookServer() + const secondServer = new AgentHookServer() + await firstServer.start({ env: 'development', userDataPath, endpointNamespace: 'dev-a' }) + await secondServer.start({ env: 'development', userDataPath, endpointNamespace: 'dev-b' }) + try { + expect(firstServer.endpointFilePath).not.toBe(secondServer.endpointFilePath) + expect(firstServer.buildPtyEnv().ORCA_AGENT_HOOK_ENDPOINT).toBe(firstServer.endpointFilePath) + expect(secondServer.buildPtyEnv().ORCA_AGENT_HOOK_ENDPOINT).toBe( + secondServer.endpointFilePath + ) + expect(existsSync(firstServer.endpointFilePath!)).toBe(true) + expect(existsSync(secondServer.endpointFilePath!)).toBe(true) + } finally { + firstServer.stop() + secondServer.stop() + } + }) + + it('buildPtyEnv omits ORCA_AGENT_HOOK_ENDPOINT when no userDataPath was provided', async () => { + // Why: the endpoint file is opt-in via userDataPath; without it, hooks fall back to v1 behavior (no ENDPOINT key). + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_PORT).toBeTruthy() + expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy() + } finally { + server.stop() + } + }) + + it('buildPtyEnv returns empty when the server is not running', () => { + const server = new AgentHookServer() + expect(server.buildPtyEnv()).toEqual({}) + }) + + it('sweeps stale .endpoint-*.tmp orphans older than 5 minutes on start', async () => { + // Why: a crash between tmp-write and rename orphans a tmp; sweep must drop stale ones (>5min) but spare a concurrent writer's fresh in-flight tmp. + const dir = join(userDataPath, 'agent-hooks') + mkdirSync(dir, { recursive: true }) + const staleTmp = join(dir, '.endpoint-999-stale.tmp') + const freshTmp = join(dir, '.endpoint-999-fresh.tmp') + writeFileSync(staleTmp, 'stale') + writeFileSync(freshTmp, 'fresh') + const sixMinAgo = (Date.now() - 6 * 60 * 1000) / 1000 + utimesSync(staleTmp, sixMinAgo, sixMinAgo) + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + expect(existsSync(staleTmp)).toBe(false) + expect(existsSync(freshTmp)).toBe(true) + } finally { + server.stop() + } + }) + + it('refuses to write the endpoint file when a value contains shell metacharacters', async () => { + // Why: written values are sourced as shell, so isShellSafeEndpointValue must reject metacharacters to prevent command injection. + const server = new AgentHookServer() + await server.start({ env: 'bad;value', userDataPath }) + try { + expect(existsSync(server.endpointFilePath!)).toBe(false) + expect(server.buildPtyEnv().ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() + // PORT/TOKEN still flow via PTY env — fail-open to v1 behavior. + expect(server.buildPtyEnv().ORCA_AGENT_HOOK_PORT).toBeTruthy() + expect(server.buildPtyEnv().ORCA_AGENT_HOOK_TOKEN).toBeTruthy() + } finally { + server.stop() + } + }) + + it('ingestRemote stamps connectionId and feeds the listener bypassing HTTP', () => { + const server = new AgentHookServer() + const events: { paneKey: string; connectionId: string | null; payload: unknown }[] = [] + server.setListener((evt) => { + events.push({ + paneKey: evt.paneKey, + connectionId: evt.connectionId, + payload: evt.payload + }) + }) + try { + const remotePane = makePaneKey('tab-3', LEAF_3) + server.ingestRemote( + { + paneKey: remotePane, + tabId: 'tab-3', + worktreeId: 'wt-3', + payload: { + state: 'working', + prompt: 'remote prompt', + agentType: 'claude' + } + }, + 'conn-42' + ) + expect(events).toHaveLength(1) + expect(events[0].paneKey).toBe(remotePane) + expect(events[0].connectionId).toBe('conn-42') + expect(events[0].payload).toMatchObject({ + state: 'working', + prompt: 'remote prompt', + agentType: 'claude' + }) + } finally { + server.setListener(null) + } + }) + + it('ingestRemote ignores malformed envelopes (fail-open)', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + try { + // Missing paneKey + server.ingestRemote({ paneKey: '', payload: { state: 'working' } } as never, 'conn-x') + // Missing payload state + server.ingestRemote({ paneKey: 'tab-1:0', payload: { foo: 'bar' } }, 'conn-x') + // Invalid payload state + server.ingestRemote({ paneKey: 'tab-1:0', payload: { state: 'nonsense' } }, 'conn-x') + // Empty connection id + server.ingestRemote({ paneKey: 'tab-1:0', payload: { state: 'working' } }, ' ') + // Wrong types + server.ingestRemote( + { paneKey: 'tab-1:0', payload: 'not-an-object' as unknown } as never, + 'conn-x' + ) + expect(listener).not.toHaveBeenCalled() + } finally { + server.setListener(null) + } + }) + + it('endpoint file contents are re-parseable by /bin/sh', async () => { + if (process.platform === 'win32') { + return + } + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const filePath = server.endpointFilePath! + const expectedPort = server.buildPtyEnv().ORCA_AGENT_HOOK_PORT + // Why: source the file exactly as the managed hook script does, catching drift from the KEY=VALUE shape before users do. + const out = execFileSync('/bin/sh', ['-c', `. "${filePath}" && echo "$ORCA_AGENT_HOOK_PORT"`]) + .toString() + .trim() + expect(out).toBe(expectedPort) + } finally { + server.stop() + } + }) +}) + +describe('Last-status persistence', () => { + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-laststatus-')) + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + function lastStatusPath(): string { + return join(userDataPath, 'agent-hooks', 'last-status.json') + } + + // Why: use a recent-but-not-now timestamp so fixtures survive hydrate's 7d drop (HYDRATE_MAX_AGE_MS) without racing the wall clock. + function recentTs(offsetMs = 0): number { + return Date.now() - 60 * 60 * 1000 + offsetMs + } + + async function postHookEvent( + server: AgentHookServer, + body: Body, + path: string = '/hook/claude' + ): Promise { + const env = server.buildPtyEnv() + return fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(body) + }) + } + + it('writes last-status.json after a hook event', async () => { + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + await postHookEvent( + server, + buildBody( + { hook_event_name: 'UserPromptSubmit', prompt: 'persist me' }, + { launchToken: 'launch-bearer-must-not-persist' } + ) + ) + await postHookEvent( + server, + buildBody( + { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }, + { launchToken: 'launch-bearer-must-not-persist' } + ) + ) + // Synchronous flush via stop() captures the trailing-debounced write. + server.flushStatusPersistSync() + expect(existsSync(lastStatusPath())).toBe(true) + const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(file.version).toBe(2) + expect(file.entries[PANE]).toMatchObject({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt: expect.any(Number), + stateStartedAt: expect.any(Number), + payload: expect.objectContaining({ + state: 'working', + workingMode: 'monitoring', + prompt: 'persist me' + }) + }) + expect(file.entries[PANE].launchToken).toBeUndefined() + expect(file.entries[PANE].launchTokenHash).toBe( + createHash('sha256').update('launch-bearer-must-not-persist').digest('hex') + ) + expect(file.entries[PANE].claudeRunningNonAgentTask).toBeUndefined() + expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('claudeRunningNonAgentTask') + expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('launch-bearer-must-not-persist') + } finally { + server.stop() + } + }) + + it('scrubs a legacy persisted launch bearer while retaining its authority commitment', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + const receivedAt = recentTs() + const launchToken = 'legacy-launch-bearer' + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + [PANE]: { + paneKey: PANE, + launchToken, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + receivedAt, + stateStartedAt: receivedAt, + payload: { + state: 'working', + prompt: 'legacy worker', + agentType: 'codex' + } + } + } + }), + 'utf8' + ) + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const launchTokenHash = createHash('sha256').update(launchToken).digest('hex') + expect(server.getHydratedAuthorityCommitments()).toEqual([ + expect.objectContaining({ paneKey: PANE, launchTokenHash }) + ]) + expect(server.getStatusSnapshotForPane(PANE)[0]?.launchToken).toBeUndefined() + + const persisted = readFileSync(lastStatusPath(), 'utf8') + expect(persisted).not.toContain(launchToken) + expect(JSON.parse(persisted).entries[PANE]).toMatchObject({ launchTokenHash }) + } finally { + server.stop() + } + + const restartedServer = new AgentHookServer() + await restartedServer.start({ env: 'production', userDataPath }) + try { + expect(restartedServer.getHydratedAuthorityCommitments()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + launchTokenHash: createHash('sha256').update(launchToken).digest('hex') + }) + ]) + expect(restartedServer.getStatusSnapshotForPane(PANE)[0]?.launchToken).toBeUndefined() + } finally { + restartedServer.stop() + } + }) + + it('persists SSH authority through transient clears until explicit retirement', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + const receivedAt = recentTs() + const launchToken = 'retained-ssh-launch-bearer' + const launchTokenHash = createHash('sha256').update(launchToken).digest('hex') + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + [PANE]: { + paneKey: PANE, + launchTokenHash, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'ssh-target', + receivedAt, + stateStartedAt: receivedAt, + payload: { + state: 'working', + prompt: 'retained SSH worker', + agentType: 'codex' + } + } + } + }), + 'utf8' + ) + + const first = new AgentHookServer() + await first.start({ env: 'production', userDataPath }) + first.clearStatusEntriesForConnection('ssh-target') + first.flushStatusPersistSync() + first.stop() + + const afterClear = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(afterClear.entries).toEqual({}) + expect(afterClear.authorityCommitments[PANE]).toMatchObject({ + paneKey: PANE, + launchTokenHash, + connectionId: 'ssh-target' + }) + + const restored = new AgentHookServer() + await restored.start({ env: 'production', userDataPath }) + expect( + restored.attestCompatibilityAuthority({ + paneKey: PANE, + launchTokenHash, + connectionId: 'ssh-target', + terminalProvenance: 'restored' + }) + ).toEqual({ paneKey: PANE, source: 'hydrated_commitment' }) + restored.retirePaneAuthority(PANE) + restored.flushStatusPersistSync() + restored.stop() + + const retired = new AgentHookServer() + await retired.start({ env: 'production', userDataPath }) + try { + expect( + retired.attestCompatibilityAuthority({ + paneKey: PANE, + launchTokenHash, + connectionId: 'ssh-target', + terminalProvenance: 'restored' + }) + ).toBeNull() + } finally { + retired.stop() + } + }) + + it('persists and hydrates Pi session identity without creating status telemetry', async () => { + const firstServer = new AgentHookServer() + const firstRendererListener = vi.fn() + const statusChangeListener = vi.fn() + firstServer.setListener(firstRendererListener) + firstServer.subscribeStatusChanges(statusChangeListener) + await firstServer.start({ env: 'production', userDataPath }) + try { + const response = await postHookEvent( + firstServer, + buildBody({ + hook_event_name: 'session_start', + session_id: 'pi-session-1', + session_file: '/tmp/pi-session-1.jsonl' + }), + '/hook/pi' + ) + expect(response.status).toBe(204) + expect(firstRendererListener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + providerSessionOnly: true, + providerSession: { + key: 'session_id', + id: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.jsonl' + } + }) + ) + expect(statusChangeListener).toHaveBeenCalledWith([]) + expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) + + firstServer.flushStatusPersistSync() + const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(file.entries[PANE]).toMatchObject({ + providerSessionOnly: true, + providerSession: { + key: 'session_id', + id: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.jsonl' + } + }) + } finally { + firstServer.stop() + } + + const hydratedServer = new AgentHookServer() + await hydratedServer.start({ env: 'production', userDataPath }) + try { + const hydratedListener = vi.fn() + hydratedServer.setListener(hydratedListener) + expect(hydratedListener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + providerSessionOnly: true, + providerSession: expect.objectContaining({ transcriptPath: '/tmp/pi-session-1.jsonl' }), + isReplay: true + }) + ) + expect(hydratedServer.getStatusChangeSnapshot()).toEqual([]) + } finally { + hydratedServer.stop() + } + }) + + it('does not write prompt interaction keys to last-status.json', async () => { + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + await postHookEvent( + server, + buildBody({ + hook_event_name: 'MessagePart', + role: 'user', + text: 'persist status only', + messageID: 'opencode-local-message-id' + }), + '/hook/opencode' + ) + server.flushStatusPersistSync() + const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(file.entries[PANE].payload.prompt).toBe('persist status only') + expect(file.entries[PANE].promptInteractionKey).toBeUndefined() + } finally { + server.stop() + } + }) + + it('hydrates last-status.json into the cache before listener registration', async () => { + // Pre-populate the file directly to simulate a prior session. + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + const receivedAt = recentTs() + const stateStartedAt = recentTs(-1000) + const fileContents = { + version: 2, + entries: { + [PANE]: { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt, + stateStartedAt, + payload: { + state: 'done', + prompt: 'survived restart', + agentType: 'claude' + } + } + } + } + writeFileSync(lastStatusPath(), JSON.stringify(fileContents), 'utf8') + + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + const listener = vi.fn() + server.setListener(listener) + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt, + stateStartedAt, + payload: expect.objectContaining({ + state: 'done', + prompt: 'survived restart', + agentType: 'claude' + }) + }) + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt, + stateStartedAt, + state: 'done', + prompt: 'survived restart', + agentType: 'claude' + }) + ]) + } finally { + server.stop() + } + }) + + it('restores hydrated nonterminal statuses as unconfirmed until a live event lands', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'may finish offline', agentType: 'claude' } + }, + 'conn-1' + ) + firstServer.ingestRemote( + { + paneKey: GOOD_PANE, + tabId: 'tab-good', + worktreeId: 'wt-1', + payload: { state: 'done', prompt: 'finished before restart', agentType: 'claude' } + }, + 'conn-1' + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const listener = vi.fn() + server.setListener(listener) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: PANE, restoredUnconfirmed: true }) + ) + const byPane = new Map(server.getStatusSnapshot().map((entry) => [entry.paneKey, entry])) + expect(byPane.get(PANE)).toMatchObject({ state: 'working', restoredUnconfirmed: true }) + // Why: `done` is a terminal fact — no later transition can have been missed, so it restores confirmed. + expect(byPane.get(GOOD_PANE)?.restoredUnconfirmed).toBeUndefined() + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'done', prompt: 'confirmed live', agentType: 'codex' } + }, + 'conn-1' + ) + const confirmed = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE) + expect(confirmed).toMatchObject({ + state: 'done', + prompt: 'confirmed live', + agentType: 'codex' + }) + expect(confirmed?.restoredUnconfirmed).toBeUndefined() + } finally { + server.stop() + } + }) + + it('lets an identical live OSC observation confirm a hydrated row', async () => { + const payload = { + state: 'working' as const, + prompt: 'still running', + agentType: 'claude' as const + } + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + payload + }) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const restored = server.getStatusSnapshot()[0] + if (!restored) { + throw new Error('expected hydrated status') + } + expect(restored?.restoredUnconfirmed).toBe(true) + vi.spyOn(Date, 'now').mockReturnValue(restored.receivedAt - 1_000) + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + payload + }) + const confirmed = server.getStatusSnapshot()[0] + expect(confirmed?.restoredUnconfirmed).toBeUndefined() + expect(confirmed?.receivedAt).toBe(restored.receivedAt + 1) + } finally { + server.stop() + } + }) + + it('does not let a hydrated Claude permission suppress the first live working event', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'PermissionRequest', + payload: { + state: 'waiting', + prompt: 'review command', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'dangerous command' + } + }, + 'conn-1' + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'PreToolUse', + payload: { + state: 'working', + prompt: 'review command', + agentType: 'claude', + toolName: 'Read', + toolInput: 'package.json' + } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + toolName: 'Read' + }) + expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined() + } finally { + server.stop() + } + }) + + it('does not inherit a stale tool id from hydrated Claude progress', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'PreToolUse', + toolUseId: 'tool-old', + toolAgentType: 'main', + payload: { + state: 'working', + prompt: 'run tests', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + } + }, + 'conn-1' + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'PermissionRequest', + toolAgentType: 'main', + payload: { + state: 'waiting', + prompt: 'run tests', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'PostToolUse', + toolUseId: 'tool-new', + toolAgentType: 'main', + payload: { + state: 'working', + prompt: 'run tests', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + } finally { + server.stop() + } + }) + + it('never persists the unconfirmed flag and re-stamps it on every hydrate', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'still running', agentType: 'claude' } + }, + 'conn-1' + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const secondServer = new AgentHookServer() + await secondServer.start({ env: 'production', userDataPath }) + secondServer.flushStatusPersistSync() + secondServer.stop() + expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('restoredUnconfirmed') + + const thirdServer = new AgentHookServer() + await thirdServer.start({ env: 'production', userDataPath }) + try { + expect(thirdServer.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'working', restoredUnconfirmed: true }) + ]) + } finally { + thirdServer.stop() + } + }) + + it('refuses interrupt inference on an unconfirmed hydrated row', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + firstServer.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType: 'codex' } + }, + 'conn-1' + ) + firstServer.flushStatusPersistSync() + firstServer.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const baseline = server.getStatusSnapshot()[0] + expect(baseline).toMatchObject({ paneKey: PANE, restoredUnconfirmed: true }) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'long task', + baselineAgentType: 'codex', + intent: 'plain-escape' + }) + // Why: synthesizing `done` onto a never-confirmed `working` would fabricate a transition from stale disk state. + expect(applied).toBe(false) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + expect(server.getStatusSnapshot()[0]?.interrupted).toBeUndefined() + } finally { + server.stop() + } + }) + + it('keeps SSH status ordering monotonic across hydration and clock rollback', async () => { + const now = 1_700_000_000_000 + vi.spyOn(Date, 'now').mockReturnValue(now) + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + const receivedAt = now + 1_000 + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + [PANE]: { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'ssh-a', + receivedAt, + stateStartedAt: receivedAt, + payload: { state: 'working', prompt: 'before restart', agentType: 'claude' } + } + } + }), + 'utf8' + ) + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const clearListener = vi.fn() + server.setPaneStatusClearListener(clearListener) + server.ingestRemote( + { paneKey: PANE, payload: { state: 'working', agentType: 'codex' } }, + 'ssh-a' + ) + + expect(server.getStatusSnapshot()[0]?.receivedAt).toBe(receivedAt + 1) + server.clearStatusEntriesForConnection('ssh-a') + const clearedAt = receivedAt + 2 + expect(clearListener).toHaveBeenCalledWith({ + transient: true, + connectionId: 'ssh-a', + clearedAt + }) + server.ingestRemote( + { + paneKey: GOOD_PANE, + isReplay: true, + payload: { state: 'working', agentType: 'claude' } + }, + 'ssh-a' + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: GOOD_PANE, + connectionId: 'ssh-a', + receivedAt: clearedAt + 1 + }) + ]) + } finally { + server.stop() + } + }) + + it('drops persisted idle Claude child rows from hydration replay', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + const receivedAt = recentTs() + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + [PANE]: { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt, + stateStartedAt: recentTs(-1000), + payload: { + state: 'done', + prompt: 'finished orchestration', + agentType: 'claude', + subagents: [ + { id: 'aweb-research-8a76b7d7', state: 'idle', startedAt: receivedAt - 5000 }, + { id: 'apr-history-9b87c6e6', state: 'idle', startedAt: receivedAt - 4000 } + ] + } + } + } + }), + 'utf8' + ) + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const listener = vi.fn() + server.setListener(listener) + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ subagents: undefined }) + }) + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + prompt: 'finished orchestration', + subagents: undefined + }) + ]) + // Why: migration must be one-time, else every launch re-prunes the same persisted idle rows. + const persisted = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(persisted.entries[PANE].payload.subagents).toBeUndefined() + } finally { + server.stop() + } + }) + + it('restores Codex child hierarchy and reaps unconfirmed children on the next root Stop', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + const receivedAt = recentTs() + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + [PANE]: { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt, + stateStartedAt: recentTs(-1000), + payload: { + state: 'working', + prompt: 'coordinate reviews', + agentType: 'codex', + model: 'gpt-5.4', + subagents: [ + { + id: '11111111-2222-4333-8444-555555555555', + state: 'working', + startedAt: receivedAt - 5000, + agentType: 'reviewer', + model: 'gpt-5.4-mini' + } + ] + } + } + } + }), + 'utf8' + ) + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + model: 'gpt-5.4', + subagents: [ + expect.objectContaining({ + id: '11111111-2222-4333-8444-555555555555', + model: 'gpt-5.4-mini' + }) + ] + }) + ]) + + await postHookEvent( + server, + buildBody({ + hook_event_name: 'PreToolUse', + agent_id: '11111111-2222-4333-8444-555555555555', + agent_type: 'reviewer', + model: 'gpt-5.4-mini', + tool_name: 'exec_command', + tool_input: { cmd: 'pnpm test' } + }), + '/hook/codex' + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + model: 'gpt-5.4', + subagents: [expect.objectContaining({ model: 'gpt-5.4-mini' })] + }) + ]) + + await postHookEvent( + server, + buildBody({ hook_event_name: 'Stop', model: 'gpt-5.4' }), + '/hook/codex' + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'done', model: 'gpt-5.4', subagents: undefined }) + ]) + } finally { + server.stop() + } + }) + + it('treats a corrupt file as empty hydration without throwing', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync(lastStatusPath(), 'not-json{{', 'utf8') + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + const listener = vi.fn() + server.setListener(listener) + expect(listener).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalled() + } finally { + server.stop() + warnSpy.mockRestore() + } + }) + + it('drops hydrated metadata-only entries without a resumable Pi session', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + const receivedAt = recentTs() + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + [PANE]: { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt, + stateStartedAt: receivedAt, + providerSessionOnly: true, + providerSession: { key: 'session_id', id: 'pi-session-without-file' }, + payload: { state: 'done', prompt: '', agentType: 'pi' } + } + } + }), + 'utf8' + ) + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + expect(server.getStatusSnapshot()).toEqual([]) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('last-status hydrate dropped 1 entries') + ) + } finally { + server.stop() + warnSpy.mockRestore() + } + }) + + it('rejects a stale version mismatch on hydrate', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 1, + entries: { + [PANE]: { + paneKey: PANE, + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, + payload: { state: 'done', prompt: 'old version', agentType: 'claude' } + } + } + }), + 'utf8' + ) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + const listener = vi.fn() + server.setListener(listener) + expect(listener).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('version mismatch')) + } finally { + server.stop() + warnSpy.mockRestore() + } + }) + + it('drops entries with malformed paneKeys but keeps valid ones', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + // Missing colon — drop. + 'no-colon': { + paneKey: 'no-colon', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, + payload: { state: 'done', prompt: 'bad', agentType: 'claude' } + }, + // Embedded paneKey mismatch — drop. + [PANE]: { + paneKey: makePaneKey('tab-x', LEAF_2), + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, + payload: { state: 'done', prompt: 'mismatch', agentType: 'claude' } + }, + // Valid. + [GOOD_PANE]: { + paneKey: GOOD_PANE, + tabId: 'tab-good', + receivedAt: recentTs(), + stateStartedAt: recentTs(-1000), + payload: { state: 'done', prompt: 'survived', agentType: 'claude' } + } + } + }), + 'utf8' + ) + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + const listener = vi.fn() + server.setListener(listener) + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: GOOD_PANE, + payload: expect.objectContaining({ prompt: 'survived' }) + }) + ) + } finally { + server.stop() + } + }) + + it('drops hydrate entries older than the TTL cutoff', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + const eightDaysAgoMs = Date.now() - 8 * 24 * 60 * 60 * 1000 + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + // Stale — should be dropped. + [OLD_PANE]: { + paneKey: OLD_PANE, + tabId: 'tab-old', + receivedAt: eightDaysAgoMs, + stateStartedAt: eightDaysAgoMs - 1000, + payload: { state: 'done', prompt: 'old', agentType: 'claude' } + }, + // Recent — should survive. + [FRESH_PANE]: { + paneKey: FRESH_PANE, + tabId: 'tab-fresh', + receivedAt: recentTs(), + stateStartedAt: recentTs(-1000), + payload: { state: 'done', prompt: 'fresh', agentType: 'claude' } + } + } + }), + 'utf8' + ) + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + const snapshot = server.getStatusSnapshot() + expect(snapshot.map((e) => e.paneKey)).toEqual([FRESH_PANE]) + } finally { + server.stop() + } + }) + + it('hydrates registered legacy numeric pane keys as stable pane status entries', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + 'tab-1:0': { + paneKey: 'tab-1:0', + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + receivedAt: recentTs(), + stateStartedAt: recentTs(-1000), + payload: { state: 'working', prompt: 'legacy cached', agentType: 'claude' } + } + } + }), + 'utf8' + ) + const server = new AgentHookServer() + server.registerPaneKeyAlias('tab-1:0', PANE) + await server.start({ + env: 'production', + userDataPath + }) + try { + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + state: 'working', + prompt: 'legacy cached', + agentType: 'claude' + }) + ]) + } finally { + server.stop() + } + }) + + it('clears hydrated stable statuses when their persisted legacy alias PTY is cleared', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + 'tab-1:0': { + paneKey: 'tab-1:0', + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + receivedAt: recentTs(), + stateStartedAt: recentTs(-1000), + payload: { state: 'working', prompt: 'legacy cached', agentType: 'claude' } + } + } + }), + 'utf8' + ) + const server = new AgentHookServer() + const statusListener = vi.fn() + server.registerPaneKeyAlias('tab-1:0', PANE, 'pty-1') + server.subscribeStatusChanges(statusListener) + await server.start({ + env: 'production', + userDataPath + }) + try { + expect(server.getStatusSnapshot()).toHaveLength(1) + + server.clearPaneKeyAliasesForPty('pty-1') + + expect(server.getStatusSnapshot()).toEqual([]) + expect(statusListener).toHaveBeenCalledWith([]) + } finally { + server.stop() + } + }) + + it('does not clear a stable status when alias cleanup no longer owns that pane', () => { + const server = new AgentHookServer() + server.registerPaneKeyAlias('tab-1:0', PANE, 'old-pty') + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', agentType: 'claude' } + }, + 'conn-1' + ) + + server.clearPaneKeyAliasesForPty('old-pty', { shouldClearStablePaneKey: () => false }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + agentType: 'claude' + }) + ]) + }) + + it('drops a hydrate entry whose tabId disagrees with the paneKey prefix', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + [TAB_A_PANE]: { + paneKey: TAB_A_PANE, + // Why: paneKey says tab-A but entry claims tab-B; sanitizer must drop the inconsistent row, not hydrate it. + tabId: 'tab-B', + receivedAt: recentTs(), + stateStartedAt: recentTs(-1000), + payload: { state: 'done', prompt: 'mismatch', agentType: 'claude' } + } + } + }), + 'utf8' + ) + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + expect(server.getStatusSnapshot()).toEqual([]) + } finally { + server.stop() + } + }) + + it('clearPaneState evicts the entry from the on-disk file', async () => { + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'about to drop' }) + ) + server.flushStatusPersistSync() + let parsed = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(parsed.entries[PANE]).toBeTruthy() + + server.clearPaneState(PANE) + server.flushStatusPersistSync() + parsed = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(parsed.entries[PANE]).toBeUndefined() + } finally { + server.stop() + } + }) + + it('skips a write when the serialized contents are byte-identical to the previous write', async () => { + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'first' }) + ) + server.flushStatusPersistSync() + const firstMtime = statSync(lastStatusPath()).mtimeMs + + // Why: clearPaneState on a paneKey not in the cache must not trigger a redundant write (clear bails when nothing was evicted). + server.clearPaneState(makePaneKey('non-existent', LEAF_5)) + server.flushStatusPersistSync() + // Assert no rewrite happened: mtime unchanged after a forced sync flush. + const secondMtime = statSync(lastStatusPath()).mtimeMs + expect(secondMtime).toBe(firstMtime) + } finally { + server.stop() + } + }) + + it('stop() flushes pending debounced writes synchronously', async () => { + const server = new AgentHookServer() + await server.start({ + env: 'production', + userDataPath + }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'flush me' }) + ) + // Note: do NOT call flushStatusPersistSync explicitly — let stop() do it. + } finally { + server.stop() + } + // Why: stop() must synchronously drain the pending trailing-debounced timer even though we never explicitly flushed. + expect(existsSync(lastStatusPath())).toBe(true) + const parsed = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(parsed.entries[PANE]?.payload?.prompt).toBe('flush me') + }) +}) + +describe('AgentHookServer ingestRemote', () => { + it('caches and replays Pi session identity without exposing a turn-status change', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const rendererListener = vi.fn() + const statusChangeListener = vi.fn() + server.setListener(rendererListener) + server.subscribeStatusChanges(statusChangeListener) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + providerSession: { + key: 'session_id', + id: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.jsonl' + }, + providerSessionOnly: true, + payload: { state: 'done', prompt: '', agentType: 'pi' } + }, + 'conn-1' + ) + + expect(rendererListener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + connectionId: 'conn-1', + providerSessionOnly: true, + providerSession: { + key: 'session_id', + id: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.jsonl' + } + }) + ) + expect(statusChangeListener).toHaveBeenCalledWith([]) + expect(server.getStatusChangeSnapshot()).toEqual([]) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + providerSessionOnly: true, + providerSession: expect.objectContaining({ transcriptPath: '/tmp/pi-session-1.jsonl' }) + }) + ]) + expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) + + const replayListener = vi.fn() + server.setListener(replayListener) + expect(replayListener).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: PANE, providerSessionOnly: true, isReplay: true }) + ) + } finally { + vi.useRealTimers() + } + }) + + it('fans a Pi session-only status out to plugins, not just the renderer', () => { + const server = new AgentHookServer() + const rendererListener = vi.fn() + const pluginListener = vi.fn() + server.setListener(rendererListener) + server.subscribeEnrichedStatus(pluginListener) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + providerSession: { + key: 'session_id', + id: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.jsonl' + }, + providerSessionOnly: true, + payload: { state: 'done', prompt: '', agentType: 'pi' } + }, + 'conn-1' + ) + + // The session-only path returns early, so it must not skip the plugin tap. + expect(rendererListener).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: PANE, providerSessionOnly: true }) + ) + expect(pluginListener).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: PANE, providerSessionOnly: true }) + ) + }) + + it('rejects invalid remote metadata-only session envelopes', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + providerSessionOnly: true, + payload: { state: 'done', prompt: '', agentType: 'pi' } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + providerSessionOnly: true, + providerSession: { key: 'session_id', id: 'pi-session-1' }, + payload: { state: 'done', prompt: '', agentType: 'pi' } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + providerSessionOnly: true, + providerSession: { + key: 'session_id', + id: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.jsonl' + }, + payload: { state: 'done', prompt: '', agentType: 'claude' } + }, + 'conn-1' + ) + + expect(listener).not.toHaveBeenCalled() + expect(server.getStatusSnapshot()).toEqual([]) + }) + + it('stamps connectionId and forwards a valid relay envelope to the listener', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, 'conn-1') + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'conn-1', + receivedAt: expect.any(Number), + stateStartedAt: expect.any(Number), + payload + }) + ) + }) + + it('preserves active pane identity when a nested remote hook reports another agent', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'parent codex', agentType: 'codex' } + }, + 'conn-1' + ) + + vi.setSystemTime(1_100) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { + state: 'working', + prompt: 'nested claude', + agentType: 'claude', + toolName: 'Read', + toolInput: '00-review-context.md' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + prompt: 'nested claude', + agentType: 'codex', + toolName: 'Read', + toolInput: '00-review-context.md', + receivedAt: 1_100 + }) + ]) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + prompt: 'nested claude', + agentType: 'codex' + }) + }) + ) + expect(trackMock).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('ignores nested remote done while the parent pane agent is still active', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'parent codex', agentType: 'codex' } + }, + 'conn-1' + ) + + vi.setSystemTime(1_100) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hasExplicitPrompt: true, + payload: { + state: 'done', + prompt: 'nested claude', + agentType: 'claude', + toolName: 'Read', + toolInput: '00-review-context.md', + lastAssistantMessage: 'child finished' + } + }, + 'conn-1' + ) + + const snapshot = server.getStatusSnapshot() + expect(snapshot).toHaveLength(1) + expect(snapshot[0]).toMatchObject({ + paneKey: PANE, + state: 'working', + prompt: 'parent codex', + agentType: 'codex', + receivedAt: 1_000, + stateStartedAt: 1_000 + }) + expect(snapshot[0].toolName).toBeUndefined() + expect(snapshot[0].toolInput).toBeUndefined() + expect(snapshot[0].lastAssistantMessage).toBeUndefined() + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + state: 'working', + prompt: 'parent codex', + agentType: 'codex' + }) + }) + ) + expect(trackMock).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('allows remote pane identity to change after the prior turn is done', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'done', prompt: 'parent codex', agentType: 'codex' } + }, + 'conn-1' + ) + + vi.setSystemTime(1_100) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'real claude turn', agentType: 'claude' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'real claude turn', + agentType: 'claude', + receivedAt: 1_100 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('allows stale active remote pane identity to change', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'old codex turn', agentType: 'codex' } + }, + 'conn-1' + ) + + vi.setSystemTime(1_000 + AGENT_STATUS_STALE_AFTER_MS + 1) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'new claude turn', agentType: 'claude' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'new claude turn', + agentType: 'claude', + receivedAt: 1_000 + AGENT_STATUS_STALE_AFTER_MS + 1 + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('lets remote Claude permission clear when matching approved tool execution starts', () => { + const server = new AgentHookServer() + const waiting = parseAgentStatusPayload( + JSON.stringify({ + state: 'waiting', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + }) + ) + const working = parseAgentStatusPayload( + JSON.stringify({ + state: 'working', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + }) + ) + if (!waiting || !working) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'PermissionRequest', + toolAgentId: 'agent-subagent-a', + toolAgentType: 'Review', + payload: waiting + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'PreToolUse', + toolUseId: 'toolu-approved-remote', + toolAgentId: 'agent-subagent-a', + toolAgentType: 'Review', + payload: working + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + connectionId: 'conn-1', + state: 'working', + agentType: 'claude', + toolName: 'Bash', + toolInput: 'pnpm test' + }) + ]) + }) + + it('drops envelopes whose payload state is not in AGENT_STATUS_STATES', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + // Why: bypass parseAgentStatusPayload with an invalid payload — ingestRemote is the trust boundary under test, not the parser. + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'nonsense', prompt: '', agentType: 'claude' } + }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('drops envelopes whose paneKey exceeds MAX_PANE_KEY_LEN', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + // 201 chars — one past the listener's 200-char cap. + const oversized = 'a'.repeat(201) + server.ingestRemote( + { paneKey: oversized, tabId: 'tab-1', worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('drops remote relay envelopes with legacy numeric paneKeys before cache mutation', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: 'tab-1:0', tabId: 'tab-1', worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + expect(server.getStatusSnapshot()).toEqual([]) + }) + + it('maps registered legacy numeric relay pane keys to stable pane keys', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.registerPaneKeyAlias('tab-1:0', PANE) + server.setListener(listener) + server.ingestRemote( + { paneKey: 'tab-1:0', tabId: 'tab-1', worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'conn-1', + payload + }) + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + state: 'working', + prompt: 'p' + }) + ]) + }) + + it('drops remote relay envelopes whose tabId disagrees with the paneKey tab', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: PANE, tabId: 'tab-other', worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + expect(server.getStatusSnapshot()).toEqual([]) + }) + + it('rejects empty connectionId', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, '') + expect(listener).not.toHaveBeenCalled() + }) + + it('rejects whitespace-only connectionId', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, ' ') + expect(listener).not.toHaveBeenCalled() + }) + + it('rejects non-string tabId', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: PANE, tabId: 123 as unknown as string, worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('rejects empty paneKey after trim', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote({ paneKey: ' ', tabId: 'tab-1', worktreeId: 'wt-1', payload }, 'conn-1') + expect(listener).not.toHaveBeenCalled() + expect(trackMock).toHaveBeenCalledWith('agent_hook_unattributed', { + reason: 'empty_pane_key' + }) + }) + + it('normalizes inner payload via normalizeAgentStatusPayload — clamps oversized prompt', () => { + // Why: a buggy/malicious relay could forward an over-cap field, so ingestRemote re-runs the normalizer to enforce the AGENT_STATUS_MAX_FIELD_LENGTH cap at the trust boundary. + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'x'.repeat(500), agentType: 'claude' } + }, + 'conn-1' + ) + expect(listener).toHaveBeenCalledTimes(1) + const event = listener.mock.calls[0][0] as { payload: { prompt: string } } + expect(event.payload.prompt.length).toBe(200) + }) +}) + +describe('AgentHookServer ingestTerminalStatus', () => { + it('keeps hook monitoring mode across an equivalent OSC ping until a hook clears it', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + source: 'claude', + hookEventName: 'Stop', + payload: { + state: 'working', + workingMode: 'monitoring', + prompt: 'watch the build', + agentType: 'claude' + } + }, + 'conn-1' + ) + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: 'conn-1', + payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' } + }) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + workingMode: 'monitoring' + }) + + server.ingestRemote( + { + paneKey: PANE, + source: 'claude', + hookEventName: 'PreToolUse', + payload: { + state: 'working', + prompt: 'watch the build', + agentType: 'claude', + toolName: 'Read' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()[0]?.workingMode).toBeUndefined() + }) + + // Why: the OSC 9999 payload cannot carry a provider session, so letting it overwrite the row + // erased the session id from persisted rows and from headless `orca serve` — which serves these + // rows straight to mobile — leaving Chat UI with no transcript to subscribe to (#10630). + it('keeps the cached provider session when an OSC status completes the turn', () => { + const server = new AgentHookServer() + const providerSession = { key: 'session_id' as const, id: 'claude-session-1' } + + server.ingestRemote( + { + paneKey: PANE, + providerSession, + payload: { state: 'working', prompt: 'summarize the diff', agentType: 'claude' } + }, + 'conn-1' + ) + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: 'conn-1', + payload: { state: 'done', prompt: 'summarize the diff', agentType: 'claude' } + }) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', providerSession }) + }) + + // Why: an OSC ping that names no agent — or the literal 'unknown', which + // resolveAgentStatusIdentity treats identically — makes no claim about the pane's identity, so it + // must not read as a mismatch. Missing this stripped the session from persisted and headless rows + // while the live renderer kept it, blanking mobile chat only after a restart (#10630). + it.each([undefined, 'unknown' as const])( + 'keeps the cached provider session when an OSC status claims agentType %s', + (agentType) => { + const server = new AgentHookServer() + const providerSession = { key: 'session_id' as const, id: 'claude-session-1' } + + server.ingestRemote( + { + paneKey: PANE, + providerSession, + payload: { state: 'working', prompt: 'summarize the diff', agentType: 'claude' } + }, + 'conn-1' + ) + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: 'conn-1', + payload: { + state: 'done', + prompt: 'summarize the diff', + ...(agentType ? { agentType } : {}) + } + }) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ providerSession }) + } + ) + + it('drops the cached provider session when an OSC status starts a new turn', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + providerSession: { key: 'session_id' as const, id: 'claude-session-1' }, + payload: { state: 'done', prompt: 'first', agentType: 'claude' } + }, + 'conn-1' + ) + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: 'conn-1', + payload: { state: 'working', prompt: 'second', agentType: 'claude' } + }) + + expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined() + }) + + it('forwards runtime terminal status through the normal listener and snapshot path', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'ship it', + agentType: 'codex' + } + }) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + receivedAt: 1_000, + stateStartedAt: 1_000, + payload: { + state: 'working', + prompt: 'ship it', + agentType: 'codex' + } + }) + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + receivedAt: 1_000, + stateStartedAt: 1_000, + state: 'working', + prompt: 'ship it', + agentType: 'codex' + }) + ]) + expect(trackMock).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) + } finally { + vi.useRealTimers() + } + }) + + it('suppresses exact duplicate runtime terminal status observations', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + const event = { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working' as const, + prompt: 'same turn', + agentType: 'codex' as const + } + } + + server.ingestTerminalStatus(event) + vi.setSystemTime(1_250) + server.ingestTerminalStatus(event) + + expect(listener).toHaveBeenCalledTimes(1) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + receivedAt: 1_000, + stateStartedAt: 1_000, + state: 'working', + prompt: 'same turn' + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('preserves runtime terminal status connection identity', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'ssh-conn-1', + payload: { + state: 'working', + prompt: 'ship it', + agentType: 'codex' + } + }) + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'ssh-conn-1', + payload: { + state: 'working', + prompt: 'ship it', + agentType: 'codex' + } + }) + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + connectionId: 'ssh-conn-1', + state: 'working', + prompt: 'ship it' + }) + ]) + } finally { + vi.useRealTimers() + } + }) + + it('rejects runtime terminal status with mismatched tab identity', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'other-tab', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'bad tab', + agentType: 'codex' + } + }) + + expect(listener).not.toHaveBeenCalled() + expect(server.getStatusSnapshot()).toEqual([]) + }) +}) + +describe('AgentHookServer closed-tab suppression bound', () => { + it('bounds closedAgentStatusTabIds with LRU eviction as tabs close', () => { + const server = new AgentHookServer() + const internals = server as unknown as { + markTabClosedForAgentStatus: (tabId: string) => void + closedAgentStatusTabIds: Set + } + + const total = CLOSED_AGENT_STATUS_TAB_IDS_MAX + 200 + for (let i = 0; i < total; i += 1) { + internals.markTabClosedForAgentStatus(`closed-tab-${i}`) + } + + // Set stays bounded; oldest ids are evicted, most-recent are retained. + expect(internals.closedAgentStatusTabIds.size).toBe(CLOSED_AGENT_STATUS_TAB_IDS_MAX) + expect(internals.closedAgentStatusTabIds.has('closed-tab-0')).toBe(false) + expect(internals.closedAgentStatusTabIds.has(`closed-tab-${total - 1}`)).toBe(true) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 65b7b1479fd..07e4d7a2127 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -72,6 +72,7 @@ import { type ParsedAgentStatusPayload, normalizeAgentStatusPayload } from '../../shared/agent-status-types' +import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' import { AgentStatusObservationSequencer, createAgentStatusAuthorityId, @@ -448,30 +449,6 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta } } -// Why: OSC never carries model/children; omit both so an equivalent OSC ping preserves the hook-cached identity graph. -function equivalentParsedAgentStatusPayload( - a: ParsedAgentStatusPayload, - b: ParsedAgentStatusPayload, - preserveActiveTurnStamp = false -): boolean { - return ( - a.state === b.state && - a.prompt === b.prompt && - a.agentType === b.agentType && - a.toolName === b.toolName && - a.toolInput === b.toolInput && - a.interactivePrompt === b.interactivePrompt && - (a.lastAssistantMessage === b.lastAssistantMessage || - (preserveActiveTurnStamp && b.lastAssistantMessage === undefined)) && - a.interrupted === b.interrupted && - // Why: a session-boundary done must never be deduped against a cached real done — - // the flag has to reach receivers deterministically (STA-3386). - a.sessionBoundary === b.sessionBoundary && - (a.turnCompletedAt === b.turnCompletedAt || - (preserveActiveTurnStamp && b.turnCompletedAt === undefined)) - ) -} - function trackEmptyPaneKeyHook(body: unknown): void { if (typeof body !== 'object' || body === null) { return @@ -1039,6 +1016,7 @@ export class AgentHookServer { providerSession: existing.providerSession, payload: { state: restored.state, + ...(restored.workingMode ? { workingMode: restored.workingMode } : {}), prompt: payload.prompt, agentType: payload.agentType, ...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}), @@ -2201,7 +2179,7 @@ export class AgentHookServer { previous?.connectionId === connectionId && previous.tabId === tabId && previous.worktreeId === worktreeId && - equivalentParsedAgentStatusPayload(previous.payload, event.payload, preserveActiveTurnStamp) + terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) ) { return } @@ -2949,7 +2927,12 @@ export class AgentHookServer { ...(state !== 'done' && restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), receivedAt: reconciledAt, stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt, - payload: { ...enriched.payload, state, subagents } + payload: { + ...enriched.payload, + state, + workingMode: state === 'working' ? enriched.payload.workingMode : undefined, + subagents + } } this.state.lastStatusByPaneKey.set(paneKey, reconciled) } diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 72c0198fb45..fcfe2948baf 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -99,6 +99,19 @@ function imageIconSrc(bodyBytes: number, withWhitespace = false): string { describe('dashboard payload validation', () => { it('accepts a complete dashboard snapshot', () => { expect(isDashboardSnapshot(SNAPSHOT)).toBe(true) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [ + { + ...SNAPSHOT.cards[0], + bucket: 'working', + dotState: 'working', + workingMode: 'monitoring' + } + ] + }) + ).toBe(true) }) it('rejects malformed or unbounded snapshot fields', () => { @@ -109,6 +122,18 @@ describe('dashboard payload validation', () => { cards: [{ ...SNAPSHOT.cards[0], bucket: 'unexpected' }] }) ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], dotState: 'monitoring' }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], dotState: 'done', workingMode: 'monitoring' }] + }) + ).toBe(false) expect( isDashboardSnapshot({ ...SNAPSHOT, diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index e8f8560aa51..8463be64bba 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -257,6 +257,8 @@ function isDashboardCard(value: unknown): boolean { DASHBOARD_BUCKETS.has(card.bucket) && typeof card.dotState === 'string' && DASHBOARD_DOT_STATES.has(card.dotState) && + (card.workingMode === undefined || + (card.dotState === 'working' && card.workingMode === 'monitoring')) && isBoundedString(card.task, AGENT_STATUS_MAX_FIELD_LENGTH, true) && isOptionalBoundedString(card.lastUserMessage, AGENT_STATUS_MAX_FIELD_LENGTH) && isOptionalBoundedString(card.lastAgentMessage, AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH) && diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3688fec9c39..6c1ebe94f5d 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -37711,6 +37711,261 @@ describe('OrcaRuntimeService', () => { expect(summary).toMatchObject({ hasHostSidebarActivity: true, status: 'working' }) }) + it('projects monitoring for folder workspaces without assuming a git worktree', async () => { + const now = Date.now() + const folderWorkspace = makeFolderWorkspace({ name: 'GG' }) + const projectGroup = makeFolderProjectGroup({ name: 'Store' }) + const runtime = new OrcaRuntimeService( + createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never, + undefined, + { + getAgentStatusSnapshot: () => [ + { + paneKey: 'folder-pane', + worktreeId: TEST_FOLDER_WORKSPACE_KEY, + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + } + ) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_FOLDER_WORKSPACE_KEY) + + expect(summary).toMatchObject({ + workspaceKind: 'folder-workspace', + status: 'working', + workingMode: 'monitoring' + }) + }) + it('projects monitoring over a title-derived working status', async () => { + const now = Date.now() + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'tab-1:1', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1', + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude working' }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ status: 'working', workingMode: 'monitoring' }) + }) + it('keeps hook monitoring mode when a newer mode-less OSC row reports the same work', async () => { + const now = Date.now() + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'tab-1:1', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1', + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now - 100, + stateStartedAt: now - 200 + } + ] + }) + syncSinglePty(runtime) + runtime.onPtyData( + 'pty-1', + '\x1b]9999;{"state":"working","prompt":"watch tests","agentType":"claude"}\x07', + 1 + ) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ status: 'working', workingMode: 'monitoring' }) + expect(summary?.agents).toEqual([ + expect.objectContaining({ + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests' + }) + ]) + }) + it('does not carry hook monitoring mode into a newer OSC turn', async () => { + const now = Date.now() + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'tab-1:1', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1', + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now - 100, + stateStartedAt: now - 200 + } + ] + }) + syncSinglePty(runtime) + runtime.onPtyData( + 'pty-1', + '\x1b]9999;{"state":"working","prompt":"fix tests","agentType":"claude"}\x07', + 1 + ) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ status: 'working' }) + expect(summary).not.toHaveProperty('workingMode') + expect(summary?.agents).toEqual([ + expect.objectContaining({ state: 'working', prompt: 'fix tests' }) + ]) + }) + it('keeps title-only foreground work ahead of monitoring in another pane', async () => { + const now = Date.now() + const monitoringLeafId = '33333333-3333-4333-8333-333333333333' + const foregroundLeafId = '44444444-4444-4444-8444-444444444444' + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: `monitoring-tab:${monitoringLeafId}`, + worktreeId: TEST_WORKTREE_ID, + tabId: 'monitoring-tab', + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'monitoring-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: monitoringLeafId, + layout: null + }, + { + tabId: 'foreground-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: foregroundLeafId, + layout: null + } + ], + leaves: [ + { + tabId: 'monitoring-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: monitoringLeafId, + paneRuntimeId: 1, + ptyId: 'monitoring-pty', + paneTitle: 'claude' + }, + { + tabId: 'foreground-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: foregroundLeafId, + paneRuntimeId: 2, + ptyId: 'foreground-pty', + paneTitle: 'claude working' + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ status: 'working' }) + expect(summary).not.toHaveProperty('workingMode') + expect(summary?.agents).toEqual([ + expect.objectContaining({ paneKey: `monitoring-tab:${monitoringLeafId}` }) + ]) + }) + it('keeps title-only foreground work ahead of monitoring in another split pane', async () => { + const now = Date.now() + const tabId = 'split-tab' + const monitoringLeafId = '33333333-3333-4333-8333-333333333333' + const foregroundLeafId = '44444444-4444-4444-8444-444444444444' + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: `${tabId}:${monitoringLeafId}`, + worktreeId: TEST_WORKTREE_ID, + tabId, + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: monitoringLeafId, + layout: null + } + ], + leaves: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: monitoringLeafId, + paneRuntimeId: 1, + ptyId: 'monitoring-pty', + paneTitle: 'claude' + }, + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: foregroundLeafId, + paneRuntimeId: 2, + ptyId: 'foreground-pty', + paneTitle: 'claude working' + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ status: 'working' }) + expect(summary).not.toHaveProperty('workingMode') + }) + it('suppresses restored-unconfirmed hook rows from worktree.ps', async () => { const leafId = '33333333-3333-4333-8333-333333333333' const now = Date.now() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ab470d78533..d4fced1427c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -55,6 +55,7 @@ import { type AgentStatusOrchestrationContext, type AgentStatusEntry } from '../../shared/agent-status-types' +import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' import { indexAgentStatusRowsByPaneKey } from '../agent-hooks/agent-status-pane-index' import type { AgentHookAuthorityAttestation } from '../agent-hooks/server' import type { @@ -1752,6 +1753,32 @@ type RuntimeAgentRowSnapshot = { updatedAt: number } +type RuntimeWorkingTerminalEvidence = { + paneKey: string | null + ptyId: string | null + tabId: string | null +} + +type RuntimeWorktreeAgentSource = { + paneKey: string + ptyId?: string + tabId?: string + worktreeId?: string + connectionId: string | null + payload: ParsedAgentStatusPayload + state: ParsedAgentStatusPayload['state'] + workingMode?: ParsedAgentStatusPayload['workingMode'] + agentType: string | null + prompt: string + lastAssistantMessage: string | null + toolName: string | null + toolInput: string | null + interrupted: boolean + stateStartedAt: number + updatedAt: number + restoredUnconfirmed?: boolean +} + /** A hook row narrowed to what `session.tabs` publishes, shaped like the retained OSC * snapshot so one projection branch can consume either carrier. */ type HookLiveAgentRow = Pick< @@ -12155,6 +12182,7 @@ export class OrcaRuntimeService { return ( !previous || previous.payload.state !== payload.state || + previous.payload.workingMode !== payload.workingMode || previous.payload.prompt !== payload.prompt || (previous.payload.agentType ?? null) !== (payload.agentType ?? null) || (previous.payload.toolName ?? null) !== (payload.toolName ?? null) || @@ -19986,6 +20014,7 @@ export class OrcaRuntimeService { const repoById = new Map((this.store?.getRepos() ?? []).map((repo) => [repo.id, repo])) const platformByRepoId = resolvedWorktreeSnapshot.platformByRepoId const summaries = new Map() + const workingTerminalEvidenceByWorktreeId = new Map() // Why: the GitHub cache is keyed by `repoPath::branch` (no refs/heads/ prefix), // matching how the renderer's fetchPRForBranch stores entries. We look up cached @@ -20169,10 +20198,15 @@ export class OrcaRuntimeService { summary.liveTerminalCount += 1 summary.hasAttachedPty = true summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, leaf.lastOutputAt) - summary.status = mergeWorktreeStatus( - summary.status, - getLeafWorktreeStatus(leaf, this.tabs.get(leaf.tabId)?.title ?? null) - ) + const leafStatus = getLeafWorktreeStatus(leaf, this.tabs.get(leaf.tabId)?.title ?? null) + if (leafStatus === 'working') { + addRuntimeWorkingTerminalEvidence(workingTerminalEvidenceByWorktreeId, summary.worktreeId, { + paneKey: this.makeRuntimePaneKey(leaf), + ptyId: leaf.ptyId, + tabId: leaf.tabId + }) + } + mergeWorktreeSummaryStatus(summary, leafStatus) if ( leaf.preview && (summary.preview.length === 0 || (leaf.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1)) @@ -20234,10 +20268,15 @@ export class OrcaRuntimeService { summary.hasAttachedPty = true summary.hasHostSidebarActivity = true summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt) - summary.status = mergeWorktreeStatus( - summary.status, - getSavedTabWorktreeStatus(owner.title, true) - ) + const ptyStatus = getSavedTabWorktreeStatus(owner.title, true) + if (ptyStatus === 'working') { + addRuntimeWorkingTerminalEvidence(workingTerminalEvidenceByWorktreeId, summary.worktreeId, { + paneKey: pty.paneKey, + ptyId: pty.ptyId, + tabId: pty.tabId ?? persistedTabId ?? null + }) + } + mergeWorktreeSummaryStatus(summary, ptyStatus) if ( pty.preview && (summary.preview.length === 0 || (pty.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1)) @@ -20338,7 +20377,8 @@ export class OrcaRuntimeService { runtimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds, mirroredWorktreeIdByTabId, - connectedPtyEvidence + connectedPtyEvidence, + workingTerminalEvidenceByWorktreeId ) const sorted = [...summaries.values()].sort(compareWorktreePs) @@ -20362,30 +20402,17 @@ export class OrcaRuntimeService { tabIds: ReadonlySet paneKeys: ReadonlySet ptyIds: ReadonlySet - } + }, + workingTerminalEvidenceByWorktreeId: ReadonlyMap< + string, + readonly RuntimeWorkingTerminalEvidence[] + > ): void { // Why: most agents report via hooks (agent-hooks/server), not OSC, so the // hook snapshot is the primary source — same one the desktop sidebar reads. // OSC-only entries (no hook) are merged in as a fallback, keyed by paneKey. - const rowSources = new Map< - string, - { - paneKey: string - ptyId?: string - tabId?: string - worktreeId?: string - connectionId: string | null - state: ParsedAgentStatusPayload['state'] - agentType: string | null - prompt: string - lastAssistantMessage: string | null - toolName: string | null - toolInput: string | null - interrupted: boolean - stateStartedAt: number - updatedAt: number - } - >() + const rowSources = new Map() + const now = Date.now() for (const snapshot of this.latestAgentStatusByPaneKey.values()) { const { payload } = snapshot rowSources.set(snapshot.paneKey, { @@ -20394,7 +20421,9 @@ export class OrcaRuntimeService { tabId: snapshot.tabId, worktreeId: snapshot.worktreeId, connectionId: snapshot.connectionId, + payload, state: payload.state, + ...(payload.workingMode ? { workingMode: payload.workingMode } : {}), agentType: payload.agentType ?? null, prompt: payload.prompt, lastAssistantMessage: payload.lastAssistantMessage ?? null, @@ -20411,9 +20440,22 @@ export class OrcaRuntimeService { continue } const existing = rowSources.get(entry.paneKey) + const hookPayload = pickParsedAgentStatusPayload(entry) // Why: hook rows win ties, but an older cached hook must not replace a // fresh OSC status and make a running mobile workspace look inactive. if (existing && existing.updatedAt > entry.receivedAt) { + if ( + entry.workingMode === 'monitoring' && + // restoredUnconfirmed rows already `continue` above. + now - entry.receivedAt <= AGENT_STATUS_STALE_AFTER_MS && + terminalStatusPayloadMatchesHook(hookPayload, existing.payload) + ) { + // Why: older OSC reporters cannot express hook-authoritative monitoring mode. + existing.workingMode = 'monitoring' + if (existing.payload.workingMode === undefined) { + existing.payload = { ...existing.payload, workingMode: 'monitoring' } + } + } continue } rowSources.set(entry.paneKey, { @@ -20424,7 +20466,9 @@ export class OrcaRuntimeService { tabId: entry.tabId, worktreeId: entry.worktreeId, connectionId: entry.connectionId, + payload: hookPayload, state: entry.state, + ...(entry.workingMode ? { workingMode: entry.workingMode } : {}), agentType: entry.agentType ?? null, prompt: entry.prompt, lastAssistantMessage: entry.lastAssistantMessage ?? null, @@ -20440,7 +20484,6 @@ export class OrcaRuntimeService { } const orchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey() const rowsByWorktree = new Map() - const now = Date.now() for (const src of rowSources.values()) { // Why: hooks retain launch-time attribution across automatic workspace // renames; the tab's current mirrored owner is authoritative when present. @@ -20487,6 +20530,7 @@ export class OrcaRuntimeService { paneKey: src.paneKey, parentPaneKey: orchestrationByPaneKey?.[src.paneKey]?.parentPaneKey ?? null, state: src.state, + ...(src.workingMode ? { workingMode: src.workingMode } : {}), agentType: src.agentType, prompt: src.prompt, taskTitle, @@ -20513,6 +20557,8 @@ export class OrcaRuntimeService { const summary = summaries.get(worktreeId) if (summary) { summary.agents = rows + let hasForegroundWorkingAgent = false + const monitoringSources: RuntimeWorktreeAgentSource[] = [] for (const row of rows) { if (!isFreshNonDoneAgentStatus(row, now)) { continue @@ -20520,9 +20566,31 @@ export class OrcaRuntimeService { // Why: worktree.ps is mobile's host-sidebar parity source, so a live // agent must survive the same temporary PTY gaps as desktop. summary.hasHostSidebarActivity = true - summary.status = mergeWorktreeStatus( - summary.status, - row.state === 'working' ? 'working' : 'permission' + if (row.state === 'working') { + if (row.workingMode === 'monitoring') { + const source = rowSources.get(row.paneKey) + if (source) { + monitoringSources.push(source) + } + } else { + hasForegroundWorkingAgent = true + } + } else { + mergeWorktreeSummaryStatus(summary, 'permission') + } + } + if (hasForegroundWorkingAgent || monitoringSources.length > 0) { + const hasIndependentWorkingTerminal = ( + workingTerminalEvidenceByWorktreeId.get(worktreeId) ?? [] + ).some((evidence) => + monitoringSources.every( + (source) => !runtimeWorkingTerminalEvidenceMatchesSource(evidence, source) + ) + ) + mergeWorktreeSummaryStatus( + summary, + 'working', + hasForegroundWorkingAgent || hasIndependentWorkingTerminal ? undefined : 'monitoring' ) } } @@ -40945,11 +41013,58 @@ function mapExplicitAgentStateToRuntimeTerminalStatus( } } -function mergeWorktreeStatus( - current: RuntimeWorktreeStatus, - next: RuntimeWorktreeStatus -): RuntimeWorktreeStatus { - return WORKTREE_STATUS_PRIORITY[next] > WORKTREE_STATUS_PRIORITY[current] ? next : current +function addRuntimeWorkingTerminalEvidence( + evidenceByWorktreeId: Map, + worktreeId: string, + evidence: RuntimeWorkingTerminalEvidence +): void { + const existing = evidenceByWorktreeId.get(worktreeId) + if (existing) { + existing.push(evidence) + } else { + evidenceByWorktreeId.set(worktreeId, [evidence]) + } +} + +function runtimeWorkingTerminalEvidenceMatchesSource( + evidence: RuntimeWorkingTerminalEvidence, + source: RuntimeWorktreeAgentSource +): boolean { + if (evidence.paneKey) { + return ( + evidence.paneKey === source.paneKey || + Boolean(evidence.ptyId && source.ptyId && evidence.ptyId === source.ptyId) + ) + } + if (evidence.ptyId && source.ptyId) { + return evidence.ptyId === source.ptyId + } + return Boolean(evidence.tabId && evidence.tabId === source.tabId) +} + +function mergeWorktreeSummaryStatus( + summary: RuntimeWorktreePsSummary, + next: RuntimeWorktreeStatus, + nextWorkingMode?: RuntimeWorktreePsSummary['workingMode'] +): void { + const currentPriority = WORKTREE_STATUS_PRIORITY[summary.status] + const nextPriority = WORKTREE_STATUS_PRIORITY[next] + if (nextPriority > currentPriority) { + summary.status = next + if (next === 'working' && nextWorkingMode === 'monitoring') { + summary.workingMode = 'monitoring' + } else { + delete summary.workingMode + } + return + } + if (nextPriority === currentPriority && next === 'working') { + if (nextWorkingMode === 'monitoring') { + summary.workingMode = 'monitoring' + } else { + delete summary.workingMode + } + } } function normalizeTerminalChunk( diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 2b8fc30c817..20f6c6533fd 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -50,6 +50,7 @@ type CapturedStatus = { connectionId: string | null payload: { state: string + workingMode?: 'monitoring' prompt: string agentType?: string toolName?: string @@ -184,6 +185,7 @@ function captureAgentStatuses(events: CapturedStatus[]): void { connectionId: event.connectionId, payload: { state: event.payload.state, + ...(event.payload.workingMode ? { workingMode: event.payload.workingMode } : {}), prompt: event.payload.prompt, agentType: event.payload.agentType, toolName: event.payload.toolName @@ -298,6 +300,42 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { }) }) + it('preserves Claude monitoring mode across the SSH relay boundary', async () => { + relay = createFakeRelay() + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: relay.transport, + serverBuildId: 'test-relay-build', + platform: 'linux-x64' + }) + const events: CapturedStatus[] = [] + captureAgentStatuses(events) + session = createSession('conn-monitoring') + await session.establish({} as SshConnection) + + relay.notifyAgentHook( + makeEnvelope({ + source: 'claude', + claudeRunningNonAgentTask: true, + payload: { + state: 'working', + workingMode: 'monitoring', + prompt: 'watch the build', + agentType: 'claude' + } + }) + ) + + await waitForStatusCount(events, 1) + expect(events[0]).toMatchObject({ + connectionId: 'conn-monitoring', + payload: { state: 'working', workingMode: 'monitoring', agentType: 'claude' } + }) + expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + workingMode: 'monitoring' + }) + }) + it('stamps SSH ownership and settles only the exact manual compact identity', async () => { relay = createFakeRelay() vi.mocked(deployAndLaunchRelay).mockResolvedValue({ diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 3458814ed5b..28825bb8515 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -99,7 +99,7 @@ describe('RelayAgentHookServer', () => { expect(forward.mock.calls[0][0]).toMatchObject({ claudeRunningNonAgentTask: true, - payload: { state: 'working', agentType: 'claude' } + payload: { state: 'working', workingMode: 'monitoring', agentType: 'claude' } }) } finally { server.stop() diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index ed032d2bbf9..6304b1cfd68 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -42,6 +42,15 @@ describe('AgentStateDot', () => { expect(markup).toContain('motion-reduce:border-t-yellow-500') }) + it('renders monitoring as a static yellow radio glyph', () => { + const markup = renderMarkup('monitoring') + + expect(markup).toContain('aria-label="Monitoring background tasks"') + expect(markup).toContain('lucide-radio') + expect(markup).toContain('text-yellow-500') + expect(markup).not.toContain('data-agent-spinner') + }) + it('renders done as an emerald check icon', () => { const markup = renderMarkup('done') diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 0c182480d98..4f361d01f87 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { CircleCheck } from 'lucide-react' +import { CircleCheck, Radio } from 'lucide-react' import { cn } from '@/lib/utils' import { AgentQuestionIcon } from '@/components/AgentQuestionIcon' import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' @@ -17,6 +17,7 @@ import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' export type AgentDotState = | 'working' + | 'monitoring' | 'blocked' | 'waiting' | 'interrupted' @@ -37,6 +38,8 @@ export function agentStateLabel(state: AgentDotState): string { switch (state) { case 'working': return 'Working' + case 'monitoring': + return 'Monitoring background tasks' case 'blocked': return 'Blocked' case 'waiting': @@ -81,6 +84,17 @@ export const AgentStateDot = React.memo(function AgentStateDot({ ) } + if (state === 'monitoring') { + return ( + + + ) + } + if (state === 'done') { // Why: the dashboard lists many agents, so a check glyph scans well for // agent-reported completion and keeps 'done' visually distinct from diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts index 480cc7fff50..48bdbb3a250 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts @@ -156,6 +156,27 @@ describe('buildActivityEvents', () => { }) }) + it('projects monitoring into thread rows and status groups', () => { + const result = makeActivityResult({ + entries: { + [PANE_KEY]: { + ...makeWorkingEntryWithoutHistory(), + workingMode: 'monitoring' + } + } + }) + const threads = makeThreads(result) + const groups = groupActivityThreadsByStatus(threads) + + expect(result.liveAgentByPaneKey[PANE_KEY].state).toBe('monitoring') + expect(threads[0].currentAgentState).toBe('monitoring') + expect(groups[0]).toMatchObject({ + id: 'monitoring', + label: 'Monitoring background tasks', + state: 'monitoring' + }) + }) + it('uses orchestration display metadata for live thread titles', () => { const result = makeActivityResult({ entries: { diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 6157c796d71..a6a1df6dc9f 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -11,7 +11,7 @@ import { TerminalSquare } from 'lucide-react' -import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' +import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, @@ -88,8 +88,15 @@ import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' type ThreadReadFilter = 'all' | 'unread' type ActivityGroupBy = 'status' | 'project' | 'worktree' | 'agent' type ActivityEventState = Extract -type ActivityLiveAgentState = Extract -type ActivityStatusGroupId = 'working' | 'blocked' | 'waiting' | 'done' | 'interrupted' +type ActivityHookLiveAgentState = Extract +type ActivityLiveAgentState = ActivityHookLiveAgentState | 'monitoring' +type ActivityStatusGroupId = + | 'working' + | 'monitoring' + | 'blocked' + | 'waiting' + | 'done' + | 'interrupted' type ActivityEvent = { id: string @@ -137,7 +144,7 @@ type ActivityThreadGroup = { key: string id?: ActivityStatusGroupId label: string - state?: AgentStatusState + state?: AgentDotState threads: AgentPaneThread[] } @@ -158,6 +165,7 @@ const ACTIVITY_TERMINAL_LOADING_LABEL_DELAY_MS = 180 const ACTIVITY_THREAD_RESPONSE_RENDER_PREVIEW_MAX_LENGTH = 320 const ACTIVITY_STATUS_GROUP_ORDER: ActivityStatusGroupId[] = [ 'working', + 'monitoring', 'blocked', 'waiting', 'done', @@ -474,7 +482,9 @@ function isActivityEventState(state: AgentStatusState): state is ActivityEventSt return state === 'done' || state === 'blocked' || state === 'waiting' } -function isActivityLiveAgentState(state: AgentStatusState): state is ActivityLiveAgentState { +function isActivityHookLiveAgentState( + state: AgentStatusState +): state is ActivityHookLiveAgentState { return state === 'working' || state === 'blocked' || state === 'waiting' } @@ -482,10 +492,15 @@ function freshActivityLiveAgentState( entry: AgentStatusEntry, now: number ): ActivityLiveAgentState | null { - if (!isActivityLiveAgentState(entry.state)) { + if ( + !isActivityHookLiveAgentState(entry.state) || + !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) + ) { return null } - return isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) ? entry.state : null + return entry.state === 'working' && entry.workingMode === 'monitoring' + ? 'monitoring' + : entry.state } function standaloneActivityWorktree(worktreeId: string): Worktree { @@ -822,7 +837,7 @@ export function buildAgentPaneThreads(args: { agentType: liveAgent.agentType, currentAgentState: liveAgent.state, currentAgentEntry: liveAgent.entry, - responsePreview: statusPreviewForEntry(liveAgent.entry, liveAgent.state), + responsePreview: statusPreviewForEntry(liveAgent.entry, liveAgent.entry.state), latestTimestamp: liveAgent.timestamp, latestEvent: null, events: [], @@ -840,7 +855,7 @@ export function buildAgentPaneThreads(args: { existing.currentAgentEntry = liveAgent.entry existing.responsePreview = statusPreviewForEntry( liveAgent.entry, - liveAgent.state, + liveAgent.entry.state, existing.responsePreview ) existing.latestTimestamp = liveAgent.timestamp @@ -960,7 +975,7 @@ function EventRepoBadge({ repo }: { repo: Repo | null }): React.JSX.Element | nu ) } -function threadAgentState(thread: AgentPaneThread): AgentStatusState { +function threadAgentState(thread: AgentPaneThread): AgentDotState { return thread.currentAgentState ?? thread.latestEvent?.state ?? 'done' } @@ -1024,10 +1039,12 @@ function threadStatusGroupId(thread: AgentPaneThread): ActivityStatusGroupId { if (!thread.currentAgentState && state === 'done' && thread.latestEvent?.entry.interrupted) { return 'interrupted' } - return state === 'working' || state === 'blocked' || state === 'waiting' ? state : 'done' + return state === 'working' || state === 'monitoring' || state === 'blocked' || state === 'waiting' + ? state + : 'done' } -function threadStatusGroupState(id: ActivityStatusGroupId): AgentStatusState { +function threadStatusGroupState(id: ActivityStatusGroupId): AgentDotState { return id === 'interrupted' ? 'done' : id } diff --git a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx index 9e8a51a6e80..d0a9ea6b619 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx @@ -37,20 +37,27 @@ function makeTerminalTab(id: string, worktreeId: string): TerminalTab { } } -function makeAgentEntry(tabId: string, state: AgentStatusState): AgentStatusEntry { +function makeAgentEntry( + tabId: string, + state: AgentStatusState, + overrides: Partial = {} +): AgentStatusEntry { return { state, prompt: '', updatedAt: Date.now(), stateStartedAt: Date.now(), paneKey: makePaneKey(tabId, LEAF), - stateHistory: [] + stateHistory: [], + ...overrides } } -function setAgentState(state: AgentStatusState): void { +function setAgentState(state: AgentStatusState, overrides: Partial = {}): void { useAppStore.setState((s) => ({ - agentStatusByPaneKey: { [makePaneKey('term-a', LEAF)]: makeAgentEntry('term-a', state) }, + agentStatusByPaneKey: { + [makePaneKey('term-a', LEAF)]: makeAgentEntry('term-a', state, overrides) + }, agentStatusEpoch: s.agentStatusEpoch + 1 })) } @@ -118,6 +125,21 @@ describe('palette live status', () => { expect(dotLabels()).toEqual(['Needs permission']) }) + it('shows monitoring when a covered pane retains a working title', async () => { + setAgentState('working', { workingMode: 'monitoring' }) + useAppStore.setState({ + tabsByWorktree: { + 'wt-a': [{ ...makeTerminalTab('term-a', 'wt-a'), title: 'claude [working]' }] + } + } as Partial) + + await render() + + expect(testContainer.querySelector('[data-spinner]')).toBeNull() + expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500') + expect(dotLabels()).toEqual(['Monitoring background tasks']) + }) + // Why this is the whole point: the palette body no longer subscribes to agent status, so if the // dot didn't hold its own subscription it would freeze at whatever it showed when you opened. it('re-renders the dot without re-rendering the frozen body around it', async () => { @@ -234,6 +256,31 @@ describe('palette live status', () => { expect(testContainer.querySelector('[title="Needs permission"]')).not.toBeNull() }) + it('shows monitoring with a static radio instead of the working spinner', async () => { + setAgentState('working', { workingMode: 'monitoring' }) + await act(async () => { + testRoot.render( + + } + /> + + ) + }) + + expect(testContainer.querySelector('[data-spinner]')).toBeNull() + expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500') + expect(dotLabels()).toEqual(['Monitoring background tasks']) + expect(testContainer.querySelector('[title="Monitoring background tasks"]')).not.toBeNull() + }) + it('shows only the content icon when a terminal-backed row is inactive', async () => { useAppStore.setState({ ptyIdsByTabId: {} diff --git a/src/renderer/src/components/cmd-j/palette-live-status.tsx b/src/renderer/src/components/cmd-j/palette-live-status.tsx index 5c755ad4164..377b1527343 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.tsx @@ -9,6 +9,7 @@ import { type TabPaneInputSources } from '@/components/sidebar/smart-attention' import { cn } from '@/lib/utils' +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state' import { getWorktreeStatus, @@ -26,6 +27,11 @@ import { } from '@/components/tab-bar/terminal-tab-activity-status' import { translate } from '@/i18n/i18n' import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { parsePaneKey } from '../../../../shared/stable-pane-id' import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { Worktree } from '../../../../shared/worktree/types' @@ -33,6 +39,7 @@ import type { Worktree } from '../../../../shared/worktree/types' /** Confines the app's hottest status subscriptions here so only the dots re-render on their churn. */ type PaletteLiveStatus = { liveAgentStatusByWorktreeId: ReadonlyMap + agentStatusPaneIdsByTabId: Record> paneSources: TabPaneInputSources tabsByWorktree: Record browserTabsByWorktree: Record @@ -85,17 +92,19 @@ export function PaletteLiveStatusProvider({ // Why: `now` decides freshness, so both derivations must read it on the same tick — otherwise a // "done" dot can outlive its window while the worktree row beside it has already decayed. const now = Date.now() + const entriesByTabId = buildExplicitEntriesByTabId( + agentStatusByPaneKey, + migrationUnsupportedByPtyId + ) return { liveAgentStatusByWorktreeId: getLiveAgentStatusByWorktreeId( agentStatusByPaneKey, tabsByWorktree, now ), + agentStatusPaneIdsByTabId: buildLiveAgentStatusPaneIdsByTabId(entriesByTabId, now), paneSources: { - entriesByTabId: buildExplicitEntriesByTabId( - agentStatusByPaneKey, - migrationUnsupportedByPtyId - ), + entriesByTabId, ptyIdsByTabId, runtimePaneTitlesByTabId, terminalLayoutsByTabId @@ -124,6 +133,32 @@ export function PaletteLiveStatusProvider({ ) } +function buildLiveAgentStatusPaneIdsByTabId( + entriesByTabId: ReadonlyMap, + now: number +): Record> { + const paneIdsByTabId: Record> = {} + for (const [tabId, entries] of entriesByTabId) { + const paneIds = new Set() + for (const entry of entries) { + if ( + entry.restoredUnconfirmed !== true && + !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) + ) { + continue + } + const paneId = parsePaneKey(entry.paneKey)?.leafId + if (paneId) { + paneIds.add(paneId) + } + } + if (paneIds.size > 0) { + paneIdsByTabId[tabId] = paneIds + } + } + return paneIdsByTabId +} + const EMPTY_LIVE_INPUTS = Object.freeze({ agentStatusByPaneKey: {}, runtimePaneTitlesByTabId: {}, @@ -158,7 +193,10 @@ export function PaletteWorktreeStatusDot({ live.browserTabsByWorktree[worktree.id] ?? [], live.paneSources.ptyIdsByTabId, live.paneSources.runtimePaneTitlesByTabId, - { liveAgentStatus: live.liveAgentStatusByWorktreeId.get(worktree.id) } + { + liveAgentStatus: live.liveAgentStatusByWorktreeId.get(worktree.id), + agentStatusPaneIdsByTabId: live.agentStatusPaneIdsByTabId + } ) return ( <> diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx index d98592232c0..d3bff0f3936 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx @@ -10,6 +10,7 @@ import { TooltipProvider } from '@/components/ui/tooltip' import { AgentKanbanCard } from './AgentKanbanCard' const agentIconRender = vi.fn() +const agentStateDotRender = vi.fn() vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => { @@ -19,7 +20,10 @@ vi.mock('@/lib/agent-catalog', () => ({ })) vi.mock('@/components/AgentStateDot', () => ({ - AgentStateDot: () => + AgentStateDot: ({ state }: { state: string }) => { + agentStateDotRender(state) + return + } })) function card(overrides: Partial = {}): DashboardCard { @@ -284,6 +288,24 @@ describe('AgentKanbanCard', () => { expect(screen.getByText('2m')).toBeInTheDocument() }) + it('rerenders when a working card enters monitoring', () => { + const initial = card() + const { rerender } = renderCard({ card: initial, now: 2_000 }) + expect(agentStateDotRender).toHaveBeenLastCalledWith('working') + + rerender( + + + + ) + + expect(agentStateDotRender).toHaveBeenLastCalledWith('monitoring') + }) + it('rerenders when the repo icon changes', () => { const onOpenTerminal = vi.fn() const initial = card({ startedAt: 1_000 }) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index faef58dc7b4..f2cc370dcdd 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -87,6 +87,7 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean { a.agentType === b.agentType && a.bucket === b.bucket && a.dotState === b.dotState && + a.workingMode === b.workingMode && a.task === b.task && a.lastUserMessage === b.lastUserMessage && a.lastAgentMessage === b.lastAgentMessage && diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx index 4a6e29c15a9..56039ad4db7 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx @@ -84,6 +84,7 @@ describe('AgentMapScene project labels', () => { agents: [], statusCounts: { working: 0, + monitoring: 0, blocked: 0, waiting: 0, done: 0, diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx index f4999a012bd..55a2d5f547e 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -63,7 +63,10 @@ function WorktreeDetails({ onDone: () => void }): React.JSX.Element { const activeCount = - worktree.statusCounts.working + worktree.statusCounts.blocked + worktree.statusCounts.waiting + worktree.statusCounts.working + + worktree.statusCounts.monitoring + + worktree.statusCounts.blocked + + worktree.statusCounts.waiting const doneCount = worktree.statusCounts.done + worktree.statusCounts['done-seen'] return ( diff --git a/src/renderer/src/components/dashboard-popout/agent-map-filter.ts b/src/renderer/src/components/dashboard-popout/agent-map-filter.ts index 455d32892ed..698bd64b49c 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-filter.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-filter.ts @@ -22,6 +22,9 @@ export function agentMapState(card: DashboardCard): AgentMapState { if (state === 'done-seen') { return 'done' } + if (state === 'monitoring') { + return 'working' + } return state } diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts index 841197437e6..67081f39084 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts @@ -137,7 +137,12 @@ function baselineBox( * idle neighbour has to drop its own. */ function labelPriority(worktree: AgentMapWorktreeRing): number { const attention = worktree.statusCounts.blocked + worktree.statusCounts.waiting - return attention * 1_000_000 + worktree.statusCounts.working * 10_000 + worktree.agents.length + return ( + attention * 1_000_000 + + worktree.statusCounts.working * 10_000 + + worktree.statusCounts.monitoring * 1_000 + + worktree.agents.length + ) } /** Worth drawing before collisions are considered: all-idle workspaces stay diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts index 3ac0f104f34..3304c45537f 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts @@ -551,6 +551,9 @@ describe('agent map layout', () => { for (const dotState of ['working', 'blocked', 'waiting', 'idle'] as const) { expect(agentMapNodeStatus(card({ dotState }))).toBe(dotState) } + expect(agentMapNodeStatus(card({ dotState: 'working', workingMode: 'monitoring' }))).toBe( + 'monitoring' + ) expect(agentMapNodeStatus(card({ dotState: 'done', unseen: true }))).toBe('done') expect(agentMapNodeStatus(card({ dotState: 'done', unseen: false }))).toBe('done-seen') const shortBlocked = card({ dotState: 'blocked', startedAt: NOW - 60_000 }) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts b/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts index 6a3b004af26..34db8ccfc31 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts @@ -1,6 +1,7 @@ import { dashboardCardDisplayState, type DashboardCard, + type DashboardCardDisplayState, type DashboardCardDotState } from '../../../../shared/dashboard-snapshot' @@ -8,7 +9,7 @@ import { * acknowledged finish into `idle`, which is right for bucket counts but loses the one * distinction the map exists to show: finished-and-unread vs finished-and-still-yours. * Kept local so `DashboardCardDotState` — which crosses the pop-out bridge — is unchanged. */ -export type AgentMapNodeStatus = DashboardCardDotState | 'done-seen' +export type AgentMapNodeStatus = DashboardCardDisplayState | 'done-seen' export function agentMapDurationMinutes(card: DashboardCard, now: number): number { if (!Number.isFinite(card.startedAt) || card.startedAt <= 0) { @@ -89,7 +90,7 @@ export function selectAgentMapRecentFlareStatuses( export type AgentMapStatusCounts = Record export function emptyAgentMapStatusCounts(): AgentMapStatusCounts { - return { working: 0, blocked: 0, waiting: 0, done: 0, 'done-seen': 0, idle: 0 } + return { working: 0, monitoring: 0, blocked: 0, waiting: 0, done: 0, 'done-seen': 0, idle: 0 } } /** Finished work you have already opened is still yours to land, but it is not asking for diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts index 58c98e56a38..1ecbdb0beb1 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts @@ -35,4 +35,9 @@ describe('agentMapWorktreeActiveStatus', () => { expect(agentMapWorktreeActiveStatus(counts({ done: 2, idle: 1 }))).toBe('done') expect(agentMapWorktreeActiveStatus(counts({ 'done-seen': 2, idle: 1 }))).toBeNull() }) + + it('keeps passive monitoring out of the active-worktree glow', () => { + expect(agentMapWorktreeActiveStatus(counts({ monitoring: 1 }))).toBeNull() + expect(agentMapWorktreeActiveStatus(counts({ working: 1, monitoring: 1 }))).toBe('working') + }) }) diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css index af1844aacd5..189540305a7 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map.css +++ b/src/renderer/src/components/dashboard-popout/agent-map.css @@ -420,6 +420,10 @@ stroke: var(--color-yellow-500); } +.fleet-status-monitoring .agent-map-agent-mark { + stroke: var(--color-yellow-500); +} + .fleet-status-blocked .agent-map-agent-mark { stroke: var(--color-red-500); } diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts index 98f9ec369c5..1d2de1e3624 100644 --- a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts @@ -86,6 +86,19 @@ describe('patchDashboardSnapshotFromAgentStatus', () => { }) }) + it('patches same-state working into passive monitoring', () => { + const result = patchDashboardSnapshotFromAgentStatus( + snapshot(), + event({ state: 'working', workingMode: 'monitoring', stateStartedAt: 100 }) + ) + + expect(result.snapshot.cards[0]).toMatchObject({ + bucket: 'working', + dotState: 'working', + workingMode: 'monitoring' + }) + }) + it('ignores stale, wrong-workspace, and session-only events', () => { const original = snapshot() expect( diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts index 816474eb70f..33ac5a1f737 100644 --- a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts @@ -49,7 +49,11 @@ export function patchDashboardSnapshotFromAgentStatus( const stateChanged = event.stateStartedAt > card.stateChangedAt const unseen = stateChanged ? card.startedAt !== 0 : card.unseen const dotState = event.state - const bucket = dashboardBucketForDotState(dashboardCardDisplayState({ dotState, unseen })) + const workingMode = + event.state === 'working' && event.workingMode === 'monitoring' ? event.workingMode : undefined + const bucket = dashboardBucketForDotState( + dashboardCardDisplayState({ dotState, workingMode, unseen }) + ) const nextCard: DashboardCard = { ...card, ...(event.agentType ? { agentType: event.agentType } : {}), @@ -62,6 +66,7 @@ export function patchDashboardSnapshotFromAgentStatus( : {}), bucket, dotState, + workingMode, unseen, stateChangedAt: stateChanged ? event.stateStartedAt : card.stateChangedAt, statusUpdatedAt: event.receivedAt, diff --git a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx index 9a9c4e45a74..3376be9102a 100644 --- a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx +++ b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx @@ -4,9 +4,10 @@ import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { renderHook } from '@testing-library/react' import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' -import type { - AgentStatusClearIpcPayload, - AgentStatusIpcPayload +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusClearIpcPayload, + type AgentStatusIpcPayload } from '../../../../shared/agent-status-types' import { useDashboardSnapshot } from './useDashboardSnapshot' @@ -238,4 +239,31 @@ describe('useDashboardSnapshot', () => { expect(requestSnapshot).toHaveBeenCalledTimes(1) vi.useRealTimers() }) + + it('refreshes when a monitoring card crosses the stale boundary', () => { + vi.useFakeTimers() + const statusUpdatedAt = 1_000 + vi.setSystemTime(statusUpdatedAt) + renderHook(() => useDashboardSnapshot()) + act(() => + apply( + snapshot([ + card({ + dotState: 'working', + workingMode: 'monitoring', + statusUpdatedAt + }) + ]) + ) + ) + requestSnapshot.mockClear() + + act(() => vi.advanceTimersByTime(AGENT_STATUS_STALE_AFTER_MS)) + expect(requestSnapshot).not.toHaveBeenCalled() + act(() => vi.advanceTimersByTime(250)) + expect(requestSnapshot).not.toHaveBeenCalled() + act(() => vi.advanceTimersByTime(1)) + expect(requestSnapshot).toHaveBeenCalledTimes(1) + vi.useRealTimers() + }) }) diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index 4ce41472d49..9a95a6293e8 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -348,6 +348,26 @@ describe('DashboardAgentRow', () => { expect(activeToolMarkup).toContain('ListDir') }) + it('renders monitoring without a spinner or active tool line', () => { + const markup = renderRow( + makeAgent( + {}, + { + workingMode: 'monitoring', + prompt: '', + toolName: 'Bash', + toolInput: 'pnpm dev' + } + ) + ) + + expect(markup).toContain('Monitoring background tasks') + expect(markup).toContain('lucide-radio') + expect(classTokens(markup)).toContain('text-yellow-500') + expect(markup).not.toContain('data-agent-spinner') + expect(markup).not.toContain('data-agent-row-tool-slot') + }) + it('names what a blocked approval is waiting on', () => { const markup = renderRow( makeAgent( diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 96c52f12d22..8bc76da2709 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -16,9 +16,13 @@ import { useAgentRowConversationName } from './use-agent-row-conversation-name' import { lastEnteredDoneAt } from './agent-finished-timestamp' // Why: narrow the dashboard's rollup states to shared dot states, defaulting unknowns to 'idle' so a row never crashes. -function asDotState(state: AgentStatusState | 'idle'): AgentDotState { +function asDotState( + state: AgentStatusState | 'idle', + workingMode?: DashboardAgentRowData['entry']['workingMode'] +): AgentDotState { switch (state) { case 'working': + return workingMode === 'monitoring' ? 'monitoring' : 'working' case 'blocked': case 'waiting': case 'done': @@ -143,12 +147,14 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const conversationName = useAgentRowConversationName(agent) const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry) // Why: prompt is '' when unknown, so fall back to the state label to keep the row labeled. - const displayLabel = prompt || agentStateLabel(asDotState(agent.state)) + const displayLabel = prompt || agentStateLabel(asDotState(agent.state, agent.entry.workingMode)) const model = agent.entry.model?.trim() ?? '' - const isWorking = agent.state === 'working' + const isMonitoring = agent.state === 'working' && agent.entry.workingMode === 'monitoring' + const isWorking = agent.state === 'working' && !isMonitoring // Why: 'working' names the running tool and 'waiting' names what an approval is blocked on; // anywhere else a leftover tool line reads as still-running. See showsAgentToolPreview. - const showsTool = showsAgentToolPreview(agent.state) + // Monitoring is excluded too: the lead turn is over, so its last tool line is stale. + const showsTool = showsAgentToolPreview(agent.state) && !isMonitoring const toolName = showsTool ? (agent.entry.toolName?.trim() ?? '') : '' const toolInput = showsTool ? (agent.entry.toolInput?.trim() ?? '') : '' const lastAssistantMessage = agent.entry.lastAssistantMessage?.trim() ?? '' @@ -164,7 +170,9 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ }` : [formatAgentTypeLabel(agent.agentType), model].filter(Boolean).join(' · ') // Why: interrupted is a terminal outcome, so surface it in the leading state dot. - const dotState: AgentDotState = isInterrupted ? 'interrupted' : asDotState(agent.state) + const dotState: AgentDotState = isInterrupted + ? 'interrupted' + : asDotState(agent.state, agent.entry.workingMode) const dotTooltipLabel = stateDotTooltipLabel(agent, dotState) // Why: always show the chevron so the row's right edge doesn't flicker as content grows/shrinks. diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts index ad032c1684d..bae9892736d 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts @@ -201,6 +201,23 @@ describe('buildDashboardSnapshot', () => { expect(card.unseen).toBe(true) }) + it('keeps monitoring in the working bucket with a passive dot state', () => { + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { + [PANE_KEY]: entry({ state: 'working', workingMode: 'monitoring' }) + } + }), + NOW + ) + + expect(snapshot.cards[0]).toMatchObject({ + bucket: 'working', + dotState: 'working', + workingMode: 'monitoring' + }) + }) + it('publishes terminal-backed orchestrated workers under their direct parent', () => { const snapshot = buildDashboardSnapshot( baseState({ diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index d6d5e6545ff..e0091066972 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -210,10 +210,16 @@ export function buildDashboardSnapshot( ? layoutPtyId : null const dotState = row.state as DashboardCardDotState + const workingMode = + row.state === 'working' && row.entry.workingMode === 'monitoring' + ? row.entry.workingMode + : undefined const unseen = !isTitleDerived && (state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt - const bucket = dashboardBucketForDotState(dashboardCardDisplayState({ dotState, unseen })) + const bucket = dashboardBucketForDotState( + dashboardCardDisplayState({ dotState, workingMode, unseen }) + ) // Why: only a live pty can open a preview terminal, and only a // card-rendering caller can open one — the sidebar's bucket counts must // not pay host resolution on every agent-status tick. @@ -249,6 +255,7 @@ export function buildDashboardSnapshot( agentType: row.agentType, bucket, dotState, + ...(workingMode ? { workingMode } : {}), task: isTitleDerived ? '' : rowTask(row), repoId: workspace.projectId, worktreeId, diff --git a/src/renderer/src/components/dashboard/dashboard-card-bucket.ts b/src/renderer/src/components/dashboard/dashboard-card-bucket.ts index a81129a227c..b02b6d7d0f7 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-bucket.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-bucket.ts @@ -1,8 +1,12 @@ -import type { DashboardBucket, DashboardCardDotState } from '../../../../shared/dashboard-snapshot' +import type { + DashboardBucket, + DashboardCardDisplayState +} from '../../../../shared/dashboard-snapshot' -export function dashboardBucketForDotState(state: DashboardCardDotState): DashboardBucket { +export function dashboardBucketForDotState(state: DashboardCardDisplayState): DashboardBucket { switch (state) { case 'working': + case 'monitoring': return 'working' case 'done': return 'done' diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index adb0c3d1ed8..adde4669ef4 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -245,7 +245,7 @@ function AgentTargetMenuItem({ onSend: (target: NotesSendAgentTarget) => void }): React.JSX.Element { const tabTitle = target.tabTitle.trim() - const state = asDotState(agent?.state ?? 'idle') + const state = asDotState(agent?.state ?? 'idle', agent?.entry.workingMode) const timeAgo = agent ? formatAgentRelativeTime(agent, now) : null const secondaryParts = [ agentStateLabel(state), @@ -302,9 +302,13 @@ function orderSendTargetsByWorktreeAgentRows( return ordered } -function asDotState(state: AgentStatusState | 'idle'): AgentDotState { +function asDotState( + state: AgentStatusState | 'idle', + workingMode?: DashboardAgentRowData['entry']['workingMode'] +): AgentDotState { switch (state) { case 'working': + return workingMode === 'monitoring' ? 'monitoring' : 'working' case 'blocked': case 'waiting': case 'done': diff --git a/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts index 0d462dd067d..687dbc83ad2 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts @@ -6,7 +6,12 @@ export function useNativeChatHookStatus( ): readonly [AgentStatusState | null, number | null, boolean] { // Why: primitive selectors keep unrelated pane/status updates from rerendering // native chat while still exposing the three fields used for reconciliation. - const state = useAppStore((store) => store.agentStatusByPaneKey[paneKey]?.state ?? null) + const state = useAppStore((store) => { + const entry = store.agentStatusByPaneKey[paneKey] + return entry?.state === 'working' && entry.workingMode === 'monitoring' + ? null + : (entry?.state ?? null) + }) const stateStartedAt = useAppStore( (store) => store.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null ) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index 3d3a3206cc5..3bb2e2785e4 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -401,6 +401,32 @@ describe('useNativeChatLiveSession — transport routing', () => { expect(latest?.status).toBe('ready') }) + it('stops foreground working UI when Claude enters monitoring', async () => { + useAppStore.setState({ + agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + }) + const transport = getMockTransport('env-1') + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + await act(async () => + transport.emit({ + type: 'snapshot', + messages: [user('u-1', 'run it')], + hasMore: false + }) + ) + expect(latest?.status).toBe('working') + + await act(async () => { + useAppStore.setState({ + agentStatusByPaneKey: { + [PANE]: { state: 'working', workingMode: 'monitoring', stateStartedAt: 1 } as never + } + }) + }) + + expect(latest?.status).toBe('ready') + }) + it('applies a lifecycle-only append after the final message frame', async () => { useAppStore.setState({ agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } diff --git a/src/renderer/src/components/pet/pet-agent-state.test.ts b/src/renderer/src/components/pet/pet-agent-state.test.ts index 09dc0813b5b..98514eb66ca 100644 --- a/src/renderer/src/components/pet/pet-agent-state.test.ts +++ b/src/renderer/src/components/pet/pet-agent-state.test.ts @@ -46,6 +46,14 @@ describe('selectPetAnimationName', () => { expect(select([entry('working')])).toBe('running') }) + it('keeps passive monitoring out of the running animation', () => { + const monitoring = entry('working', { workingMode: 'monitoring' }) + + expect(select([monitoring])).toBe('idle') + expect(select([monitoring, entry('working')])).toBe('running') + expect(select([monitoring, entry('done')])).toBe('review') + }) + it('maps blocked and waiting states to waiting', () => { expect(select([entry('blocked')])).toBe('waiting') expect(select([entry('waiting')])).toBe('waiting') diff --git a/src/renderer/src/components/pet/pet-agent-state.ts b/src/renderer/src/components/pet/pet-agent-state.ts index 72a0f520f66..bc5a711ce62 100644 --- a/src/renderer/src/components/pet/pet-agent-state.ts +++ b/src/renderer/src/components/pet/pet-agent-state.ts @@ -53,7 +53,7 @@ function agentStateAnimation( if (entry.state === 'blocked' || entry.state === 'waiting') { return 'waiting' } - if (entry.state === 'working') { + if (entry.state === 'working' && entry.workingMode !== 'monitoring') { hasWorking = true } else if (entry.state === 'done') { hasDone = true diff --git a/src/renderer/src/components/sidebar/StatusIndicator.test.ts b/src/renderer/src/components/sidebar/StatusIndicator.test.ts index d447f05cad4..89fdcb12f75 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.test.ts +++ b/src/renderer/src/components/sidebar/StatusIndicator.test.ts @@ -31,6 +31,15 @@ describe('StatusIndicator', () => { expect(markup).toContain('motion-reduce:border-t-yellow-500') }) + it('renders monitoring as a static radio glyph', () => { + const markup = renderMarkup('monitoring') + + expect(markup).toContain('title="Monitoring background tasks"') + expect(markup).toContain('lucide-radio') + expect(markup).toContain('text-yellow-500') + expect(markup).not.toContain('data-agent-spinner') + }) + it('renders permission as the shared question glyph', () => { const markup = renderMarkup('permission') diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index 2b3da37a092..97726487c19 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { Radio } from 'lucide-react' import { cn } from '@/lib/utils' import { AgentQuestionIcon } from '@/components/AgentQuestionIcon' import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' @@ -40,6 +41,18 @@ const StatusIndicator = React.memo(function StatusIndicator({ ) } + if (status === 'monitoring') { + return ( + + + ) + } + if (status === 'permission') { return ( selectTerminalLayoutRootsForWorktree(s, worktreeId)) ) - const { hasPermission, hasLiveWorking, hasLiveDone, hasRetainedDone, agentStatusPaneIdsByTabId } = - useAppStore(useShallow((s) => selectWorktreeAgentActivitySummary(s, worktreeId))) + const { + hasPermission, + hasLiveWorking, + hasLiveMonitoring, + hasLiveDone, + hasRetainedDone, + agentStatusPaneIdsByTabId + } = useAppStore(useShallow((s) => selectWorktreeAgentActivitySummary(s, worktreeId))) // Why: compact and detailed cards need the same status-dot semantics: // runtime liveness gates title-derived states, then explicit agent rows can @@ -39,6 +45,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { terminalLayoutRootsByTabId, hasPermission, hasLiveWorking, + hasLiveMonitoring, hasLiveDone, hasRetainedDone }), @@ -51,6 +58,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { terminalLayoutRootsByTabId, hasPermission, hasLiveWorking, + hasLiveMonitoring, hasLiveDone, hasRetainedDone ] diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts index 4b1216d7151..a281af71630 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts @@ -33,6 +33,7 @@ export function selectWorktreeActivityStatuses( const { hasPermission, hasLiveWorking, + hasLiveMonitoring, hasLiveDone, hasRetainedDone, agentStatusPaneIdsByTabId @@ -48,6 +49,7 @@ export function selectWorktreeActivityStatuses( terminalLayoutRootsByTabId: selectTerminalLayoutRootsForWorktree(statusInputs, worktreeId), hasPermission, hasLiveWorking, + hasLiveMonitoring, hasLiveDone, hasRetainedDone }) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index ed086d7c94e..f14a01d784d 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -16,6 +16,7 @@ function makeAgentStatusEntry(args: { worktreeId?: string parentPaneKey?: string restoredUnconfirmed?: true + workingMode?: AgentStatusEntry['workingMode'] }): AgentStatusEntry { return { paneKey: args.paneKey, @@ -26,6 +27,7 @@ function makeAgentStatusEntry(args: { stateHistory: [], worktreeId: args.worktreeId, restoredUnconfirmed: args.restoredUnconfirmed, + workingMode: args.workingMode, orchestration: args.parentPaneKey ? { taskId: 'task-1', @@ -167,6 +169,30 @@ describe('selectWorktreeAgentActivitySummary', () => { expect(nowSpy).toHaveBeenCalledTimes(2) }) + it('separates passive monitoring from active working', () => { + vi.spyOn(Date, 'now').mockReturnValue(2_000) + const paneKey = makePaneKey('tab-1', LEAF_ID) + const summary = selectWorktreeAgentActivitySummary( + { + tabsByWorktree: { 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] }, + agentStatusEpoch: 1, + agentStatusByPaneKey: { + [paneKey]: makeAgentStatusEntry({ + paneKey, + state: 'working', + workingMode: 'monitoring' + }) + }, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + }, + 'repo::/wt-1' + ) + + expect(summary).toMatchObject({ hasLiveWorking: false, hasLiveMonitoring: true }) + }) + it('lets an unconfirmed restored row suppress only its pane title', () => { vi.spyOn(Date, 'now').mockReturnValue(2_000) const paneKey = makePaneKey('tab-1', LEAF_ID) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index 38e585d39ce..638341bf8f3 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -15,6 +15,7 @@ import { export type WorktreeAgentActivitySummary = { hasPermission: boolean hasLiveWorking: boolean + hasLiveMonitoring: boolean hasLiveDone: boolean hasRetainedDone: boolean agentStatusPaneIdsByTabId: Record> @@ -25,6 +26,7 @@ const EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID: Record> const EMPTY_SUMMARY: WorktreeAgentActivitySummary = { hasPermission: false, hasLiveWorking: false, + hasLiveMonitoring: false, hasLiveDone: false, hasRetainedDone: false, agentStatusPaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID @@ -177,6 +179,7 @@ function summariesEqual( return ( previous.hasPermission === next.hasPermission && previous.hasLiveWorking === next.hasLiveWorking && + previous.hasLiveMonitoring === next.hasLiveMonitoring && previous.hasLiveDone === next.hasLiveDone && previous.hasRetainedDone === next.hasRetainedDone && agentStatusPaneIdsByTabIdEqual( @@ -214,12 +217,16 @@ function agentStatusPaneIdsByTabIdEqual( function applyLiveAgentState( summary: WorktreeAgentActivitySummary, - entry: Pick + entry: Pick ): void { if (entry.state === 'blocked' || entry.state === 'waiting') { summary.hasPermission = true } else if (entry.state === 'working') { - summary.hasLiveWorking = true + if (entry.workingMode === 'monitoring') { + summary.hasLiveMonitoring = true + } else { + summary.hasLiveWorking = true + } } else if (entry.state === 'done') { summary.hasLiveDone = true } diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts new file mode 100644 index 00000000000..3aa1c857547 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import { getCompactAgentSecondary } from './worktree-card-compact-agent-row' +import { getAgentDotState, summarizeAgents } from './worktree-card-agent-summary' + +function monitoringAgent(): DashboardAgentRowData { + return { + paneKey: 'tab-1:leaf-1', + state: 'working', + agentType: 'claude', + startedAt: 1, + tab: { + id: 'tab-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'Claude', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + }, + entry: { + state: 'working', + workingMode: 'monitoring', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + paneKey: 'tab-1:leaf-1' + } + } +} + +describe('worktree card agent summary', () => { + it('presents passive working as monitoring', () => { + const agent = monitoringAgent() + + expect(getAgentDotState(agent)).toBe('monitoring') + expect(getCompactAgentSecondary(agent)).toBe('Monitoring background tasks') + expect(summarizeAgents([agent], 'Agent')).toBe('Agent monitoring') + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts index dc08ea6f271..6c66d5cb9d3 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts @@ -13,6 +13,7 @@ const SUMMARY_STATE_ORDER: AgentDotState[] = [ 'blocked', 'interrupted', 'working', + 'monitoring', 'done', 'idle' ] @@ -30,7 +31,12 @@ function asDotState(state: AgentStatusState | 'idle'): AgentDotState { } export function getAgentDotState(agent: DashboardAgentRowData): AgentDotState { - return agent.entry.interrupted === true ? 'interrupted' : asDotState(agent.state) + if (agent.entry.interrupted === true) { + return 'interrupted' + } + return agent.state === 'working' && agent.entry.workingMode === 'monitoring' + ? 'monitoring' + : asDotState(agent.state) } export function formatSummaryStateLabel(state: AgentDotState): string { @@ -45,6 +51,8 @@ export function formatSummaryStateLabel(state: AgentDotState): string { return 'failed' case 'working': return 'working' + case 'monitoring': + return 'monitoring' case 'done': return 'done' case 'idle': diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index c285d4769af..258869b07c3 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -37,10 +37,14 @@ function getCompactAgentPrimary( return prompt || agentStateLabel(getAgentDotState(agent)) } -function getCompactAgentSecondary(agent: DashboardAgentRowData): string { +export function getCompactAgentSecondary(agent: DashboardAgentRowData): string { if (agent.entry.interrupted === true) { return 'Interrupted by user' } + // Why: the lead turn is over in monitoring, so its last tool line is stale; name the state instead. + if (agent.state === 'working' && agent.entry.workingMode === 'monitoring') { + return agentStateLabel('monitoring') + } const toolPreview = formatAgentToolPreview(agent.entry, agent.state) if (toolPreview) { return toolPreview diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index e475ae49a4d..f173b4261f3 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -62,6 +62,28 @@ describe('resolveTerminalTabActivityStatus', () => { ).toBe('working') }) + it('reports monitoring without hiding active or actionable siblings', () => { + const monitoring = entry(FIRST_LEAF_ID, 'working', { workingMode: 'monitoring' }) + const working = entry(SECOND_LEAF_ID, 'working') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [monitoring.paneKey]: monitoring }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('monitoring') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { + [monitoring.paneKey]: monitoring, + [working.paneKey]: working + }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('working') + }) + it('lets a needs-input pane outrank a working sibling', () => { const working = entry(FIRST_LEAF_ID, 'working') const waiting = entry(SECOND_LEAF_ID, 'waiting') @@ -246,6 +268,9 @@ describe('resolveTerminalTabAttentionBadge', () => { expect(resolveTerminalTabAttentionBadge({ status: 'permission', hasUnread: true })).toBe( 'permission' ) + expect(resolveTerminalTabAttentionBadge({ status: 'monitoring', hasUnread: true })).toBe( + 'monitoring' + ) expect(resolveTerminalTabAttentionBadge({ status: 'done', hasUnread: true })).toBe('unread') expect(resolveTerminalTabAttentionBadge({ status: 'done', hasUnread: false })).toBe('done') expect(resolveTerminalTabAttentionBadge({ status: 'active', hasUnread: false })).toBeNull() @@ -274,6 +299,7 @@ describe('terminalTabHasUnreadActivity', () => { describe('terminalTabActivityToAgentDotState', () => { it('maps glyph statuses and drops quiet ones', () => { expect(terminalTabActivityToAgentDotState('working')).toBe('working') + expect(terminalTabActivityToAgentDotState('monitoring')).toBe('monitoring') expect(terminalTabActivityToAgentDotState('permission')).toBe('permission') expect(terminalTabActivityToAgentDotState('done')).toBe('done') expect(terminalTabActivityToAgentDotState('active')).toBeNull() diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts index afba5dc4c90..c130a602e1a 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -19,6 +19,7 @@ export type TerminalTabActivityStatus = WorktreeStatus type TerminalTabActivityFlags = { hasPermission: boolean hasLiveWorking: boolean + hasLiveMonitoring: boolean hasLiveDone: boolean paneIds: Set } @@ -75,7 +76,11 @@ function getTerminalTabActivityFlags( if (entry.state === 'blocked' || entry.state === 'waiting') { flags.hasPermission = true } else if (entry.state === 'working') { - flags.hasLiveWorking = true + if (entry.workingMode === 'monitoring') { + flags.hasLiveMonitoring = true + } else { + flags.hasLiveWorking = true + } } else if (entry.state === 'done') { // Why: an interrupted `done` still reads as completed here, matching the // WorktreeCard dot (resolveWorktreeStatus has no interrupted state); only @@ -97,6 +102,7 @@ function getOrCreateTerminalTabActivityFlags( flags = { hasPermission: false, hasLiveWorking: false, + hasLiveMonitoring: false, hasLiveDone: false, paneIds: new Set() } @@ -157,6 +163,7 @@ export function resolveTerminalTabActivityStatus({ terminalLayoutsByTabId: terminalLayout ? { [tab.id]: terminalLayout } : undefined, hasPermission: flags?.hasPermission ?? false, hasLiveWorking: flags?.hasLiveWorking ?? false, + hasLiveMonitoring: flags?.hasLiveMonitoring ?? false, hasLiveDone: flags?.hasLiveDone ?? false, // Why: retained/orchestration promotions are worktree-aggregate concerns; // a tab reflects its own live panes and title only. @@ -166,14 +173,14 @@ export function resolveTerminalTabActivityStatus({ /** True while the tab shows a live in-turn signal (spinner or needs-input). */ export function isTerminalTabActivityLive(status: TerminalTabActivityStatus): boolean { - return status === 'working' || status === 'permission' + return status === 'working' || status === 'monitoring' || status === 'permission' } /** * Glyph-bearing attention states for a terminal tab (tab bar + Cmd+J recent chats). * Quiet active/inactive map to null so identity icons stay clean. */ -export type TerminalTabAttentionBadge = 'working' | 'permission' | 'unread' | 'done' +export type TerminalTabAttentionBadge = 'working' | 'monitoring' | 'permission' | 'unread' | 'done' /** * Single priority ladder shared by the tab strip and Cmd+J recent rows: @@ -192,6 +199,9 @@ export function resolveTerminalTabAttentionBadge({ if (status === 'permission') { return 'permission' } + if (status === 'monitoring') { + return 'monitoring' + } if (hasUnread) { return 'unread' } @@ -204,9 +214,10 @@ export function resolveTerminalTabAttentionBadge({ /** Map a container activity status onto AgentStateDot's vocabulary (no unread — that's a bell). */ export function terminalTabActivityToAgentDotState( status: TerminalTabActivityStatus -): 'working' | 'permission' | 'done' | null { +): 'working' | 'monitoring' | 'permission' | 'done' | null { switch (status) { case 'working': + case 'monitoring': case 'permission': case 'done': return status diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts index c52a9a020e6..9bf1fb0896b 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts @@ -139,7 +139,9 @@ export function buildWorkspaceCleanupFacets( isPinned: worktree?.isPinned ?? candidate.blockers.includes('pinned'), isUnread: worktree?.isUnread ?? false, hasComment, - agentState: sources.liveAgentStatusByWorktreeId?.get(candidate.worktreeId) ?? 'idle', + agentState: toWorkspaceCleanupAgentState( + sources.liveAgentStatusByWorktreeId?.get(candidate.worktreeId) + ), retainedDoneAgentCount: candidate.localContext.retainedDoneAgentCount, gitState: getWorkspaceCleanupGitState(candidate), upstreamAhead: toFiniteOrNull(candidate.git.upstreamAhead), @@ -168,6 +170,17 @@ export function countWorkspaceCleanupMeasuredRows(rows: readonly WorkspaceCleanu return rows.reduce((count, row) => count + (row.sizeBytes === null ? 0 : 1), 0) } +// Why: monitoring is still registered background work, so it filters as active — offering +// such a workspace as idle would invite cleaning up a running dev server (#10997). +function toWorkspaceCleanupAgentState( + status: LiveAgentWorktreeStatus | undefined +): WorkspaceCleanupAgentState { + if (status === undefined) { + return 'idle' + } + return status === 'monitoring' ? 'working' : status +} + function getLocalContextCount(candidate: WorkspaceCleanupCandidate): number { const context = candidate.localContext return ( diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-hydration.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-hydration.test.ts index 30dd6026114..7d893fb2ed8 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-hydration.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-hydration.test.ts @@ -253,6 +253,7 @@ describe('useIpcEvents agent status snapshot integration', () => { { paneKey: FUTURE_PANE_KEY, state: 'working' as const, + workingMode: 'monitoring' as const, prompt: 'p', agentType: 'claude', receivedAt: 1_700_000_000_000, @@ -340,7 +341,12 @@ describe('useIpcEvents agent status snapshot integration', () => { expect(setAgentStatuses.mock.calls[0]?.[0]).toHaveLength(1) expect(setAgentStatus).toHaveBeenCalledWith( FUTURE_PANE_KEY, - expect.objectContaining({ state: 'working', prompt: 'p', agentType: 'claude' }), + expect.objectContaining({ + state: 'working', + workingMode: 'monitoring', + prompt: 'p', + agentType: 'claude' + }), 'Future Tab', { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 }, expectWorktreeRouting('wt-1'), diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 333f5b51a69..9370a2f367c 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -3310,6 +3310,7 @@ export function useIpcEvents(): void { const ownerTabId = parsePaneKey(paneKey)?.tabId ?? data.tabId const payload = normalizeAgentStatusPayload({ state: data.state, + workingMode: data.workingMode, prompt: data.prompt, agentType: data.agentType, model: data.model, diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts index c36a48dc615..85b348dc639 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts @@ -240,6 +240,29 @@ describe('resolveRecentWorkspaceTabStatus', () => { ).toBe('working') }) + it('preserves monitoring unless another pane is actively working', () => { + const monitoring = row('monitoring') + const monitoringEntry = entry('monitoring', 'working', NOW, { + workingMode: 'monitoring' + }) + + expect(resolveRecentWorkspaceTabStatus(monitoring, sources([monitoringEntry]), NOW)).toBe( + 'monitoring' + ) + expect( + resolveRecentWorkspaceTabStatus( + monitoring, + sources([ + monitoringEntry, + entry('monitoring', 'working', NOW, { + paneKey: 'monitoring:22222222-3333-4444-8555-666666666666' + }) + ]), + NOW + ) + ).toBe('working') + }) + it('falls back to live-pty presence for idle rows', () => { const live = row('live') diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.ts b/src/renderer/src/lib/recent-workspace-tab-rows.ts index 5005030b81a..6123b7c904d 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.ts @@ -79,14 +79,24 @@ export function resolveRecentWorkspaceTabStatus( paneSources: TabPaneInputSources, now: number ): WorktreeStatus { - const attention = resolveRecentWorkspaceTabAttention(row, paneSources, now) + if (!row.terminalTab) { + return 'inactive' + } + const panes = collectTabPaneInputs(row.terminalTab, row.worktreeLastActivityAt, paneSources, now) + const attention = resolveAttention(panes, now) const explicit = STATUS_BY_ATTENTION_CLASS[attention.cls] + if (explicit === 'working') { + const hasForegroundWork = panes.some( + (pane) => + resolveAttention([pane], now).cls === 3 && + (pane.kind === 'title' || pane.entry.workingMode !== 'monitoring') + ) + return hasForegroundWork ? 'working' : 'monitoring' + } if (explicit) { return explicit } - return row.terminalTab && tabHasLivePty(paneSources.ptyIdsByTabId, row.terminalTab.id) - ? 'active' - : 'inactive' + return tabHasLivePty(paneSources.ptyIdsByTabId, row.terminalTab.id) ? 'active' : 'inactive' } /** `TabGroup.recentTabIds` keeps most-recent at the tail, so the index is the ordinal. */ diff --git a/src/renderer/src/lib/worktree-activity-state.test.ts b/src/renderer/src/lib/worktree-activity-state.test.ts index c0e5855a598..c378e751262 100644 --- a/src/renderer/src/lib/worktree-activity-state.test.ts +++ b/src/renderer/src/lib/worktree-activity-state.test.ts @@ -223,4 +223,41 @@ describe('getWorktreeIdsWithLiveAgent', () => { ]) ) }) + + it('reports monitoring below active working and permission', () => { + const entries = { + 'tab-1:leaf-1': makeAgentEntry({ + paneKey: 'tab-1:leaf-1', + worktreeId: 'wt-1', + workingMode: 'monitoring' + }), + 'tab-2:leaf-2': makeAgentEntry({ + paneKey: 'tab-2:leaf-2', + worktreeId: 'wt-2', + workingMode: 'monitoring' + }), + 'tab-3:leaf-3': makeAgentEntry({ + paneKey: 'tab-3:leaf-3', + worktreeId: 'wt-2' + }), + 'tab-4:leaf-4': makeAgentEntry({ + paneKey: 'tab-4:leaf-4', + worktreeId: 'wt-3', + workingMode: 'monitoring' + }), + 'tab-5:leaf-5': makeAgentEntry({ + paneKey: 'tab-5:leaf-5', + worktreeId: 'wt-3', + state: 'waiting' + }) + } + + expect(getLiveAgentStatusByWorktreeId(entries, {}, NOW)).toEqual( + new Map([ + ['wt-1', 'monitoring'], + ['wt-2', 'working'], + ['wt-3', 'permission'] + ]) + ) + }) }) diff --git a/src/renderer/src/lib/worktree-activity-state.ts b/src/renderer/src/lib/worktree-activity-state.ts index 2f6e804dcc7..a319b6a8dc3 100644 --- a/src/renderer/src/lib/worktree-activity-state.ts +++ b/src/renderer/src/lib/worktree-activity-state.ts @@ -12,7 +12,7 @@ type BrowserLikeTab = { id: string } type TabsByWorktree = Record type PtyIdsByTabId = Record type BrowserTabsByWorktree = Record -export type LiveAgentWorktreeStatus = 'working' | 'permission' +export type LiveAgentWorktreeStatus = 'working' | 'monitoring' | 'permission' /** * Worktree ids that currently have a live agent session, derived from the @@ -50,8 +50,18 @@ export function getLiveAgentStatusByWorktreeId( for (const entry of entries) { const worktreeId = resolveAgentStatusWorktreeId(entry, worktreeIdByTabId) if (worktreeId) { - const status = entry.state === 'working' ? 'working' : 'permission' - if (status === 'permission' || !result.has(worktreeId)) { + const status = + entry.state === 'working' + ? entry.workingMode === 'monitoring' + ? 'monitoring' + : 'working' + : 'permission' + const current = result.get(worktreeId) + if ( + status === 'permission' || + current === undefined || + (status === 'working' && current === 'monitoring') + ) { result.set(worktreeId, status) } } diff --git a/src/renderer/src/lib/worktree-status.test.ts b/src/renderer/src/lib/worktree-status.test.ts index bdea2105785..0f096260640 100644 --- a/src/renderer/src/lib/worktree-status.test.ts +++ b/src/renderer/src/lib/worktree-status.test.ts @@ -254,6 +254,41 @@ describe('resolveWorktreeStatus', () => { expect(status).toBe('working') }) + it('reports passive monitoring below active work and permission', () => { + const base = { + tabs: [{ id: 'tab-1', title: 'bash' }], + browserTabs: [], + ptyIdsByTabId: livePtyMap('tab-1'), + hasLiveDone: false, + hasRetainedDone: false + } + + expect( + resolveWorktreeStatus({ + ...base, + hasPermission: false, + hasLiveWorking: false, + hasLiveMonitoring: true + }) + ).toBe('monitoring') + expect( + resolveWorktreeStatus({ + ...base, + hasPermission: false, + hasLiveWorking: true, + hasLiveMonitoring: true + }) + ).toBe('working') + expect( + resolveWorktreeStatus({ + ...base, + hasPermission: true, + hasLiveWorking: true, + hasLiveMonitoring: true + }) + ).toBe('permission') + }) + it('lets heuristic working beat hasLiveDone (newer in-progress signal wins)', () => { const status = resolveWorktreeStatus({ tabs: [{ id: 'tab-1', title: 'claude [working]' }], diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 91470f75409..038e9f8ef11 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -11,7 +11,13 @@ import type { import type { TuiAgent } from '../../../shared/tui-agent' import type { LiveAgentWorktreeStatus } from './worktree-activity-state' -export type WorktreeStatus = 'active' | 'working' | 'permission' | 'done' | 'inactive' +export type WorktreeStatus = + | 'active' + | 'working' + | 'monitoring' + | 'permission' + | 'done' + | 'inactive' type WorktreeStatusHeuristicOptions = { liveAgentStatus?: LiveAgentWorktreeStatus @@ -23,6 +29,7 @@ type WorktreeStatusHeuristicOptions = { const STATUS_LABELS: Record = { active: 'Active', working: 'Working', + monitoring: 'Monitoring background tasks', permission: 'Needs permission', done: 'Done', inactive: 'Inactive' @@ -48,6 +55,9 @@ export function getWorktreeStatus( if (options.liveAgentStatus === 'working' || hasStatus('working')) { return 'working' } + if (options.liveAgentStatus === 'monitoring') { + return 'monitoring' + } if (liveTabs.length > 0 || browserTabs.length > 0) { // Why: browser-only worktrees (no PTY) are still active from the user's point of view. return 'active' @@ -132,6 +142,7 @@ export function resolveWorktreeStatus(args: { terminalLayoutRootsByTabId?: Record hasPermission: boolean hasLiveWorking: boolean + hasLiveMonitoring?: boolean hasLiveDone: boolean hasRetainedDone: boolean }): WorktreeStatus { @@ -157,6 +168,9 @@ export function resolveWorktreeStatus(args: { if (args.hasLiveWorking || heuristic === 'working') { return 'working' } + if (args.hasLiveMonitoring || heuristic === 'monitoring') { + return 'monitoring' + } if (args.hasLiveDone || args.hasRetainedDone) { return 'done' } diff --git a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts index 551e584decd..c6cb21f9d2f 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts @@ -18,6 +18,7 @@ function referenceProjection(map: AppState['agentStatusByPaneKey']): string { paneKey, entryPaneKey: entry.paneKey, state: entry.state, + workingMode: entry.workingMode ?? null, prompt: entry.prompt, updatedAtBucket: Math.floor(entry.updatedAt / BUCKET_MS), stateStartedAt: entry.stateStartedAt, @@ -65,6 +66,7 @@ describe('mobile agent-status projection equivalence', () => { const shapes: AppState['agentStatusByPaneKey'][] = [] shapes.push({}) shapes.push({ 'tab-0:leaf-0': makeEntry(0) }) + shapes.push({ 'tab-0:leaf-0': makeEntry(0, { workingMode: 'monitoring' }) }) const many: AppState['agentStatusByPaneKey'] = {} for (let index = 0; index < 12; index += 1) { many[`tab-${index}:leaf-0`] = makeEntry(index) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 888c9642508..81836cc2692 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -620,6 +620,7 @@ function serializeRuntimeMobileAgentStatusEntry( paneKey, entryPaneKey: entry.paneKey, state: entry.state, + workingMode: entry.workingMode ?? null, prompt: entry.prompt, updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS), stateStartedAt: entry.stateStartedAt, diff --git a/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts index d33c6f80b5d..fcff7e7ef43 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts @@ -514,5 +514,61 @@ describe('remote-paired pane: host snapshot mirror vs client byte-derived status expect(entry?.state).toBe('blocked') expect(entry?.interactivePrompt).toBe('Allow Bash(rm -rf)?') }) + + it('adopts host monitoring metadata without replacing client-owned byte status', () => { + const store = seedPairedClientStore() + applyHostSnapshot( + store, + makeHostSnapshot({ snapshotVersion: 1, hostNow: T0, includeAgentStatus: false }), + T0 + ) + replayClientOscWorking(store, T0 + 500) + const epochBeforeHostStatus = store.getState().agentStatusEpoch + const sortEpochBeforeHostStatus = store.getState().sortEpoch + + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 2, + hostNow: T0 + 1_000, + includeAgentStatus: true, + agentStatusOverrides: { + state: 'working', + workingMode: 'monitoring', + prompt: 'host hook prompt' + } + }), + T0 + 1_000 + ) + + expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]).toMatchObject({ + state: 'working', + workingMode: 'monitoring', + prompt: CLIENT_PROMPT, + updatedAt: T0 + 500 + }) + expect(store.getState().agentStatusEpoch).toBe(epochBeforeHostStatus + 1) + expect(store.getState().sortEpoch).toBe(sortEpochBeforeHostStatus) + + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 3, + hostNow: T0 + 2_000, + includeAgentStatus: true, + agentStatusOverrides: { state: 'working' } + }), + T0 + 2_000 + ) + + expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]).toMatchObject({ + state: 'working', + prompt: CLIENT_PROMPT, + updatedAt: T0 + 500 + }) + expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.workingMode).toBeUndefined() + expect(store.getState().agentStatusEpoch).toBe(epochBeforeHostStatus + 2) + expect(store.getState().sortEpoch).toBe(sortEpochBeforeHostStatus) + }) }) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index cff237cb129..8f3472bd28e 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -1396,6 +1396,9 @@ function buildMirroredAgentStatusPatch( existing && (clientOwnsEntry || existing.updatedAt > entry.updatedAt) ? { ...normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType), + ...(clientOwnsEntry && existing.state === 'working' && entry.state === 'working' + ? { workingMode: entry.workingMode } + : {}), paneKey: entry.paneKey, worktreeId: entry.worktreeId ?? existing.worktreeId, tabId: entry.tabId, @@ -1472,6 +1475,7 @@ function buildMirroredAgentStatusPatch( existing?.state === 'done' && entry.state === 'done' && agentEntryCompletionAt(existing) !== agentEntryCompletionAt(entry) + const workingModeChanged = existing?.workingMode !== entry.workingMode const entrySortRelevantChange = !existing || existing.state !== entry.state || @@ -1480,7 +1484,8 @@ function buildMirroredAgentStatusPatch( entryAttributionChanged || doneAttentionChanged || isMirroredCommandCodeTurnBump(existing, entry) - aggregateRelevantChange = aggregateRelevantChange || entrySortRelevantChange + aggregateRelevantChange = + aggregateRelevantChange || entrySortRelevantChange || workingModeChanged sortRelevantChange = sortRelevantChange || entrySortRelevantChange } @@ -2132,6 +2137,7 @@ function agentStatusEntryEqual(a: AgentStatusEntry | undefined, b: AgentStatusEn } return ( a.state === b.state && + a.workingMode === b.workingMode && a.prompt === b.prompt && a.updatedAt === b.updatedAt && a.stateStartedAt === b.stateStartedAt && diff --git a/src/renderer/src/store/slices/agent-status-tool-assistant-fields.test.ts b/src/renderer/src/store/slices/agent-status-tool-assistant-fields.test.ts index 5b37bb0ad65..61cc02158f1 100644 --- a/src/renderer/src/store/slices/agent-status-tool-assistant-fields.test.ts +++ b/src/renderer/src/store/slices/agent-status-tool-assistant-fields.test.ts @@ -270,6 +270,30 @@ describe('agent status tool + assistant fields', () => { expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1) }) + it('bumps the status epoch when same-state working enters monitoring', () => { + vi.useFakeTimers() + const store = createTestStore() + store.getState().setAgentStatus('tab-1:1', { state: 'working', prompt: 'p' }, 'claude', { + updatedAt: 1_000, + stateStartedAt: 1_000 + }) + const firstEpoch = store.getState().agentStatusEpoch + const firstSortEpoch = store.getState().sortEpoch + + store + .getState() + .setAgentStatus( + 'tab-1:1', + { state: 'working', workingMode: 'monitoring', prompt: 'p' }, + 'claude', + { updatedAt: 2_000, stateStartedAt: 1_000 } + ) + + expect(store.getState().agentStatusByPaneKey['tab-1:1'].workingMode).toBe('monitoring') + expect(store.getState().agentStatusEpoch).toBe(firstEpoch + 1) + expect(store.getState().sortEpoch).toBe(firstSortEpoch) + }) + it('bumps the status epoch, not sort epoch, for same-state done updates', () => { vi.useFakeTimers() const store = createTestStore() diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 95cde4a3979..a6c29b681cb 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -2304,6 +2304,7 @@ export const createAgentStatusSlice: StateCreator -): AgentStatusState { +): ClaudePaneStatusResolution { if (lead.state !== 'done') { - return lead.state + return { stateName: lead.state } } const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) - return claudeRosterHasWorkingSubagent(roster) || - (!lead.interrupted && - (state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - state.claudeActiveSessionCronPaneKeys.has(paneKey))) - ? 'working' - : 'done' + if (claudeRosterHasWorkingSubagent(roster)) { + return { stateName: 'working' } + } + if ( + !lead.interrupted && + (state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + state.claudeActiveSessionCronPaneKeys.has(paneKey)) + ) { + return { stateName: 'working', workingMode: 'monitoring' } + } + return { stateName: 'done' } } /** SubagentStart/Stop/TeammateIdle update the roster and re-emit the lead's last known state with the fresh child list, so the sidebar reflects spawn/finish even when a background child outlives the lead turn with no other hook traffic. */ @@ -2851,7 +2862,9 @@ function clearClaudePendingWaitForAgent( export function clearClaudeAnsweredQuestionWait( state: HookListenerState, paneKey: string -): Pick { +): Pick & { + workingMode?: AgentWorkingMode +} { const lead = state.claudeLeadStateByPaneKey.get(paneKey) const restored = lead?.state === 'waiting' @@ -2865,11 +2878,12 @@ export function clearClaudeAnsweredQuestionWait( ? { lastAssistantMessage: previousTool.lastAssistantMessage } : {} ) - const effectiveState = resolveClaudePaneState(state, paneKey, restored) - return effectiveState === restored.state + const resolved = resolveClaudePaneStatus(state, paneKey, restored) + return resolved.stateName === restored.state && resolved.workingMode === undefined ? restored : { - state: effectiveState, + state: resolved.stateName, + ...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}), ...(restored.interrupted ? { interrupted: true as const } : {}), ...(restored.turnCompletedAt !== undefined ? { turnCompletedAt: restored.turnCompletedAt } @@ -2903,7 +2917,7 @@ function buildClaudeCachedLeadStatusPayload( } } return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, { - stateName: resolveClaudePaneState(state, paneKey, { + ...resolveClaudePaneStatus(state, paneKey, { state: leadState, interrupted: lead?.interrupted }), @@ -3075,7 +3089,7 @@ function normalizeClaudeEvent( const restored = lead.stateBeforeWait ?? { state: 'working' as const } state.claudeLeadStateByPaneKey.set(paneKey, restored) return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { - stateName: resolveClaudePaneState(state, paneKey, restored), + ...resolveClaudePaneStatus(state, paneKey, restored), updateToolSnapshot: true, interrupted: restored.interrupted, turnCompletedAt: restored.turnCompletedAt @@ -3124,7 +3138,7 @@ function normalizeClaudeEvent( state.claudeActiveSessionCronPaneKeys.delete(paneKey) } - const effectiveState = resolveClaudePaneState(state, paneKey, { + const resolvedStatus = resolveClaudePaneStatus(state, paneKey, { state: reportedStateName, interrupted }) @@ -3133,7 +3147,7 @@ function normalizeClaudeEvent( eventAgentId === undefined && isTurnBoundary && reportedStateName === 'done' && - effectiveState === 'working' && + resolvedStatus.stateName === 'working' && interrupted !== true ? Date.now() : undefined @@ -3151,7 +3165,7 @@ function normalizeClaudeEvent( if ( isTurnBoundary && eventAgentId === undefined && - effectiveState === 'working' && + resolvedStatus.stateName === 'working' && claudeRosterHasRestoredSnapshotSubagent(effectiveRoster) && !claudeRosterHasRuntimeWorkingSubagent(effectiveRoster) && !state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) && @@ -3162,7 +3176,7 @@ function normalizeClaudeEvent( } return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { - stateName: effectiveState, + ...resolvedStatus, updateToolSnapshot: true, interrupted, turnCompletedAt @@ -3177,6 +3191,7 @@ function buildClaudeStatusPayload( hookPayload: Record, options: { stateName: AgentStatusState + workingMode?: AgentWorkingMode updateToolSnapshot: boolean interrupted?: boolean sessionBoundary?: boolean @@ -3194,6 +3209,7 @@ function buildClaudeStatusPayload( // The normalizer clamps `interrupted` to done payloads, so a gated 'working' emit drops it; claudeLeadStateByPaneKey preserves it for the eventual done. return normalizeAgentStatusPayload({ state: options.stateName, + workingMode: options.workingMode, // Why: only lead-origin events may reset the prompt cache; a child-driven refresh must not blank the lead's prompt label. prompt: resolvePrompt(state, paneKey, promptText, { resetOnNewTurn: options.updateToolSnapshot && isNewTurnEvent('claude', eventName) diff --git a/src/shared/agent-hook-relay.test.ts b/src/shared/agent-hook-relay.test.ts index fb9dbae8cac..81615eaffb7 100644 --- a/src/shared/agent-hook-relay.test.ts +++ b/src/shared/agent-hook-relay.test.ts @@ -27,6 +27,7 @@ describe('agent-hook-relay wire shape', () => { compactTrigger: 'manual', payload: { state: 'working', + workingMode: 'monitoring', prompt: 'roundtrip', agentType: 'claude' } @@ -36,6 +37,7 @@ describe('agent-hook-relay wire shape', () => { expect(decoded).toEqual(envelope) expect(decoded.connectionId).toBeNull() expect(decoded.payload.prompt).toBe('roundtrip') + expect(decoded.payload.workingMode).toBe('monitoring') }) it('exposes stable JSON-RPC method names', () => { diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts new file mode 100644 index 00000000000..71c830714eb --- /dev/null +++ b/src/shared/agent-status-ipc-payload.ts @@ -0,0 +1,56 @@ +// ─── Wire shapes carried from the hook receivers to the renderer over IPC ──── +// Why: split out of agent-status-types so that module stays the status vocabulary +// (states, entries, normalization) and the transport envelopes live next to each +// other. Re-exported from agent-status-types, so existing import sites are unchanged. + +import type { AgentProviderSessionMetadata } from './agent-session-resume' +import type { WithAgentStatusObservation } from './agent-status-observation' +import type { + AgentStatusOrchestrationContext, + ParsedAgentStatusPayload +} from './agent-status-types' + +/** A PTY the pane-key migration could not move, reported for operator triage. */ +export type MigrationUnsupportedPtyEntry = { + ptyId: string + worktreeId?: string + tabId?: string + leafId?: string + /** Registry-backed UUID pane proof, when available. */ + paneKey?: string + reason: 'legacy-numeric-pane-key' + source: 'local' | 'ssh' + updatedAt: number +} + +export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { + paneKey: string + launchToken?: string + terminalHandle?: string + tabId?: string + worktreeId?: string + /** Identifies the SSH connection the event arrived on, or null for local. + * Only the remote-ingest path (`ingestRemote`) can stamp it from mux identity; the HTTP path has no mux and always sets null. */ + connectionId: string | null + /** Timestamp (ms) when the hook server received this latest status event. */ + receivedAt: number + /** Timestamp (ms) when the current state first appeared for this pane. */ + stateStartedAt: number + orchestration?: AgentStatusOrchestrationContext + providerSession?: AgentProviderSessionMetadata + /** Resume identity update only; the status-shaped fields are transport placeholders. */ + providerSessionOnly?: boolean + /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ + promptInteractionKey?: string + /** See AgentStatusEntry.restoredUnconfirmed — hydrated nonterminal provenance. */ + restoredUnconfirmed?: boolean +} & WithAgentStatusObservation + +/** Wire shape for ordinary pane teardown or a stamped SSH disconnect batch. */ +export type AgentStatusClearIpcPayload = + | { paneKey: string } + | { + transient: true + connectionId: string + clearedAt: number + } diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index f82932f3aeb..f042921fc04 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -60,6 +60,19 @@ describe('parseAgentStatusPayload', () => { } }) + it('accepts monitoring only as an optional working discriminator', () => { + expect(parseAgentStatusPayload('{"state":"working","workingMode":"monitoring"}')).toMatchObject( + { state: 'working', workingMode: 'monitoring' } + ) + expect( + parseAgentStatusPayload('{"state":"done","workingMode":"monitoring"}')?.workingMode + ).toBeUndefined() + expect( + parseAgentStatusPayload('{"state":"working","workingMode":"unknown"}')?.workingMode + ).toBeUndefined() + expect(parseAgentStatusPayload('{"state":"working"}')?.workingMode).toBeUndefined() + }) + it('returns null for invalid state', () => { expect(parseAgentStatusPayload('{"state":"running"}')).toBeNull() expect(parseAgentStatusPayload('{"state":"idle"}')).toBeNull() @@ -586,7 +599,14 @@ describe('agentSubagentsEqual', () => { // they used to take, including where stringify would have altered the payload. describe('normalizeAgentStatusPayload matches the JSON round trip', () => { const CASES: Record[] = [ - { state: 'working', prompt: 'p', agentType: 'grok', toolName: 'sh', toolInput: 'ls' }, + { + state: 'working', + workingMode: 'monitoring', + prompt: 'p', + agentType: 'grok', + toolName: 'sh', + toolInput: 'ls' + }, { state: 'done', prompt: '', agentType: 'devin', interrupted: true }, // stringify DROPS undefined-valued keys; the direct path passes them through { diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 6df1c6568f7..515be93a363 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -14,9 +14,15 @@ import { import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' export { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization' +export type { + AgentStatusClearIpcPayload, + AgentStatusIpcPayload, + MigrationUnsupportedPtyEntry +} from './agent-status-ipc-payload' export const AGENT_STATUS_STATES = ['working', 'blocked', 'waiting', 'done'] as const export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number] +export type AgentWorkingMode = 'monitoring' // Why: agent types aren't a fixed set (custom agents exist); any non-empty string is // accepted — these well-known names are just a convenience union for pattern-matching. export type WellKnownAgentType = @@ -93,6 +99,8 @@ export type AgentSubagentSnapshot = { export type AgentStatusEntry = { state: AgentStatusState + /** Ongoing work that does not require foreground agent execution. Only valid while working. */ + workingMode?: AgentWorkingMode /** The user's most recent prompt. Cached across the turn — later tool-use events * omit it, so the last value persists until a new prompt or pane reset. Empty when unknown. */ prompt: string @@ -154,23 +162,13 @@ export type AgentStatusEntry = { restoredUnconfirmed?: boolean } & WithAgentStatusObservation -export type MigrationUnsupportedPtyEntry = { - ptyId: string - worktreeId?: string - tabId?: string - leafId?: string - /** Registry-backed UUID pane proof, when available. */ - paneKey?: string - reason: 'legacy-numeric-pane-key' - source: 'local' | 'ssh' - updatedAt: number -} - // ─── Agent status payload shape (what hook receivers send via IPC) ────────── // Hook integrations provide only normalized state fields; the renderer fills the rest (updatedAt, paneKey, …) on IPC receipt. export type AgentStatusPayload = { state: AgentStatusState + /** Ongoing work that does not require foreground agent execution. Only valid while working. */ + workingMode?: AgentWorkingMode prompt?: string agentType?: AgentType model?: string @@ -212,6 +210,7 @@ export function pickParsedAgentStatusPayload( ): ParsedAgentStatusPayload { return { state: row.state, + ...(row.workingMode !== undefined ? { workingMode: row.workingMode } : {}), prompt: row.prompt, ...(row.agentType !== undefined ? { agentType: row.agentType } : {}), ...(row.model !== undefined ? { model: row.model } : {}), @@ -232,38 +231,6 @@ export function pickParsedAgentStatusPayload( * Wire shape for agent-status IPC. Both `agentStatus:set` and `agentStatus:getSnapshot` * produce this shape so renderer call sites share a single `setAgentStatus` path. */ -export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { - paneKey: string - launchToken?: string - terminalHandle?: string - tabId?: string - worktreeId?: string - /** Identifies the SSH connection the event arrived on, or null for local. - * Only the remote-ingest path (`ingestRemote`) can stamp it from mux identity; the HTTP path has no mux and always sets null. */ - connectionId: string | null - /** Timestamp (ms) when the hook server received this latest status event. */ - receivedAt: number - /** Timestamp (ms) when the current state first appeared for this pane. */ - stateStartedAt: number - orchestration?: AgentStatusOrchestrationContext - providerSession?: AgentProviderSessionMetadata - /** Resume identity update only; the status-shaped fields are transport placeholders. */ - providerSessionOnly?: boolean - /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ - promptInteractionKey?: string - /** See AgentStatusEntry.restoredUnconfirmed — hydrated nonterminal provenance. */ - restoredUnconfirmed?: boolean -} & WithAgentStatusObservation - -/** Wire shape for ordinary pane teardown or a stamped SSH disconnect batch. */ -export type AgentStatusClearIpcPayload = - | { paneKey: string } - | { - transient: true - connectionId: string - clearedAt: number - } - /** Maximum character length for the toolName field. */ export const AGENT_STATUS_TOOL_NAME_MAX_LENGTH = 60 /** Maximum character length for the toolInput preview. */ @@ -407,6 +374,7 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload | } return { state: state as AgentStatusState, + workingMode: state === 'working' && obj.workingMode === 'monitoring' ? 'monitoring' : undefined, prompt: normalizePromptField(obj.prompt), // Why: normalize like the other single-line fields so embedded newlines (e.g. `agentType: "claude\nrogue"`) can't break single-line UI and equality checks. agentType: normalizeOptionalField(obj.agentType, AGENT_TYPE_MAX_LENGTH), diff --git a/src/shared/agent-terminal-status-equivalence.test.ts b/src/shared/agent-terminal-status-equivalence.test.ts new file mode 100644 index 00000000000..dd3affba347 --- /dev/null +++ b/src/shared/agent-terminal-status-equivalence.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { terminalStatusPayloadMatchesHook } from './agent-terminal-status-equivalence' + +describe('terminalStatusPayloadMatchesHook', () => { + const hook = { + state: 'working' as const, + workingMode: 'monitoring' as const, + prompt: 'watch tests', + agentType: 'claude' + } + + it('accepts an otherwise equivalent terminal payload that omits hook-owned mode', () => { + expect( + terminalStatusPayloadMatchesHook(hook, { + state: 'working', + prompt: 'watch tests', + agentType: 'claude' + }) + ).toBe(true) + }) + + it('rejects a terminal payload from a different turn', () => { + expect( + terminalStatusPayloadMatchesHook(hook, { + state: 'working', + prompt: 'fix tests', + agentType: 'claude' + }) + ).toBe(false) + }) +}) diff --git a/src/shared/agent-terminal-status-equivalence.ts b/src/shared/agent-terminal-status-equivalence.ts new file mode 100644 index 00000000000..d80eab80f34 --- /dev/null +++ b/src/shared/agent-terminal-status-equivalence.ts @@ -0,0 +1,32 @@ +import type { ParsedAgentStatusPayload } from './agent-status-types' + +/** Whether a terminal status is the same hook status with only hook-owned metadata omitted. + * + * `preserveActiveTurnStamp` marks the cached hook payload as the still-open turn the OSC ping + * belongs to: OSC carries neither the turn stamp nor the assistant tail, so their absence must + * read as "omitted", not "changed", or the ping overwrites a live turn's stamp. */ +export function terminalStatusPayloadMatchesHook( + cachedHookPayload: ParsedAgentStatusPayload, + terminalPayload: ParsedAgentStatusPayload, + preserveActiveTurnStamp = false +): boolean { + // OSC never carries model/children; compare only fields its protocol owns. + return ( + cachedHookPayload.state === terminalPayload.state && + // OSC never carries workingMode either; only a stated mismatch breaks equivalence. + (terminalPayload.workingMode === undefined || + cachedHookPayload.workingMode === terminalPayload.workingMode) && + cachedHookPayload.prompt === terminalPayload.prompt && + cachedHookPayload.agentType === terminalPayload.agentType && + cachedHookPayload.toolName === terminalPayload.toolName && + cachedHookPayload.toolInput === terminalPayload.toolInput && + cachedHookPayload.interactivePrompt === terminalPayload.interactivePrompt && + (cachedHookPayload.lastAssistantMessage === terminalPayload.lastAssistantMessage || + (preserveActiveTurnStamp && terminalPayload.lastAssistantMessage === undefined)) && + cachedHookPayload.interrupted === terminalPayload.interrupted && + // A session-boundary done must never dedupe against a real done (STA-3386). + cachedHookPayload.sessionBoundary === terminalPayload.sessionBoundary && + (cachedHookPayload.turnCompletedAt === terminalPayload.turnCompletedAt || + (preserveActiveTurnStamp && terminalPayload.turnCompletedAt === undefined)) + ) +} diff --git a/src/shared/claude-background-task-status.test.ts b/src/shared/claude-background-task-status.test.ts index ecf685706fe..186f924b897 100644 --- a/src/shared/claude-background-task-status.test.ts +++ b/src/shared/claude-background-task-status.test.ts @@ -112,28 +112,28 @@ describe('Claude background task status', () => { claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'run a background sleep' - })?.state - ).toBe('working') + }) + ).toMatchObject({ state: 'working', workingMode: undefined }) expect( claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] - })?.state - ).toBe('working') + }) + ).toMatchObject({ state: 'working', workingMode: 'monitoring' }) expect( claudeEvent(state, SOURCE_PANE, { hook_event_name: 'SubagentStop', agent_id: 'a70fdf2986e38302b', background_tasks: [RUNNING_SHELL] - })?.state - ).toBe('working') + }) + ).toMatchObject({ state: 'working', workingMode: 'monitoring' }) expect( claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'completed' - })?.state - ).toBe('working') + }) + ).toMatchObject({ state: 'working', workingMode: undefined }) expect( claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', @@ -142,6 +142,27 @@ describe('Claude background task status', () => { ).toBe('done') }) + it('keeps foreground child work active before falling back to monitoring', () => { + const state = createHookListenerState() + claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'SubagentStart', + agent_id: 'child-1' + }) + + expect( + claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Stop', + background_tasks: [{ id: 'child-1', type: 'subagent', status: 'running' }, RUNNING_SHELL] + }) + ).toMatchObject({ state: 'working', workingMode: undefined }) + expect( + claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'SubagentStop', + agent_id: 'child-1' + }) + ).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + }) + it('keeps pending task state when pane authority moves', () => { const state = createHookListenerState() claudeEvent(state, SOURCE_PANE, { @@ -209,15 +230,15 @@ describe('Claude background task status', () => { claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', session_crons: [{ id: 'cron-1' }] - })?.state - ).toBe('working') + }) + ).toMatchObject({ state: 'working', workingMode: 'monitoring' }) expect(state.claudeActiveSessionCronPaneKeys.has(SOURCE_PANE)).toBe(true) expect( claudeEvent(state, SOURCE_PANE, { hook_event_name: 'SubagentStop', agent_id: 'child-1' - })?.state - ).toBe('working') + }) + ).toMatchObject({ state: 'working', workingMode: 'monitoring' }) expect( claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index 16ed5420aa1..a4026e2eeac 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -1,4 +1,4 @@ -import type { AgentType } from './agent-status-types' +import type { AgentType, AgentWorkingMode } from './agent-status-types' import type { ExecutionHostId } from './execution-host' import type { RepoIcon } from './repo-icon' import type { TuiAgent } from './tui-agent' @@ -35,11 +35,15 @@ export const DASHBOARD_MAX_MAP_WORKSPACES = 2_000 /** Kept distinct from `bucket` so attention cards retain their precise dot state. */ export type DashboardCardDotState = 'working' | 'blocked' | 'waiting' | 'done' | 'idle' +export type DashboardCardDisplayState = DashboardCardDotState | 'monitoring' /** Completed agents stay green until acknowledged, then settle into gray idle. */ export function dashboardCardDisplayState( - card: Pick -): DashboardCardDotState { + card: Pick +): DashboardCardDisplayState { + if (card.dotState === 'working' && card.workingMode === 'monitoring') { + return 'monitoring' + } return card.dotState === 'done' && !card.unseen ? 'idle' : card.dotState } @@ -84,6 +88,8 @@ export type DashboardCard = { agentType: AgentType bucket: DashboardBucket dotState: DashboardCardDotState + /** Additive discriminator; older pop-outs render this as ordinary working. */ + workingMode?: AgentWorkingMode /** One-line task/prompt text shown on the card. */ task: string /** The most recent message the user sent this agent (its current prompt). */ diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 7b1b224435c..211d7adc05d 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -3,6 +3,7 @@ import type { AgentStatusEntry, AgentStatusOrchestrationContext, AgentStatusState, + AgentWorkingMode, AgentType } from './agent-status-types' import type { @@ -812,6 +813,7 @@ export type RuntimeWorktreeAgentRow = { /** paneKey of the orchestration parent, or null for a root agent. */ parentPaneKey: string | null state: AgentStatusState + workingMode?: AgentWorkingMode agentType: AgentType | null /** Raw hook-reported prompt. Display surfaces can prefer displayName. */ prompt: string @@ -871,6 +873,8 @@ export type RuntimeWorktreePsSummary = { lastOutputAt: number | null preview: string status: RuntimeWorktreeStatus + /** Optional discriminator for a working workspace; older clients fall back to ordinary working. */ + workingMode?: AgentWorkingMode /** Live agents in this worktree, newest-state-first. Empty for shell-only * worktrees. Mirrors desktop's inline agent list (WorktreeCardAgents). */ agents: RuntimeWorktreeAgentRow[]