From ec4687c434fb72f1816b990a091235a4c95dacb6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:24:32 -0700 Subject: [PATCH 01/65] feat(agents): distinguish Claude background monitoring (takes over #14205) (#16201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. * fix(sidebar): keep monitoring status visible (cherry picked from commit fd6b38654db5d3a302f8125448bdcf7465773412) * test(agents): cover Claude monitoring drain (cherry picked from commit ce4d61ebf8adc432edf45b91077b0902529d4450) * test(mobile): avoid unresolved renderer test type (cherry picked from commit bbcfa35ff9e801c99ab2f03414c0fb6c5cb85b5c) * feat(agents): render Claude monitoring as a static turquoise dot Replaces the yellow Radio glyph from #14205 with a static dot in a new --agent-monitoring token (#8abeb7), defined once for light and once for dark like --workspace-status-done, so the status keeps its identity when the theme flips. Deliberately a fixed UI value: it never reads terminal theme state at runtime. Adds the turn-boundary notification pins. The monitoring predicate and the turnCompletedAt stamp are computed from the same "lead said done but the pane resolves to working" expression, so a rename can silently drop the stamp and kill a completion notification that works today with nothing else going red. * revert(agents): restore the yellow Radio glyph for monitoring Brennan chose #14205's original treatment over the turquoise dot, so the visual goes back to nwparker's: lucide Radio in text-yellow-500 across the sidebar, dashboard dot, cmd-j palette and agent-map ring. Reverts only the visual surface. The turn-boundary notification pins stay — the monitoring predicate and the turnCompletedAt stamp share an expression, so a rename can silently drop the stamp and kill a completion that works today with nothing else going red. The --agent-monitoring token is removed with its last consumer rather than left dead in main.css. --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- 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 | 87 ++++++ .../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 + .../server-ingest-terminal-status.test.ts | 46 ++++ .../server-interrupt-inference-guards.test.ts | 1 + .../server-last-status-write.test.ts | 6 +- 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 | 37 ++- .../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 | 58 ++++ .../sidebar/worktree-card-agent-summary.ts | 10 +- .../worktree-card-compact-agent-row.tsx | 19 +- .../terminal-tab-activity-status.test.ts | 26 ++ .../tab-bar/terminal-tab-activity-status.ts | 19 +- ...on-coordinator-monitoring-turn-end.test.ts | 131 +++++++++ .../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 | 57 ++-- 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 | 58 +--- .../agent-terminal-status-equivalence.test.ts | 31 +++ .../agent-terminal-status-equivalence.ts | 32 +++ .../claude-background-task-status.test.ts | 142 ++++++++-- src/shared/dashboard-snapshot.ts | 12 +- src/shared/runtime-types.ts | 4 + 90 files changed, 2065 insertions(+), 233 deletions(-) create mode 100644 mobile/src/components/agent-monitoring-indicators.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts create mode 100644 src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.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 && ( } + } + unmount(): void +} + +const { animationLoop, animationTiming, setValue } = vi.hoisted(() => ({ + 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: MonitoringTestRenderer | 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-ingest-terminal-status.test.ts b/src/main/agent-hooks/server-ingest-terminal-status.test.ts index 8661701e1dc..0ac729fa72e 100644 --- a/src/main/agent-hooks/server-ingest-terminal-status.test.ts +++ b/src/main/agent-hooks/server-ingest-terminal-status.test.ts @@ -27,6 +27,52 @@ afterEach(() => { }) 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() + }) + it('preserves a hook turn stamp when an OSC repaint omits hook-only completion text', () => { const server = new AgentHookServer() const listener = vi.fn() diff --git a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts index 8d0d6a3975c..29803adf020 100644 --- a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts @@ -228,6 +228,7 @@ describe('AgentHookServer listener replay', () => { const baseline = server.getStatusSnapshot()[0] expect(baseline).not.toHaveProperty('claudeRunningNonAgentTask') + expect(baseline).toMatchObject({ state: 'working', workingMode: 'monitoring' }) expect( server.inferInterrupt({ paneKey: PANE, diff --git a/src/main/agent-hooks/server-last-status-write.test.ts b/src/main/agent-hooks/server-last-status-write.test.ts index f693c2d4c36..b0bfb7db929 100644 --- a/src/main/agent-hooks/server-last-status-write.test.ts +++ b/src/main/agent-hooks/server-last-status-write.test.ts @@ -77,7 +77,11 @@ describe('Last-status persistence', () => { worktreeId: 'wt-1', receivedAt: expect.any(Number), stateStartedAt: expect.any(Number), - payload: expect.objectContaining({ state: 'working', prompt: 'persist me' }) + payload: expect.objectContaining({ + state: 'working', + workingMode: 'monitoring', + prompt: 'persist me' + }) }) expect(file.entries[PANE].launchToken).toBeUndefined() expect(file.entries[PANE].launchTokenHash).toBe( diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 6def8b6b6c2..f69ffea2e4c 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -77,6 +77,7 @@ import { type ParsedAgentStatusPayload, normalizeAgentStatusPayload } from '../../shared/agent-status-types' +import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' import { AgentStatusObservationSequencer, createAgentStatusAuthorityId, @@ -453,30 +454,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 @@ -1044,6 +1021,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 } : {}), @@ -2205,7 +2183,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 } @@ -3053,7 +3031,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 79a0a30d654..243fed1191f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -37718,6 +37718,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 60a20f7929f..d1646a58c6d 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< @@ -12160,6 +12187,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) || @@ -20020,6 +20048,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 @@ -20203,10 +20232,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)) @@ -20268,10 +20302,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)) @@ -20372,7 +20411,8 @@ export class OrcaRuntimeService { runtimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds, mirroredWorktreeIdByTabId, - connectedPtyEvidence + connectedPtyEvidence, + workingTerminalEvidenceByWorktreeId ) const sorted = [...summaries.values()].sort(compareWorktreePs) @@ -20396,30 +20436,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, { @@ -20428,7 +20455,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, @@ -20445,9 +20474,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, { @@ -20458,7 +20500,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, @@ -20474,7 +20518,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. @@ -20521,6 +20564,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, @@ -20547,6 +20591,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 @@ -20554,9 +20600,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' ) } } @@ -40979,11 +41047,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 7e03d75ce7a..89c38a6003f 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..f80de335e43 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -76,7 +76,7 @@ describe('RelayAgentHookServer', () => { } }) - it('forwards Claude background-work evidence with the normalized status', async () => { + it('forwards Claude background monitoring until its authoritative inventory drains', async () => { const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) await server.start() @@ -99,7 +99,40 @@ describe('RelayAgentHookServer', () => { expect(forward.mock.calls[0][0]).toMatchObject({ claudeRunningNonAgentTask: true, - payload: { state: 'working', agentType: 'claude' } + payload: { state: 'working', workingMode: 'monitoring', agentType: 'claude' } + }) + + await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: + 'shell-1completed' + } + }) + }) + await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + payload: { hook_event_name: 'Stop', background_tasks: [], session_crons: [] } + }) + }) + + expect(forward).toHaveBeenCalledTimes(3) + expect(forward.mock.calls[2][0]).toMatchObject({ + claudeRunningNonAgentTask: false, + payload: { state: 'done', 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..bbb115c8f65 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts @@ -0,0 +1,58 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import { CompactAgentRow, 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') + }) + + it('keeps monitoring visible before a compact row prompt', () => { + const agent = monitoringAgent() + agent.entry.prompt = 'Run background checks' + + const markup = renderToStaticMarkup( + createElement(CompactAgentRow, { agent, now: 2000, onActivate: vi.fn() }) + ) + + expect(markup).toContain('title="Monitoring background tasks - Run background checks"') + expect(markup).toMatch( + /Monitoring background tasks<\/span>]*> - Run background checks<\/span>/ + ) + }) +}) 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..63163bfe207 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 @@ -119,6 +123,11 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ const primary = getCompactAgentPrimary(agent, conversationName) const isLineageChild = agent.lineage?.depth === 1 const secondary = getCompactAgentSecondary(agent) + // Why: sidebar truncation must preserve the passive-vs-active distinction. + const leadingText = dotState === 'monitoring' ? secondary : primary + const trailingText = + dotState === 'monitoring' ? (primary === secondary ? '' : primary) : secondary + const rowTitle = `${leadingText}${trailingText ? ` - ${trailingText}` : ''}` const model = agent.entry.model?.trim() ?? '' const shortTime = getCompactAgentTime(agent, now) const cacheTimer = usePromptCacheCountdownForPane(agent.paneKey, cacheTimerActive) @@ -201,12 +210,12 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ {/* Why: the selected-row fill is strong enough to wash out the dimmed prompt/secondary text, so lift both toward full foreground when focused. */} - {primary} + {leadingText} - {secondary && ( + {trailingText && ( {' '} - - {secondary} + - {trailingText} )} @@ -269,7 +278,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ role={agent.lineage ? 'treeitem' : undefined} aria-level={agent.lineage ? agent.lineage.depth + 1 : undefined} aria-expanded={hasChildDisclosure ? childAgentsExpanded : undefined} - title={sendTargetDisabledReason ?? `${primary}${secondary ? ` - ${secondary}` : ''}`} + title={sendTargetDisabledReason ?? rowTitle} > {rowBody} 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/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts new file mode 100644 index 00000000000..f0b2aab1520 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAgentCompletionCoordinator } from './agent-completion-coordinator' +import { useAgentCompletionCoordinatorLifecycle } from './agent-completion-coordinator-test-harness' +import { + createHookListenerState, + normalizeHookPayload +} from '../../../../shared/agent-hook-listener' +import { makePaneKey } from '../../../../shared/stable-pane-id' + +// Why: STA-4119's second complaint is the missing completion notification. This drives the REAL +// hook listener and feeds its real output into the REAL coordinator, rather than hand-writing a +// payload — the whole question is whether the two layers actually agree about a monitoring turn. +const PANE = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const RUNNING_SHELL = { + id: 'shell-1', + type: 'shell', + status: 'running', + description: 'Run the dev server', + command: 'pnpm dev' +} + +function hookPayload( + state: ReturnType, + payload: Record +) { + const parsed = normalizeHookPayload( + state, + 'claude', + { paneKey: PANE, payload }, + 'production' + )?.payload + if (!parsed) { + throw new Error('listener produced no payload') + } + // The hook server stamps stateStartedAt on the way to the renderer. + return { ...parsed, stateStartedAt: 1_700_000_000_000 } +} + +function createCoordinator() { + const dispatchCompletion = vi.fn() + const dispatchHookLifecycle = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: PANE, + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + dispatchHookLifecycle, + isLive: () => true + }) + return { coordinator, dispatchCompletion, dispatchHookLifecycle } +} + +/** Meta the coordinator hands the notification dispatcher. */ +function completionMeta(dispatchCompletion: ReturnType) { + return dispatchCompletion.mock.calls[0]?.[1] as + | { source?: string; agentStatus?: { state?: string; workingMode?: string } } + | undefined +} + +describe('completion notification when a lead turn ends into monitoring', () => { + useAgentCompletionCoordinatorLifecycle() + + it('announces completion when the turn ends but the pane stays working for a background shell', () => { + const listener = createHookListenerState() + const { coordinator, dispatchCompletion, dispatchHookLifecycle } = createCoordinator() + + coordinator.observeHookStatus( + hookPayload(listener, { hook_event_name: 'UserPromptSubmit', prompt: 'start the dev server' }) + ) + const monitoring = hookPayload(listener, { + hook_event_name: 'Stop', + background_tasks: [RUNNING_SHELL] + }) + + // Precondition: the pane really is in the monitoring state, not done. + expect(monitoring).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + expect(typeof monitoring.turnCompletedAt).toBe('number') + + coordinator.observeHookStatus(monitoring) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + const meta = completionMeta(dispatchCompletion) + expect(meta?.source).toBe('hook') + // Why: the announcement is a synthesized `done` while the reported row stays `working` — + // the notification follows the TURN boundary, not the rendered state. + expect(meta?.agentStatus?.state).toBe('done') + // Why: announce only. Running pane lifecycle here would settle a pane that is still working. + expect(dispatchHookLifecycle).not.toHaveBeenCalledWith( + expect.objectContaining({ state: 'done' }) + ) + }) + + it('does not announce twice when the background shell later clears to done', () => { + const listener = createHookListenerState() + const { coordinator, dispatchCompletion } = createCoordinator() + + coordinator.observeHookStatus( + hookPayload(listener, { hook_event_name: 'UserPromptSubmit', prompt: 'start the dev server' }) + ) + coordinator.observeHookStatus( + hookPayload(listener, { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }) + ) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + // The shell exits; the same turn's all-clear must not re-announce. + coordinator.observeHookStatus( + hookPayload(listener, { + hook_event_name: 'PostToolUse', + tool_name: 'KillShell', + background_tasks: [] + }) + ) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + + it('does not announce mid-turn while the agent is still working in the foreground', () => { + const listener = createHookListenerState() + const { coordinator, dispatchCompletion } = createCoordinator() + + coordinator.observeHookStatus( + hookPayload(listener, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' }) + ) + coordinator.observeHookStatus( + hookPayload(listener, { hook_event_name: 'PreToolUse', tool_name: 'Bash' }) + ) + + expect(dispatchCompletion).not.toHaveBeenCalled() + }) +}) 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 8928b8b2b49..9b983ce6fae 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts @@ -145,7 +145,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), @@ -174,6 +176,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 b04c3cd92de..55422204501 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -3305,6 +3305,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 bf822ba3308..1a8336f323c 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts @@ -328,6 +328,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 d17e42eed99..9167db9b318 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.ts @@ -91,14 +91,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' } /** 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 756e91549fc..96476583f77 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. */ @@ -2806,7 +2817,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' @@ -2820,11 +2833,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 } @@ -2858,7 +2872,7 @@ function buildClaudeCachedLeadStatusPayload( } } return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, { - stateName: resolveClaudePaneState(state, paneKey, { + ...resolveClaudePaneStatus(state, paneKey, { state: leadState, interrupted: lead?.interrupted }), @@ -3111,7 +3125,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 @@ -3176,16 +3190,19 @@ function normalizeClaudeEvent( } } - const effectiveState = resolveClaudePaneState(state, paneKey, { + const resolvedStatus = resolveClaudePaneStatus(state, paneKey, { state: reportedStateName, interrupted }) + // Why: #15202's compact-completion guard reads the resolved state; this branch replaced the + // resolver with one that also reports workingMode, so bridge rather than resolve twice. + const effectiveState = resolvedStatus.stateName // Why: the lead already ended — the pane stays `working` only because background inventory is still registered. `stateStartedAt` is pinned for that whole run, so this end time is the per-turn identity and the later all-clear's pair key. const turnCompletedAt = eventAgentId === undefined && isTurnBoundary && reportedStateName === 'done' && - effectiveState === 'working' && + resolvedStatus.stateName === 'working' && interrupted !== true ? Date.now() : undefined @@ -3203,7 +3220,7 @@ function normalizeClaudeEvent( if ( isTurnBoundary && eventAgentId === undefined && - effectiveState === 'working' && + resolvedStatus.stateName === 'working' && claudeRosterHasRestoredSnapshotSubagent(effectiveRoster) && !claudeRosterHasRuntimeWorkingSubagent(effectiveRoster) && !state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) && @@ -3223,7 +3240,7 @@ function normalizeClaudeEvent( } return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { - stateName: effectiveState, + ...resolvedStatus, updateToolSnapshot: true, interrupted, // Why: a finished compact is a session-shaped boundary, not a completed turn. Without this the @@ -3242,6 +3259,7 @@ function buildClaudeStatusPayload( hookPayload: Record, options: { stateName: AgentStatusState + workingMode?: AgentWorkingMode updateToolSnapshot: boolean interrupted?: boolean sessionBoundary?: boolean @@ -3259,6 +3277,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 714b47d4a80..de177dcd4b8 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -3,7 +3,7 @@ // a narrow interrupt fallback synthesizes a final `done` when an agent misses its cancellation hook. import type { AgentProviderSessionMetadata } from './agent-session-resume' -import type { AgentStatusRowFacets, WithAgentStatusObservation } from './agent-status-observation' +import type { AgentStatusRowFacets } from './agent-status-observation' import { normalizeInteractivePromptField, normalizeOptionalField, @@ -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 } & AgentStatusRowFacets -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..6722dded5d3 100644 --- a/src/shared/claude-background-task-status.test.ts +++ b/src/shared/claude-background-task-status.test.ts @@ -105,41 +105,147 @@ describe('Claude background task status', () => { } }) - it('stays working through Claude 2.1.220 Stop and SubagentStop until the shell finishes', () => { + it('drains the Claude 2.1.229 background-task completion sequence', () => { const state = createHookListenerState() expect( 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') + prompt: + 'b8rs2wmxgcompleted' + }) + ).toMatchObject({ state: 'working', workingMode: undefined }) expect( claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', - background_tasks: [] + background_tasks: [], + session_crons: [] })?.state ).toBe('done') + expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(false) + }) + + // Why: STA-4119's second complaint is the missing completion notification. The renderer's + // completion coordinator announces off `turnCompletedAt` on a still-`working` payload, so the + // stamp — not the rendered state — is what makes the notification fire when a turn ends into + // monitoring. The stamp predicate and the monitoring predicate are computed from the same + // "lead said done but the pane resolves to working" expression, so a refactor that renames one + // can silently drop the other with nothing else going red. These pin both halves. + it('stamps the turn end when a finished lead falls back to monitoring', () => { + const state = createHookListenerState() + + claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'UserPromptSubmit', + prompt: 'start the dev server' + }) + const monitoring = claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Stop', + background_tasks: [RUNNING_SHELL] + }) + + expect(monitoring).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + expect(typeof monitoring?.turnCompletedAt).toBe('number') + }) + + it('stamps a lead that ends into a working subagent, which carries no monitoring mode', () => { + const state = createHookListenerState() + + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'SubagentStart', agent_id: 'child-1' }) + const childWorking = claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Stop', + background_tasks: [{ id: 'child-1', type: 'subagent', status: 'running' }] + }) + + // Why: real agent work keeps the spinner (no workingMode) but the lead turn still ended, + // so the stamp must be present. The two predicates are deliberately not the same. + expect(childWorking).toMatchObject({ state: 'working', workingMode: undefined }) + expect(typeof childWorking?.turnCompletedAt).toBe('number') + }) + + it('does not stamp a turn that is still running in the foreground', () => { + const state = createHookListenerState() + + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'work' }) + const midTurn = claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'PreToolUse', + tool_name: 'Bash' + }) + + expect(midTurn).toMatchObject({ state: 'working', workingMode: undefined }) + expect(midTurn?.turnCompletedAt).toBeUndefined() + }) + + it('does not stamp an interrupted lead, which settles done instead of monitoring', () => { + const state = createHookListenerState() + + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'start it' }) + markClaudeLeadTurnInterrupted(state, SOURCE_PANE) + const interrupted = claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Stop', + background_tasks: [RUNNING_SHELL] + }) + + expect(interrupted?.state).toBe('done') + expect(interrupted?.workingMode).toBeUndefined() + expect(interrupted?.turnCompletedAt).toBeUndefined() + }) + + it('does not stamp a turn that simply finished with nothing left running', () => { + const state = createHookListenerState() + + claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'quick job' }) + const finished = claudeEvent(state, SOURCE_PANE, { + hook_event_name: 'Stop', + background_tasks: [], + session_crons: [] + }) + + // Why: the stamp exists to announce a turn whose pane STAYS working. A plain done + // needs no stamp — the ordinary done path already notifies. + expect(finished?.state).toBe('done') + expect(finished?.turnCompletedAt).toBeUndefined() + }) + + 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', () => { @@ -209,15 +315,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', @@ -550,7 +656,11 @@ describe('Claude background task status', () => { background_tasks: [RUNNING_SHELL] }) - expect(claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop' })?.state).toBe('working') + expect(claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop' })).toMatchObject({ + state: 'working', + workingMode: 'monitoring' + }) + expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true) clearPaneCacheState(state, SOURCE_PANE) expect(state.claudeRunningNonAgentTaskPaneKeys.size).toBe(0) 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[] From b76a47e468f47f2f2488d1fa449e4cf6378a3567 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:28:59 -0700 Subject: [PATCH 02/65] Add requireTuiAgentConfig to validate agent ids (#16310) Agent ids persist in automations and settings, so they outlive the build that wrote them. Direct config lookups fail with unclear "Cannot read properties of undefined" when an id becomes unknown. This function validates the agent and throws a clear error message naming the unknown id. --- .../lib/launch-agent-background-session.ts | 6 +++--- src/shared/require-tui-agent-config.test.ts | 21 +++++++++++++++++++ src/shared/require-tui-agent-config.ts | 15 +++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 src/shared/require-tui-agent-config.test.ts create mode 100644 src/shared/require-tui-agent-config.ts diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 6fd30efde21..947cabb317b 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -11,7 +11,7 @@ import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' -import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' +import { requireTuiAgentConfig } from '../../../shared/require-tui-agent-config' import { resolveAgentBackgroundLaunchHost } from '@/lib/agent-background-session-launch-host' import { makePaneKey } from '../../../shared/stable-pane-id' import { @@ -62,7 +62,7 @@ export async function launchAgentBackgroundSession( worktreePath: worktree.path, repo }) - const preflight = TUI_AGENT_CONFIG[agent].preflightTrust + const preflight = requireTuiAgentConfig(agent).preflightTrust if (preflight && worktree.path && window.api.agentTrust?.markTrusted) { try { await window.api.agentTrust.markTrusted({ @@ -82,7 +82,7 @@ export async function launchAgentBackgroundSession( }) const trimmedPrompt = prompt?.trim() ?? '' const hasPrompt = trimmedPrompt.length > 0 - const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' + const isFollowupPath = requireTuiAgentConfig(agent).promptInjectionMode === 'stdin-after-start' const pasteDraftAfterLaunch = hasPrompt && isFollowupPath ? trimmedPrompt : null const startupPlan = buildAgentStartupPlan({ diff --git a/src/shared/require-tui-agent-config.test.ts b/src/shared/require-tui-agent-config.test.ts new file mode 100644 index 00000000000..a399fffd954 --- /dev/null +++ b/src/shared/require-tui-agent-config.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import type { TuiAgent } from './tui-agent' +import { requireTuiAgentConfig } from './require-tui-agent-config' + +describe('requireTuiAgentConfig', () => { + it('returns the config for a known agent', () => { + expect(requireTuiAgentConfig('codex').preflightTrust).toBe('codex') + }) + + it('names the unknown id instead of throwing a property-of-undefined error', () => { + // A custom-agent id persisted by a branch build, read back by one without the feature. + const stale = 'custom-agent:codex:b2e1ff6f-8932-413a-9133-edffa44e0ee9' as TuiAgent + expect(() => requireTuiAgentConfig(stale)).toThrow( + `Unknown agent "${stale}". This version of Orca has no such agent — pick a different agent and try again.` + ) + }) + + it('rejects a prototype key that Object.hasOwn keeps off the config', () => { + expect(() => requireTuiAgentConfig('toString' as TuiAgent)).toThrow(/Unknown agent "toString"/) + }) +}) diff --git a/src/shared/require-tui-agent-config.ts b/src/shared/require-tui-agent-config.ts new file mode 100644 index 00000000000..78f3f14b6df --- /dev/null +++ b/src/shared/require-tui-agent-config.ts @@ -0,0 +1,15 @@ +import type { TuiAgent } from './tui-agent' +import { isTuiAgent, TUI_AGENT_CONFIG, type TuiAgentConfig } from './tui-agent-config' + +/** Why: agent ids persist in automations and settings, so they outlive the build that + * wrote them — an id a branch build understood reads back as unknown here. Name the id + * instead of letting a bare `TUI_AGENT_CONFIG[agent].x` lookup fail with the unreadable + * "Cannot read properties of undefined (reading 'preflightTrust')". */ +export function requireTuiAgentConfig(agent: TuiAgent): TuiAgentConfig { + if (!isTuiAgent(agent)) { + throw new Error( + `Unknown agent "${String(agent)}". This version of Orca has no such agent — pick a different agent and try again.` + ) + } + return TUI_AGENT_CONFIG[agent] +} From 3bc13f7b8cbb5b0fe818cab3d1199c3a8612274c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:30:16 -0700 Subject: [PATCH 03/65] Split monolithic PTY IPC module into organized submodules (#15172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * rm unused files * remove unused files * Refactor PTY IPC and add host environment paths - Split PTY handlers out of inline baseline checks - Rename local PTY shell provider for clarity - Pass userDataPath and resourcesPath to host environment * Establish PTY daemon identity before first await in spawn flow Move identity setup, session ID minting, and hidden delivery state to the beginning of preflight, ensuring these complete synchronously before any awaited operations. Defer async operations like folder workspace validation; add liveness tracking for SSH provider failures. Refactor pane spawn reservation to prevent concurrent spawns from creating duplicate providers. * Add incarnationId tracking throughout PTY exit lifecycle Track PTY incarnation IDs in exit messages sent to renderer, and add cause tracking for exit events. This enables proper lifecycle state management when PTYs can be respawned or have multiple concurrent instances. Also adds deadline support to process listing operations and stop-request tracking for better shutdown observability. * Use fake timers in SFTP namespace tests for deterministic abort handling Tests now use `vi.useFakeTimers()` to control time during abort scenarios, advancing timers explicitly instead of waiting on real async delays. Ensures more reliable test execution without flakiness from timing-dependent behavior. * Fix PTY spawn lifecycle: handle concurrent races and cleanup abandoned a Properly release Agent Teams leader handles when spawns are abandoned or fail, restore provisional PTY sizes on reattachment, and settle concurrent spawn races for the same pane. Add validation guards for destroyed renderers and improve handler re-registration to reset delivery state before bridging a new window. * Move PTY cleanup to localized error boundaries Restore provisional PTY size when build-options fails and guard pre-allocated handle registration. This ensures cleanup happens at the point of error, not deferred to the general catch block. * Replace Promise.resolve() with vi.waitFor in PTY claim test Wait explicitly for the providerSpawn call to be made using vi.waitFor() instead of relying on event-loop yielding. This makes the test more deterministic and reduces flakiness from timing assumptions. * Redact PTY IDs in pending data drop diagnostics Prevent workspace paths embedded in session IDs from leaking through diagnostic logs by using redactPtyIdForDiagnostics. * Mark PTY exit events as observed by provider Exit handlers now receive `providerExitObserved: true` to distinguish definitive provider-witnessed exits from inferred state changes. Preserves optional exit cause when present. * Add defensive input validation to PTY IPC handlers Validate that IPC arguments are present and the correct type before passing them to handler logic. Uses optional chaining and type checks to safely handle malformed requests from the renderer process. * Replace direct Electron imports with PTY host bindings Abstract app, ipcMain, and powerMonitor access through getter functions to support multiple host environments and improve testability. * Defend against transient PTY setup failures with state cleanup Host-env setup failures now trigger cleanup of runtime-allocated PTY state. Cached PTY geometry is preserved after transient reattach failures but cleared when the provider reports the PTY exited before the spawn reply—preventing stale geometry from corrupting future operations. Error handling now distinguishes expired SSH sessions and early-exit conditions to preserve geometry appropriately. --- config/max-lines-baseline.txt | 1 - .../ipc/pty-applied-size-reporting.test.ts | 81 + .../ipc/pty-buffer-snapshot-dispatch.test.ts | 26 +- .../pty-controller-spawn-admission.test.ts | 73 + .../pty-daemon-controller-teardown.test.ts | 56 + .../ipc/pty-ipc-hidden-delivery-gate.test.ts | 2 +- .../ipc/pty-ipc-producer-flow-control.test.ts | 18 + .../ipc/pty-pane-claim-arbitration.test.ts | 17 +- .../ipc/pty-pane-materialization-race.test.ts | 118 + .../pty-serializer-settlement-mapping.test.ts | 15 + ...pty-session-liveness-and-ownership.test.ts | 15 + .../pty-spawn-runtime-handle-binding.test.ts | 123 + .../ipc/pty-startup-barrier-ordering.test.ts | 33 +- src/main/ipc/pty.ts | 8072 +---------------- src/main/ipc/pty/delivery/accept.ts | 190 + src/main/ipc/pty/delivery/accounting.ts | 219 + src/main/ipc/pty/delivery/constants.ts | 17 + src/main/ipc/pty/delivery/debug-snapshot.ts | 168 + src/main/ipc/pty/delivery/debug.ts | 124 + src/main/ipc/pty/delivery/exit.ts | 177 + src/main/ipc/pty/delivery/flush.ts | 235 + .../ipc/pty/delivery/hidden-transition.ts | 35 + src/main/ipc/pty/delivery/interactive.ts | 34 + src/main/ipc/pty/delivery/lifecycle-reset.ts | 96 + src/main/ipc/pty/delivery/payload.ts | 137 + src/main/ipc/pty/delivery/pending.ts | 193 + src/main/ipc/pty/delivery/producer-sync.ts | 64 + src/main/ipc/pty/delivery/ssh-intake.ts | 101 + src/main/ipc/pty/delivery/visibility-state.ts | 31 + src/main/ipc/pty/delivery/wire-session.ts | 159 + src/main/ipc/pty/host-env/assembly.ts | 272 + src/main/ipc/pty/host-env/codex-home.ts | 301 + src/main/ipc/pty/host-env/codex-resume.ts | 138 + .../ipc/pty/host-env/fresh-spawn-routing.ts | 56 + src/main/ipc/pty/host-env/path.ts | 47 + src/main/ipc/pty/host-env/pi-agent.ts | 202 + src/main/ipc/pty/host-env/spawn-cwd.ts | 55 + src/main/ipc/pty/host-env/spawn-env-keys.ts | 16 + src/main/ipc/pty/host-env/types.ts | 73 + src/main/ipc/pty/ipc/inspect.ts | 340 + src/main/ipc/pty/ipc/resize-visibility.ts | 339 + src/main/ipc/pty/ipc/serialize-buffer.ts | 111 + src/main/ipc/pty/ipc/snapshot.ts | 105 + src/main/ipc/pty/ipc/spawn-begin.ts | 122 + src/main/ipc/pty/ipc/spawn-commit-persist.ts | 154 + src/main/ipc/pty/ipc/spawn-commit.ts | 233 + src/main/ipc/pty/ipc/spawn-env-codex.ts | 164 + src/main/ipc/pty/ipc/spawn-env.ts | 135 + src/main/ipc/pty/ipc/spawn-execute.ts | 202 + src/main/ipc/pty/ipc/spawn-options.ts | 181 + src/main/ipc/pty/ipc/spawn-preflight.ts | 229 + src/main/ipc/pty/ipc/spawn-run.ts | 79 + src/main/ipc/pty/ipc/spawn-state.ts | 159 + src/main/ipc/pty/ipc/spawn-types.ts | 123 + src/main/ipc/pty/ipc/spawn.ts | 16 + src/main/ipc/pty/ipc/write-input.ts | 204 + src/main/ipc/pty/ipc/write.ts | 74 + src/main/ipc/pty/kill-all.ts | 11 + src/main/ipc/pty/pane/adopt-stable.ts | 86 + src/main/ipc/pty/pane/agent-session-owners.ts | 126 + src/main/ipc/pty/pane/key-state.ts | 47 + src/main/ipc/pty/pane/launch-authority.ts | 41 + src/main/ipc/pty/pane/serializer-state.ts | 71 + src/main/ipc/pty/pane/spawn-reservation.ts | 115 + src/main/ipc/pty/pane/stable-owner.ts | 293 + src/main/ipc/pty/provider/bind-listeners.ts | 111 + .../ipc/pty/provider/listener-lifecycle.ts | 106 + src/main/ipc/pty/provider/liveness.ts | 142 + src/main/ipc/pty/provider/local-configure.ts | 115 + src/main/ipc/pty/provider/ownership-state.ts | 35 + src/main/ipc/pty/provider/registry.ts | 131 + src/main/ipc/pty/provider/shutdown-detect.ts | 25 + src/main/ipc/pty/provider/state-cleanup.ts | 124 + src/main/ipc/pty/register-handlers.ts | 267 + src/main/ipc/pty/register-headless-runtime.ts | 43 + src/main/ipc/pty/runtime/controller-deps.ts | 89 + src/main/ipc/pty/runtime/controller.ts | 80 + src/main/ipc/pty/runtime/kill.ts | 311 + src/main/ipc/pty/runtime/operations.ts | 259 + src/main/ipc/pty/runtime/spawn-commit.ts | 307 + src/main/ipc/pty/runtime/spawn-early.ts | 71 + src/main/ipc/pty/runtime/spawn-execute.ts | 262 + src/main/ipc/pty/runtime/spawn-options.ts | 250 + src/main/ipc/pty/runtime/spawn-preflight.ts | 281 + src/main/ipc/pty/runtime/spawn-state.ts | 185 + src/main/ipc/pty/runtime/spawn.ts | 119 + src/main/ipc/pty/session.ts | 289 + src/main/runtime/orca-runtime.ts | 6 + .../ssh/ssh-connection-sftp-namespace.test.ts | 46 +- 89 files changed, 11120 insertions(+), 8084 deletions(-) create mode 100644 src/main/ipc/pty/delivery/accept.ts create mode 100644 src/main/ipc/pty/delivery/accounting.ts create mode 100644 src/main/ipc/pty/delivery/constants.ts create mode 100644 src/main/ipc/pty/delivery/debug-snapshot.ts create mode 100644 src/main/ipc/pty/delivery/debug.ts create mode 100644 src/main/ipc/pty/delivery/exit.ts create mode 100644 src/main/ipc/pty/delivery/flush.ts create mode 100644 src/main/ipc/pty/delivery/hidden-transition.ts create mode 100644 src/main/ipc/pty/delivery/interactive.ts create mode 100644 src/main/ipc/pty/delivery/lifecycle-reset.ts create mode 100644 src/main/ipc/pty/delivery/payload.ts create mode 100644 src/main/ipc/pty/delivery/pending.ts create mode 100644 src/main/ipc/pty/delivery/producer-sync.ts create mode 100644 src/main/ipc/pty/delivery/ssh-intake.ts create mode 100644 src/main/ipc/pty/delivery/visibility-state.ts create mode 100644 src/main/ipc/pty/delivery/wire-session.ts create mode 100644 src/main/ipc/pty/host-env/assembly.ts create mode 100644 src/main/ipc/pty/host-env/codex-home.ts create mode 100644 src/main/ipc/pty/host-env/codex-resume.ts create mode 100644 src/main/ipc/pty/host-env/fresh-spawn-routing.ts create mode 100644 src/main/ipc/pty/host-env/path.ts create mode 100644 src/main/ipc/pty/host-env/pi-agent.ts create mode 100644 src/main/ipc/pty/host-env/spawn-cwd.ts create mode 100644 src/main/ipc/pty/host-env/spawn-env-keys.ts create mode 100644 src/main/ipc/pty/host-env/types.ts create mode 100644 src/main/ipc/pty/ipc/inspect.ts create mode 100644 src/main/ipc/pty/ipc/resize-visibility.ts create mode 100644 src/main/ipc/pty/ipc/serialize-buffer.ts create mode 100644 src/main/ipc/pty/ipc/snapshot.ts create mode 100644 src/main/ipc/pty/ipc/spawn-begin.ts create mode 100644 src/main/ipc/pty/ipc/spawn-commit-persist.ts create mode 100644 src/main/ipc/pty/ipc/spawn-commit.ts create mode 100644 src/main/ipc/pty/ipc/spawn-env-codex.ts create mode 100644 src/main/ipc/pty/ipc/spawn-env.ts create mode 100644 src/main/ipc/pty/ipc/spawn-execute.ts create mode 100644 src/main/ipc/pty/ipc/spawn-options.ts create mode 100644 src/main/ipc/pty/ipc/spawn-preflight.ts create mode 100644 src/main/ipc/pty/ipc/spawn-run.ts create mode 100644 src/main/ipc/pty/ipc/spawn-state.ts create mode 100644 src/main/ipc/pty/ipc/spawn-types.ts create mode 100644 src/main/ipc/pty/ipc/spawn.ts create mode 100644 src/main/ipc/pty/ipc/write-input.ts create mode 100644 src/main/ipc/pty/ipc/write.ts create mode 100644 src/main/ipc/pty/kill-all.ts create mode 100644 src/main/ipc/pty/pane/adopt-stable.ts create mode 100644 src/main/ipc/pty/pane/agent-session-owners.ts create mode 100644 src/main/ipc/pty/pane/key-state.ts create mode 100644 src/main/ipc/pty/pane/launch-authority.ts create mode 100644 src/main/ipc/pty/pane/serializer-state.ts create mode 100644 src/main/ipc/pty/pane/spawn-reservation.ts create mode 100644 src/main/ipc/pty/pane/stable-owner.ts create mode 100644 src/main/ipc/pty/provider/bind-listeners.ts create mode 100644 src/main/ipc/pty/provider/listener-lifecycle.ts create mode 100644 src/main/ipc/pty/provider/liveness.ts create mode 100644 src/main/ipc/pty/provider/local-configure.ts create mode 100644 src/main/ipc/pty/provider/ownership-state.ts create mode 100644 src/main/ipc/pty/provider/registry.ts create mode 100644 src/main/ipc/pty/provider/shutdown-detect.ts create mode 100644 src/main/ipc/pty/provider/state-cleanup.ts create mode 100644 src/main/ipc/pty/register-handlers.ts create mode 100644 src/main/ipc/pty/register-headless-runtime.ts create mode 100644 src/main/ipc/pty/runtime/controller-deps.ts create mode 100644 src/main/ipc/pty/runtime/controller.ts create mode 100644 src/main/ipc/pty/runtime/kill.ts create mode 100644 src/main/ipc/pty/runtime/operations.ts create mode 100644 src/main/ipc/pty/runtime/spawn-commit.ts create mode 100644 src/main/ipc/pty/runtime/spawn-early.ts create mode 100644 src/main/ipc/pty/runtime/spawn-execute.ts create mode 100644 src/main/ipc/pty/runtime/spawn-options.ts create mode 100644 src/main/ipc/pty/runtime/spawn-preflight.ts create mode 100644 src/main/ipc/pty/runtime/spawn-state.ts create mode 100644 src/main/ipc/pty/runtime/spawn.ts create mode 100644 src/main/ipc/pty/session.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 940129be633..e601ddf611e 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -38,7 +38,6 @@ inline src/main/index.ts inline src/main/ipc/filesystem-watcher.ts inline src/main/ipc/filesystem.ts inline src/main/ipc/github.ts -inline src/main/ipc/pty.ts inline src/main/ipc/repos.ts inline src/main/ipc/ssh.ts inline src/main/ipc/worktree-remote.ts diff --git a/src/main/ipc/pty-applied-size-reporting.test.ts b/src/main/ipc/pty-applied-size-reporting.test.ts index 5607c7cab61..87b8c5e086f 100644 --- a/src/main/ipc/pty-applied-size-reporting.test.ts +++ b/src/main/ipc/pty-applied-size-reporting.test.ts @@ -75,6 +75,30 @@ describe('registerPtyHandlers', () => { return write } + function setupProviderWithTransientAttachFailure(earlyExit = false): ReturnType { + const spawn = vi.fn(async (opts: { sessionId?: string }) => { + if (spawn.mock.calls.length > 1) { + if (earlyExit) { + return { id: opts.sessionId ?? 'daemon-pty', exitedBeforeSpawnReply: true as const } + } + throw new Error('transport unavailable') + } + return { id: opts.sessionId ?? 'daemon-pty' } + }) + setLocalPtyProvider({ + spawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + return spawn + } + const resizeListener = (): ((event: unknown, args: unknown) => void) => { const call = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:resize') if (!call) { @@ -150,6 +174,63 @@ describe('registerPtyHandlers', () => { const reported = await handlers.get('pty:getSize')!(null, { id }) expect(reported).toEqual({ cols: 100, rows: 30 }) }) + it('preserves cached geometry after a transient renderer attach failure', async () => { + setupProviderWithTransientAttachFailure() + handlers.clear() + registerPtyHandlers(mainWindow as never) + const request = { sessionId: 'daemon-existing-size', env: {} } + await handlers.get('pty:spawn')!(null, { ...request, cols: 200, rows: 50 }) + + await expect( + handlers.get('pty:spawn')!(null, { ...request, cols: 80, rows: 24 }) + ).rejects.toThrow('transport unavailable') + + await expect(handlers.get('pty:getSize')!(null, { id: request.sessionId })).resolves.toEqual({ + cols: 200, + rows: 50 + }) + }) + it('preserves cached geometry after a transient runtime attach failure', async () => { + setupProviderWithTransientAttachFailure() + let controller: + | { + spawn: (args: Record) => Promise + getSize: (id: string) => { cols: number; rows: number } | null + } + | undefined + const runtime = { + setPtyController: vi.fn((next) => { + controller = next + }), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const request = { sessionId: 'runtime-existing-size' } + await controller!.spawn({ ...request, cols: 200, rows: 50 }) + + await expect(controller!.spawn({ ...request, cols: 80, rows: 24 })).rejects.toThrow( + 'transport unavailable' + ) + + expect(controller!.getSize(request.sessionId)).toEqual({ cols: 200, rows: 50 }) + }) + it('clears cached geometry when the provider proves the PTY exited before reply', async () => { + setupProviderWithTransientAttachFailure(true) + handlers.clear() + registerPtyHandlers(mainWindow as never) + const request = { sessionId: 'daemon-exited-before-reply', env: {} } + await handlers.get('pty:spawn')!(null, { ...request, cols: 200, rows: 50 }) + + await expect( + handlers.get('pty:spawn')!(null, { ...request, cols: 80, rows: 24 }) + ).rejects.toThrow('agent_session_exited_during_start') + + await expect( + handlers.get('pty:getSize')!(null, { id: request.sessionId }) + ).resolves.toBeNull() + }) it('fans out accepted desktop resizes to the runtime after provider resize', async () => { const resize = vi.fn() setupProviderWithAppliedSize({ applied: { cols: 120, rows: 30 }, resize }) diff --git a/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts b/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts index 04ca18f997f..e08e6aa2090 100644 --- a/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts +++ b/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts @@ -48,8 +48,13 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () => ) describe('registerPtyHandlers', () => { - const { handlers, mainWindow, createMockProc, installObservableDaemonTestProvider } = - setupPtyIpcSuite() + const { + handlers, + mainWindow, + mainWindowIpcEvent, + createMockProc, + installObservableDaemonTestProvider + } = setupPtyIpcSuite() describe('agent_started telemetry', () => { // Why: telemetry-plan.md§Agent launch semantics — agent_started fires only after provider.spawn resolves; a malformed payload must not emit a silent event. @@ -190,7 +195,7 @@ describe('registerPtyHandlers', () => { expect(responseChannelRegistrations.length).toBe(1) // Drain the in-flight requests so the test doesn't leak timers. for (const requestId of getSentRequestIds()) { - listener(null, { requestId, snapshot: null }) + listener(mainWindowIpcEvent, { requestId, snapshot: null }) } await Promise.all(inflight) }) @@ -202,11 +207,11 @@ describe('registerPtyHandlers', () => { const requestIdA = ids[0] const requestIdB = ids[1] - listener(null, { + listener(mainWindowIpcEvent, { requestId: requestIdB, snapshot: { data: 'B-data', cols: 80, rows: 24 } }) - listener(null, { + listener(mainWindowIpcEvent, { requestId: requestIdA, snapshot: { data: 'A-data', cols: 100, rows: 30, lastTitle: 'A-title' } }) @@ -224,11 +229,11 @@ describe('registerPtyHandlers', () => { const pending = controller.serializeBuffer('pty-1') const realRequestId = getSentRequestIds()[0] - listener(null, { + listener(mainWindowIpcEvent, { requestId: 'not-a-real-id', snapshot: { data: 'irrelevant', cols: 1, rows: 1 } }) - listener(null, { requestId: undefined, snapshot: null }) + listener(mainWindowIpcEvent, { requestId: undefined, snapshot: null }) let resolved = false void pending.then(() => { @@ -237,7 +242,10 @@ describe('registerPtyHandlers', () => { await new Promise((r) => setTimeout(r, 0)) expect(resolved).toBe(false) - listener(null, { requestId: realRequestId, snapshot: { data: 'ok', cols: 80, rows: 24 } }) + listener(mainWindowIpcEvent, { + requestId: realRequestId, + snapshot: { data: 'ok', cols: 80, rows: 24 } + }) await expect(pending).resolves.toEqual({ data: 'ok', cols: 80, rows: 24 }) }) it('resolves to null and removes the entry when the 750ms timeout fires', async () => { @@ -255,7 +263,7 @@ describe('registerPtyHandlers', () => { const { listener, controller } = setup() const pending = controller.serializeBuffer('pty-bad') const requestId = getSentRequestIds()[0] - listener(null, { requestId, snapshot: { data: 'ok', cols: 'not-a-number' } }) + listener(mainWindowIpcEvent, { requestId, snapshot: { data: 'ok', cols: 'not-a-number' } }) await expect(pending).resolves.toBeNull() }) }) diff --git a/src/main/ipc/pty-controller-spawn-admission.test.ts b/src/main/ipc/pty-controller-spawn-admission.test.ts index ae4af67434c..65f5ba565c9 100644 --- a/src/main/ipc/pty-controller-spawn-admission.test.ts +++ b/src/main/ipc/pty-controller-spawn-admission.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it, vi } from 'vitest' +import { + openCodeBuildPtyEnvMock, + openCodeClearPtyMock, + piClearPtyMock +} from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { OrcaRuntimeService } from '../runtime/orca-runtime' @@ -227,6 +232,74 @@ describe('registerPtyHandlers', () => { expect.objectContaining({ sessionId: 'serve-stable-session', isNewSession: true }) ) }) + it('clears a fresh runtime session when host-env setup fails', async () => { + const provider = createAgentClaimProvider({ spawn: vi.fn() }) + setLocalPtyProvider(provider as never) + openCodeBuildPtyEnvMock.mockImplementationOnce(() => { + throw new Error('host env failed') + }) + const controller = registerAgentClaimController() + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + cwd: '/tmp/runtime-host-env-failure', + worktreeId: 'repo::/tmp/runtime-host-env-failure' + }) + ).rejects.toThrow('host env failed') + + expect(provider.spawn).not.toHaveBeenCalled() + expect(openCodeClearPtyMock).toHaveBeenCalledOnce() + expect(piClearPtyMock).toHaveBeenCalledOnce() + }) + it('preserves caller-owned runtime state when host-env setup fails', async () => { + const provider = createAgentClaimProvider({ spawn: vi.fn() }) + setLocalPtyProvider(provider as never) + openCodeBuildPtyEnvMock.mockImplementationOnce(() => { + throw new Error('host env failed') + }) + const controller = registerAgentClaimController() + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + sessionId: 'caller-owned-session', + isNewSession: true + }) + ).rejects.toThrow('host env failed') + + expect(provider.spawn).not.toHaveBeenCalled() + expect(openCodeClearPtyMock).not.toHaveBeenCalled() + expect(piClearPtyMock).not.toHaveBeenCalled() + }) + it('preserves operation-owned runtime state when retry host-env setup fails', async () => { + const physicalSpawn = vi.fn(async (options: { sessionId?: string }) => ({ + id: options.sessionId ?? 'unexpected-spawn' + })) + const provider = createAgentClaimProvider({ spawn: physicalSpawn }) + setLocalPtyProvider(provider as never) + const controller = registerAgentClaimController() + const request = { + cols: 80, + rows: 24, + worktreeId: 'repo::/tmp/operation-retry', + agentSessionCreateOperationId: 'a'.repeat(43) + } + await controller.spawn(request) + openCodeClearPtyMock.mockClear() + piClearPtyMock.mockClear() + openCodeBuildPtyEnvMock.mockImplementationOnce(() => { + throw new Error('host env failed') + }) + + await expect(controller.spawn(request)).rejects.toThrow('host env failed') + + expect(physicalSpawn).toHaveBeenCalledOnce() + expect(openCodeClearPtyMock).not.toHaveBeenCalled() + expect(piClearPtyMock).not.toHaveBeenCalled() + }) it('adopts a daemon owner recovered from provider listing before claimed ensure', async () => { const owner: AgentSessionOwnerBinding = { claim: recoveredAgentClaim, diff --git a/src/main/ipc/pty-daemon-controller-teardown.test.ts b/src/main/ipc/pty-daemon-controller-teardown.test.ts index 73ae897b8b3..63786b361fb 100644 --- a/src/main/ipc/pty-daemon-controller-teardown.test.ts +++ b/src/main/ipc/pty-daemon-controller-teardown.test.ts @@ -153,6 +153,62 @@ describe('registerPtyHandlers', () => { }) expect(runtime.onPtyExit).not.toHaveBeenCalled() }) + it('bounds keep-history inventory polls by the settlement deadline', async () => { + vi.useFakeTimers() + try { + const listProcesses = vi.fn(async (_opts?: { deadlineMs?: number }) => []) + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(async () => undefined), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses, + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + stopAndWait: ( + ptyId: string, + opts?: { keepHistory?: boolean; deadlineMs?: number } + ) => Promise + } + const callerDeadlineMs = Date.now() + 5_000 + const stopPromise = controller.stopAndWait('local-pty', { + keepHistory: true, + deadlineMs: callerDeadlineMs + }) + + await vi.advanceTimersByTimeAsync(1_000) + await expect(stopPromise).resolves.toBe(true) + + expect(listProcesses.mock.calls[0]?.[0]).toEqual({ deadlineMs: callerDeadlineMs }) + const settlementDeadlines = listProcesses.mock.calls + .slice(1) + .map(([opts]) => opts?.deadlineMs) + expect(settlementDeadlines.length).toBeGreaterThan(0) + expect(new Set(settlementDeadlines)).toEqual(new Set([callerDeadlineMs - 4_000])) + } finally { + vi.useRealTimers() + } + }) it('uses inventory, not an incarnation-less exit, to confirm the current PTY stopped', async () => { const exitListeners = new Set< (payload: { id: string; code: number; incarnationId?: string }) => void diff --git a/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts b/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts index f323039191f..bfacfc96935 100644 --- a/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts +++ b/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts @@ -198,7 +198,7 @@ describe('registerPtyHandlers', () => { }) expect(warnSpy).toHaveBeenCalledWith( '[pty] hidden-delivery gate is dropping bytes for a visible/active pty', - expect.objectContaining({ id: result.id, visible: true }) + expect.objectContaining({ id: redactPtyIdForDiagnostics(result.id), visible: true }) ) // Unhiding resolves the contradiction. diff --git a/src/main/ipc/pty-ipc-producer-flow-control.test.ts b/src/main/ipc/pty-ipc-producer-flow-control.test.ts index bfc65403daa..8b97751dba7 100644 --- a/src/main/ipc/pty-ipc-producer-flow-control.test.ts +++ b/src/main/ipc/pty-ipc-producer-flow-control.test.ts @@ -206,6 +206,24 @@ describe('registerPtyHandlers', () => { vi.useRealTimers() } }) + it('releases a paused producer when handlers re-register for a replacement window', () => { + vi.useFakeTimers() + try { + const provider = installObservableDaemonTestProvider() + registerPtyHandlers(mainWindow as never) + mainWindow.webContents.send.mockClear() + + provider.emitData('flood-pty', 'x'.repeat(320 * 1024)) + expect(provider.pauseProducer).toHaveBeenCalledTimes(1) + + // Re-registration hands delivery to a new window; the outgoing session must run its real + // lifecycle reset before the bridge is neutralized or this shell stays paused forever. + registerPtyHandlers(mainWindow as never) + expect(provider.resumeProducer).toHaveBeenCalledWith('flood-pty') + } finally { + vi.useRealTimers() + } + }) it('fences synchronous producer data and duplicate exit while releasing an exiting PTY', () => { vi.useFakeTimers() try { diff --git a/src/main/ipc/pty-pane-claim-arbitration.test.ts b/src/main/ipc/pty-pane-claim-arbitration.test.ts index 8ab7beca71c..f4f57fc6b0b 100644 --- a/src/main/ipc/pty-pane-claim-arbitration.test.ts +++ b/src/main/ipc/pty-pane-claim-arbitration.test.ts @@ -179,12 +179,19 @@ describe('registerPtyHandlers', () => { } }): Promise<{ id: string }> } + let exitCallback: ((event: { exitCode: number }) => void) | undefined + const killSpy = vi.fn(() => { + exitCallback?.({ exitCode: 0 }) + }) const proc = { onData: vi.fn(), - onExit: vi.fn(), + onExit: vi.fn((cb) => { + exitCallback = cb + return { dispose: () => {} } + }), write: vi.fn(), resize: vi.fn(), - kill: vi.fn(), + kill: killSpy, process: 'zsh', pid: 12345 } @@ -239,7 +246,7 @@ describe('registerPtyHandlers', () => { expect(store.persistPtyBinding).toHaveBeenCalledWith( expect.objectContaining({ expectedSourceBinding }) ) - expect(proc.kill).toHaveBeenCalledOnce() + expect(killSpy).toHaveBeenCalledOnce() }) it('reports lower-owner commit before rejecting an early-exited runtime incarnation', async () => { const persistPtyBinding = vi.fn() @@ -498,9 +505,7 @@ describe('registerPtyHandlers', () => { ORCA_WORKTREE_ID: 'repo-1::/tmp' } }) as Promise<{ id: string }> - await Promise.resolve() - - expect(providerSpawn).toHaveBeenCalledTimes(1) + await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledTimes(1)) resolveSpawn({ id: 'pty-shared' }) await expect(Promise.all([runtimeSpawn, rendererSpawn])).resolves.toEqual([ { id: 'pty-shared' }, diff --git a/src/main/ipc/pty-pane-materialization-race.test.ts b/src/main/ipc/pty-pane-materialization-race.test.ts index bce7b448d3c..ee1bc16c75b 100644 --- a/src/main/ipc/pty-pane-materialization-race.test.ts +++ b/src/main/ipc/pty-pane-materialization-race.test.ts @@ -131,6 +131,124 @@ describe('registerPtyHandlers', () => { await expect(mounted).resolves.toMatchObject({ id: 'pty-after-runtime-claim' }) expect(providerSpawn).toHaveBeenCalledOnce() }) + it('adopts a runtime reservation created during renderer preflight', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + command?: string + sessionId?: string + tabId: string + leafId: string + env: Record + }): Promise<{ id: string }> + } + let resolveProviderSpawn!: (result: { id: string }) => void + const providerSpawn = vi.fn( + () => + new Promise<{ id: string }>((resolve) => { + resolveProviderSpawn = resolve + }) + ) + setLocalPtyProvider({ + spawn: providerSpawn, + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-concurrent'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + let releaseAuth!: (value: { + configDir: string + envPatch: Record + stripAuthEnv: false + provenance: string + }) => void + const prepareClaudeAuth = vi.fn( + () => + new Promise<{ + configDir: string + envPatch: Record + stripAuthEnv: false + provenance: string + }>((resolve) => { + releaseAuth = resolve + }) + ) + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + prepareClaudeAuth + ) + const tabId = 'tab-mid-preflight-runtime' + const leafId = '55555555-5555-4555-8555-555555555555' + const paneKey = makePaneKey(tabId, leafId) + const paneArgs = { + cols: 80, + rows: 24, + sessionId: 'pty-runtime-reservation', + tabId, + leafId, + env: { ORCA_PANE_KEY: paneKey } + } + + const rendererSpawn = handlers.get('pty:spawn')!(null, { + ...paneArgs, + command: 'claude' + }) + await vi.waitFor(() => expect(prepareClaudeAuth).toHaveBeenCalledOnce()) + + const runtimeSpawn = (controller as unknown as RuntimeSpawnController).spawn({ + ...paneArgs, + command: 'printf runtime' + }) + await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledOnce()) + releaseAuth({ + configDir: '/tmp/claude', + envPatch: {}, + stripAuthEnv: false, + provenance: 'managed:test' + }) + await new Promise((resolve) => setImmediate(resolve)) + resolveProviderSpawn({ id: 'pty-runtime-reservation' }) + + await expect(runtimeSpawn).resolves.toEqual({ id: 'pty-runtime-reservation' }) + await expect(rendererSpawn).resolves.toMatchObject({ + id: 'pty-runtime-reservation', + isReattach: true + }) + expect(providerSpawn).toHaveBeenCalledOnce() + await expect( + handlers.get('pty:getSize')!(null, { id: 'pty-runtime-reservation' }) + ).resolves.toEqual({ cols: 80, rows: 24 }) + }) it('reuses renderer spawn when runtime materialization starts for the same pane', async () => { type RuntimeSpawnController = { spawn(args: { diff --git a/src/main/ipc/pty-serializer-settlement-mapping.test.ts b/src/main/ipc/pty-serializer-settlement-mapping.test.ts index a203af2743e..7e526ea317e 100644 --- a/src/main/ipc/pty-serializer-settlement-mapping.test.ts +++ b/src/main/ipc/pty-serializer-settlement-mapping.test.ts @@ -408,6 +408,7 @@ describe('registerPtyHandlers', () => { const destroyedListeners: (() => void)[] = [] const sender = { id: 42, + isDestroyed: () => false, once: vi.fn((event: string, listener: () => void) => { if (event === 'destroyed') { destroyedListeners.push(listener) @@ -422,6 +423,20 @@ describe('registerPtyHandlers', () => { destroyedListeners[0]() expect(hasPendingRendererSerializerForPaneKey(paneKey)).toBe(false) }) + it('does not retain a serializer declaration from an already-destroyed renderer', async () => { + registerPtyHandlers(mainWindow as never) + const paneKey = makePaneKey('tab-dead', '77777777-7777-4777-8777-777777777777') + const sender = { + id: 43, + isDestroyed: () => true, + once: vi.fn() + } + + await handlers.get('pty:declarePendingPaneSerializer')!({ sender }, { paneKey }) + + expect(hasPendingRendererSerializerForPaneKey(paneKey)).toBe(false) + expect(sender.once).not.toHaveBeenCalled() + }) it('ignores renderer-provided ORCA_TERMINAL_HANDLE for local PTY spawns', async () => { const runtime = { setPtyController: vi.fn(), diff --git a/src/main/ipc/pty-session-liveness-and-ownership.test.ts b/src/main/ipc/pty-session-liveness-and-ownership.test.ts index e7377dbe927..97c0e614933 100644 --- a/src/main/ipc/pty-session-liveness-and-ownership.test.ts +++ b/src/main/ipc/pty-session-liveness-and-ownership.test.ts @@ -91,6 +91,21 @@ describe('registerPtyHandlers', () => { ).resolves.toBe(null) expect(hasPty).not.toHaveBeenCalled() }) + it.each(['remote:env-1@@terminal-1', 'ssh:missing-host@@pty-1'])( + 'never routes process inspection for %s to the local provider', + async (id) => { + const inspectProcess = vi.fn() + registerPtyHandlers(mainWindow as never) + setLocalPtyProvider({ inspectProcess } as never) + + await expect(handlers.get('pty:inspectProcess')!(null, { id })).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: false, + unavailable: true + }) + expect(inspectProcess).not.toHaveBeenCalled() + } + ) it('lists duplicate SSH relay session ids as distinct app sessions', async () => { registerPtyHandlers(mainWindow as never) const shutdownA = vi.fn(async () => undefined) diff --git a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts index 219b3f94d9d..6e5e57f3ad6 100644 --- a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts +++ b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts @@ -5,6 +5,12 @@ import { createHash } from 'node:crypto' import { delimiter } from 'node:path' import { makePaneKey } from '../../shared/stable-pane-id' import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { ClaudeAgentTeamsService } from '../runtime/claude-agent-teams-service' +import { + clearPaneSpawnReservation, + makePaneSpawnReservationKey, + reservePaneSpawn +} from './pty/pane/spawn-reservation' import { registerPtyHandlers, registerSshPtyProvider } from './pty' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -500,4 +506,121 @@ describe('registerPtyHandlers', () => { id: spawned.id }) }) + it('releases the Agent Teams leader of a spawn that loses the pane mid-preflight', async () => { + const leafId = '22222222-2222-4222-8222-222222222222' + const teams = new ClaudeAgentTeamsService() + let handleSeq = 0 + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => `term_agent_teams_${++handleSeq}`), + prepareClaudeAgentTeamsLeaderForHandle: vi.fn( + async (args: { handle: string; baseEnv?: Record }) => + teams.createLaunchEnv({ + leaderHandle: args.handle, + baseEnv: args.baseEnv ?? {}, + shimDir: '/tmp/orca-agent-teams-shim', + shimBin: null + }) + ), + releaseClaudeAgentTeamsLeaderForHandle: vi.fn((handle: string) => { + teams.removeTeamForLeaderHandle(handle) + }), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + registerPtyHandlers(mainWindow as never, runtime as never) + // Why: no worktreeId, so the reservation key only resolves after env assembly — the + // window where a concurrent winner's reservation appears mid-preflight. + const spawnArgs = { + cols: 80, + rows: 24, + cwd: '/repo', + command: 'claude --teammate-mode auto', + tabId: 'tab-1', + leafId, + env: { ORCA_PANE_KEY: `tab-1:${leafId}`, ORCA_TAB_ID: 'tab-1' }, + launchConfig: { agentCommand: 'claude --teammate-mode auto', agentArgs: '', agentEnv: {} }, + launchAgent: 'claude' + } + const winner = (await handlers.get('pty:spawn')!(mainWindowIpcEvent, spawnArgs)) as { + id: string + } + expect(teams.getActiveTeamCount()).toBe(1) + + const reservationKey = makePaneSpawnReservationKey( + undefined, + undefined, + makePaneKey('tab-1', leafId) + )! + const winnerReservation = reservePaneSpawn(reservationKey) + winnerReservation.resolve({ id: winner.id }) + try { + const loser = await handlers.get('pty:spawn')!(mainWindowIpcEvent, spawnArgs) + expect(loser).toMatchObject({ id: winner.id, isReattach: true }) + } finally { + clearPaneSpawnReservation(reservationKey, winnerReservation) + } + + const loserHandle = runtime.prepareClaudeAgentTeamsLeaderForHandle.mock.calls[1]?.[0].handle + expect(runtime.prepareClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledTimes(2) + expect(runtime.releaseClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledWith(loserHandle) + // The losing request must not grow the team count it can never bind to a PTY. + expect(teams.getActiveTeamCount()).toBe(1) + }) + it('releases the Agent Teams leader when its provider spawn fails', async () => { + const leafId = '33333333-3333-4333-8333-333333333333' + const teams = new ClaudeAgentTeamsService() + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => 'term_agent_teams_failure'), + prepareClaudeAgentTeamsLeaderForHandle: vi.fn( + async (args: { handle: string; baseEnv?: Record }) => + teams.createLaunchEnv({ + leaderHandle: args.handle, + baseEnv: args.baseEnv ?? {}, + shimDir: '/tmp/orca-agent-teams-shim', + shimBin: null + }) + ), + releaseClaudeAgentTeamsLeaderForHandle: vi.fn((handle: string) => { + teams.removeTeamForLeaderHandle(handle) + }), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + spawnMock.mockImplementation(() => { + throw new Error('provider spawn failed') + }) + + registerPtyHandlers(mainWindow as never, runtime as never) + + await expect( + handlers.get('pty:spawn')!(mainWindowIpcEvent, { + cols: 80, + rows: 24, + cwd: '/repo', + command: 'claude --teammate-mode auto', + tabId: 'tab-1', + leafId, + worktreeId: 'wt-1', + env: { ORCA_PANE_KEY: `tab-1:${leafId}`, ORCA_TAB_ID: 'tab-1' }, + launchConfig: { agentCommand: 'claude --teammate-mode auto', agentArgs: '', agentEnv: {} }, + launchAgent: 'claude' + }) + ).rejects.toThrow() + + expect(runtime.releaseClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledWith( + 'term_agent_teams_failure' + ) + expect(teams.getActiveTeamCount()).toBe(0) + }) }) diff --git a/src/main/ipc/pty-startup-barrier-ordering.test.ts b/src/main/ipc/pty-startup-barrier-ordering.test.ts index 95dafaaab8a..dcf8a5d4c44 100644 --- a/src/main/ipc/pty-startup-barrier-ordering.test.ts +++ b/src/main/ipc/pty-startup-barrier-ordering.test.ts @@ -2,26 +2,27 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +function readRepoSource(relPath: string): string { + return readFileSync(join(process.cwd(), relPath), 'utf8').replace(/\r\n?/g, '\n') +} + describe('PTY startup barrier ordering', () => { it('waits for local startup before resolving the provider for runtime and renderer spawns', () => { - const source = readFileSync(join(process.cwd(), 'src/main/ipc/pty.ts'), 'utf8').replace( - /\r\n?/g, - '\n' - ) - const runtimeSpawnStart = source.indexOf('spawn: async (args) => {') - const runtimeSpawnEnd = source.indexOf(' write:', runtimeSpawnStart) - const runtimeSpawn = source.slice(runtimeSpawnStart, runtimeSpawnEnd) - // Why `ipc.` and not `ipcMain.`: the module registers against an injected surface now - // (pty-host-bindings) so it can run without electron. The ordering this asserts is unchanged. - const rendererSpawnStart = source.indexOf("ipc.handle(\n 'pty:spawn'") - const rendererSpawnEnd = source.indexOf("ipc.handle(\n 'pty:kill'", rendererSpawnStart) - const rendererSpawn = source.slice(rendererSpawnStart, rendererSpawnEnd) + const runtimeSpawn = + readRepoSource('src/main/ipc/pty/runtime/spawn.ts') + + readRepoSource('src/main/ipc/pty/runtime/spawn-early.ts') + + readRepoSource('src/main/ipc/pty/runtime/spawn-preflight.ts') + + const rendererSource = + readRepoSource('src/main/ipc/pty/ipc/spawn.ts') + + readRepoSource('src/main/ipc/pty/ipc/spawn-begin.ts') + + readRepoSource('src/main/ipc/pty/ipc/spawn-preflight.ts') + const rendererSpawnStart = rendererSource.indexOf("ipcMain.handle('pty:spawn'") + const rendererSpawn = rendererSource.slice(rendererSpawnStart) for (const spawnBlock of [runtimeSpawn, rendererSpawn]) { - const barrierIndex = spawnBlock.indexOf( - 'const startupPromise = getLocalPtyStartupPromise(args.connectionId)' - ) - const providerIndex = spawnBlock.indexOf('const provider = getProvider(args.connectionId)') + const barrierIndex = spawnBlock.indexOf('getLocalPtyStartupPromise(args.connectionId)') + const providerIndex = spawnBlock.indexOf('getProvider(args.connectionId)') expect(barrierIndex).toBeGreaterThanOrEqual(0) expect(providerIndex).toBeGreaterThanOrEqual(0) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 3adcdb56376..7499cf10a01 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1,8035 +1,39 @@ -/* eslint-disable max-lines -- Why: PTY IPC is centralized in one main-process module so spawn env scoping, lifecycle cleanup, process inspection, and renderer IPC stay behind one audited boundary. */ -import { join, delimiter } from 'node:path' -import { getAppEnvironment } from '../../shared/app-environment' -import { getPtyIpc, getPtyPower, type PtyPowerSurface } from './pty-host-bindings' -import { isRendererGone, rendererWebContents, sendToRenderer } from './pty-renderer-surface' -import { randomUUID } from 'node:crypto' -import { statSync } from 'node:fs' -import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready-bash-rcfile' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import type { PtyBindingSourceExpectation, Store } from '../persistence' -import { retireTerminalSurfaceFromPersistence } from '../runtime/mobile-session-terminal-persistence-retirement' -import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../shared/pty-liveness-verdict' -import type { GlobalSettings } from '../../shared/global-settings-types' -import type { TuiAgent } from '../../shared/tui-agent' -import { - LOCAL_EXECUTION_HOST_ID, - toSshExecutionHostId, - type ExecutionHostId -} from '../../shared/execution-host' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy' -import type { - PtyDeliveryWriteOff, - PtyRendererDeliveryHealthReply, - PtyRendererDeliveryStateReport -} from '../../shared/pty-renderer-delivery-health' -import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' -import { - INITIAL_MODE_2031_REPLY_SCAN_STATE, - scanMode2031ReplyDecision, - type Mode2031ReplyScanState -} from '../../shared/terminal-color-scheme-protocol' -import { - type PtyMainDeliveryDiagnostics, - type PtyPerPtyDeliveryDiagnostics, - EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, - createPtyDeliveryBreadcrumbRing, - redactPtyIdForDiagnostics -} from '../../shared/pty-delivery-diagnostics' -import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' -import { isTuiAgent } from '../../shared/tui-agent-config' -import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' -import { - normalizeAgentProviderSession, - type AgentProviderSessionMetadata, - type SleepingAgentLaunchConfig -} from '../../shared/agent-session-resume' -import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' -import { - isWslShellName, - resolveLocalWindowsTerminalRuntimeOptions -} from '../../shared/local-windows-terminal-runtime' -import { applyTerminalGitCredentialPromptGuard } from './terminal-git-credential-guard' -import { openCodeHookService } from '../opencode/hook-service' -import { mimoCodeHookService } from '../mimo/hook-service' -import { - getCommandTokenPathBasename, - getFirstCommandToken -} from '../../shared/command-token-scanner' -import { agentHookServer } from '../agent-hooks/server' -import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' -import { ensureWslHookRelayForReattach } from '../agent-hooks/wsl-hook-relay-reattach' -import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' -import { piTitlebarExtensionService } from '../pi/titlebar-extension-service' -import { - detectExplicitPiAgentKindFromCommand, - isPiCompatibleAgentType, - PRIMARY_AGENT_DIR_ENV_BY_KIND, - SOURCE_AGENT_DIR_ENV_BY_KIND, - type PiAgentKind -} from '../../shared/pi-agent-kind' -import { isPwshAvailableAsync } from '../pwsh' -import { LocalPtyProvider } from '../providers/local-pty-provider' -import type { PtyProcessInfo } from '../providers/pty-process-info' -import { normalizeWindowsTerminalCwd } from '../providers/windows-shell-args' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' -import { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' -import { - inspectPtyProviderProcess, - inspectPtyProviderProcessForRenderer -} from '../providers/pty-process-inspection' -import { - PtyProcessListAdmission, - visitPtyProcessListingsInBatches -} from '../providers/pty-process-list-admission' -import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' -import { - SSH_SESSION_EXPIRED_ERROR, - isSshPtyIdentityMismatchError, - isSshPtyNotFoundError -} from '../providers/ssh-pty-errors' -import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' -import { createPtySpawnTiming } from './pty-spawn-timing' -import { - isSafePtySessionId, - mintPtySessionId, - ptySessionIdForAgentCreateOperation -} from '../daemon/pty-session-id' -import { resolveWslSessionContext } from '../daemon/wsl-session-context' -import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' -import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe' -import { - isDaemonEndpointGoneError, - TerminalHostGoneError, - TerminalSessionOwnerUnverifiedError -} from '../daemon/daemon-errors' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' -import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' -import { - isClaudeAuthSwitchInProgress, - markClaudePtyExited, - markClaudePtySpawned -} from '../claude-accounts/live-pty-gate' -import { ensureLinuxTerminalOrcaCliShimDir } from '../cli/linux-terminal-orca-cli-shim' -import { - isLegacyTerminalShimPathEntry, - LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS, - stripLegacyTerminalShimEnv -} from '../pty/legacy-terminal-shim-dir' -import { registerPty, unregisterPty } from '../memory/pty-registry' -import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' -import { track } from '../telemetry/client' -import { classifyError } from '../telemetry/classify-error' -import { getCohortAtEmit } from '../telemetry/cohort-classifier' -import { - agentKindSchema, - launchSourceSchema, - requestKindSchema -} from '../../shared/telemetry-events' -import { - isTerminalInputTooLargeWithDeferredMeasurement, - iterateTerminalInputChunks -} from '../../shared/terminal-input' -import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' -import { createTerminalSessionStateSaveFailureMessage } from '../../shared/terminal-session-state-save-failure' -import { RendererTerminalSerializerReadiness } from './renderer-terminal-serializer-readiness' -import { readSessionShellStartupEnvVar } from '../pty/shell-startup-env' -import { - isTerminalLeafId, - makePaneKey, - parseLegacyNumericPaneKey, - parsePaneKey -} from '../../shared/stable-pane-id' -import { isValidTerminalTabId } from '../../shared/terminal-tab-id' -import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' -import { - resolveTerminalStartupCwdForWorkspace, - type TerminalStartupCwdMissingDirFallback -} from '../../shared/terminal-startup-cwd' -import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' -import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' -import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' -import { - agentSessionOwnerBindingsEqual, - ClaimedAgentPtyOwnerRegistry -} from '../../shared/claimed-agent-pty-owner' -import { - clearMigrationUnsupportedPty, - clearMigrationUnsupportedPtysForPaneKey -} from '../agent-hooks/migration-unsupported-pty-state' -import { parseWslPath, wslUncDirectoryExistsAsync } from '../wsl' -import { mergePersistedWindowsPath, resolvePathEnvKey } from '../pty/windows-environment-path' -import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env' -import { resolveCodexShellLaunchPreflightCommand } from '../pty/codex-shell-launch-preflight' -import { PtyProducerFlowController } from './pty-producer-flow-control' -import { beginTerminalInstall } from './watcher-removal-gate' -import { - clearHiddenRendererPtyDeliveryState, - getHiddenRendererPtyDeliveryDebug, - getHiddenRendererPtyIds, - isHiddenPtyDeliveryGateEnabled, - isHiddenRendererPty, - markHiddenRendererPty, - recordHiddenRendererPtyDataDrop, - resetHiddenRendererPtyDeliveryDebugCounters, - resetRendererScopedHiddenPtyDeliveryState, - setRendererPtyDeliveryInterest, - shouldDropHiddenRendererPtyData, - unmarkHiddenRendererPty -} from './pty-hidden-delivery-gate' -import { PtyPendingDataDrainQueue, type PendingPtyData } from './pty-pending-data-drain-queue' -import { - appendPendingProjectionAdmission, - compactPendingProjectionAdmissions, - propagatePendingProjectionRemainder, - type PendingProjectionAdmissions -} from './pty-pending-projection-admissions' -import { SshPtyOutputIntake } from './ssh-pty-output-intake' -import { - cancelSshPtySourceDelivery, - installSshPtyOutputIntake, - publishSshPtySourceAck -} from './ssh-pty-output-intake-registry' -import type { LegacySshProjectionSemantics } from './ssh-pty-legacy-projection' -import { - clearNativeWindowsConptyPty, - isNativeWindowsLocalPtySpawn, - markNativeWindowsConptyPty -} from '../runtime/terminal-model-query-authority' -import { setTerminalViewAttributes } from '../runtime/terminal-view-attribute-store' -import { validateTerminalViewAttributes } from '../../shared/terminal-view-attributes' -import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker' -import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' -import { - isCodexHomeAuthReadyForLaunch, - waitForManagedCodexAuthReady -} from '../codex-accounts/managed-codex-auth-readiness' -import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' -import { - forgetCodexPaneAccount, - getCodexPaneAccount, - recordCodexPaneAccount, - type CodexPaneHomeRoute -} from '../codex/codex-pane-account-registry' -import { resolveCodexPaneLaunchAccount } from '../codex/codex-pane-launch-account' -import { getSystemCodexHomePath } from '../codex/codex-home-paths' -import { ensureCodexStateDbBackfillRecoveryStarted } from '../codex/codex-state-db-backfill-recovery' -import { - environmentCodexHomeOverrideContextsEqual, - getCustomCodexHomeOverrideForLaunch, - shellStartupCodexHomeOverrideContextsEqual -} from '../codex/codex-real-home-path' -import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home' -import { dropUnverifiedCodexResumeArgv } from '../codex/codex-unverified-resume-launch' -import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' -import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' -import { - resolveSetupAgentSequenceLaunchCommand, - SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV -} from '../../shared/setup-agent-sequencing' -import { dropAgentResumeArgvFromCommand } from '../../shared/agent-resume-argv-drop' -import { parseWorkspaceKey } from '../../shared/workspace-scope' -import { getStartupTerminalColorQueryReplyColors } from './terminal-startup-color-query-replies' -import { - assertFolderWorkspacePathUsable, - getFolderWorkspacePathStatus -} from '../project-groups/folder-workspace-path-status' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' -import { isPtyIncarnationId } from '../../shared/pty-incarnation' -import type { PtyListedSession } from '../../shared/pty-listed-session' - -// ─── Provider Registry ────────────────────────────────────────────── -// Routes PTY operations by connectionId (null = local provider). - -let localProvider: IPtyProvider = new LocalPtyProvider() -const sshProviders = new Map() -const sshProvidersByGeneration = new Map() - -type RegisteredPtyProvider = { - provider: IPtyProvider - connectionId: string | null -} - -function registeredPtyProviders(): RegisteredPtyProvider[] { - return [ - { provider: localProvider, connectionId: null }, - ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) - ] -} - -// Why: settling each provider separately only bounds a relay that *rejects*. An -// unanswered relay list runs to the mux's own 30s default — far past the caller's -// budget — so the aggregate still expired and the runtime lost the inventory it -// needs to retire exited PTYs, freezing every retained pane as "active" (STA-517). -// Forward the caller's deadline so a silent relay fails fast and lands in the -// unavailable branch below: unknown, not empty. -async function listRegisteredPtyProcessesWithHostScope( - onSshInventoryUnavailable?: (connectionId: string, error: unknown) => void, - opts?: { deadlineMs?: number } -): Promise<{ - processes: PtyProcessInfo[] - hostIds: ExecutionHostId[] -}> { - const providers = registeredPtyProviders() - const providerSessions = await Promise.all( - providers.map(async ({ provider, connectionId }) => { - try { - const hostId: ExecutionHostId = connectionId - ? toSshExecutionHostId(connectionId) - : LOCAL_EXECUTION_HOST_ID - return { - // Why: the deadline only applies to relay round-trips; the local provider answers in-process. - processes: await (connectionId ? provider.listProcesses(opts) : provider.listProcesses()), - hostId - } - } catch (error) { - if (!connectionId) { - throw error - } - onSshInventoryUnavailable?.(connectionId, error) - return null - } - }) - ) - const respondingSessions = providerSessions.filter((session) => session !== null) - return { - processes: respondingSessions.flatMap((session) => session.processes), - hostIds: respondingSessions.map((session) => session.hostId) - } -} - -const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 -// Why: kill switch — flip to disable producer flow control (pause/resume) without untangling the wiring. -const PRODUCER_FLOW_CONTROL_ENABLED = true -// Why: post-spawn write/resize/kill calls carry only the PTY ID; map it to its connectionId so ops route to the right provider. -const ptyOwnership = new Map() -const ptyIncarnationById = new Map() - -export function isCurrentPtyExit(payload: { id: string; incarnationId?: string }): boolean { - const current = ptyIncarnationById.get(payload.id) - return !current || payload.incarnationId === current -} -// Why: mobile clients must mirror desktop PTY geometry even before the renderer can provide an xterm snapshot (e.g. right after tab creation). -const ptySizes = new Map() -// Why: the "recent user input" signal is PTY-scoped and must be cleared by every teardown path, incl. SSH/daemon shutdowns that skip the local exit listener. -const lastInputAtByPty = new Map() -const interactiveOutputCharsByPty = new Map() -const activeRendererPtys = new Set() -const visibleRendererPtys = new Set() -const rendererVisibilityKnownPtys = new Set() -let invalidatePendingPtyDrainPriority = (_id?: string, _schedule?: boolean): void => {} -let invalidatePendingPtyDrainPolicy = (_id?: string, _schedule?: boolean): void => {} -const pendingHiddenRendererResizeOutputPtys = new Set() -const deliveredHiddenRendererResizeOutputPtys = new Set() -const KEEP_HISTORY_STOP_SETTLE_MS = 1_000 -const KEEP_HISTORY_STOP_POLL_MS = 100 -// Why: track spawn-time paneKey so teardown can clear the agent-hooks server's per-paneKey cache, which otherwise grows unbounded as panes come and go. -const ptyPaneKey = new Map() -// Why: reverse of ptyPaneKey — callers with a paneKey from outside the PTY lifecycle (e.g. agent-hook status routing) need the ptyId; kept in lock-step via the same sites. -const paneKeyPtyId = new Map() - -const AGENT_HOOK_RUNTIME_ENV_KEYS = [ - 'ORCA_AGENT_HOOK_PORT', - 'ORCA_AGENT_HOOK_TOKEN', - 'ORCA_AGENT_HOOK_ENV', - 'ORCA_AGENT_HOOK_VERSION', - 'ORCA_AGENT_HOOK_ENDPOINT', - // Why: PR 2778 briefly exported this path; keep deleting stale inherited values so older PTYs can't leak the reverted path. - 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' -] as const - -// Why: Orca never sets these, so an inherited value means a pty host launched from inside a Claude session — Claude reads it as a nested child and silently stops persisting the transcript. -const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ - 'CLAUDE_CODE_CHILD_SESSION', - 'CLAUDE_CODE_SESSION_ID', - 'CLAUDE_CODE_BRIDGE_SESSION_ID' -] as const - -export function getPtyIdForPaneKey(paneKey: string): string | undefined { - return paneKeyPtyId.get(paneKey) -} - -// Why: let consumers tear down paneKey-scoped state on PTY exit so their timers can't leak; a callback registry keeps the cross-module dependency narrow. -type PaneKeyTeardownListener = (paneKey: string) => void -const paneKeyTeardownListeners = new Set() - -export function registerPaneKeyTeardownListener(listener: PaneKeyTeardownListener): () => void { - paneKeyTeardownListeners.add(listener) - return () => paneKeyTeardownListeners.delete(listener) -} - -// Why: renderer pre-declares serializer ownership before pty:spawn to suppress the daemon-snapshot seed; gen tokens prevent paneKey-reuse races on teardown. See docs/mobile-prefer-renderer-scrollback.md. -let pendingSerializerGenSeq = 0 -const pendingByPaneKey = new Map() -const pendingPaneSerializerCleanupRegistered = new Set() -type PaneSpawnReservation = { - promise: Promise - resolve: (result: PaneSpawnReservationResult) => void - reject: (error: unknown) => void -} -type PaneSpawnReservationResult = { - id: string - launchConfig?: SleepingAgentLaunchConfig - stablePaneOwner?: { - handle: string - tabId: string - leafId: string - } -} & Partial -// Why: identical pane coordinates on different worktrees or hosts are independent owners. -const paneSpawnReservationsByOwnerKey = new Map() -type PendingRuntimePaneCreate = { - count: number - promise: Promise - resolve: () => void -} -const pendingRuntimePaneCreatesByOwnerKey = new Map() -// Why: one main process can route the same remote provider namespace through -// multiple SSH relays; coordinate claims above every provider boundary too. -const agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() -let agentSessionOwnerReconciliation: Promise | null = null - -// Why: restore payloads (reattach snapshot / cold-restore scrollback / relay -// replay + lastTitle) ride spawn RPC results, never onPtyData, so EVERY spawn -// choke point — renderer pty:spawn and the runtime controller — must seed the -// terminal list/read records or headless/CLI-created reattaches stay blank. -// The runtime's empty-record guard makes a second seed for the same session a -// no-op, so overlapping paths cannot double-apply history. -function seedTerminalRestoreRecordsFromSpawnResult( - runtime: OrcaRuntimeService | undefined, - result: PtySpawnResult -): void { - const text = - typeof result.snapshot === 'string' && result.snapshot.length > 0 - ? result.snapshot - : typeof result.coldRestore?.scrollback === 'string' && - result.coldRestore.scrollback.length > 0 - ? result.coldRestore.scrollback - : typeof result.replay === 'string' && result.replay.length > 0 - ? result.replay - : undefined - const lastTitle = - typeof result.lastTitle === 'string' && result.lastTitle.length > 0 - ? result.lastTitle - : typeof result.coldRestore?.lastTitle === 'string' && result.coldRestore.lastTitle.length > 0 - ? result.coldRestore.lastTitle - : undefined - if (text !== undefined || lastTitle !== undefined) { - runtime?.seedTerminalRestoreTail?.(result.id, { - ...(text !== undefined ? { text } : {}), - ...(lastTitle !== undefined ? { lastTitle } : {}) - }) - } -} - -function assertSpawnReplyWasLive(result: PtySpawnResult): void { - if (!result.exitedBeforeSpawnReply) { - return - } - // Why: lower owners can resolve a different canonical id, so controller-local pending ids cannot prove this exit. - throw Object.assign(new Error('agent_session_exited_during_start'), { - agentSessionOperationOutcome: 'unknown' as const - }) -} - -async function reconcileAgentSessionOwnerListings(): Promise { - if (agentSessionOwnerReconciliation) { - return await agentSessionOwnerReconciliation - } - const reconciliation = (async () => { - const providers: { provider: IPtyProvider; connectionId: string | null }[] = [ - { provider: localProvider, connectionId: null }, - ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) - ] - const listings = await Promise.all( - providers.map(async ({ provider, connectionId }) => ({ - connectionId, - sessions: await provider.listProcesses() - })) - ) - const advertisedOwners: AgentSessionOwnerBinding[] = [] - const advertisedOwnerSessions: { - id: string - connectionId: string | null - incarnationId: string - }[] = [] - for (const { connectionId, sessions } of listings) { - for (const session of sessions) { - const incarnationId = session.incarnationId - let hasAdvertisedOwner = false - for (const owner of session.agentSessionOwners ?? []) { - if (owner.ptyId !== session.id || !isPtyIncarnationId(incarnationId)) { - // Why: a recovered claim without process-incarnation proof cannot safely reject a delayed exit. - throw new Error('agent_session_ownership_unknown') - } - advertisedOwners.push(owner) - hasAdvertisedOwner = true - } - if (hasAdvertisedOwner && isPtyIncarnationId(incarnationId)) { - advertisedOwnerSessions.push({ id: session.id, connectionId, incarnationId }) - } - } - } - agentSessionOwners.reconcileAuthoritative(advertisedOwners, { - // Why: an unregistered relay can still own a live PTY during reconnect; - // only providers that serialize claims may make listing absence authoritative. - isInAuthoritativeScope: (owner) => { - const provider = tryGetProviderForAgentSessionOwner(owner.ptyId) - return provider?.providesAgentSessionOwnerListings?.(owner.ptyId) === true - } - }) - for (const session of advertisedOwnerSessions) { - ptyOwnership.set(session.id, session.connectionId) - ptyIncarnationById.set(session.id, session.incarnationId) - } - })() - agentSessionOwnerReconciliation = reconciliation - try { - await reconciliation - } finally { - if (agentSessionOwnerReconciliation === reconciliation) { - agentSessionOwnerReconciliation = null - } - } -} -// Why: bind the declaration generation directly to its spawn result. PTY ids -// are reusable and teardown callbacks carry no incarnation token, so teardown -// must never guess which pending renderer generation it owns. -const pendingPtyIdBySerializerGeneration = new Map() -// Why: hasRendererSerializer probe needs a ptyId-keyed signal; a later spawn starts a fresh incarnation, subscription abort owns waiter cleanup. -const rendererSerializerReadiness = new RendererTerminalSerializerReadiness() - -function parseValidPaneKey(paneKey: unknown): ReturnType { - if (typeof paneKey !== 'string' || paneKey.length > 256) { - return null - } - return parsePaneKey(paneKey) -} - -function isValidPaneKey(paneKey: unknown): paneKey is string { - return parseValidPaneKey(paneKey) !== null -} - -const AGENT_LAUNCH_TOKEN_MAX_LENGTH = 128 - -function admitRendererAgentLaunchAuthority(args: { - launchToken: unknown - spawnEnv: Record | undefined - launchAgent: unknown - launchConfig: SleepingAgentLaunchConfig | undefined - isReattach: boolean - hasStablePaneOwner: boolean - incarnationId: unknown -}): { launchToken: string; launchAgent: TuiAgent } | null { - if ( - args.isReattach || - args.hasStablePaneOwner || - !args.launchConfig || - !isTuiAgent(args.launchAgent) || - !isPtyIncarnationId(args.incarnationId) || - typeof args.launchToken !== 'string' || - args.launchToken.length === 0 || - args.launchToken.length > AGENT_LAUNCH_TOKEN_MAX_LENGTH || - args.spawnEnv?.ORCA_AGENT_LAUNCH_TOKEN !== args.launchToken - ) { - return null - } - return { launchToken: args.launchToken, launchAgent: args.launchAgent } -} - -function shouldRefreshNativeClaudeAgentTeamsEnv(args: { - command?: string - launchConfig?: SleepingAgentLaunchConfig -}): boolean { - const capturedCommand = args.launchConfig?.agentCommand?.trim() || args.command?.trim() || '' - const capturedArgs = args.launchConfig?.agentArgs?.trim() ?? '' - const capturedLaunch = `${capturedCommand} ${capturedArgs}`.trim() - return /(^|\s)--teammate-mode(?:=|\s+)auto(?:\s|$)/.test(capturedLaunch) -} - -function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null { - const normalizedPaneKey = typeof paneKey === 'string' ? paneKey.trim() : '' - if (!isValidPaneKey(normalizedPaneKey)) { - return null - } - ptyPaneKey.set(ptyId, normalizedPaneKey) - paneKeyPtyId.set(normalizedPaneKey, ptyId) - return normalizedPaneKey -} - -function cleanupPendingPaneSerializersForSender(ownerWebContentsId: number): void { - pendingPaneSerializerCleanupRegistered.delete(ownerWebContentsId) - for (const [paneKey, pending] of pendingByPaneKey) { - if (pending.ownerWebContentsId === ownerWebContentsId) { - pendingByPaneKey.delete(paneKey) - pendingPtyIdBySerializerGeneration.delete(pending.gen) - } - } -} - -function registerPendingPaneSerializerCleanup(sender: WebContents | undefined): void { - if (!sender || pendingPaneSerializerCleanupRegistered.has(sender.id)) { - return - } - pendingPaneSerializerCleanupRegistered.add(sender.id) - sender.once('destroyed', () => cleanupPendingPaneSerializersForSender(sender.id)) -} - -function declarePendingPaneSerializer(paneKey: string, sender: WebContents | undefined): number { - const gen = ++pendingSerializerGenSeq - registerPendingPaneSerializerCleanup(sender) - const replaced = pendingByPaneKey.get(paneKey) - if (replaced) { - pendingPtyIdBySerializerGeneration.delete(replaced.gen) - } - pendingByPaneKey.set(paneKey, { gen, ownerWebContentsId: sender?.id ?? null }) - const existingPtyId = paneKeyPtyId.get(paneKey) - if (existingPtyId) { - pendingPtyIdBySerializerGeneration.set(gen, existingPtyId) - } - return gen -} - -function reservePaneSpawn(paneKey: string): PaneSpawnReservation { - let resolve!: (result: PaneSpawnReservationResult) => void - let reject!: (error: unknown) => void - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve - reject = promiseReject - }) - promise.catch(() => {}) - const reservation = { promise, resolve, reject } - paneSpawnReservationsByOwnerKey.set(paneKey, reservation) - return reservation -} - -function clearPaneSpawnReservation(paneKey: string, reservation: PaneSpawnReservation): void { - if (paneSpawnReservationsByOwnerKey.get(paneKey) === reservation) { - paneSpawnReservationsByOwnerKey.delete(paneKey) - } -} - -function makePaneSpawnReservationKey( - worktreeId: string | undefined, - connectionId: string | null | undefined, - paneKey: string | null | undefined -): string | null { - return paneKey ? JSON.stringify([connectionId ?? null, worktreeId ?? null, paneKey]) : null -} - -function claimRuntimePaneCreate(ownerKey: string): () => void { - const existing = pendingRuntimePaneCreatesByOwnerKey.get(ownerKey) - if (existing) { - existing.count += 1 - return () => releaseRuntimePaneCreate(ownerKey, existing) - } - let resolve!: () => void - const claim = { - count: 1, - promise: new Promise((done) => { - resolve = done - }), - resolve: () => resolve() - } - pendingRuntimePaneCreatesByOwnerKey.set(ownerKey, claim) - return () => releaseRuntimePaneCreate(ownerKey, claim) -} - -function releaseRuntimePaneCreate(ownerKey: string, claim: PendingRuntimePaneCreate): void { - if (pendingRuntimePaneCreatesByOwnerKey.get(ownerKey) !== claim) { - return - } - claim.count -= 1 - if (claim.count > 0) { - return - } - pendingRuntimePaneCreatesByOwnerKey.delete(ownerKey) - claim.resolve() -} - -function rejectPaneSpawnReservation( - paneKey: string | null | undefined, - reservation: PaneSpawnReservation | null | undefined, - error: unknown -): void { - if (!reservation) { - return - } - reservation.reject(error) - if (paneKey) { - clearPaneSpawnReservation(paneKey, reservation) - } -} - -function resolvePaneSpawnReservation( - paneKey: string | null | undefined, - reservation: PaneSpawnReservation | null | undefined, - response: T -): T { - if (!reservation) { - return response - } - reservation.resolve(response) - if (paneKey) { - clearPaneSpawnReservation(paneKey, reservation) - } - return response -} - -type StablePaneOwner = { - handle?: string - tabId: string - leafId: string - ptyId: string - incarnationId?: string - hasPersistedBinding?: true - persistedIncarnationId?: string - runtimeIncarnationId?: string -} -type StablePaneAdoption = { - result: PtySpawnResult - owner: StablePaneOwner - materialized?: true -} | null -const stablePaneAdoptionsByOwnerKey = new Map>() - -function resolvePersistedStablePaneOwner( - store: Store | undefined, - paneKey: string, - worktreeId: string, - connectionId: string | null | undefined -): Pick | null { - if (!store || typeof store.getWorkspaceSession !== 'function') { - return null - } - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return null - } - const session = store.getWorkspaceSession( - connectionId ? toSshExecutionHostId(connectionId) : undefined - ) - const tab = session.tabsByWorktree?.[worktreeId]?.find( - (candidate) => candidate.id === parsed.tabId && candidate.worktreeId === worktreeId - ) - const ptyId = session.terminalLayoutsByTabId?.[parsed.tabId]?.ptyIdsByLeafId?.[parsed.leafId] - if (!tab || typeof ptyId !== 'string' || ptyId.length === 0) { - return null - } - const incarnationId = session.terminalPtyIncarnationsByPaneKey?.[paneKey] - return { - tabId: parsed.tabId, - leafId: parsed.leafId, - ptyId, - ...(incarnationId ? { incarnationId } : {}) - } -} - -function resolveStablePaneOwner( - runtime: OrcaRuntimeService | undefined, - store: Store | undefined, - paneKey: string | null | undefined, - worktreeId: string | undefined, - connectionId: string | null | undefined -): StablePaneOwner | null { - if (!paneKey || !worktreeId) { - return null - } - let resolved: ReturnType | null = null - let resolvedHandleCandidate: ReturnType | null = null - if (runtime && typeof runtime.resolveTerminalPane === 'function') { - try { - const candidate = runtime.resolveTerminalPane(paneKey, worktreeId) - resolvedHandleCandidate = candidate - resolved = candidate.connected === false ? null : candidate - } catch (error) { - if (!(error instanceof Error && error.message === 'terminal_not_found')) { - throw error - } - } - } - const persisted = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) - if (resolved?.ptyId && persisted && resolved.ptyId !== persisted.ptyId) { - throw new Error('terminal_pane_owner_conflict') - } - const ptyId = resolved?.ptyId ?? persisted?.ptyId - if (!ptyId) { - return null - } - const registeredConnectionId = ptyOwnership.get(ptyId) - const parsedSshId = registeredConnectionId === undefined ? parseAppSshPtyId(ptyId) : null - const ownerConnectionId = registeredConnectionId ?? parsedSshId?.connectionId ?? null - if (ownerConnectionId !== (connectionId ?? null)) { - throw new Error('terminal_pane_owner_host_mismatch') - } - const runtimeIncarnationId = ptyIncarnationById.get(ptyId) - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return null - } - return { - ...(resolvedHandleCandidate?.ptyId === ptyId ? { handle: resolvedHandleCandidate.handle } : {}), - tabId: resolved?.tabId || persisted?.tabId || parsed.tabId, - leafId: resolved?.leafId || persisted?.leafId || parsed.leafId, - ptyId, - ...(runtimeIncarnationId || persisted?.incarnationId - ? { incarnationId: runtimeIncarnationId ?? persisted?.incarnationId } - : {}), - ...(persisted ? { hasPersistedBinding: true as const } : {}), - ...(persisted?.incarnationId ? { persistedIncarnationId: persisted.incarnationId } : {}), - ...(runtimeIncarnationId ? { runtimeIncarnationId } : {}) - } -} - -function retirePersistedStablePaneOwner( - store: Store | undefined, - owner: StablePaneOwner, - worktreeId: string, - connectionId: string | null | undefined -): boolean { - if (!store) { - return false - } - const paneKey = makePaneKey(owner.tabId, owner.leafId) - const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined - const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) - if (!current) { - // Why: persistence already dropped this pane binding (an earlier stop retired it while the - // runtime kept history), so there is nothing left to clear — that is a completed retirement, - // not a competing owner. Reporting failure here strands the pane after its PTY is proven dead. - return true - } - if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { - return false - } - const session = store.getWorkspaceSession(hostId) - const retired = retireTerminalSurfaceFromPersistence(session, { - worktreeId, - parentTabId: owner.tabId, - leafId: owner.leafId, - ptyId: owner.ptyId, - ...(current.incarnationId ? { incarnationId: current.incarnationId } : {}) - }) - if (retired === session) { - return false - } - store.setWorkspaceSession(retired, hostId) - store.flushOrThrow() - return true -} - -type StablePaneSpawnContext = { - runtime: OrcaRuntimeService | undefined - store?: Store - provider: IPtyProvider - spawnOptions: PtySpawnOptions - owner: StablePaneOwner | null - worktreeId?: string - connectionId?: string | null - resolveOwner?: () => StablePaneOwner | null - onFreshSpawn?: (result: PtySpawnResult) => void -} - -function stablePanePersistenceFence( - owner: StablePaneOwner | null -): { ptyId: string; incarnationId?: string } | undefined { - return owner?.hasPersistedBinding - ? { - ptyId: owner.ptyId, - ...(owner.persistedIncarnationId ? { incarnationId: owner.persistedIncarnationId } : {}) - } - : undefined -} - -function persistAdmittedStablePaneBinding(args: { - store: Store | undefined - owner: StablePaneOwner | null - result: PtySpawnResult - worktreeId: string | undefined - startupCwd: string | undefined - connectionId: string | null | undefined -}): boolean { - const expectedBinding = stablePanePersistenceFence(args.owner) - if (!args.store || !args.owner || !args.worktreeId || !expectedBinding) { - return false - } - const persisted = args.store.persistPtyBinding( - { - worktreeId: args.worktreeId, - tabId: args.owner.tabId, - leafId: args.owner.leafId, - ptyId: args.result.id, - ...(args.result.incarnationId ? { incarnationId: args.result.incarnationId } : {}), - ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), - expectedBinding - }, - args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined - ) - if (persisted === false) { - throw new Error('terminal_pane_owner_changed') - } - return true -} - -async function attachStablePaneOwner( - args: StablePaneSpawnContext & { owner: StablePaneOwner } -): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> { - const { owner, provider, runtime, spawnOptions } = args - let result: PtySpawnResult - try { - result = await provider.spawn({ - ...spawnOptions, - sessionId: owner.ptyId, - attachOnly: true, - expectedIncarnationId: owner.runtimeIncarnationId ?? owner.persistedIncarnationId, - expectedIncarnationIsAuthoritative: owner.runtimeIncarnationId !== undefined, - isNewSession: undefined, - command: undefined, - commandDelivery: undefined, - startupCommandDelivery: undefined, - launchAgent: undefined, - startupIngress: undefined, - agentSessionEnsure: undefined, - agentSessionCreateOperationId: undefined, - onPtySpawnCommitted: undefined - }) - } catch (error) { - if (error instanceof TerminalSessionOwnerUnverifiedError) { - throw new Error('terminal_pane_owner_unverified') - } - // Why: translate before paired-runtime RPC strips the socket error's code and syscall. - if (isDaemonEndpointGoneError(error)) { - throw new TerminalHostGoneError() - } - if (!isPtyAlreadyGoneError(error)) { - throw error - } - const ownerBeforeRetire = args.resolveOwner?.() - if ( - ownerBeforeRetire && - (ownerBeforeRetire.ptyId !== owner.ptyId || - ownerBeforeRetire.runtimeIncarnationId !== owner.runtimeIncarnationId || - ownerBeforeRetire.hasPersistedBinding !== owner.hasPersistedBinding || - ownerBeforeRetire.persistedIncarnationId !== owner.persistedIncarnationId) - ) { - throw new Error('terminal_pane_owner_changed') - } - runtime?.onPtyExit(owner.ptyId, 0, owner.incarnationId) - clearProviderPtyState(owner.ptyId) - ptyOwnership.delete(owner.ptyId) - if ( - args.worktreeId && - !retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId) - ) { - throw new Error('terminal_pane_owner_changed') - } - if (args.resolveOwner?.()) { - throw new Error('terminal_pane_owner_changed') - } - return null - } - if ( - result.id !== owner.ptyId || - result.isReattach !== true || - (owner.runtimeIncarnationId !== undefined && - result.incarnationId !== owner.runtimeIncarnationId) || - (result.incarnationId === undefined && owner.incarnationId !== undefined) - ) { - throw new Error('terminal_pane_owner_changed') - } - return { result, owner } -} - -async function spawnForStablePane( - args: StablePaneSpawnContext -): Promise<{ result: PtySpawnResult; owner: StablePaneOwner | null }> { - if (args.owner) { - const attached = await attachStablePaneOwner({ ...args, owner: args.owner }) - if (attached) { - return attached - } - } - const result = await args.provider.spawn(args.spawnOptions) - args.onFreshSpawn?.(result) - return { result, owner: null } -} - -function settlePendingPaneSerializer(paneKey: string, gen: number): boolean { - if (pendingByPaneKey.get(paneKey)?.gen !== gen) { - return false - } - pendingByPaneKey.delete(paneKey) - return true -} - -export function hasPendingRendererSerializerForPaneKey(paneKey: string): boolean { - return isValidPaneKey(paneKey) && pendingByPaneKey.has(paneKey) -} - -function getProvider(connectionId: string | null | undefined): IPtyProvider { - if (!connectionId) { - return localProvider - } - const provider = sshProviders.get(connectionId) - if (!provider) { - throw new Error(`No PTY provider for connection "${connectionId}"`) - } - return provider -} - -function getProviderForPty(ptyId: string): IPtyProvider { - const connectionId = ptyOwnership.get(ptyId) - if (connectionId === undefined) { - const parsedSshId = parseAppSshPtyId(ptyId) - if (parsedSshId) { - // Why: disconnected SSH PTYs retain their encoded owner and must never fall through to the HUB-local provider. - return getProvider(parsedSshId.connectionId) - } - return localProvider - } - return getProvider(connectionId) -} - -function hasPtyProviderForInspection(ptyId: string): boolean { - // Why: process inspection is background polling; disconnected SSH hosts should read as idle, not raise repeated IPC errors. - const connectionId = ptyOwnership.get(ptyId) - return connectionId == null || sshProviders.has(connectionId) -} - -function getAppPtyId(connectionId: string | null | undefined, ptyId: string): string { - return connectionId ? toAppSshPtyId(connectionId, ptyId) : ptyId -} - -function getRelayPtyId(connectionId: string | null | undefined, ptyId: string): string { - return connectionId ? toRelaySshPtyId(connectionId, ptyId) : ptyId -} - -function stripRemotePaneEnvWhenHooksDisabled( - connectionId: string | null | undefined, - env: Record | undefined -): Record | undefined { - if (!connectionId || isRemoteAgentHooksEnabled()) { - return env - } - if ( - !env || - (!('ORCA_PANE_KEY' in env) && - !('ORCA_TAB_ID' in env) && - !('ORCA_WORKTREE_ID' in env) && - !('ORCA_AGENT_LAUNCH_TOKEN' in env)) - ) { - return env - } - const stripped = { ...env } - delete stripped.ORCA_PANE_KEY - delete stripped.ORCA_TAB_ID - delete stripped.ORCA_WORKTREE_ID - delete stripped.ORCA_AGENT_LAUNCH_TOKEN - return stripped -} - -function tryGetProviderForPty(ptyId: string): IPtyProvider | undefined { - try { - return getProviderForPty(ptyId) - } catch { - return undefined - } -} - -function closeStartupQueryAuthorityForPty(ptyId: string): void { - try { - void Promise.resolve(tryGetProviderForPty(ptyId)?.closeStartupQueryAuthority?.(ptyId)).catch( - () => {} - ) - } catch { - /* Best-effort handoff; the bounded source deadline remains the fallback. */ - } -} - -function tryGetProviderForAgentSessionOwner(ptyId: string): IPtyProvider | undefined { - const ownedConnectionId = ptyOwnership.get(ptyId) - const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(ptyId) : null - try { - return getProvider(parsedSshId?.connectionId ?? ownedConnectionId) - } catch { - return undefined - } -} - -function normalizeNodePtySpawnError(err: unknown): Error { - const rawMessage = err instanceof Error ? err.message : String(err) - const hintedMessage = addNodePtyRecoveryHint(rawMessage) - if (hintedMessage === rawMessage && err instanceof Error) { - return err - } - if (err instanceof Error) { - // Why: preserve the original stack/name/custom fields while adding the same recovery hint as the pty:spawn path. - err.message = hintedMessage - return err - } - return new Error(hintedMessage) -} - -function isPtyAlreadyGoneError(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err) - return isSshPtyNotFoundError(err) || /Session not found/i.test(message) -} - -function delay(ms: number): Promise { - return new Promise((resolve) => { - const timer = setTimeout(resolve, ms) - if (typeof timer.unref === 'function') { - timer.unref() - } - }) -} - -async function isProviderPtyLive( - provider: IPtyProvider, - ptyId: string, - deadlineMs?: number -): Promise { - // Why: bound the liveness list RPC by the teardown deadline so a wedged daemon - // fails fast; undefined keeps the provider default for all other callers. - return (await provider.listProcesses(deadlineMs !== undefined ? { deadlineMs } : undefined)).some( - (session) => session.id === ptyId - ) -} - -async function isProviderAgentSessionOwnerLive( - provider: IPtyProvider, - owner: AgentSessionOwnerBinding -): Promise { - const session = (await provider.listProcesses()).find((candidate) => candidate.id === owner.ptyId) - if (!session) { - return false - } - if (provider.providesAgentSessionOwnerListings?.(owner.ptyId) !== true) { - // Why: in-process local owners cannot serialize the controller claim; exact incarnation - // liveness keeps that claim authoritative until the normal PTY exit releases it. - const expectedIncarnation = ptyIncarnationById.get(owner.ptyId) - return expectedIncarnation !== undefined && session.incarnationId === expectedIncarnation - } - return Boolean( - session.agentSessionOwners?.some((candidate) => - agentSessionOwnerBindingsEqual(candidate, owner) - ) - ) -} - -async function verifyPtyStopped( - provider: IPtyProvider, - ptyId: string, - opts: { keepHistory?: boolean; deadlineMs?: number } | undefined -): Promise { - if (await isProviderPtyLive(provider, ptyId, opts?.deadlineMs)) { - return false - } - if (!opts?.keepHistory) { - return true - } - const deadline = Date.now() + KEEP_HISTORY_STOP_SETTLE_MS - while (Date.now() < deadline) { - await delay(KEEP_HISTORY_STOP_POLL_MS) - if (await isProviderPtyLive(provider, ptyId, opts?.deadlineMs)) { - return false - } - } - return true -} - -function finishPtyShutdown( - id: string, - connectionId: string | null | undefined, - store: Store | undefined -): string | undefined { - const incarnationId = ptyIncarnationById.get(id) - clearProviderPtyState(id) - if (connectionId) { - store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated') - } - ptyOwnership.delete(id) - markClaudePtyExited(id) - return incarnationId -} - -// ─── Host PTY env assembly ────────────────────────────────────────── -// Why: centralize host-local env injections so both spawn paths (local + daemon) get them; implemented twice they drifted, silently breaking daemon PTYs. - -export type BuildPtyHostEnvOptions = { - isPackaged: boolean - resourcesPath?: string - userDataPath: string - selectedCodexHomePath: string | null - skipCodexHomeEnv?: boolean - /** System-default real-home routing (flag ON): inject no managed CODEX_HOME, - * and strip only an inherited Orca-owned override so nested Orca panes do not - * leak the parent's managed home. A user-set CODEX_HOME is preserved. */ - stripInheritedOrcaCodexHome?: boolean - /** Launch command the renderer chose (e.g. 'pi', 'omp', 'claude'); resolves the per-agent - * extension target for Pi/OMP. Undefined for bare shells → defaults to Pi. NEVER infer from - * disk presence (cross-agent shadowing when both dirs exist). */ - launchCommand?: string - /** Trusted agent identity for wrapped commands that cannot be recognized from text. */ - launchAgent?: TuiAgent - isWsl?: boolean - /** Distro for WSL spawns (null = Windows default distro); drives the WSL hook relay + endpoint repoint. Only read when isWsl. */ - wslDistro?: string | null - agentStatusHooksEnabled: boolean - codexStatusHooksEnabled?: boolean - networkProxySettings?: NetworkProxySettings - /** Keep indexed Git config off the sparse daemon wire; the daemon appends guard entries after merging its inherited env. */ - deferGitConfigGuardToDaemon?: boolean -} - -function readInheritedPath(baseEnv: Record): string { - const pathKey = resolvePathEnvKey(baseEnv, process.platform) - return baseEnv[pathKey] ?? process.env[pathKey] ?? '' -} - -function firstPathEntry(pathValue: string | undefined): string | null { - const first = pathValue?.split(delimiter).find((entry) => entry.trim().length > 0) - return first ?? null -} - -function promoteAgentTeamsShimPath( - env: Record | undefined, - requestedPath: string | undefined -): void { - if (!env?.ORCA_AGENT_TEAMS_TEAM_ID) { - return - } - const shimPath = firstPathEntry(requestedPath) - // Why: requestedPath is captured before buildPtyHostEnv scrubs, so a legacy entry that - // reached the front would be re-prepended here and outlive the scrub. - if (!shimPath || isLegacyTerminalShimPathEntry(shimPath)) { - return - } - const currentPathKey = env.PATH !== undefined || env.Path === undefined ? 'PATH' : 'Path' - const currentPath = env[currentPathKey] ?? '' - const remaining = currentPath - .split(delimiter) - .filter((entry) => entry.length > 0 && entry !== shimPath) - // Why: host env injection prepends Orca's shims; Claude Agent Teams must still resolve our fake tmux before any real tmux. - env[currentPathKey] = [shimPath, ...remaining].join(delimiter) -} - -function deleteRequestedEnvKeys( - env: Record | undefined, - keys: string[] | undefined -): void { - if (!env || !keys) { - return - } - for (const key of keys) { - delete env[key] - } -} - -function shouldSkipCodexHomeEnvForWindowsShell( - shellPath: string | undefined, - cwd: string | undefined -): boolean { - return isWslShellName(shellPath) || (typeof cwd === 'string' && parseWslPath(cwd) !== null) -} - -function isCodexStatusHooksEnabled(settings: GlobalSettings | undefined): boolean { - return ( - isAgentStatusHooksEnabled(settings) && isTuiAgentEnabled('codex', settings?.disabledTuiAgents) - ) -} - -// Why: with the real-home flag ON, a host system-default launch resolves to a -// null managed home. Signal the env builder to strip a nested-Orca-inherited -// override instead of injecting one, so Codex runs on the user's own ~/.codex. -function shouldStripInheritedOrcaCodexHome(args: { - target: CodexAccountSelectionTarget - selectedCodexHomePath: string | null - skipCodexHomeEnv: boolean - settings: GlobalSettings | undefined -}): boolean { - return ( - args.target.runtime === 'host' && args.selectedCodexHomePath === null && !args.skipCodexHomeEnv - ) -} - -const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const - -// Why: system-default real-home routing runs Codex on the user's own ~/.codex. -// Nested Orca panes inherit the parent's Orca-owned override; strip only that -// (CODEX_HOME matching Orca's private ORCA_CODEX_HOME marker), and always drop -// the marker so a shell-ready wrapper cannot restore the managed home. A -// user-set CODEX_HOME with no Orca marker is preserved untouched (see #8606). -function stripInheritedOrcaCodexHomeOverride(baseEnv: Record): void { - for (const key of getLocalOrcaCodexHomeEnvKeysToDelete(baseEnv)) { - delete baseEnv[key] - } -} - -// Why: in-process spawns share main's inherited environment, so equality with -// the private marker is authoritative here. Persistent daemons compare locally. -function getLocalOrcaCodexHomeEnvKeysToDelete(env: Record): string[] { - const inheritedOrcaOverride = env.ORCA_CODEX_HOME ?? process.env.ORCA_CODEX_HOME - const inheritedCodexHome = env.CODEX_HOME ?? process.env.CODEX_HOME - const keysToDelete = ['ORCA_CODEX_HOME'] - if (inheritedOrcaOverride && inheritedCodexHome === inheritedOrcaOverride) { - keysToDelete.push('CODEX_HOME') - } - return keysToDelete -} - -export type CodexHomeLaunchContext = { - workspacePath?: string - launchAgent?: TuiAgent - unavailableManagedHomePath?: string -} - -export type GetSelectedCodexHomePath = ( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv, - launchContext?: CodexHomeLaunchContext -) => string | null -export type PrepareCodexSessionResume = (args: { - providerSession: AgentProviderSessionMetadata - target: CodexAccountSelectionTarget - launchEnv?: NodeJS.ProcessEnv - workspacePath?: string -}) => Promise - -export type CodexHomePtySpawnedLifecycleArgs = { - id: string - codexHomePath: string | null - reattached?: boolean - reattachedHomeRoute?: CodexPaneHomeRoute | null - launchEnv?: NodeJS.ProcessEnv - startedAt?: Date - startedSequence?: number -} - -let ptyLifecycleSequence = 0 - -type PrepareClaudeAuth = ( - target?: ClaudeAccountSelectionTarget -) => Promise - -function getCodexSelectionTargetForPty( - shellPath: string | undefined, - cwd: string | undefined, - wslDistro?: string | null -): CodexAccountSelectionTarget { - const wslPath = typeof cwd === 'string' ? parseWslPath(cwd) : null - if (isWslShellName(shellPath) || wslPath) { - return { runtime: 'wsl', wslDistro: wslPath?.distro ?? wslDistro ?? null } - } - return { runtime: 'host' } -} - -function getCompatibleSelectedCodexHomePath( - target: CodexAccountSelectionTarget, - selectedCodexHomePath: string | null -): string | null { - if (!selectedCodexHomePath) { - return null - } - const wslInfo = parseWslPath(selectedCodexHomePath) - if (target.runtime === 'wsl') { - return wslInfo || !isHostCodexHomeForWsl(selectedCodexHomePath) ? selectedCodexHomePath : null - } - return wslInfo || (process.platform === 'win32' && isWslCodexHomeForHost(selectedCodexHomePath)) - ? null - : selectedCodexHomePath -} - -const MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE = - 'The selected Codex account credentials are temporarily unavailable. Try opening the terminal again.' -const CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE = - 'The Codex account credentials for this session are temporarily unavailable. Try opening the terminal again.' -const MANAGED_CODEX_HOME_UNAVAILABLE_MESSAGE = - 'Codex account files are temporarily locked. Retry in a moment.' - -/** - * Why: launch prep refuses an unreadable managed home by throwing instead of - * returning `null`, because `null` already means "launch the system default". - * Turn that refusal into the same shape as the auth-unavailable refusal above — - * a user-facing spawn rejection — so the pane never starts on another account's - * credentials (#STA-4422). - */ -function resolveSelectedCodexHomeOrRefuseSpawn(resolve: () => string | null): string | null { - try { - return resolve() - } catch (error) { - if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { - throw new Error(MANAGED_CODEX_HOME_UNAVAILABLE_MESSAGE, { cause: error }) - } - throw error - } -} - -type ManagedCodexAuthResolutionArgs = { - selectedCodexHomePath: string | null - getSettings: () => GlobalSettings | undefined - requiredCodexHomePath?: string - target: CodexAccountSelectionTarget - resolveCurrent: () => string | null - resolveAfterUnavailable: (unavailableManagedHomePath: string) => string | null -} - -export function resolveCodexHomeAfterManagedAuthReadiness( - args: ManagedCodexAuthResolutionArgs -): string | null | Promise { - const selectedCodexHomePath = args.selectedCodexHomePath - if ( - args.requiredCodexHomePath && - !codexHomePathsEqual(selectedCodexHomePath, args.requiredCodexHomePath) - ) { - throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) - } - const readiness = waitForManagedCodexAuthReady({ - codexHomePath: selectedCodexHomePath, - settings: args.getSettings(), - target: args.target - }) - return readiness - ? continueCodexHomeAfterManagedAuthWait(args, selectedCodexHomePath, readiness) - : selectedCodexHomePath -} - -async function continueCodexHomeAfterManagedAuthWait( - args: ManagedCodexAuthResolutionArgs, - initialCodexHomePath: string | null, - initialReadiness: Promise -): Promise { - let selectedCodexHomePath = initialCodexHomePath - let readiness = initialReadiness - for (let attempt = 0; attempt < 2; attempt += 1) { - if (await readiness) { - if (args.requiredCodexHomePath) { - return selectedCodexHomePath - } - const currentCodexHomePath = args.resolveCurrent() - if (codexHomeSelectionsEqual(selectedCodexHomePath, currentCodexHomePath)) { - return selectedCodexHomePath - } - selectedCodexHomePath = currentCodexHomePath - if (attempt === 1) { - break - } - const nextReadiness = waitForManagedCodexAuthReady({ - codexHomePath: selectedCodexHomePath, - settings: args.getSettings(), - target: args.target - }) - if (!nextReadiness) { - return selectedCodexHomePath - } - readiness = nextReadiness - continue - } - if (args.requiredCodexHomePath) { - throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) - } - selectedCodexHomePath = args.resolveAfterUnavailable(selectedCodexHomePath!) - if (attempt === 1) { - break - } - const nextReadiness = waitForManagedCodexAuthReady({ - codexHomePath: selectedCodexHomePath, - settings: args.getSettings(), - target: args.target - }) - if (!nextReadiness) { - return selectedCodexHomePath - } - readiness = nextReadiness - } - if ( - isCodexHomeAuthReadyForLaunch({ - codexHomePath: selectedCodexHomePath, - settings: args.getSettings(), - target: args.target - }) - ) { - return selectedCodexHomePath - } - throw new Error(MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE) -} - -function codexHomeSelectionsEqual(left: string | null, right: string | null): boolean { - return ( - left === right || - (left !== null && - right !== null && - normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right)) - ) -} - -function codexHomePathsEqual(left: string | null, right: string): boolean { - return codexHomeSelectionsEqual(left, right) -} - -// Why: CODEX_HOME is fixed in a shell's environment at spawn and the daemon -// keeps that shell alive across app restarts, so the launch account is the only -// way to tell later that a pane still runs Codex as the previously selected -// account. A reattach inherits that baked environment rather than choosing one, -// so re-recording it under the current selection would erase the very evidence -// that the pane is stale. -function recordCodexPaneAccountForSpawn(args: { - ptyId: string | undefined - isDaemonHostSpawn: boolean - isReattach: boolean - pinnedByResume: boolean - launchCodexHomePath: string | null - launchEnv?: NodeJS.ProcessEnv - target: CodexAccountSelectionTarget - settings: GlobalSettings | undefined -}): void { - if (!args.ptyId || !args.isDaemonHostSpawn || args.isReattach) { - return - } - const customHomeOverride = getCustomCodexHomeOverrideForLaunch(args.launchEnv) - const processHomeOverride = customHomeOverride ? getCustomCodexHomeOverrideForLaunch() : null - const recheckableEnvironmentOverride = - customHomeOverride?.source === 'environment' && - processHomeOverride?.source === 'environment' && - environmentCodexHomeOverrideContextsEqual( - customHomeOverride.context, - processHomeOverride.context - ) - ? customHomeOverride.context - : undefined - const recheckableShellStartupOverride = - customHomeOverride?.source === 'shell-startup' && - processHomeOverride?.source === 'shell-startup' && - shellStartupCodexHomeOverrideContextsEqual( - customHomeOverride.context, - processHomeOverride.context - ) - ? customHomeOverride.context - : undefined - const record = args.settings - ? resolveCodexPaneLaunchAccount({ - pinnedByResume: args.pinnedByResume, - launchCodexHomePath: args.launchCodexHomePath, - // Why: pane-local overrides cannot be re-derived when a restart builds - // a fresh launch env, so route prompts would guess and block valid input. - recordComparableHomeRoute: - args.pinnedByResume || - ((customHomeOverride?.source !== 'environment' || - recheckableEnvironmentOverride !== undefined) && - (customHomeOverride?.source !== 'shell-startup' || - recheckableShellStartupOverride !== undefined)), - shellStartupHomeOverride: args.pinnedByResume ? undefined : recheckableShellStartupOverride, - environmentHomeOverride: args.pinnedByResume ? undefined : recheckableEnvironmentOverride, - systemCodexHomePath: getSystemCodexHomePath(), - settings: args.settings, - target: args.target - }) - : null - if (!record) { - forgetCodexPaneAccount(args.ptyId) - return - } - recordCodexPaneAccount(args.ptyId, record) -} - -function snapshotCodexPaneHomeRoutes( - ptyIds: readonly (string | null | undefined)[] -): ReadonlyMap { - const routes = new Map() - for (const ptyId of ptyIds) { - if (!ptyId || routes.has(ptyId)) { - continue - } - routes.set(ptyId, getCodexPaneAccount(ptyId)?.homeRoute ?? null) - } - return routes -} - -function codexReattachedHomeRouteField( - routes: ReadonlyMap, - ptyId: string, - reattached: boolean -): { reattachedHomeRoute?: CodexPaneHomeRoute | null } { - if (!reattached || !routes.has(ptyId)) { - return {} - } - return { reattachedHomeRoute: routes.get(ptyId) ?? null } -} - -function readEnvWithProcessFallback( - baseEnv: Record, - key: string -): string | undefined { - return baseEnv[key] ?? process.env[key] -} - -function resolvePiAgentSourceDir( - baseEnv: Record, - kind: PiAgentKind -): string | undefined { - const sourceKey = SOURCE_AGENT_DIR_ENV_BY_KIND[kind] - const primaryKey = PRIMARY_AGENT_DIR_ENV_BY_KIND[kind] - - const sourceDir = readEnvWithProcessFallback(baseEnv, sourceKey) - if (sourceDir) { - return sourceDir - } - - if (kind === 'prime-agent') { - return ( - readEnvWithProcessFallback(baseEnv, primaryKey) ?? - readSessionShellStartupEnvVar(primaryKey, baseEnv) - ) - } - - const overlayKey = kind === 'omp' ? 'ORCA_OMP_CODING_AGENT_DIR' : 'ORCA_PI_CODING_AGENT_DIR' - const otherOverlayKey = kind === 'omp' ? 'ORCA_PI_CODING_AGENT_DIR' : 'ORCA_OMP_CODING_AGENT_DIR' - - const publicDir = readEnvWithProcessFallback(baseEnv, primaryKey) - const ownOverlayDir = readEnvWithProcessFallback(baseEnv, overlayKey) - const otherOverlayDir = readEnvWithProcessFallback(baseEnv, otherOverlayKey) - // Why: if PI_CODING_AGENT_DIR is a restored Orca overlay with no source shadow, remirroring leaks another agent's overlay tree; fall through to defaults. - if (publicDir && publicDir !== ownOverlayDir && publicDir !== otherOverlayDir) { - return publicDir - } - - return readSessionShellStartupEnvVar(primaryKey, baseEnv) -} - -function resolveScopedPiAgentSourceDir( - baseEnv: Record, - kind: PiAgentKind -): string | undefined { - return readEnvWithProcessFallback(baseEnv, SOURCE_AGENT_DIR_ENV_BY_KIND[kind]) -} - -function clearPiAgentShadowEnv(baseEnv: Record, kind: PiAgentKind): void { - if (kind === 'omp') { - delete baseEnv.ORCA_OMP_CODING_AGENT_DIR - delete baseEnv.ORCA_OMP_SOURCE_AGENT_DIR - delete baseEnv.ORCA_OMP_STATUS_EXTENSION - return - } - if (kind === 'prime-agent') { - delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR - delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION - return - } - delete baseEnv.ORCA_PI_CODING_AGENT_DIR - delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR -} - -function exposePiManagedExtensionEnv( - baseEnv: Record, - kind: PiAgentKind, - managedEnv: Record -): void { - if (kind === 'omp') { - delete baseEnv.ORCA_OMP_CODING_AGENT_DIR - if (managedEnv.ORCA_OMP_SOURCE_AGENT_DIR) { - baseEnv.ORCA_OMP_SOURCE_AGENT_DIR = managedEnv.ORCA_OMP_SOURCE_AGENT_DIR - } else { - delete baseEnv.ORCA_OMP_SOURCE_AGENT_DIR - } - if (managedEnv.ORCA_OMP_STATUS_EXTENSION) { - baseEnv.ORCA_OMP_STATUS_EXTENSION = managedEnv.ORCA_OMP_STATUS_EXTENSION - } else { - delete baseEnv.ORCA_OMP_STATUS_EXTENSION - } - return - } - if (kind === 'prime-agent') { - if (managedEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR) { - baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR = managedEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR - } else { - delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR - } - return - } - delete baseEnv.ORCA_PI_CODING_AGENT_DIR - if (managedEnv.ORCA_PI_SOURCE_AGENT_DIR) { - baseEnv.ORCA_PI_SOURCE_AGENT_DIR = managedEnv.ORCA_PI_SOURCE_AGENT_DIR - } else { - delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR - } -} - -// Why: variadic because a nested call per source made intermediate `string[] | undefined` collide with the parameter type. -function mergePtyEnvDeletions( - existingKeys: string[] | undefined, - ...additionalKeyGroups: readonly (readonly string[])[] -): string[] | undefined { - if (!existingKeys && additionalKeyGroups.every((keys) => keys.length === 0)) { - return undefined - } - return Array.from(new Set([...(existingKeys ?? []), ...additionalKeyGroups.flat()])) -} - -function removeCodexHomeDeletionRequests(keys: string[] | undefined): string[] | undefined { - // Why: resume provenance is launch-authoritative; late deletions must not fall back to the current account. - const filtered = keys?.filter((key) => key !== 'CODEX_HOME' && key !== 'ORCA_CODEX_HOME') - return filtered?.length ? filtered : undefined -} - -function getInheritedAgentHookEnvKeysToDelete( - spawnEnv: Record | undefined -): string[] { - const env = spawnEnv ?? {} - // Why: providers merge process.env after cleanup; delete stale hook keys without dropping fresh coordinates buildPtyHostEnv set. - return AGENT_HOOK_RUNTIME_ENV_KEYS.filter((key) => env[key] === undefined) -} - -function getInheritedClaudeSessionStampEnvKeysToDelete( - spawnEnv: Record | undefined -): string[] { - const env = spawnEnv ?? {} - // Why: strip only values inherited from the pty host; a caller that explicitly - // provides a stamp (deliberately spawning a nested Claude child) keeps it. - return CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined) -} - -// Why: a nested terminal can inherit prior OpenCode/Pi/OMP overlay env; restore the user's recorded source dir, else strip only Orca-owned values. -function restoreOrStripOverlayEnv( - baseEnv: Record, - keys: { - primary: string - overlay: string - source: string - } -): void { - const sourceValue = baseEnv[keys.source] ?? process.env[keys.source] - const overlayValue = baseEnv[keys.overlay] ?? process.env[keys.overlay] - if (sourceValue) { - baseEnv[keys.primary] = sourceValue - } else if (overlayValue && baseEnv[keys.primary] === overlayValue) { - delete baseEnv[keys.primary] - } - delete baseEnv[keys.overlay] - delete baseEnv[keys.source] -} - -function isMimoLaunchCommand(launchCommand: string | undefined): boolean { - const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCommand ?? '')) - .toLowerCase() - .replace(/\.(?:cmd|exe|sh)$/, '') - return binary === 'mimo' -} - -function resolveMimocodeSourceHome(baseEnv: Record): string | undefined { - const sourceHome = baseEnv.ORCA_MIMOCODE_SOURCE_HOME ?? process.env.ORCA_MIMOCODE_SOURCE_HOME - if (sourceHome) { - return sourceHome - } - const configHome = baseEnv.MIMOCODE_HOME ?? process.env.MIMOCODE_HOME - const orcaHome = baseEnv.ORCA_MIMOCODE_HOME ?? process.env.ORCA_MIMOCODE_HOME - if (configHome && orcaHome && configHome === orcaHome) { - return undefined - } - return configHome -} - -function resolveOpenCodeSourceConfigDir(baseEnv: Record): string | undefined { - const sourceDir = - baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR - if (sourceDir) { - return sourceDir - } - - const configDir = baseEnv.OPENCODE_CONFIG_DIR ?? process.env.OPENCODE_CONFIG_DIR - const orcaConfigDir = baseEnv.ORCA_OPENCODE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_CONFIG_DIR - // Why: with no recorded source dir, an inherited OPENCODE_CONFIG_DIR is Orca-owned, not user config; treating it as user config makes child Orcas mirror the hook dir. - if (configDir && orcaConfigDir && configDir === orcaConfigDir) { - return undefined - } - - return configDir ?? readSessionShellStartupEnvVar('OPENCODE_CONFIG_DIR', baseEnv) -} - -/** - * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. - * - * Do NOT call when `args.connectionId` is set (SSH): every injection is host-loopback - * or references local filesystem paths meaningless to a remote shell. - */ -export function buildPtyHostEnv( - id: string, - baseEnv: Record, - opts: BuildPtyHostEnvOptions -): Record { - mergePersistedWindowsPath(baseEnv) - Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings)) - - // Why: local path's baseEnv includes process.env but the daemon path doesn't (fork inheritance, not IPC); check both sources so guards stay in lock-step across spawn paths. - const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv) - const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) - const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent) - ? opts.launchAgent - : opts.launchAgent === undefined - ? detectExplicitPiAgentKindFromCommand(launchCommandHint) - : null - const piAgentKind = explicitPiAgentKind ?? 'pi' - const hasLaunchCommand = - typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 - - // Why: unattended agents must fail instead of looping on OS credential prompts; user terminals keep normal Git behavior. - applyTerminalGitCredentialPromptGuard(baseEnv, { - launchCommand: launchCommandHint, - isUnattended: opts.launchAgent !== undefined, - deferGitConfigGuardToHost: opts.deferGitConfigGuardToDaemon - }) - - const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand - // Why: source shadows are agent-scoped; trusting the other kind's source reintroduces Pi/OMP extension-state shadowing. - const preexistingPiAgentDir = resolvePiAgentSourceDir(baseEnv, 'pi') - const preexistingOmpAgentDir = - piAgentKind === 'omp' - ? resolvePiAgentSourceDir(baseEnv, 'omp') - : resolveScopedPiAgentSourceDir(baseEnv, 'omp') - const preexistingPrimeAgentDir = - piAgentKind === 'prime-agent' - ? resolvePiAgentSourceDir(baseEnv, 'prime-agent') - : resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent') - - if (opts.agentStatusHooksEnabled) { - // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md. - Object.assign(baseEnv, openCodeHookService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) - if (baseEnv.OPENCODE_CONFIG_DIR) { - // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value. - baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR - if (preexistingOpenCodeConfigDir) { - // Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays. - baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir - } else { - delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR - } - } - if (isMimoLaunchCommand(launchCommandHint)) { - const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) - Object.assign(baseEnv, mimoCodeHookService.buildPtyEnv(id, preexistingMimocodeHome)) - if (baseEnv.MIMOCODE_HOME) { - baseEnv.ORCA_MIMOCODE_HOME = baseEnv.MIMOCODE_HOME - if (preexistingMimocodeHome) { - baseEnv.ORCA_MIMOCODE_SOURCE_HOME = preexistingMimocodeHome - } else { - delete baseEnv.ORCA_MIMOCODE_SOURCE_HOME - } - } - } - } else { - restoreOrStripOverlayEnv(baseEnv, { - primary: 'OPENCODE_CONFIG_DIR', - overlay: 'ORCA_OPENCODE_CONFIG_DIR', - source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' - }) - restoreOrStripOverlayEnv(baseEnv, { - primary: 'MIMOCODE_HOME', - overlay: 'ORCA_MIMOCODE_HOME', - source: 'ORCA_MIMOCODE_SOURCE_HOME' - }) - } - - // Why: strip inherited hook coordinates before injecting this PTY's fresh loopback receiver, so nested-terminal callbacks route to the owning pane. - for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) { - delete baseEnv[key] - } - if (opts.agentStatusHooksEnabled) { - Object.assign(baseEnv, agentHookServer.buildPtyEnv()) - if (opts.isWsl === true) { - // Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one. - const distro = opts.wslDistro ?? null - wslHookRelayManager.ensureForDistro(distro) - const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro) - if (guestEndpoint) { - baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint - } - // Why: OpenCode loads its status plugin from a guest config overlay, so point OPENCODE_CONFIG_DIR at the guest dir the relay materialized. - const opencodeOverlayDir = wslHookRelayManager.getOpenCodeOverlayDir(distro) - if (opencodeOverlayDir) { - baseEnv.OPENCODE_CONFIG_DIR = opencodeOverlayDir - baseEnv.ORCA_OPENCODE_CONFIG_DIR = opencodeOverlayDir - delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR - } else { - // Why: relay not connected yet (or older guest bundle) — never cross the Windows overlay path into WSL; drop it so in-guest OpenCode uses its own config (pre-fix behavior, no status but no regression). - delete baseEnv.OPENCODE_CONFIG_DIR - delete baseEnv.ORCA_OPENCODE_CONFIG_DIR - delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR - } - } - } - - // Why: PI_CODING_AGENT_DIR is the user's config/session root; install only Orca-owned extension files, don't override it. - if (opts.agentStatusHooksEnabled) { - clearPiAgentShadowEnv(baseEnv, 'pi') - clearPiAgentShadowEnv(baseEnv, 'omp') - clearPiAgentShadowEnv(baseEnv, 'prime-agent') - // Why: bare shells historically defaulted to Pi + OMP shadow prep and - // created ~/./agent even when the user never launches those agents - // (#10196). Only create default homes on an explicit Pi/OMP launch; - // otherwise install only into an existing agent dir (or userData for OMP - // status so a typed `omp` still gets the shell wrapper extension). - if (piAgentKind === 'pi') { - const piEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir, 'pi', { - materializeDefaultHome: explicitPiAgentKind === 'pi' - }) - Object.assign(baseEnv, piEnv) - exposePiManagedExtensionEnv(baseEnv, 'pi', piEnv) - } - - if (shouldPrepareOmpShadow) { - const ompEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingOmpAgentDir, 'omp', { - materializeDefaultHome: explicitPiAgentKind === 'omp' - }) - Object.assign(baseEnv, ompEnv) - exposePiManagedExtensionEnv(baseEnv, 'omp', ompEnv) - } - - if (piAgentKind === 'prime-agent' && !opts.isWsl) { - const primeEnv = piTitlebarExtensionService.buildPtyEnv( - id, - preexistingPrimeAgentDir, - 'prime-agent', - { materializeDefaultHome: explicitPiAgentKind === 'prime-agent' } - ) - Object.assign(baseEnv, primeEnv) - exposePiManagedExtensionEnv(baseEnv, 'prime-agent', primeEnv) - } - } else { - // Why: nested PTYs must not inherit stale source or overlay state from another agent. - restoreOrStripOverlayEnv(baseEnv, { - primary: 'PI_CODING_AGENT_DIR', - overlay: 'ORCA_PI_CODING_AGENT_DIR', - source: 'ORCA_PI_SOURCE_AGENT_DIR' - }) - restoreOrStripOverlayEnv(baseEnv, { - primary: 'PI_CODING_AGENT_DIR', - overlay: 'ORCA_OMP_CODING_AGENT_DIR', - source: 'ORCA_OMP_SOURCE_AGENT_DIR' - }) - delete baseEnv.ORCA_OMP_STATUS_EXTENSION - delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR - delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION - } - - // Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex. - if (opts.skipCodexHomeEnv) { - delete baseEnv.CODEX_HOME - delete baseEnv.ORCA_CODEX_HOME - delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT - } else if (opts.selectedCodexHomePath) { - baseEnv.CODEX_HOME = opts.selectedCodexHomePath - // Why: user startup files may re-export CODEX_HOME; shell-ready wrappers restore this runtime home before Codex launches. - baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath - const preflightCommand = resolveCodexShellLaunchPreflightCommand({ - hooksEnabled: opts.codexStatusHooksEnabled ?? opts.agentStatusHooksEnabled, - isPackaged: opts.isPackaged, - isWsl: opts.isWsl, - managedHomePath: opts.selectedCodexHomePath, - userDataPath: opts.userDataPath, - resourcesPath: opts.resourcesPath - }) - if (preflightCommand) { - baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT = preflightCommand - } else { - delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT - } - } else if (opts.stripInheritedOrcaCodexHome) { - stripInheritedOrcaCodexHomeOverride(baseEnv) - delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT - } else { - delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT - } - - // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. - if (opts.isWsl) { - baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath - // Why: managed WSL registration uses `orca-ide`; exposing that literal scopes agent guidance to WSL without a bare-orca shim. - baseEnv.ORCA_CLI_COMMAND = opts.isPackaged ? 'orca-ide' : 'orca-dev' - } else { - if (!opts.isPackaged) { - baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath - } - delete baseEnv.ORCA_CLI_COMMAND - } - // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca. - if (!opts.isPackaged) { - const devCliBin = join(opts.userDataPath, 'cli', 'bin') - const inheritedPath = readInheritedPath(baseEnv) - // Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter. - baseEnv[resolvePathEnvKey(baseEnv, process.platform)] = inheritedPath - ? `${devCliBin}${delimiter}${inheritedPath}` - : devCliBin - } else if (process.platform === 'linux') { - // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904). - const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath }) - if (shimDir) { - const inheritedEntries = readInheritedPath(baseEnv) - .split(delimiter) - .filter((entry) => entry.length > 0 && entry !== shimDir) - baseEnv.PATH = [shimDir, ...inheritedEntries].join(delimiter) - } - } else if ( - opts.resourcesPath && - (process.platform === 'darwin' || process.platform === 'win32') - ) { - // Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI. - const bundledCliBin = join(opts.resourcesPath, 'bin') - const inheritedPath = readInheritedPath(baseEnv) - baseEnv[resolvePathEnvKey(baseEnv, process.platform)] = inheritedPath - ? `${bundledCliBin}${delimiter}${inheritedPath}` - : bundledCliBin - } - - // Why: must run after the prepends above — they re-read PATH from the unscrubbed - // process.env when baseEnv carries none, which is the daemon path's normal shape. - stripLegacyTerminalShimEnv(baseEnv, process.platform) - - return baseEnv -} - -function isClaudeLaunchCommand(command: string | undefined): boolean { - if (!command) { - return false - } - return /(^|[\s;&|('"`])(?:[^\s;&|('"`]*[\\/])?claude(?:\.cmd|\.exe)?($|[\s;&|)'"`])/i.test( - command - ) -} - -function routesFreshSpawnsToLocalProvider(provider: IPtyProvider): boolean { - return provider.routesFreshSpawnsToLocalProvider === true -} - -function recoverFreshSpawnProviderRouting( - provider: IPtyProvider, - connectionId: string | null | undefined, - sessionId: string | undefined, - isNewSession = sessionId === undefined -): Promise | undefined { - if (connectionId || (!isNewSession && sessionId) || !routesFreshSpawnsToLocalProvider(provider)) { - return - } - return provider.recoverFreshSpawnRouting?.() -} - -function beginPtySpawnForWorktree( - worktreeId: string | undefined, - cwd: string | undefined, - connectionId: string | null | undefined -): () => void { - const worktreePath = worktreeId - ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath - : undefined - const installPaths = new Map() - for (const candidate of [worktreePath, cwd]) { - if (candidate) { - installPaths.set(normalizeRuntimePathForComparison(candidate), candidate) - } - } - const finishes: (() => void)[] = [] - try { - for (const candidate of installPaths.values()) { - finishes.push(beginTerminalInstall(candidate, connectionId ?? undefined)) - } - } catch (error) { - // Why: worktree ID and cwd can be different roots; release earlier admissions before rejecting. - finishes.toReversed().forEach((finish) => finish()) - throw error - } - return () => finishes.toReversed().forEach((finish) => finish()) -} - -/** Register an SSH PTY provider for a connection. */ -export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void { - sshProviders.set(connectionId, provider) - const generation = (provider as { providerGeneration?: number }).providerGeneration - if (Number.isSafeInteger(generation) && generation! > 0) { - sshProvidersByGeneration.set(generation!, provider) - } -} - -/** Remove an SSH PTY provider when a connection is closed. */ -export function unregisterSshPtyProvider(connectionId: string): void { - const provider = sshProviders.get(connectionId) - const generation = (provider as { providerGeneration?: number } | undefined)?.providerGeneration - if (generation !== undefined && sshProvidersByGeneration.get(generation) === provider) { - sshProvidersByGeneration.delete(generation) - } - sshProviders.delete(connectionId) -} - -/** Get the SSH PTY provider for a connection (for dispose on cleanup). */ -export function getSshPtyProvider(connectionId: string): IPtyProvider | undefined { - return sshProviders.get(connectionId) -} - -/** Get the installed PTY provider (for direct access in tests/runtime). - * After daemon init this may be a DaemonPtyAdapter/DaemonPtyRouter, not LocalPtyProvider; - * callers needing LocalPtyProvider-specific methods must type-narrow or import the class. */ -export function getLocalPtyProvider(): IPtyProvider { - return localProvider -} - -/** Replace the local PTY provider with a daemon-backed one. - * Call before registerPtyHandlers so the IPC layer routes through the daemon. */ -export function setLocalPtyProvider(provider: IPtyProvider): void { - localProvider = provider -} - -/** Get all PTY IDs owned by a given connectionId (for reconnection reattach). */ -export function getPtyIdsForConnection(connectionId: string): string[] { - const ids: string[] = [] - for (const [ptyId, connId] of ptyOwnership) { - if (connId === connectionId) { - ids.push(ptyId) - } - } - return ids -} - -/** - * Remove transient PTY routing entries for a disconnected connection. - * Claimed agent owners remain fenced because the relay process may survive and - * prove the exact same generation after reconnect. - */ -export function clearPtyOwnershipForConnection(connectionId: string): void { - for (const [ptyId, connId] of ptyOwnership) { - if (connId === connectionId) { - // Why: pane-scoped caches cannot route while disconnected, but claimed - // ownership must survive until reconnect makes absence authoritative. - clearProviderPtyState(ptyId, { preserveAgentSessionOwners: true }) - ptyOwnership.delete(ptyId) - } - } -} - -// ─── Provider-scoped PTY state cleanup ────────────────────────────── - -export function clearProviderPtyState( - id: string, - opts: { preserveAgentSessionOwners?: boolean } = {} -): void { - if (!opts.preserveAgentSessionOwners) { - agentSessionOwners.release(id) - // Why: the launch-account record outlives the app, so only a real teardown - // may drop it — a disconnect that can reconnect is not a death, and a reused - // id must never inherit a dead pane's Codex account. - forgetCodexPaneAccount(id) - } - // Why: OpenCode and Pi both allocate PTY-scoped runtime state outside the - // node-pty process table. Centralizing provider cleanup avoids drift where a - // new teardown path forgets to remove one provider's overlay/hook state. - openCodeHookService.clearPty(id) - piTitlebarExtensionService.clearPty(id) - // Why: SSH exit/teardown paths bypass pty.ts's local onExit but still must release Claude account-switch guards. - markClaudePtyExited(id) - ptySizes.delete(id) - ptyIncarnationById.delete(id) - lastInputAtByPty.delete(id) - interactiveOutputCharsByPty.delete(id) - const activeChanged = activeRendererPtys.delete(id) - visibleRendererPtys.delete(id) - rendererVisibilityKnownPtys.delete(id) - pendingHiddenRendererResizeOutputPtys.delete(id) - deliveredHiddenRendererResizeOutputPtys.delete(id) - // Why: every teardown path funnels through here — hidden/interest gate bits must not outlive the PTY or a reused map entry could silently gate a new one. - const deliveryPolicyChanged = isHiddenRendererPty(id) - clearHiddenRendererPtyDeliveryState(id) - if (activeChanged) { - invalidatePendingPtyDrainPriority(id, false) - } - if (deliveryPolicyChanged) { - invalidatePendingPtyDrainPolicy(id, false) - } - clearBackgroundedDeliverySyncForPty(id) - providerSnapshotRequiredPtys.delete(id) - // Why: the Phase-5 ConPTY DA1 spawn record must not leak onto a reused id. - clearNativeWindowsConptyPty(id) - const paneKey = ptyPaneKey.get(id) - const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false - // Why: drop the memory-collector registration so a dead PTY doesn't resolve its dead pid on every snapshot; no-op for never-registered (SSH-owned) PTYs. - unregisterPty(id) - // Why: cover paths that bypass runtime.onPtyExit (SSH reattach/shutdown, daemon spawn-failure) — else the watcher's per-PTY buffer and worktree binding outlive the PTY. - advertisedUrlWatcher.unbindPty(id) - clearMigrationUnsupportedPty(id) - agentHookServer.clearPaneKeyAliasesForPty(id, { - shouldClearStablePaneKey: (stablePaneKey) => { - // Why: when this PTY never rebuilt ptyPaneKey after restart, alias ownership is our only proof — don't erase a newer PTY that now owns the same stable paneKey. - const stablePaneOwner = paneKeyPtyId.get(stablePaneKey) - if (stablePaneOwner && stablePaneOwner !== id) { - return false - } - return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey) - } - }) - // Why: clear the hook server's per-paneKey caches (via the spawn-time paneKey mapping, its only ptyId→paneKey correlation) so dead panes don't accumulate over process lifetime. - if (paneKey) { - if (stillOwnsPaneKey) { - agentHookServer.clearPaneState(paneKey) - paneKeyPtyId.delete(paneKey) - } - ptyPaneKey.delete(id) - if (stillOwnsPaneKey) { - // Why: notify AFTER dropping the paneKey↔ptyId entries so a listener re-reading the map sees post-teardown state; wrap each so one throw can't block the rest. - for (const listener of paneKeyTeardownListeners) { - try { - listener(paneKey) - } catch (err) { - console.error('[pty] paneKey teardown listener threw', err) - } - } - } - } -} - -export function deletePtyOwnership(id: string): void { - ptyOwnership.delete(id) -} - -export function setPtyOwnership(id: string, connectionId: string | null): void { - ptyOwnership.set(id, connectionId) -} - -export function restorePtyIncarnation(id: string, incarnationId: string): void { - if (!isPtyIncarnationId(incarnationId)) { - throw new Error('Invalid PTY incarnation') - } - ptyIncarnationById.set(id, incarnationId) -} - -// Why: localProvider.onData/onExit return unsubscribe functions. Without -// storing and calling these on re-registration, macOS app re-activation -// creates a new BrowserWindow and re-calls registerPtyHandlers, leaking -// duplicate listeners that forward every event twice. -let localDataUnsub: (() => void) | null = null -let localExitUnsub: (() => void) | null = null -let localBackgroundStreamUnsub: (() => void) | null = null -let localWriteUnavailableUnsub: (() => void) | null = null -let didFinishLoadHandler: (() => void) | null = null -let didFinishLoadWebContents: WebContents | null = null -let rendererLifecycleResetWebContents: WebContents | null = null -let rendererLifecycleResetHandler: (() => void) | null = null -// Why: the hidden-delivery gate registries mirror renderer state; a reload/crash destroys owners without unregistering, so they reset when the renderer is replaced (drop memory preserved). -let rendererGateResetLoadHandler: (() => void) | null = null -let rendererGateResetGoneHandler: (() => void) | null = null -let rendererGateResetWebContents: WebContents | null = null -// Why: the backgrounded-delivery dedupe map lives in the registerPtyHandlers closure but teardown funnels through module-scope clearProviderPtyState. -let clearBackgroundedDeliverySyncForPty: (id: string) => void = () => {} -// Why: after daemon keep-tail thinning main's mirror holds only the kept tail, so recovery must keep consulting the daemon's complete model until exit. -const providerSnapshotRequiredPtys = new Set() -type RendererNavigationDetails = { - isMainFrame: boolean - isSameDocument: boolean -} - -// Why: navigation details identify the triggering frame; querying aggregate load state can misclassify an overlapping subframe load. -let rendererDidStartNavigationHandler: ((details: RendererNavigationDetails) => void) | null = null - -// Why: Restart daemon must re-bind provider→renderer listeners after replaceDaemonProvider swaps localProvider, else subscribers stay bound to the disposed adapter and new PTY data silently drops. -let rebindProviderListeners: (() => void) | null = null -let sshOutputIntakeCleanup: (() => void) | null = null - -export function rebindLocalProviderListeners(): void { - rebindProviderListeners?.() -} - -export type PtyRendererDeliveryDebugSnapshot = { - pendingPtyCount: number - pendingChars: number - maxPendingCharsByPty: number - rendererInFlightPtyCount: number - rendererInFlightChars: number - maxRendererInFlightCharsByPty: number - activeRendererPtyCount: number - flushScheduled: boolean - peakPendingChars: number - peakMaxPendingCharsByPty: number - peakRendererInFlightChars: number - peakMaxRendererInFlightCharsByPty: number - ackGatedFlushSkipCount: number - hiddenDeliveryGatedPtyCount: number - /** Hidden-gated ptys the renderer ALSO reports visible/active — a contradiction that should be zero (v1.4.124-rc.2.perf field lead). */ - hiddenDeliveryGatedVisiblePtyCount: number - hiddenDeliveryGatedActivePtyCount: number - deliveryInterestPtyCount: number - hiddenDeliveryDroppedChars: number - hiddenDeliveryDroppedChunks: number - pendingDroppedChars: number - /** One-paste freeze diagnostics: per-pty delivery table + event history. */ - diagnostics: PtyMainDeliveryDiagnostics - // Why: a nonzero lastLifecycleResetClearedChars is the exact signature of the leaked-accounting freeze this reset fixes. - rendererLifecycleResetCount: number - lastLifecycleResetClearedChars: number - // Why: the boot-window hold early-returns before ackGatedFlushSkipCount++, so these expose an otherwise-invisible held gate; forcedCount > 0 flags a watchdog self-heal. - rendererPtyDispatcherReady: boolean - rendererDispatcherReadyForcedCount: number -} - -// Why module scope: breadcrumb writers live both inside registerPtyHandlers and outside it (renderer lifecycle resets). -const mainDeliveryBreadcrumbs = createPtyDeliveryBreadcrumbRing() -let lastPowerSuspendAtMs: number | null = null -let lastPowerResumeAtMs: number | null = null -let powerSignalBreadcrumbsInstalled = false - -// Why: both field freeze variants correlate with display sleep; suspend/resume timestamps let breadcrumbs line up against the wake. -function installPowerSignalBreadcrumbs(power: PtyPowerSurface): void { - if (powerSignalBreadcrumbsInstalled) { - return - } - powerSignalBreadcrumbsInstalled = true - power.on('suspend', () => { - lastPowerSuspendAtMs = Date.now() - mainDeliveryBreadcrumbs.record('power-suspend') - }) - power.on('resume', () => { - lastPowerResumeAtMs = Date.now() - mainDeliveryBreadcrumbs.record('power-resume') - }) -} - -const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapshot = { - pendingPtyCount: 0, - pendingChars: 0, - maxPendingCharsByPty: 0, - rendererInFlightPtyCount: 0, - rendererInFlightChars: 0, - maxRendererInFlightCharsByPty: 0, - activeRendererPtyCount: 0, - flushScheduled: false, - peakPendingChars: 0, - peakMaxPendingCharsByPty: 0, - peakRendererInFlightChars: 0, - peakMaxRendererInFlightCharsByPty: 0, - ackGatedFlushSkipCount: 0, - hiddenDeliveryGatedPtyCount: 0, - hiddenDeliveryGatedVisiblePtyCount: 0, - hiddenDeliveryGatedActivePtyCount: 0, - deliveryInterestPtyCount: 0, - hiddenDeliveryDroppedChars: 0, - hiddenDeliveryDroppedChunks: 0, - pendingDroppedChars: 0, - diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, - rendererLifecycleResetCount: 0, - lastLifecycleResetClearedChars: 0, - rendererPtyDispatcherReady: false, - rendererDispatcherReadyForcedCount: 0 -} - -let readPtyRendererDeliveryDebugSnapshot = (): PtyRendererDeliveryDebugSnapshot => ({ - ...EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT -}) -let resetPtyRendererDeliveryDebugSnapshot = (): void => {} -// Bridged into the registerPtyHandlers closure so the module-scope lifecycle-reset handler can zero closure-owned delivery accounting on renderer reload/crash. -let resetRendererDeliveryAccountingForLifecycleReset = (): void => {} -// Bridged so a re-registration can cancel the prior closure's dispatcher-ready watchdog before wiring its own. -let clearRendererDispatcherReadyWatchdog = (): void => {} - -export function getPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { - return readPtyRendererDeliveryDebugSnapshot() -} - -export function resetPtyRendererDeliveryDebug(): void { - resetPtyRendererDeliveryDebugSnapshot() -} - -function clearDidFinishLoadHandler(): void { - if (didFinishLoadHandler && didFinishLoadWebContents) { - didFinishLoadWebContents.removeListener('did-finish-load', didFinishLoadHandler) - } - didFinishLoadHandler = null - didFinishLoadWebContents = null -} - -function markRendererPtysHiddenForRendererLifecycleReset(): void { - // A reload/crash in the breadcrumb history is load-bearing context for any freeze report. - mainDeliveryBreadcrumbs.record('renderer-lifecycle-reset') - // Why: renderer-owned hints die with the page; clear visibility so surviving daemon/SSH PTYs fail closed until the new renderer reports. - const activePriorityChanged = activeRendererPtys.size > 0 - activeRendererPtys.clear() - visibleRendererPtys.clear() - // Why: the dead page never ACKs its in-flight bytes, so leaked accounting would delivery-gate surviving PTYs forever after a reload/crash. - resetRendererDeliveryAccountingForLifecycleReset() - if (activePriorityChanged) { - invalidatePendingPtyDrainPriority() - } -} - -function clearRendererLifecycleResetHandlers(): void { - if (!rendererLifecycleResetWebContents) { - return - } - if (rendererDidStartNavigationHandler) { - rendererLifecycleResetWebContents.removeListener( - 'did-start-navigation', - rendererDidStartNavigationHandler - ) - } - if (rendererLifecycleResetHandler) { - rendererLifecycleResetWebContents.removeListener( - 'render-process-gone', - rendererLifecycleResetHandler - ) - rendererLifecycleResetWebContents.removeListener('destroyed', rendererLifecycleResetHandler) - } - rendererLifecycleResetWebContents = null - rendererLifecycleResetHandler = null - rendererDidStartNavigationHandler = null -} - -function registerRendererLifecycleResetHandlers(webContents: WebContents): void { - clearRendererLifecycleResetHandlers() - markRendererPtysHiddenForRendererLifecycleReset() - rendererLifecycleResetWebContents = webContents - rendererLifecycleResetHandler = markRendererPtysHiddenForRendererLifecycleReset - rendererDidStartNavigationHandler = (details) => { - if (!details.isMainFrame || details.isSameDocument) { - return - } - markRendererPtysHiddenForRendererLifecycleReset() - } - webContents.on('did-start-navigation', rendererDidStartNavigationHandler) - webContents.on('render-process-gone', rendererLifecycleResetHandler) - webContents.on('destroyed', rendererLifecycleResetHandler) -} - -function clearRendererGateResetHandlers(): void { - if (rendererGateResetWebContents) { - if (rendererGateResetLoadHandler) { - rendererGateResetWebContents.removeListener('did-finish-load', rendererGateResetLoadHandler) - } - if (rendererGateResetGoneHandler) { - rendererGateResetWebContents.removeListener( - 'render-process-gone', - rendererGateResetGoneHandler - ) - } - } - rendererGateResetLoadHandler = null - rendererGateResetGoneHandler = null - rendererGateResetWebContents = null -} - -// Why: Restart daemon must detach listeners AFTER synthetic pty:exit events fan out but BEFORE replaceDaemonProvider swaps the adapter; this export narrows that window to the caller. -export function unbindLocalProviderListeners(): void { - localDataUnsub?.() - localExitUnsub?.() - localBackgroundStreamUnsub?.() - localWriteUnavailableUnsub?.() - localDataUnsub = null - localExitUnsub = null - localBackgroundStreamUnsub = null - localWriteUnavailableUnsub = null -} - -// ─── IPC Registration ─────────────────────────────────────────────── - -export function registerPtyHandlers( - mainWindow: BrowserWindow | null, - runtime?: OrcaRuntimeService, - getSelectedCodexHomePath?: GetSelectedCodexHomePath, - getSettings?: () => GlobalSettings, - prepareClaudeAuth?: PrepareClaudeAuth, - store?: Store, - options?: { - prepareCodexSessionResume?: PrepareCodexSessionResume - awaitLocalPtyStartup?: () => Promise - awaitLocalPtyProviderStartup?: () => Promise - // Why: returns true once for the crash-recovery reload so its did-finish-load skips the orphan sweep and keeps live PTYs (#5787). - isRecoveryReloadInFlight?: (webContentsId: number) => boolean - onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void - onPtyExit?: (id: string, exitSequence: number) => void - } -): void { - const ipc = getPtyIpc() - // Why: a re-registration means a new window owns delivery — cancel the prior closure's watchdog and neutralize its bridged reset so mark-hidden below can't arm a timer against the dead closure. - clearRendererDispatcherReadyWatchdog() - resetRendererDeliveryAccountingForLifecycleReset = () => {} - invalidatePendingPtyDrainPriority = () => {} - invalidatePendingPtyDrainPolicy = () => {} - const rendererContents = rendererWebContents(mainWindow) - if (rendererContents) { - registerRendererLifecycleResetHandlers(rendererContents) - } - - const getLocalPtyStartupPromise = (connectionId?: string | null): Promise | undefined => { - if (connectionId) { - return undefined - } - // Why: during cold start the daemon provider swap overlaps first paint, so local spawns must wait; SSH/headless don't use the desktop daemon. - return options?.awaitLocalPtyStartup?.() - } - - const getLocalPtyProviderStartupPromise = ( - connectionId?: string | null - ): Promise | undefined => { - if (connectionId) { - return undefined - } - return options?.awaitLocalPtyProviderStartup?.() ?? options?.awaitLocalPtyStartup?.() - } - - // Remove prior handlers so re-registration (e.g. macOS re-activate creating a new window) doesn't double-register. - ipc.removeHandler('pty:spawn') - ipc.removeHandler('pty:kill') - ipc.removeHandler('pty:listSessions') - ipc.removeHandler('pty:hasPty') - ipc.removeHandler('pty:hasChildProcesses') - ipc.removeHandler('pty:getForegroundProcess') - ipc.removeHandler('pty:inspectProcess') - ipc.removeHandler('pty:confirmForegroundProcess') - ipc.removeHandler('pty:getCwd') - ipc.removeHandler('pty:getSize') - ipc.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities') - ipc.removeHandler('pty:declarePendingPaneSerializer') - ipc.removeHandler('pty:settlePaneSerializer') - ipc.removeHandler('pty:clearPendingPaneSerializer') - ipc.removeHandler('pty:reportRendererSerializerReady') - ipc.removeHandler('pty:getMainBufferSnapshot') - ipc.removeHandler('pty:sideEffectSnapshot') - ipc.removeHandler('pty:getRendererDeliveryDebugSnapshot') - ipc.removeHandler('pty:resetRendererDeliveryDebug') - ipc.removeHandler('pty:reportRendererDeliveryState') - ipc.removeHandler('pty:writeAccepted') - ipc.removeAllListeners('pty:write') - ipc.removeAllListeners('pty:ackColdRestore') - ipc.removeAllListeners('pty:ackData') - ipc.removeAllListeners('pty:deliveryResyncResponse') - ipc.removeAllListeners('pty:serializeBuffer:response') - - // Why: only LocalPtyProvider needs main-process hook injection; daemon-backed providers spawn subprocesses internally. - if (localProvider instanceof LocalPtyProvider) { - localProvider.configure({ - isHistoryEnabled: () => getSettings?.()?.terminalScopeHistoryByWorktree ?? true, - getWindowsShell: () => getSettings?.()?.terminalWindowsShell, - getWindowsPowerShellImplementation: () => - getSettings - ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') - : undefined, - pwshAvailable: () => isPwshAvailableAsync(), - buildSpawnEnv: (id, baseEnv, ctx) => { - const codexSelectionTarget: CodexAccountSelectionTarget = - ctx?.isWsl === true - ? { runtime: 'wsl', wslDistro: ctx.wslDistro ?? null } - : { runtime: 'host' } - const selectedCodexHomePath = getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - ctx?.codexHomePathOverride - ? ctx.codexHomePathOverride.value - : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: ctx?.cwd, - launchAgent: ctx?.launchAgent - }) ?? null) - ) - const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath - const ptySettings = getSettings?.() - const env = buildPtyHostEnv(id, baseEnv, { - isPackaged: getAppEnvironment().isPackaged(), - resourcesPath: process.resourcesPath, - userDataPath: getAppEnvironment().getPath('userData'), - selectedCodexHomePath, - skipCodexHomeEnv, - stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({ - target: codexSelectionTarget, - selectedCodexHomePath, - skipCodexHomeEnv, - settings: ptySettings - }), - launchCommand: ctx?.command, - launchAgent: ctx?.launchAgent, - isWsl: ctx?.isWsl, - wslDistro: ctx?.wslDistro ?? null, - agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), - codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), - networkProxySettings: ptySettings - }) - // Why: agents need their terminal handle at process start to self-identify in orchestration messages without an extra RPC. - const requestedHandle = baseEnv.ORCA_TERMINAL_HANDLE - const preAllocatedHandle = - requestedHandle && trustedTerminalHandleEnv.has(requestedHandle) - ? requestedHandle - : runtime?.preAllocateHandleForPty(id) - if (requestedHandle && requestedHandle !== preAllocatedHandle) { - delete env.ORCA_TERMINAL_HANDLE - } - if (preAllocatedHandle) { - env.ORCA_TERMINAL_HANDLE = preAllocatedHandle - } - stampWslOrchestrationCompatibilityHost( - env, - runtime?.getOrchestrationCompatibilityHostId?.(), - ctx?.isWsl === true ? ctx.wslDistro : null - ) - if (ctx?.isWsl === true) { - addOrcaWslInteropEnv(env) - } - return env - }, - onSpawned: (id, incarnationId) => runtime?.onPtySpawned(id, incarnationId), - onExit: (id, code, incarnationId, cause) => { - if (!isCurrentPtyExit({ id, incarnationId })) { - return - } - clearProviderPtyState(id) - ptyOwnership.delete(id) - markClaudePtyExited(id) - // Why: the provider's physical exit callback is a death certificate even when node-pty - // reports the code as -1; only the synthesized failed/unverified stop calls omit this. - runtime?.onPtyExit(id, code, incarnationId, { - providerExitObserved: true, - ...(cause ? { cause } : {}) - }) - }, - onData: (id, data, timestamp, sequenceChars, transformed) => - runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed) - }) - } - - type PtyDataPayload = { - id: string - data: string - seq?: number - rawLength?: number - transformed?: boolean - background?: boolean - droppedOutput?: boolean - } - - // Why: bounded batch windows amortize renderer IPC; keystroke echo/redraws bypass them below. - const pendingData = new PtyPendingDataDrainQueue( - (id) => { - const runnableLane = activeRendererPtys.has(id) ? 'active' : 'background' - // Why first: hidden bytes are dropped from main's pending queue even when renderer credit is exhausted. - if (shouldDropHiddenRendererPtyData(id, getSettings?.())) { - return runnableLane - } - if ( - !rendererPtyDispatcherReady || - !canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) }) - ) { - return 'blocked' - } - return runnableLane - }, - () => isHiddenPtyDeliveryGateEnabled(getSettings?.()) - ) - let sshOutputIntake: SshPtyOutputIntake | null = null - // Why: resuming a paused producer during exit can synchronously emit; those bytes must not queue behind pty:exit. - const rendererExitingPtyIds = new Set() - const rendererCreditBeforeExitByPty = new Map() - const rendererDeliveryRestoreNeededPtys = new Set() - - function transitionHiddenRendererPtyDeliveryState(id: string, hidden: boolean) { - const settings = getSettings?.() - const wasDroppable = shouldDropHiddenRendererPtyData(id, settings) - let droppedWhileHidden = false - if (hidden) { - markHiddenRendererPty(id) - } else { - droppedWhileHidden = unmarkHiddenRendererPty(id).droppedWhileHidden - } - const droppable = shouldDropHiddenRendererPtyData(id, settings) - return { droppable, droppedWhileHidden, policyChanged: wasDroppable !== droppable } - } - - function transitionSpawnHiddenRendererPtyDeliveryState(id: string, hidden: boolean): void { - const transition = transitionHiddenRendererPtyDeliveryState(id, hidden) - if (transition.policyChanged) { - invalidatePendingPtyDrainPolicy(id) - } - } - // Why: one restore marker per overflow episode — cleared on full drain so a later overflow re-marks exactly once. - const pendingOverflowMarkedPtys = new Set() - // Why: TCP-style cumulative accounting — monotonic sent/acked totals self-heal on any later ACK, where relative in-flight counters would make each lost ACK a permanent debt. - type RendererPtyDeliveryAccounting = { - sentChars: number - ackedChars: number - lastSendAtMs: number - lastAckAtMs: number | null - } - const rendererDeliveryAccountingByPty = new Map() - const trustedTerminalHandleEnv = new Set() - let flushTimer: ReturnType | null = null - let pendingDataFlushActive = false - let pendingDataCreditReleasedDuringFlush = false - let rendererInFlightTotalChars = 0 - let pendingDroppedChars = 0 - let deliveryResyncRequestSerial = 0 - let deliveryResyncOutstandingRequestId: number | null = null - let deliveryResyncTimer: ReturnType | null = null - let deliveryResyncUnansweredWarnLogged = false - let lastAckReceivedAtMs: number | null = null - // Why 2ms: pairs with the daemon stream batcher (daemon-stream-data-batcher.ts); keeps flood coalescing at negligible IPC overhead while cutting the pipeline's latency tax. - const PTY_BATCH_INTERVAL_MS = 2 - const PTY_BATCH_DRAIN_CONTINUE_MS = 1 - const PTY_BATCH_FLUSH_CHUNK_CHARS = 16 * 1024 - const PTY_BATCH_FLUSH_MAX_WRITES = 2 - const PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS = 512 * 1024 - const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 8 * 1024 * 1024 - // Why: cap unbounded pendingData growth when the renderer can't receive (frozen/reloading); beyond it bytes drop and the pane heals from the main buffer snapshot via the droppedOutput sentinel (#7630). - // Why read settings live: the cap scales with the user's scrollback so power users don't lose lines their scrollback would have retained. - const pendingDataCapChars = (): number => - terminalOutputBacklogCapChars(getSettings?.().terminalScrollbackRows) - // Why: self-heal bound — if a reloaded page never sends pty:rendererDispatcherReady, force sends on after this window so a lost handshake can't become a permanent hold. - const PTY_DISPATCHER_READY_WATCHDOG_MS = 10_000 - const PTY_RENDERER_INTERACTIVE_RESERVE_CHARS = 256 * 1024 - // Why: reserve a bounded lane so an active pane's keystroke redraw reaches the renderer ahead of hidden bulk output's ACKs. - const PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS = 512 * 1024 - // Why: request/response hygiene only (never mutates delivery state) — clears the outstanding-probe flag so a later arrival can re-probe, logs once per silent streak. - const PTY_DELIVERY_RESYNC_TIMEOUT_MS = 5_000 - // Why: a heal write-off destroys delivery accounting; require this much main-side ACK silence (independent of renderer evidence) before declaring the channel dead. - const PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS = 10_000 - // Why: keep the immediate path to keystroke-sized TUI redraws; large/non-interactive output must use the batcher. - const INTERACTIVE_OUTPUT_WINDOW_MS = 100 - const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 - const INTERACTIVE_REDRAW_MAX_CHARS = PTY_BATCH_FLUSH_CHUNK_CHARS - const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 - let peakPendingChars = 0 - let peakMaxPendingCharsByPty = 0 - let peakRendererInFlightChars = 0 - let peakMaxRendererInFlightCharsByPty = 0 - let ackGatedFlushSkipCount = 0 - let rendererLifecycleResetCount = 0 - let lastLifecycleResetClearedChars = 0 - // Why: count of watchdog gate force-opens (no handshake arrived); nonzero flags a dropped-handshake self-heal. - let rendererDispatcherReadyForcedCount = 0 - // Why: gate sends until the page's pty:data listener exists; else webContents.send drops bytes but still counts them in-flight, permanently pinning the gate. - let rendererPtyDispatcherReady = false - let dispatcherReadyWatchdogTimer: ReturnType | null = null - - // Why: watermark producer pause/resume keyed on per-PTY pendingData (in-flight is already ACK-bounded); providers without pauseProducer no-op, memory still bounded by the pending cap. - const producerFlowControl = new PtyProducerFlowController({ - pauseProducer: (id) => tryGetProviderForPty(id)?.pauseProducer?.(id), - resumeProducer: (id) => tryGetProviderForPty(id)?.resumeProducer?.(id) - }) - const sourceCreditPendingPtys = new Set() - - function updateProducerFlowControl(id: string): void { - if (!PRODUCER_FLOW_CONTROL_ENABLED) { - return - } - if (sourceCreditPendingPtys.has(id)) { - if (pendingData.get(id)) { - return - } - sourceCreditPendingPtys.delete(id) - } - producerFlowControl.update(id, pendingData.get(id)?.data.length ?? 0) - } - - // Why: hidden ptys are exempt from pendingData flow control, so background agents can run 100MB+ ahead in the daemon stream buffer; this sync tells the provider transport which ptys to keep-tail thin. - // Why keyed on the visibility registry (not gate marks): thinning asks "does any visible view show this PTY?"; remote-view subscribers consume raw bytes, so their presence vetoes thinning. - const backgroundedDeliverySyncByPty = new Map() - function syncPtyBackgroundedDelivery(id: string, caller: string): void { - const background = - rendererPtyIsKnownHidden(id) && !(runtime?.hasRawTerminalViewSubscriber?.(id) ?? false) - if (backgroundedDeliverySyncByPty.get(id) === background) { - return - } - const provider = tryGetProviderForPty(id) - if (!provider?.setPtyBackgrounded) { - return - } - recordDaemonStreamBacklogEvent('mainBackgroundSync', { - sessionIdSuffix: id.slice(-10), - background, - caller, - known: rendererVisibilityKnownPtys.has(id), - visible: visibleRendererPtys.has(id) - }) - backgroundedDeliverySyncByPty.set(id, background) - provider.setPtyBackgrounded(id, background) - } - clearBackgroundedDeliverySyncForPty = (id: string) => { - backgroundedDeliverySyncByPty.delete(id) - } - if (runtime) { - runtime.onRemoteTerminalViewPresenceChanged = (id) => - syncPtyBackgroundedDelivery(id, 'remote-view') - } - function resyncBackgroundedDeliveriesAfterGateReset(): void { - for (const id of backgroundedDeliverySyncByPty.keys()) { - syncPtyBackgroundedDelivery(id, 'gate-reset') - } - } - - function getRendererInFlightCharsForPty(id: string): number { - const accounting = rendererDeliveryAccountingByPty.get(id) - return accounting ? accounting.sentChars - accounting.ackedChars : 0 - } - - // Why touched PTY only: pressure peaks are monotonic between explicit resets. - function recordPtyRendererDeliveryPressure(id: string): void { - peakPendingChars = Math.max(peakPendingChars, pendingData.totalPendingChars) - peakMaxPendingCharsByPty = Math.max( - peakMaxPendingCharsByPty, - pendingData.get(id)?.data.length ?? 0 - ) - peakRendererInFlightChars = Math.max(peakRendererInFlightChars, rendererInFlightTotalChars) - peakMaxRendererInFlightCharsByPty = Math.max( - peakMaxRendererInFlightCharsByPty, - getRendererInFlightCharsForPty(id) - ) - } - - function setPendingPtyData(id: string, pending: PendingPtyData): void { - pendingData.set(id, pending) - recordPtyRendererDeliveryPressure(id) - } - - function deletePendingPtyData(id: string): void { - pendingData.delete(id) - } - - function clearPendingPtyData(): void { - for (const pending of pendingData.values()) { - if (pending.projectionAdmissionIds) { - sshOutputIntake?.transferProjections( - pending.projectionAdmissionIds, - 'renderer-lifecycle-reset' - ) - } - } - pendingData.clear() - sourceCreditPendingPtys.clear() - } - - function readCurrentPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { - let pendingChars = 0 - let maxPendingCharsByPty = 0 - for (const pending of pendingData.values()) { - const chars = pending.data.length - pendingChars += chars - maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) - } - const hiddenDeliveryDebug = getHiddenRendererPtyDeliveryDebug() - let rendererInFlightPtyCount = 0 - let maxRendererInFlightCharsByPty = 0 - for (const accounting of rendererDeliveryAccountingByPty.values()) { - const inFlight = accounting.sentChars - accounting.ackedChars - if (inFlight > 0) { - rendererInFlightPtyCount++ - } - maxRendererInFlightCharsByPty = Math.max(maxRendererInFlightCharsByPty, inFlight) - } - // Why: a pty both hidden-gated and reported visible means main is starving a visible pane (v1.4.124-rc.2.perf field lead). - let hiddenDeliveryGatedVisiblePtyCount = 0 - for (const id of visibleRendererPtys) { - if (isHiddenRendererPty(id)) { - hiddenDeliveryGatedVisiblePtyCount++ - } - } - let hiddenDeliveryGatedActivePtyCount = 0 - for (const id of activeRendererPtys) { - if (isHiddenRendererPty(id)) { - hiddenDeliveryGatedActivePtyCount++ - } - } - return { - pendingPtyCount: pendingData.size, - pendingChars, - maxPendingCharsByPty, - rendererInFlightPtyCount, - rendererInFlightChars: rendererInFlightTotalChars, - maxRendererInFlightCharsByPty, - activeRendererPtyCount: activeRendererPtys.size, - flushScheduled: flushTimer !== null, - peakPendingChars, - peakMaxPendingCharsByPty, - peakRendererInFlightChars, - peakMaxRendererInFlightCharsByPty, - ackGatedFlushSkipCount, - ...hiddenDeliveryDebug, - hiddenDeliveryGatedVisiblePtyCount, - hiddenDeliveryGatedActivePtyCount, - pendingDroppedChars, - diagnostics: buildMainDeliveryDiagnostics(), - rendererLifecycleResetCount, - lastLifecycleResetClearedChars, - rendererPtyDispatcherReady, - rendererDispatcherReadyForcedCount - } - } - - const DELIVERY_DIAGNOSTICS_MAX_PTYS = 30 - - // Built only when the debug snapshot is read (never on the data path): the per-pty table + breadcrumb history says WHICH pty is wedged and WHEN, unlike aggregate counters. - function buildMainDeliveryDiagnostics(): PtyMainDeliveryDiagnostics { - const now = Date.now() - // Include hidden/visible/active members even without an accounting entry: a pty gated before its first byte is exactly the wedge case to surface. - const ids = new Set([ - ...rendererDeliveryAccountingByPty.keys(), - ...pendingData.keys(), - ...getHiddenRendererPtyIds(), - ...visibleRendererPtys, - ...activeRendererPtys - ]) - const perPty: PtyPerPtyDeliveryDiagnostics[] = [] - for (const id of ids) { - const accounting = rendererDeliveryAccountingByPty.get(id) - perPty.push({ - id: redactPtyIdForDiagnostics(id), - sentChars: accounting?.sentChars ?? 0, - ackedChars: accounting?.ackedChars ?? 0, - inFlightChars: accounting ? accounting.sentChars - accounting.ackedChars : 0, - pendingChars: pendingData.get(id)?.data.length ?? 0, - hidden: isHiddenRendererPty(id), - visible: visibleRendererPtys.has(id), - active: activeRendererPtys.has(id), - msSinceLastSend: accounting ? now - accounting.lastSendAtMs : null, - msSinceLastAck: accounting?.lastAckAtMs == null ? null : now - accounting.lastAckAtMs - }) - } - perPty.sort((a, b) => b.inFlightChars + b.pendingChars - (a.inFlightChars + a.pendingChars)) - const windowAlive = !isRendererGone(mainWindow) - return { - appVersion: getAppEnvironment().getVersion(), - mainUptimeMs: Math.round(process.uptime() * 1000), - windowFocused: windowAlive ? mainWindow!.isFocused() : null, - windowVisible: windowAlive ? mainWindow!.isVisible() : null, - windowMinimized: windowAlive ? mainWindow!.isMinimized() : null, - msSinceLastPowerSuspend: lastPowerSuspendAtMs === null ? null : now - lastPowerSuspendAtMs, - msSinceLastPowerResume: lastPowerResumeAtMs === null ? null : now - lastPowerResumeAtMs, - perPty: perPty.slice(0, DELIVERY_DIAGNOSTICS_MAX_PTYS), - breadcrumbs: mainDeliveryBreadcrumbs.snapshot() - } - } - - // Why rate-limited: the contradiction persists chunk after chunk while latched; one line per minute keeps field logs readable but present. - let lastHiddenDropContradictionWarnAtMs = 0 - function warnIfDroppingHiddenBytesForVisiblePty(id: string, droppedChars: number): void { - if (!visibleRendererPtys.has(id) && !activeRendererPtys.has(id)) { - return - } - // Recorded before the warn rate limit: the ring coalesces repeats, and the contradiction must appear in the freeze report either way. - mainDeliveryBreadcrumbs.record('hidden-drop-visible', { - id: redactPtyIdForDiagnostics(id), - droppedChars - }) - const now = Date.now() - if (now - lastHiddenDropContradictionWarnAtMs < 60_000) { - return - } - lastHiddenDropContradictionWarnAtMs = now - console.warn('[pty] hidden-delivery gate is dropping bytes for a visible/active pty', { - id, - droppedChars, - visible: visibleRendererPtys.has(id), - active: activeRendererPtys.has(id), - ...readCurrentPtyRendererDeliveryDebugSnapshot() - }) - } - - function seedPtyRendererDeliveryPeaksFromCurrentState(): void { - let pendingChars = 0 - let maxPendingCharsByPty = 0 - for (const pending of pendingData.values()) { - const chars = pending.data.length - pendingChars += chars - maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) - } - peakPendingChars = pendingChars - peakMaxPendingCharsByPty = maxPendingCharsByPty - peakRendererInFlightChars = rendererInFlightTotalChars - let maxRendererInFlightCharsByPty = 0 - for (const accounting of rendererDeliveryAccountingByPty.values()) { - maxRendererInFlightCharsByPty = Math.max( - maxRendererInFlightCharsByPty, - accounting.sentChars - accounting.ackedChars - ) - } - peakMaxRendererInFlightCharsByPty = maxRendererInFlightCharsByPty - } - - readPtyRendererDeliveryDebugSnapshot = readCurrentPtyRendererDeliveryDebugSnapshot - resetPtyRendererDeliveryDebugSnapshot = () => { - peakPendingChars = 0 - peakMaxPendingCharsByPty = 0 - peakRendererInFlightChars = 0 - peakMaxRendererInFlightCharsByPty = 0 - ackGatedFlushSkipCount = 0 - pendingDroppedChars = 0 - resetHiddenRendererPtyDeliveryDebugCounters() - seedPtyRendererDeliveryPeaksFromCurrentState() - } - resetRendererDeliveryAccountingForLifecycleReset = () => { - // Why lossless: pendingData bytes were bound for the dead page; the replacement repaints from main's authoritative sources, which superset it. - lastLifecycleResetClearedChars = rendererInFlightTotalChars - rendererLifecycleResetCount += 1 - // Why release before clearing: pending bytes and credits belonged to the dead page; releasing producer pauses first keeps no shell wedged. - producerFlowControl.releaseAll() - clearDeliveryResyncProbe() - deliveryResyncUnansweredWarnLogged = false - for (const id of rendererDeliveryAccountingByPty.keys()) { - sshOutputIntake?.transferPtyProjections(id, 'renderer-lifecycle-reset') - } - rendererDeliveryAccountingByPty.clear() - rendererInFlightTotalChars = 0 - clearPendingPtyData() - pendingOverflowMarkedPtys.clear() - rendererDeliveryRestoreNeededPtys.clear() - // Why hold sends: the reloading page's pty:data listener is gone until it re-registers/handshakes, so bytes would drop into a listener-less page and re-pin the gate. - rendererPtyDispatcherReady = false - // Why: arm the self-heal watchdog so a never-arriving handshake can't hold the gate forever; the real handshake cancels it. - armDispatcherReadyWatchdog() - } - // Why the bridge: let a later re-registration cancel this closure's watchdog (armed via a hoisted fn, so this assignment can precede its definition). - clearRendererDispatcherReadyWatchdog = clearDispatcherReadyWatchdog - - function isLikelyInteractiveRedraw(data: string): boolean { - if (data.length <= INTERACTIVE_OUTPUT_MAX_CHARS) { - return true - } - // Why the ANSI check: Codex-style TUIs repaint >1 KB per keypress (latency-sensitive), while plain command output should stay on the throughput batch path. - return data.length <= INTERACTIVE_REDRAW_MAX_CHARS && data.includes('\x1b[') - } - - function shouldSendInteractiveOutputNow(id: string, data: string, now: number): boolean { - const lastInputAt = lastInputAtByPty.get(id) - if (lastInputAt === undefined || now - lastInputAt > INTERACTIVE_OUTPUT_WINDOW_MS) { - interactiveOutputCharsByPty.delete(id) - return false - } - if (!isLikelyInteractiveRedraw(data)) { - interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) - return false - } - const usedChars = interactiveOutputCharsByPty.get(id) ?? 0 - if (usedChars + data.length > INTERACTIVE_OUTPUT_BUDGET_CHARS) { - interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) - return false - } - interactiveOutputCharsByPty.set(id, usedChars + data.length) - return true - } - - function makePtyDataPayload( - id: string, - data: string, - startSeq: number | undefined, - containsBackgroundOutput: boolean | undefined, - rawLength = data.length, - transformed = false - ): PtyDataPayload { - const payload: PtyDataPayload = { id, data } - if (typeof startSeq === 'number') { - payload.seq = startSeq + rawLength - } - if (typeof startSeq === 'number' || rawLength !== data.length || transformed) { - payload.rawLength = rawLength - } - if (transformed) { - payload.transformed = true - } - if (containsBackgroundOutput === true) { - payload.background = true - } - return payload - } - - function getPtyPayloadCharCount(payload: { data: string; rawLength?: number }): number { - return Math.max(0, payload.rawLength ?? payload.data.length) - } - - function canSendPtyDataToRenderer(id: string, options: { interactive?: boolean } = {}): boolean { - const totalLimit = - PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS + - (options.interactive === true ? PTY_RENDERER_INTERACTIVE_RESERVE_CHARS : 0) - // Why per-PTY (not global) reserve: keep one active pane responsive without letting every background pane burst past the cap. - const ptyLimit = - PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS + - (options.interactive === true ? PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS : 0) - return getRendererInFlightCharsForPty(id) < ptyLimit && rendererInFlightTotalChars < totalLimit - } - - // Why max-merge cumulative totals: idempotent and reorder-tolerant — replayed/out-of-order ACKs can't double-credit and a lost ACK self-heals. Returns the newly acknowledged delta. - function applyCumulativeAck(id: string, processedChars: number): number { - const accounting = rendererDeliveryAccountingByPty.get(id) - if (!accounting) { - return 0 - } - // Clamped to sentChars so a corrupt payload cannot drive in-flight negative. - const nextAckedChars = Math.min( - accounting.sentChars, - Math.max(accounting.ackedChars, processedChars) - ) - const acknowledged = nextAckedChars - accounting.ackedChars - accounting.ackedChars = nextAckedChars - if (acknowledged > 0) { - accounting.lastAckAtMs = Date.now() - } - rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged) - if (acknowledged > 0) { - sshOutputIntake?.settleProjectionPrefix(id, acknowledged) - } - return acknowledged - } - - function schedulePendingDataAfterCreditReport(creditedAny: boolean): void { - if (creditedAny) { - pendingData.reactivateBlocked() - } - if (pendingData.size > 0 && !flushTimer) { - schedulePendingDataFlush(0) - } - } - - function clearDeliveryResyncProbe(): void { - deliveryResyncOutstandingRequestId = null - if (deliveryResyncTimer) { - clearTimeout(deliveryResyncTimer) - deliveryResyncTimer = null - } - } - - // Why: data for a fully gated PTY signals delivery may be stuck on lost ACKs (e.g. dropped across suspend); ask the renderer for authoritative totals instead of a wall-clock guess. - function requestDeliveryResyncForGatedPty(): void { - if (deliveryResyncOutstandingRequestId !== null || isRendererGone(mainWindow)) { - return - } - deliveryResyncRequestSerial += 1 - const requestId = deliveryResyncRequestSerial - deliveryResyncOutstandingRequestId = requestId - deliveryResyncTimer = setTimeout(() => { - if (deliveryResyncOutstandingRequestId !== requestId) { - return - } - clearDeliveryResyncProbe() - // Why no mutation on timeout: unanswered means dead IPC that only a reload cures; log once per silent streak to avoid spamming every probe. - if (deliveryResyncUnansweredWarnLogged) { - return - } - deliveryResyncUnansweredWarnLogged = true - console.warn('[pty] delivery resync probe unanswered — renderer IPC unresponsive', { - msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, - ...readCurrentPtyRendererDeliveryDebugSnapshot() - }) - }, PTY_DELIVERY_RESYNC_TIMEOUT_MS) - deliveryResyncTimer.unref?.() - sendToRenderer(mainWindow, 'pty:requestDeliveryResync', { requestId }) - } - - // Why write off: bytes sent but never received after a confirmed wedge are gone (no ACK can repay them); hand back restore markers so panes repaint from the snapshot. - function writeOffLostRendererDelivery( - report: PtyRendererDeliveryStateReport - ): PtyDeliveryWriteOff[] { - const writtenOff: PtyDeliveryWriteOff[] = [] - for (const [id, accounting] of rendererDeliveryAccountingByPty) { - if (accounting.sentChars - accounting.ackedChars <= 0) { - continue - } - const received = report.receivedCharsByPty?.[id] - const receivedChars = - typeof received === 'number' && Number.isFinite(received) ? Math.max(0, received) : 0 - // Why skip: received-but-unparsed bytes are alive in the renderer write queue; their deferred ACK still repays this debt. - if (receivedChars > accounting.ackedChars) { - continue - } - const acknowledged = applyCumulativeAck(id, accounting.sentChars) - if (acknowledged <= 0) { - continue - } - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) - // Why drop pending: everything at/before markerSeq comes from the snapshot, so flushing pre-marker bytes would double-paint the restore. - const pending = pendingData.get(id) - if (pending) { - if (pending.projectionAdmissionIds) { - sshOutputIntake?.transferProjections( - pending.projectionAdmissionIds, - 'renderer-delivery-writeoff' - ) - } - pendingDroppedChars += pending.data.length - deletePendingPtyData(id) - pendingOverflowMarkedPtys.delete(id) - updateProducerFlowControl(id) - } - const markerSeq = runtime?.getPtyOutputSequence(id) - writtenOff.push({ - id, - ...(typeof markerSeq === 'number' ? { markerSeq } : {}), - writtenOffChars: acknowledged - }) - } - if (writtenOff.length > 0) { - clearDeliveryResyncProbe() - deliveryResyncUnansweredWarnLogged = false - mainDeliveryBreadcrumbs.record('delivery-heal-writeoff', { - writtenOffPtyCount: writtenOff.length, - writtenOffChars: writtenOff.reduce((sum, { writtenOffChars }) => sum + writtenOffChars, 0) - }) - console.warn('[pty] delivery heal: wrote off renderer-bound bytes lost in push channel', { - rendererPtyDataListenerCount: report.rendererPtyDataListenerCount ?? null, - msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, - writtenOffByPty: writtenOff.map(({ id, writtenOffChars }) => ({ id, writtenOffChars })), - ...readCurrentPtyRendererDeliveryDebugSnapshot() - }) - } - return writtenOff - } - - function sendPtyDataToRenderer( - id: string, - payload: PtyDataPayload, - projectionAdmissionIds?: readonly string[] - ): { sent: boolean; projectionsTransferred: boolean } { - const charCount = getPtyPayloadCharCount(payload) - const accounting = rendererDeliveryAccountingByPty.get(id) - const hadAccounting = accounting !== undefined - if (accounting) { - accounting.sentChars += charCount - accounting.lastSendAtMs = Date.now() - } else { - rendererDeliveryAccountingByPty.set(id, { - sentChars: charCount, - ackedChars: 0, - lastSendAtMs: Date.now(), - lastAckAtMs: null - }) - } - rendererInFlightTotalChars += charCount - recordPtyRendererDeliveryPressure(id) - try { - sendToRenderer(mainWindow, 'pty:data', payload) - } catch (error) { - const current = rendererDeliveryAccountingByPty.get(id) - if (current) { - const inFlightBeforeRollback = current.sentChars - current.ackedChars - current.sentChars = Math.max(0, current.sentChars - charCount) - current.ackedChars = Math.min(current.ackedChars, current.sentChars) - const inFlightAfterRollback = current.sentChars - current.ackedChars - rendererInFlightTotalChars = Math.max( - 0, - rendererInFlightTotalChars - (inFlightBeforeRollback - inFlightAfterRollback) - ) - if (!hadAccounting && current.sentChars === 0) { - rendererDeliveryAccountingByPty.delete(id) - } - } - rendererDeliveryRestoreNeededPtys.add(id) - if (projectionAdmissionIds) { - sshOutputIntake?.transferProjections(projectionAdmissionIds, 'renderer-send-failed') - } - mainDeliveryBreadcrumbs.record('pty-data-send-failed', { - id: redactPtyIdForDiagnostics(id), - chars: charCount - }) - console.error('[pty] renderer data send failed; payload will not be retried', error) - return { sent: false, projectionsTransferred: projectionAdmissionIds !== undefined } - } - let projectionsTransferred = false - if (projectionAdmissionIds) { - try { - sshOutputIntake?.publishProjectionPrefix( - projectionAdmissionIds, - payload.data.length, - charCount - ) - } catch { - sshOutputIntake?.transferProjections(projectionAdmissionIds, 'projection-publish-failed') - projectionsTransferred = true - } - } - if (rendererDeliveryRestoreNeededPtys.has(id)) { - try { - sendModelRestoreNeededMarker(id, 'delivery-heal', runtime?.getPtyOutputSequence(id)) - rendererDeliveryRestoreNeededPtys.delete(id) - } catch (error) { - console.error( - '[pty] renderer delivery-heal marker send failed; restore remains pending', - error - ) - } - } - return { sent: true, projectionsTransferred } - } - - function rendererPtyIsKnownHidden(id: string): boolean { - return rendererVisibilityKnownPtys.has(id) && !visibleRendererPtys.has(id) - } - - function ptyHasHiddenRendererResizeOutput(id: string): boolean { - return ( - pendingHiddenRendererResizeOutputPtys.has(id) || - deliveredHiddenRendererResizeOutputPtys.has(id) - ) - } - - function markHiddenRendererResizeOutputDelivered(id: string): void { - if (!pendingHiddenRendererResizeOutputPtys.delete(id)) { - return - } - deliveredHiddenRendererResizeOutputPtys.add(id) - } - - function clearDeliveredHiddenRendererResizeOutput(id: string): void { - deliveredHiddenRendererResizeOutputPtys.delete(id) - } - - function clearHiddenRendererResizeOutput(id: string): void { - pendingHiddenRendererResizeOutputPtys.delete(id) - deliveredHiddenRendererResizeOutputPtys.delete(id) - } - - // Why out-of-band (not pty:data): an in-band empty chunk is indistinguishable from one fully consumed by renderer-side OSC-9999 stripping, which spuriously restored visible panes. - function sendModelRestoreNeededMarker( - id: string, - reason: PtyModelRestoreReason, - markerSeq: number | undefined - ): void { - if (isRendererGone(mainWindow)) { - return - } - sendToRenderer(mainWindow, 'pty:modelRestoreNeeded', { - id, - reason, - ...(typeof markerSeq === 'number' ? { markerSeq } : {}) - }) - } - - const pendingDataDropWarnedPtys = new Set() - - // Why capped: keeps O(1) memory per PTY; salvaged query bytes are tiny, so past the cap a pathological stream can degrade to the plain sentinel. - const DROPPED_QUERY_SALVAGE_MAX_CHARS = 4096 - - // Why carve out queries: a bulk drop must not swallow reply-eliciting probes (DSR/CPR, DA1/DA2, DECRQM, OSC 10/11) the program blocks on; snapshot heals content so replies can't double-fire. - function extractDroppedPtyQueryBytes(data: string): string { - if (!data.includes('\x1b')) { - return '' - } - const extracted = extractHiddenStartupRendererQueryData(data, '') - return extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData - } - - function scanDroppedMode2031Data( - data: string, - previous: Mode2031ReplyScanState - ): { data: string; state: Mode2031ReplyScanState } { - const result = scanMode2031ReplyDecision(previous, data) - const decisionData = - result.decision === 'subscribed' - ? '\x1b[?2031h' - : result.decision === 'unsubscribed' - ? '\x1b[?2031l' - : '' - return { data: decisionData, state: result.state } - } - - function getDroppedMode2031RendererData(pending: PendingPtyData): string { - const state = pending.droppedMode2031ScanState - if (!state) { - return pending.droppedMode2031Data ?? '' - } - const pendingSubscribe = state.pendingSubscribe ? '\x1b[?2031h' : '' - return (pending.droppedMode2031Data ?? '') + pendingSubscribe + state.tail - } - - function dropOversizedPendingPtyData(id: string, pending: PendingPtyData): PendingPtyData { - const capChars = pendingDataCapChars() - if (pending.droppedOutput === true || pending.data.length <= capChars) { - return pending - } - if (!pendingDataDropWarnedPtys.has(id)) { - pendingDataDropWarnedPtys.add(id) - console.error( - `[pty] dropped ${pending.data.length} buffered chars for ${id}: renderer not receiving and per-PTY pending cap exceeded; pane will restore from the main-owned snapshot` - ) - // Why: field visibility for cap tuning (issue #2836 / #7017); no pty id since session ids can embed workspace paths. - recordCrashBreadcrumb('terminal_pending_output_dropped', { - droppedChars: pending.data.length, - capChars - }) - } - if (isHiddenPtyDeliveryGateEnabled(getSettings?.()) && !pendingOverflowMarkedPtys.has(id)) { - pendingOverflowMarkedPtys.add(id) - } - pendingDroppedChars += pending.data.length - if (pending.projectionAdmissionIds) { - sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'pending-cap') - } - const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE) - // Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along). - return { - data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS), - droppedOutput: true, - droppedMode2031Data: mode2031.data, - droppedMode2031ScanState: mode2031.state - } - } - - function updatePendingProjectionAdmissions( - pending: PendingPtyData, - state: PendingProjectionAdmissions - ): void { - delete pending.projectionAdmissionIds - delete pending.projectionAdmissionsTransferred - if (state.projectionAdmissionIds) { - pending.projectionAdmissionIds = state.projectionAdmissionIds - } - if (state.projectionAdmissionsTransferred) { - pending.projectionAdmissionsTransferred = true - } - } - - function compactPendingProjectionState( - pending: PendingProjectionAdmissions, - projectionSemanticsId?: string - ): PendingProjectionAdmissions { - const options = pendingProjectionAdmissionOptions() - const compacted = compactPendingProjectionAdmissions(pending, options) - return projectionSemanticsId - ? appendPendingProjectionAdmission(compacted, projectionSemanticsId, options) - : compacted - } - - function pendingProjectionAdmissionOptions() { - return { - isPending: (id: string) => sshOutputIntake?.hasUnpublishedProjection(id) ?? false, - transfer: (ids: readonly string[], reason: string) => - sshOutputIntake?.transferProjections(ids, reason) - } - } - - function appendPendingPtyData( - id: string, - existing: PendingPtyData | undefined, - data: string, - startSeq: number | undefined, - preservesSeq: boolean, - containsBackgroundOutput: boolean, - rawLength = data.length, - transformed = false, - projectionSemanticsId?: string - ): PendingPtyData { - // Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode. - if (existing?.droppedOutput === true) { - if (projectionSemanticsId) { - sshOutputIntake?.transferProjections([projectionSemanticsId], 'pending-cap') - } - const mode2031 = scanDroppedMode2031Data( - data, - existing.droppedMode2031ScanState ?? INITIAL_MODE_2031_REPLY_SCAN_STATE - ) - const remainingQueryCapacity = Math.max( - 0, - DROPPED_QUERY_SALVAGE_MAX_CHARS - existing.data.length - ) - const salvaged = extractDroppedPtyQueryBytes(data).slice(0, remainingQueryCapacity) - return { - ...existing, - data: existing.data + salvaged, - droppedMode2031Data: mode2031.data || existing.droppedMode2031Data, - droppedMode2031ScanState: mode2031.state - } - } - const projectionState = compactPendingProjectionState(existing ?? {}, projectionSemanticsId) - const nextContainsBackgroundOutput = - existing?.containsBackgroundOutput === true || containsBackgroundOutput - if (!existing) { - const pending: PendingPtyData = { - data, - ...(typeof startSeq === 'number' ? { startSeq } : {}), - ...(rawLength !== data.length ? { rawLength } : {}), - ...(transformed ? { transformed: true } : {}), - ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) - } - updatePendingProjectionAdmissions(pending, projectionState) - return dropOversizedPendingPtyData(id, pending) - } - const existingRawLength = existing.rawLength ?? existing.data.length - const next: PendingPtyData = { - data: existing.data + data, - ...(!preservesSeq || existing.transformed || transformed - ? { rawLength: existingRawLength + rawLength, transformed: true as const } - : {}), - ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) - } - updatePendingProjectionAdmissions(next, projectionState) - if (typeof existing.startSeq === 'number') { - next.startSeq = existing.startSeq - } - return dropOversizedPendingPtyData(id, next) - } - - function schedulePendingDataFlush(delayMs: number): void { - if (flushTimer) { - return - } - flushTimer = setTimeout(flushPendingData, delayMs) - } - - function invalidatePendingPtyDrainClassification(id?: string, schedule = true): void { - const invalidated = - typeof id === 'string' ? pendingData.invalidate(id) : pendingData.invalidateAll() - if (invalidated && schedule && !flushTimer) { - schedulePendingDataFlush(0) - } - } - invalidatePendingPtyDrainPriority = invalidatePendingPtyDrainClassification - invalidatePendingPtyDrainPolicy = invalidatePendingPtyDrainClassification - - function clearDispatcherReadyWatchdog(): void { - if (dispatcherReadyWatchdogTimer) { - clearTimeout(dispatcherReadyWatchdogTimer) - dispatcherReadyWatchdogTimer = null - } - } - - function armDispatcherReadyWatchdog(): void { - clearDispatcherReadyWatchdog() - if (isRendererGone(mainWindow)) { - return - } - // Why: one-shot self-heal — force the gate open if the reloaded page never signals ready, so a dropped handshake can't hold it forever. Unref'd so it can't keep the process alive. - dispatcherReadyWatchdogTimer = setTimeout(() => { - dispatcherReadyWatchdogTimer = null - if (rendererPtyDispatcherReady || isRendererGone(mainWindow)) { - return - } - rendererPtyDispatcherReady = true - rendererDispatcherReadyForcedCount += 1 - pendingData.reactivateBlocked() - schedulePendingDataFlush(0) - }, PTY_DISPATCHER_READY_WATCHDOG_MS) - dispatcherReadyWatchdogTimer.unref?.() - } - - function flushPendingData(): void { - flushTimer = null - if (isRendererGone(mainWindow)) { - // Why release now: bookkeeping is being wiped, so no future drain can resume these producers — local shells would wedge. - producerFlowControl.releaseAll() - clearDeliveryResyncProbe() - clearPendingPtyData() - pendingOverflowMarkedPtys.clear() - rendererDeliveryAccountingByPty.clear() - rendererInFlightTotalChars = 0 - clearDispatcherReadyWatchdog() - return - } - // Ordinary boot-window data is blocked in the queue; hidden-droppable entries still retire before renderer readiness. - const settings = getSettings?.() - let writes = 0 - let sendFailed = false - const round = pendingData.beginRound() - let creditReleasedDuringFlush = false - pendingDataFlushActive = true - pendingDataCreditReleasedDuringFlush = false - try { - while (writes < PTY_BATCH_FLUSH_MAX_WRITES) { - const selection = pendingData.takeNext(round) - if (!selection) { - break - } - const { id, pending } = selection - // Why drop, never re-queue: the model already ingested hidden-gated bytes; reveal restores from the snapshot+seq machinery. - if (shouldDropHiddenRendererPtyData(id, settings)) { - pendingData.remove(selection) - pendingOverflowMarkedPtys.delete(id) - updateProducerFlowControl(id) - const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length) - if (pending.projectionAdmissionIds) { - sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'hidden-drop') - } - warnIfDroppingHiddenBytesForVisiblePty(id, pending.data.length) - if (drop.shouldEmitRestoreMarker) { - sendModelRestoreNeededMarker(id, 'hidden-drop', runtime?.getPtyOutputSequence(id)) - } - continue - } - if (!canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) { - pendingData.block(selection) - continue - } - if (pending.droppedOutput === true) { - pendingData.remove(selection) - updateProducerFlowControl(id) - // Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only). - if ( - !sendPtyDataToRenderer( - id, - { - id, - data: pending.data + getDroppedMode2031RendererData(pending), - droppedOutput: true - }, - pending.projectionAdmissionIds - ).sent - ) { - sendFailed = true - break - } - writes++ - continue - } - const { data } = pending - const indivisible = pending.transformed === true - const chunk = indivisible ? data : data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS) - const remaining = indivisible ? '' : data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS) - let nextPending: PendingPtyData | undefined - if (remaining) { - nextPending = { data: remaining } - if (typeof pending.startSeq === 'number') { - nextPending.startSeq = pending.startSeq + chunk.length - } - if (pending.containsBackgroundOutput === true) { - nextPending.containsBackgroundOutput = true - } - if (pending.projectionAdmissionIds) { - nextPending.projectionAdmissionIds = pending.projectionAdmissionIds - } - if (pending.projectionAdmissionsTransferred) { - nextPending.projectionAdmissionsTransferred = true - } - pendingData.replaceWithRemainder(selection, nextPending) - } else { - pendingData.remove(selection) - pendingOverflowMarkedPtys.delete(id) - } - updateProducerFlowControl(id) - const delivery = sendPtyDataToRenderer( - id, - makePtyDataPayload( - id, - chunk, - pending.startSeq, - pending.containsBackgroundOutput, - pending.rawLength, - pending.transformed - ), - pending.projectionAdmissionIds - ) - if (nextPending) { - updatePendingProjectionAdmissions( - nextPending, - propagatePendingProjectionRemainder( - nextPending, - delivery, - pendingProjectionAdmissionOptions() - ) - ) - } - if (!delivery.sent) { - sendFailed = true - break - } - writes++ - } - } finally { - pendingDataFlushActive = false - creditReleasedDuringFlush = pendingDataCreditReleasedDuringFlush - pendingDataCreditReleasedDuringFlush = false - pendingData.endRound(round) - } - if (rendererPtyDispatcherReady && pendingData.size > 0 && writes === 0 && !sendFailed) { - ackGatedFlushSkipCount++ - } - if (sendFailed && pendingData.size > 0) { - if (flushTimer) { - clearTimeout(flushTimer) - flushTimer = null - } - schedulePendingDataFlush(PTY_BATCH_DRAIN_CONTINUE_MS) - return - } - if (pendingData.size > 0 && (writes > 0 || creditReleasedDuringFlush)) { - // Why yield between slices: a background terminal can dump megabytes at once, and keystroke writes must not stall behind one flush. - schedulePendingDataFlush(writes > 0 ? PTY_BATCH_DRAIN_CONTINUE_MS : 0) - } - } - - const clearFlushTimerIfIdle = (): void => { - if (pendingData.size > 0 || flushTimer === null) { - return - } - clearTimeout(flushTimer) - flushTimer = null - } - - const syntheticKillExitPtyIds = new Map() - const reversibleStopOwnersByPtyId = new Map() - - function rememberSyntheticKillExit(id: string): void { - const existing = syntheticKillExitPtyIds.get(id) - if (existing) { - clearTimeout(existing) - } - // Why a timed window: providers may report the real exit after kill completes; skip only that late duplicate, not a future reused id forever. - const cleanupTimer = setTimeout(() => { - syntheticKillExitPtyIds.delete(id) - }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) - cleanupTimer.unref?.() - syntheticKillExitPtyIds.set(id, cleanupTimer) - } - - // Why: a rejected split retires its PTY while kill's shutdown is still in flight; kill's late - // synthetic exit must not land afterwards, or an SSH pane's code -1 would re-preserve the - // surface the retirement just removed. Timed like the duplicate window so a reused id is free. - const retiredRejectedPtyIds = new Map() - - function rememberRetiredRejectedPty(id: string): void { - const existing = retiredRejectedPtyIds.get(id) - if (existing) { - clearTimeout(existing) - } - const cleanupTimer = setTimeout(() => { - retiredRejectedPtyIds.delete(id) - }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) - cleanupTimer.unref?.() - retiredRejectedPtyIds.set(id, cleanupTimer) - } - - function consumeSyntheticKillExit(id: string): boolean { - const cleanupTimer = syntheticKillExitPtyIds.get(id) - if (!cleanupTimer) { - return false - } - clearTimeout(cleanupTimer) - syntheticKillExitPtyIds.delete(id) - return true - } - - function preparePtyExitForRenderer(payload: { id: string; code: number }): (() => void) | null { - if (isRendererGone(mainWindow)) { - sshOutputIntake?.transferPtyProjections(payload.id, 'renderer-destroyed') - return () => {} - } - if (rendererExitingPtyIds.has(payload.id)) { - return null - } - rendererExitingPtyIds.add(payload.id) - let released = false - const release = (): void => { - if (released) { - return - } - released = true - rendererExitingPtyIds.delete(payload.id) - } - try { - if (!rendererCreditBeforeExitByPty.has(payload.id)) { - rendererCreditBeforeExitByPty.set( - payload.id, - getRendererInFlightCharsForPty(payload.id) > 0 - ) - } - // Why flush before exit: the renderer tears down the terminal on pty:exit, so any batched output not yet flushed would be silently lost. - const remaining = pendingData.delete(payload.id) - clearFlushTimerIfIdle() - if (remaining) { - if (remaining.droppedOutput === true) { - // Sentinel entry: only salvaged query bytes remain; keep the flag so the renderer knows the span was dropped. - sendPtyDataToRenderer( - payload.id, - { - id: payload.id, - data: remaining.data, - droppedOutput: true - }, - remaining.projectionAdmissionIds - ) - } else { - sendPtyDataToRenderer( - payload.id, - makePtyDataPayload( - payload.id, - remaining.data, - remaining.startSeq, - remaining.containsBackgroundOutput, - remaining.rawLength, - remaining.transformed - ), - remaining.projectionAdmissionIds - ) - } - } - return release - } catch (error) { - release() - throw error - } - } - - function finalizePtyExitForRenderer(payload: { id: string; code: number }): void { - if (isRendererGone(mainWindow)) { - rendererCreditBeforeExitByPty.delete(payload.id) - return - } - const hadReleasableRendererCredit = - rendererCreditBeforeExitByPty.get(payload.id) ?? - getRendererInFlightCharsForPty(payload.id) > 0 - rendererCreditBeforeExitByPty.delete(payload.id) - // Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id. - producerFlowControl.release(payload.id) - sourceCreditPendingPtys.delete(payload.id) - pendingOverflowMarkedPtys.delete(payload.id) - rendererDeliveryRestoreNeededPtys.delete(payload.id) - lastInputAtByPty.delete(payload.id) - interactiveOutputCharsByPty.delete(payload.id) - const releasedRendererCredit = getRendererInFlightCharsForPty(payload.id) - rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - releasedRendererCredit) - // Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides. - rendererDeliveryAccountingByPty.delete(payload.id) - if (hadReleasableRendererCredit) { - if (pendingDataFlushActive) { - // Why: let the open round coalesce this wake into its one post-round continuation. - const reactivatedBlocked = pendingData.reactivateBlocked() - pendingDataCreditReleasedDuringFlush ||= reactivatedBlocked - } else { - schedulePendingDataAfterCreditReport(true) - } - } - sendToRenderer(mainWindow, 'pty:exit', { - ...payload, - ...(reversibleStopOwnersByPtyId.has(payload.id) ? { preserveRendererBinding: true } : {}) - }) - } - - function sendPtyExitToRenderer(payload: { id: string; code: number }): void { - options?.onPtyExit?.(payload.id, ++ptyLifecycleSequence) - const release = preparePtyExitForRenderer(payload) - if (!release) { - return - } - try { - sshOutputIntake?.transferPtyProjections(payload.id, 'legacy-pty-exit') - finalizePtyExitForRenderer(payload) - } finally { - release() - } - } - - function sendPtySpawnedToRenderer(id: string): void { - if (!isRendererGone(mainWindow)) { - sendToRenderer(mainWindow, 'pty:spawned', { id }) - } - } - - function acceptPtyDataForRenderer( - payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - }, - outputSeq: number | undefined, - projection?: LegacySshProjectionSemantics - ): void { - const rawLength = payload.sequenceChars ?? payload.data.length - const preservesSeq = !payload.transformed && rawLength === payload.data.length - const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined - const projectionId = projection?.identity.projectionSemanticsId - if (isRendererGone(mainWindow)) { - if (projectionId) { - sshOutputIntake?.transferProjections([projectionId], 'renderer-destroyed') - } - if (flushTimer) { - clearTimeout(flushTimer) - flushTimer = null - } - producerFlowControl.releaseAll() - clearDeliveryResyncProbe() - clearPendingPtyData() - pendingOverflowMarkedPtys.clear() - rendererDeliveryAccountingByPty.clear() - rendererInFlightTotalChars = 0 - clearDispatcherReadyWatchdog() - return - } - if (rendererExitingPtyIds.has(payload.id)) { - if (projectionId) { - sshOutputIntake?.transferProjections([projectionId], 'pty-exiting') - } - return - } - if (shouldDropHiddenRendererPtyData(payload.id, getSettings?.())) { - if (projectionId) { - sshOutputIntake?.transferProjections([projectionId], 'hidden-drop') - } - const droppedChars = projection ? rawLength : payload.data.length - const drop = recordHiddenRendererPtyDataDrop(payload.id, droppedChars) - warnIfDroppingHiddenBytesForVisiblePty(payload.id, droppedChars) - if (drop.shouldEmitRestoreMarker) { - sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq) - } - return - } - if (payload.data.length === 0 && !payload.transformed) { - if (projectionId) { - sshOutputIntake?.transferProjections([projectionId], 'empty-projection') - } - return - } - const containsBackgroundOutput = - rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id) - if (containsBackgroundOutput) { - markHiddenRendererResizeOutputDelivered(payload.id) - } - const overflowMarkedBeforeAppend = pendingOverflowMarkedPtys.has(payload.id) - if (projection?.desktopSpan) { - sourceCreditPendingPtys.add(payload.id) - } - const pending = appendPendingPtyData( - payload.id, - pendingData.get(payload.id), - payload.data, - startSeq, - preservesSeq, - containsBackgroundOutput, - rawLength, - payload.transformed === true, - projectionId - ) - const shouldEmitPendingCapRestoreMarker = - pending.droppedOutput === true && - !overflowMarkedBeforeAppend && - pendingOverflowMarkedPtys.has(payload.id) - const nextData = pending.data + getDroppedMode2031RendererData(pending) - const isInteractiveOutput = shouldSendInteractiveOutputNow( - payload.id, - nextData, - performance.now() - ) - if (isInteractiveOutput && rendererPtyDispatcherReady) { - if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) { - setPendingPtyData(payload.id, pending) - if (shouldEmitPendingCapRestoreMarker) { - sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq) - } - updateProducerFlowControl(payload.id) - requestDeliveryResyncForGatedPty() - return - } - deletePendingPtyData(payload.id) - clearFlushTimerIfIdle() - if (shouldEmitPendingCapRestoreMarker) { - sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq) - } - pendingOverflowMarkedPtys.delete(payload.id) - try { - sendPtyDataToRenderer( - payload.id, - { - id: payload.id, - data: nextData, - ...(typeof pending.startSeq === 'number' - ? { - seq: pending.startSeq + (pending.rawLength ?? nextData.length), - rawLength: pending.rawLength ?? nextData.length - } - : {}), - ...(pending.transformed ? { transformed: true } : {}), - ...(pending.containsBackgroundOutput === true ? { background: true } : {}), - ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) - }, - pending.projectionAdmissionIds - ) - } finally { - updateProducerFlowControl(payload.id) - } - return - } - setPendingPtyData(payload.id, pending) - if (shouldEmitPendingCapRestoreMarker) { - sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq) - } - updateProducerFlowControl(payload.id) - if ( - !canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) }) - ) { - requestDeliveryResyncForGatedPty() - } - if (!flushTimer) { - schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) - } - } - - sshOutputIntakeCleanup?.() - sshOutputIntake = new SshPtyOutputIntake({ - getModelSequence: (id) => runtime?.getPtyOutputSequence(id) ?? 0, - acceptModel: (event, projection) => { - if (!runtime) { - throw new Error('SSH PTY output requires the main terminal model') - } - return runtime.acceptPtyDataBounded( - event.id, - event.data, - Date.now(), - event.rawLength, - event.transformed, - projection.desktopSpan ? [projection.desktopSpan] : undefined - ) - }, - project: (event, projection) => - acceptPtyDataForRenderer( - { - id: event.id, - data: event.data, - sequenceChars: event.rawLength, - transformed: event.transformed - }, - projection.identity.sequenceEnd, - projection - ), - prepareExit: (event) => { - const release = preparePtyExitForRenderer(event) - if (!release) { - throw new Error('pty_renderer_exit_in_progress') - } - return release - }, - finalizeExit: (event) => { - runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation, { - hostExitConfirmed: true - }) - finalizePtyExitForRenderer(event) - }, - pauseProvider: (generation, id) => { - const provider = sshProvidersByGeneration.get(generation) as - | (IPtyProvider & { hasPtyDeliveryPauseAdapter?: () => boolean }) - | undefined - if (!provider?.hasPtyDeliveryPauseAdapter?.()) { - return false - } - provider.pauseProducer?.(id) - return true - }, - resumeProvider: (generation, id) => - sshProvidersByGeneration.get(generation)?.resumeProducer?.(id), - closeProvider: (generation, reason) => { - const provider = sshProvidersByGeneration.get(generation) - ;( - provider as (IPtyProvider & { closeOutputIntake?: (reason: string) => void }) | undefined - )?.closeOutputIntake?.(reason) - }, - resetModelForMigration: (_generation, id) => runtime?.resetPtyModelAfterMigrationFailure(id), - onGenerationClosed: (providerGeneration) => { - for (const id of pendingData.keys()) { - const pending = pendingData.get(id) - if ( - pending?.projectionAdmissionIds && - sshOutputIntake?.hasProjectionFromGeneration( - pending.projectionAdmissionIds, - providerGeneration - ) - ) { - pendingData.delete(id) - updateProducerFlowControl(id) - pendingOverflowMarkedPtys.delete(id) - } - } - sshProvidersByGeneration.delete(providerGeneration) - }, - publishSourceAck: publishSshPtySourceAck, - cancelSourceDelivery: cancelSshPtySourceDelivery - }) - runtime?.setRemoteTerminalSourceRangeConsumerHooks?.( - sshOutputIntake.getRemoteSourceRangeConsumerHooks() - ) - const cleanupSshOutputIntakeRegistry = installSshPtyOutputIntake(sshOutputIntake) - sshOutputIntakeCleanup = () => { - runtime?.setRemoteTerminalSourceRangeConsumerHooks?.(null) - cleanupSshOutputIntakeRegistry() - } - - async function shutdownProviderAndDetectExit( - provider: IPtyProvider, - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise { - let providerExitObserved = false - const expectedIncarnationId = ptyIncarnationById.get(id) - const unsubscribe = provider.onExit((payload) => { - if ( - payload.id === id && - (!expectedIncarnationId || payload.incarnationId === expectedIncarnationId) - ) { - providerExitObserved = true - } - }) - try { - await provider.shutdown(id, opts) - } finally { - unsubscribe() - } - return providerExitObserved - } - - // Why extracted: the "Restart daemon" flow rebinds against the fresh adapter after replaceDaemonProvider, sharing this code path with startup registration. - const bindProviderListeners = (): void => { - localDataUnsub?.() - localExitUnsub?.() - localBackgroundStreamUnsub?.() - localWriteUnavailableUnsub?.() - - // Why: a daemon death takes down every session at once. The provider signals - // each affected pane here so background panes remount + re-attach too, not - // just the pane whose write happened to detect the dead endpoint (STA-2373). - localWriteUnavailableUnsub = - localProvider.onWriteUnavailable?.((payload) => { - if (isRendererGone(mainWindow)) { - return - } - sendToRenderer(mainWindow, 'pty:writeUnavailable', { id: payload.id }) - }) ?? null - - // Daemon keep-tail thinning facts, in byte order with onData: markers flip transient-fact scan authority; a gap forces renderer restore from the snapshot. - localBackgroundStreamUnsub = - localProvider.onBackgroundStreamEvent?.((payload) => { - if (payload.kind === 'backgroundMarker') { - runtime?.setPtyTransientFactDelegation( - payload.id, - payload.background, - payload.scanSeedAnsi, - payload.mode2031PendingSubscribe - ) - return - } - if (payload.kind === 'dataGap') { - providerSnapshotRequiredPtys.add(payload.id) - runtime?.notePtyDataGap(payload.id, payload.sequenceChars ?? payload.droppedChars) - sendModelRestoreNeededMarker( - payload.id, - 'hidden-drop', - runtime?.getPtyOutputSequence(payload.id) - ) - return - } - runtime?.emitDaemonPtyTransientFact(payload.id, payload.fact) - }) ?? null - - // Why: daemon providers lack configure().onData, so feed the runtime here or their tail buffer (terminal.read, agent-detection, mobile stream) stays empty. - const isLocalProvider = localProvider instanceof LocalPtyProvider - - localDataUnsub = localProvider.onData((payload) => { - const rawLength = payload.sequenceChars ?? payload.data.length - const outputSeq = isLocalProvider - ? runtime?.getPtyOutputSequence(payload.id) - : runtime?.onPtyData(payload.id, payload.data, Date.now(), rawLength, payload.transformed) - acceptPtyDataForRenderer(payload, outputSeq) - }) - localExitUnsub = localProvider.onExit((payload) => { - if (!isCurrentPtyExit(payload)) { - return - } - if (consumeSyntheticKillExit(payload.id)) { - return - } - if (!isLocalProvider) { - clearProviderPtyState(payload.id) - ptyOwnership.delete(payload.id) - markClaudePtyExited(payload.id) - // Why: same physical-exit certificate as the LocalPtyProvider branch above — this is the - // daemon/WSL callback, and on macOS it is the branch that actually runs. - runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId, { - providerExitObserved: true, - ...(payload.cause ? { cause: payload.cause } : {}) - }) - } - // Why not the whole payload: the exit cause is a main-process fact for the - // runtime's records; the renderer's pty:exit contract stays as it was. - sendPtyExitToRenderer({ - id: payload.id, - code: payload.code, - ...(payload.incarnationId ? { incarnationId: payload.incarnationId } : {}) - }) - }) - } - - bindProviderListeners() - rebindProviderListeners = bindProviderListeners - - // Why: one persistent listener with a request-ID dispatch table instead of one per call, so concurrent serialize requests don't trip Node's MaxListeners=10 warning. - type SerializeResult = { - data: string - cols: number - rows: number - seq?: number - lastTitle?: string - kittyKeyboardFlags?: number - } | null - const pendingSerializeRequests = new Map< - string, - { resolve: (result: SerializeResult) => void; timeout: NodeJS.Timeout } - >() - - function settleSerializeRequest(requestId: string, result: SerializeResult): void { - const pending = pendingSerializeRequests.get(requestId) - if (!pending) { - return - } - clearTimeout(pending.timeout) - pendingSerializeRequests.delete(requestId) - pending.resolve(result) - } - - ipc.on( - 'pty:serializeBuffer:response', - ( - _event, - args: { - requestId?: string - snapshot?: { - data?: unknown - cols?: unknown - rows?: unknown - seq?: unknown - lastTitle?: unknown - kittyKeyboardFlags?: unknown - } | null - } - ) => { - if (typeof args?.requestId !== 'string') { - return - } - const snapshot = args.snapshot - if ( - snapshot && - typeof snapshot.data === 'string' && - typeof snapshot.cols === 'number' && - typeof snapshot.rows === 'number' - ) { - const result: { - data: string - cols: number - rows: number - seq?: number - lastTitle?: string - kittyKeyboardFlags?: number - } = { - data: snapshot.data, - cols: snapshot.cols, - rows: snapshot.rows - } - if (typeof snapshot.seq === 'number' && Number.isFinite(snapshot.seq)) { - result.seq = snapshot.seq - } - if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) { - result.lastTitle = snapshot.lastTitle - } - // Why gated on seq: without a boundary the flags cannot be reconciled - // against live bytes, so they prove nothing. - const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.kittyKeyboardFlags) - if (result.seq !== undefined && kittyKeyboardFlags !== undefined) { - result.kittyKeyboardFlags = kittyKeyboardFlags - } - settleSerializeRequest(args.requestId, result) - } else { - settleSerializeRequest(args.requestId, null) - } - } - ) - - function requestSerializedBuffer( - ptyId: string, - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - ): Promise { - if (isRendererGone(mainWindow)) { - return Promise.resolve(null) - } - - const requestId = randomUUID() - return new Promise((resolve) => { - const timeout = setTimeout(() => { - settleSerializeRequest(requestId, null) - }, 750) - pendingSerializeRequests.set(requestId, { resolve, timeout }) - const payload: { - requestId: string - ptyId: string - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - } = { requestId, ptyId } - if (opts) { - payload.opts = opts - } - sendToRenderer(mainWindow, 'pty:serializeBuffer:request', payload) - }) - } - - // Why: reload/crash orphans delivery-interest holds and hidden marks; reset so surviving PTYs aren't stuck force-fed or gated — each pane's first sync re-marks. - clearRendererGateResetHandlers() - const resetRendererPtyDeliveryGateState = (): void => { - const gateDebug = getHiddenRendererPtyDeliveryDebug() - resetRendererScopedHiddenPtyDeliveryState() - if (gateDebug.hiddenDeliveryGatedPtyCount > 0 || gateDebug.deliveryInterestPtyCount > 0) { - invalidatePendingPtyDrainPolicy() - } - // Why: the daemon pacer must not keep throttling ptys whose hidden marks died with the renderer; the fresh renderer's sync re-marks the still-hidden ones. - resyncBackgroundedDeliveriesAfterGateReset() - } - rendererGateResetLoadHandler = resetRendererPtyDeliveryGateState - rendererGateResetGoneHandler = resetRendererPtyDeliveryGateState - // Why guarded: with no renderer there is nothing to reset a gate for, and nothing - // that will ever emit these. Registering against a fake window was the old workaround. - if (rendererContents) { - rendererGateResetWebContents = rendererContents - rendererContents.on('did-finish-load', rendererGateResetLoadHandler) - rendererContents.on('render-process-gone', rendererGateResetGoneHandler) - } - - // Why: only LocalPtyProvider PTYs (main-process) can be orphaned on reload; daemon sessions survive by design and cleanup would kill them. - clearDidFinishLoadHandler() - if (localProvider instanceof LocalPtyProvider) { - const lp = localProvider - didFinishLoadHandler = () => { - // Why: always advance to keep the generation monotonic, but skip the sweep on crash/freeze-recovery reload — it would kill live local PTYs before session restore (#5787). - const generation = lp.advanceGeneration() - if (rendererContents && options?.isRecoveryReloadInFlight?.(rendererContents.id)) { - return - } - // Why: the retained provider onExit callback is the only physical-exit proof; it clears ownership after the OS reaps it. - lp.killOrphanedPtys(generation - 1) - } - if (rendererContents) { - didFinishLoadWebContents = rendererContents - rendererContents.on('did-finish-load', didFinishLoadHandler) - } - } - - const assertFolderWorkspacePtyPathUsable = async ( - worktreeId: string | undefined - ): Promise => { - const workspaceScope = typeof worktreeId === 'string' ? parseWorkspaceKey(worktreeId) : null - if (!store || workspaceScope?.type !== 'folder') { - return - } - const status = await getFolderWorkspacePathStatus( - store, - { scope: 'folder-workspace', folderWorkspaceId: workspaceScope.folderWorkspaceId }, - { getSshFilesystemProvider } - ) - assertFolderWorkspacePathUsable(status) - } - - const resolvePtySpawnStartupCwd = ( - worktreeId: string | undefined, - cwd: string | undefined, - missingDirFallback?: TerminalStartupCwdMissingDirFallback - ): string | undefined => - resolveTerminalStartupCwdForWorkspace({ - workspaceId: worktreeId, - requestedCwd: cwd, - missingDirFallback, - resolveFolderWorkspacePath: (folderWorkspaceId) => - store?.getFolderWorkspace(folderWorkspaceId)?.folderPath - }) - - const localStartupCwdDirectoryExists = (path: string): boolean => { - // Why: Win32 statSync on \\wsl.localhost 9P shares can falsely report ENOENT; defer to the provider's WSL-aware validation. - if (isWslUncPath(path)) { - return true - } - try { - return statSync(path).isDirectory() - } catch { - return false - } - } - - const prepareCodexResumeHome = (args: { - connectionId?: string | null - launchAgent?: TuiAgent - providerSession?: AgentProviderSessionMetadata - target: CodexAccountSelectionTarget - launchEnv?: NodeJS.ProcessEnv - workspacePath?: string - }): { - providerSession: AgentProviderSessionMetadata - preparation: Promise - } | null => { - if (args.connectionId || args.launchAgent !== 'codex' || !options?.prepareCodexSessionResume) { - return null - } - const providerSession = normalizeAgentProviderSession(args.providerSession) - if (!providerSession) { - return null - } - return { - providerSession, - preparation: options.prepareCodexSessionResume({ - providerSession, - target: args.target, - launchEnv: args.launchEnv, - workspacePath: args.workspacePath - }) - } - } - - type CodexResumeLaunch = { - codexResumeHome: Extract | null - command: string | undefined - notifyResumeUnavailable: boolean - droppedResumeArgv: boolean - providerSession: AgentProviderSessionMetadata | null - } - - /** Kept separate from resolveCodexResumeLaunch so non-Codex spawns never await: - * an extra tick reorders the pane-spawn reservation races this handler arbitrates. */ - const noCodexResumeLaunch = (command: string | undefined): CodexResumeLaunch => ({ - codexResumeHome: null, - command, - notifyResumeUnavailable: false, - droppedResumeArgv: false, - providerSession: null - }) - - /** The command a Codex launch actually runs: unchanged when provenance is verified, - * stripped of `resume ` when it is not. */ - const resolveCodexResumeLaunch = ( - command: string | undefined, - preparation: NonNullable> - ): Promise => - preparation.preparation.then((prepared) => { - const providerSession = preparation.providerSession - if (prepared?.outcome !== 'fresh') { - return { - codexResumeHome: prepared ?? null, - command, - notifyResumeUnavailable: false, - droppedResumeArgv: false, - providerSession - } - } - const dropped = dropUnverifiedCodexResumeArgv({ - command, - providerSession, - claimedCodexProvenance: prepared.claimedCodexProvenance - }) - return { - codexResumeHome: null, - command: dropped.command, - // Why: staying silent only makes sense for metadata that positively belongs to - // another agent; a resume with no transcript path at all still owes the user a notice. - notifyResumeUnavailable: - dropped.droppedResumeArgv && - (prepared.claimedCodexProvenance || !providerSession.transcriptPath), - droppedResumeArgv: dropped.droppedResumeArgv, - providerSession - } - }) - - const reconcileSharedRuntimeResumeHome = ( - resumeHome: Extract, - resolveCurrentHome: () => string | null - ): string => { - if (!resumeHome.reconcileSharedRuntimeAuth) { - return resumeHome.codexHomePath - } - const currentHome = resolveCurrentHome() - if (!codexHomePathsEqual(currentHome, resumeHome.codexHomePath)) { - throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) - } - return resumeHome.codexHomePath - } - - /** Why: buildPtyHostEnv prefers ORCA_SEQUENCED_STARTUP_COMMAND over the launch command - * and the sequenced wrapper `eval`s it, so a dropped resume argv has to go there too. */ - const stripSequencedStartupResumeArgv = | undefined>( - env: T, - launch: CodexResumeLaunch - ): T => { - const sequenced = env?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV] - if (!env || !sequenced || !launch.droppedResumeArgv || !launch.providerSession) { - return env - } - const drop = dropAgentResumeArgvFromCommand({ - command: sequenced, - agent: 'codex', - providerSession: launch.providerSession - }) - return drop.status === 'dropped' - ? { ...env, [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: drop.command } - : env - } - - const adoptStablePane = async (args: { - cols: number - rows: number - cwd?: string - connectionId?: string | null - worktreeId: string - preAllocatedHandle?: string - tabId: string - leafId: string - ownsPaneSpawnReservation?: true - }) => { - const paneKey = makePaneKey(args.tabId, args.leafId) - const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) - const pendingAdoption = ownerKey ? stablePaneAdoptionsByOwnerKey.get(ownerKey) : undefined - if (pendingAdoption) { - return await pendingAdoption - } - const activePaneSpawn = - ownerKey && !args.ownsPaneSpawnReservation - ? paneSpawnReservationsByOwnerKey.get(ownerKey) - : undefined - if (activePaneSpawn) { - const result = await activePaneSpawn.promise - const owner = resolveStablePaneOwner( - runtime, - store, - paneKey, - args.worktreeId, - args.connectionId - ) - if ( - !owner || - owner.ptyId !== result.id || - (owner.incarnationId !== undefined && - result.incarnationId !== undefined && - owner.incarnationId !== result.incarnationId) - ) { - throw new Error('terminal_pane_owner_changed') - } - return { - result: { - ...result, - isReattach: true, - ...(owner.incarnationId ? { incarnationId: owner.incarnationId } : {}) - }, - owner, - materialized: true as const - } - } - const owner = resolveStablePaneOwner( - runtime, - store, - paneKey, - args.worktreeId, - args.connectionId - ) - if (!owner) { - return null - } - const adoption = attachStablePaneOwner({ - runtime, - store, - provider: getProvider(args.connectionId), - spawnOptions: { - cols: args.cols, - rows: args.rows, - cwd: args.cwd - }, - owner, - worktreeId: args.worktreeId, - connectionId: args.connectionId, - resolveOwner: () => - resolveStablePaneOwner(runtime, store, paneKey, args.worktreeId, args.connectionId) - }) - if (!ownerKey) { - return await adoption - } - stablePaneAdoptionsByOwnerKey.set(ownerKey, adoption) - try { - return await adoption - } finally { - if (stablePaneAdoptionsByOwnerKey.get(ownerKey) === adoption) { - stablePaneAdoptionsByOwnerKey.delete(ownerKey) - } - } - } - - const markSshInventoryUnverifiable = (connectionId: string, error: unknown): void => { - const reason = error instanceof Error ? error.message : String(error) - for (const [ptyId, ownerConnectionId] of ptyOwnership) { - if (ownerConnectionId === connectionId) { - runtime?.markPtyLivenessUnverifiable?.(ptyId, reason) - } - } - } - - // Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them. - runtime?.setPtyController({ - claimStablePaneCreate: (args) => { - const paneKey = makePaneKey(args.tabId, args.leafId) - const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) - return ownerKey ? claimRuntimePaneCreate(ownerKey) : () => {} - }, - adoptStablePane, - spawn: async (args) => { - const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined - const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined - const preAdoptedStablePane = args.adoptedStablePane ?? null - const reattachedCodexHomeRoutes = !args.connectionId - ? snapshotCodexPaneHomeRoutes([ - preAdoptedStablePane?.result.id, - args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined - ]) - : new Map() - const startupPromise = getLocalPtyStartupPromise(args.connectionId) - if (startupPromise) { - await startupPromise - } - if (preAdoptedStablePane?.materialized) { - const handle = preAdoptedStablePane.owner.handle ?? args.preAllocatedHandle - if (!handle) { - throw new Error('terminal_pane_owner_unknown') - } - const result = { - id: preAdoptedStablePane.result.id, - ...(preAdoptedStablePane.result.incarnationId - ? { incarnationId: preAdoptedStablePane.result.incarnationId } - : {}), - ...(typeof preAdoptedStablePane.result.wslDistro === 'string' - ? { wslDistro: preAdoptedStablePane.result.wslDistro } - : {}), - stablePaneOwner: { - handle, - tabId: preAdoptedStablePane.owner.tabId, - leafId: preAdoptedStablePane.owner.leafId - } - } - ensureWslHookRelayForReattach( - { isReattach: true, wslDistro: preAdoptedStablePane.result.wslDistro }, - args.connectionId - ) - if (!args.connectionId) { - options?.onCodexHomePtySpawned?.({ - id: result.id, - codexHomePath: null, - reattached: true, - startedAt: codexHomeLaunchStartedAt, - startedSequence: codexHomeLaunchStartedSequence, - ...codexReattachedHomeRouteField(reattachedCodexHomeRoutes, result.id, true) - }) - } - return result - } - if (!preAdoptedStablePane) { - await assertFolderWorkspacePtyPathUsable(args.worktreeId) - } - const cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) - const provider = getProvider(args.connectionId) - const freshSpawnRecovery = preAdoptedStablePane - ? undefined - : recoverFreshSpawnProviderRouting( - provider, - args.connectionId, - args.sessionId, - args.isNewSession - ) - if (freshSpawnRecovery) { - await freshSpawnRecovery - } - const isClaudeLaunch = - !preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) - if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') - } - // Why: runtime-created terminals carry no renderer-computed projectRuntime; resolve from worktreeId to honor the project's Windows runtime. - const terminalRuntimeOptions = - process.platform === 'win32' && !args.connectionId - ? resolveLocalWindowsTerminalRuntimeOptions({ - requestedShellOverride: undefined, - settings: getSettings?.(), - projectRuntime: resolveLocalProjectRuntimeForWorktreeId(store, args.worktreeId), - fallbackHostShell: process.env.COMSPEC || 'powershell.exe' - }) - : { shellOverride: undefined, terminalWindowsWslDistro: null } - const daemonShellOverride = terminalRuntimeOptions.shellOverride - const isDaemonHostSpawn = - !args.connectionId && - !(provider instanceof LocalPtyProvider) && - !routesFreshSpawnsToLocalProvider(provider) - const callerRequestedSessionId = args.sessionId?.trim() - const requestedSessionId = - callerRequestedSessionId ?? - (isDaemonHostSpawn && args.agentSessionCreateOperationId - ? ptySessionIdForAgentCreateOperation(args.worktreeId, args.agentSessionCreateOperationId) - : undefined) - const sessionId = - requestedSessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) - const effectiveSessionRelayId = - sessionId !== undefined ? getRelayPtyId(args.connectionId, sessionId) : undefined - const effectiveSessionAppId = - sessionId !== undefined ? getAppPtyId(args.connectionId, sessionId) : undefined - const isNewDaemonSession = - !preAdoptedStablePane && - isDaemonHostSpawn && - (callerRequestedSessionId === undefined || args.isNewSession === true) - const expectedWslDistro = !args.connectionId - ? (resolveWslSessionContext({ - cwd, - sessionId, - shellOverride: terminalRuntimeOptions.shellOverride, - terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro - })?.distro ?? null) - : null - const codexSelectionTarget = getCodexSelectionTargetForPty( - daemonShellOverride, - cwd, - expectedWslDistro - ) - const codexResumePreparation = preAdoptedStablePane - ? null - : prepareCodexResumeHome({ - connectionId: args.connectionId, - launchAgent: args.launchAgent, - providerSession: args.resumeProviderSession, - target: codexSelectionTarget, - launchEnv: args.env, - workspacePath: cwd - }) - const codexResumeLaunch = codexResumePreparation - ? await resolveCodexResumeLaunch(args.command, codexResumePreparation) - : noCodexResumeLaunch(preAdoptedStablePane ? undefined : args.command) - const codexResumeHome = codexResumeLaunch.codexResumeHome - // Why: the drop still applies here, but this controller's result has no field for - // notifyResumeUnavailable — runtime/relay panes start fresh without the notice. - const launchCommand = codexResumeLaunch.command - const claudeAuth = - isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(codexSelectionTarget) : null - if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') - } - if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { - throw new Error( - 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' - ) - } - - const shouldPersistHostSessionBinding = args.persistHostSessionBinding === true - let hostSessionBinding: { - store: NonNullable - worktreeId: string - tabId: string - leafId: string - expectedSourceBinding?: PtyBindingSourceExpectation - } | null = null - if (shouldPersistHostSessionBinding) { - if ( - !store || - typeof args.worktreeId !== 'string' || - typeof args.tabId !== 'string' || - !isValidTerminalTabId(args.tabId) || - typeof args.leafId !== 'string' || - !isTerminalLeafId(args.leafId) - ) { - throw new Error( - 'Cannot persist runtime PTY binding without worktreeId, tabId, and leafId' - ) - } - hostSessionBinding = { - store, - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: args.leafId, - ...(args.expectedSourceBinding - ? { expectedSourceBinding: args.expectedSourceBinding } - : {}) - } - } - const sshScopedEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env) - let env: Record | undefined = claudeAuth - ? { ...sshScopedEnv, ...claudeAuth.envPatch } - : sshScopedEnv - const requestedAgentTeamsPath = env?.ORCA_AGENT_TEAMS_TEAM_ID - ? env[resolvePathEnvKey(env, process.platform)] - : undefined - env = stripSequencedStartupResumeArgv(env, codexResumeLaunch) - if (args.preAllocatedHandle) { - env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } - } - let selectedCodexHomePath = resolveSelectedCodexHomeOrRefuseSpawn(() => - !preAdoptedStablePane && !args.connectionId - ? getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - codexResumeHome - ? reconcileSharedRuntimeResumeHome(codexResumeHome, () => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null - ) - ) - : (getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null) - ) - : null - ) - if ( - !preAdoptedStablePane && - args.launchAgent === 'codex' && - callerRequestedSessionId === undefined - ) { - const resolution = resolveCodexHomeAfterManagedAuthReadiness({ - selectedCodexHomePath, - getSettings: () => getSettings?.(), - requiredCodexHomePath: codexResumeHome?.codexHomePath, - target: codexSelectionTarget, - resolveCurrent: () => - resolveSelectedCodexHomeOrRefuseSpawn(() => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: 'codex' - }) ?? null - ) - ), - resolveAfterUnavailable: (unavailableManagedHomePath) => - resolveSelectedCodexHomeOrRefuseSpawn(() => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: 'codex', - unavailableManagedHomePath - }) ?? null - ) - ) - }) - selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution - } - if (args.launchAgent === 'codex' && selectedCodexHomePath) { - await ensureCodexStateDbBackfillRecoveryStarted(selectedCodexHomePath) - } - const codexResumeHomeSelected = Boolean( - codexResumeHome && codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath) - ) - const skipCodexHomeEnv = - isDaemonHostSpawn && - shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) && - !selectedCodexHomePath - const ptySettings = isDaemonHostSpawn ? getSettings?.() : undefined - const stripInheritedOrcaCodexHome = - isDaemonHostSpawn && - shouldStripInheritedOrcaCodexHome({ - target: codexSelectionTarget, - selectedCodexHomePath, - skipCodexHomeEnv, - settings: ptySettings - }) - if (isDaemonHostSpawn && sessionId && !preAdoptedStablePane) { - if (!isSafePtySessionId(sessionId, getAppEnvironment().getPath('userData'))) { - throw new Error('Invalid PTY session id') - } - env = buildPtyHostEnv(sessionId, env ?? {}, { - isPackaged: getAppEnvironment().isPackaged(), - resourcesPath: process.resourcesPath, - userDataPath: getAppEnvironment().getPath('userData'), - selectedCodexHomePath, - skipCodexHomeEnv, - stripInheritedOrcaCodexHome, - launchCommand, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, - isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd), - wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null, - agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), - codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), - networkProxySettings: ptySettings, - deferGitConfigGuardToDaemon: provider.supportsGitCredentialGuardHost?.(sessionId) === true - }) - stampWslOrchestrationCompatibilityHost( - env, - runtime?.getOrchestrationCompatibilityHostId?.(), - codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null - ) - promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) - } - - const authEnvToDelete = claudeAuth?.stripAuthEnv - ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] - : undefined - const spawnOptions: PtySpawnOptions = { - cols: args.cols, - rows: args.rows, - cwd, - env, - ...(isNewDaemonSession ? { isNewSession: true } : {}), - historyIsolationEnabled: getSettings?.()?.terminalScopeHistoryByWorktree ?? true - } - if (!args.connectionId && !isDaemonHostSpawn) { - spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath } - } - const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) - if (startupTerminalColorQueryReplyColors) { - spawnOptions.startupIngress = { - colors: startupTerminalColorQueryReplyColors, - deadlineMs: 5_000 - } - } - let ptySpawnCommitReported = false - const reportPtySpawnCommitted = (): void => { - if (ptySpawnCommitReported) { - return - } - ptySpawnCommitReported = true - args.onPtySpawnCommitted?.() - } - spawnOptions.envToDelete = mergePtyEnvDeletions( - authEnvToDelete, - args.envToDelete ?? [], - // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. - isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], - isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [], - // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. - getInheritedClaudeSessionStampEnvKeysToDelete(env) - ) - if (skipCodexHomeEnv) { - spawnOptions.envToDelete = mergePtyEnvDeletions( - spawnOptions.envToDelete, - CODEX_HOME_ENV_KEYS - ) - } else if (stripInheritedOrcaCodexHome) { - // Why: the daemon owns a persistent inherited environment that may - // differ from main. ORCA_CODEX_HOME asks it to compare/delete the pair. - spawnOptions.envToDelete = mergePtyEnvDeletions(spawnOptions.envToDelete, [ - 'ORCA_CODEX_HOME' - ]) - } - if (codexResumeHomeSelected) { - spawnOptions.envToDelete = removeCodexHomeDeletionRequests(spawnOptions.envToDelete) - } - deleteRequestedEnvKeys(env, spawnOptions.envToDelete) - promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) - if (launchCommand !== undefined) { - spawnOptions.command = launchCommand - } - if (args.commandDelivery !== undefined) { - spawnOptions.commandDelivery = args.commandDelivery - } - if (args.startupCommandDelivery !== undefined) { - spawnOptions.startupCommandDelivery = args.startupCommandDelivery - } - if (isTuiAgent(args.launchAgent)) { - spawnOptions.launchAgent = args.launchAgent - } - if (args.worktreeId !== undefined) { - spawnOptions.worktreeId = args.worktreeId - } - const hadSessionSizeBeforeAttach = - effectiveSessionAppId !== undefined ? ptySizes.has(effectiveSessionAppId) : false - const sessionSizeBeforeAttach = - effectiveSessionAppId !== undefined ? ptySizes.get(effectiveSessionAppId) : undefined - if (sessionId !== undefined) { - spawnOptions.sessionId = sessionId - ptySizes.set(effectiveSessionAppId ?? sessionId, { cols: args.cols, rows: args.rows }) - } - const materializedPaneKey = hostSessionBinding - ? makePaneKey(hostSessionBinding.tabId, hostSessionBinding.leafId) - : null - const metadataLeafId = - typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null - const metadataPaneKey = - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - metadataLeafId - ? makePaneKey(args.tabId, metadataLeafId) - : null - const spawnIdentityPaneKey = materializedPaneKey ?? metadataPaneKey - if (spawnIdentityPaneKey) { - spawnOptions.paneKey = spawnIdentityPaneKey - } - if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) { - spawnOptions.tabId = args.tabId - } - if (process.platform === 'win32' && !args.connectionId) { - spawnOptions.shellOverride = terminalRuntimeOptions.shellOverride - spawnOptions.terminalWindowsWslDistro = expectedWslDistro - spawnOptions.terminalWindowsPowerShellImplementation = getSettings - ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') - : undefined - } - if ( - !preAdoptedStablePane && - args.agentSessionEnsure && - (await (provider as IPtyProvider).supportsAgentSessionClaims?.()) === false - ) { - // Why: runtime routing must select legacy before dispatch; never downgrade here after it began. - throw new Error('agent_session_claim_unavailable') - } - if ( - !preAdoptedStablePane && - args.agentSessionCreateOperationId && - (await (provider as IPtyProvider).supportsAgentSessionCreateOperations?.()) === false - ) { - throw new Error('execution_owner_unavailable') - } - if (!preAdoptedStablePane && args.agentSessionEnsure) { - spawnOptions.agentSessionEnsure = args.agentSessionEnsure - } - if (!preAdoptedStablePane && args.agentSessionCreateOperationId) { - spawnOptions.agentSessionCreateOperationId = args.agentSessionCreateOperationId - } - if (args.signal) { - spawnOptions.signal = args.signal - } - if ( - args.onPtySpawnCommitted && - (provider instanceof LocalPtyProvider || routesFreshSpawnsToLocalProvider(provider)) - ) { - // Why: local fallback has no lower operation ledger, so commit must be reported at native spawn. - spawnOptions.onPtySpawnCommitted = reportPtySpawnCommitted - } - - const paneSpawnReservationKey = makePaneSpawnReservationKey( - args.worktreeId, - args.connectionId, - spawnIdentityPaneKey - ) - const existingPaneSpawn = paneSpawnReservationKey - ? paneSpawnReservationsByOwnerKey.get(paneSpawnReservationKey) - : undefined - if (existingPaneSpawn) { - const concurrentResult = await existingPaneSpawn.promise - const concurrentOwner = resolveStablePaneOwner( - runtime, - store, - spawnIdentityPaneKey, - args.worktreeId, - args.connectionId - ) - if ( - !concurrentOwner?.handle || - concurrentOwner.ptyId !== concurrentResult.id || - (concurrentOwner.incarnationId !== undefined && - concurrentResult.incarnationId !== undefined && - concurrentOwner.incarnationId !== concurrentResult.incarnationId) - ) { - throw new Error('terminal_pane_owner_unknown') - } - return { - id: concurrentOwner.ptyId, - ...(concurrentOwner.incarnationId - ? { incarnationId: concurrentOwner.incarnationId } - : {}), - stablePaneOwner: { - handle: concurrentOwner.handle, - tabId: concurrentOwner.tabId, - leafId: concurrentOwner.leafId - } - } - } - const finishTerminalInstall = beginPtySpawnForWorktree( - args.worktreeId, - cwd, - args.connectionId - ) - const paneSpawnReservation = paneSpawnReservationKey - ? reservePaneSpawn(paneSpawnReservationKey) - : null - let result: PtySpawnResult - let stablePaneOwner: StablePaneOwner | null = null - let stablePaneBindingPersisted = false - let rejectedRegistrationCandidate: PtySpawnResult | null = null - let pendingRegistrationPtyId: string | null = null - // Why hoisted to the reply scope: main reconciles the provider sequence - // deep inside the spawn path, but the pane needs that renderer-domain - // boundary beside the daemon snapshot's kitty flags. - let reconciledSnapshotSeq: number | null = null - // False when bytes crossed the data socket during the spawn RPC: the - // reconciled boundary covers them, but the daemon proved its kitty flags - // before they existed, so the claim must not erase what the pane may - // have scanned from those bytes live. - let snapshotKittyFlagsCoverReconciledSeq = true - let preparedProvisionalExecutionContext = false - let releaseWorktreeSpawn: (() => void) | undefined - try { - releaseWorktreeSpawn = await runtime?.acquireWorktreeTerminalSpawn?.(args.worktreeId) - try { - if (args.preAllocatedHandle) { - trustedTerminalHandleEnv.add(args.preAllocatedHandle) - } - const stablePaneOwnerCandidate = preAdoptedStablePane - ? preAdoptedStablePane.owner - : args.agentSessionEnsure - ? null - : resolveStablePaneOwner( - runtime, - store, - spawnIdentityPaneKey, - args.worktreeId, - args.connectionId - ) - const expectedPtyId = - stablePaneOwnerCandidate?.ptyId ?? effectiveSessionAppId ?? sessionId - if (expectedPtyId) { - runtime?.beginPtyRegistration?.(expectedPtyId) - pendingRegistrationPtyId = expectedPtyId - } - if (isDaemonHostSpawn && expectedPtyId) { - preparedProvisionalExecutionContext = - runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, { - resetIncarnation: isNewDaemonSession && !stablePaneOwnerCandidate, - preserveExisting: !isNewDaemonSession || Boolean(stablePaneOwnerCandidate) - }) ?? false - } - const sequenceBeforeProviderSpawn = expectedPtyId - ? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) - : 0 - const assertClientStillConnected = (): void => { - if (args.signal?.aborted) { - throw new Error('client_disconnected') - } - } - if (args.agentSessionEnsure && !preAdoptedStablePane) { - // Why: daemon-backed claims can outlive this controller; import all - // proven owners before deciding that an identity is absent. - await reconcileAgentSessionOwnerListings() - const recoveredOwner = agentSessionOwners.find(args.agentSessionEnsure.claim) - if (recoveredOwner && pendingRegistrationPtyId !== recoveredOwner.ptyId) { - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId) - } - runtime?.beginPtyRegistration?.( - recoveredOwner.ptyId, - ptyIncarnationById.get(recoveredOwner.ptyId) - ) - pendingRegistrationPtyId = recoveredOwner.ptyId - } - let providerResult: PtySpawnResult | null = null - const ensured = await agentSessionOwners.ensure({ - claim: args.agentSessionEnsure.claim, - surface: args.agentSessionEnsure.surface, - spawn: async () => { - assertClientStillConnected() - providerResult = await provider.spawn(spawnOptions) - rejectedRegistrationCandidate = providerResult - // Why: a successful lower-owner return proves physical work committed even if admission sees an early exit. - reportPtySpawnCommitted() - assertSpawnReplyWasLive(providerResult) - runtime?.assertPtyRegistrationAllowed?.( - providerResult.id, - providerResult.incarnationId - ) - if (providerResult.incarnationId) { - // Why: local providers cannot serialize controller claims, so liveness proof - // needs the exact incarnation before the registry promotes the new owner. - ptyIncarnationById.set(providerResult.id, providerResult.incarnationId) - } - const providerEnsure = providerResult.agentSessionEnsure - return { - ptyId: providerResult.id, - ...(providerEnsure - ? { - owner: providerEnsure.owner, - disposition: providerEnsure.disposition - } - : {}) - } - }, - isLive: async (owner) => { - const ownerProvider = tryGetProviderForAgentSessionOwner(owner.ptyId) - if (!ownerProvider) { - // Why: a disconnected relay may keep its PTY alive during the - // grace window; missing transport is unknown, never absence. - throw new Error('execution_owner_unavailable') - } - return await isProviderAgentSessionOwnerLive(ownerProvider, owner) - } - }) - result = providerResult ?? { - id: ensured.owner.ptyId, - isReattach: true, - // Why: adoption from an authoritative listing must preserve the - // incarnation proof used to reject a delayed exit from an older process. - incarnationId: ptyIncarnationById.get(ensured.owner.ptyId) - } - result.agentSessionEnsure = ensured - } else { - assertClientStillConnected() - const stablePaneSpawn = preAdoptedStablePane - ? preAdoptedStablePane - : await spawnForStablePane({ - runtime, - store, - provider, - spawnOptions, - owner: stablePaneOwnerCandidate, - worktreeId: args.worktreeId, - connectionId: args.connectionId, - resolveOwner: () => - resolveStablePaneOwner( - runtime, - store, - spawnIdentityPaneKey, - args.worktreeId, - args.connectionId - ), - onFreshSpawn: reportPtySpawnCommitted - }) - result = stablePaneSpawn.result - stablePaneOwner = stablePaneSpawn.owner - if ( - stablePaneOwner && - isNewDaemonSession && - effectiveSessionAppId && - effectiveSessionAppId !== result.id - ) { - clearProviderPtyState(effectiveSessionAppId) - } - rejectedRegistrationCandidate = result - assertSpawnReplyWasLive(result) - } - rejectedRegistrationCandidate ??= result - if (pendingRegistrationPtyId !== result.id) { - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId) - } - runtime?.beginPtyRegistration?.(result.id, result.incarnationId) - pendingRegistrationPtyId = result.id - } - // Why: admission precedes sequence/context state and every durable publication below. - runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId) - if (result.providerSequence) { - const runtimeSequenceBeforeReconcile = runtime?.getPtyOutputSequence?.(result.id) ?? 0 - // Why kept: this is the reattach boundary in the RENDERER's sequence - // domain, and the daemon snapshot's kitty flags mean nothing without - // the boundary they were proven at. - reconciledSnapshotSeq = - runtime?.synchronizePtyOutputSequenceFromProvider?.( - result.id, - result.providerSequence, - sequenceBeforeProviderSpawn - ) ?? null - if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) { - snapshotKittyFlagsCoverReconciledSeq = false - } - } - ensureWslHookRelayForReattach(result, args.connectionId) - runtime?.preparePtyExecutionContext?.( - result.id, - args.connectionId - ? null - : result.wslDistro === undefined - ? expectedWslDistro - : result.wslDistro - ) - } catch (err) { - if ( - (isNewDaemonSession || preparedProvisionalExecutionContext) && - effectiveSessionAppId - ) { - runtime?.preparePtyExecutionContext?.(effectiveSessionAppId, null, { - resetIncarnation: true - }) - } - const rawMessage = err instanceof Error ? err.message : String(err) - if (rawMessage === 'agent_session_exited_during_start' && rejectedRegistrationCandidate) { - runtime?.releaseRejectedPtyRegistrationFence?.( - rejectedRegistrationCandidate.id, - rejectedRegistrationCandidate.incarnationId - ) - } - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.( - pendingRegistrationPtyId, - rejectedRegistrationCandidate?.incarnationId - ) - pendingRegistrationPtyId = null - } - const spawnError = normalizeNodePtySpawnError(err) - const isIdentityMismatch = - isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage) - if (effectiveSessionAppId !== undefined) { - if (isIdentityMismatch && hadSessionSizeBeforeAttach && sessionSizeBeforeAttach) { - ptySizes.set(effectiveSessionAppId, sessionSizeBeforeAttach) - } else { - ptySizes.delete(effectiveSessionAppId) - } - } - if ( - args.connectionId && - effectiveSessionRelayId !== undefined && - (spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) || - rawMessage.includes(SSH_SESSION_EXPIRED_ERROR)) - ) { - if (effectiveSessionAppId !== undefined && !isIdentityMismatch) { - clearProviderPtyState(effectiveSessionAppId) - deletePtyOwnership(effectiveSessionAppId) - } - if (!isIdentityMismatch) { - store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired') - } - } - if (isNewDaemonSession && sessionId !== undefined) { - clearProviderPtyState(sessionId) - } - throw spawnError - } finally { - if (args.preAllocatedHandle) { - trustedTerminalHandleEnv.delete(args.preAllocatedHandle) - } - } - try { - stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ - store: hostSessionBinding?.store, - owner: stablePaneOwner, - result, - worktreeId: hostSessionBinding?.worktreeId, - startupCwd: cwd, - connectionId: args.connectionId - }) - } catch (error) { - if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { - throw error - } - console.error('[pty] failed to persist runtime PTY binding after attach:', error) - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { - agentSessionOperationOutcome: 'unknown' as const - }) - } - if (result.agentSessionEnsure?.disposition === 'adopted') { - const owner = result.agentSessionEnsure.owner - ptyOwnership.set(result.id, args.connectionId ?? ptyOwnership.get(result.id) ?? null) - runtime?.registerPreAllocatedHandleForPty(result.id, owner.surface.terminalHandle) - if (result.incarnationId) { - ptyIncarnationById.set(result.id, result.incarnationId) - } - runtime?.registerPty(result.id, owner.surface.worktreeId, args.connectionId ?? null, { - tabId: owner.surface.tabId, - leafId: owner.surface.leafId, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) - }) - if (!args.connectionId) { - options?.onCodexHomePtySpawned?.({ - id: result.id, - codexHomePath: selectedCodexHomePath, - reattached: true, - startedAt: codexHomeLaunchStartedAt, - startedSequence: codexHomeLaunchStartedSequence, - ...codexReattachedHomeRouteField(reattachedCodexHomeRoutes, result.id, true), - ...(env ? { launchEnv: env } : {}) - }) - } - return { - id: result.id, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - agentSessionEnsure: result.agentSessionEnsure - } - } - ptyOwnership.set(result.id, args.connectionId ?? null) - if (result.incarnationId) { - ptyIncarnationById.set(result.id, result.incarnationId) - } - // Why: record the native-Windows-local-PTY determination before any byte reaches the emulator, so its ConPTY DA1 override exists from byte zero. - if ( - isNativeWindowsLocalPtySpawn({ - connectionId: args.connectionId, - cwd: args.cwd, - shellOverride: daemonShellOverride - }) - ) { - markNativeWindowsConptyPty(result.id) - } - const relayResultId = getRelayPtyId(args.connectionId, result.id) - const persistSshLease = (): void => { - if (!store || !args.connectionId) { - return - } - // Why: SSH leases keep relay ids for remote reconciliation, while session bindings keep app-facing ids for hydration. - store.upsertSshRemotePtyLease({ - targetId: args.connectionId, - ptyId: relayResultId, - ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), - ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), - ...(typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) - ? { leafId: args.leafId } - : {}), - state: 'attached', - lastAttachedAt: Date.now() - }) - } - if (!hostSessionBinding) { - persistSshLease() - } - ptySizes.set(result.id, { cols: args.cols, rows: args.rows }) - if (effectiveSessionAppId !== undefined && effectiveSessionAppId !== result.id) { - ptySizes.delete(effectiveSessionAppId) - } - recordCodexPaneAccountForSpawn({ - ptyId: result.id, - isDaemonHostSpawn, - isReattach: result.isReattach === true, - pinnedByResume: codexResumeHomeSelected, - launchCodexHomePath: selectedCodexHomePath, - launchEnv: args.env, - target: codexSelectionTarget, - settings: getSettings?.() - }) - if (hostSessionBinding && !stablePaneBindingPersisted) { - try { - const binding = { - worktreeId: hostSessionBinding.worktreeId, - tabId: hostSessionBinding.tabId, - leafId: hostSessionBinding.leafId, - ptyId: result.id, - hostAdmittedMembership: true, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(cwd ? { startupCwd: cwd } : {}), - ...(hostSessionBinding.expectedSourceBinding - ? { expectedSourceBinding: hostSessionBinding.expectedSourceBinding } - : {}) - } - const persisted = args.connectionId - ? hostSessionBinding.store.persistPtyBinding( - binding, - toSshExecutionHostId(args.connectionId) - ) - : hostSessionBinding.store.persistPtyBinding(binding) - if (persisted === false) { - throw new Error('terminal_split_source_not_found') - } - } catch (err) { - console.error('[pty] failed to persist runtime PTY binding after spawn:', err) - if (!result.isReattach) { - deletePtyOwnership(result.id) - try { - await provider.shutdown(result.id, { immediate: true }) - } catch (shutdownErr) { - console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) - } - clearProviderPtyState(result.id) - } - if (err instanceof Error && err.message === 'terminal_split_source_not_found') { - throw err - } - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { - agentSessionOperationOutcome: 'unknown' as const - }) - } - persistSshLease() - } - if (args.preAllocatedHandle && !stablePaneOwner?.handle) { - runtime?.registerPreAllocatedHandleForPty(result.id, args.preAllocatedHandle) - } - if (args.worktreeId) { - runtime?.registerPty( - result.id, - args.worktreeId, - args.connectionId ?? null, - // Why: thread validated pane identity so main can back a pending mobile create even if graph-sync stalls (#7587). - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - metadataLeafId !== null - ? { - tabId: args.tabId, - leafId: metadataLeafId, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) - } - : undefined, - !args.connectionId - ? shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) - : undefined - ) - } else { - // Why: non-worktree PTYs have no later surface-registration phase to clear admission intent. - runtime?.cancelPendingPtyRegistration?.(result.id, result.incarnationId) - } - // Why: runtime-controller creates (headless serve, CLI, splits) adopt surviving daemon sessions too; without this seed their records stay blank. - seedTerminalRestoreRecordsFromSpawnResult(runtime, result) - // Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity). - if (!stablePaneOwner) { - runtime?.noteTerminalSpawnCommand?.(result.id, launchCommand ?? null) - } - if (isClaudeLaunch && !stablePaneOwner) { - markClaudePtySpawned(result.id) - } - if (args.telemetry && !stablePaneOwner) { - const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) - const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) - const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) - if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { - track('agent_started', { - agent_kind: agentKindParse.data, - launch_source: launchSourceParse.data, - request_kind: requestKindParse.data, - ...getCohortAtEmit() - }) - } - } - // Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; record paneKey here too since hook titles and cache cleanup need this reverse lookup. - const paneKey = rememberPaneKeyForPty(result.id, env?.ORCA_PANE_KEY) - const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined - const inheritRendererReadiness = - result.isReattach === true && - !pendingSerializer && - rendererSerializerReadiness.has(result.id) - rendererSerializerReadiness.beginIncarnation(result.id, inheritRendererReadiness) - if (paneKey && pendingSerializer) { - pendingPtyIdBySerializerGeneration.set(pendingSerializer.gen, result.id) - } - if (!args.connectionId) { - registerPty({ - ptyId: result.id, - worktreeId: args.worktreeId ?? null, - sessionId: sessionId ?? null, - paneKey, - pid: - typeof result.pid === 'number' && Number.isFinite(result.pid) && result.pid > 0 - ? result.pid - : null - }) - } - // Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal. - sendPtySpawnedToRenderer(result.id) - if (!args.connectionId) { - options?.onCodexHomePtySpawned?.({ - id: result.id, - codexHomePath: selectedCodexHomePath, - startedAt: codexHomeLaunchStartedAt, - startedSequence: codexHomeLaunchStartedSequence, - ...codexReattachedHomeRouteField( - reattachedCodexHomeRoutes, - result.id, - result.isReattach === true - ), - ...(result.isReattach === true ? { reattached: true } : env ? { launchEnv: env } : {}) - }) - } - const response = { - id: result.id, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(stablePaneOwner && (stablePaneOwner.handle || args.preAllocatedHandle) - ? { - stablePaneOwner: { - handle: stablePaneOwner.handle ?? args.preAllocatedHandle!, - tabId: stablePaneOwner.tabId, - leafId: stablePaneOwner.leafId - } - } - : {}), - ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) - } - resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, { - ...result, - ...(typeof result.snapshotKittyKeyboardFlags === 'number' && - reconciledSnapshotSeq !== null && - snapshotKittyFlagsCoverReconciledSeq - ? { snapshotSeq: reconciledSnapshotSeq } - : { snapshotKittyKeyboardFlags: undefined }), - isReattach: true - }) - return response - } catch (err) { - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.( - pendingRegistrationPtyId, - rejectedRegistrationCandidate?.incarnationId - ) - pendingRegistrationPtyId = null - } - // Why: once the reservation is created, any later throw — spawn - // failure, persist failure, or a post-spawn helper such as - // registerPty/rememberPaneKeyForPty/track — must settle it. Otherwise - // it lingers in paneSpawnReservationsByOwnerKey and every future spawn - // for this pane awaits a promise that never resolves. reject is a - // no-op once the reservation has already resolved. - rejectPaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, err) - throw err - } finally { - releaseWorktreeSpawn?.() - finishTerminalInstall() - } - }, - write: (ptyId, data) => { - try { - return getProviderForPty(ptyId).write(ptyId, data) !== false - } catch { - return false - } - }, - writeWithSettlement: async (ptyId, data) => { - try { - const provider = getProviderForPty(ptyId) - return provider.writeWithSettlement - ? await provider.writeWithSettlement(ptyId, data) - : provider.write(ptyId, data) !== false - } catch { - return false - } - }, - probePtyLiveness: async (ptyId) => { - try { - // Why: no locally routed provider can authoritatively answer for a - // remote host's PTY, so remote-scoped ids stay unknown, never absent. - if (ptyId.startsWith('remote:')) { - return null - } - const connectionId = ptyOwnership.get(ptyId) ?? parseAppSshPtyId(ptyId)?.connectionId - // Why: during cold start the daemon swap is in flight; the pre-swap - // fallback would answer absent for every daemon-owned id. - const startupPromise = getLocalPtyProviderStartupPromise(connectionId) - if (startupPromise) { - await startupPromise - } - const provider = getProviderForPty(ptyId) - if (provider.probePtyLiveness) { - return await provider.probePtyLiveness(ptyId) - } - // Why: the in-process provider is its own sole owner (#12393), so its - // refusal is authoritative; every other probe-less provider is doubt. - if (provider instanceof LocalPtyProvider) { - return provider.hasPty(ptyId) - } - return null - } catch { - return null - } - }, - // Why: subscriber-driven ingestion for daemon sessions no renderer pane - // ever attached. Local daemon sessions only — SSH panes have their own - // lease machinery, and the in-process local provider streams without - // attach. Attach-only and false-on-doubt: never creates or resizes. - attach: async (ptyId) => { - if (ptyOwnership.get(ptyId) != null || parseAppSshPtyId(ptyId)) { - return false - } - let provider: IPtyProvider - try { - provider = getProviderForPty(ptyId) - } catch { - return false - } - if (provider !== localProvider || provider instanceof LocalPtyProvider) { - return false - } - try { - const sequenceBeforeProviderAttach = runtime?.getPtyOutputSequence?.(ptyId) ?? 0 - const attachResult = await provider.attach(ptyId) - if (attachResult?.providerSequence) { - runtime?.synchronizePtyOutputSequenceFromProvider?.( - ptyId, - attachResult.providerSequence, - sequenceBeforeProviderAttach - ) - } - return true - } catch { - return false - } - }, - kill: (ptyId) => { - runtime?.markPtyStopRequested?.(ptyId) - let connectionId: string | null | undefined = ptyOwnership.get(ptyId) - const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null - connectionId ??= parsedSshId?.connectionId - const killWithCurrentProvider = (): boolean => { - let provider: IPtyProvider - try { - provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId) - } catch { - if (connectionId) { - // Why: runtime/CLI close can target a detached SSH PTY after its - // provider was unregistered. Tombstone the lease so reconnect does - // not revive a terminal the user explicitly closed — but a detached - // relay PTY outlives its provider, so report an unconfirmed stop - // rather than a kill nobody performed. - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) - return false - } - return false - } - // Why: controller is synchronous, but keep ownership until async shutdown proves whether the provider emitted an exit. - void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false }) - .then((providerExitObserved) => { - const retired = retiredRejectedPtyIds.has(ptyId) - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - if (!providerExitObserved && !retired) { - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - } - }) - .catch((err) => { - const retired = retiredRejectedPtyIds.has(ptyId) - if (isPtyAlreadyGoneError(err)) { - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - if (!retired) { - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - } - return - } - console.warn( - `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` - ) - // Why: close runtime tails without clearing provider ownership, so - // a retry can still target a PTY that survived the failed shutdown. - if (!retired) { - if (connectionId) { - runtime?.markPtyLivenessUnverifiable?.( - ptyId, - err instanceof Error ? err.message : String(err) - ) - } - runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) - } - }) - return true - } - const startupPromise = getLocalPtyProviderStartupPromise(connectionId) - if (startupPromise) { - // Why: select the provider after the daemon swap; the fallback first can report success while orphaning a daemon PTY. - void startupPromise.then(killWithCurrentProvider).catch((err) => { - console.warn( - `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` - ) - if (!retiredRejectedPtyIds.has(ptyId)) { - if (connectionId) { - runtime?.markPtyLivenessUnverifiable?.( - ptyId, - err instanceof Error ? err.message : String(err) - ) - } - runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) - } - }) - return true - } - return killWithCurrentProvider() - }, - retireRejectedPty: (ptyId, stopConfirmed) => { - rememberRetiredRejectedPty(ptyId) - if (!stopConfirmed) { - runtime?.markPtyLivenessUnverifiable?.( - ptyId, - 'a follow-up stop was issued but its outcome could not be verified' - ) - if (!ptyOwnership.has(ptyId)) { - return - } - runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - return - } - // Why: a completed stop already cleared provider state, tombstoned the lease and told the - // renderer; repeating that double-fires the exit IPC. - if (!ptyOwnership.has(ptyId)) { - runtime?.onPtyExit(ptyId, 0, ptyIncarnationById.get(ptyId)) - return - } - let connectionId: string | null | undefined = ptyOwnership.get(ptyId) - const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null - connectionId ??= parsedSshId?.connectionId - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, 0, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: 0 }) - }, - markReversibleStops: (ptyIds) => { - for (const ptyId of ptyIds) { - reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1) - } - let released = false - return () => { - if (released) { - return - } - released = true - for (const ptyId of ptyIds) { - const owners = (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) - 1 - if (owners > 0) { - reversibleStopOwnersByPtyId.set(ptyId, owners) - } else { - reversibleStopOwnersByPtyId.delete(ptyId) - } - } - } - }, - stopAndWait: async (ptyId, opts) => { - runtime?.markPtyStopRequested?.(ptyId) - let connectionId: string | null | undefined = ptyOwnership.get(ptyId) - const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null - connectionId ??= parsedSshId?.connectionId - // Why: destructive teardown threads one absolute deadline through every await - // below; each RPC leaf converts it to the remaining time when it issues, so - // sequential RPCs share the budget and cannot overrun the sweep deadline. - const deadlineMs = opts?.deadlineMs - const startupPromise = getLocalPtyProviderStartupPromise(connectionId) - if (startupPromise) { - // Why: exact-stop must resolve the provider after daemon startup just - // like renderer kills, or the fallback can falsely confirm teardown. - if (deadlineMs !== undefined) { - // Why: bound the cold-start await by the teardown deadline instead of the - // 60s startup fail-open cap; fail closed so the sweep records the miss. - const won = await Promise.race([ - // Why: () => false on rejection both fails closed on a startup error and - // keeps the losing branch's rejection from surfacing as unhandled. - startupPromise.then( - () => true, - () => false - ), - delay(Math.max(1, deadlineMs - Date.now())).then(() => false) - ]) - if (!won) { - return false - } - } else { - await startupPromise - } - } - let provider: IPtyProvider - try { - provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId) - } catch { - if (connectionId) { - // Why: the relay lease must still be tombstoned, but an absent SSH - // provider is lost contact — the remote PTY is designed to survive it, - // so nothing here observed an exit to report as a confirmed stop. - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) - } - return false - } - let providerExitObserved = false - try { - providerExitObserved = await shutdownProviderAndDetectExit(provider, ptyId, { - immediate: true, - keepHistory: opts?.keepHistory ?? false, - deadlineMs - }) - } catch (err) { - if (!isPtyAlreadyGoneError(err)) { - if (connectionId) { - runtime?.markPtyLivenessUnverifiable?.( - ptyId, - err instanceof Error ? err.message : String(err) - ) - } - console.warn( - `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` - ) - return false - } - } - try { - if (!(await verifyPtyStopped(provider, ptyId, opts))) { - runtime?.markPtyLivenessLive?.(ptyId) - return false - } - } catch (err) { - if (connectionId) { - runtime?.markPtyLivenessUnverifiable?.( - ptyId, - err instanceof Error ? err.message : String(err) - ) - } - console.warn( - `[pty] Failed to verify PTY ${ptyId} stopped: ${ - err instanceof Error ? err.message : String(err) - }` - ) - return false - } - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - if (!providerExitObserved) { - // The owning provider's fresh inventory observed absence, so this is a - // death certificate even when its exit event was missed. - runtime?.onPtyExit(ptyId, 0, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: 0 }) - } - return true - }, - getForegroundProcess: async (ptyId) => { - try { - return await getProviderForPty(ptyId).getForegroundProcess(ptyId) - } catch { - return null - } - }, - inspectProcess: async (ptyId) => inspectPtyProviderProcess(getProviderForPty(ptyId), ptyId), - confirmForegroundProcess: async (ptyId) => { - try { - const provider = getProviderForPty(ptyId) - // Why: cached foreground evidence cannot resolve a fresh shell conflict. - return (await provider.confirmForegroundProcess?.(ptyId)) ?? null - } catch { - return null - } - }, - getCwd: async (ptyId) => { - try { - const cwd = await getProviderForPty(ptyId).getCwd(ptyId) - return cwd || null - } catch { - return null - } - }, - hasChildProcesses: async (ptyId) => { - try { - return await getProviderForPty(ptyId).hasChildProcesses(ptyId) - } catch { - return false - } - }, - clearBuffer: async (ptyId) => { - // Why: desktop xterm and daemon/SSH providers hold separate buffers; clear both so mobile resubscribe can't resurrect cleared history. - sendToRenderer(mainWindow, 'pty:clearBuffer:request', { ptyId }) - try { - await getProviderForPty(ptyId).clearBuffer(ptyId) - } catch { - /* best effort: renderer clear still handles local PTYs */ - } - }, - hasPty: (ptyId) => { - try { - return getProviderForPty(ptyId).hasPty?.(ptyId) ?? null - } catch { - return null - } - }, - listProcesses: async (connectionId, opts) => { - if (connectionId === null) { - return localProvider.listProcesses() - } - if (connectionId !== undefined) { - try { - return await getProvider(connectionId).listProcesses(opts) - } catch (error) { - markSshInventoryUnverifiable(connectionId, error) - throw error - } - } - return (await listRegisteredPtyProcessesWithHostScope(markSshInventoryUnverifiable, opts)) - .processes - }, - listProcessesWithHostScope: (opts) => - listRegisteredPtyProcessesWithHostScope(markSshInventoryUnverifiable, opts), - serializeBuffer: (ptyId, opts) => { - // Why: mobile xterm must start from the desktop's exact screen state/dimensions before live TUI chunks render correctly. - return requestSerializedBuffer(ptyId, opts) - }, - serializeProviderBuffer: async (ptyId, opts) => { - try { - // Why: restored daemon PTYs can be live while their desktop pane is unmounted; query the provider model so phone-local navigation works. - return (await getProviderForPty(ptyId).getBufferSnapshot?.(ptyId, opts)) ?? null - } catch { - return null - } - }, - hasRendererSerializer: (ptyId) => { - // Why: a synchronous probe lets the runtime decide whether to skip the daemon-snapshot seed (renderer will hydrate) or run it (no renderer authoritative). - return rendererSerializerReadiness.has(ptyId) - }, - getRendererSerializerGeneration: (ptyId) => { - return rendererSerializerReadiness.generation(ptyId) - }, - waitForRendererSerializer: (ptyId, afterGeneration, timeoutMs, signal) => { - return rendererSerializerReadiness.wait(ptyId, afterGeneration, timeoutMs, signal) - }, - getSize: (ptyId) => ptySizes.get(ptyId) ?? null, - resize: (ptyId, cols, rows) => { - try { - getProviderForPty(ptyId).resize(ptyId, cols, rows) - ptySizes.set(ptyId, { cols, rows }) - return true - } catch { - return false - } - } - }) - - // ─── IPC Handlers (thin dispatch layer) ───────────────────────── - - function normalizeSnapshotScrollbackRows(value: unknown): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return undefined - } - return Math.max(0, Math.min(50_000, Math.floor(value))) - } - - ipc.handle( - 'pty:getMainBufferSnapshot', - async ( - _event, - args: { id?: unknown; opts?: { scrollbackRows?: unknown } } - ): Promise<{ - data: string - frameRestoreAnsi?: string - cols: number - rows: number - cwd?: string | null - lastTitle?: string - seq?: number - pendingDeliveryStartSeq?: number - source?: 'headless' | 'renderer' - alternateScreen?: boolean - scrollbackAnsi?: string - pendingEscapeTailAnsi?: string - kittyKeyboardFlags?: number - } | null> => { - if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { - return null - } - const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows) - try { - const runtimeSeqBeforeSnapshot = runtime.getPtyOutputSequence(args.id) - const providerSnapshotRequired = providerSnapshotRequiredPtys.has(args.id) - const providerSnapshot = providerSnapshotRequired - ? await tryGetProviderForPty(args.id)?.getBufferSnapshot?.(args.id, { - scrollbackRows - }) - : null - // Why: after a data gap main holds only the retained tail; returning it as a full snapshot would erase older scrollback. - if (providerSnapshotRequired && !providerSnapshot) { - return null - } - const snapshot = - providerSnapshot ?? - (await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { - scrollbackRows - })) - if (!snapshot || typeof snapshot.seq !== 'number') { - return snapshot - } - // Why: the renderer's post-restore dedupe needs this pending-queue bound, or a stale baseline swallows new chunks whose seq sits below the snapshot counter. - const pending = pendingData.get(args.id) - if (pending && typeof pending.startSeq !== 'number') { - // Why: a seq-less backlog cannot be bounded — stay conservative. - return snapshot - } - return { - ...snapshot, - pendingDeliveryStartSeq: Math.min( - pending?.startSeq ?? (providerSnapshot ? runtimeSeqBeforeSnapshot : snapshot.seq), - snapshot.seq - ) - } - } catch { - return null - } - } - ) - - // Why: main owns side effects, so this replay restores title state only — never historical bells/completions (no-attention-replay rule, terminal-side-effect-authority.md). - ipc.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => { - if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { - return null - } - return runtime.getTerminalSideEffectSnapshot(args.id) - }) - - installPowerSignalBreadcrumbs(getPtyPower()) - ipc.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => { - return getPtyRendererDeliveryDebugSnapshot() - }) - ipc.handle('pty:resetRendererDeliveryDebug', (): void => { - resetPtyRendererDeliveryDebug() - }) - - ipc.handle( - 'pty:spawn', - async ( - _event, - args: { - cols: number - rows: number - cwd?: string - // Why: fresh local spawns opt into recovering a saved cwd whose dir was deleted (#7239); reattach/remote need exact cwd, so the flag alone isn't sufficient. - cwdFallback?: 'worktree' - env?: Record - envToDelete?: string[] - command?: string - commandDelivery?: 'renderer' | 'provider' - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: unknown - launchAgent?: TuiAgent - startupCommandDelivery?: StartupCommandDelivery - connectionId?: string | null - worktreeId?: string - sessionId?: string - shellOverride?: string - projectRuntime?: ProjectExecutionRuntimeResolution - terminalColorQueryReplies?: { - foreground?: unknown - background?: unknown - } - // Why: hidden-at-spawn declaration (terminal-query-authority.md §races) — main marks hidden before byte zero so the gate owns spawn-time queries. - initiallyHidden?: boolean - // Why: closes the SIGKILL race (INVESTIGATION.md) by letting main sync-flush the binding before pty:spawn returns; only the Ctrl+T daemon-host path threads these. - tabId?: string - leafId?: string - // Why: renderer-threaded launch telemetry (telemetry-plan.md§Agent launch semantics); loosely typed because the main-side schema validator is the single enforcement point. - telemetry?: { - agent_kind?: unknown - launch_source?: unknown - request_kind?: unknown - } - } - ) => { - const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined - const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined - const initialLeafId = - typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null - const initialPaneKey = - typeof args.worktreeId === 'string' && - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - initialLeafId - ? makePaneKey(args.tabId, initialLeafId) - : null - const initialStablePanePtyId = (() => { - try { - return !args.connectionId && initialPaneKey - ? resolveStablePaneOwner( - runtime, - store, - initialPaneKey, - args.worktreeId, - args.connectionId - )?.ptyId - : undefined - } catch { - return undefined - } - })() - const reattachedCodexHomeRoutes = !args.connectionId - ? snapshotCodexPaneHomeRoutes([ - initialStablePanePtyId, - args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined - ]) - : new Map() - const spawnTiming = createPtySpawnTiming() - const startupPromise = getLocalPtyStartupPromise(args.connectionId) - if (startupPromise) { - await startupPromise - } - let cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) - let prevalidatedCwd: string | undefined - let startupCwdFallback: { kind: 'worktree'; cwd: string } | undefined - const earlyLeafId = - typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null - const earlyPaneKey = - typeof args.worktreeId === 'string' && - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - earlyLeafId - ? makePaneKey(args.tabId, earlyLeafId) - : null - const earlyReservationKey = makePaneSpawnReservationKey( - args.worktreeId, - args.connectionId, - earlyPaneKey - ) - const pendingRuntimeCreate = earlyReservationKey - ? pendingRuntimePaneCreatesByOwnerKey.get(earlyReservationKey) - : undefined - if (pendingRuntimeCreate) { - await pendingRuntimeCreate.promise - } - const existingPaneSpawn = earlyReservationKey - ? paneSpawnReservationsByOwnerKey.get(earlyReservationKey) - : undefined - if (existingPaneSpawn) { - return { ...(await existingPaneSpawn.promise), isReattach: true } - } - const earlyStablePaneOwner = - earlyPaneKey && args.worktreeId - ? resolveStablePaneOwner(runtime, store, earlyPaneKey, args.worktreeId, args.connectionId) - : null - const earlyWorktreeId = args.worktreeId - let paneSpawnReservationKey = - earlyStablePaneOwner && earlyReservationKey ? earlyReservationKey : null - let paneSpawnReservation = paneSpawnReservationKey - ? reservePaneSpawn(paneSpawnReservationKey) - : null - let finishTerminalInstall = (): void => {} - let result: PtySpawnResult - let stablePaneOwner: StablePaneOwner | null = null - let stablePaneBindingPersisted = false - let rejectedRegistrationCandidate: PtySpawnResult | null = null - let pendingRegistrationPtyId: string | null = null - // Why hoisted to the reply scope: main reconciles the provider sequence - // deep inside the spawn path, but the pane needs that renderer-domain - // boundary beside the daemon snapshot's kitty flags. - let reconciledSnapshotSeq: number | null = null - // False when bytes crossed the data socket during the spawn RPC: the - // reconciled boundary covers them, but the daemon proved its kitty flags - // before they existed, so the claim must not erase what the pane may - // have scanned from those bytes live. - let snapshotKittyFlagsCoverReconciledSeq = true - let preparedProvisionalExecutionContext = false - let releaseWorktreeSpawn: (() => void) | undefined - try { - if (!earlyStablePaneOwner) { - await assertFolderWorkspacePtyPathUsable(args.worktreeId) - } - const provider = getProvider(args.connectionId) - spawnTiming.mark('stable_adoption_setup') - const preAdoptedStablePane = - earlyStablePaneOwner && earlyWorktreeId - ? await adoptStablePane({ - cols: args.cols, - rows: args.rows, - cwd, - connectionId: args.connectionId, - worktreeId: earlyWorktreeId, - tabId: earlyStablePaneOwner.tabId, - leafId: earlyStablePaneOwner.leafId, - ownsPaneSpawnReservation: true - }) - : null - spawnTiming.mark('stable_adoption') - if (earlyStablePaneOwner && !preAdoptedStablePane) { - await assertFolderWorkspacePtyPathUsable(args.worktreeId) - } - if (!preAdoptedStablePane) { - // Why: reattach needs exact cwd, SSH cannot probe locally, and successful stable-pane adoption needs no launch preflight. - const requestedMissingCwdFallback = - !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' - const isPosixStartupCwd = args.cwd?.startsWith('/') === true - const startupWorkspaceCwd = - requestedMissingCwdFallback && isPosixStartupCwd - ? resolvePtySpawnStartupCwd(args.worktreeId, '.') - : undefined - const initiallyResolvedStartupCwd = - requestedMissingCwdFallback && isPosixStartupCwd ? cwd : undefined - const startupTerminalRuntimeOptions = - requestedMissingCwdFallback && process.platform === 'win32' - ? resolveLocalWindowsTerminalRuntimeOptions({ - requestedShellOverride: args.shellOverride, - settings: getSettings?.(), - projectRuntime: args.projectRuntime, - fallbackHostShell: process.env.COMSPEC || 'powershell.exe' - }) - : undefined - const wslRuntimeOwnsStartupCwd = - requestedMissingCwdFallback && - isPosixStartupCwd && - (isWslShellName(startupTerminalRuntimeOptions?.shellOverride) || - isWslUncPath(startupWorkspaceCwd ?? '')) - const startupWslContext = wslRuntimeOwnsStartupCwd - ? resolveWslSessionContext({ - cwd: startupWorkspaceCwd, - shellOverride: startupTerminalRuntimeOptions?.shellOverride, - terminalWindowsWslDistro: startupTerminalRuntimeOptions?.terminalWindowsWslDistro - }) - : undefined - let wslStartupCwdExists: boolean | null = null - let wslWorkspaceCwdExists: boolean | null = null - if (startupWslContext && initiallyResolvedStartupCwd) { - const validationCwd = toWindowsWslPath( - initiallyResolvedStartupCwd, - startupWslContext.distro - ) - wslStartupCwdExists = isWslUncPath(validationCwd) - ? await wslUncDirectoryExistsAsync(validationCwd) - : localStartupCwdDirectoryExists(validationCwd) - if (wslStartupCwdExists === true) { - prevalidatedCwd = validationCwd - } - if (wslStartupCwdExists === false && startupWorkspaceCwd) { - wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd) - ? await wslUncDirectoryExistsAsync(startupWorkspaceCwd) - : localStartupCwdDirectoryExists(startupWorkspaceCwd) - if (wslWorkspaceCwdExists === true) { - prevalidatedCwd = startupWorkspaceCwd - } - } - } - const allowMissingCwdFallback = - requestedMissingCwdFallback && - (!wslRuntimeOwnsStartupCwd || - (wslStartupCwdExists === false && wslWorkspaceCwdExists === true)) - let didFallbackToWorkspaceRootCwd = false - cwd = resolvePtySpawnStartupCwd( - args.worktreeId, - args.cwd, - allowMissingCwdFallback - ? { - directoryExists: (path) => - startupWslContext && - wslStartupCwdExists === false && - path === initiallyResolvedStartupCwd - ? false - : startupWslContext && path === startupWorkspaceCwd - ? wslWorkspaceCwdExists === true - : localStartupCwdDirectoryExists( - process.platform === 'win32' ? normalizeWindowsTerminalCwd(path) : path - ), - onFallbackToWorkspaceRoot: () => { - didFallbackToWorkspaceRootCwd = true - } - } - : undefined - ) - if (didFallbackToWorkspaceRootCwd && wslWorkspaceCwdExists === true && cwd) { - prevalidatedCwd = cwd - } - startupCwdFallback = - didFallbackToWorkspaceRootCwd && cwd ? { kind: 'worktree', cwd } : undefined - } - spawnTiming.mark('preflight') - const freshSpawnRecovery = preAdoptedStablePane - ? undefined - : recoverFreshSpawnProviderRouting(provider, args.connectionId, args.sessionId) - if (freshSpawnRecovery) { - await freshSpawnRecovery - } - const isClaudeLaunch = - !preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) - if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') - } - const terminalRuntimeOptions = - process.platform === 'win32' && !args.connectionId - ? resolveLocalWindowsTerminalRuntimeOptions({ - requestedShellOverride: args.shellOverride, - settings: getSettings?.(), - projectRuntime: args.projectRuntime, - fallbackHostShell: process.env.COMSPEC || 'powershell.exe' - }) - : { shellOverride: args.shellOverride, terminalWindowsWslDistro: null } - const initialShellOverride = terminalRuntimeOptions.shellOverride - const isDaemonHostSpawn = - !args.connectionId && - !(provider instanceof LocalPtyProvider) && - !routesFreshSpawnsToLocalProvider(provider) - // Why: daemon host-env setup needs a stable id BEFORE provider.spawn so buildPtyHostEnv hooks/Pi cleanup can run; daemon still honors opts.sessionId ?? mint(). - // Note: sessionId is STABLE across daemon restarts by design — do NOT simplify to a fresh UUID per spawn; that orphans reconnectable state. - // Why: only clear ids minted in THIS request on failure — a caller-supplied args.sessionId may name an existing PTY we must not clobber. - const isMintedSessionId = args.sessionId === undefined && isDaemonHostSpawn - const effectiveSessionId = - args.sessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) - const effectiveSessionAppId = - effectiveSessionId !== undefined - ? getAppPtyId(args.connectionId, effectiveSessionId) - : undefined - const effectiveSessionRelayId = - effectiveSessionId !== undefined - ? getRelayPtyId(args.connectionId, effectiveSessionId) - : undefined - const expectedWslDistro = !args.connectionId - ? (resolveWslSessionContext({ - cwd, - sessionId: effectiveSessionId, - shellOverride: terminalRuntimeOptions.shellOverride, - terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro - })?.distro ?? null) - : null - const initialSelectionTarget = getCodexSelectionTargetForPty( - initialShellOverride, - cwd, - expectedWslDistro - ) - const claudeAuth = - isClaudeLaunch && prepareClaudeAuth - ? await prepareClaudeAuth(initialSelectionTarget) - : null - spawnTiming.mark('auth') - if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') - } - if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { - throw new Error( - 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' - ) - } - // Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity. - // Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host. - const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) - // Why: forward pane env to SSH only when the relay hook path is enabled, or a newer relay could emit statuses this build can't route. - const sshSourceEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env) - const baseEnvWithAuth = claudeAuth - ? { ...sshSourceEnv, ...claudeAuth.envPatch } - : sshSourceEnv - const spawnPaneKey = baseEnvWithAuth?.ORCA_PANE_KEY - const parsedSpawnPaneKey = parseValidPaneKey(spawnPaneKey) - const verifiedPaneKey = - parsedSpawnPaneKey && - typeof args.tabId === 'string' && - args.tabId === parsedSpawnPaneKey.tabId && - args.leafId === parsedSpawnPaneKey.leafId - ? makePaneKey(parsedSpawnPaneKey.tabId, parsedSpawnPaneKey.leafId) - : null - const verifiedLeafId = - verifiedPaneKey && parsedSpawnPaneKey ? parsedSpawnPaneKey.leafId : null - const metadataLeafId = - typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null - const metadataPaneKey = - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - metadataLeafId - ? makePaneKey(args.tabId, metadataLeafId) - : null - const legacySpawnPaneKey = verifiedPaneKey ? null : parseLegacyNumericPaneKey(spawnPaneKey) - const migrationUnsupportedPaneKey = - legacySpawnPaneKey && - typeof args.tabId === 'string' && - args.tabId === legacySpawnPaneKey.tabId && - typeof args.leafId === 'string' && - isTerminalLeafId(args.leafId) - ? makePaneKey(args.tabId, args.leafId) - : null - const stablePaneKey = verifiedPaneKey ?? migrationUnsupportedPaneKey - let baseEnv = baseEnvWithAuth ? { ...baseEnvWithAuth } : undefined - const shouldRefreshAgentTeamsEnv = - !preAdoptedStablePane && - !args.connectionId && - runtime !== undefined && - stablePaneKey !== null && - shouldRefreshNativeClaudeAgentTeamsEnv({ - command: args.command, - launchConfig: args.launchConfig - }) - let effectiveLaunchConfig = args.launchConfig - const shouldPreAllocateTerminalHandle = - runtime !== undefined && - ((!(provider instanceof LocalPtyProvider) && - !routesFreshSpawnsToLocalProvider(provider)) || - shouldRefreshAgentTeamsEnv) - const preAllocatedHandle = shouldPreAllocateTerminalHandle - ? (preAdoptedStablePane?.owner.handle ?? runtime.createPreAllocatedTerminalHandle()) - : null - if (shouldRefreshAgentTeamsEnv && preAllocatedHandle) { - // Why: Agent Teams ids/tokens are process-local, so the team env must be regenerated for the new leader PTY. - const prepared = await runtime.prepareClaudeAgentTeamsLeaderForHandle({ - handle: preAllocatedHandle, - baseEnv: baseEnv ?? {} - }) - baseEnv = { - ...baseEnv, - ...prepared.env - } - if (args.launchConfig) { - effectiveLaunchConfig = { - ...args.launchConfig, - agentEnv: { - ...args.launchConfig.agentEnv, - ...prepared.env - } - } - } - } - const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID - ? baseEnv[resolvePathEnvKey(baseEnv, process.platform)] - : undefined - const agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv ? ['TERM_PROGRAM'] : undefined - if (baseEnv && stablePaneKey) { - baseEnv.ORCA_PANE_KEY = stablePaneKey - if (typeof args.tabId === 'string') { - baseEnv.ORCA_TAB_ID = args.tabId - } else if (!args.connectionId) { - delete baseEnv.ORCA_TAB_ID - } - if (typeof args.worktreeId === 'string') { - baseEnv.ORCA_WORKTREE_ID = args.worktreeId - } else if (!args.connectionId) { - delete baseEnv.ORCA_WORKTREE_ID - } - } else if (baseEnv) { - // Why: ORCA_PANE_KEY crosses into shells/hook registries; only a key proven to match this spawn's tab+leaf may cross the IPC boundary. - delete baseEnv.ORCA_PANE_KEY - delete baseEnv.ORCA_TAB_ID - delete baseEnv.ORCA_WORKTREE_ID - delete baseEnv.ORCA_AGENT_LAUNCH_TOKEN - } - const validatedPaneKey = stablePaneKey - // Why: SSH can strip ORCA_PANE_KEY when remote hooks are off; IPC tab/leaf metadata still names the pane. - const reservationPaneKey = metadataPaneKey ?? validatedPaneKey - const validatedLeafId = verifiedLeafId ?? metadataLeafId - const effectiveShellOverride = terminalRuntimeOptions.shellOverride - const nativeWindowsConptySpawn = isNativeWindowsLocalPtySpawn({ - connectionId: args.connectionId, - cwd: args.cwd, - shellOverride: effectiveShellOverride - }) - const codexSelectionTarget = getCodexSelectionTargetForPty( - effectiveShellOverride, - cwd, - expectedWslDistro - ) - const codexResumePreparation = preAdoptedStablePane - ? null - : prepareCodexResumeHome({ - connectionId: args.connectionId, - launchAgent: args.launchAgent, - providerSession: args.resumeProviderSession, - target: codexSelectionTarget, - launchEnv: baseEnv, - workspacePath: cwd - }) - const codexResumeLaunch = codexResumePreparation - ? await resolveCodexResumeLaunch(args.command, codexResumePreparation) - : noCodexResumeLaunch(preAdoptedStablePane ? undefined : args.command) - const codexResumeHome = codexResumeLaunch.codexResumeHome - const launchCommand = codexResumeLaunch.command - baseEnv = stripSequencedStartupResumeArgv(baseEnv, codexResumeLaunch) - // Why: declared after the strip so a local-provider spawn cannot capture the - // pre-strip env — only the daemon branch below re-derives this from baseEnv. - let env: Record | undefined = baseEnv - let selectedCodexHomePath = resolveSelectedCodexHomeOrRefuseSpawn(() => - !preAdoptedStablePane && !args.connectionId - ? getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - codexResumeHome - ? reconcileSharedRuntimeResumeHome(codexResumeHome, () => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null - ) - ) - : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null) - ) - : null - ) - if (!preAdoptedStablePane && args.launchAgent === 'codex' && args.sessionId === undefined) { - const resolution = resolveCodexHomeAfterManagedAuthReadiness({ - selectedCodexHomePath, - getSettings: () => getSettings?.(), - requiredCodexHomePath: codexResumeHome?.codexHomePath, - target: codexSelectionTarget, - resolveCurrent: () => - resolveSelectedCodexHomeOrRefuseSpawn(() => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: 'codex' - }) ?? null - ) - ), - resolveAfterUnavailable: (unavailableManagedHomePath) => - resolveSelectedCodexHomeOrRefuseSpawn(() => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: 'codex', - unavailableManagedHomePath - }) ?? null - ) - ) - }) - selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution - } - if (args.launchAgent === 'codex' && selectedCodexHomePath) { - await ensureCodexStateDbBackfillRecoveryStarted(selectedCodexHomePath) - } - const codexResumeHomeSelected = Boolean( - codexResumeHome && - codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath) - ) - const skipCodexHomeEnv = - isDaemonHostSpawn && - shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) && - !selectedCodexHomePath - const ptySettings = isDaemonHostSpawn ? getSettings?.() : undefined - const stripInheritedOrcaCodexHome = - isDaemonHostSpawn && - shouldStripInheritedOrcaCodexHome({ - target: codexSelectionTarget, - selectedCodexHomePath, - skipCodexHomeEnv, - settings: ptySettings - }) - if (isDaemonHostSpawn && !preAdoptedStablePane) { - if (effectiveSessionId === undefined) { - // Should be unreachable: effectiveSessionId is a string when isDaemonHostSpawn; defense-in-depth. - throw new Error('Invariant violation: daemon spawn without sessionId') - } - const sessionIdForEnv = effectiveSessionId - // Why: this id reaches filesystem paths; reject traversal/separators so a crafted IPC payload can't escape the expected roots. - if (!isSafePtySessionId(sessionIdForEnv, getAppEnvironment().getPath('userData'))) { - throw new Error('Invalid PTY session id') - } - // Why: clone before mutating so injections don't leak back into args.env (renderer may reuse it). - env = { ...baseEnv } - try { - buildPtyHostEnv(sessionIdForEnv, env, { - isPackaged: getAppEnvironment().isPackaged(), - resourcesPath: process.resourcesPath, - userDataPath: getAppEnvironment().getPath('userData'), - selectedCodexHomePath, - skipCodexHomeEnv, - stripInheritedOrcaCodexHome, - launchCommand, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, - isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd), - wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null, - agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), - codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), - networkProxySettings: ptySettings, - deferGitConfigGuardToDaemon: - provider.supportsGitCredentialGuardHost?.(effectiveSessionId) === true - }) - stampWslOrchestrationCompatibilityHost( - env, - runtime?.getOrchestrationCompatibilityHostId?.(), - codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null - ) - promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) - } catch (err) { - // Why: buildPtyHostEnv has fs side-effects (Pi/OMP install); clear per-PTY state on throw, but only minted ids — caller ids may name existing PTYs. - if (isMintedSessionId) { - clearProviderPtyState(sessionIdForEnv) - } - throw err - } - } - spawnTiming.mark('host_env') - const spawnEnv = preAllocatedHandle - ? { ...env, ORCA_TERMINAL_HANDLE: preAllocatedHandle } - : env - const envToDelete = claudeAuth?.stripAuthEnv - ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] - : undefined - let combinedEnvToDelete = mergePtyEnvDeletions( - envToDelete, - args.envToDelete ?? [], - agentTeamsEnvToDelete ?? [], - // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. - isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], - isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [], - getInheritedClaudeSessionStampEnvKeysToDelete(spawnEnv), - skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], - // Why: the persistent daemon compares its own merged CODEX_HOME pair; - // main cannot safely decide ownership for a process it may not parent. - stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] - ) - if (codexResumeHomeSelected) { - combinedEnvToDelete = removeCodexHomeDeletionRequests(combinedEnvToDelete) - } - deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete) - promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath) - const spawnOptions: PtySpawnOptions = { - cols: args.cols, - rows: args.rows, - cwd, - ...(prevalidatedCwd && !isDaemonHostSpawn ? { prevalidatedCwd } : {}), - env: spawnEnv, - ...(isMintedSessionId ? { isNewSession: true } : {}), - historyIsolationEnabled: getSettings?.()?.terminalScopeHistoryByWorktree ?? true - } - if (!args.connectionId && !isDaemonHostSpawn) { - spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath } - } - if (combinedEnvToDelete) { - spawnOptions.envToDelete = combinedEnvToDelete - } - if (launchCommand !== undefined) { - spawnOptions.command = launchCommand - } - if (args.commandDelivery !== undefined) { - spawnOptions.commandDelivery = args.commandDelivery - } - if (args.startupCommandDelivery !== undefined) { - spawnOptions.startupCommandDelivery = args.startupCommandDelivery - } - if (isTuiAgent(args.launchAgent)) { - spawnOptions.launchAgent = args.launchAgent - } - if (args.worktreeId !== undefined) { - spawnOptions.worktreeId = args.worktreeId - } - if (reservationPaneKey) { - spawnOptions.paneKey = reservationPaneKey - } - if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) { - spawnOptions.tabId = args.tabId - } - if (effectiveSessionId !== undefined) { - spawnOptions.sessionId = effectiveSessionId - } - // Why: without this, the Windows daemon path ignores the user's Default Shell preference (LocalPtyProvider already honors it via getWindowsShell()). - if (effectiveShellOverride !== undefined) { - spawnOptions.shellOverride = effectiveShellOverride - } - const hadSessionSizeBeforeAttach = - effectiveSessionAppId !== undefined ? ptySizes.has(effectiveSessionAppId) : false - const sessionSizeBeforeAttach = - effectiveSessionAppId !== undefined ? ptySizes.get(effectiveSessionAppId) : undefined - if (effectiveSessionId !== undefined) { - // Why: daemon PTYs can emit before spawn() resolves; set real geometry now or early bytes default to 80x24 and wrap TUIs. - ptySizes.set(effectiveSessionAppId ?? effectiveSessionId, { - cols: args.cols, - rows: args.rows - }) - } - if (process.platform === 'win32' && !args.connectionId) { - // Why: the renderer models PowerShell as one shell family; thread the implementation choice so both PTY paths resolve the same executable. - spawnOptions.terminalWindowsWslDistro = expectedWslDistro - spawnOptions.terminalWindowsPowerShellImplementation = getSettings - ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') - : undefined - } - if (startupTerminalColorQueryReplyColors) { - spawnOptions.startupIngress = { - colors: startupTerminalColorQueryReplyColors, - deadlineMs: 5_000 - } - } - const resolvedPaneSpawnReservationKey = makePaneSpawnReservationKey( - args.worktreeId, - args.connectionId, - reservationPaneKey - ) - if ( - paneSpawnReservationKey && - resolvedPaneSpawnReservationKey !== paneSpawnReservationKey - ) { - throw new Error('terminal_pane_identity_changed') - } - if (!paneSpawnReservationKey) { - paneSpawnReservationKey = resolvedPaneSpawnReservationKey - const pendingRuntimeCreateAfterPreflight = paneSpawnReservationKey - ? pendingRuntimePaneCreatesByOwnerKey.get(paneSpawnReservationKey) - : undefined - if (pendingRuntimeCreateAfterPreflight) { - await pendingRuntimeCreateAfterPreflight.promise - } - const existingPaneSpawnAfterPreflight = paneSpawnReservationKey - ? paneSpawnReservationsByOwnerKey.get(paneSpawnReservationKey) - : undefined - if (existingPaneSpawnAfterPreflight) { - return { ...(await existingPaneSpawnAfterPreflight.promise), isReattach: true } - } - paneSpawnReservation = paneSpawnReservationKey - ? reservePaneSpawn(paneSpawnReservationKey) - : null - } - finishTerminalInstall = beginPtySpawnForWorktree(args.worktreeId, cwd, args.connectionId) - const initiallyHidden = args.initiallyHidden === true - // Why: daemon PTYs can emit before spawn() resolves, so the hidden mark must beat byte zero (terminal-query-authority.md §races); other providers are safe with the post-spawn mark below. - const preSpawnHiddenMarkId = - initiallyHidden && isDaemonHostSpawn && effectiveSessionAppId !== undefined - ? effectiveSessionAppId - : null - if (preSpawnHiddenMarkId !== null) { - transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, true) - } - releaseWorktreeSpawn = await runtime?.acquireWorktreeTerminalSpawn?.(args.worktreeId) - try { - if (preAllocatedHandle) { - trustedTerminalHandleEnv.add(preAllocatedHandle) - } - spawnTiming.mark('options') - const stablePaneOwnerCandidate = resolveStablePaneOwner( - runtime, - store, - reservationPaneKey, - args.worktreeId, - args.connectionId - ) - const expectedPtyId = - stablePaneOwnerCandidate?.ptyId ?? effectiveSessionAppId ?? effectiveSessionId - if (expectedPtyId) { - runtime?.beginPtyRegistration?.(expectedPtyId) - pendingRegistrationPtyId = expectedPtyId - } - if (isDaemonHostSpawn && expectedPtyId) { - preparedProvisionalExecutionContext = - runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, { - resetIncarnation: isMintedSessionId && !stablePaneOwnerCandidate, - preserveExisting: !isMintedSessionId || Boolean(stablePaneOwnerCandidate) - }) ?? false - } - const sequenceBeforeProviderSpawn = expectedPtyId - ? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) - : 0 - const stablePaneSpawn = preAdoptedStablePane - ? preAdoptedStablePane - : await spawnForStablePane({ - runtime, - store, - provider, - spawnOptions, - owner: stablePaneOwnerCandidate, - worktreeId: args.worktreeId, - connectionId: args.connectionId, - resolveOwner: () => - resolveStablePaneOwner( - runtime, - store, - reservationPaneKey, - args.worktreeId, - args.connectionId - ) - }) - result = stablePaneSpawn.result - stablePaneOwner = stablePaneSpawn.owner - if ( - stablePaneOwner && - isMintedSessionId && - effectiveSessionAppId && - effectiveSessionAppId !== result.id - ) { - clearProviderPtyState(effectiveSessionAppId) - } - rejectedRegistrationCandidate = result - if (pendingRegistrationPtyId !== result.id) { - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId) - } - runtime?.beginPtyRegistration?.(result.id, result.incarnationId) - pendingRegistrationPtyId = result.id - } - assertSpawnReplyWasLive(result) - runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId) - if (result.providerSequence) { - const runtimeSequenceBeforeReconcile = runtime?.getPtyOutputSequence?.(result.id) ?? 0 - // Why kept: this is the reattach boundary in the RENDERER's sequence - // domain, and the daemon snapshot's kitty flags mean nothing without - // the boundary they were proven at. - reconciledSnapshotSeq = - runtime?.synchronizePtyOutputSequenceFromProvider?.( - result.id, - result.providerSequence, - sequenceBeforeProviderSpawn - ) ?? null - if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) { - snapshotKittyFlagsCoverReconciledSeq = false - } - } - ensureWslHookRelayForReattach(result, args.connectionId) - runtime?.preparePtyExecutionContext?.( - result.id, - args.connectionId - ? null - : result.wslDistro === undefined - ? expectedWslDistro - : result.wslDistro - ) - spawnTiming.mark('provider_spawn') - } catch (err) { - if ((isMintedSessionId || preparedProvisionalExecutionContext) && effectiveSessionAppId) { - runtime?.preparePtyExecutionContext?.(effectiveSessionAppId, null, { - resetIncarnation: true - }) - } - // Why: a stale hidden mark on this session id would gate a later visible attach that reuses it. - if (preSpawnHiddenMarkId !== null) { - transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, false) - } - const rawMessage = err instanceof Error ? err.message : String(err) - if (rawMessage === 'agent_session_exited_during_start' && rejectedRegistrationCandidate) { - runtime?.releaseRejectedPtyRegistrationFence?.( - rejectedRegistrationCandidate.id, - rejectedRegistrationCandidate.incarnationId - ) - } - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.( - pendingRegistrationPtyId, - rejectedRegistrationCandidate?.incarnationId - ) - pendingRegistrationPtyId = null - } - const spawnError = normalizeNodePtySpawnError(err) - const isIdentityMismatch = - isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage) - if (effectiveSessionAppId !== undefined) { - if (isIdentityMismatch && hadSessionSizeBeforeAttach && sessionSizeBeforeAttach) { - ptySizes.set(effectiveSessionAppId, sessionSizeBeforeAttach) - } else { - ptySizes.delete(effectiveSessionAppId) - } - } - if ( - args.connectionId && - effectiveSessionRelayId !== undefined && - (spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) || - rawMessage.includes(SSH_SESSION_EXPIRED_ERROR)) - ) { - // Why: expired remote reattach = relay already dropped the PTY; clear the lease so writes can't restore the stale binding. - if (effectiveSessionAppId !== undefined && !isIdentityMismatch) { - clearProviderPtyState(effectiveSessionAppId) - deletePtyOwnership(effectiveSessionAppId) - } - if (!isIdentityMismatch) { - store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired') - } - } - // Why: provider state buildPtyHostEnv materialized for this minted id leaks if spawn failed. - if (isMintedSessionId && effectiveSessionId !== undefined) { - clearProviderPtyState(effectiveSessionId) - } - // Why: telemetry-plan.md§agent_error — attribute the error to the renderer-threaded agent_kind, else sniff the command for `claude`; raw messages are dropped at the validator boundary. - const rendererAgentKindParse = - args.telemetry?.agent_kind !== undefined - ? agentKindSchema.safeParse(args.telemetry.agent_kind) - : null - const errorAgentKind = rendererAgentKindParse?.success - ? rendererAgentKindParse.data - : isClaudeLaunch - ? ('claude-code' as const) - : null - if (errorAgentKind) { - const classified = classifyError(spawnError) - track('agent_error', { - agent_kind: errorAgentKind, - error_class: classified.error_class, - ...getCohortAtEmit() - }) - } - throw spawnError - } finally { - if (preAllocatedHandle) { - trustedTerminalHandleEnv.delete(preAllocatedHandle) - } - } - try { - stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ - store, - owner: stablePaneOwner, - result, - worktreeId: args.worktreeId, - startupCwd: cwd, - connectionId: args.connectionId - }) - } catch (error) { - if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { - throw error - } - console.error('[pty] failed to persist PTY binding after attach:', error) - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { - agentSessionOperationOutcome: 'unknown' as const - }) - } - spawnTiming.log(result.id, { - daemon: isDaemonHostSpawn, - reattach: result.isReattach ?? false - }) - recordCodexPaneAccountForSpawn({ - ptyId: result.id, - isDaemonHostSpawn, - isReattach: result.isReattach === true, - pinnedByResume: codexResumeHomeSelected, - launchCodexHomePath: selectedCodexHomePath, - launchEnv: baseEnv, - target: codexSelectionTarget, - settings: getSettings?.() - }) - ptyOwnership.set(result.id, args.connectionId ?? null) - if (result.incarnationId) { - ptyIncarnationById.set(result.id, result.incarnationId) - } - if (initiallyHidden) { - // Why marked synchronously here: provider data events dispatch on later tasks, so this still lands ahead of the first byte's delivery decision (idempotent if already marked pre-spawn). - transitionSpawnHiddenRendererPtyDeliveryState(result.id, true) - if (preSpawnHiddenMarkId !== null && preSpawnHiddenMarkId !== result.id) { - // Defense: never strand a mark on an id the provider renamed. - transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, false) - } - // Why after ptyOwnership.set: provider lookup routes by ownership, and a hidden-spawned agent should be paceable from its first flood. - syncPtyBackgroundedDelivery(result.id, 'spawn') - closeStartupQueryAuthorityForPty(result.id) - } - // Why: record the native-Windows-ConPTY determination before the headless seed so the emulator's DA1 override exists from byte zero. - if (nativeWindowsConptySpawn) { - markNativeWindowsConptyPty(result.id) - } - const relayResultId = getRelayPtyId(args.connectionId, result.id) - if (store && args.connectionId) { - // Why: remote PTYs live in the SSH relay grace window after Orca detaches; persist IDs immediately so reconnect reattaches instead of spawning a fresh shell. - store.upsertSshRemotePtyLease({ - targetId: args.connectionId, - ptyId: relayResultId, - ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), - ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), - ...(validatedLeafId ? { leafId: validatedLeafId } : {}), - state: 'attached', - lastAttachedAt: Date.now() - }) - } - if (preAllocatedHandle && !stablePaneOwner?.handle) { - runtime?.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) - } - ptySizes.set(result.id, { cols: args.cols, rows: args.rows }) - if (effectiveSessionAppId !== undefined && effectiveSessionAppId !== result.id) { - ptySizes.delete(effectiveSessionAppId) - } - // Why: patch the load-bearing ptyId binding synchronously so a force-quit in the renderer's ~450 ms debounce window can't orphan daemon history or an SSH relay lease (Issue #217). - if ( - store && - typeof args.worktreeId === 'string' && - typeof args.tabId === 'string' && - validatedLeafId !== null && - !stablePaneBindingPersisted - ) { - try { - const binding = { - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: validatedLeafId, - ptyId: result.id, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(cwd ? { startupCwd: cwd } : {}) - } - if (args.connectionId) { - store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId)) - } else { - store.persistPtyBinding(binding) - } - } catch (err) { - console.error('[pty] failed to persist PTY binding after spawn:', err) - if (!result.isReattach) { - try { - await provider.shutdown(result.id, { immediate: true }) - } catch (shutdownErr) { - console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) - } - clearProviderPtyState(result.id) - deletePtyOwnership(result.id) - } - if (!result.isReattach && args.connectionId && store) { - store.removeSshRemotePtyLease(args.connectionId, relayResultId) - } - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { - agentSessionOperationOutcome: 'unknown' as const - }) - } - } - // Why: when the renderer has declared it will own the serializer for this paneKey, suppress the daemon-snapshot seed so its hydration path is sole authority (keyed on paneKey since the ptyId isn't known yet). See docs/mobile-prefer-renderer-scrollback.md. - const rendererPreSignaled = validatedPaneKey - ? pendingByPaneKey.has(validatedPaneKey) - : false - const rendererAlreadyRegistered = - result.isReattach === true && - !rendererPreSignaled && - rendererSerializerReadiness.has(result.id) - rendererSerializerReadiness.beginIncarnation(result.id, rendererAlreadyRegistered) - // Why: capture the pending gen at spawn time so this PTY's teardown only settles its own generation, not a remount that replaced the entry. - if (validatedPaneKey && rendererPreSignaled) { - const pending = pendingByPaneKey.get(validatedPaneKey) - if (pending) { - pendingPtyIdBySerializerGeneration.set(pending.gen, result.id) - } - } - - // Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback). - // Skip when the renderer will be authoritative — its xterm buffer is richer than the daemon snapshot. - if (runtime && !rendererPreSignaled && !rendererAlreadyRegistered) { - const snapshotSeedSize = - typeof result.snapshotCols === 'number' && typeof result.snapshotRows === 'number' - ? { cols: result.snapshotCols, rows: result.snapshotRows } - : undefined - if (typeof result.snapshot === 'string' && result.snapshot.length > 0) { - // Why kitty flags ride seed metadata: the snapshot omits them, but the re-seeded emulator must answer hidden `CSI ? u` with the running app's flags (terminal-query-authority.md). - runtime.seedHeadlessTerminal( - result.id, - result.snapshot, - snapshotSeedSize, - typeof result.snapshotKittyKeyboardFlags === 'number' - ? { kittyKeyboardFlags: result.snapshotKittyKeyboardFlags } - : {} - ) - } else if ( - result.coldRestore && - typeof result.coldRestore.scrollback === 'string' && - result.coldRestore.scrollback.length > 0 - ) { - const coldRestoreSeedSize = - typeof result.coldRestore.cols === 'number' && - typeof result.coldRestore.rows === 'number' - ? { cols: result.coldRestore.cols, rows: result.coldRestore.rows } - : undefined - runtime.seedHeadlessTerminal( - result.id, - result.coldRestore.scrollback, - coldRestoreSeedSize, - { - cwd: result.coldRestore.cwd, - oscLinks: result.coldRestore.oscLinks, - preferProviderIfExisting: true - } - ) - } else if (typeof result.replay === 'string' && result.replay.length > 0) { - // Why: the relay reattach replay is the one restore main never ingests, so without - // this seed its model is a mere suffix of what the renderer painted — and a later - // park-reveal would outrank the fuller relay replay with that fragment. - runtime.seedHeadlessTerminal(result.id, result.replay) - } - } - if ( - typeof args.worktreeId === 'string' && - args.worktreeId.length > 0 && - args.worktreeId.length <= 512 - ) { - const agentLaunchAuthority = admitRendererAgentLaunchAuthority({ - launchToken: args.launchToken, - spawnEnv, - launchAgent: args.launchAgent, - launchConfig: effectiveLaunchConfig, - isReattach: result.isReattach === true, - hasStablePaneOwner: stablePaneOwner !== null, - incarnationId: result.incarnationId - }) - runtime?.registerPty( - result.id, - args.worktreeId, - args.connectionId ?? null, - // Why: pass validated pane identity so a throttled mobile create publishes its surface main-side instead of destroying the live PTY (#7587); bound the untrusted tabId. - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - metadataLeafId !== null - ? { - tabId: args.tabId, - leafId: metadataLeafId, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(agentLaunchAuthority ? { agentLaunchAuthority } : {}) - } - : undefined, - !args.connectionId - ? shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) - : undefined - ) - pendingRegistrationPtyId = null - } else if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId, result.incarnationId) - pendingRegistrationPtyId = null - } - // Why: seed after registerPty binds the worktree — including on - // desktop, where the renderer-authority gate above skips the emulator - // seed but the list/read records still live main-side. - seedTerminalRestoreRecordsFromSpawnResult(runtime, result) - // Why: arm main's per-PTY Command Code output detector from the launch command (startupCommand parity); banner detection covers PTYs without one. - if (!stablePaneOwner) { - runtime?.noteTerminalSpawnCommand?.( - result.id, - typeof launchCommand === 'string' ? launchCommand : null - ) - } - if (isClaudeLaunch && !stablePaneOwner) { - markClaudePtySpawned(result.id) - } - // Why: record the paneKey mapping so clearProviderPtyState can clear the agent-hooks server's per-paneKey caches on exit. - // Why: args.env is untrusted IPC JSON (type unenforced); bound the paneKey so malformed/oversized values can't pollute ptyPaneKey or clearPaneState. - const rememberedPaneKey = validatedPaneKey - ? rememberPaneKeyForPty(result.id, validatedPaneKey) - : null - if (legacySpawnPaneKey && migrationUnsupportedPaneKey) { - agentHookServer.registerPaneKeyAlias( - legacySpawnPaneKey.paneKey, - migrationUnsupportedPaneKey, - result.id, - Date.now(), - { authorityVerified: true } - ) - clearMigrationUnsupportedPtysForPaneKey(migrationUnsupportedPaneKey) - } else if (validatedPaneKey) { - if (!result.isReattach) { - clearMigrationUnsupportedPtysForPaneKey(validatedPaneKey) - } - } - // Why: register only local PTYs with the memory collector — SSH PTYs run remotely and their process tree is invisible to our local `ps`. - if (!args.connectionId) { - // Why: record the spawn-result pid once here so the memory module needn't reach back into ipc/pty on a hot path (works for in-process and daemon-hosted PTYs). - const spawnedPid = result.pid ?? null - // Why: args.worktreeId/sessionId arrive as untrusted IPC strings (type unenforced at the boundary); bound them so malformed/oversized values can't pollute registerPty's maps. - registerPty({ - ptyId: result.id, - worktreeId: - typeof args.worktreeId === 'string' && - args.worktreeId.length > 0 && - args.worktreeId.length <= 512 - ? args.worktreeId - : null, - sessionId: - typeof args.sessionId === 'string' && - args.sessionId.length > 0 && - args.sessionId.length <= 256 - ? args.sessionId - : null, - paneKey: rememberedPaneKey, - pid: - typeof spawnedPid === 'number' && Number.isFinite(spawnedPid) && spawnedPid > 0 - ? spawnedPid - : null - }) - } - // Why: telemetry-plan.md§Agent launch semantics — fire agent_started only after spawn resolved; safeParse each field so a spoofed IPC payload can't poison the event (missing required field skips it). - if (args.telemetry && !stablePaneOwner) { - const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) - const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) - const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) - if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { - track('agent_started', { - agent_kind: agentKindParse.data, - launch_source: launchSourceParse.data, - request_kind: requestKindParse.data, - ...getCohortAtEmit() - }) - } - } - const response = { - ...result, - // Why both or neither: a pane can only adopt proven kitty flags together - // with the sequence boundary they describe. - ...(typeof result.snapshotKittyKeyboardFlags === 'number' && - reconciledSnapshotSeq !== null && - snapshotKittyFlagsCoverReconciledSeq - ? { snapshotSeq: reconciledSnapshotSeq } - : { snapshotKittyKeyboardFlags: undefined }), - ...(!result.isReattach && effectiveLaunchConfig - ? { launchConfig: effectiveLaunchConfig } - : {}), - // Why: a daemon-retry race can surface isReattach even for a minted session id, and a reattach must never claim its cwd was remapped. - ...(startupCwdFallback && !result.isReattach ? { startupCwdFallback } : {}), - // Why: the pane asked to resume and got a fresh session instead; only the - // renderer can say so, and a reattach never ran this launch command. - ...(codexResumeLaunch.notifyResumeUnavailable && !result.isReattach - ? { agentResumeUnavailable: true as const } - : {}) - } - // Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory. - sendPtySpawnedToRenderer(result.id) - if (!args.connectionId) { - options?.onCodexHomePtySpawned?.({ - id: result.id, - codexHomePath: selectedCodexHomePath, - startedAt: codexHomeLaunchStartedAt, - startedSequence: codexHomeLaunchStartedSequence, - ...codexReattachedHomeRouteField( - reattachedCodexHomeRoutes, - result.id, - result.isReattach === true - ), - ...(result.isReattach === true - ? { reattached: true } - : baseEnv - ? { launchEnv: baseEnv } - : {}) - }) - } - return resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, response) - } catch (err) { - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.( - pendingRegistrationPtyId, - rejectedRegistrationCandidate?.incarnationId - ) - pendingRegistrationPtyId = null - } - // Why: once the reservation is created, any later throw — - // spawn failure, persist failure, or a post-spawn helper such as - // seedHeadlessTerminal/registerPty/track — must settle it. Otherwise - // it lingers in paneSpawnReservationsByOwnerKey and every future spawn - // for this pane awaits a promise that never resolves. reject is a - // no-op once the reservation has already resolved. - rejectPaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, err) - throw err - } finally { - releaseWorktreeSpawn?.() - finishTerminalInstall() - } - } - ) - - const reportUnavailablePtyWrite = (id: string, error: unknown): void => { - if (!isPtyWriteUnavailableError(error) || isRendererGone(mainWindow)) { - return - } - sendToRenderer(mainWindow, 'pty:writeUnavailable', { id }) - } - - const writePtyProviderInputWithinLimit = ( - provider: IPtyProvider, - id: string, - data: string - ): boolean | Promise => { - const chunks = iterateTerminalInputChunks(data) - const first = chunks.next() - if (first.done) { - provider.write(id, data) - return true - } - const second = chunks.next() - if (second.done) { - provider.write(id, first.value) - return true - } - return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value) - } - - const writePtyProviderInput = ( - provider: IPtyProvider, - id: string, - data: string - ): boolean | Promise => { - try { - const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) - if (typeof tooLarge === 'boolean') { - return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data) - } - return tooLarge - .then((result) => (result ? false : writePtyProviderInputWithinLimit(provider, id, data))) - .catch((error) => { - reportUnavailablePtyWrite(id, error) - return false - }) - } catch (error) { - reportUnavailablePtyWrite(id, error) - return false - } - } - - const writePtyProviderInputChunks = async ( - provider: IPtyProvider, - id: string, - chunks: Iterator, - firstChunk: string, - secondChunk: string - ): Promise => { - try { - let chunk: IteratorResult = { done: false, value: firstChunk } - let nextChunk: IteratorResult = { done: false, value: secondChunk } - while (!chunk.done) { - provider.write(id, chunk.value) - if (!nextChunk.done) { - await new Promise((resolve) => setTimeout(resolve, 0)) - } - chunk = nextChunk - nextChunk = chunks.next() - } - return true - } catch (error) { - reportUnavailablePtyWrite(id, error) - return false - } - } - - type PtyWritePayload = { id: string; data: string } - type PtyViewportClaimPayload = { id: string; cols: number; rows: number } - - const isPtyWritePayload = (value: unknown): value is PtyWritePayload => - typeof value === 'object' && - value !== null && - typeof (value as { id?: unknown }).id === 'string' && - (value as { id: string }).id.length > 0 && - typeof (value as { data?: unknown }).data === 'string' - - const isPtyViewportClaimPayload = (value: unknown): value is PtyViewportClaimPayload => - typeof value === 'object' && - value !== null && - typeof (value as { id?: unknown }).id === 'string' && - (value as { id: string }).id.length > 0 && - typeof (value as { cols?: unknown }).cols === 'number' && - Number.isFinite((value as { cols: number }).cols) && - typeof (value as { rows?: unknown }).rows === 'number' && - Number.isFinite((value as { rows: number }).rows) && - (value as { cols: number }).cols > 0 && - (value as { rows: number }).rows > 0 - - // Why null-tolerant: with no renderer there is no sender that can legitimately match, - // so every write is rejected. These handlers cannot fire headless anyway — ipcMain has - // nothing to deliver from — but failing closed is the right answer if that ever changes. - const isPtyWriteEventFromMainWindow = ( - event: IpcMainEvent | IpcMainInvokeEvent, - mainWebContents: WebContents | null - ): boolean => - mainWebContents !== null && - event.sender === mainWebContents && - !isRendererGone(mainWindow) && - !(typeof mainWebContents.isDestroyed === 'function' && mainWebContents.isDestroyed()) - - const writePtyInput = (args: PtyWritePayload): boolean | Promise => { - // Why: mobile-presence-lock defense-in-depth — the renderer's onData guard can let one keystroke slip during the state-flip lag, so catch it server-side. See docs/mobile-presence-lock.md. - if (runtime?.getDriver(args.id).kind === 'mobile') { - return false - } - const provider = ptyOwnership.has(args.id) ? tryGetProviderForPty(args.id) : undefined - if (!provider) { - return false - } - try { - const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) - if (visibleRendererPtys.has(args.id)) { - clearHiddenRendererResizeOutput(args.id) - } - return writePtyProviderInput(provider, args.id, args.data) - } catch { - return false - } - } - - const writePtyInputAccepted = (args: PtyWritePayload): boolean | Promise => { - if (runtime?.getDriver(args.id).kind === 'mobile') { - return false - } - // Why: the ack infers Ctrl+C/Escape reached the local PTY; SSH providers are fire-and-forget relay notifications and can't truthfully acknowledge yet. - if (ptyOwnership.get(args.id) !== null) { - return false - } - const provider = tryGetProviderForPty(args.id) - if (!provider?.hasPty?.(args.id)) { - return false - } - try { - const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) - if (visibleRendererPtys.has(args.id)) { - clearHiddenRendererResizeOutput(args.id) - } - return writePtyProviderInput(provider, args.id, args.data) - } catch { - return false - } - } - - const hostViewportClaimTails = new Map>() - - ipc.on('pty:write', (event, args: unknown) => { - if ( - !isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) || - !isPtyWritePayload(args) - ) { - return - } - const claimTail = hostViewportClaimTails.get(args.id) - if (claimTail) { - void claimTail.then((claimed) => (claimed ? writePtyInput(args) : false)) - return - } - writePtyInput(args) - }) - ipc.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise => { - if ( - !isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) || - !isPtyWritePayload(args) - ) { - return false - } - const claimTail = hostViewportClaimTails.get(args.id) - return claimTail - ? claimTail.then((claimed) => (claimed ? writePtyInputAccepted(args) : false)) - : writePtyInputAccepted(args) - }) - - ipc.removeAllListeners('pty:claimViewport') - ipc.on('pty:claimViewport', (event, args: unknown) => { - if ( - !isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) || - !runtime || - !isPtyViewportClaimPayload(args) - ) { - return - } - const prior = hostViewportClaimTails.get(args.id) - // Why: two panes can mirror one PTY — never let a later no-op claim replace the in-flight resize that the following host input must await. - const claim = ( - prior - ? prior.then( - () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows), - () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) - ) - : runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) - ).catch(() => false) - hostViewportClaimTails.set(args.id, claim) - void claim.then(() => { - if (hostViewportClaimTails.get(args.id) === claim) { - hostViewportClaimTails.delete(args.id) - } - }) - }) - - // Why: resize is fire-and-forget — ipc.on (not .handle) halves IPC traffic by skipping the empty acknowledgement reply. - ipc.removeAllListeners('pty:resize') - ipc.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => { - // Why: after a desktop-fit override change the renderer's safeFit cascade re-measures ALL panes (background ones at full width), so suppress every pty:resize in this window to avoid corrupting PTY dimensions. - if (runtime?.isResizeSuppressed()) { - return - } - // Why: presence-lock defense-in-depth — while a phone or remote-desktop viewer drives the width, host-side resizes must not reach the PTY or its alt-screen grid garbles; load-bearing because the renderer mirror lags one IPC hop. See docs/mobile-presence-lock.md. - const mobileOwnsResize = runtime?.getDriver(args.id).kind === 'mobile' - const remoteDesktopOwnsResize = runtime?.isRemoteDesktopResizeDriven?.(args.id) === true - if (mobileOwnsResize || remoteDesktopOwnsResize) { - if (remoteDesktopOwnsResize) { - runtime?.recordRemoteDesktopHostReclaimTarget(args.id, args.cols, args.rows) - } - return - } - const provider = tryGetProviderForPty(args.id) - if (!provider) { - return - } - const markedHiddenResizeOutput = rendererPtyIsKnownHidden(args.id) - if (markedHiddenResizeOutput) { - // Why: alt-screen TUIs repaint on SIGWINCH; a hidden repaint read after switch-back must not masquerade as live output and overwrite the correctly-sized screen. - pendingHiddenRendererResizeOutputPtys.add(args.id) - deliveredHiddenRendererResizeOutputPtys.delete(args.id) - } else if (visibleRendererPtys.has(args.id)) { - // Why: after the stale hidden-resize repaint is observed, the renderer's visible resize pulse owns the next repaint. - clearDeliveredHiddenRendererResizeOutput(args.id) - } - try { - provider.resize(args.id, args.cols, args.rows) - } catch { - if (markedHiddenResizeOutput) { - pendingHiddenRendererResizeOutputPtys.delete(args.id) - } - return - } - ptySizes.set(args.id, { cols: args.cols, rows: args.rows }) - runtime?.onExternalPtyResize(args.id, args.cols, args.rows) - }) - - // Why: pty:reportGeometry is a measurement-only sibling of pty:resize — it refreshes the restore-target cache (never resizes) so mobile-fit hold learns real desktop dims even while resize is blocked. See docs/mobile-fit-hold.md. - ipc.removeAllListeners('pty:reportGeometry') - ipc.on('pty:reportGeometry', (_event, args: { id: string; cols: number; rows: number }) => { - runtime?.recordRendererGeometry(args.id, args.cols, args.rows) - }) - - // Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold-restore cache after the renderer consumed it; no-op for non-daemon providers. - ipc.on('pty:ackColdRestore', (_event, args: { id: string }) => { - const provider = tryGetProviderForPty(args.id) - if (provider && 'ackColdRestore' in provider && typeof provider.ackColdRestore === 'function') { - provider.ackColdRestore(args.id) - } - }) - - // Why: renderer ACKs bound main→renderer delivery without stopping PTY ingestion — agent/status consumers still see every chunk via the provider/runtime path. - ipc.on( - 'pty:ackData', - (_event, args: { id: string; charCount?: number; processedChars?: number }) => { - lastAckReceivedAtMs = Date.now() - // Why: a live ACK channel means a future unanswered probe is a fresh diagnostic event, not a continuation of the last silent streak. - deliveryResyncUnansweredWarnLogged = false - let acknowledged = 0 - if (typeof args.processedChars === 'number' && Number.isFinite(args.processedChars)) { - acknowledged = applyCumulativeAck(args.id, Math.max(0, args.processedChars)) - } else { - // Why: tolerate legacy per-chunk delta payloads — dev hot-reload can pair an old renderer with a new main. - const accounting = rendererDeliveryAccountingByPty.get(args.id) - const delta = Number.isFinite(args.charCount) ? Math.max(0, args.charCount ?? 0) : 0 - acknowledged = accounting ? applyCumulativeAck(args.id, accounting.ackedChars + delta) : 0 - } - tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged) - schedulePendingDataAfterCreditReport(acknowledged > 0) - } - ) - - ipc.on( - 'pty:deliveryResyncResponse', - (_event, args: { requestId: number; processedCharsByPty: Record }) => { - if ( - deliveryResyncOutstandingRequestId === null || - args?.requestId !== deliveryResyncOutstandingRequestId - ) { - return - } - clearDeliveryResyncProbe() - deliveryResyncUnansweredWarnLogged = false - // Why max-merge: the renderer's cumulative totals are authoritative for what it processed, draining exactly the in-flight debt from lost ACKs. - let creditedAny = false - for (const [id, processedChars] of Object.entries(args.processedCharsByPty ?? {})) { - if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { - continue - } - const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars)) - if (acknowledged > 0) { - creditedAny = true - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) - } - } - schedulePendingDataAfterCreditReport(creditedAny) - } - ) - - // Why invoke + renderer-initiated: the field wedge (v1.4.121-rc.0) kills every main→renderer push channel while invoke survives, so the resync rides here plus a write-off lane. - ipc.handle( - 'pty:reportRendererDeliveryState', - (_event, args: PtyRendererDeliveryStateReport): PtyRendererDeliveryHealthReply => { - // Extra repair lane for the lost-ACK variant: identical max-merge to the resync response, so a heal is only reached when merging cannot drain. - let creditedAny = false - for (const [id, processedChars] of Object.entries(args?.processedCharsByPty ?? {})) { - if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { - continue - } - const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars)) - if (acknowledged > 0) { - creditedAny = true - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) - } - } - let writtenOff: PtyDeliveryWriteOff[] = [] - // Why the main-side ACK-silence check: requiring main to have also seen no ACK stops a buggy/foreign caller from writing off live delivery. - if ( - args?.heal === true && - rendererInFlightTotalChars > 0 && - (lastAckReceivedAtMs === null || - Date.now() - lastAckReceivedAtMs >= PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS) - ) { - writtenOff = writeOffLostRendererDelivery(args) - creditedAny ||= writtenOff.length > 0 - } - schedulePendingDataAfterCreditReport(creditedAny) - let inFlightPtyCount = 0 - for (const accounting of rendererDeliveryAccountingByPty.values()) { - if (accounting.sentChars - accounting.ackedChars > 0) { - inFlightPtyCount++ - } - } - return { - inFlightTotalChars: rendererInFlightTotalChars, - inFlightPtyCount, - msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, - ...(writtenOff.length > 0 ? { writtenOff } : {}) - } - } - ) - - // Why: renderer signals its pty:data listener is live; until then sends are held so boot-window bytes can't drop into a listener-less page and pin the gate. - ipc.removeAllListeners('pty:rendererDispatcherReady') - ipc.on('pty:rendererDispatcherReady', (event) => { - // Why: the reconcile below destructively clears delivery accounting, so a straggler handshake from a dying window must not reset the new window. - if (!isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow))) { - return - } - // Why: a handshake while the gate is already open means a page load whose lifecycle reset was missed; clear the dead page's stale accounting so it can't permanently gate survivors. - if (rendererPtyDispatcherReady) { - resetRendererDeliveryAccountingForLifecycleReset() - } - // Why: real handshake landed — cancel the self-heal watchdog so it can't later force-open the gate. - clearDispatcherReadyWatchdog() - rendererPtyDispatcherReady = true - pendingData.reactivateBlocked() - schedulePendingDataFlush(0) - }) - - ipc.removeAllListeners('pty:setActiveRendererPty') - ipc.on('pty:setActiveRendererPty', (_event, args: { id: string; active: boolean }) => { - if (typeof args.id !== 'string' || !args.id) { - return - } - // Why: renderer scheduling hint only — active panes just get first chance at the bounded output reserve; reads/state/notifications continue for inactive terminals. - if (args.active) { - if (activeRendererPtys.has(args.id)) { - return - } - activeRendererPtys.add(args.id) - } else if (!activeRendererPtys.delete(args.id)) { - return - } - invalidatePendingPtyDrainPriority(args.id) - }) - - ipc.removeAllListeners('pty:setRendererPtyVisible') - ipc.on('pty:setRendererPtyVisible', (_event, args: { id: string; visible: boolean }) => { - if (typeof args.id !== 'string' || !args.id) { - return - } - // Why: data produced while no renderer can see this PTY must keep that origin through batching, even if the user switches back before the flush lands. - rendererVisibilityKnownPtys.add(args.id) - if (args.visible) { - visibleRendererPtys.add(args.id) - closeStartupQueryAuthorityForPty(args.id) - } else { - visibleRendererPtys.delete(args.id) - } - syncPtyBackgroundedDelivery(args.id, 'visibility-report') - }) - - ipc.removeAllListeners('pty:setHiddenRendererPty') - ipc.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => { - if (typeof args.id !== 'string' || !args.id) { - return - } - mainDeliveryBreadcrumbs.record(args.hidden === true ? 'gate-mark' : 'gate-unmark', { - id: redactPtyIdForDiagnostics(args.id) - }) - const transition = transitionHiddenRendererPtyDeliveryState(args.id, args.hidden === true) - if (args.hidden === true) { - closeStartupQueryAuthorityForPty(args.id) - // Why: drop bytes queued for a newly hidden PTY instead of holding them under ACK starvation; reveal restores from the snapshot. - const pending = pendingData.get(args.id) - if (pending && transition.droppable) { - pendingData.delete(args.id) - if (pending.projectionAdmissionIds) { - sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'hidden-drop') - } - updateProducerFlowControl(args.id) - pendingOverflowMarkedPtys.delete(args.id) - const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length) - if (drop.shouldEmitRestoreMarker) { - sendModelRestoreNeededMarker( - args.id, - 'hidden-drop', - runtime?.getPtyOutputSequence(args.id) - ) - } - } - if (transition.policyChanged) { - invalidatePendingPtyDrainPolicy(args.id) - } - syncPtyBackgroundedDelivery(args.id, 'gate-mark') - return - } - if (transition.policyChanged) { - invalidatePendingPtyDrainPolicy(args.id) - } - syncPtyBackgroundedDelivery(args.id, 'gate-unmark') - // Why: a reload/remount may have replaced the view that latched restore-needed, so re-emit on unhide; a redundant replay is cheap/idempotent, a missed restore corrupts the pane. - if (transition.droppedWhileHidden) { - sendModelRestoreNeededMarker(args.id, 'unhide', runtime?.getPtyOutputSequence(args.id)) - } - }) - - ipc.removeAllListeners('pty:terminalViewAttributes') - ipc.on('pty:terminalViewAttributes', (_event, args: unknown) => { - // Why validate-or-drop: a malformed palette gives a wrong color reply that breaks TUI theme detection worse than the silent-until-first-push default. - const attributes = validateTerminalViewAttributes(args) - if (attributes) { - setTerminalViewAttributes(attributes) - } - }) - - ipc.removeAllListeners('pty:setPtyDeliveryInterest') - ipc.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { - if (typeof args.id !== 'string' || !args.id) { - return - } - // Why: any delivery interest suppresses the hidden-delivery gate (raw-byte consumers keep receiving while hidden); not synced to the daemon pacer so interest churn can't un-pace a flood. - const settings = getSettings?.() - const wasDroppable = shouldDropHiddenRendererPtyData(args.id, settings) - setRendererPtyDeliveryInterest(args.id, args.interested === true) - if (wasDroppable !== shouldDropHiddenRendererPtyData(args.id, settings)) { - invalidatePendingPtyDrainPolicy(args.id) - } - }) - - ipc.removeAllListeners('pty:signal') - ipc.on('pty:signal', (_event, args: { id: string; signal: string }) => { - tryGetProviderForPty(args.id) - ?.sendSignal(args.id, args.signal) - .catch(() => {}) - }) - - ipc.removeAllListeners('pty:clearBuffer') - ipc.on('pty:clearBuffer', (_event, args: { id: string }) => { - // Why: clear PTY-side state (ConPTY/daemon/SSH buffer) so the next prompt repaint doesn't land at a stale cursor row. - tryGetProviderForPty(args.id) - ?.clearBuffer(args.id) - .catch(() => {}) - runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {}) - }) - - ipc.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => { - if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { - // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. - throw new Error('Invalid PTY provider id') - } - runtime?.markPtyStopRequested?.(args.id) - const ownedConnectionId = ptyOwnership.get(args.id) - const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null - const connectionId = ownedConnectionId ?? parsedSshId?.connectionId - // Why: wait for daemon startup before selecting the local provider, else a fallback shutdown falsely succeeds and orphans a restored daemon PTY (#7742). - const startupPromise = getLocalPtyProviderStartupPromise(connectionId) - if (startupPromise) { - await startupPromise - } - const provider = connectionId ? sshProviders.get(connectionId) : tryGetProviderForPty(args.id) - if (!provider && connectionId) { - // Why: detached SSH PTYs intentionally keep ownership after their - // provider is unregistered; hydrated app-scoped ids can also arrive - // before ownership is rebuilt. Tombstone instead of falling back local. - const incarnationId = finishPtyShutdown(args.id, connectionId, store) - runtime?.markPtyLivenessUnverifiable?.(args.id, SSH_PROVIDER_UNREGISTERED_REASON) - runtime?.onPtyExit(args.id, -1, incarnationId) - rememberSyntheticKillExit(args.id) - sendPtyExitToRenderer({ id: args.id, code: -1 }) - return - } - const shutdownProvider = provider ?? getProviderForPty(args.id) - let providerExitObserved = false - try { - providerExitObserved = await shutdownProviderAndDetectExit(shutdownProvider, args.id, { - immediate: true, - keepHistory: args.keepHistory ?? false - }) - } catch (err) { - if (!isPtyAlreadyGoneError(err)) { - // Why: a failed shutdown can leave the process alive (SSH relay grace window / local daemon); keep ownership/lease state so the user can retry. - throw err - } - /* session already dead — cleanup below handles the rest */ - } - // Why: some shutdown paths do not emit onExit through the provider listener. - // Explicit cleanup is idempotent and covers already-dead PTYs. - const incarnationId = finishPtyShutdown(args.id, connectionId, store) - if (!providerExitObserved) { - runtime?.onPtyExit(args.id, -1, incarnationId) - rememberSyntheticKillExit(args.id) - sendPtyExitToRenderer({ id: args.id, code: -1 }) - } - }) - - ipc.handle('pty:listSessions', async (): Promise => { - const deduped = new Map() - const admission = new PtyProcessListAdmission() - await visitPtyProcessListingsInBatches( - registeredPtyProviders(), - ({ provider, connectionId }) => - connectionId === null ? provider.listProcesses() : provider.listProcesses().catch(() => []), - ({ provider, connectionId }, sessions) => { - for (const rawSession of sessions) { - const session = admission.admit(rawSession) - // Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider. - ptyOwnership.set(session.id, connectionId) - deduped.set(session.id, { - id: session.id, - cwd: session.cwd, - title: session.title, - // Why: the renderer's binding map is empty during restore, so ownership is the only - // liveness evidence it has. Absence is authoritative only from a provider that - // serializes claims — otherwise it is 'unknown', never 'absent' (#8459). - agentOwnership: - (session.agentSessionOwners?.length ?? 0) > 0 - ? 'present' - : provider.providesAgentSessionOwnerListings?.(session.id) === true - ? 'absent' - : 'unknown' - }) - } - } - ) - return Array.from(deduped.values()) - }) - - ipc.handle( - 'pty:getAuthoritativeBufferSnapshotCapabilities', - async (_event, args: { ids?: unknown }) => { - const ids = Array.isArray(args?.ids) ? args.ids.slice(0, 512) : [] - const hasLocalPtyId = ids.some((value) => { - if ( - typeof value !== 'string' || - value.length === 0 || - value.length > 512 || - value.startsWith('remote:') || - parseAppSshPtyId(value) - ) { - return false - } - const ownedConnectionId = ptyOwnership.get(value) - return ownedConnectionId === undefined || ownedConnectionId === null - }) - if (hasLocalPtyId) { - await getLocalPtyProviderStartupPromise() - } - const capabilities: { id: string; authoritative: boolean | null }[] = [] - const seen = new Set() - for (const value of ids) { - if ( - typeof value !== 'string' || - value.length === 0 || - value.length > 512 || - seen.has(value) - ) { - continue - } - seen.add(value) - const provider = tryGetProviderForPty(value) - // Resolved providers without the optional method are definitively non-authoritative; null remains retryable. - capabilities.push({ - id: value, - authoritative: - provider === undefined || provider === null - ? null - : provider.canProvideAuthoritativeBufferSnapshot - ? provider.canProvideAuthoritativeBufferSnapshot(value) - : false - }) - } - return capabilities - } - ) - - ipc.handle('pty:hasPty', async (_event, args: { id: string }): Promise => { - if (typeof args?.id !== 'string' || args.id.startsWith('remote:')) { - // Why: same routing hazard pty:kill guards against — ptyOwnership never holds - // a runtime terminal handle and parseAppSshPtyId ignores it, so the lookup - // falls through to the local provider and its "not in my table" reads as an - // authoritative dead. That is a fabricated answer about another host's PTY. - return null - } - const ownedConnectionId = ptyOwnership.get(args.id) - const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null - const provider = parsedSshId - ? sshProviders.get(parsedSshId.connectionId) - : tryGetProviderForPty(args.id) - if (!provider?.hasPty) { - return null - } - try { - return provider.hasPty(args.id) - } catch { - // Why: liveness is only allowed to close panes on an authoritative false. - return null - } - }) - - ipc.handle('pty:hasChildProcesses', async (_event, args: { id: string }): Promise => { - if (!hasPtyProviderForInspection(args.id)) { - return false - } - return getProviderForPty(args.id).hasChildProcesses(args.id) - }) - - ipc.handle( - 'pty:getForegroundProcess', - async (_event, args: { id: string }): Promise => { - if (!hasPtyProviderForInspection(args.id)) { - return null - } - return getProviderForPty(args.id).getForegroundProcess(args.id) - } - ) - - ipc.handle('pty:inspectProcess', async (_event, args: { id: string }) => - inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id) - ) - - ipc.handle( - 'pty:confirmForegroundProcess', - async (_event, args: { id: string }): Promise => { - if (!hasPtyProviderForInspection(args.id)) { - return null - } - const provider = getProviderForPty(args.id) - // Why: the cached foreground API would turn stale process identity into shell/agent authority at a command boundary. - return provider.confirmForegroundProcess?.(args.id) ?? null - } - ) - - // Why: Cmd+D split needs the live shell cwd so the new pane inherits it (not the worktree root); '' means unknown/unresolvable (Windows) → renderer falls through. - ipc.handle('pty:getCwd', async (_event, args: { id: string }): Promise => { - try { - return await getProviderForPty(args.id).getCwd(args.id) - } catch { - return '' - } - }) - - // Why: prefer the provider's APPLIED size over the requested ptySizes so the renderer's resume drift-check can spot a dropped resize; null means "cannot confirm" → re-forward once. - ipc.handle( - 'pty:getSize', - async (_event, args: { id: string }): Promise<{ cols: number; rows: number } | null> => { - const provider = tryGetProviderForPty(args.id) - try { - if (provider?.getAppliedSize) { - // Why: a provider-owned null means it could not verify the applied - // grid; preserve null so the renderer re-forwards instead of trusting - // the requested-size cache that may describe a dropped resize. - return await provider.getAppliedSize(args.id) - } - } catch { - // Fall through to the requested-size cache so a dead daemon/relay can't throw across the IPC boundary. - } - return ptySizes.get(args.id) ?? null - } - ) - - // Pre-signal handshake handlers (declare→spawn→settle/clear); see docs/mobile-prefer-renderer-scrollback.md and `pendingByPaneKey` above. - ipc.handle( - 'pty:declarePendingPaneSerializer', - async (event, args: { paneKey?: unknown }): Promise => { - if (!isValidPaneKey(args.paneKey)) { - throw new Error('Invalid paneKey') - } - return declarePendingPaneSerializer(args.paneKey, event?.sender) - } - ) - - ipc.handle( - 'pty:settlePaneSerializer', - async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { - if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { - return - } - const ptyId = pendingPtyIdBySerializerGeneration.get(args.gen) - const settledCurrentGeneration = settlePendingPaneSerializer(args.paneKey, args.gen) - // Why: the generation-to-PTY binding survives late teardown of a reused id; paneKey reverse maps may already be gone. - pendingPtyIdBySerializerGeneration.delete(args.gen) - if (settledCurrentGeneration && ptyId) { - rendererSerializerReadiness.markReady(ptyId) - } - } - ) - - ipc.handle( - 'pty:clearPendingPaneSerializer', - async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { - if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { - return - } - settlePendingPaneSerializer(args.paneKey, args.gen) - pendingPtyIdBySerializerGeneration.delete(args.gen) - } - ) - - ipc.handle( - 'pty:reportRendererSerializerReady', - async (_event, args: { ptyId?: unknown }): Promise => { - if ( - typeof args.ptyId !== 'string' || - !args.ptyId.startsWith('remote:') || - args.ptyId.length > 512 - ) { - return - } - // Why: remote-runtime panes skip the local spawn cooperation gate, so their exact PTY id is the only readiness key. - rendererSerializerReadiness.markReady(args.ptyId) - } - ) -} - -export function registerHeadlessPtyRuntime( - runtime: OrcaRuntimeService, - getSelectedCodexHomePath?: GetSelectedCodexHomePath, - getSettings?: () => GlobalSettings, - prepareClaudeAuth?: PrepareClaudeAuth, - store?: Store, - prepareCodexSessionResume?: PrepareCodexSessionResume, - lifecycle?: { - onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void - onPtyExit?: (id: string, exitSequence: number) => void - } -): void { - // Why null and not a stub window: headless `orca serve` has no renderer but needs the - // same PTY handlers so remote clients can drive terminals. This used to pass a fake - // BrowserWindow whose isDestroyed() returned true — a window-shaped object that lied - // about being a window. `null` says the same thing honestly, and keeps `electron` out - // of this path entirely. - registerPtyHandlers( - null, - runtime, - getSelectedCodexHomePath, - getSettings, - prepareClaudeAuth, - store, - { prepareCodexSessionResume, ...lifecycle } - ) -} - -/** - * Kill in-process local PTYs. Daemon-backed PTYs are preserved by daemon disconnect. - */ -export function killAllPty(): void { - if (localProvider instanceof LocalPtyProvider) { - localProvider.killAll() - } -} +export { + isCurrentPtyExit, + deletePtyOwnership, + setPtyOwnership, + restorePtyIncarnation, + getPtyIdsForConnection +} from './pty/provider/ownership-state' +export { getPtyIdForPaneKey, registerPaneKeyTeardownListener } from './pty/pane/key-state' +export { hasPendingRendererSerializerForPaneKey } from './pty/pane/serializer-state' +export type { + BuildPtyHostEnvOptions, + CodexHomeLaunchContext, + GetSelectedCodexHomePath, + PrepareCodexSessionResume, + CodexHomePtySpawnedLifecycleArgs +} from './pty/host-env/types' +export { resolveCodexHomeAfterManagedAuthReadiness } from './pty/host-env/codex-home' +export { buildPtyHostEnv } from './pty/host-env/assembly' +export { + registerSshPtyProvider, + unregisterSshPtyProvider, + getSshPtyProvider, + getLocalPtyProvider, + setLocalPtyProvider +} from './pty/provider/registry' +export { clearProviderPtyState, clearPtyOwnershipForConnection } from './pty/provider/state-cleanup' +export { + rebindLocalProviderListeners, + unbindLocalProviderListeners +} from './pty/provider/listener-lifecycle' +export type { PtyRendererDeliveryDebugSnapshot } from './pty/delivery/debug' +export { + getPtyRendererDeliveryDebugSnapshot, + resetPtyRendererDeliveryDebug +} from './pty/delivery/debug' +export { registerPtyHandlers } from './pty/register-handlers' +export { registerHeadlessPtyRuntime } from './pty/register-headless-runtime' +export { killAllPty } from './pty/kill-all' diff --git a/src/main/ipc/pty/delivery/accept.ts b/src/main/ipc/pty/delivery/accept.ts new file mode 100644 index 00000000000..91117147dde --- /dev/null +++ b/src/main/ipc/pty/delivery/accept.ts @@ -0,0 +1,190 @@ +import type { LegacySshProjectionSemantics } from '../../ssh-pty-legacy-projection' +import { + recordHiddenRendererPtyDataDrop, + shouldDropHiddenRendererPtyData +} from '../../pty-hidden-delivery-gate' +import { + deliveredHiddenRendererResizeOutputPtys, + pendingHiddenRendererResizeOutputPtys, + rendererVisibilityKnownPtys, + visibleRendererPtys, + activeRendererPtys +} from './visibility-state' +import { PTY_BATCH_INTERVAL_MS } from './constants' +import { appendPendingPtyData, getDroppedMode2031RendererData } from './pending' +import { sendModelRestoreNeededMarker, sendPtyDataToRenderer } from './payload' +import { shouldSendInteractiveOutputNow } from './interactive' +import { requestDeliveryResyncForGatedPty } from './accounting' +import { warnIfDroppingHiddenBytesForVisiblePty } from './debug-snapshot' +import { clearFlushTimerIfIdle } from './flush' +import type { PtyIpcSession } from '../session' + +export function rendererPtyIsKnownHidden(id: string): boolean { + return rendererVisibilityKnownPtys.has(id) && !visibleRendererPtys.has(id) +} + +export function ptyHasHiddenRendererResizeOutput(id: string): boolean { + return ( + pendingHiddenRendererResizeOutputPtys.has(id) || deliveredHiddenRendererResizeOutputPtys.has(id) + ) +} + +export function markHiddenRendererResizeOutputDelivered(id: string): void { + if (!pendingHiddenRendererResizeOutputPtys.delete(id)) { + return + } + deliveredHiddenRendererResizeOutputPtys.add(id) +} + +export function clearDeliveredHiddenRendererResizeOutput(id: string): void { + deliveredHiddenRendererResizeOutputPtys.delete(id) +} + +export function clearHiddenRendererResizeOutput(id: string): void { + pendingHiddenRendererResizeOutputPtys.delete(id) + deliveredHiddenRendererResizeOutputPtys.delete(id) +} + +export function acceptPtyDataForRenderer( + session: PtyIpcSession, + payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + }, + outputSeq: number | undefined, + projection?: LegacySshProjectionSemantics +): void { + const rawLength = payload.sequenceChars ?? payload.data.length + const preservesSeq = !payload.transformed && rawLength === payload.data.length + const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined + const projectionId = projection?.identity.projectionSemanticsId + if (session.mainWindow.isDestroyed()) { + if (projectionId) { + session.sshOutputIntake?.transferProjections([projectionId], 'renderer-destroyed') + } + if (session.flushTimer) { + clearTimeout(session.flushTimer) + session.flushTimer = null + } + session.producerFlowControl.releaseAll() + session.clearDeliveryResyncProbe() + session.clearPendingPtyData() + session.pendingOverflowMarkedPtys.clear() + session.rendererDeliveryAccountingByPty.clear() + session.rendererInFlightTotalChars = 0 + session.clearDispatcherReadyWatchdog() + return + } + if (session.rendererExitingPtyIds.has(payload.id)) { + if (projectionId) { + session.sshOutputIntake?.transferProjections([projectionId], 'pty-exiting') + } + return + } + if (shouldDropHiddenRendererPtyData(payload.id, session.getSettings?.())) { + if (projectionId) { + session.sshOutputIntake?.transferProjections([projectionId], 'hidden-drop') + } + const droppedChars = projection ? rawLength : payload.data.length + const drop = recordHiddenRendererPtyDataDrop(payload.id, droppedChars) + warnIfDroppingHiddenBytesForVisiblePty(session, payload.id, droppedChars) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker(session, payload.id, 'hidden-drop', outputSeq) + } + return + } + if (payload.data.length === 0 && !payload.transformed) { + if (projectionId) { + session.sshOutputIntake?.transferProjections([projectionId], 'empty-projection') + } + return + } + const containsBackgroundOutput = + rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id) + if (containsBackgroundOutput) { + markHiddenRendererResizeOutputDelivered(payload.id) + } + const overflowMarkedBeforeAppend = session.pendingOverflowMarkedPtys.has(payload.id) + if (projection?.desktopSpan) { + session.sourceCreditPendingPtys.add(payload.id) + } + const pending = appendPendingPtyData( + session, + payload.id, + session.pendingData.get(payload.id), + payload.data, + startSeq, + preservesSeq, + containsBackgroundOutput, + rawLength, + payload.transformed === true, + projectionId + ) + const shouldEmitPendingCapRestoreMarker = + pending.droppedOutput === true && + !overflowMarkedBeforeAppend && + session.pendingOverflowMarkedPtys.has(payload.id) + const nextData = pending.data + getDroppedMode2031RendererData(pending) + const isInteractiveOutput = shouldSendInteractiveOutputNow( + payload.id, + nextData, + performance.now() + ) + if (isInteractiveOutput && session.rendererPtyDispatcherReady) { + if (!session.canSendPtyDataToRenderer(payload.id, { interactive: true })) { + session.setPendingPtyData(payload.id, pending) + if (shouldEmitPendingCapRestoreMarker) { + sendModelRestoreNeededMarker(session, payload.id, 'pending-cap', outputSeq) + } + session.updateProducerFlowControl(payload.id) + requestDeliveryResyncForGatedPty(session) + return + } + session.deletePendingPtyData(payload.id) + clearFlushTimerIfIdle(session) + if (shouldEmitPendingCapRestoreMarker) { + sendModelRestoreNeededMarker(session, payload.id, 'pending-cap', outputSeq) + } + session.pendingOverflowMarkedPtys.delete(payload.id) + try { + sendPtyDataToRenderer( + session, + payload.id, + { + id: payload.id, + data: nextData, + ...(typeof pending.startSeq === 'number' + ? { + seq: pending.startSeq + (pending.rawLength ?? nextData.length), + rawLength: pending.rawLength ?? nextData.length + } + : {}), + ...(pending.transformed ? { transformed: true } : {}), + ...(pending.containsBackgroundOutput === true ? { background: true } : {}), + ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) + }, + pending.projectionAdmissionIds + ) + } finally { + session.updateProducerFlowControl(payload.id) + } + return + } + session.setPendingPtyData(payload.id, pending) + if (shouldEmitPendingCapRestoreMarker) { + sendModelRestoreNeededMarker(session, payload.id, 'pending-cap', outputSeq) + } + session.updateProducerFlowControl(payload.id) + if ( + !session.canSendPtyDataToRenderer(payload.id, { + interactive: activeRendererPtys.has(payload.id) + }) + ) { + requestDeliveryResyncForGatedPty(session) + } + if (!session.flushTimer) { + session.schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) + } +} diff --git a/src/main/ipc/pty/delivery/accounting.ts b/src/main/ipc/pty/delivery/accounting.ts new file mode 100644 index 00000000000..eddc49be3f2 --- /dev/null +++ b/src/main/ipc/pty/delivery/accounting.ts @@ -0,0 +1,219 @@ +import type { + PtyDeliveryWriteOff, + PtyRendererDeliveryStateReport +} from '../../../../shared/pty-renderer-delivery-health' +import { tryGetProviderForPty } from '../provider/registry' +import { mainDeliveryBreadcrumbs } from './debug' +import { + PTY_DELIVERY_RESYNC_TIMEOUT_MS, + PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS, + PTY_RENDERER_INTERACTIVE_RESERVE_CHARS, + PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS, + PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS +} from './constants' +import type { PtyIpcSession } from '../session' +import type { PendingPtyData } from '../../pty-pending-data-drain-queue' + +export function getRendererInFlightCharsForPty(session: PtyIpcSession, id: string): number { + const accounting = session.rendererDeliveryAccountingByPty.get(id) + return accounting ? accounting.sentChars - accounting.ackedChars : 0 +} + +export function recordPtyRendererDeliveryPressure(session: PtyIpcSession, id: string): void { + session.peakPendingChars = Math.max( + session.peakPendingChars, + session.pendingData.totalPendingChars + ) + session.peakMaxPendingCharsByPty = Math.max( + session.peakMaxPendingCharsByPty, + session.pendingData.get(id)?.data.length ?? 0 + ) + session.peakRendererInFlightChars = Math.max( + session.peakRendererInFlightChars, + session.rendererInFlightTotalChars + ) + session.peakMaxRendererInFlightCharsByPty = Math.max( + session.peakMaxRendererInFlightCharsByPty, + getRendererInFlightCharsForPty(session, id) + ) +} + +export function setPendingPtyData( + session: PtyIpcSession, + id: string, + pending: PendingPtyData +): void { + session.pendingData.set(id, pending) + recordPtyRendererDeliveryPressure(session, id) +} + +export function deletePendingPtyData(session: PtyIpcSession, id: string): void { + session.pendingData.delete(id) +} + +export function clearPendingPtyData(session: PtyIpcSession): void { + for (const pending of session.pendingData.values()) { + if (pending.projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections( + pending.projectionAdmissionIds, + 'renderer-lifecycle-reset' + ) + } + } + session.pendingData.clear() + session.sourceCreditPendingPtys.clear() +} + +export function canSendPtyDataToRenderer( + session: PtyIpcSession, + id: string, + options: { interactive?: boolean } = {} +): boolean { + const totalLimit = + PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS + + (options.interactive === true ? PTY_RENDERER_INTERACTIVE_RESERVE_CHARS : 0) + // Why per-PTY (not global) reserve: keep one active pane responsive without letting every background pane burst past the cap. + const ptyLimit = + PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS + + (options.interactive === true ? PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS : 0) + return ( + getRendererInFlightCharsForPty(session, id) < ptyLimit && + session.rendererInFlightTotalChars < totalLimit + ) +} + +export function applyCumulativeAck( + session: PtyIpcSession, + id: string, + processedChars: number +): number { + const accounting = session.rendererDeliveryAccountingByPty.get(id) + if (!accounting) { + return 0 + } + // Clamped to sentChars so a corrupt payload cannot drive in-flight negative. + const nextAckedChars = Math.min( + accounting.sentChars, + Math.max(accounting.ackedChars, processedChars) + ) + const acknowledged = nextAckedChars - accounting.ackedChars + accounting.ackedChars = nextAckedChars + if (acknowledged > 0) { + accounting.lastAckAtMs = Date.now() + } + session.rendererInFlightTotalChars = Math.max( + 0, + session.rendererInFlightTotalChars - acknowledged + ) + if (acknowledged > 0) { + session.sshOutputIntake?.settleProjectionPrefix(id, acknowledged) + } + return acknowledged +} + +export function schedulePendingDataAfterCreditReport( + session: PtyIpcSession, + creditedAny: boolean +): void { + if (creditedAny) { + session.pendingData.reactivateBlocked() + } + if (session.pendingData.size > 0 && !session.flushTimer) { + session.schedulePendingDataFlush(0) + } +} + +export function clearDeliveryResyncProbe(session: PtyIpcSession): void { + session.deliveryResyncOutstandingRequestId = null + if (session.deliveryResyncTimer) { + clearTimeout(session.deliveryResyncTimer) + session.deliveryResyncTimer = null + } +} + +export function requestDeliveryResyncForGatedPty(session: PtyIpcSession): void { + if (session.deliveryResyncOutstandingRequestId !== null || session.mainWindow.isDestroyed()) { + return + } + session.deliveryResyncRequestSerial += 1 + const requestId = session.deliveryResyncRequestSerial + session.deliveryResyncOutstandingRequestId = requestId + session.deliveryResyncTimer = setTimeout(() => { + if (session.deliveryResyncOutstandingRequestId !== requestId) { + return + } + clearDeliveryResyncProbe(session) + // Why no mutation on timeout: unanswered means dead IPC that only a reload cures; log once per silent streak to avoid spamming every probe. + if (session.deliveryResyncUnansweredWarnLogged) { + return + } + session.deliveryResyncUnansweredWarnLogged = true + console.warn('[pty] delivery resync probe unanswered — renderer IPC unresponsive', { + msSinceLastAck: + session.lastAckReceivedAtMs === null ? null : Date.now() - session.lastAckReceivedAtMs, + ...session.readCurrentPtyRendererDeliveryDebugSnapshot() + }) + }, PTY_DELIVERY_RESYNC_TIMEOUT_MS) + session.deliveryResyncTimer.unref?.() + session.mainWindow.webContents.send('pty:requestDeliveryResync', { requestId }) +} + +export function writeOffLostRendererDelivery( + session: PtyIpcSession, + report: PtyRendererDeliveryStateReport +): PtyDeliveryWriteOff[] { + const writtenOff: PtyDeliveryWriteOff[] = [] + for (const [id, accounting] of session.rendererDeliveryAccountingByPty) { + if (accounting.sentChars - accounting.ackedChars <= 0) { + continue + } + const received = report.receivedCharsByPty?.[id] + const receivedChars = + typeof received === 'number' && Number.isFinite(received) ? Math.max(0, received) : 0 + // Why skip: received-but-unparsed bytes are alive in the renderer write queue; their deferred ACK still repays this debt. + if (receivedChars > accounting.ackedChars) { + continue + } + const acknowledged = applyCumulativeAck(session, id, accounting.sentChars) + if (acknowledged <= 0) { + continue + } + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + // Why drop pending: everything at/before markerSeq comes from the snapshot, so flushing pre-marker bytes would double-paint the restore. + const pending = session.pendingData.get(id) + if (pending) { + if (pending.projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections( + pending.projectionAdmissionIds, + 'renderer-delivery-writeoff' + ) + } + session.pendingDroppedChars += pending.data.length + deletePendingPtyData(session, id) + session.pendingOverflowMarkedPtys.delete(id) + session.updateProducerFlowControl(id) + } + const markerSeq = session.runtime?.getPtyOutputSequence(id) + writtenOff.push({ + id, + ...(typeof markerSeq === 'number' ? { markerSeq } : {}), + writtenOffChars: acknowledged + }) + } + if (writtenOff.length > 0) { + clearDeliveryResyncProbe(session) + session.deliveryResyncUnansweredWarnLogged = false + mainDeliveryBreadcrumbs.record('delivery-heal-writeoff', { + writtenOffPtyCount: writtenOff.length, + writtenOffChars: writtenOff.reduce((sum, { writtenOffChars }) => sum + writtenOffChars, 0) + }) + console.warn('[pty] delivery heal: wrote off renderer-bound bytes lost in push channel', { + rendererPtyDataListenerCount: report.rendererPtyDataListenerCount ?? null, + msSinceLastAck: + session.lastAckReceivedAtMs === null ? null : Date.now() - session.lastAckReceivedAtMs, + writtenOffByPty: writtenOff.map(({ id, writtenOffChars }) => ({ id, writtenOffChars })), + ...session.readCurrentPtyRendererDeliveryDebugSnapshot() + }) + } + return writtenOff +} diff --git a/src/main/ipc/pty/delivery/constants.ts b/src/main/ipc/pty/delivery/constants.ts new file mode 100644 index 00000000000..b9b3bb3670c --- /dev/null +++ b/src/main/ipc/pty/delivery/constants.ts @@ -0,0 +1,17 @@ +export const PTY_BATCH_INTERVAL_MS = 2 +export const PTY_BATCH_DRAIN_CONTINUE_MS = 1 +export const PTY_BATCH_FLUSH_CHUNK_CHARS = 16 * 1024 +export const PTY_BATCH_FLUSH_MAX_WRITES = 2 +export const PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS = 512 * 1024 +export const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 8 * 1024 * 1024 +export const PTY_DISPATCHER_READY_WATCHDOG_MS = 10_000 +export const PTY_RENDERER_INTERACTIVE_RESERVE_CHARS = 256 * 1024 +export const PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS = 512 * 1024 +export const PTY_DELIVERY_RESYNC_TIMEOUT_MS = 5_000 +export const PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS = 10_000 +export const INTERACTIVE_OUTPUT_WINDOW_MS = 100 +export const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 +export const INTERACTIVE_REDRAW_MAX_CHARS = PTY_BATCH_FLUSH_CHUNK_CHARS +export const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 +export const DELIVERY_DIAGNOSTICS_MAX_PTYS = 30 +export const DROPPED_QUERY_SALVAGE_MAX_CHARS = 4096 diff --git a/src/main/ipc/pty/delivery/debug-snapshot.ts b/src/main/ipc/pty/delivery/debug-snapshot.ts new file mode 100644 index 00000000000..facc04e4e74 --- /dev/null +++ b/src/main/ipc/pty/delivery/debug-snapshot.ts @@ -0,0 +1,168 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import { + type PtyMainDeliveryDiagnostics, + type PtyPerPtyDeliveryDiagnostics, + redactPtyIdForDiagnostics +} from '../../../../shared/pty-delivery-diagnostics' +import { + getHiddenRendererPtyDeliveryDebug, + getHiddenRendererPtyIds, + isHiddenRendererPty +} from '../../pty-hidden-delivery-gate' +import { + lastPowerResumeAtMs, + lastPowerSuspendAtMs, + mainDeliveryBreadcrumbs, + type PtyRendererDeliveryDebugSnapshot +} from './debug' +import { activeRendererPtys, visibleRendererPtys } from './visibility-state' +import { DELIVERY_DIAGNOSTICS_MAX_PTYS } from './constants' +import type { PtyIpcSession } from '../session' + +export function buildMainDeliveryDiagnostics(session: PtyIpcSession): PtyMainDeliveryDiagnostics { + const now = Date.now() + // Include hidden/visible/active members even without an accounting entry: a pty gated before its first byte is exactly the wedge case to surface. + const ids = new Set([ + ...session.rendererDeliveryAccountingByPty.keys(), + ...session.pendingData.keys(), + ...getHiddenRendererPtyIds(), + ...visibleRendererPtys, + ...activeRendererPtys + ]) + const perPty: PtyPerPtyDeliveryDiagnostics[] = [] + for (const id of ids) { + const accounting = session.rendererDeliveryAccountingByPty.get(id) + perPty.push({ + id: redactPtyIdForDiagnostics(id), + sentChars: accounting?.sentChars ?? 0, + ackedChars: accounting?.ackedChars ?? 0, + inFlightChars: accounting ? accounting.sentChars - accounting.ackedChars : 0, + pendingChars: session.pendingData.get(id)?.data.length ?? 0, + hidden: isHiddenRendererPty(id), + visible: visibleRendererPtys.has(id), + active: activeRendererPtys.has(id), + msSinceLastSend: accounting ? now - accounting.lastSendAtMs : null, + msSinceLastAck: accounting?.lastAckAtMs == null ? null : now - accounting.lastAckAtMs + }) + } + perPty.sort((a, b) => b.inFlightChars + b.pendingChars - (a.inFlightChars + a.pendingChars)) + const windowAlive = !session.mainWindow.isDestroyed() + return { + appVersion: getAppEnvironment().getVersion(), + mainUptimeMs: Math.round(process.uptime() * 1000), + windowFocused: windowAlive ? session.mainWindow.isFocused() : null, + windowVisible: windowAlive ? session.mainWindow.isVisible() : null, + windowMinimized: windowAlive ? session.mainWindow.isMinimized() : null, + msSinceLastPowerSuspend: lastPowerSuspendAtMs === null ? null : now - lastPowerSuspendAtMs, + msSinceLastPowerResume: lastPowerResumeAtMs === null ? null : now - lastPowerResumeAtMs, + perPty: perPty.slice(0, DELIVERY_DIAGNOSTICS_MAX_PTYS), + breadcrumbs: mainDeliveryBreadcrumbs.snapshot() + } +} + +export function readCurrentPtyRendererDeliveryDebugSnapshot( + session: PtyIpcSession +): PtyRendererDeliveryDebugSnapshot { + let pendingChars = 0 + let maxPendingCharsByPty = 0 + for (const pending of session.pendingData.values()) { + const chars = pending.data.length + pendingChars += chars + maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) + } + const hiddenDeliveryDebug = getHiddenRendererPtyDeliveryDebug() + let rendererInFlightPtyCount = 0 + let maxRendererInFlightCharsByPty = 0 + for (const accounting of session.rendererDeliveryAccountingByPty.values()) { + const inFlight = accounting.sentChars - accounting.ackedChars + if (inFlight > 0) { + rendererInFlightPtyCount++ + } + maxRendererInFlightCharsByPty = Math.max(maxRendererInFlightCharsByPty, inFlight) + } + // Why: a pty both hidden-gated and reported visible means main is starving a visible pane (v1.4.124-rc.2.perf field lead). + let hiddenDeliveryGatedVisiblePtyCount = 0 + for (const id of visibleRendererPtys) { + if (isHiddenRendererPty(id)) { + hiddenDeliveryGatedVisiblePtyCount++ + } + } + let hiddenDeliveryGatedActivePtyCount = 0 + for (const id of activeRendererPtys) { + if (isHiddenRendererPty(id)) { + hiddenDeliveryGatedActivePtyCount++ + } + } + return { + pendingPtyCount: session.pendingData.size, + pendingChars, + maxPendingCharsByPty, + rendererInFlightPtyCount, + rendererInFlightChars: session.rendererInFlightTotalChars, + maxRendererInFlightCharsByPty, + activeRendererPtyCount: activeRendererPtys.size, + flushScheduled: session.flushTimer !== null, + peakPendingChars: session.peakPendingChars, + peakMaxPendingCharsByPty: session.peakMaxPendingCharsByPty, + peakRendererInFlightChars: session.peakRendererInFlightChars, + peakMaxRendererInFlightCharsByPty: session.peakMaxRendererInFlightCharsByPty, + ackGatedFlushSkipCount: session.ackGatedFlushSkipCount, + ...hiddenDeliveryDebug, + hiddenDeliveryGatedVisiblePtyCount, + hiddenDeliveryGatedActivePtyCount, + pendingDroppedChars: session.pendingDroppedChars, + diagnostics: buildMainDeliveryDiagnostics(session), + rendererLifecycleResetCount: session.rendererLifecycleResetCount, + lastLifecycleResetClearedChars: session.lastLifecycleResetClearedChars, + rendererPtyDispatcherReady: session.rendererPtyDispatcherReady, + rendererDispatcherReadyForcedCount: session.rendererDispatcherReadyForcedCount + } +} + +export function seedPtyRendererDeliveryPeaksFromCurrentState(session: PtyIpcSession): void { + let pendingChars = 0 + let maxPendingCharsByPty = 0 + for (const pending of session.pendingData.values()) { + const chars = pending.data.length + pendingChars += chars + maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) + } + session.peakPendingChars = pendingChars + session.peakMaxPendingCharsByPty = maxPendingCharsByPty + session.peakRendererInFlightChars = session.rendererInFlightTotalChars + let maxRendererInFlightCharsByPty = 0 + for (const accounting of session.rendererDeliveryAccountingByPty.values()) { + maxRendererInFlightCharsByPty = Math.max( + maxRendererInFlightCharsByPty, + accounting.sentChars - accounting.ackedChars + ) + } + session.peakMaxRendererInFlightCharsByPty = maxRendererInFlightCharsByPty +} + +export function warnIfDroppingHiddenBytesForVisiblePty( + session: PtyIpcSession, + id: string, + droppedChars: number +): void { + if (!visibleRendererPtys.has(id) && !activeRendererPtys.has(id)) { + return + } + // Recorded before the warn rate limit: the ring coalesces repeats, and the contradiction must appear in the freeze report either way. + mainDeliveryBreadcrumbs.record('hidden-drop-visible', { + id: redactPtyIdForDiagnostics(id), + droppedChars + }) + const now = Date.now() + if (now - session.lastHiddenDropContradictionWarnAtMs < 60_000) { + return + } + session.lastHiddenDropContradictionWarnAtMs = now + console.warn('[pty] hidden-delivery gate is dropping bytes for a visible/active pty', { + id: redactPtyIdForDiagnostics(id), + droppedChars, + visible: visibleRendererPtys.has(id), + active: activeRendererPtys.has(id), + ...readCurrentPtyRendererDeliveryDebugSnapshot(session) + }) +} diff --git a/src/main/ipc/pty/delivery/debug.ts b/src/main/ipc/pty/delivery/debug.ts new file mode 100644 index 00000000000..99dae69488d --- /dev/null +++ b/src/main/ipc/pty/delivery/debug.ts @@ -0,0 +1,124 @@ +import { getPtyPower } from '../../pty-host-bindings' +import { + type PtyMainDeliveryDiagnostics, + EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, + createPtyDeliveryBreadcrumbRing +} from '../../../../shared/pty-delivery-diagnostics' + +export type PtyRendererDeliveryDebugSnapshot = { + pendingPtyCount: number + pendingChars: number + maxPendingCharsByPty: number + rendererInFlightPtyCount: number + rendererInFlightChars: number + maxRendererInFlightCharsByPty: number + activeRendererPtyCount: number + flushScheduled: boolean + peakPendingChars: number + peakMaxPendingCharsByPty: number + peakRendererInFlightChars: number + peakMaxRendererInFlightCharsByPty: number + ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + /** Hidden-gated ptys the renderer ALSO reports visible/active — a contradiction that should be zero (v1.4.124-rc.2.perf field lead). */ + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryGatedActivePtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number + /** One-paste freeze diagnostics: per-pty delivery table + event history. */ + diagnostics: PtyMainDeliveryDiagnostics + // Why: a nonzero lastLifecycleResetClearedChars is the exact signature of the leaked-accounting freeze this reset fixes. + rendererLifecycleResetCount: number + lastLifecycleResetClearedChars: number + // Why: the boot-window hold early-returns before ackGatedFlushSkipCount++, so these expose an otherwise-invisible held gate; forcedCount > 0 flags a watchdog self-heal. + rendererPtyDispatcherReady: boolean + rendererDispatcherReadyForcedCount: number +} + +// Why module scope: breadcrumb writers live both inside registerPtyHandlers and outside it (renderer lifecycle resets). +export const mainDeliveryBreadcrumbs = createPtyDeliveryBreadcrumbRing() +export let lastPowerSuspendAtMs: number | null = null +export let lastPowerResumeAtMs: number | null = null +let powerSignalBreadcrumbsInstalled = false + +// Why: both field freeze variants correlate with display sleep; suspend/resume timestamps let breadcrumbs line up against the wake. +export function installPowerSignalBreadcrumbs(): void { + if (powerSignalBreadcrumbsInstalled) { + return + } + powerSignalBreadcrumbsInstalled = true + const powerMonitor = getPtyPower() + powerMonitor.on('suspend', () => { + lastPowerSuspendAtMs = Date.now() + mainDeliveryBreadcrumbs.record('power-suspend') + }) + powerMonitor.on('resume', () => { + lastPowerResumeAtMs = Date.now() + mainDeliveryBreadcrumbs.record('power-resume') + }) +} + +export const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapshot = { + pendingPtyCount: 0, + pendingChars: 0, + maxPendingCharsByPty: 0, + rendererInFlightPtyCount: 0, + rendererInFlightChars: 0, + maxRendererInFlightCharsByPty: 0, + activeRendererPtyCount: 0, + flushScheduled: false, + peakPendingChars: 0, + peakMaxPendingCharsByPty: 0, + peakRendererInFlightChars: 0, + peakMaxRendererInFlightCharsByPty: 0, + ackGatedFlushSkipCount: 0, + hiddenDeliveryGatedPtyCount: 0, + hiddenDeliveryGatedVisiblePtyCount: 0, + hiddenDeliveryGatedActivePtyCount: 0, + deliveryInterestPtyCount: 0, + hiddenDeliveryDroppedChars: 0, + hiddenDeliveryDroppedChunks: 0, + pendingDroppedChars: 0, + diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, + rendererLifecycleResetCount: 0, + lastLifecycleResetClearedChars: 0, + rendererPtyDispatcherReady: false, + rendererDispatcherReadyForcedCount: 0 +} + +export let readPtyRendererDeliveryDebugSnapshot = (): PtyRendererDeliveryDebugSnapshot => ({ + ...EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT +}) +export let resetPtyRendererDeliveryDebugSnapshot = (): void => {} +// Bridged into the registerPtyHandlers closure so the module-scope lifecycle-reset handler can zero closure-owned delivery accounting on renderer reload/crash. +export let resetRendererDeliveryAccountingForLifecycleReset = (): void => {} +// Bridged so a re-registration can cancel the prior closure's dispatcher-ready watchdog before wiring its own. +export let clearRendererDispatcherReadyWatchdog = (): void => {} + +export function setReadPtyRendererDeliveryDebugSnapshot( + fn: () => PtyRendererDeliveryDebugSnapshot +): void { + readPtyRendererDeliveryDebugSnapshot = fn +} + +export function setResetPtyRendererDeliveryDebugSnapshot(fn: () => void): void { + resetPtyRendererDeliveryDebugSnapshot = fn +} + +export function setResetRendererDeliveryAccountingForLifecycleReset(fn: () => void): void { + resetRendererDeliveryAccountingForLifecycleReset = fn +} + +export function setClearRendererDispatcherReadyWatchdog(fn: () => void): void { + clearRendererDispatcherReadyWatchdog = fn +} + +export function getPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { + return readPtyRendererDeliveryDebugSnapshot() +} + +export function resetPtyRendererDeliveryDebug(): void { + resetPtyRendererDeliveryDebugSnapshot() +} diff --git a/src/main/ipc/pty/delivery/exit.ts b/src/main/ipc/pty/delivery/exit.ts new file mode 100644 index 00000000000..3825be51192 --- /dev/null +++ b/src/main/ipc/pty/delivery/exit.ts @@ -0,0 +1,177 @@ +import { + interactiveOutputCharsByPty, + lastInputAtByPty, + SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS +} from './visibility-state' +import { allocatePtyLifecycleSequence } from '../host-env/types' +import { makePtyDataPayload, sendPtyDataToRenderer } from './payload' +import { getRendererInFlightCharsForPty } from './accounting' +import { clearFlushTimerIfIdle } from './flush' +import type { PtyIpcSession } from '../session' + +export function rememberSyntheticKillExit(session: PtyIpcSession, id: string): void { + const existing = session.syntheticKillExitPtyIds.get(id) + if (existing) { + clearTimeout(existing) + } + // Why a timed window: providers may report the real exit after kill completes; skip only that late duplicate, not a future reused id forever. + const cleanupTimer = setTimeout(() => { + session.syntheticKillExitPtyIds.delete(id) + }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) + cleanupTimer.unref?.() + session.syntheticKillExitPtyIds.set(id, cleanupTimer) +} + +export function rememberRetiredRejectedPty(session: PtyIpcSession, id: string): void { + const existing = session.retiredRejectedPtyIds.get(id) + if (existing) { + clearTimeout(existing) + } + const cleanupTimer = setTimeout(() => { + session.retiredRejectedPtyIds.delete(id) + }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) + cleanupTimer.unref?.() + session.retiredRejectedPtyIds.set(id, cleanupTimer) +} + +export function consumeSyntheticKillExit(session: PtyIpcSession, id: string): boolean { + const cleanupTimer = session.syntheticKillExitPtyIds.get(id) + if (!cleanupTimer) { + return false + } + clearTimeout(cleanupTimer) + session.syntheticKillExitPtyIds.delete(id) + return true +} + +export function preparePtyExitForRenderer( + session: PtyIpcSession, + payload: { id: string; code: number; incarnationId?: string } +): (() => void) | null { + if (session.mainWindow.isDestroyed()) { + session.sshOutputIntake?.transferPtyProjections(payload.id, 'renderer-destroyed') + return () => {} + } + if (session.rendererExitingPtyIds.has(payload.id)) { + return null + } + session.rendererExitingPtyIds.add(payload.id) + let released = false + const release = (): void => { + if (released) { + return + } + released = true + session.rendererExitingPtyIds.delete(payload.id) + } + try { + if (!session.rendererCreditBeforeExitByPty.has(payload.id)) { + session.rendererCreditBeforeExitByPty.set( + payload.id, + getRendererInFlightCharsForPty(session, payload.id) > 0 + ) + } + // Why flush before exit: the renderer tears down the terminal on pty:exit, so any batched output not yet flushed would be silently lost. + const remaining = session.pendingData.delete(payload.id) + clearFlushTimerIfIdle(session) + if (remaining) { + if (remaining.droppedOutput === true) { + // Sentinel entry: only salvaged query bytes remain; keep the flag so the renderer knows the span was dropped. + sendPtyDataToRenderer( + session, + payload.id, + { + id: payload.id, + data: remaining.data, + droppedOutput: true + }, + remaining.projectionAdmissionIds + ) + } else { + sendPtyDataToRenderer( + session, + payload.id, + makePtyDataPayload( + payload.id, + remaining.data, + remaining.startSeq, + remaining.containsBackgroundOutput, + remaining.rawLength, + remaining.transformed + ), + remaining.projectionAdmissionIds + ) + } + } + return release + } catch (error) { + release() + throw error + } +} + +export function finalizePtyExitForRenderer( + session: PtyIpcSession, + payload: { id: string; code: number; incarnationId?: string } +): void { + if (session.mainWindow.isDestroyed()) { + session.rendererCreditBeforeExitByPty.delete(payload.id) + return + } + const hadReleasableRendererCredit = + session.rendererCreditBeforeExitByPty.get(payload.id) ?? + getRendererInFlightCharsForPty(session, payload.id) > 0 + session.rendererCreditBeforeExitByPty.delete(payload.id) + // Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id. + session.producerFlowControl.release(payload.id) + session.sourceCreditPendingPtys.delete(payload.id) + session.pendingOverflowMarkedPtys.delete(payload.id) + session.rendererDeliveryRestoreNeededPtys.delete(payload.id) + lastInputAtByPty.delete(payload.id) + interactiveOutputCharsByPty.delete(payload.id) + const releasedRendererCredit = getRendererInFlightCharsForPty(session, payload.id) + session.rendererInFlightTotalChars = Math.max( + 0, + session.rendererInFlightTotalChars - releasedRendererCredit + ) + // Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides. + session.rendererDeliveryAccountingByPty.delete(payload.id) + if (hadReleasableRendererCredit) { + if (session.pendingDataFlushActive) { + // Why: let the open round coalesce this wake into its one post-round continuation. + const reactivatedBlocked = session.pendingData.reactivateBlocked() + session.pendingDataCreditReleasedDuringFlush ||= reactivatedBlocked + } else { + session.schedulePendingDataAfterCreditReport(true) + } + } + session.mainWindow.webContents.send('pty:exit', { + ...payload, + ...(session.reversibleStopOwnersByPtyId.has(payload.id) + ? { preserveRendererBinding: true } + : {}) + }) +} + +export function sendPtyExitToRenderer( + session: PtyIpcSession, + payload: { id: string; code: number; incarnationId?: string } +): void { + session.options?.onPtyExit?.(payload.id, allocatePtyLifecycleSequence()) + const release = preparePtyExitForRenderer(session, payload) + if (!release) { + return + } + try { + session.sshOutputIntake?.transferPtyProjections(payload.id, 'legacy-pty-exit') + finalizePtyExitForRenderer(session, payload) + } finally { + release() + } +} + +export function sendPtySpawnedToRenderer(session: PtyIpcSession, id: string): void { + if (!session.mainWindow.isDestroyed()) { + session.mainWindow.webContents.send('pty:spawned', { id }) + } +} diff --git a/src/main/ipc/pty/delivery/flush.ts b/src/main/ipc/pty/delivery/flush.ts new file mode 100644 index 00000000000..038d955a932 --- /dev/null +++ b/src/main/ipc/pty/delivery/flush.ts @@ -0,0 +1,235 @@ +import { + recordHiddenRendererPtyDataDrop, + shouldDropHiddenRendererPtyData +} from '../../pty-hidden-delivery-gate' +import { propagatePendingProjectionRemainder } from '../../pty-pending-projection-admissions' +import type { PendingPtyData } from '../../pty-pending-data-drain-queue' +import { activeRendererPtys } from './visibility-state' +import { + PTY_BATCH_DRAIN_CONTINUE_MS, + PTY_BATCH_FLUSH_CHUNK_CHARS, + PTY_BATCH_FLUSH_MAX_WRITES, + PTY_DISPATCHER_READY_WATCHDOG_MS +} from './constants' +import { + getDroppedMode2031RendererData, + pendingProjectionAdmissionOptions, + updatePendingProjectionAdmissions +} from './pending' +import { makePtyDataPayload, sendModelRestoreNeededMarker, sendPtyDataToRenderer } from './payload' +import { warnIfDroppingHiddenBytesForVisiblePty } from './debug-snapshot' +import type { PtyIpcSession } from '../session' + +export function schedulePendingDataFlush(session: PtyIpcSession, delayMs: number): void { + if (session.flushTimer) { + return + } + session.flushTimer = setTimeout(() => session.flushPendingData(), delayMs) +} + +export function invalidatePendingPtyDrainClassification( + session: PtyIpcSession, + id?: string, + schedule = true +): void { + const invalidated = + typeof id === 'string' + ? session.pendingData.invalidate(id) + : session.pendingData.invalidateAll() + if (invalidated && schedule && !session.flushTimer) { + schedulePendingDataFlush(session, 0) + } +} + +export function clearDispatcherReadyWatchdog(session: PtyIpcSession): void { + if (session.dispatcherReadyWatchdogTimer) { + clearTimeout(session.dispatcherReadyWatchdogTimer) + session.dispatcherReadyWatchdogTimer = null + } +} + +export function armDispatcherReadyWatchdog(session: PtyIpcSession): void { + clearDispatcherReadyWatchdog(session) + if (session.mainWindow.isDestroyed()) { + return + } + // Why: one-shot self-heal — force the gate open if the reloaded page never signals ready, so a dropped handshake can't hold it forever. Unref'd so it can't keep the process alive. + session.dispatcherReadyWatchdogTimer = setTimeout(() => { + session.dispatcherReadyWatchdogTimer = null + if (session.rendererPtyDispatcherReady || session.mainWindow.isDestroyed()) { + return + } + session.rendererPtyDispatcherReady = true + session.rendererDispatcherReadyForcedCount += 1 + session.pendingData.reactivateBlocked() + schedulePendingDataFlush(session, 0) + }, PTY_DISPATCHER_READY_WATCHDOG_MS) + session.dispatcherReadyWatchdogTimer.unref?.() +} + +export function clearFlushTimerIfIdle(session: PtyIpcSession): void { + if (session.pendingData.size > 0 || session.flushTimer === null) { + return + } + clearTimeout(session.flushTimer) + session.flushTimer = null +} + +export function flushPendingData(session: PtyIpcSession): void { + session.flushTimer = null + if (session.mainWindow.isDestroyed()) { + // Why release now: bookkeeping is being wiped, so no future drain can resume these producers — local shells would wedge. + session.producerFlowControl.releaseAll() + session.clearDeliveryResyncProbe() + session.clearPendingPtyData() + session.pendingOverflowMarkedPtys.clear() + session.rendererDeliveryAccountingByPty.clear() + session.rendererInFlightTotalChars = 0 + session.clearDispatcherReadyWatchdog() + return + } + // Ordinary boot-window data is blocked in the queue; hidden-droppable entries still retire before renderer readiness. + const settings = session.getSettings?.() + let writes = 0 + let sendFailed = false + const round = session.pendingData.beginRound() + let creditReleasedDuringFlush = false + session.pendingDataFlushActive = true + session.pendingDataCreditReleasedDuringFlush = false + try { + while (writes < PTY_BATCH_FLUSH_MAX_WRITES) { + const selection = session.pendingData.takeNext(round) + if (!selection) { + break + } + const { id, pending } = selection + // Why drop, never re-queue: the model already ingested hidden-gated bytes; reveal restores from the snapshot+seq machinery. + if (shouldDropHiddenRendererPtyData(id, settings)) { + session.pendingData.remove(selection) + session.pendingOverflowMarkedPtys.delete(id) + session.updateProducerFlowControl(id) + const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length) + if (pending.projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections( + pending.projectionAdmissionIds, + 'hidden-drop' + ) + } + warnIfDroppingHiddenBytesForVisiblePty(session, id, pending.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker( + session, + id, + 'hidden-drop', + session.runtime?.getPtyOutputSequence(id) + ) + } + continue + } + if (!session.canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) { + session.pendingData.block(selection) + continue + } + if (pending.droppedOutput === true) { + session.pendingData.remove(selection) + session.updateProducerFlowControl(id) + // Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only). + if ( + !sendPtyDataToRenderer( + session, + id, + { + id, + data: pending.data + getDroppedMode2031RendererData(pending), + droppedOutput: true + }, + pending.projectionAdmissionIds + ).sent + ) { + sendFailed = true + break + } + writes++ + continue + } + const { data } = pending + const indivisible = pending.transformed === true + const chunk = indivisible ? data : data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS) + const remaining = indivisible ? '' : data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS) + let nextPending: PendingPtyData | undefined + if (remaining) { + nextPending = { data: remaining } + if (typeof pending.startSeq === 'number') { + nextPending.startSeq = pending.startSeq + chunk.length + } + if (pending.containsBackgroundOutput === true) { + nextPending.containsBackgroundOutput = true + } + if (pending.projectionAdmissionIds) { + nextPending.projectionAdmissionIds = pending.projectionAdmissionIds + } + if (pending.projectionAdmissionsTransferred) { + nextPending.projectionAdmissionsTransferred = true + } + session.pendingData.replaceWithRemainder(selection, nextPending) + } else { + session.pendingData.remove(selection) + session.pendingOverflowMarkedPtys.delete(id) + } + session.updateProducerFlowControl(id) + const delivery = sendPtyDataToRenderer( + session, + id, + makePtyDataPayload( + id, + chunk, + pending.startSeq, + pending.containsBackgroundOutput, + pending.rawLength, + pending.transformed + ), + pending.projectionAdmissionIds + ) + if (nextPending) { + updatePendingProjectionAdmissions( + nextPending, + propagatePendingProjectionRemainder( + nextPending, + delivery, + pendingProjectionAdmissionOptions(session) + ) + ) + } + if (!delivery.sent) { + sendFailed = true + break + } + writes++ + } + } finally { + session.pendingDataFlushActive = false + creditReleasedDuringFlush = session.pendingDataCreditReleasedDuringFlush + session.pendingDataCreditReleasedDuringFlush = false + session.pendingData.endRound(round) + } + if ( + session.rendererPtyDispatcherReady && + session.pendingData.size > 0 && + writes === 0 && + !sendFailed + ) { + session.ackGatedFlushSkipCount++ + } + if (sendFailed && session.pendingData.size > 0) { + if (session.flushTimer) { + clearTimeout(session.flushTimer) + session.flushTimer = null + } + schedulePendingDataFlush(session, PTY_BATCH_DRAIN_CONTINUE_MS) + return + } + if (session.pendingData.size > 0 && (writes > 0 || creditReleasedDuringFlush)) { + // Why yield between slices: a background terminal can dump megabytes at once, and keystroke writes must not stall behind one flush. + schedulePendingDataFlush(session, writes > 0 ? PTY_BATCH_DRAIN_CONTINUE_MS : 0) + } +} diff --git a/src/main/ipc/pty/delivery/hidden-transition.ts b/src/main/ipc/pty/delivery/hidden-transition.ts new file mode 100644 index 00000000000..a6ca1204875 --- /dev/null +++ b/src/main/ipc/pty/delivery/hidden-transition.ts @@ -0,0 +1,35 @@ +import { + markHiddenRendererPty, + shouldDropHiddenRendererPtyData, + unmarkHiddenRendererPty +} from '../../pty-hidden-delivery-gate' +import { invalidatePendingPtyDrainPolicy } from './visibility-state' +import type { PtyIpcSession } from '../session' + +export function transitionHiddenRendererPtyDeliveryState( + session: PtyIpcSession, + id: string, + hidden: boolean +): { droppable: boolean; droppedWhileHidden: boolean; policyChanged: boolean } { + const settings = session.getSettings?.() + const wasDroppable = shouldDropHiddenRendererPtyData(id, settings) + let droppedWhileHidden = false + if (hidden) { + markHiddenRendererPty(id) + } else { + droppedWhileHidden = unmarkHiddenRendererPty(id).droppedWhileHidden + } + const droppable = shouldDropHiddenRendererPtyData(id, settings) + return { droppable, droppedWhileHidden, policyChanged: wasDroppable !== droppable } +} + +export function transitionSpawnHiddenRendererPtyDeliveryState( + session: PtyIpcSession, + id: string, + hidden: boolean +): void { + const transition = transitionHiddenRendererPtyDeliveryState(session, id, hidden) + if (transition.policyChanged) { + invalidatePendingPtyDrainPolicy(id) + } +} diff --git a/src/main/ipc/pty/delivery/interactive.ts b/src/main/ipc/pty/delivery/interactive.ts new file mode 100644 index 00000000000..6a69af426f8 --- /dev/null +++ b/src/main/ipc/pty/delivery/interactive.ts @@ -0,0 +1,34 @@ +import { interactiveOutputCharsByPty, lastInputAtByPty } from './visibility-state' +import { + INTERACTIVE_OUTPUT_BUDGET_CHARS, + INTERACTIVE_OUTPUT_MAX_CHARS, + INTERACTIVE_OUTPUT_WINDOW_MS, + INTERACTIVE_REDRAW_MAX_CHARS +} from './constants' + +export function isLikelyInteractiveRedraw(data: string): boolean { + if (data.length <= INTERACTIVE_OUTPUT_MAX_CHARS) { + return true + } + // Why the ANSI check: Codex-style TUIs repaint >1 KB per keypress (latency-sensitive), while plain command output should stay on the throughput batch path. + return data.length <= INTERACTIVE_REDRAW_MAX_CHARS && data.includes('\x1b[') +} + +export function shouldSendInteractiveOutputNow(id: string, data: string, now: number): boolean { + const lastInputAt = lastInputAtByPty.get(id) + if (lastInputAt === undefined || now - lastInputAt > INTERACTIVE_OUTPUT_WINDOW_MS) { + interactiveOutputCharsByPty.delete(id) + return false + } + if (!isLikelyInteractiveRedraw(data)) { + interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) + return false + } + const usedChars = interactiveOutputCharsByPty.get(id) ?? 0 + if (usedChars + data.length > INTERACTIVE_OUTPUT_BUDGET_CHARS) { + interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) + return false + } + interactiveOutputCharsByPty.set(id, usedChars + data.length) + return true +} diff --git a/src/main/ipc/pty/delivery/lifecycle-reset.ts b/src/main/ipc/pty/delivery/lifecycle-reset.ts new file mode 100644 index 00000000000..961c09188a1 --- /dev/null +++ b/src/main/ipc/pty/delivery/lifecycle-reset.ts @@ -0,0 +1,96 @@ +import type { WebContents } from 'electron' +import { + didFinishLoadHandler, + didFinishLoadWebContents, + rendererDidStartNavigationHandler, + rendererGateResetGoneHandler, + rendererGateResetLoadHandler, + rendererGateResetWebContents, + rendererLifecycleResetHandler, + rendererLifecycleResetWebContents, + setDidFinishLoadHandler, + setRendererGateResetState, + setRendererLifecycleResetState +} from '../provider/listener-lifecycle' +import { mainDeliveryBreadcrumbs, resetRendererDeliveryAccountingForLifecycleReset } from './debug' +import { + activeRendererPtys, + invalidatePendingPtyDrainPriority, + visibleRendererPtys +} from './visibility-state' + +export function clearDidFinishLoadHandler(): void { + if (didFinishLoadHandler && didFinishLoadWebContents) { + didFinishLoadWebContents.removeListener('did-finish-load', didFinishLoadHandler) + } + setDidFinishLoadHandler(null, null) +} + +export function markRendererPtysHiddenForRendererLifecycleReset(): void { + // A reload/crash in the breadcrumb history is load-bearing context for any freeze report. + mainDeliveryBreadcrumbs.record('renderer-lifecycle-reset') + // Why: renderer-owned hints die with the page; clear visibility so surviving daemon/SSH PTYs fail closed until the new renderer reports. + const activePriorityChanged = activeRendererPtys.size > 0 + activeRendererPtys.clear() + visibleRendererPtys.clear() + // Why: the dead page never ACKs its in-flight bytes, so leaked accounting would delivery-gate surviving PTYs forever after a reload/crash. + resetRendererDeliveryAccountingForLifecycleReset() + if (activePriorityChanged) { + invalidatePendingPtyDrainPriority() + } +} + +export function clearRendererLifecycleResetHandlers(): void { + if (!rendererLifecycleResetWebContents) { + return + } + if (rendererDidStartNavigationHandler) { + rendererLifecycleResetWebContents.removeListener( + 'did-start-navigation', + rendererDidStartNavigationHandler + ) + } + if (rendererLifecycleResetHandler) { + rendererLifecycleResetWebContents.removeListener( + 'render-process-gone', + rendererLifecycleResetHandler + ) + rendererLifecycleResetWebContents.removeListener('destroyed', rendererLifecycleResetHandler) + } + setRendererLifecycleResetState({ contents: null, handler: null, navigation: null }) +} + +export function registerRendererLifecycleResetHandlers(webContents: WebContents): void { + clearRendererLifecycleResetHandlers() + markRendererPtysHiddenForRendererLifecycleReset() + const handler = markRendererPtysHiddenForRendererLifecycleReset + const navigationHandler = (details: { isMainFrame: boolean; isSameDocument: boolean }) => { + if (!details.isMainFrame || details.isSameDocument) { + return + } + markRendererPtysHiddenForRendererLifecycleReset() + } + setRendererLifecycleResetState({ + contents: webContents, + handler, + navigation: navigationHandler + }) + webContents.on('did-start-navigation', navigationHandler) + webContents.on('render-process-gone', handler) + webContents.on('destroyed', handler) +} + +export function clearRendererGateResetHandlers(): void { + if (rendererGateResetWebContents) { + if (rendererGateResetLoadHandler) { + rendererGateResetWebContents.removeListener('did-finish-load', rendererGateResetLoadHandler) + } + if (rendererGateResetGoneHandler) { + rendererGateResetWebContents.removeListener( + 'render-process-gone', + rendererGateResetGoneHandler + ) + } + } + setRendererGateResetState({ contents: null, load: null, gone: null }) +} diff --git a/src/main/ipc/pty/delivery/payload.ts b/src/main/ipc/pty/delivery/payload.ts new file mode 100644 index 00000000000..119bb173ed7 --- /dev/null +++ b/src/main/ipc/pty/delivery/payload.ts @@ -0,0 +1,137 @@ +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' +import type { PtyModelRestoreReason } from '../../../../shared/pty-model-restore-marker' +import { mainDeliveryBreadcrumbs } from './debug' +import { recordPtyRendererDeliveryPressure } from './accounting' +import type { PtyDataPayload, PtyIpcSession } from '../session' + +export function makePtyDataPayload( + id: string, + data: string, + startSeq: number | undefined, + containsBackgroundOutput: boolean | undefined, + rawLength = data.length, + transformed = false +): PtyDataPayload { + const payload: PtyDataPayload = { id, data } + if (typeof startSeq === 'number') { + payload.seq = startSeq + rawLength + } + if (typeof startSeq === 'number' || rawLength !== data.length || transformed) { + payload.rawLength = rawLength + } + if (transformed) { + payload.transformed = true + } + if (containsBackgroundOutput === true) { + payload.background = true + } + return payload +} + +export function getPtyPayloadCharCount(payload: { data: string; rawLength?: number }): number { + return Math.max(0, payload.rawLength ?? payload.data.length) +} + +export function sendModelRestoreNeededMarker( + session: PtyIpcSession, + id: string, + reason: PtyModelRestoreReason, + markerSeq: number | undefined +): boolean { + if (session.mainWindow.isDestroyed()) { + return false + } + try { + session.mainWindow.webContents.send('pty:modelRestoreNeeded', { + id, + reason, + ...(typeof markerSeq === 'number' ? { markerSeq } : {}) + }) + } catch (error) { + // Why: a disposed render frame throws synchronously here, and this rides the data path. + console.error('[pty] renderer model-restore marker send failed', error) + return false + } + return true +} + +export function sendPtyDataToRenderer( + session: PtyIpcSession, + id: string, + payload: PtyDataPayload, + projectionAdmissionIds?: readonly string[] +): { sent: boolean; projectionsTransferred: boolean } { + const charCount = getPtyPayloadCharCount(payload) + const accounting = session.rendererDeliveryAccountingByPty.get(id) + const hadAccounting = accounting !== undefined + if (accounting) { + accounting.sentChars += charCount + accounting.lastSendAtMs = Date.now() + } else { + session.rendererDeliveryAccountingByPty.set(id, { + sentChars: charCount, + ackedChars: 0, + lastSendAtMs: Date.now(), + lastAckAtMs: null + }) + } + session.rendererInFlightTotalChars += charCount + recordPtyRendererDeliveryPressure(session, id) + try { + session.mainWindow.webContents.send('pty:data', payload) + } catch (error) { + const current = session.rendererDeliveryAccountingByPty.get(id) + if (current) { + const inFlightBeforeRollback = current.sentChars - current.ackedChars + current.sentChars = Math.max(0, current.sentChars - charCount) + current.ackedChars = Math.min(current.ackedChars, current.sentChars) + const inFlightAfterRollback = current.sentChars - current.ackedChars + session.rendererInFlightTotalChars = Math.max( + 0, + session.rendererInFlightTotalChars - (inFlightBeforeRollback - inFlightAfterRollback) + ) + if (!hadAccounting && current.sentChars === 0) { + session.rendererDeliveryAccountingByPty.delete(id) + } + } + session.rendererDeliveryRestoreNeededPtys.add(id) + if (projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections(projectionAdmissionIds, 'renderer-send-failed') + } + mainDeliveryBreadcrumbs.record('pty-data-send-failed', { + id: redactPtyIdForDiagnostics(id), + chars: charCount + }) + console.error('[pty] renderer data send failed; payload will not be retried', error) + return { sent: false, projectionsTransferred: projectionAdmissionIds !== undefined } + } + let projectionsTransferred = false + if (projectionAdmissionIds) { + try { + session.sshOutputIntake?.publishProjectionPrefix( + projectionAdmissionIds, + payload.data.length, + charCount + ) + } catch { + session.sshOutputIntake?.transferProjections( + projectionAdmissionIds, + 'projection-publish-failed' + ) + projectionsTransferred = true + } + } + if ( + session.rendererDeliveryRestoreNeededPtys.has(id) && + sendModelRestoreNeededMarker( + session, + id, + 'delivery-heal', + session.runtime?.getPtyOutputSequence(id) + ) + ) { + // Why cleared only on a successful send: an unsent marker leaves the restore pending. + session.rendererDeliveryRestoreNeededPtys.delete(id) + } + return { sent: true, projectionsTransferred } +} diff --git a/src/main/ipc/pty/delivery/pending.ts b/src/main/ipc/pty/delivery/pending.ts new file mode 100644 index 00000000000..ff14ccc8c52 --- /dev/null +++ b/src/main/ipc/pty/delivery/pending.ts @@ -0,0 +1,193 @@ +import { extractHiddenStartupRendererQueryData } from '../../../../shared/terminal-reply-query-extraction' +import { + INITIAL_MODE_2031_REPLY_SCAN_STATE, + scanMode2031ReplyDecision, + type Mode2031ReplyScanState +} from '../../../../shared/terminal-color-scheme-protocol' +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' +import { recordCrashBreadcrumb } from '../../../crash-reporting/crash-breadcrumb-store' +import { terminalOutputBacklogCapChars } from '../../../../shared/terminal-scrollback-policy' +import { isHiddenPtyDeliveryGateEnabled } from '../../pty-hidden-delivery-gate' +import { + appendPendingProjectionAdmission, + compactPendingProjectionAdmissions, + type PendingProjectionAdmissions +} from '../../pty-pending-projection-admissions' +import type { PendingPtyData } from '../../pty-pending-data-drain-queue' +import { DROPPED_QUERY_SALVAGE_MAX_CHARS } from './constants' +import type { PtyIpcSession } from '../session' + +export function pendingDataCapChars(session: PtyIpcSession): number { + return terminalOutputBacklogCapChars(session.getSettings?.().terminalScrollbackRows) +} + +export function extractDroppedPtyQueryBytes(data: string): string { + if (!data.includes('\x1b')) { + return '' + } + const extracted = extractHiddenStartupRendererQueryData(data, '') + return extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData +} + +export function scanDroppedMode2031Data( + data: string, + previous: Mode2031ReplyScanState +): { data: string; state: Mode2031ReplyScanState } { + const result = scanMode2031ReplyDecision(previous, data) + const decisionData = + result.decision === 'subscribed' + ? '\x1b[?2031h' + : result.decision === 'unsubscribed' + ? '\x1b[?2031l' + : '' + return { data: decisionData, state: result.state } +} + +export function getDroppedMode2031RendererData(pending: PendingPtyData): string { + const state = pending.droppedMode2031ScanState + if (!state) { + return pending.droppedMode2031Data ?? '' + } + const pendingSubscribe = state.pendingSubscribe ? '\x1b[?2031h' : '' + return (pending.droppedMode2031Data ?? '') + pendingSubscribe + state.tail +} + +export function pendingProjectionAdmissionOptions(session: PtyIpcSession) { + return { + isPending: (id: string) => session.sshOutputIntake?.hasUnpublishedProjection(id) ?? false, + transfer: (ids: readonly string[], reason: string) => + session.sshOutputIntake?.transferProjections(ids, reason) + } +} + +export function updatePendingProjectionAdmissions( + pending: PendingPtyData, + state: PendingProjectionAdmissions +): void { + delete pending.projectionAdmissionIds + delete pending.projectionAdmissionsTransferred + if (state.projectionAdmissionIds) { + pending.projectionAdmissionIds = state.projectionAdmissionIds + } + if (state.projectionAdmissionsTransferred) { + pending.projectionAdmissionsTransferred = true + } +} + +export function compactPendingProjectionState( + session: PtyIpcSession, + pending: PendingProjectionAdmissions, + projectionSemanticsId?: string +): PendingProjectionAdmissions { + const options = pendingProjectionAdmissionOptions(session) + const compacted = compactPendingProjectionAdmissions(pending, options) + return projectionSemanticsId + ? appendPendingProjectionAdmission(compacted, projectionSemanticsId, options) + : compacted +} + +export function dropOversizedPendingPtyData( + session: PtyIpcSession, + id: string, + pending: PendingPtyData +): PendingPtyData { + const capChars = pendingDataCapChars(session) + if (pending.droppedOutput === true || pending.data.length <= capChars) { + return pending + } + if (!session.pendingDataDropWarnedPtys.has(id)) { + session.pendingDataDropWarnedPtys.add(id) + console.error( + `[pty] dropped ${pending.data.length} buffered chars for ${redactPtyIdForDiagnostics(id)}: renderer not receiving and per-PTY pending cap exceeded; pane will restore from the main-owned snapshot` + ) + // Why: field visibility for cap tuning (issue #2836 / #7017); no pty id since session ids can embed workspace paths. + recordCrashBreadcrumb('terminal_pending_output_dropped', { + droppedChars: pending.data.length, + capChars + }) + } + if ( + isHiddenPtyDeliveryGateEnabled(session.getSettings?.()) && + !session.pendingOverflowMarkedPtys.has(id) + ) { + session.pendingOverflowMarkedPtys.add(id) + } + session.pendingDroppedChars += pending.data.length + if (pending.projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'pending-cap') + } + const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE) + // Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along). + return { + data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS), + droppedOutput: true, + droppedMode2031Data: mode2031.data, + droppedMode2031ScanState: mode2031.state + } +} + +export function appendPendingPtyData( + session: PtyIpcSession, + id: string, + existing: PendingPtyData | undefined, + data: string, + startSeq: number | undefined, + preservesSeq: boolean, + containsBackgroundOutput: boolean, + rawLength = data.length, + transformed = false, + projectionSemanticsId?: string +): PendingPtyData { + // Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode. + if (existing?.droppedOutput === true) { + if (projectionSemanticsId) { + session.sshOutputIntake?.transferProjections([projectionSemanticsId], 'pending-cap') + } + const mode2031 = scanDroppedMode2031Data( + data, + existing.droppedMode2031ScanState ?? INITIAL_MODE_2031_REPLY_SCAN_STATE + ) + const remainingQueryCapacity = Math.max( + 0, + DROPPED_QUERY_SALVAGE_MAX_CHARS - existing.data.length + ) + const salvaged = extractDroppedPtyQueryBytes(data).slice(0, remainingQueryCapacity) + return { + ...existing, + data: existing.data + salvaged, + droppedMode2031Data: mode2031.data || existing.droppedMode2031Data, + droppedMode2031ScanState: mode2031.state + } + } + const projectionState = compactPendingProjectionState( + session, + existing ?? {}, + projectionSemanticsId + ) + const nextContainsBackgroundOutput = + existing?.containsBackgroundOutput === true || containsBackgroundOutput + if (!existing) { + const pending: PendingPtyData = { + data, + ...(typeof startSeq === 'number' ? { startSeq } : {}), + ...(rawLength !== data.length ? { rawLength } : {}), + ...(transformed ? { transformed: true } : {}), + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + } + updatePendingProjectionAdmissions(pending, projectionState) + return dropOversizedPendingPtyData(session, id, pending) + } + const existingRawLength = existing.rawLength ?? existing.data.length + const next: PendingPtyData = { + data: existing.data + data, + ...(!preservesSeq || existing.transformed || transformed + ? { rawLength: existingRawLength + rawLength, transformed: true as const } + : {}), + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + } + updatePendingProjectionAdmissions(next, projectionState) + if (typeof existing.startSeq === 'number') { + next.startSeq = existing.startSeq + } + return dropOversizedPendingPtyData(session, id, next) +} diff --git a/src/main/ipc/pty/delivery/producer-sync.ts b/src/main/ipc/pty/delivery/producer-sync.ts new file mode 100644 index 00000000000..b718e3cebe7 --- /dev/null +++ b/src/main/ipc/pty/delivery/producer-sync.ts @@ -0,0 +1,64 @@ +import { recordDaemonStreamBacklogEvent } from '../../../daemon/daemon-stream-backlog-probe' +import { tryGetProviderForPty } from '../provider/registry' +import { + PRODUCER_FLOW_CONTROL_ENABLED, + rendererVisibilityKnownPtys, + visibleRendererPtys +} from './visibility-state' +import type { PtyIpcSession } from '../session' + +export function updateProducerFlowControl(session: PtyIpcSession, id: string): void { + if (!PRODUCER_FLOW_CONTROL_ENABLED) { + return + } + if (session.sourceCreditPendingPtys.has(id)) { + if (session.pendingData.get(id)) { + return + } + session.sourceCreditPendingPtys.delete(id) + } + session.producerFlowControl.update(id, session.pendingData.get(id)?.data.length ?? 0) +} + +export function syncPtyBackgroundedDelivery( + session: PtyIpcSession, + id: string, + caller: string +): void { + const background = + session.rendererPtyIsKnownHidden(id) && + !(session.runtime?.hasRawTerminalViewSubscriber?.(id) ?? false) + if (session.backgroundedDeliverySyncByPty.get(id) === background) { + return + } + const provider = tryGetProviderForPty(id) + if (!provider?.setPtyBackgrounded) { + return + } + recordDaemonStreamBacklogEvent('mainBackgroundSync', { + sessionIdSuffix: id.slice(-10), + background, + caller, + known: rendererVisibilityKnownPtys.has(id), + visible: visibleRendererPtys.has(id) + }) + const previous = session.backgroundedDeliverySyncByPty.get(id) + session.backgroundedDeliverySyncByPty.set(id, background) + try { + provider.setPtyBackgrounded(id, background) + } catch (error) { + // Why: a recorded state the provider never took would short-circuit every later sync for this PTY. + if (previous === undefined) { + session.backgroundedDeliverySyncByPty.delete(id) + } else { + session.backgroundedDeliverySyncByPty.set(id, previous) + } + console.error('[pty] setPtyBackgrounded failed; delivery sync state rolled back', error) + } +} + +export function resyncBackgroundedDeliveriesAfterGateReset(session: PtyIpcSession): void { + for (const id of session.backgroundedDeliverySyncByPty.keys()) { + syncPtyBackgroundedDelivery(session, id, 'gate-reset') + } +} diff --git a/src/main/ipc/pty/delivery/ssh-intake.ts b/src/main/ipc/pty/delivery/ssh-intake.ts new file mode 100644 index 00000000000..2f86608fb82 --- /dev/null +++ b/src/main/ipc/pty/delivery/ssh-intake.ts @@ -0,0 +1,101 @@ +import type { IPtyProvider } from '../../../providers/types' +import { SshPtyOutputIntake } from '../../ssh-pty-output-intake' +import { + cancelSshPtySourceDelivery, + installSshPtyOutputIntake, + publishSshPtySourceAck +} from '../../ssh-pty-output-intake-registry' +import { sshProvidersByGeneration } from '../provider/registry' +import { setSshOutputIntakeCleanup, sshOutputIntakeCleanup } from '../provider/listener-lifecycle' +import type { PtyIpcSession } from '../session' + +export function installSessionSshOutputIntake(session: PtyIpcSession): void { + sshOutputIntakeCleanup?.() + session.sshOutputIntake = new SshPtyOutputIntake({ + getModelSequence: (id) => session.runtime?.getPtyOutputSequence(id) ?? 0, + acceptModel: (event, projection) => { + if (!session.runtime) { + throw new Error('SSH PTY output requires the main terminal model') + } + return session.runtime.acceptPtyDataBounded( + event.id, + event.data, + Date.now(), + event.rawLength, + event.transformed, + projection.desktopSpan ? [projection.desktopSpan] : undefined + ) + }, + project: (event, projection) => + session.acceptPtyDataForRenderer( + { + id: event.id, + data: event.data, + sequenceChars: event.rawLength, + transformed: event.transformed + }, + projection.identity.sequenceEnd, + projection + ), + prepareExit: (event) => { + const release = session.preparePtyExitForRenderer(event) + if (!release) { + throw new Error('pty_renderer_exit_in_progress') + } + return release + }, + finalizeExit: (event) => { + session.runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation, { + hostExitConfirmed: true + }) + session.finalizePtyExitForRenderer(event) + }, + pauseProvider: (generation, id) => { + const provider = sshProvidersByGeneration.get(generation) as + | (IPtyProvider & { hasPtyDeliveryPauseAdapter?: () => boolean }) + | undefined + if (!provider?.hasPtyDeliveryPauseAdapter?.()) { + return false + } + provider.pauseProducer?.(id) + return true + }, + resumeProvider: (generation, id) => + sshProvidersByGeneration.get(generation)?.resumeProducer?.(id), + closeProvider: (generation, reason) => { + const provider = sshProvidersByGeneration.get(generation) + ;( + provider as (IPtyProvider & { closeOutputIntake?: (reason: string) => void }) | undefined + )?.closeOutputIntake?.(reason) + }, + resetModelForMigration: (_generation, id) => + session.runtime?.resetPtyModelAfterMigrationFailure(id), + onGenerationClosed: (providerGeneration) => { + for (const id of session.pendingData.keys()) { + const pending = session.pendingData.get(id) + if ( + pending?.projectionAdmissionIds && + session.sshOutputIntake?.hasProjectionFromGeneration( + pending.projectionAdmissionIds, + providerGeneration + ) + ) { + session.pendingData.delete(id) + session.updateProducerFlowControl(id) + session.pendingOverflowMarkedPtys.delete(id) + } + } + sshProvidersByGeneration.delete(providerGeneration) + }, + publishSourceAck: publishSshPtySourceAck, + cancelSourceDelivery: cancelSshPtySourceDelivery + }) + session.runtime?.setRemoteTerminalSourceRangeConsumerHooks?.( + session.sshOutputIntake.getRemoteSourceRangeConsumerHooks() + ) + const cleanupSshOutputIntakeRegistry = installSshPtyOutputIntake(session.sshOutputIntake) + setSshOutputIntakeCleanup(() => { + session.runtime?.setRemoteTerminalSourceRangeConsumerHooks?.(null) + cleanupSshOutputIntakeRegistry() + }) +} diff --git a/src/main/ipc/pty/delivery/visibility-state.ts b/src/main/ipc/pty/delivery/visibility-state.ts new file mode 100644 index 00000000000..dbe9526ae2e --- /dev/null +++ b/src/main/ipc/pty/delivery/visibility-state.ts @@ -0,0 +1,31 @@ +export const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 +// Why: kill switch — flip to disable producer flow control (pause/resume) without untangling the wiring. +export const PRODUCER_FLOW_CONTROL_ENABLED = true +// Why: mobile clients must mirror desktop PTY geometry even before the renderer can provide an xterm snapshot (e.g. right after tab creation). +export const ptySizes = new Map() +// Why: the "recent user input" signal is PTY-scoped and must be cleared by every teardown path, incl. SSH/daemon shutdowns that skip the local exit listener. +export const lastInputAtByPty = new Map() +export const interactiveOutputCharsByPty = new Map() +export const activeRendererPtys = new Set() +export const visibleRendererPtys = new Set() +export const rendererVisibilityKnownPtys = new Set() +export let invalidatePendingPtyDrainPriority = (_id?: string, _schedule?: boolean): void => {} +export let invalidatePendingPtyDrainPolicy = (_id?: string, _schedule?: boolean): void => {} +export const pendingHiddenRendererResizeOutputPtys = new Set() +export const deliveredHiddenRendererResizeOutputPtys = new Set() +export const KEEP_HISTORY_STOP_SETTLE_MS = 1_000 +export const KEEP_HISTORY_STOP_POLL_MS = 100 +// Why: after daemon keep-tail thinning main's mirror holds only the kept tail, so recovery must keep consulting the daemon's complete model until exit. +export const providerSnapshotRequiredPtys = new Set() + +export function setInvalidatePendingPtyDrainPriority( + fn: (id?: string, schedule?: boolean) => void +): void { + invalidatePendingPtyDrainPriority = fn +} + +export function setInvalidatePendingPtyDrainPolicy( + fn: (id?: string, schedule?: boolean) => void +): void { + invalidatePendingPtyDrainPolicy = fn +} diff --git a/src/main/ipc/pty/delivery/wire-session.ts b/src/main/ipc/pty/delivery/wire-session.ts new file mode 100644 index 00000000000..5425acb9f10 --- /dev/null +++ b/src/main/ipc/pty/delivery/wire-session.ts @@ -0,0 +1,159 @@ +import { resetHiddenRendererPtyDeliveryDebugCounters } from '../../pty-hidden-delivery-gate' +import { + setReadPtyRendererDeliveryDebugSnapshot, + setResetPtyRendererDeliveryDebugSnapshot, + setResetRendererDeliveryAccountingForLifecycleReset, + setClearRendererDispatcherReadyWatchdog +} from './debug' +import { + setInvalidatePendingPtyDrainPolicy, + setInvalidatePendingPtyDrainPriority +} from './visibility-state' +import { setClearBackgroundedDeliverySyncForPty } from '../provider/listener-lifecycle' +import { + applyCumulativeAck, + canSendPtyDataToRenderer, + clearDeliveryResyncProbe, + clearPendingPtyData, + deletePendingPtyData, + getRendererInFlightCharsForPty, + schedulePendingDataAfterCreditReport, + setPendingPtyData, + writeOffLostRendererDelivery +} from './accounting' +import { + readCurrentPtyRendererDeliveryDebugSnapshot, + seedPtyRendererDeliveryPeaksFromCurrentState +} from './debug-snapshot' +import { + armDispatcherReadyWatchdog, + clearDispatcherReadyWatchdog, + flushPendingData, + invalidatePendingPtyDrainClassification, + schedulePendingDataFlush +} from './flush' +import { sendModelRestoreNeededMarker, sendPtyDataToRenderer } from './payload' +import { + resyncBackgroundedDeliveriesAfterGateReset, + syncPtyBackgroundedDelivery, + updateProducerFlowControl +} from './producer-sync' +import { + acceptPtyDataForRenderer, + clearDeliveredHiddenRendererResizeOutput, + clearHiddenRendererResizeOutput, + rendererPtyIsKnownHidden +} from './accept' +import { + consumeSyntheticKillExit, + finalizePtyExitForRenderer, + preparePtyExitForRenderer, + rememberRetiredRejectedPty, + rememberSyntheticKillExit, + sendPtyExitToRenderer, + sendPtySpawnedToRenderer +} from './exit' +import { + transitionHiddenRendererPtyDeliveryState, + transitionSpawnHiddenRendererPtyDeliveryState +} from './hidden-transition' +import { requestSerializedBuffer } from '../ipc/serialize-buffer' +import { shutdownProviderAndDetectExit } from '../provider/shutdown-detect' +import type { PtyIpcSession } from '../session' + +export function wirePtyIpcSession(session: PtyIpcSession): void { + session.canSendPtyDataToRenderer = (id, options) => canSendPtyDataToRenderer(session, id, options) + session.schedulePendingDataFlush = (delayMs) => schedulePendingDataFlush(session, delayMs) + session.flushPendingData = () => flushPendingData(session) + session.sendPtyDataToRenderer = (id, payload, projectionAdmissionIds) => + sendPtyDataToRenderer(session, id, payload, projectionAdmissionIds) + session.sendModelRestoreNeededMarker = (id, reason, markerSeq) => + sendModelRestoreNeededMarker(session, id, reason, markerSeq) + session.updateProducerFlowControl = (id) => updateProducerFlowControl(session, id) + session.applyCumulativeAck = (id, processedChars) => + applyCumulativeAck(session, id, processedChars) + session.readCurrentPtyRendererDeliveryDebugSnapshot = () => + readCurrentPtyRendererDeliveryDebugSnapshot(session) + session.clearDeliveryResyncProbe = () => clearDeliveryResyncProbe(session) + session.clearPendingPtyData = () => clearPendingPtyData(session) + session.deletePendingPtyData = (id) => deletePendingPtyData(session, id) + session.setPendingPtyData = (id, pending) => setPendingPtyData(session, id, pending) + session.clearDispatcherReadyWatchdog = () => clearDispatcherReadyWatchdog(session) + session.armDispatcherReadyWatchdog = () => armDispatcherReadyWatchdog(session) + session.acceptPtyDataForRenderer = (payload, outputSeq, projection) => + acceptPtyDataForRenderer(session, payload, outputSeq, projection) + session.preparePtyExitForRenderer = (payload) => preparePtyExitForRenderer(session, payload) + session.finalizePtyExitForRenderer = (payload) => finalizePtyExitForRenderer(session, payload) + session.sendPtyExitToRenderer = (payload) => sendPtyExitToRenderer(session, payload) + session.sendPtySpawnedToRenderer = (id) => sendPtySpawnedToRenderer(session, id) + session.requestSerializedBuffer = (ptyId, opts) => requestSerializedBuffer(session, ptyId, opts) + session.shutdownProviderAndDetectExit = (provider, id, opts) => + shutdownProviderAndDetectExit(provider, id, opts) + session.rememberSyntheticKillExit = (id) => rememberSyntheticKillExit(session, id) + session.rememberRetiredRejectedPty = (id) => rememberRetiredRejectedPty(session, id) + session.consumeSyntheticKillExit = (id) => consumeSyntheticKillExit(session, id) + session.syncPtyBackgroundedDelivery = (id, caller) => + syncPtyBackgroundedDelivery(session, id, caller) + session.resyncBackgroundedDeliveriesAfterGateReset = () => + resyncBackgroundedDeliveriesAfterGateReset(session) + session.transitionHiddenRendererPtyDeliveryState = (id, hidden) => + transitionHiddenRendererPtyDeliveryState(session, id, hidden) + session.transitionSpawnHiddenRendererPtyDeliveryState = (id, hidden) => + transitionSpawnHiddenRendererPtyDeliveryState(session, id, hidden) + session.rendererPtyIsKnownHidden = rendererPtyIsKnownHidden + session.clearHiddenRendererResizeOutput = clearHiddenRendererResizeOutput + session.clearDeliveredHiddenRendererResizeOutput = clearDeliveredHiddenRendererResizeOutput + session.schedulePendingDataAfterCreditReport = (creditedAny) => + schedulePendingDataAfterCreditReport(session, creditedAny) + session.writeOffLostRendererDelivery = (report) => writeOffLostRendererDelivery(session, report) + session.getRendererInFlightCharsForPty = (id) => getRendererInFlightCharsForPty(session, id) + + setClearBackgroundedDeliverySyncForPty((id: string) => { + session.backgroundedDeliverySyncByPty.delete(id) + }) + if (session.runtime) { + session.runtime.onRemoteTerminalViewPresenceChanged = (id) => + session.syncPtyBackgroundedDelivery(id, 'remote-view') + } + + setReadPtyRendererDeliveryDebugSnapshot(session.readCurrentPtyRendererDeliveryDebugSnapshot) + setResetPtyRendererDeliveryDebugSnapshot(() => { + session.peakPendingChars = 0 + session.peakMaxPendingCharsByPty = 0 + session.peakRendererInFlightChars = 0 + session.peakMaxRendererInFlightCharsByPty = 0 + session.ackGatedFlushSkipCount = 0 + session.pendingDroppedChars = 0 + resetHiddenRendererPtyDeliveryDebugCounters() + seedPtyRendererDeliveryPeaksFromCurrentState(session) + }) + setResetRendererDeliveryAccountingForLifecycleReset(() => { + // Why lossless: pendingData bytes were bound for the dead page; the replacement repaints from main's authoritative sources, which superset it. + session.lastLifecycleResetClearedChars = session.rendererInFlightTotalChars + session.rendererLifecycleResetCount += 1 + // Why release before clearing: pending bytes and credits belonged to the dead page; releasing producer pauses first keeps no shell wedged. + session.producerFlowControl.releaseAll() + session.clearDeliveryResyncProbe() + session.deliveryResyncUnansweredWarnLogged = false + for (const id of session.rendererDeliveryAccountingByPty.keys()) { + session.sshOutputIntake?.transferPtyProjections(id, 'renderer-lifecycle-reset') + } + session.rendererDeliveryAccountingByPty.clear() + session.rendererInFlightTotalChars = 0 + session.clearPendingPtyData() + session.pendingOverflowMarkedPtys.clear() + session.rendererDeliveryRestoreNeededPtys.clear() + // Why hold sends: the reloading page's pty:data listener is gone until it re-registers/handshakes, so bytes would drop into a listener-less page and re-pin the gate. + session.rendererPtyDispatcherReady = false + // Why: arm the self-heal watchdog so a never-arriving handshake can't hold the gate forever; the real handshake cancels it. + session.armDispatcherReadyWatchdog() + }) + // Why the bridge: let a later re-registration cancel this closure's watchdog (armed via a hoisted fn, so this assignment can precede its definition). + setClearRendererDispatcherReadyWatchdog(session.clearDispatcherReadyWatchdog) + setInvalidatePendingPtyDrainPriority((id, schedule) => + invalidatePendingPtyDrainClassification(session, id, schedule) + ) + setInvalidatePendingPtyDrainPolicy((id, schedule) => + invalidatePendingPtyDrainClassification(session, id, schedule) + ) +} diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts new file mode 100644 index 00000000000..569b40052df --- /dev/null +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -0,0 +1,272 @@ +import { join, delimiter } from 'node:path' +import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' +import { + detectExplicitPiAgentKindFromCommand, + isPiCompatibleAgentType +} from '../../../../shared/pi-agent-kind' +import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' +import { openCodeHookService } from '../../../opencode/hook-service' +import { mimoCodeHookService } from '../../../mimo/hook-service' +import { agentHookServer } from '../../../agent-hooks/server' +import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager' +import { piTitlebarExtensionService } from '../../../pi/titlebar-extension-service' +import { ensureLinuxTerminalOrcaCliShimDir } from '../../../cli/linux-terminal-orca-cli-shim' +import { stripLegacyTerminalShimEnv } from '../../../pty/legacy-terminal-shim-dir' +import { resolvePathEnvKey, mergePersistedWindowsPath } from '../../../pty/windows-environment-path' +import { resolveCodexShellLaunchPreflightCommand } from '../../../pty/codex-shell-launch-preflight' +import { buildConfiguredProxyEnv } from '../../../../shared/network-proxy' +import type { BuildPtyHostEnvOptions } from './types' +import { readInheritedPath } from './path' +import { stripInheritedOrcaCodexHomeOverride } from './codex-home' +import { + clearPiAgentShadowEnv, + exposePiManagedExtensionEnv, + isMimoLaunchCommand, + resolveMimocodeSourceHome, + resolveOpenCodeSourceConfigDir, + resolvePiAgentSourceDir, + resolveScopedPiAgentSourceDir, + restoreOrStripOverlayEnv +} from './pi-agent' +import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys' + +/** + * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. + * + * Do NOT call when `args.connectionId` is set (SSH): every injection is host-loopback + * or references local filesystem paths meaningless to a remote shell. + */ +export function buildPtyHostEnv( + id: string, + baseEnv: Record, + opts: BuildPtyHostEnvOptions +): Record { + mergePersistedWindowsPath(baseEnv) + Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings)) + + // Why: local path's baseEnv includes process.env but the daemon path doesn't (fork inheritance, not IPC); check both sources so guards stay in lock-step across spawn paths. + const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv) + const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) + const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent) + ? opts.launchAgent + : opts.launchAgent === undefined + ? detectExplicitPiAgentKindFromCommand(launchCommandHint) + : null + const piAgentKind = explicitPiAgentKind ?? 'pi' + const hasLaunchCommand = + typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 + + // Why: unattended agents must fail instead of looping on OS credential prompts; user terminals keep normal Git behavior. + applyTerminalGitCredentialPromptGuard(baseEnv, { + launchCommand: launchCommandHint, + isUnattended: opts.launchAgent !== undefined, + deferGitConfigGuardToHost: opts.deferGitConfigGuardToDaemon + }) + + const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand + // Why: source shadows are agent-scoped; trusting the other kind's source reintroduces Pi/OMP extension-state shadowing. + const preexistingPiAgentDir = resolvePiAgentSourceDir(baseEnv, 'pi') + const preexistingOmpAgentDir = + piAgentKind === 'omp' + ? resolvePiAgentSourceDir(baseEnv, 'omp') + : resolveScopedPiAgentSourceDir(baseEnv, 'omp') + const preexistingPrimeAgentDir = + piAgentKind === 'prime-agent' + ? resolvePiAgentSourceDir(baseEnv, 'prime-agent') + : resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent') + + if (opts.agentStatusHooksEnabled) { + // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md. + Object.assign(baseEnv, openCodeHookService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) + if (baseEnv.OPENCODE_CONFIG_DIR) { + // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value. + baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR + if (preexistingOpenCodeConfigDir) { + // Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays. + baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir + } else { + delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } + } + if (isMimoLaunchCommand(launchCommandHint)) { + const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) + Object.assign(baseEnv, mimoCodeHookService.buildPtyEnv(id, preexistingMimocodeHome)) + if (baseEnv.MIMOCODE_HOME) { + baseEnv.ORCA_MIMOCODE_HOME = baseEnv.MIMOCODE_HOME + if (preexistingMimocodeHome) { + baseEnv.ORCA_MIMOCODE_SOURCE_HOME = preexistingMimocodeHome + } else { + delete baseEnv.ORCA_MIMOCODE_SOURCE_HOME + } + } + } + } else { + restoreOrStripOverlayEnv(baseEnv, { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + }) + restoreOrStripOverlayEnv(baseEnv, { + primary: 'MIMOCODE_HOME', + overlay: 'ORCA_MIMOCODE_HOME', + source: 'ORCA_MIMOCODE_SOURCE_HOME' + }) + } + + // Why: strip inherited hook coordinates before injecting this PTY's fresh loopback receiver, so nested-terminal callbacks route to the owning pane. + for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) { + delete baseEnv[key] + } + if (opts.agentStatusHooksEnabled) { + Object.assign(baseEnv, agentHookServer.buildPtyEnv()) + if (opts.isWsl === true) { + // Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one. + const distro = opts.wslDistro ?? null + wslHookRelayManager.ensureForDistro(distro) + const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro) + if (guestEndpoint) { + baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint + } + // Why: OpenCode loads its status plugin from a guest config overlay, so point OPENCODE_CONFIG_DIR at the guest dir the relay materialized. + const opencodeOverlayDir = wslHookRelayManager.getOpenCodeOverlayDir(distro) + if (opencodeOverlayDir) { + baseEnv.OPENCODE_CONFIG_DIR = opencodeOverlayDir + baseEnv.ORCA_OPENCODE_CONFIG_DIR = opencodeOverlayDir + delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } else { + // Why: relay not connected yet (or older guest bundle) — never cross the Windows overlay path into WSL; drop it so in-guest OpenCode uses its own config (pre-fix behavior, no status but no regression). + delete baseEnv.OPENCODE_CONFIG_DIR + delete baseEnv.ORCA_OPENCODE_CONFIG_DIR + delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } + } + } + + // Why: PI_CODING_AGENT_DIR is the user's config/session root; install only Orca-owned extension files, don't override it. + if (opts.agentStatusHooksEnabled) { + clearPiAgentShadowEnv(baseEnv, 'pi') + clearPiAgentShadowEnv(baseEnv, 'omp') + clearPiAgentShadowEnv(baseEnv, 'prime-agent') + // Why: bare shells historically defaulted to Pi + OMP shadow prep and + // created ~/./agent even when the user never launches those agents + // (#10196). Only create default homes on an explicit Pi/OMP launch; + // otherwise install only into an existing agent dir (or userData for OMP + // status so a typed `omp` still gets the shell wrapper extension). + if (piAgentKind === 'pi') { + const piEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir, 'pi', { + materializeDefaultHome: explicitPiAgentKind === 'pi' + }) + Object.assign(baseEnv, piEnv) + exposePiManagedExtensionEnv(baseEnv, 'pi', piEnv) + } + + if (shouldPrepareOmpShadow) { + const ompEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingOmpAgentDir, 'omp', { + materializeDefaultHome: explicitPiAgentKind === 'omp' + }) + Object.assign(baseEnv, ompEnv) + exposePiManagedExtensionEnv(baseEnv, 'omp', ompEnv) + } + + if (piAgentKind === 'prime-agent' && !opts.isWsl) { + const primeEnv = piTitlebarExtensionService.buildPtyEnv( + id, + preexistingPrimeAgentDir, + 'prime-agent', + { materializeDefaultHome: explicitPiAgentKind === 'prime-agent' } + ) + Object.assign(baseEnv, primeEnv) + exposePiManagedExtensionEnv(baseEnv, 'prime-agent', primeEnv) + } + } else { + // Why: nested PTYs must not inherit stale source or overlay state from another agent. + restoreOrStripOverlayEnv(baseEnv, { + primary: 'PI_CODING_AGENT_DIR', + overlay: 'ORCA_PI_CODING_AGENT_DIR', + source: 'ORCA_PI_SOURCE_AGENT_DIR' + }) + restoreOrStripOverlayEnv(baseEnv, { + primary: 'PI_CODING_AGENT_DIR', + overlay: 'ORCA_OMP_CODING_AGENT_DIR', + source: 'ORCA_OMP_SOURCE_AGENT_DIR' + }) + delete baseEnv.ORCA_OMP_STATUS_EXTENSION + delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION + } + + // Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex. + if (opts.skipCodexHomeEnv) { + delete baseEnv.CODEX_HOME + delete baseEnv.ORCA_CODEX_HOME + delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT + } else if (opts.selectedCodexHomePath) { + baseEnv.CODEX_HOME = opts.selectedCodexHomePath + // Why: user startup files may re-export CODEX_HOME; shell-ready wrappers restore this runtime home before Codex launches. + baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath + const preflightCommand = resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: opts.codexStatusHooksEnabled ?? opts.agentStatusHooksEnabled, + isPackaged: opts.isPackaged, + isWsl: opts.isWsl, + managedHomePath: opts.selectedCodexHomePath, + userDataPath: opts.userDataPath, + resourcesPath: opts.resourcesPath + }) + if (preflightCommand) { + baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT = preflightCommand + } else { + delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT + } + } else if (opts.stripInheritedOrcaCodexHome) { + stripInheritedOrcaCodexHomeOverride(baseEnv) + delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT + } else { + delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT + } + + // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. + if (opts.isWsl) { + baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath + // Why: managed WSL registration uses `orca-ide`; exposing that literal scopes agent guidance to WSL without a bare-orca shim. + baseEnv.ORCA_CLI_COMMAND = opts.isPackaged ? 'orca-ide' : 'orca-dev' + } else { + if (!opts.isPackaged) { + baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath + } + delete baseEnv.ORCA_CLI_COMMAND + } + // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca. + if (!opts.isPackaged) { + const devCliBin = join(opts.userDataPath, 'cli', 'bin') + const inheritedPath = readInheritedPath(baseEnv) + // Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter. + baseEnv[resolvePathEnvKey(baseEnv, process.platform)] = inheritedPath + ? `${devCliBin}${delimiter}${inheritedPath}` + : devCliBin + } else if (process.platform === 'linux') { + // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904). + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath }) + if (shimDir) { + const inheritedEntries = readInheritedPath(baseEnv) + .split(delimiter) + .filter((entry) => entry.length > 0 && entry !== shimDir) + baseEnv.PATH = [shimDir, ...inheritedEntries].join(delimiter) + } + } else if ( + opts.resourcesPath && + (process.platform === 'darwin' || process.platform === 'win32') + ) { + // Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI. + const bundledCliBin = join(opts.resourcesPath, 'bin') + const inheritedPath = readInheritedPath(baseEnv) + baseEnv[resolvePathEnvKey(baseEnv, process.platform)] = inheritedPath + ? `${bundledCliBin}${delimiter}${inheritedPath}` + : bundledCliBin + } + + // Why: must run after the prepends above — they re-read PATH from the unscrubbed + // process.env when baseEnv carries none, which is the daemon path's normal shape. + stripLegacyTerminalShimEnv(baseEnv, process.platform) + + return baseEnv +} diff --git a/src/main/ipc/pty/host-env/codex-home.ts b/src/main/ipc/pty/host-env/codex-home.ts new file mode 100644 index 00000000000..ae825e31e78 --- /dev/null +++ b/src/main/ipc/pty/host-env/codex-home.ts @@ -0,0 +1,301 @@ +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' +import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' +import { + isCodexHomeAuthReadyForLaunch, + waitForManagedCodexAuthReady +} from '../../../codex-accounts/managed-codex-auth-readiness' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import { + forgetCodexPaneAccount, + getCodexPaneAccount, + recordCodexPaneAccount, + type CodexPaneHomeRoute +} from '../../../codex/codex-pane-account-registry' +import { resolveCodexPaneLaunchAccount } from '../../../codex/codex-pane-launch-account' +import { getSystemCodexHomePath } from '../../../codex/codex-home-paths' +import { + environmentCodexHomeOverrideContextsEqual, + getCustomCodexHomeOverrideForLaunch, + shellStartupCodexHomeOverrideContextsEqual +} from '../../../codex/codex-real-home-path' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../../../pty/codex-home-wsl-env' +import { isWslShellName } from '../../../../shared/local-windows-terminal-runtime' +import { parseWslPath } from '../../../wsl' + +export function shouldSkipCodexHomeEnvForWindowsShell( + shellPath: string | undefined, + cwd: string | undefined +): boolean { + return isWslShellName(shellPath) || (typeof cwd === 'string' && parseWslPath(cwd) !== null) +} + +export function isCodexStatusHooksEnabled(settings: GlobalSettings | undefined): boolean { + return ( + isAgentStatusHooksEnabled(settings) && isTuiAgentEnabled('codex', settings?.disabledTuiAgents) + ) +} + +// Why: with the real-home flag ON, a host system-default launch resolves to a +// null managed home. Signal the env builder to strip a nested-Orca-inherited +// override instead of injecting one, so Codex runs on the user's own ~/.codex. +export function shouldStripInheritedOrcaCodexHome(args: { + target: CodexAccountSelectionTarget + selectedCodexHomePath: string | null + skipCodexHomeEnv: boolean + settings: GlobalSettings | undefined +}): boolean { + return ( + args.target.runtime === 'host' && args.selectedCodexHomePath === null && !args.skipCodexHomeEnv + ) +} + +export const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const + +// Why: system-default real-home routing runs Codex on the user's own ~/.codex. +// Nested Orca panes inherit the parent's Orca-owned override; strip only that +// (CODEX_HOME matching Orca's private ORCA_CODEX_HOME marker), and always drop +// the marker so a shell-ready wrapper cannot restore the managed home. A +// user-set CODEX_HOME with no Orca marker is preserved untouched (see #8606). +export function stripInheritedOrcaCodexHomeOverride(baseEnv: Record): void { + for (const key of getLocalOrcaCodexHomeEnvKeysToDelete(baseEnv)) { + delete baseEnv[key] + } +} + +// Why: in-process spawns share main's inherited environment, so equality with +// the private marker is authoritative here. Persistent daemons compare locally. +export function getLocalOrcaCodexHomeEnvKeysToDelete(env: Record): string[] { + const inheritedOrcaOverride = env.ORCA_CODEX_HOME ?? process.env.ORCA_CODEX_HOME + const inheritedCodexHome = env.CODEX_HOME ?? process.env.CODEX_HOME + const keysToDelete = ['ORCA_CODEX_HOME'] + if (inheritedOrcaOverride && inheritedCodexHome === inheritedOrcaOverride) { + keysToDelete.push('CODEX_HOME') + } + return keysToDelete +} + +export function getCodexSelectionTargetForPty( + shellPath: string | undefined, + cwd: string | undefined, + wslDistro?: string | null +): CodexAccountSelectionTarget { + const wslPath = typeof cwd === 'string' ? parseWslPath(cwd) : null + if (isWslShellName(shellPath) || wslPath) { + return { runtime: 'wsl', wslDistro: wslPath?.distro ?? wslDistro ?? null } + } + return { runtime: 'host' } +} + +export function getCompatibleSelectedCodexHomePath( + target: CodexAccountSelectionTarget, + selectedCodexHomePath: string | null +): string | null { + if (!selectedCodexHomePath) { + return null + } + const wslInfo = parseWslPath(selectedCodexHomePath) + if (target.runtime === 'wsl') { + return wslInfo || !isHostCodexHomeForWsl(selectedCodexHomePath) ? selectedCodexHomePath : null + } + return wslInfo || (process.platform === 'win32' && isWslCodexHomeForHost(selectedCodexHomePath)) + ? null + : selectedCodexHomePath +} + +export const MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE = + 'The selected Codex account credentials are temporarily unavailable. Try opening the terminal again.' +export const CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE = + 'The Codex account credentials for this session are temporarily unavailable. Try opening the terminal again.' + +type ManagedCodexAuthResolutionArgs = { + selectedCodexHomePath: string | null + getSettings: () => GlobalSettings | undefined + requiredCodexHomePath?: string + target: CodexAccountSelectionTarget + resolveCurrent: () => string | null + resolveAfterUnavailable: (unavailableManagedHomePath: string) => string | null +} + +export function resolveCodexHomeAfterManagedAuthReadiness( + args: ManagedCodexAuthResolutionArgs +): string | null | Promise { + const selectedCodexHomePath = args.selectedCodexHomePath + if ( + args.requiredCodexHomePath && + !codexHomePathsEqual(selectedCodexHomePath, args.requiredCodexHomePath) + ) { + throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) + } + const readiness = waitForManagedCodexAuthReady({ + codexHomePath: selectedCodexHomePath, + settings: args.getSettings(), + target: args.target + }) + return readiness + ? continueCodexHomeAfterManagedAuthWait(args, selectedCodexHomePath, readiness) + : selectedCodexHomePath +} + +async function continueCodexHomeAfterManagedAuthWait( + args: ManagedCodexAuthResolutionArgs, + initialCodexHomePath: string | null, + initialReadiness: Promise +): Promise { + let selectedCodexHomePath = initialCodexHomePath + let readiness = initialReadiness + for (let attempt = 0; attempt < 2; attempt += 1) { + if (await readiness) { + if (args.requiredCodexHomePath) { + return selectedCodexHomePath + } + const currentCodexHomePath = args.resolveCurrent() + if (codexHomeSelectionsEqual(selectedCodexHomePath, currentCodexHomePath)) { + return selectedCodexHomePath + } + selectedCodexHomePath = currentCodexHomePath + if (attempt === 1) { + break + } + const nextReadiness = waitForManagedCodexAuthReady({ + codexHomePath: selectedCodexHomePath, + settings: args.getSettings(), + target: args.target + }) + if (!nextReadiness) { + return selectedCodexHomePath + } + readiness = nextReadiness + continue + } + if (args.requiredCodexHomePath) { + throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) + } + selectedCodexHomePath = args.resolveAfterUnavailable(selectedCodexHomePath!) + if (attempt === 1) { + break + } + const nextReadiness = waitForManagedCodexAuthReady({ + codexHomePath: selectedCodexHomePath, + settings: args.getSettings(), + target: args.target + }) + if (!nextReadiness) { + return selectedCodexHomePath + } + readiness = nextReadiness + } + if ( + isCodexHomeAuthReadyForLaunch({ + codexHomePath: selectedCodexHomePath, + settings: args.getSettings(), + target: args.target + }) + ) { + return selectedCodexHomePath + } + throw new Error(MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE) +} + +export function codexHomeSelectionsEqual(left: string | null, right: string | null): boolean { + return ( + left === right || + (left !== null && + right !== null && + normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right)) + ) +} + +export function codexHomePathsEqual(left: string | null, right: string): boolean { + return codexHomeSelectionsEqual(left, right) +} + +// Why: CODEX_HOME is fixed in a shell's environment at spawn and the daemon +// keeps that shell alive across app restarts, so the launch account is the only +// way to tell later that a pane still runs Codex as the previously selected +// account. A reattach inherits that baked environment rather than choosing one, +// so re-recording it under the current selection would erase the very evidence +// that the pane is stale. +export function recordCodexPaneAccountForSpawn(args: { + ptyId: string | undefined + isDaemonHostSpawn: boolean + isReattach: boolean + pinnedByResume: boolean + launchCodexHomePath: string | null + launchEnv?: NodeJS.ProcessEnv + target: CodexAccountSelectionTarget + settings: GlobalSettings | undefined +}): void { + if (!args.ptyId || !args.isDaemonHostSpawn || args.isReattach) { + return + } + const customHomeOverride = getCustomCodexHomeOverrideForLaunch(args.launchEnv) + const processHomeOverride = customHomeOverride ? getCustomCodexHomeOverrideForLaunch() : null + const recheckableEnvironmentOverride = + customHomeOverride?.source === 'environment' && + processHomeOverride?.source === 'environment' && + environmentCodexHomeOverrideContextsEqual( + customHomeOverride.context, + processHomeOverride.context + ) + ? customHomeOverride.context + : undefined + const recheckableShellStartupOverride = + customHomeOverride?.source === 'shell-startup' && + processHomeOverride?.source === 'shell-startup' && + shellStartupCodexHomeOverrideContextsEqual( + customHomeOverride.context, + processHomeOverride.context + ) + ? customHomeOverride.context + : undefined + const record = args.settings + ? resolveCodexPaneLaunchAccount({ + pinnedByResume: args.pinnedByResume, + launchCodexHomePath: args.launchCodexHomePath, + // Why: pane-local overrides cannot be re-derived when a restart builds + // a fresh launch env, so route prompts would guess and block valid input. + recordComparableHomeRoute: + args.pinnedByResume || + ((customHomeOverride?.source !== 'environment' || + recheckableEnvironmentOverride !== undefined) && + (customHomeOverride?.source !== 'shell-startup' || + recheckableShellStartupOverride !== undefined)), + shellStartupHomeOverride: args.pinnedByResume ? undefined : recheckableShellStartupOverride, + environmentHomeOverride: args.pinnedByResume ? undefined : recheckableEnvironmentOverride, + systemCodexHomePath: getSystemCodexHomePath(), + settings: args.settings, + target: args.target + }) + : null + if (!record) { + forgetCodexPaneAccount(args.ptyId) + return + } + recordCodexPaneAccount(args.ptyId, record) +} + +export function snapshotCodexPaneHomeRoutes( + ptyIds: readonly (string | null | undefined)[] +): ReadonlyMap { + const routes = new Map() + for (const ptyId of ptyIds) { + if (!ptyId || routes.has(ptyId)) { + continue + } + routes.set(ptyId, getCodexPaneAccount(ptyId)?.homeRoute ?? null) + } + return routes +} + +export function codexReattachedHomeRouteField( + routes: ReadonlyMap, + ptyId: string, + reattached: boolean +): { reattachedHomeRoute?: CodexPaneHomeRoute | null } { + if (!reattached || !routes.has(ptyId)) { + return {} + } + return { reattachedHomeRoute: routes.get(ptyId) ?? null } +} diff --git a/src/main/ipc/pty/host-env/codex-resume.ts b/src/main/ipc/pty/host-env/codex-resume.ts new file mode 100644 index 00000000000..a32aefe9057 --- /dev/null +++ b/src/main/ipc/pty/host-env/codex-resume.ts @@ -0,0 +1,138 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import { + normalizeAgentProviderSession, + type AgentProviderSessionMetadata +} from '../../../../shared/agent-session-resume' +import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing' +import { dropAgentResumeArgvFromCommand } from '../../../../shared/agent-resume-argv-drop' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import { dropUnverifiedCodexResumeArgv } from '../../../codex/codex-unverified-resume-launch' +import type { CodexSessionResumePreparation } from '../../../codex/codex-session-resume-home' +import { CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE, codexHomePathsEqual } from './codex-home' +import type { PrepareCodexSessionResume } from './types' + +export type CodexResumeLaunch = { + codexResumeHome: Extract | null + command: string | undefined + notifyResumeUnavailable: boolean + droppedResumeArgv: boolean + providerSession: AgentProviderSessionMetadata | null +} + +export type PreparedCodexResumeHome = { + providerSession: AgentProviderSessionMetadata + preparation: Promise +} + +export type PrepareCodexResumeHomeArgs = { + connectionId?: string | null + launchAgent?: TuiAgent + providerSession?: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string +} + +export function prepareCodexResumeHome( + prepareCodexSessionResume: PrepareCodexSessionResume | undefined, + args: PrepareCodexResumeHomeArgs +): PreparedCodexResumeHome | null { + if (args.connectionId || args.launchAgent !== 'codex' || !prepareCodexSessionResume) { + return null + } + const providerSession = normalizeAgentProviderSession(args.providerSession) + if (!providerSession) { + return null + } + return { + providerSession, + preparation: prepareCodexSessionResume({ + providerSession, + target: args.target, + launchEnv: args.launchEnv, + workspacePath: args.workspacePath + }) + } +} + +/** Kept separate from resolveCodexResumeLaunch so non-Codex spawns never await: + * an extra tick reorders the pane-spawn reservation races this handler arbitrates. */ +export function noCodexResumeLaunch(command: string | undefined): CodexResumeLaunch { + return { + codexResumeHome: null, + command, + notifyResumeUnavailable: false, + droppedResumeArgv: false, + providerSession: null + } +} + +/** The command a Codex launch actually runs: unchanged when provenance is verified, + * stripped of `resume ` when it is not. */ +export function resolveCodexResumeLaunch( + command: string | undefined, + preparation: PreparedCodexResumeHome +): Promise { + return preparation.preparation.then((prepared) => { + const providerSession = preparation.providerSession + if (prepared?.outcome !== 'fresh') { + return { + codexResumeHome: prepared ?? null, + command, + notifyResumeUnavailable: false, + droppedResumeArgv: false, + providerSession + } + } + const dropped = dropUnverifiedCodexResumeArgv({ + command, + providerSession, + claimedCodexProvenance: prepared.claimedCodexProvenance + }) + return { + codexResumeHome: null, + command: dropped.command, + // Why: staying silent only makes sense for metadata that positively belongs to + // another agent; a resume with no transcript path at all still owes the user a notice. + notifyResumeUnavailable: + dropped.droppedResumeArgv && + (prepared.claimedCodexProvenance || !providerSession.transcriptPath), + droppedResumeArgv: dropped.droppedResumeArgv, + providerSession + } + }) +} + +export function reconcileSharedRuntimeResumeHome( + resumeHome: Extract, + resolveCurrentHome: () => string | null +): string { + if (!resumeHome.reconcileSharedRuntimeAuth) { + return resumeHome.codexHomePath + } + const currentHome = resolveCurrentHome() + if (!codexHomePathsEqual(currentHome, resumeHome.codexHomePath)) { + throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) + } + return resumeHome.codexHomePath +} + +/** Why: buildPtyHostEnv prefers ORCA_SEQUENCED_STARTUP_COMMAND over the launch command + * and the sequenced wrapper `eval`s it, so a dropped resume argv has to go there too. */ +export function stripSequencedStartupResumeArgv | undefined>( + env: T, + launch: CodexResumeLaunch +): T { + const sequenced = env?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV] + if (!env || !sequenced || !launch.droppedResumeArgv || !launch.providerSession) { + return env + } + const drop = dropAgentResumeArgvFromCommand({ + command: sequenced, + agent: 'codex', + providerSession: launch.providerSession + }) + return drop.status === 'dropped' + ? { ...env, [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: drop.command } + : env +} diff --git a/src/main/ipc/pty/host-env/fresh-spawn-routing.ts b/src/main/ipc/pty/host-env/fresh-spawn-routing.ts new file mode 100644 index 00000000000..64034d544c2 --- /dev/null +++ b/src/main/ipc/pty/host-env/fresh-spawn-routing.ts @@ -0,0 +1,56 @@ +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' +import type { IPtyProvider } from '../../../providers/types' +import { beginTerminalInstall } from '../../watcher-removal-gate' + +export function isClaudeLaunchCommand(command: string | undefined): boolean { + if (!command) { + return false + } + return /(^|[\s;&|('"`])(?:[^\s;&|('"`]*[\\/])?claude(?:\.cmd|\.exe)?($|[\s;&|)'"`])/i.test( + command + ) +} + +export function routesFreshSpawnsToLocalProvider(provider: IPtyProvider): boolean { + return provider.routesFreshSpawnsToLocalProvider === true +} + +export function recoverFreshSpawnProviderRouting( + provider: IPtyProvider, + connectionId: string | null | undefined, + sessionId: string | undefined, + isNewSession = sessionId === undefined +): Promise | undefined { + if (connectionId || (!isNewSession && sessionId) || !routesFreshSpawnsToLocalProvider(provider)) { + return + } + return provider.recoverFreshSpawnRouting?.() +} + +export function beginPtySpawnForWorktree( + worktreeId: string | undefined, + cwd: string | undefined, + connectionId: string | null | undefined +): () => void { + const worktreePath = worktreeId + ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + : undefined + const installPaths = new Map() + for (const candidate of [worktreePath, cwd]) { + if (candidate) { + installPaths.set(normalizeRuntimePathForComparison(candidate), candidate) + } + } + const finishes: (() => void)[] = [] + try { + for (const candidate of installPaths.values()) { + finishes.push(beginTerminalInstall(candidate, connectionId ?? undefined)) + } + } catch (error) { + // Why: worktree ID and cwd can be different roots; release earlier admissions before rejecting. + finishes.toReversed().forEach((finish) => finish()) + throw error + } + return () => finishes.toReversed().forEach((finish) => finish()) +} diff --git a/src/main/ipc/pty/host-env/path.ts b/src/main/ipc/pty/host-env/path.ts new file mode 100644 index 00000000000..226ad8691fe --- /dev/null +++ b/src/main/ipc/pty/host-env/path.ts @@ -0,0 +1,47 @@ +import { delimiter } from 'node:path' +import { isLegacyTerminalShimPathEntry } from '../../../pty/legacy-terminal-shim-dir' +import { resolvePathEnvKey } from '../../../pty/windows-environment-path' + +export function readInheritedPath(baseEnv: Record): string { + const pathKey = resolvePathEnvKey(baseEnv, process.platform) + return baseEnv[pathKey] ?? process.env[pathKey] ?? '' +} + +export function firstPathEntry(pathValue: string | undefined): string | null { + const first = pathValue?.split(delimiter).find((entry) => entry.trim().length > 0) + return first ?? null +} + +export function promoteAgentTeamsShimPath( + env: Record | undefined, + requestedPath: string | undefined +): void { + if (!env?.ORCA_AGENT_TEAMS_TEAM_ID) { + return + } + const shimPath = firstPathEntry(requestedPath) + // Why: requestedPath is captured before buildPtyHostEnv scrubs, so a legacy entry that + // reached the front would be re-prepended here and outlive the scrub. + if (!shimPath || isLegacyTerminalShimPathEntry(shimPath)) { + return + } + const currentPathKey = env.PATH !== undefined || env.Path === undefined ? 'PATH' : 'Path' + const currentPath = env[currentPathKey] ?? '' + const remaining = currentPath + .split(delimiter) + .filter((entry) => entry.length > 0 && entry !== shimPath) + // Why: host env injection prepends Orca's shims; Claude Agent Teams must still resolve our fake tmux before any real tmux. + env[currentPathKey] = [shimPath, ...remaining].join(delimiter) +} + +export function deleteRequestedEnvKeys( + env: Record | undefined, + keys: string[] | undefined +): void { + if (!env || !keys) { + return + } + for (const key of keys) { + delete env[key] + } +} diff --git a/src/main/ipc/pty/host-env/pi-agent.ts b/src/main/ipc/pty/host-env/pi-agent.ts new file mode 100644 index 00000000000..545d04d063d --- /dev/null +++ b/src/main/ipc/pty/host-env/pi-agent.ts @@ -0,0 +1,202 @@ +import { + getCommandTokenPathBasename, + getFirstCommandToken +} from '../../../../shared/command-token-scanner' +import { + PRIMARY_AGENT_DIR_ENV_BY_KIND, + SOURCE_AGENT_DIR_ENV_BY_KIND, + type PiAgentKind +} from '../../../../shared/pi-agent-kind' +import { readSessionShellStartupEnvVar } from '../../../pty/shell-startup-env' +import { AGENT_HOOK_RUNTIME_ENV_KEYS, CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS } from './spawn-env-keys' + +export function readEnvWithProcessFallback( + baseEnv: Record, + key: string +): string | undefined { + return baseEnv[key] ?? process.env[key] +} + +export function resolvePiAgentSourceDir( + baseEnv: Record, + kind: PiAgentKind +): string | undefined { + const sourceKey = SOURCE_AGENT_DIR_ENV_BY_KIND[kind] + const primaryKey = PRIMARY_AGENT_DIR_ENV_BY_KIND[kind] + + const sourceDir = readEnvWithProcessFallback(baseEnv, sourceKey) + if (sourceDir) { + return sourceDir + } + + if (kind === 'prime-agent') { + return ( + readEnvWithProcessFallback(baseEnv, primaryKey) ?? + readSessionShellStartupEnvVar(primaryKey, baseEnv) + ) + } + + const overlayKey = kind === 'omp' ? 'ORCA_OMP_CODING_AGENT_DIR' : 'ORCA_PI_CODING_AGENT_DIR' + const otherOverlayKey = kind === 'omp' ? 'ORCA_PI_CODING_AGENT_DIR' : 'ORCA_OMP_CODING_AGENT_DIR' + + const publicDir = readEnvWithProcessFallback(baseEnv, primaryKey) + const ownOverlayDir = readEnvWithProcessFallback(baseEnv, overlayKey) + const otherOverlayDir = readEnvWithProcessFallback(baseEnv, otherOverlayKey) + // Why: if PI_CODING_AGENT_DIR is a restored Orca overlay with no source shadow, remirroring leaks another agent's overlay tree; fall through to defaults. + if (publicDir && publicDir !== ownOverlayDir && publicDir !== otherOverlayDir) { + return publicDir + } + + return readSessionShellStartupEnvVar(primaryKey, baseEnv) +} + +export function resolveScopedPiAgentSourceDir( + baseEnv: Record, + kind: PiAgentKind +): string | undefined { + return readEnvWithProcessFallback(baseEnv, SOURCE_AGENT_DIR_ENV_BY_KIND[kind]) +} + +export function clearPiAgentShadowEnv(baseEnv: Record, kind: PiAgentKind): void { + if (kind === 'omp') { + delete baseEnv.ORCA_OMP_CODING_AGENT_DIR + delete baseEnv.ORCA_OMP_SOURCE_AGENT_DIR + delete baseEnv.ORCA_OMP_STATUS_EXTENSION + return + } + if (kind === 'prime-agent') { + delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION + return + } + delete baseEnv.ORCA_PI_CODING_AGENT_DIR + delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR +} + +export function exposePiManagedExtensionEnv( + baseEnv: Record, + kind: PiAgentKind, + managedEnv: Record +): void { + if (kind === 'omp') { + delete baseEnv.ORCA_OMP_CODING_AGENT_DIR + if (managedEnv.ORCA_OMP_SOURCE_AGENT_DIR) { + baseEnv.ORCA_OMP_SOURCE_AGENT_DIR = managedEnv.ORCA_OMP_SOURCE_AGENT_DIR + } else { + delete baseEnv.ORCA_OMP_SOURCE_AGENT_DIR + } + if (managedEnv.ORCA_OMP_STATUS_EXTENSION) { + baseEnv.ORCA_OMP_STATUS_EXTENSION = managedEnv.ORCA_OMP_STATUS_EXTENSION + } else { + delete baseEnv.ORCA_OMP_STATUS_EXTENSION + } + return + } + if (kind === 'prime-agent') { + if (managedEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR) { + baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR = managedEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + } else { + delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + } + return + } + delete baseEnv.ORCA_PI_CODING_AGENT_DIR + if (managedEnv.ORCA_PI_SOURCE_AGENT_DIR) { + baseEnv.ORCA_PI_SOURCE_AGENT_DIR = managedEnv.ORCA_PI_SOURCE_AGENT_DIR + } else { + delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR + } +} + +// Why: variadic because a nested call per source made intermediate `string[] | undefined` collide with the parameter type. +export function mergePtyEnvDeletions( + existingKeys: string[] | undefined, + ...additionalKeyGroups: readonly (readonly string[])[] +): string[] | undefined { + if (!existingKeys && additionalKeyGroups.every((keys) => keys.length === 0)) { + return undefined + } + return Array.from(new Set([...(existingKeys ?? []), ...additionalKeyGroups.flat()])) +} + +export function removeCodexHomeDeletionRequests(keys: string[] | undefined): string[] | undefined { + // Why: resume provenance is launch-authoritative; late deletions must not fall back to the current account. + const filtered = keys?.filter((key) => key !== 'CODEX_HOME' && key !== 'ORCA_CODEX_HOME') + return filtered?.length ? filtered : undefined +} + +export function getInheritedAgentHookEnvKeysToDelete( + spawnEnv: Record | undefined +): string[] { + const env = spawnEnv ?? {} + // Why: providers merge process.env after cleanup; delete stale hook keys without dropping fresh coordinates buildPtyHostEnv set. + return AGENT_HOOK_RUNTIME_ENV_KEYS.filter((key) => env[key] === undefined) +} + +export function getInheritedClaudeSessionStampEnvKeysToDelete( + spawnEnv: Record | undefined +): string[] { + const env = spawnEnv ?? {} + // Why: strip only values inherited from the pty host; a caller that explicitly + // provides a stamp (deliberately spawning a nested Claude child) keeps it. + return CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined) +} + +// Why: a nested terminal can inherit prior OpenCode/Pi/OMP overlay env; restore the user's recorded source dir, else strip only Orca-owned values. +export function restoreOrStripOverlayEnv( + baseEnv: Record, + keys: { + primary: string + overlay: string + source: string + } +): void { + const sourceValue = baseEnv[keys.source] ?? process.env[keys.source] + const overlayValue = baseEnv[keys.overlay] ?? process.env[keys.overlay] + if (sourceValue) { + baseEnv[keys.primary] = sourceValue + } else if (overlayValue && baseEnv[keys.primary] === overlayValue) { + delete baseEnv[keys.primary] + } + delete baseEnv[keys.overlay] + delete baseEnv[keys.source] +} + +export function isMimoLaunchCommand(launchCommand: string | undefined): boolean { + const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCommand ?? '')) + .toLowerCase() + .replace(/\.(?:cmd|exe|sh)$/, '') + return binary === 'mimo' +} + +export function resolveMimocodeSourceHome(baseEnv: Record): string | undefined { + const sourceHome = baseEnv.ORCA_MIMOCODE_SOURCE_HOME ?? process.env.ORCA_MIMOCODE_SOURCE_HOME + if (sourceHome) { + return sourceHome + } + const configHome = baseEnv.MIMOCODE_HOME ?? process.env.MIMOCODE_HOME + const orcaHome = baseEnv.ORCA_MIMOCODE_HOME ?? process.env.ORCA_MIMOCODE_HOME + if (configHome && orcaHome && configHome === orcaHome) { + return undefined + } + return configHome +} + +export function resolveOpenCodeSourceConfigDir( + baseEnv: Record +): string | undefined { + const sourceDir = + baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR + if (sourceDir) { + return sourceDir + } + + const configDir = baseEnv.OPENCODE_CONFIG_DIR ?? process.env.OPENCODE_CONFIG_DIR + const orcaConfigDir = baseEnv.ORCA_OPENCODE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_CONFIG_DIR + // Why: with no recorded source dir, an inherited OPENCODE_CONFIG_DIR is Orca-owned, not user config; treating it as user config makes child Orcas mirror the hook dir. + if (configDir && orcaConfigDir && configDir === orcaConfigDir) { + return undefined + } + + return configDir ?? readSessionShellStartupEnvVar('OPENCODE_CONFIG_DIR', baseEnv) +} diff --git a/src/main/ipc/pty/host-env/spawn-cwd.ts b/src/main/ipc/pty/host-env/spawn-cwd.ts new file mode 100644 index 00000000000..03bac6611be --- /dev/null +++ b/src/main/ipc/pty/host-env/spawn-cwd.ts @@ -0,0 +1,55 @@ +import { statSync } from 'node:fs' +import type { Store } from '../../../persistence' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { isWslUncPath } from '../../../../shared/wsl-paths' +import { + resolveTerminalStartupCwdForWorkspace, + type TerminalStartupCwdMissingDirFallback +} from '../../../../shared/terminal-startup-cwd' +import { + assertFolderWorkspacePathUsable, + getFolderWorkspacePathStatus +} from '../../../project-groups/folder-workspace-path-status' +import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' + +export function assertFolderWorkspacePtyPathUsable( + store: Store | undefined, + worktreeId: string | undefined +): Promise | void { + const workspaceScope = typeof worktreeId === 'string' ? parseWorkspaceKey(worktreeId) : null + if (!store || workspaceScope?.type !== 'folder') { + return + } + return getFolderWorkspacePathStatus( + store, + { scope: 'folder-workspace', folderWorkspaceId: workspaceScope.folderWorkspaceId }, + { getSshFilesystemProvider } + ).then(assertFolderWorkspacePathUsable) +} + +export function resolvePtySpawnStartupCwd( + store: Store | undefined, + worktreeId: string | undefined, + cwd: string | undefined, + missingDirFallback?: TerminalStartupCwdMissingDirFallback +): string | undefined { + return resolveTerminalStartupCwdForWorkspace({ + workspaceId: worktreeId, + requestedCwd: cwd, + missingDirFallback, + resolveFolderWorkspacePath: (folderWorkspaceId) => + store?.getFolderWorkspace(folderWorkspaceId)?.folderPath + }) +} + +export function localStartupCwdDirectoryExists(path: string): boolean { + // Why: Win32 statSync on \\wsl.localhost 9P shares can falsely report ENOENT; defer to the provider's WSL-aware validation. + if (isWslUncPath(path)) { + return true + } + try { + return statSync(path).isDirectory() + } catch { + return false + } +} diff --git a/src/main/ipc/pty/host-env/spawn-env-keys.ts b/src/main/ipc/pty/host-env/spawn-env-keys.ts new file mode 100644 index 00000000000..0cd0b183729 --- /dev/null +++ b/src/main/ipc/pty/host-env/spawn-env-keys.ts @@ -0,0 +1,16 @@ +export const AGENT_HOOK_RUNTIME_ENV_KEYS = [ + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN', + 'ORCA_AGENT_HOOK_ENV', + 'ORCA_AGENT_HOOK_VERSION', + 'ORCA_AGENT_HOOK_ENDPOINT', + // Why: PR 2778 briefly exported this path; keep deleting stale inherited values so older PTYs can't leak the reverted path. + 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' +] as const + +// Why: Orca never sets these, so an inherited value means a pty host launched from inside a Claude session — Claude reads it as a nested child and silently stops persisting the transcript. +export const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' +] as const diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts new file mode 100644 index 00000000000..61ff335b43d --- /dev/null +++ b/src/main/ipc/pty/host-env/types.ts @@ -0,0 +1,73 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import type { NetworkProxySettings } from '../../../../shared/network-proxy' +import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' +import type { ClaudeAccountSelectionTarget } from '../../../claude-accounts/runtime-selection' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' +import type { CodexSessionResumePreparation } from '../../../codex/codex-session-resume-home' + +export type BuildPtyHostEnvOptions = { + isPackaged: boolean + resourcesPath?: string + userDataPath: string + selectedCodexHomePath: string | null + skipCodexHomeEnv?: boolean + /** System-default real-home routing (flag ON): inject no managed CODEX_HOME, + * and strip only an inherited Orca-owned override so nested Orca panes do not + * leak the parent's managed home. A user-set CODEX_HOME is preserved. */ + stripInheritedOrcaCodexHome?: boolean + /** Launch command the renderer chose (e.g. 'pi', 'omp', 'claude'); resolves the per-agent + * extension target for Pi/OMP. Undefined for bare shells → defaults to Pi. NEVER infer from + * disk presence (cross-agent shadowing when both dirs exist). */ + launchCommand?: string + /** Trusted agent identity for wrapped commands that cannot be recognized from text. */ + launchAgent?: TuiAgent + isWsl?: boolean + /** Distro for WSL spawns (null = Windows default distro); drives the WSL hook relay + endpoint repoint. Only read when isWsl. */ + wslDistro?: string | null + agentStatusHooksEnabled: boolean + codexStatusHooksEnabled?: boolean + networkProxySettings?: NetworkProxySettings + /** Keep indexed Git config off the sparse daemon wire; the daemon appends guard entries after merging its inherited env. */ + deferGitConfigGuardToDaemon?: boolean +} + +export type CodexHomeLaunchContext = { + workspacePath?: string + launchAgent?: TuiAgent + unavailableManagedHomePath?: string +} + +export type GetSelectedCodexHomePath = ( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + launchContext?: CodexHomeLaunchContext +) => string | null + +export type PrepareCodexSessionResume = (args: { + providerSession: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string +}) => Promise + +export type CodexHomePtySpawnedLifecycleArgs = { + id: string + codexHomePath: string | null + reattached?: boolean + reattachedHomeRoute?: CodexPaneHomeRoute | null + launchEnv?: NodeJS.ProcessEnv + startedAt?: Date + startedSequence?: number +} + +let ptyLifecycleSequence = 0 + +export function allocatePtyLifecycleSequence(): number { + return ++ptyLifecycleSequence +} + +export type PrepareClaudeAuth = ( + target?: ClaudeAccountSelectionTarget +) => Promise diff --git a/src/main/ipc/pty/ipc/inspect.ts b/src/main/ipc/pty/ipc/inspect.ts new file mode 100644 index 00000000000..2bb1b6a07e1 --- /dev/null +++ b/src/main/ipc/pty/ipc/inspect.ts @@ -0,0 +1,340 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import type { Store } from '../../../persistence' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { IPtyProvider } from '../../../providers/types' +import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' +import { inspectPtyProviderProcessForRenderer } from '../../../providers/pty-process-inspection' +import { + PtyProcessListAdmission, + visitPtyProcessListingsInBatches +} from '../../../providers/pty-process-list-admission' +import type { PtyListedSession } from '../../../../shared/pty-listed-session' +import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict' +import { ptyOwnership } from '../provider/ownership-state' +import { + getProviderForPty, + hasPtyProviderForInspection, + registeredPtyProviders, + sshProviders, + tryGetProviderForPty +} from '../provider/registry' +import { finishPtyShutdown, isPtyAlreadyGoneError } from '../provider/liveness' +import { ptySizes } from '../delivery/visibility-state' +import { isValidPaneKey } from '../pane/key-state' +import { + declarePendingPaneSerializer, + pendingPtyIdBySerializerGeneration, + rendererSerializerReadiness, + settlePendingPaneSerializer +} from '../pane/serializer-state' + +export function installPtyInspectIpcHandlers(deps: { + store?: Store + runtime?: OrcaRuntimeService + getLocalPtyProviderStartupPromise: (connectionId?: string | null) => Promise | undefined + shutdownProviderAndDetectExit: ( + provider: IPtyProvider, + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ) => Promise + rememberSyntheticKillExit: (id: string) => void + sendPtyExitToRenderer: (payload: { id: string; code: number; incarnationId?: string }) => void +}): void { + const ipcMain = getPtyIpc() + const { + store, + runtime, + getLocalPtyProviderStartupPromise, + shutdownProviderAndDetectExit, + rememberSyntheticKillExit, + sendPtyExitToRenderer + } = deps + + ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => { + if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { + // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. + throw new Error('Invalid PTY provider id') + } + runtime?.markPtyStopRequested?.(args.id) + const ownedConnectionId = ptyOwnership.get(args.id) + const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null + const connectionId = ownedConnectionId ?? parsedSshId?.connectionId + // Why: wait for daemon startup before selecting the local provider, else a fallback shutdown falsely succeeds and orphans a restored daemon PTY (#7742). + const startupPromise = getLocalPtyProviderStartupPromise(connectionId) + if (startupPromise) { + await startupPromise + } + const provider = connectionId ? sshProviders.get(connectionId) : tryGetProviderForPty(args.id) + if (!provider && connectionId) { + // Why: detached SSH PTYs intentionally keep ownership after their + // provider is unregistered; hydrated app-scoped ids can also arrive + // before ownership is rebuilt. Tombstone instead of falling back local. + const incarnationId = finishPtyShutdown(args.id, connectionId, store) + runtime?.markPtyLivenessUnverifiable?.(args.id, SSH_PROVIDER_UNREGISTERED_REASON) + runtime?.onPtyExit(args.id, -1, incarnationId) + rememberSyntheticKillExit(args.id) + sendPtyExitToRenderer({ + id: args.id, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + return + } + const shutdownProvider = provider ?? getProviderForPty(args.id) + let providerExitObserved = false + try { + providerExitObserved = await shutdownProviderAndDetectExit(shutdownProvider, args.id, { + immediate: true, + keepHistory: args.keepHistory ?? false + }) + } catch (err) { + if (!isPtyAlreadyGoneError(err)) { + // Why: a failed shutdown can leave the process alive (SSH relay grace window / local daemon); keep ownership/lease state so the user can retry. + throw err + } + /* session already dead — cleanup below handles the rest */ + } + // Why: some shutdown paths do not emit onExit through the provider listener. + // Explicit cleanup is idempotent and covers already-dead PTYs. + const incarnationId = finishPtyShutdown(args.id, connectionId, store) + if (!providerExitObserved) { + runtime?.onPtyExit(args.id, -1, incarnationId) + rememberSyntheticKillExit(args.id) + sendPtyExitToRenderer({ + id: args.id, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + } + }) + + ipcMain.handle('pty:listSessions', async (): Promise => { + const deduped = new Map() + const admission = new PtyProcessListAdmission() + await visitPtyProcessListingsInBatches( + registeredPtyProviders(), + ({ provider, connectionId }) => + connectionId === null ? provider.listProcesses() : provider.listProcesses().catch(() => []), + ({ provider, connectionId }, sessions) => { + for (const rawSession of sessions) { + const session = admission.admit(rawSession) + // Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider. + ptyOwnership.set(session.id, connectionId) + deduped.set(session.id, { + id: session.id, + cwd: session.cwd, + title: session.title, + // Why: the renderer's binding map is empty during restore, so ownership is the only + // liveness evidence it has. Absence is authoritative only from a provider that + // serializes claims — otherwise it is 'unknown', never 'absent' (#8459). + agentOwnership: + (session.agentSessionOwners?.length ?? 0) > 0 + ? 'present' + : provider.providesAgentSessionOwnerListings?.(session.id) === true + ? 'absent' + : 'unknown' + }) + } + } + ) + return Array.from(deduped.values()) + }) + + ipcMain.handle( + 'pty:getAuthoritativeBufferSnapshotCapabilities', + async (_event, args: { ids?: unknown }) => { + const ids = Array.isArray(args?.ids) ? args.ids.slice(0, 512) : [] + const hasLocalPtyId = ids.some((value) => { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 512 || + value.startsWith('remote:') || + parseAppSshPtyId(value) + ) { + return false + } + const ownedConnectionId = ptyOwnership.get(value) + return ownedConnectionId === undefined || ownedConnectionId === null + }) + if (hasLocalPtyId) { + await getLocalPtyProviderStartupPromise() + } + const capabilities: { id: string; authoritative: boolean | null }[] = [] + const seen = new Set() + for (const value of ids) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 512 || + seen.has(value) + ) { + continue + } + seen.add(value) + const provider = tryGetProviderForPty(value) + // Resolved providers without the optional method are definitively non-authoritative; null remains retryable. + capabilities.push({ + id: value, + authoritative: + provider === undefined || provider === null + ? null + : provider.canProvideAuthoritativeBufferSnapshot + ? provider.canProvideAuthoritativeBufferSnapshot(value) + : false + }) + } + return capabilities + } + ) + + ipcMain.handle('pty:hasPty', async (_event, args: { id: string }): Promise => { + if (typeof args?.id !== 'string' || args.id.startsWith('remote:')) { + // Why: same routing hazard pty:kill guards against — ptyOwnership never holds + // a runtime terminal handle and parseAppSshPtyId ignores it, so the lookup + // falls through to the local provider and its "not in my table" reads as an + // authoritative dead. That is a fabricated answer about another host's PTY. + return null + } + const ownedConnectionId = ptyOwnership.get(args.id) + const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null + const provider = parsedSshId + ? sshProviders.get(parsedSshId.connectionId) + : tryGetProviderForPty(args.id) + if (!provider?.hasPty) { + return null + } + try { + return provider.hasPty(args.id) + } catch { + // Why: liveness is only allowed to close panes on an authoritative false. + return null + } + }) + + ipcMain.handle( + 'pty:hasChildProcesses', + async (_event, args: { id: string }): Promise => { + if (typeof args?.id !== 'string' || !hasPtyProviderForInspection(args.id)) { + return false + } + return getProviderForPty(args.id).hasChildProcesses(args.id) + } + ) + + ipcMain.handle( + 'pty:getForegroundProcess', + async (_event, args: { id: string }): Promise => { + if (typeof args?.id !== 'string' || !hasPtyProviderForInspection(args.id)) { + return null + } + return getProviderForPty(args.id).getForegroundProcess(args.id) + } + ) + + ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) => { + // Why: same routing hazard as pty:hasPty — an unroutable id must read as unavailable, not as a local-provider answer or a raised IPC error. + if ( + typeof args?.id !== 'string' || + !args.id || + args.id.startsWith('remote:') || + !hasPtyProviderForInspection(args.id) + ) { + return { foregroundProcess: null, hasChildProcesses: false, unavailable: true as const } + } + return inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id) + }) + + ipcMain.handle( + 'pty:confirmForegroundProcess', + async (_event, args: { id: string }): Promise => { + if (typeof args?.id !== 'string' || !hasPtyProviderForInspection(args.id)) { + return null + } + const provider = getProviderForPty(args.id) + // Why: the cached foreground API would turn stale process identity into shell/agent authority at a command boundary. + return provider.confirmForegroundProcess?.(args.id) ?? null + } + ) + + // Why: Cmd+D split needs the live shell cwd so the new pane inherits it (not the worktree root); '' means unknown/unresolvable (Windows) → renderer falls through. + ipcMain.handle('pty:getCwd', async (_event, args: { id: string }): Promise => { + try { + return await getProviderForPty(args?.id).getCwd(args.id) + } catch { + return '' + } + }) + + // Why: prefer the provider's APPLIED size over the requested ptySizes so the renderer's resume drift-check can spot a dropped resize; null means "cannot confirm" → re-forward once. + ipcMain.handle( + 'pty:getSize', + async (_event, args: { id: string }): Promise<{ cols: number; rows: number } | null> => { + const provider = tryGetProviderForPty(args?.id) + try { + if (provider?.getAppliedSize) { + // Why: a provider-owned null means it could not verify the applied + // grid; preserve null so the renderer re-forwards instead of trusting + // the requested-size cache that may describe a dropped resize. + return await provider.getAppliedSize(args.id) + } + } catch { + // Fall through to the requested-size cache so a dead daemon/relay can't throw across the IPC boundary. + } + return ptySizes.get(args?.id) ?? null + } + ) + + // Pre-signal handshake handlers (declare→spawn→settle/clear); see docs/mobile-prefer-renderer-scrollback.md and `pendingByPaneKey` above. + ipcMain.handle( + 'pty:declarePendingPaneSerializer', + async (event, args: { paneKey?: unknown }): Promise => { + if (!isValidPaneKey(args?.paneKey)) { + throw new Error('Invalid paneKey') + } + return declarePendingPaneSerializer(args.paneKey, event?.sender) + } + ) + + ipcMain.handle( + 'pty:settlePaneSerializer', + async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { + if (!isValidPaneKey(args?.paneKey) || typeof args.gen !== 'number') { + return + } + const ptyId = pendingPtyIdBySerializerGeneration.get(args.gen) + const settledCurrentGeneration = settlePendingPaneSerializer(args.paneKey, args.gen) + // Why: the generation-to-PTY binding survives late teardown of a reused id; paneKey reverse maps may already be gone. + pendingPtyIdBySerializerGeneration.delete(args.gen) + if (settledCurrentGeneration && ptyId) { + rendererSerializerReadiness.markReady(ptyId) + } + } + ) + + ipcMain.handle( + 'pty:clearPendingPaneSerializer', + async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { + if (!isValidPaneKey(args?.paneKey) || typeof args.gen !== 'number') { + return + } + settlePendingPaneSerializer(args.paneKey, args.gen) + pendingPtyIdBySerializerGeneration.delete(args.gen) + } + ) + + ipcMain.handle( + 'pty:reportRendererSerializerReady', + async (_event, args: { ptyId?: unknown }): Promise => { + if ( + typeof args?.ptyId !== 'string' || + !args.ptyId.startsWith('remote:') || + args.ptyId.length > 512 + ) { + return + } + // Why: remote-runtime panes skip the local spawn cooperation gate, so their exact PTY id is the only readiness key. + rendererSerializerReadiness.markReady(args.ptyId) + } + ) +} diff --git a/src/main/ipc/pty/ipc/resize-visibility.ts b/src/main/ipc/pty/ipc/resize-visibility.ts new file mode 100644 index 00000000000..d0c376fabc8 --- /dev/null +++ b/src/main/ipc/pty/ipc/resize-visibility.ts @@ -0,0 +1,339 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import type { + PtyDeliveryWriteOff, + PtyRendererDeliveryHealthReply, + PtyRendererDeliveryStateReport +} from '../../../../shared/pty-renderer-delivery-health' +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' +import { setTerminalViewAttributes } from '../../../runtime/terminal-view-attribute-store' +import { validateTerminalViewAttributes } from '../../../../shared/terminal-view-attributes' +import { + recordHiddenRendererPtyDataDrop, + setRendererPtyDeliveryInterest, + shouldDropHiddenRendererPtyData +} from '../../pty-hidden-delivery-gate' +import { tryGetProviderForPty, closeStartupQueryAuthorityForPty } from '../provider/registry' +import { + activeRendererPtys, + deliveredHiddenRendererResizeOutputPtys, + invalidatePendingPtyDrainPolicy, + invalidatePendingPtyDrainPriority, + pendingHiddenRendererResizeOutputPtys, + ptySizes, + rendererVisibilityKnownPtys, + visibleRendererPtys +} from '../delivery/visibility-state' +import { + mainDeliveryBreadcrumbs, + resetRendererDeliveryAccountingForLifecycleReset +} from '../delivery/debug' +import { PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS } from '../delivery/constants' +import { applyCumulativeAck } from '../delivery/accounting' +import { sendModelRestoreNeededMarker } from '../delivery/payload' +import { isMainWindowPtyIpcEvent } from './write-input' +import type { PtyIpcSession } from '../session' + +export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { + const ipcMain = getPtyIpc() + const { runtime, mainWindow } = session + + // Why: resize is fire-and-forget — ipcMain.on (not .handle) halves IPC traffic by skipping the empty acknowledgement reply. + ipcMain.removeAllListeners('pty:resize') + ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => { + // Why: after a desktop-fit override change the renderer's safeFit cascade re-measures ALL panes (background ones at full width), so suppress every pty:resize in this window to avoid corrupting PTY dimensions. + if (runtime?.isResizeSuppressed()) { + return + } + // Why: presence-lock defense-in-depth — while a phone or remote-desktop viewer drives the width, host-side resizes must not reach the PTY or its alt-screen grid garbles; load-bearing because the renderer mirror lags one IPC hop. See docs/mobile-presence-lock.md. + const mobileOwnsResize = runtime?.getDriver(args.id).kind === 'mobile' + const remoteDesktopOwnsResize = runtime?.isRemoteDesktopResizeDriven?.(args.id) === true + if (mobileOwnsResize || remoteDesktopOwnsResize) { + if (remoteDesktopOwnsResize) { + runtime?.recordRemoteDesktopHostReclaimTarget(args.id, args.cols, args.rows) + } + return + } + const provider = tryGetProviderForPty(args.id) + if (!provider) { + return + } + const markedHiddenResizeOutput = session.rendererPtyIsKnownHidden(args.id) + if (markedHiddenResizeOutput) { + // Why: alt-screen TUIs repaint on SIGWINCH; a hidden repaint read after switch-back must not masquerade as live output and overwrite the correctly-sized screen. + pendingHiddenRendererResizeOutputPtys.add(args.id) + deliveredHiddenRendererResizeOutputPtys.delete(args.id) + } else if (visibleRendererPtys.has(args.id)) { + // Why: after the stale hidden-resize repaint is observed, the renderer's visible resize pulse owns the next repaint. + session.clearDeliveredHiddenRendererResizeOutput(args.id) + } + try { + provider.resize(args.id, args.cols, args.rows) + } catch { + if (markedHiddenResizeOutput) { + pendingHiddenRendererResizeOutputPtys.delete(args.id) + } + return + } + ptySizes.set(args.id, { cols: args.cols, rows: args.rows }) + runtime?.onExternalPtyResize(args.id, args.cols, args.rows) + }) + + // Why: pty:reportGeometry is a measurement-only sibling of pty:resize — it refreshes the restore-target cache (never resizes) so mobile-fit hold learns real desktop dims even while resize is blocked. See docs/mobile-fit-hold.md. + ipcMain.removeAllListeners('pty:reportGeometry') + ipcMain.on('pty:reportGeometry', (_event, args: { id: string; cols: number; rows: number }) => { + runtime?.recordRendererGeometry(args.id, args.cols, args.rows) + }) + + // Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold-restore cache after the renderer consumed it; no-op for non-daemon providers. + ipcMain.removeAllListeners('pty:ackColdRestore') + ipcMain.on('pty:ackColdRestore', (_event, args: { id: string }) => { + const provider = tryGetProviderForPty(args.id) + if (provider && 'ackColdRestore' in provider && typeof provider.ackColdRestore === 'function') { + provider.ackColdRestore(args.id) + } + }) + + // Why: renderer ACKs bound main→renderer delivery without stopping PTY ingestion — agent/status consumers still see every chunk via the provider/runtime path. + ipcMain.removeAllListeners('pty:ackData') + ipcMain.on( + 'pty:ackData', + (_event, args: { id: string; charCount?: number; processedChars?: number }) => { + session.lastAckReceivedAtMs = Date.now() + // Why: a live ACK channel means a future unanswered probe is a fresh diagnostic event, not a continuation of the last silent streak. + session.deliveryResyncUnansweredWarnLogged = false + let acknowledged = 0 + if (typeof args.processedChars === 'number' && Number.isFinite(args.processedChars)) { + acknowledged = applyCumulativeAck(session, args.id, Math.max(0, args.processedChars)) + } else { + // Why: tolerate legacy per-chunk delta payloads — dev hot-reload can pair an old renderer with a new main. + const accounting = session.rendererDeliveryAccountingByPty.get(args.id) + const delta = Number.isFinite(args.charCount) ? Math.max(0, args.charCount ?? 0) : 0 + acknowledged = accounting + ? applyCumulativeAck(session, args.id, accounting.ackedChars + delta) + : 0 + } + tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged) + session.schedulePendingDataAfterCreditReport(acknowledged > 0) + } + ) + + ipcMain.removeAllListeners('pty:deliveryResyncResponse') + ipcMain.on( + 'pty:deliveryResyncResponse', + (_event, args: { requestId: number; processedCharsByPty: Record }) => { + if ( + session.deliveryResyncOutstandingRequestId === null || + args?.requestId !== session.deliveryResyncOutstandingRequestId + ) { + return + } + session.clearDeliveryResyncProbe() + session.deliveryResyncUnansweredWarnLogged = false + // Why max-merge: the renderer's cumulative totals are authoritative for what it processed, draining exactly the in-flight debt from lost ACKs. + let creditedAny = false + for (const [id, processedChars] of Object.entries(args.processedCharsByPty ?? {})) { + if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { + continue + } + const acknowledged = applyCumulativeAck(session, id, Math.max(0, processedChars)) + if (acknowledged > 0) { + creditedAny = true + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + } + } + session.schedulePendingDataAfterCreditReport(creditedAny) + } + ) + + // Why invoke + renderer-initiated: the field wedge (v1.4.121-rc.0) kills every main→renderer push channel while invoke survives, so the resync rides here plus a write-off lane. + ipcMain.removeHandler('pty:reportRendererDeliveryState') + ipcMain.handle( + 'pty:reportRendererDeliveryState', + (_event, args: PtyRendererDeliveryStateReport): PtyRendererDeliveryHealthReply => { + // Extra repair lane for the lost-ACK variant: identical max-merge to the resync response, so a heal is only reached when merging cannot drain. + let creditedAny = false + for (const [id, processedChars] of Object.entries(args?.processedCharsByPty ?? {})) { + if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { + continue + } + const acknowledged = applyCumulativeAck(session, id, Math.max(0, processedChars)) + if (acknowledged > 0) { + creditedAny = true + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + } + } + let writtenOff: PtyDeliveryWriteOff[] = [] + // Why the main-side ACK-silence check: requiring main to have also seen no ACK stops a buggy/foreign caller from writing off live delivery. + if ( + args?.heal === true && + session.rendererInFlightTotalChars > 0 && + (session.lastAckReceivedAtMs === null || + Date.now() - session.lastAckReceivedAtMs >= PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS) + ) { + writtenOff = session.writeOffLostRendererDelivery(args) + creditedAny ||= writtenOff.length > 0 + } + session.schedulePendingDataAfterCreditReport(creditedAny) + let inFlightPtyCount = 0 + for (const accounting of session.rendererDeliveryAccountingByPty.values()) { + if (accounting.sentChars - accounting.ackedChars > 0) { + inFlightPtyCount++ + } + } + return { + inFlightTotalChars: session.rendererInFlightTotalChars, + inFlightPtyCount, + msSinceLastAck: + session.lastAckReceivedAtMs === null ? null : Date.now() - session.lastAckReceivedAtMs, + ...(writtenOff.length > 0 ? { writtenOff } : {}) + } + } + ) + + // Why: renderer signals its pty:data listener is live; until then sends are held so boot-window bytes can't drop into a listener-less page and pin the gate. + ipcMain.removeAllListeners('pty:rendererDispatcherReady') + ipcMain.on('pty:rendererDispatcherReady', (event) => { + // Why: the reconcile below destructively clears delivery accounting, so a straggler handshake from a dying window must not reset the new window. + if (!isMainWindowPtyIpcEvent(event, mainWindow, mainWindow.webContents)) { + return + } + // Why: a handshake while the gate is already open means a page load whose lifecycle reset was missed; clear the dead page's stale accounting so it can't permanently gate survivors. + if (session.rendererPtyDispatcherReady) { + resetRendererDeliveryAccountingForLifecycleReset() + } + // Why: real handshake landed — cancel the self-heal watchdog so it can't later force-open the gate. + session.clearDispatcherReadyWatchdog() + session.rendererPtyDispatcherReady = true + session.pendingData.reactivateBlocked() + session.schedulePendingDataFlush(0) + }) + + ipcMain.removeAllListeners('pty:setActiveRendererPty') + ipcMain.on('pty:setActiveRendererPty', (_event, args: { id: string; active: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + // Why: renderer scheduling hint only — active panes just get first chance at the bounded output reserve; reads/state/notifications continue for inactive terminals. + if (args.active) { + if (activeRendererPtys.has(args.id)) { + return + } + activeRendererPtys.add(args.id) + } else if (!activeRendererPtys.delete(args.id)) { + return + } + invalidatePendingPtyDrainPriority(args.id) + }) + + ipcMain.removeAllListeners('pty:setRendererPtyVisible') + ipcMain.on('pty:setRendererPtyVisible', (_event, args: { id: string; visible: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + // Why: data produced while no renderer can see this PTY must keep that origin through batching, even if the user switches back before the flush lands. + rendererVisibilityKnownPtys.add(args.id) + if (args.visible) { + visibleRendererPtys.add(args.id) + closeStartupQueryAuthorityForPty(args.id) + } else { + visibleRendererPtys.delete(args.id) + } + session.syncPtyBackgroundedDelivery(args.id, 'visibility-report') + }) + + ipcMain.removeAllListeners('pty:setHiddenRendererPty') + ipcMain.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + mainDeliveryBreadcrumbs.record(args.hidden === true ? 'gate-mark' : 'gate-unmark', { + id: redactPtyIdForDiagnostics(args.id) + }) + const transition = session.transitionHiddenRendererPtyDeliveryState( + args.id, + args.hidden === true + ) + if (args.hidden === true) { + closeStartupQueryAuthorityForPty(args.id) + // Why: drop bytes queued for a newly hidden PTY instead of holding them under ACK starvation; reveal restores from the snapshot. + const pending = session.pendingData.get(args.id) + if (pending && transition.droppable) { + session.pendingData.delete(args.id) + if (pending.projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections( + pending.projectionAdmissionIds, + 'hidden-drop' + ) + } + session.updateProducerFlowControl(args.id) + session.pendingOverflowMarkedPtys.delete(args.id) + const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker( + session, + args.id, + 'hidden-drop', + runtime?.getPtyOutputSequence(args.id) + ) + } + } + if (transition.policyChanged) { + invalidatePendingPtyDrainPolicy(args.id) + } + session.syncPtyBackgroundedDelivery(args.id, 'gate-mark') + return + } + if (transition.policyChanged) { + invalidatePendingPtyDrainPolicy(args.id) + } + session.syncPtyBackgroundedDelivery(args.id, 'gate-unmark') + // Why: a reload/remount may have replaced the view that latched restore-needed, so re-emit on unhide; a redundant replay is cheap/idempotent, a missed restore corrupts the pane. + if (transition.droppedWhileHidden) { + sendModelRestoreNeededMarker( + session, + args.id, + 'unhide', + runtime?.getPtyOutputSequence(args.id) + ) + } + }) + + ipcMain.removeAllListeners('pty:terminalViewAttributes') + ipcMain.on('pty:terminalViewAttributes', (_event, args: unknown) => { + // Why validate-or-drop: a malformed palette gives a wrong color reply that breaks TUI theme detection worse than the silent-until-first-push default. + const attributes = validateTerminalViewAttributes(args) + if (attributes) { + setTerminalViewAttributes(attributes) + } + }) + + ipcMain.removeAllListeners('pty:setPtyDeliveryInterest') + ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + // Why: any delivery interest suppresses the hidden-delivery gate (raw-byte consumers keep receiving while hidden); not synced to the daemon pacer so interest churn can't un-pace a flood. + const settings = session.getSettings?.() + const wasDroppable = shouldDropHiddenRendererPtyData(args.id, settings) + setRendererPtyDeliveryInterest(args.id, args.interested === true) + if (wasDroppable !== shouldDropHiddenRendererPtyData(args.id, settings)) { + invalidatePendingPtyDrainPolicy(args.id) + } + }) + + ipcMain.removeAllListeners('pty:signal') + ipcMain.on('pty:signal', (_event, args: { id: string; signal: string }) => { + tryGetProviderForPty(args.id) + ?.sendSignal(args.id, args.signal) + .catch(() => {}) + }) + + ipcMain.removeAllListeners('pty:clearBuffer') + ipcMain.on('pty:clearBuffer', (_event, args: { id: string }) => { + // Why: clear PTY-side state (ConPTY/daemon/SSH buffer) so the next prompt repaint doesn't land at a stale cursor row. + tryGetProviderForPty(args.id) + ?.clearBuffer(args.id) + .catch(() => {}) + runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {}) + }) +} diff --git a/src/main/ipc/pty/ipc/serialize-buffer.ts b/src/main/ipc/pty/ipc/serialize-buffer.ts new file mode 100644 index 00000000000..10011c19aae --- /dev/null +++ b/src/main/ipc/pty/ipc/serialize-buffer.ts @@ -0,0 +1,111 @@ +import { randomUUID } from 'node:crypto' +import { getPtyIpc } from '../../pty-host-bindings' +import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal-kitty-keyboard-flags' +import { isMainWindowPtyIpcEvent } from './write-input' +import type { PtyIpcSession, SerializeResult } from '../session' + +export function settleSerializeRequest( + session: PtyIpcSession, + requestId: string, + result: SerializeResult +): void { + const pending = session.pendingSerializeRequests.get(requestId) + if (!pending) { + return + } + clearTimeout(pending.timeout) + session.pendingSerializeRequests.delete(requestId) + pending.resolve(result) +} + +export function installPtySerializeBufferIpc(session: PtyIpcSession): void { + const ipcMain = getPtyIpc() + // Why: one persistent listener with a request-ID dispatch table instead of one per call, so concurrent serialize requests don't trip Node's MaxListeners=10 warning. + ipcMain.on( + 'pty:serializeBuffer:response', + ( + event, + args: { + requestId?: string + snapshot?: { + data?: unknown + cols?: unknown + rows?: unknown + seq?: unknown + lastTitle?: unknown + kittyKeyboardFlags?: unknown + } | null + } + ) => { + // Why: the snapshot seeds terminal restore state, so only the main window may settle it. + if ( + !isMainWindowPtyIpcEvent(event, session.mainWindow, session.mainWindow.webContents) || + typeof args?.requestId !== 'string' + ) { + return + } + const snapshot = args.snapshot + if ( + snapshot && + typeof snapshot.data === 'string' && + typeof snapshot.cols === 'number' && + typeof snapshot.rows === 'number' + ) { + const result: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + kittyKeyboardFlags?: number + } = { + data: snapshot.data, + cols: snapshot.cols, + rows: snapshot.rows + } + if (typeof snapshot.seq === 'number' && Number.isFinite(snapshot.seq)) { + result.seq = snapshot.seq + } + if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) { + result.lastTitle = snapshot.lastTitle + } + // Why gated on seq: without a boundary the flags cannot be reconciled + // against live bytes, so they prove nothing. + const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.kittyKeyboardFlags) + if (result.seq !== undefined && kittyKeyboardFlags !== undefined) { + result.kittyKeyboardFlags = kittyKeyboardFlags + } + settleSerializeRequest(session, args.requestId, result) + } else { + settleSerializeRequest(session, args.requestId, null) + } + } + ) +} + +export function requestSerializedBuffer( + session: PtyIpcSession, + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } +): Promise { + if (session.mainWindow.isDestroyed()) { + return Promise.resolve(null) + } + + const requestId = randomUUID() + return new Promise((resolve) => { + const timeout = setTimeout(() => { + settleSerializeRequest(session, requestId, null) + }, 750) + session.pendingSerializeRequests.set(requestId, { resolve, timeout }) + const payload: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + } = { requestId, ptyId } + if (opts) { + payload.opts = opts + } + session.mainWindow.webContents.send('pty:serializeBuffer:request', payload) + }) +} diff --git a/src/main/ipc/pty/ipc/snapshot.ts b/src/main/ipc/pty/ipc/snapshot.ts new file mode 100644 index 00000000000..607b3bfe781 --- /dev/null +++ b/src/main/ipc/pty/ipc/snapshot.ts @@ -0,0 +1,105 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { tryGetProviderForPty } from '../provider/registry' +import { providerSnapshotRequiredPtys } from '../delivery/visibility-state' +import type { PtyPendingDataDrainQueue } from '../../pty-pending-data-drain-queue' +import { + getPtyRendererDeliveryDebugSnapshot, + installPowerSignalBreadcrumbs, + resetPtyRendererDeliveryDebug, + type PtyRendererDeliveryDebugSnapshot +} from '../delivery/debug' + +function normalizeSnapshotScrollbackRows(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined + } + return Math.max(0, Math.min(50_000, Math.floor(value))) +} + +export function installPtySnapshotIpcHandlers(deps: { + runtime?: OrcaRuntimeService + pendingData: PtyPendingDataDrainQueue +}): void { + const ipcMain = getPtyIpc() + const { runtime, pendingData } = deps + + ipcMain.handle( + 'pty:getMainBufferSnapshot', + async ( + _event, + args: { id?: unknown; opts?: { scrollbackRows?: unknown } } + ): Promise<{ + data: string + frameRestoreAnsi?: string + cols: number + rows: number + cwd?: string | null + lastTitle?: string + seq?: number + pendingDeliveryStartSeq?: number + source?: 'headless' | 'renderer' + alternateScreen?: boolean + scrollbackAnsi?: string + pendingEscapeTailAnsi?: string + kittyKeyboardFlags?: number + } | null> => { + if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { + return null + } + const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows) + try { + const runtimeSeqBeforeSnapshot = runtime.getPtyOutputSequence(args.id) + const providerSnapshotRequired = providerSnapshotRequiredPtys.has(args.id) + const providerSnapshot = providerSnapshotRequired + ? await tryGetProviderForPty(args.id)?.getBufferSnapshot?.(args.id, { + scrollbackRows + }) + : null + // Why: after a data gap main holds only the retained tail; returning it as a full snapshot would erase older scrollback. + if (providerSnapshotRequired && !providerSnapshot) { + return null + } + const snapshot = + providerSnapshot ?? + (await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { + scrollbackRows + })) + if (!snapshot || typeof snapshot.seq !== 'number') { + return snapshot + } + // Why: the renderer's post-restore dedupe needs this pending-queue bound, or a stale baseline swallows new chunks whose seq sits below the snapshot counter. + const pending = pendingData.get(args.id) + if (pending && typeof pending.startSeq !== 'number') { + // Why: a seq-less backlog cannot be bounded — stay conservative. + return snapshot + } + return { + ...snapshot, + pendingDeliveryStartSeq: Math.min( + pending?.startSeq ?? (providerSnapshot ? runtimeSeqBeforeSnapshot : snapshot.seq), + snapshot.seq + ) + } + } catch { + return null + } + } + ) + + // Why: main owns side effects, so this replay restores title state only — never historical bells/completions (no-attention-replay rule, terminal-side-effect-authority.md). + ipcMain.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => { + if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { + return null + } + return runtime.getTerminalSideEffectSnapshot(args.id) + }) + + installPowerSignalBreadcrumbs() + ipcMain.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => { + return getPtyRendererDeliveryDebugSnapshot() + }) + ipcMain.handle('pty:resetRendererDeliveryDebug', (): void => { + resetPtyRendererDeliveryDebug() + }) +} diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts new file mode 100644 index 00000000000..0b59d347e4d --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -0,0 +1,122 @@ +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import type { PtySpawnResult } from '../../../providers/types' +import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' +import { createPtySpawnTiming } from '../../pty-spawn-timing' +import { allocatePtyLifecycleSequence } from '../host-env/types' +import { snapshotCodexPaneHomeRoutes } from '../host-env/codex-home' +import { getAppPtyId } from '../provider/registry' +import { + makePaneSpawnReservationKey, + reservePaneSpawn, + paneSpawnReservationsByOwnerKey, + pendingRuntimePaneCreatesByOwnerKey +} from '../pane/spawn-reservation' +import { resolveStablePaneOwner } from '../pane/stable-owner' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function beginPtyIpcSpawn( + ctx: PtyIpcSpawnState +): Promise { + const args = ctx.args + ctx.codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined + ctx.codexHomeLaunchStartedSequence = !args.connectionId + ? allocatePtyLifecycleSequence() + : undefined + const initialLeafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + const initialPaneKey = + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + initialLeafId + ? makePaneKey(args.tabId, initialLeafId) + : null + const initialStablePanePtyId = (() => { + try { + return !args.connectionId && initialPaneKey + ? resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + initialPaneKey, + args.worktreeId, + args.connectionId + )?.ptyId + : undefined + } catch { + return undefined + } + })() + ctx.reattachedCodexHomeRoutes = !args.connectionId + ? new Map( + snapshotCodexPaneHomeRoutes([ + initialStablePanePtyId, + args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined + ]) + ) + : new Map() + ctx.spawnTiming = createPtySpawnTiming() + ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) + + const earlyLeafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + const earlyPaneKey = + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + earlyLeafId + ? makePaneKey(args.tabId, earlyLeafId) + : null + const earlyReservationKey = makePaneSpawnReservationKey( + args.worktreeId, + args.connectionId, + earlyPaneKey + ) + const pendingRuntimeCreate = earlyReservationKey + ? pendingRuntimePaneCreatesByOwnerKey.get(earlyReservationKey) + : undefined + if (pendingRuntimeCreate) { + await pendingRuntimeCreate.promise + } + const existingPaneSpawn = earlyReservationKey + ? paneSpawnReservationsByOwnerKey.get(earlyReservationKey) + : undefined + if (existingPaneSpawn) { + return { ...(await existingPaneSpawn.promise), isReattach: true } + } + ctx.earlyStablePaneOwner = + earlyPaneKey && args.worktreeId + ? resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + earlyPaneKey, + args.worktreeId, + args.connectionId + ) + : null + ctx.earlyWorktreeId = args.worktreeId + // Reserve early so renderer/runtime materialization cannot start duplicate provider spawns. + ctx.paneSpawnReservationKey = earlyReservationKey + ctx.paneSpawnReservation = ctx.paneSpawnReservationKey + ? reservePaneSpawn(ctx.paneSpawnReservationKey) + : null + ctx.finishTerminalInstall = (): void => {} + ctx.stablePaneOwner = null + ctx.stablePaneBindingPersisted = false + ctx.rejectedRegistrationCandidate = null + ctx.pendingRegistrationPtyId = null + // Why hoisted to the reply scope: main reconciles the provider sequence + // deep inside the spawn path, but the pane needs that renderer-domain + // boundary beside the daemon snapshot's kitty flags. + ctx.reconciledSnapshotSeq = null + // False when bytes crossed the data socket during the spawn RPC: the + // reconciled boundary covers them, but the daemon proved its kitty flags + // before they existed, so the claim must not erase what the pane may + // have scanned from those bytes live. + ctx.snapshotKittyFlagsCoverReconciledSeq = true + ctx.preparedProvisionalExecutionContext = false + + return null +} diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts new file mode 100644 index 00000000000..be9cb31394a --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -0,0 +1,154 @@ +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' +import { closeStartupQueryAuthorityForPty, getRelayPtyId } from '../provider/registry' +import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' +import { recordCodexPaneAccountForSpawn } from '../host-env/codex-home' +import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' +import { + pendingByPaneKey, + pendingPtyIdBySerializerGeneration, + rendererSerializerReadiness +} from '../pane/serializer-state' +import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' +import { ptySizes } from '../delivery/visibility-state' +import { clearProviderPtyState } from '../provider/state-cleanup' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ + rendererPreSignaled: boolean + rendererAlreadyRegistered: boolean +}> { + const args = ctx.args + try { + ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + store: ctx.deps.store, + owner: ctx.stablePaneOwner, + result: ctx.result, + worktreeId: args.worktreeId, + startupCwd: ctx.cwd, + connectionId: args.connectionId + }) + } catch (error) { + if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { + throw error + } + console.error('[pty] failed to persist PTY binding after attach:', error) + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } + ctx.spawnTiming.log(ctx.result.id, { + daemon: ctx.isDaemonHostSpawn, + reattach: ctx.result.isReattach ?? false + }) + recordCodexPaneAccountForSpawn({ + ptyId: ctx.result.id, + isDaemonHostSpawn: ctx.isDaemonHostSpawn, + isReattach: ctx.result.isReattach === true, + pinnedByResume: ctx.codexResumeHomeSelected, + launchCodexHomePath: ctx.selectedCodexHomePath, + launchEnv: ctx.baseEnv, + target: ctx.codexSelectionTarget, + settings: ctx.deps.getSettings?.() + }) + ptyOwnership.set(ctx.result.id, args.connectionId ?? null) + if (ctx.result.incarnationId) { + ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) + } + if (ctx.initiallyHidden) { + // Why marked synchronously here: provider data events dispatch on later tasks, so this still lands ahead of the first byte's delivery decision (idempotent if already marked pre-spawn). + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.result.id, true) + if (ctx.preSpawnHiddenMarkId !== null && ctx.preSpawnHiddenMarkId !== ctx.result.id) { + // Defense: never strand a mark on an id the provider renamed. + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) + } + // Why after ptyOwnership.set: provider lookup routes by ownership, and a hidden-spawned agent should be paceable from its first flood. + ctx.deps.syncPtyBackgroundedDelivery(ctx.result.id, 'spawn') + closeStartupQueryAuthorityForPty(ctx.result.id) + } + // Why: record the native-Windows-ConPTY determination before the headless seed so the emulator's DA1 override exists from byte zero. + if (ctx.nativeWindowsConptySpawn) { + markNativeWindowsConptyPty(ctx.result.id) + } + const relayResultId = getRelayPtyId(args.connectionId, ctx.result.id) + if (ctx.deps.store && args.connectionId) { + // Why: remote PTYs live in the SSH relay grace window after Orca detaches; persist IDs immediately so reconnect reattaches instead of spawning a fresh shell. + ctx.deps.store.upsertSshRemotePtyLease({ + targetId: args.connectionId, + ptyId: relayResultId, + ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), + ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), + ...(ctx.validatedLeafId ? { leafId: ctx.validatedLeafId } : {}), + state: 'attached', + lastAttachedAt: Date.now() + }) + } + if (ctx.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { + if (ctx.deps.runtime?.registerPreAllocatedHandleForPty) { + ctx.deps.runtime.registerPreAllocatedHandleForPty(ctx.result.id, ctx.preAllocatedHandle) + ctx.agentTeamsLeaderHandle = null + } + } + ptySizes.set(ctx.result.id, { cols: args.cols, rows: args.rows }) + if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { + ptySizes.delete(ctx.effectiveSessionAppId) + } + // Why: patch the load-bearing ptyId binding synchronously so a force-quit in the renderer's ~450 ms debounce window can't orphan daemon history or an SSH relay lease (Issue #217). + if ( + ctx.deps.store && + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + ctx.validatedLeafId !== null && + !ctx.stablePaneBindingPersisted + ) { + try { + const binding = { + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: ctx.validatedLeafId, + ptyId: ctx.result.id, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}) + } + if (args.connectionId) { + ctx.deps.store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId)) + } else { + ctx.deps.store.persistPtyBinding(binding) + } + } catch (err) { + console.error('[pty] failed to persist PTY binding after spawn:', err) + if (!ctx.result.isReattach) { + try { + await ctx.provider.shutdown(ctx.result.id, { immediate: true }) + } catch (shutdownErr) { + console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) + } + clearProviderPtyState(ctx.result.id) + deletePtyOwnership(ctx.result.id) + } + if (!ctx.result.isReattach && args.connectionId && ctx.deps.store) { + ctx.deps.store.removeSshRemotePtyLease(args.connectionId, relayResultId) + } + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } + } + // Why: when the renderer has declared it will own the serializer for this paneKey, suppress the daemon-snapshot seed so its hydration path is sole authority (keyed on paneKey since the ptyId isn't known yet). See docs/mobile-prefer-renderer-scrollback.md. + const rendererPreSignaled = ctx.validatedPaneKey + ? pendingByPaneKey.has(ctx.validatedPaneKey) + : false + const rendererAlreadyRegistered = + ctx.result.isReattach === true && + !rendererPreSignaled && + rendererSerializerReadiness.has(ctx.result.id) + rendererSerializerReadiness.beginIncarnation(ctx.result.id, rendererAlreadyRegistered) + // Why: capture the pending gen at spawn time so this PTY's teardown only settles its own generation, not a remount that replaced the entry. + if (ctx.validatedPaneKey && rendererPreSignaled) { + const pending = pendingByPaneKey.get(ctx.validatedPaneKey) + if (pending) { + pendingPtyIdBySerializerGeneration.set(pending.gen, ctx.result.id) + } + } + return { rendererPreSignaled, rendererAlreadyRegistered } +} diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts new file mode 100644 index 00000000000..7e277c7c3f5 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -0,0 +1,233 @@ +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { agentHookServer } from '../../../agent-hooks/server' +import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' +import { registerPty } from '../../../memory/pty-registry' +import type { PtySpawnResult } from '../../../providers/types' +import { clearMigrationUnsupportedPtysForPaneKey } from '../../../agent-hooks/migration-unsupported-pty-state' +import { track } from '../../../telemetry/client' +import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' +import { + agentKindSchema, + launchSourceSchema, + requestKindSchema +} from '../../../../shared/telemetry-events' +import { + shouldSkipCodexHomeEnvForWindowsShell, + codexReattachedHomeRouteField +} from '../host-env/codex-home' +import { rememberPaneKeyForPty } from '../pane/key-state' +import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' +import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' +import { admitRendererAgentLaunchAuthority } from '../pane/launch-authority' +import type { PtyIpcSpawnState } from './spawn-state' +import { persistPtyIpcSpawnCommit } from './spawn-commit-persist' + +export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + const args = ctx.args + const { rendererPreSignaled, rendererAlreadyRegistered } = await persistPtyIpcSpawnCommit(ctx) + + // Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback). + // Skip when the renderer will be authoritative — its xterm buffer is richer than the daemon snapshot. + if (ctx.deps.runtime && !rendererPreSignaled && !rendererAlreadyRegistered) { + const snapshotSeedSize = + typeof ctx.result.snapshotCols === 'number' && typeof ctx.result.snapshotRows === 'number' + ? { cols: ctx.result.snapshotCols, rows: ctx.result.snapshotRows } + : undefined + if (typeof ctx.result.snapshot === 'string' && ctx.result.snapshot.length > 0) { + // Why kitty flags ride seed metadata: the snapshot omits them, but the re-seeded emulator must answer hidden `CSI ? u` with the running app's flags (terminal-query-authority.md). + ctx.deps.runtime.seedHeadlessTerminal( + ctx.result.id, + ctx.result.snapshot, + snapshotSeedSize, + typeof ctx.result.snapshotKittyKeyboardFlags === 'number' + ? { kittyKeyboardFlags: ctx.result.snapshotKittyKeyboardFlags } + : {} + ) + } else if ( + ctx.result.coldRestore && + typeof ctx.result.coldRestore.scrollback === 'string' && + ctx.result.coldRestore.scrollback.length > 0 + ) { + const coldRestoreSeedSize = + typeof ctx.result.coldRestore.cols === 'number' && + typeof ctx.result.coldRestore.rows === 'number' + ? { cols: ctx.result.coldRestore.cols, rows: ctx.result.coldRestore.rows } + : undefined + ctx.deps.runtime.seedHeadlessTerminal( + ctx.result.id, + ctx.result.coldRestore.scrollback, + coldRestoreSeedSize, + { + cwd: ctx.result.coldRestore.cwd, + oscLinks: ctx.result.coldRestore.oscLinks, + preferProviderIfExisting: true + } + ) + } else if (typeof ctx.result.replay === 'string' && ctx.result.replay.length > 0) { + // Why: relay reattach replay is the only restore main never ingests; skip this seed and park-reveal would replace it with a suffix fragment. + ctx.deps.runtime.seedHeadlessTerminal(ctx.result.id, ctx.result.replay) + } + } + if ( + typeof args.worktreeId === 'string' && + args.worktreeId.length > 0 && + args.worktreeId.length <= 512 + ) { + const agentLaunchAuthority = admitRendererAgentLaunchAuthority({ + launchToken: args.launchToken, + spawnEnv: ctx.spawnEnv, + launchAgent: args.launchAgent, + launchConfig: ctx.effectiveLaunchConfig, + isReattach: ctx.result.isReattach === true, + hasStablePaneOwner: ctx.stablePaneOwner !== null, + incarnationId: ctx.result.incarnationId + }) + ctx.deps.runtime?.registerPty( + ctx.result.id, + args.worktreeId, + args.connectionId ?? null, + // Why: pass validated pane identity so a throttled mobile create publishes its surface main-side instead of destroying the live PTY (#7587); bound the untrusted tabId. + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + ctx.metadataLeafId !== null + ? { + tabId: args.tabId, + leafId: ctx.metadataLeafId, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(agentLaunchAuthority ? { agentLaunchAuthority } : {}) + } + : undefined, + !args.connectionId + ? shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd) + : undefined + ) + ctx.pendingRegistrationPtyId = null + } else if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.( + ctx.pendingRegistrationPtyId, + ctx.result.incarnationId + ) + ctx.pendingRegistrationPtyId = null + } + // Why: seed after registerPty binds the worktree — including on + // desktop, where the renderer-authority gate above skips the emulator + // seed but the list/read records still live main-side. + seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) + // Why: arm main's per-PTY Command Code output detector from the launch command (startupCommand parity); banner detection covers PTYs without one. + if (!ctx.stablePaneOwner) { + ctx.deps.runtime?.noteTerminalSpawnCommand?.( + ctx.result.id, + typeof ctx.launchCommand === 'string' ? ctx.launchCommand : null + ) + } + if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { + markClaudePtySpawned(ctx.result.id) + } + // Why: record the paneKey mapping so clearProviderPtyState can clear the agent-hooks server's per-paneKey caches on exit. + // Why: args.env is untrusted IPC JSON (type unenforced); bound the paneKey so malformed/oversized values can't pollute ptyPaneKey or clearPaneState. + const rememberedPaneKey = ctx.validatedPaneKey + ? rememberPaneKeyForPty(ctx.result.id, ctx.validatedPaneKey) + : null + if (ctx.legacySpawnPaneKey && ctx.migrationUnsupportedPaneKey) { + agentHookServer.registerPaneKeyAlias( + ctx.legacySpawnPaneKey.paneKey, + ctx.migrationUnsupportedPaneKey, + ctx.result.id, + Date.now(), + { authorityVerified: true } + ) + clearMigrationUnsupportedPtysForPaneKey(ctx.migrationUnsupportedPaneKey) + } else if (ctx.validatedPaneKey) { + if (!ctx.result.isReattach) { + clearMigrationUnsupportedPtysForPaneKey(ctx.validatedPaneKey) + } + } + // Why: register only local PTYs with the memory collector — SSH PTYs run remotely and their process tree is invisible to our local `ps`. + if (!args.connectionId) { + // Why: record the spawn-result pid once here so the memory module needn't reach back into ipc/pty on a hot path (works for in-process and daemon-hosted PTYs). + const spawnedPid = ctx.result.pid ?? null + // Why: args.worktreeId/sessionId arrive as untrusted IPC strings (type unenforced at the boundary); bound them so malformed/oversized values can't pollute registerPty's maps. + registerPty({ + ptyId: ctx.result.id, + worktreeId: + typeof args.worktreeId === 'string' && + args.worktreeId.length > 0 && + args.worktreeId.length <= 512 + ? args.worktreeId + : null, + sessionId: + typeof args.sessionId === 'string' && + args.sessionId.length > 0 && + args.sessionId.length <= 256 + ? args.sessionId + : null, + paneKey: rememberedPaneKey, + pid: + typeof spawnedPid === 'number' && Number.isFinite(spawnedPid) && spawnedPid > 0 + ? spawnedPid + : null + }) + } + // Why: telemetry-plan.md§Agent launch semantics — fire agent_started only after spawn resolved; safeParse each field so a spoofed IPC payload can't poison the event (missing required field skips it). + if (args.telemetry && !ctx.stablePaneOwner) { + const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) + const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) + const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) + if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { + track('agent_started', { + agent_kind: agentKindParse.data, + launch_source: launchSourceParse.data, + request_kind: requestKindParse.data, + ...getCohortAtEmit() + }) + } + } + const response = { + ...ctx.result, + // Why both or neither: a pane can only adopt proven kitty flags together + // with the sequence boundary they describe. + ...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' && + ctx.reconciledSnapshotSeq !== null && + ctx.snapshotKittyFlagsCoverReconciledSeq + ? { snapshotSeq: ctx.reconciledSnapshotSeq } + : { snapshotKittyKeyboardFlags: undefined }), + ...(!ctx.result.isReattach && ctx.effectiveLaunchConfig + ? { launchConfig: ctx.effectiveLaunchConfig } + : {}), + // Why: a daemon-retry race can surface isReattach even for a minted session id, and a reattach must never claim its cwd was remapped. + ...(ctx.startupCwdFallback && !ctx.result.isReattach + ? { startupCwdFallback: ctx.startupCwdFallback } + : {}), + // Why: the pane asked to resume and got a fresh session instead; only the + // renderer can say so, and a reattach never ran this launch command. + ...(ctx.codexResumeLaunch.notifyResumeUnavailable && !ctx.result.isReattach + ? { agentResumeUnavailable: true as const } + : {}) + } + // Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory. + ctx.deps.sendPtySpawnedToRenderer(ctx.result.id) + if (!args.connectionId) { + ctx.deps.options?.onCodexHomePtySpawned?.({ + id: ctx.result.id, + codexHomePath: ctx.selectedCodexHomePath, + startedAt: ctx.codexHomeLaunchStartedAt, + startedSequence: ctx.codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField( + ctx.reattachedCodexHomeRoutes, + ctx.result.id, + ctx.result.isReattach === true + ), + ...(ctx.result.isReattach === true + ? { reattached: true } + : ctx.baseEnv + ? { launchEnv: ctx.baseEnv } + : {}) + }) + } + return resolvePaneSpawnReservation( + ctx.paneSpawnReservationKey, + ctx.paneSpawnReservation, + response + ) +} diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts new file mode 100644 index 00000000000..655a848e980 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -0,0 +1,164 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' +import { isSafePtySessionId } from '../../../daemon/pty-session-id' +import { isNativeWindowsLocalPtySpawn } from '../../../runtime/terminal-model-query-authority' +import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' +import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery' +import { buildPtyHostEnv } from '../host-env/assembly' +import { + getCompatibleSelectedCodexHomePath, + getCodexSelectionTargetForPty, + resolveCodexHomeAfterManagedAuthReadiness, + shouldSkipCodexHomeEnvForWindowsShell, + shouldStripInheritedOrcaCodexHome, + isCodexStatusHooksEnabled, + codexHomePathsEqual +} from '../host-env/codex-home' +import { promoteAgentTeamsShimPath } from '../host-env/path' +import { clearProviderPtyState } from '../provider/state-cleanup' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promise { + const args = ctx.args + ctx.effectiveShellOverride = ctx.terminalRuntimeOptions.shellOverride + ctx.nativeWindowsConptySpawn = isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: ctx.effectiveShellOverride + }) + ctx.codexSelectionTarget = getCodexSelectionTargetForPty( + ctx.effectiveShellOverride, + ctx.cwd, + ctx.expectedWslDistro + ) + const codexResumePreparation = ctx.preAdoptedStablePane + ? null + : ctx.deps.prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: ctx.codexSelectionTarget, + launchEnv: ctx.baseEnv, + workspacePath: ctx.cwd + }) + ctx.codexResumeLaunch = codexResumePreparation + ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) + : ctx.deps.noCodexResumeLaunch(ctx.preAdoptedStablePane ? undefined : args.command) + const codexResumeHome = ctx.codexResumeLaunch.codexResumeHome + ctx.launchCommand = ctx.codexResumeLaunch.command + ctx.baseEnv = ctx.deps.stripSequencedStartupResumeArgv(ctx.baseEnv, ctx.codexResumeLaunch) + // Why: declared after the strip so a local-provider spawn cannot capture the + // pre-strip env — only the daemon branch below re-derives this from baseEnv. + ctx.env = ctx.baseEnv + ctx.selectedCodexHomePath = + !ctx.preAdoptedStablePane && !args.connectionId + ? getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + codexResumeHome + ? ctx.deps.reconcileSharedRuntimeResumeHome(codexResumeHome, () => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { + workspacePath: ctx.cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null + ) + ) + : (ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { + workspacePath: ctx.cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null) + ) + : null + if (!ctx.preAdoptedStablePane && args.launchAgent === 'codex' && args.sessionId === undefined) { + const resolution = resolveCodexHomeAfterManagedAuthReadiness({ + selectedCodexHomePath: ctx.selectedCodexHomePath, + getSettings: () => ctx.deps.getSettings?.(), + requiredCodexHomePath: codexResumeHome?.codexHomePath, + target: ctx.codexSelectionTarget, + resolveCurrent: () => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { + workspacePath: ctx.cwd, + launchAgent: 'codex' + }) ?? null + ), + resolveAfterUnavailable: (unavailableManagedHomePath) => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { + workspacePath: ctx.cwd, + launchAgent: 'codex', + unavailableManagedHomePath + }) ?? null + ) + }) + ctx.selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution + } + if (args.launchAgent === 'codex' && ctx.selectedCodexHomePath) { + await ensureCodexStateDbBackfillRecoveryStarted(ctx.selectedCodexHomePath) + } + ctx.codexResumeHomeSelected = Boolean( + codexResumeHome && codexHomePathsEqual(ctx.selectedCodexHomePath, codexResumeHome.codexHomePath) + ) + ctx.skipCodexHomeEnv = + ctx.isDaemonHostSpawn && + shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd) && + !ctx.selectedCodexHomePath + const ptySettings = ctx.isDaemonHostSpawn ? ctx.deps.getSettings?.() : undefined + ctx.stripInheritedOrcaCodexHome = + ctx.isDaemonHostSpawn && + shouldStripInheritedOrcaCodexHome({ + target: ctx.codexSelectionTarget, + selectedCodexHomePath: ctx.selectedCodexHomePath, + skipCodexHomeEnv: ctx.skipCodexHomeEnv, + settings: ptySettings + }) + if (ctx.isDaemonHostSpawn && !ctx.preAdoptedStablePane) { + if (ctx.effectiveSessionId === undefined) { + // Should be unreachable: effectiveSessionId is a string when isDaemonHostSpawn; defense-in-depth. + throw new Error('Invariant violation: daemon spawn without sessionId') + } + const sessionIdForEnv = ctx.effectiveSessionId + // Why: this id reaches filesystem paths; reject traversal/separators so a crafted IPC payload can't escape the expected roots. + if (!isSafePtySessionId(sessionIdForEnv, getAppEnvironment().getPath('userData'))) { + throw new Error('Invalid PTY session id') + } + // Why: clone before mutating so injections don't leak back into args.env (renderer may reuse it). + ctx.env = { ...ctx.baseEnv } + try { + buildPtyHostEnv(sessionIdForEnv, ctx.env, { + isPackaged: getAppEnvironment().isPackaged(), + resourcesPath: process.resourcesPath, + userDataPath: getAppEnvironment().getPath('userData'), + selectedCodexHomePath: ctx.selectedCodexHomePath, + skipCodexHomeEnv: ctx.skipCodexHomeEnv, + stripInheritedOrcaCodexHome: ctx.stripInheritedOrcaCodexHome, + launchCommand: ctx.launchCommand, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd), + wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), + codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), + networkProxySettings: ptySettings, + deferGitConfigGuardToDaemon: + ctx.provider.supportsGitCredentialGuardHost?.(ctx.effectiveSessionId) === true + }) + stampWslOrchestrationCompatibilityHost( + ctx.env, + ctx.deps.runtime?.getOrchestrationCompatibilityHostId?.(), + ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null + ) + promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) + } catch (err) { + // Why: buildPtyHostEnv has fs side-effects (Pi/OMP install); clear per-PTY state on throw, but only minted ids — caller ids may name existing PTYs. + if (ctx.isMintedSessionId) { + clearProviderPtyState(sessionIdForEnv) + } + throw err + } + } + ctx.spawnTiming.mark('host_env') +} diff --git a/src/main/ipc/pty/ipc/spawn-env.ts b/src/main/ipc/pty/ipc/spawn-env.ts new file mode 100644 index 00000000000..2fd4c51ac04 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-env.ts @@ -0,0 +1,135 @@ +import { + isTerminalLeafId, + makePaneKey, + parseLegacyNumericPaneKey +} from '../../../../shared/stable-pane-id' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' +import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { resolvePathEnvKey } from '../../../pty/windows-environment-path' +import { routesFreshSpawnsToLocalProvider } from '../host-env/fresh-spawn-routing' +import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness' +import { parseValidPaneKey } from '../pane/key-state' +import { shouldRefreshNativeClaudeAgentTeamsEnv } from '../pane/launch-authority' +import type { PtyIpcSpawnState } from './spawn-state' +import { assemblePtyIpcSpawnCodexEnv } from './spawn-env-codex' + +export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise { + const args = ctx.args + if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { + throw new Error( + 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' + ) + } + // Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity. + // Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host. + // Why: forward pane env to SSH only when the relay hook path is enabled, or a newer relay could emit statuses this build can't route. + const sshSourceEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env) + const baseEnvWithAuth = ctx.claudeAuth + ? { ...sshSourceEnv, ...ctx.claudeAuth.envPatch } + : sshSourceEnv + const spawnPaneKey = baseEnvWithAuth?.ORCA_PANE_KEY + const parsedSpawnPaneKey = parseValidPaneKey(spawnPaneKey) + const verifiedPaneKey = + parsedSpawnPaneKey && + typeof args.tabId === 'string' && + args.tabId === parsedSpawnPaneKey.tabId && + args.leafId === parsedSpawnPaneKey.leafId + ? makePaneKey(parsedSpawnPaneKey.tabId, parsedSpawnPaneKey.leafId) + : null + ctx.verifiedLeafId = verifiedPaneKey && parsedSpawnPaneKey ? parsedSpawnPaneKey.leafId : null + ctx.metadataLeafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + ctx.metadataPaneKey = + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + ctx.metadataLeafId + ? makePaneKey(args.tabId, ctx.metadataLeafId) + : null + ctx.legacySpawnPaneKey = verifiedPaneKey ? null : parseLegacyNumericPaneKey(spawnPaneKey) + ctx.migrationUnsupportedPaneKey = + ctx.legacySpawnPaneKey && + typeof args.tabId === 'string' && + args.tabId === ctx.legacySpawnPaneKey.tabId && + typeof args.leafId === 'string' && + isTerminalLeafId(args.leafId) + ? makePaneKey(args.tabId, args.leafId) + : null + ctx.stablePaneKey = verifiedPaneKey ?? ctx.migrationUnsupportedPaneKey + ctx.baseEnv = baseEnvWithAuth ? { ...baseEnvWithAuth } : undefined + const shouldRefreshAgentTeamsEnv = + !ctx.preAdoptedStablePane && + !args.connectionId && + ctx.deps.runtime !== undefined && + ctx.stablePaneKey !== null && + shouldRefreshNativeClaudeAgentTeamsEnv({ + command: args.command, + launchConfig: args.launchConfig + }) + ctx.effectiveLaunchConfig = args.launchConfig + const shouldPreAllocateTerminalHandle = + ctx.deps.runtime !== undefined && + ((!(ctx.provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(ctx.provider)) || + shouldRefreshAgentTeamsEnv) + const runtime = ctx.deps.runtime + ctx.preAllocatedHandle = shouldPreAllocateTerminalHandle + ? (ctx.preAdoptedStablePane?.owner.handle ?? + runtime?.createPreAllocatedTerminalHandle() ?? + null) + : null + if (shouldRefreshAgentTeamsEnv && ctx.preAllocatedHandle && runtime) { + // Why: Agent Teams ids/tokens are process-local, so the team env must be regenerated for the new leader PTY. + const prepared = await runtime.prepareClaudeAgentTeamsLeaderForHandle({ + handle: ctx.preAllocatedHandle, + baseEnv: ctx.baseEnv ?? {} + }) + ctx.agentTeamsLeaderHandle = ctx.preAllocatedHandle + ctx.baseEnv = { + ...ctx.baseEnv, + ...prepared.env + } + if (args.launchConfig) { + ctx.effectiveLaunchConfig = { + ...args.launchConfig, + agentEnv: { + ...args.launchConfig.agentEnv, + ...prepared.env + } + } + } + } + ctx.requestedAgentTeamsPath = ctx.baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID + ? ctx.baseEnv[resolvePathEnvKey(ctx.baseEnv, process.platform)] + : undefined + ctx.agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv ? ['TERM_PROGRAM'] : undefined + if (ctx.baseEnv && ctx.stablePaneKey) { + ctx.baseEnv.ORCA_PANE_KEY = ctx.stablePaneKey + if (typeof args.tabId === 'string') { + ctx.baseEnv.ORCA_TAB_ID = args.tabId + } else if (!args.connectionId) { + delete ctx.baseEnv.ORCA_TAB_ID + } + if (typeof args.worktreeId === 'string') { + ctx.baseEnv.ORCA_WORKTREE_ID = args.worktreeId + } else if (!args.connectionId) { + delete ctx.baseEnv.ORCA_WORKTREE_ID + } + } else if (ctx.baseEnv) { + // Why: ORCA_PANE_KEY crosses into shells/hook registries; only a key proven to match this spawn's tab+leaf may cross the IPC boundary. + delete ctx.baseEnv.ORCA_PANE_KEY + delete ctx.baseEnv.ORCA_TAB_ID + delete ctx.baseEnv.ORCA_WORKTREE_ID + delete ctx.baseEnv.ORCA_AGENT_LAUNCH_TOKEN + } + ctx.validatedPaneKey = ctx.stablePaneKey + // Why: SSH can strip ORCA_PANE_KEY when remote hooks are off; IPC tab/leaf metadata still names the pane. + ctx.reservationPaneKey = ctx.metadataPaneKey ?? ctx.validatedPaneKey + ctx.validatedLeafId = ctx.verifiedLeafId ?? ctx.metadataLeafId + await assemblePtyIpcSpawnCodexEnv(ctx) +} diff --git a/src/main/ipc/pty/ipc/spawn-execute.ts b/src/main/ipc/pty/ipc/spawn-execute.ts new file mode 100644 index 00000000000..58525e162aa --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-execute.ts @@ -0,0 +1,202 @@ +import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' +import { + SSH_SESSION_EXPIRED_ERROR, + isSshPtyIdentityMismatchError +} from '../../../providers/ssh-pty-errors' +import { classifyError } from '../../../telemetry/classify-error' +import { track } from '../../../telemetry/client' +import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' +import { agentKindSchema } from '../../../../shared/telemetry-events' +import { normalizeNodePtySpawnError } from '../provider/liveness' +import { resolveStablePaneOwner, spawnForStablePane } from '../pane/stable-owner' +import { assertSpawnReplyWasLive } from '../pane/agent-session-owners' +import { deletePtyOwnership } from '../provider/ownership-state' +import { ptySizes } from '../delivery/visibility-state' +import { clearProviderPtyState } from '../provider/state-cleanup' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + const args = ctx.args + try { + if (ctx.preAllocatedHandle) { + ctx.deps.trustedTerminalHandleEnv.add(ctx.preAllocatedHandle) + } + ctx.spawnTiming.mark('options') + const stablePaneOwnerCandidate = resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.reservationPaneKey, + args.worktreeId, + args.connectionId + ) + const expectedPtyId = + stablePaneOwnerCandidate?.ptyId ?? ctx.effectiveSessionAppId ?? ctx.effectiveSessionId + if (expectedPtyId) { + ctx.deps.runtime?.beginPtyRegistration?.(expectedPtyId) + ctx.pendingRegistrationPtyId = expectedPtyId + } + if (ctx.isDaemonHostSpawn && expectedPtyId) { + ctx.preparedProvisionalExecutionContext = + ctx.deps.runtime?.preparePtyExecutionContext?.(expectedPtyId, ctx.expectedWslDistro, { + resetIncarnation: ctx.isMintedSessionId && !stablePaneOwnerCandidate, + preserveExisting: !ctx.isMintedSessionId || Boolean(stablePaneOwnerCandidate) + }) ?? false + } + const sequenceBeforeProviderSpawn = expectedPtyId + ? (ctx.deps.runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) + : 0 + const stablePaneSpawn = ctx.preAdoptedStablePane + ? ctx.preAdoptedStablePane + : await spawnForStablePane({ + runtime: ctx.deps.runtime, + store: ctx.deps.store, + provider: ctx.provider, + spawnOptions: ctx.spawnOptions, + owner: stablePaneOwnerCandidate, + worktreeId: args.worktreeId, + connectionId: args.connectionId, + resolveOwner: () => + resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.reservationPaneKey, + args.worktreeId, + args.connectionId + ) + }) + ctx.result = stablePaneSpawn.result + ctx.stablePaneOwner = stablePaneSpawn.owner + if ( + ctx.stablePaneOwner && + ctx.isMintedSessionId && + ctx.effectiveSessionAppId && + ctx.effectiveSessionAppId !== ctx.result.id + ) { + clearProviderPtyState(ctx.effectiveSessionAppId) + } + ctx.rejectedRegistrationCandidate = ctx.result + if (ctx.pendingRegistrationPtyId !== ctx.result.id) { + if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.pendingRegistrationPtyId) + } + ctx.deps.runtime?.beginPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) + ctx.pendingRegistrationPtyId = ctx.result.id + } + assertSpawnReplyWasLive(ctx.result) + ctx.deps.runtime?.assertPtyRegistrationAllowed?.(ctx.result.id, ctx.result.incarnationId) + if (ctx.result.providerSequence) { + const runtimeSequenceBeforeReconcile = + ctx.deps.runtime?.getPtyOutputSequence?.(ctx.result.id) ?? 0 + // Why kept: this is the reattach boundary in the RENDERER's sequence + // domain, and the daemon snapshot's kitty flags mean nothing without + // the boundary they were proven at. + ctx.reconciledSnapshotSeq = + ctx.deps.runtime?.synchronizePtyOutputSequenceFromProvider?.( + ctx.result.id, + ctx.result.providerSequence, + sequenceBeforeProviderSpawn + ) ?? null + if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) { + ctx.snapshotKittyFlagsCoverReconciledSeq = false + } + } + ensureWslHookRelayForReattach(ctx.result, args.connectionId) + ctx.deps.runtime?.preparePtyExecutionContext?.( + ctx.result.id, + args.connectionId + ? null + : ctx.result.wslDistro === undefined + ? ctx.expectedWslDistro + : ctx.result.wslDistro + ) + ctx.spawnTiming.mark('provider_spawn') + } catch (err) { + if ( + (ctx.isMintedSessionId || ctx.preparedProvisionalExecutionContext) && + ctx.effectiveSessionAppId + ) { + ctx.deps.runtime?.preparePtyExecutionContext?.(ctx.effectiveSessionAppId, null, { + resetIncarnation: true + }) + } + // Why: a stale hidden mark on this session id would gate a later visible attach that reuses it. + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) + } + const rawMessage = err instanceof Error ? err.message : String(err) + if (rawMessage === 'agent_session_exited_during_start' && ctx.rejectedRegistrationCandidate) { + ctx.deps.runtime?.releaseRejectedPtyRegistrationFence?.( + ctx.rejectedRegistrationCandidate.id, + ctx.rejectedRegistrationCandidate.incarnationId + ) + } + if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.( + ctx.pendingRegistrationPtyId, + ctx.rejectedRegistrationCandidate?.incarnationId + ) + ctx.pendingRegistrationPtyId = null + } + const spawnError = normalizeNodePtySpawnError(err) + const isIdentityMismatch = + isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage) + const isExpiredSshSession = + Boolean(args.connectionId) && + (spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) || + rawMessage.includes(SSH_SESSION_EXPIRED_ERROR)) + const exitedBeforeSpawnReply = + ctx.rejectedRegistrationCandidate?.exitedBeforeSpawnReply === true + if (ctx.effectiveSessionAppId !== undefined) { + if ( + ctx.hadSessionSizeBeforeAttach && + ctx.sessionSizeBeforeAttach && + (isIdentityMismatch || (!isExpiredSshSession && !exitedBeforeSpawnReply)) + ) { + ptySizes.set(ctx.effectiveSessionAppId, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(ctx.effectiveSessionAppId) + } + } + if (args.connectionId && ctx.effectiveSessionRelayId !== undefined && isExpiredSshSession) { + // Why: expired remote reattach = relay already dropped the PTY; clear the lease so writes can't restore the stale binding. + if (ctx.effectiveSessionAppId !== undefined && !isIdentityMismatch) { + clearProviderPtyState(ctx.effectiveSessionAppId) + deletePtyOwnership(ctx.effectiveSessionAppId) + } + if (!isIdentityMismatch) { + ctx.deps.store?.markSshRemotePtyLease( + args.connectionId, + ctx.effectiveSessionRelayId, + 'expired' + ) + } + } + // Why: provider state buildPtyHostEnv materialized for this minted id leaks if spawn failed. + if (ctx.isMintedSessionId && ctx.effectiveSessionId !== undefined) { + clearProviderPtyState(ctx.effectiveSessionId) + } + // Why: telemetry-plan.md§agent_error — attribute the error to the renderer-threaded agent_kind, else sniff the command for `claude`; raw messages are dropped at the validator boundary. + const rendererAgentKindParse = + args.telemetry?.agent_kind !== undefined + ? agentKindSchema.safeParse(args.telemetry.agent_kind) + : null + const errorAgentKind = rendererAgentKindParse?.success + ? rendererAgentKindParse.data + : ctx.isClaudeLaunch + ? ('claude-code' as const) + : null + if (errorAgentKind) { + const classified = classifyError(spawnError) + track('agent_error', { + agent_kind: errorAgentKind, + error_class: classified.error_class, + ...getCohortAtEmit() + }) + } + throw spawnError + } finally { + if (ctx.preAllocatedHandle) { + ctx.deps.trustedTerminalHandleEnv.delete(ctx.preAllocatedHandle) + } + } +} diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts new file mode 100644 index 00000000000..ed75b989f6d --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -0,0 +1,181 @@ +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' +import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' +import { CODEX_HOME_ENV_KEYS } from '../host-env/codex-home' +import { + mergePtyEnvDeletions, + removeCodexHomeDeletionRequests, + getInheritedAgentHookEnvKeysToDelete, + getInheritedClaudeSessionStampEnvKeysToDelete +} from '../host-env/pi-agent' +import { promoteAgentTeamsShimPath, deleteRequestedEnvKeys } from '../host-env/path' +import { beginPtySpawnForWorktree } from '../host-env/fresh-spawn-routing' +import { + makePaneSpawnReservationKey, + reservePaneSpawn, + paneSpawnReservationsByOwnerKey, + pendingRuntimePaneCreatesByOwnerKey +} from '../pane/spawn-reservation' +import { ptySizes } from '../delivery/visibility-state' +import { getStartupTerminalColorQueryReplyColors } from '../../terminal-startup-color-query-replies' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function buildPtyIpcSpawnOptions( + ctx: PtyIpcSpawnState +): Promise<{ isReattach: true } | null> { + const args = ctx.args + ctx.spawnEnv = ctx.preAllocatedHandle + ? { ...ctx.env, ORCA_TERMINAL_HANDLE: ctx.preAllocatedHandle } + : ctx.env + const envToDelete = ctx.claudeAuth?.stripAuthEnv + ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] + : undefined + ctx.combinedEnvToDelete = mergePtyEnvDeletions( + envToDelete, + args.envToDelete ?? [], + ctx.agentTeamsEnvToDelete ?? [], + // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. + ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], + ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.spawnEnv) : [], + getInheritedClaudeSessionStampEnvKeysToDelete(ctx.spawnEnv), + ctx.skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], + // Why: the persistent daemon compares its own merged CODEX_HOME pair; + // main cannot safely decide ownership for a process it may not parent. + ctx.stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] + ) + if (ctx.codexResumeHomeSelected) { + ctx.combinedEnvToDelete = removeCodexHomeDeletionRequests(ctx.combinedEnvToDelete) + } + deleteRequestedEnvKeys(ctx.spawnEnv, ctx.combinedEnvToDelete) + promoteAgentTeamsShimPath(ctx.spawnEnv, ctx.requestedAgentTeamsPath) + ctx.spawnOptions = { + cols: args.cols, + rows: args.rows, + cwd: ctx.cwd, + ...(ctx.prevalidatedCwd && !ctx.isDaemonHostSpawn + ? { prevalidatedCwd: ctx.prevalidatedCwd } + : {}), + env: ctx.spawnEnv, + historyIsolationEnabled: ctx.deps.getSettings?.()?.terminalScopeHistoryByWorktree ?? true, + ...(ctx.isMintedSessionId ? { isNewSession: true } : {}) + } + if (!args.connectionId && !ctx.isDaemonHostSpawn) { + ctx.spawnOptions.codexHomePathOverride = { value: ctx.selectedCodexHomePath } + } + if (ctx.combinedEnvToDelete) { + ctx.spawnOptions.envToDelete = ctx.combinedEnvToDelete + } + if (ctx.launchCommand !== undefined) { + ctx.spawnOptions.command = ctx.launchCommand + } + if (args.commandDelivery !== undefined) { + ctx.spawnOptions.commandDelivery = args.commandDelivery + } + if (args.startupCommandDelivery !== undefined) { + ctx.spawnOptions.startupCommandDelivery = args.startupCommandDelivery + } + if (isTuiAgent(args.launchAgent)) { + ctx.spawnOptions.launchAgent = args.launchAgent + } + if (args.worktreeId !== undefined) { + ctx.spawnOptions.worktreeId = args.worktreeId + } + if (ctx.reservationPaneKey) { + ctx.spawnOptions.paneKey = ctx.reservationPaneKey + } + if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) { + ctx.spawnOptions.tabId = args.tabId + } + if (ctx.effectiveSessionId !== undefined) { + ctx.spawnOptions.sessionId = ctx.effectiveSessionId + } + // Why: without this, the Windows daemon path ignores the user's Default Shell preference (LocalPtyProvider already honors it via getWindowsShell()). + if (ctx.effectiveShellOverride !== undefined) { + ctx.spawnOptions.shellOverride = ctx.effectiveShellOverride + } + ctx.hadSessionSizeBeforeAttach = + ctx.effectiveSessionAppId !== undefined ? ptySizes.has(ctx.effectiveSessionAppId) : false + ctx.sessionSizeBeforeAttach = + ctx.effectiveSessionAppId !== undefined ? ptySizes.get(ctx.effectiveSessionAppId) : undefined + if (ctx.effectiveSessionId !== undefined) { + // Why: daemon PTYs can emit before spawn() resolves; set real geometry now or early bytes default to 80x24 and wrap TUIs. + ptySizes.set(ctx.effectiveSessionAppId ?? ctx.effectiveSessionId, { + cols: args.cols, + rows: args.rows + }) + } + if (process.platform === 'win32' && !args.connectionId) { + // Why: the renderer models PowerShell as one shell family; thread the implementation choice so both PTY paths resolve the same executable. + ctx.spawnOptions.terminalWindowsWslDistro = ctx.expectedWslDistro + ctx.spawnOptions.terminalWindowsPowerShellImplementation = ctx.deps.getSettings + ? (ctx.deps.getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') + : undefined + } + const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) + if (startupTerminalColorQueryReplyColors) { + ctx.spawnOptions.startupIngress = { + colors: startupTerminalColorQueryReplyColors, + deadlineMs: 5_000 + } + } + const resolvedPaneSpawnReservationKey = makePaneSpawnReservationKey( + args.worktreeId, + args.connectionId, + ctx.reservationPaneKey + ) + if ( + ctx.paneSpawnReservationKey && + resolvedPaneSpawnReservationKey !== ctx.paneSpawnReservationKey + ) { + throw new Error('terminal_pane_identity_changed') + } + if (!ctx.paneSpawnReservationKey) { + ctx.paneSpawnReservationKey = resolvedPaneSpawnReservationKey + const pendingRuntimeCreateAfterPreflight = ctx.paneSpawnReservationKey + ? pendingRuntimePaneCreatesByOwnerKey.get(ctx.paneSpawnReservationKey) + : undefined + if (pendingRuntimeCreateAfterPreflight) { + await pendingRuntimeCreateAfterPreflight.promise + } + const existingPaneSpawnAfterPreflight = ctx.paneSpawnReservationKey + ? paneSpawnReservationsByOwnerKey.get(ctx.paneSpawnReservationKey) + : undefined + if (existingPaneSpawnAfterPreflight) { + const reattach = { + ...(await existingPaneSpawnAfterPreflight.promise), + isReattach: true as const + } + // Why: discard only a distinct provisional id; the winner may have committed this id's real size. + if (ctx.effectiveSessionId !== undefined) { + const provisionalSizeKey = ctx.effectiveSessionAppId ?? ctx.effectiveSessionId + if (provisionalSizeKey !== reattach.id) { + if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) { + ptySizes.set(provisionalSizeKey, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(provisionalSizeKey) + } + } + } + return reattach + } + ctx.paneSpawnReservation = ctx.paneSpawnReservationKey + ? reservePaneSpawn(ctx.paneSpawnReservationKey) + : null + } + ctx.finishTerminalInstall = beginPtySpawnForWorktree(args.worktreeId, ctx.cwd, args.connectionId) + ctx.initiallyHidden = args.initiallyHidden === true + // Why: daemon PTYs can emit before spawn() resolves, so the hidden mark must beat byte zero (terminal-query-authority.md §races); other providers are safe with the post-spawn mark below. + ctx.preSpawnHiddenMarkId = + ctx.initiallyHidden && ctx.isDaemonHostSpawn && ctx.effectiveSessionAppId !== undefined + ? ctx.effectiveSessionAppId + : null + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true) + } + const runtime = ctx.deps.runtime + const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn + ctx.releaseWorktreeSpawn = acquireWorktreeSpawn + ? await acquireWorktreeSpawn.call(runtime, args.worktreeId) + : undefined + return null +} diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts new file mode 100644 index 00000000000..f40b46e5dd5 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -0,0 +1,229 @@ +import { + isWslShellName, + resolveLocalWindowsTerminalRuntimeOptions +} from '../../../../shared/local-windows-terminal-runtime' +import { isWslUncPath, toWindowsWslPath } from '../../../../shared/wsl-paths' +import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' +import { mintPtySessionId } from '../../../daemon/pty-session-id' +import { resolveWslSessionContext } from '../../../daemon/wsl-session-context' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { normalizeWindowsTerminalCwd } from '../../../providers/windows-shell-args' +import { wslUncDirectoryExistsAsync } from '../../../wsl' +import { getCodexSelectionTargetForPty } from '../host-env/codex-home' +import { + isClaudeLaunchCommand, + recoverFreshSpawnProviderRouting, + routesFreshSpawnsToLocalProvider +} from '../host-env/fresh-spawn-routing' +import { getAppPtyId, getProvider, getRelayPtyId } from '../provider/registry' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promise { + const args = ctx.args + // Establish daemon identity before the first await so hidden delivery is gated before byte zero. + ctx.provider = getProvider(args.connectionId) + ctx.isDaemonHostSpawn = + !args.connectionId && + !(ctx.provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(ctx.provider) + ctx.isMintedSessionId = args.sessionId === undefined && ctx.isDaemonHostSpawn + ctx.effectiveSessionId = + args.sessionId ?? (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) + ctx.effectiveSessionAppId = + ctx.effectiveSessionId !== undefined + ? getAppPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.effectiveSessionRelayId = + ctx.effectiveSessionId !== undefined + ? getRelayPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.initiallyHidden = args.initiallyHidden === true + ctx.preSpawnHiddenMarkId = + ctx.initiallyHidden && ctx.isDaemonHostSpawn && ctx.effectiveSessionAppId !== undefined + ? ctx.effectiveSessionAppId + : null + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true) + } + if (!ctx.earlyStablePaneOwner) { + const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + if (pathUsable) { + await pathUsable + } + } + ctx.spawnTiming.mark('stable_adoption_setup') + ctx.preAdoptedStablePane = + ctx.earlyStablePaneOwner && ctx.earlyWorktreeId + ? await ctx.deps.adoptStablePane({ + cols: args.cols, + rows: args.rows, + cwd: ctx.cwd, + connectionId: args.connectionId, + worktreeId: ctx.earlyWorktreeId, + tabId: ctx.earlyStablePaneOwner.tabId, + leafId: ctx.earlyStablePaneOwner.leafId, + ownsPaneSpawnReservation: true + }) + : null + ctx.spawnTiming.mark('stable_adoption') + if (ctx.earlyStablePaneOwner && !ctx.preAdoptedStablePane) { + const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + if (pathUsable) { + await pathUsable + } + } + if (!ctx.preAdoptedStablePane) { + // Why: reattach needs exact cwd, SSH cannot probe locally, and successful stable-pane adoption needs no launch preflight. + const requestedMissingCwdFallback = + !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' + const isPosixStartupCwd = args.cwd?.startsWith('/') === true + const startupWorkspaceCwd = + requestedMissingCwdFallback && isPosixStartupCwd + ? ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, '.') + : undefined + const initiallyResolvedStartupCwd = + requestedMissingCwdFallback && isPosixStartupCwd ? ctx.cwd : undefined + const startupTerminalRuntimeOptions = + requestedMissingCwdFallback && process.platform === 'win32' + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.shellOverride, + settings: ctx.deps.getSettings?.(), + projectRuntime: args.projectRuntime, + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : undefined + const wslRuntimeOwnsStartupCwd = + requestedMissingCwdFallback && + isPosixStartupCwd && + (isWslShellName(startupTerminalRuntimeOptions?.shellOverride) || + isWslUncPath(startupWorkspaceCwd ?? '')) + const startupWslContext = wslRuntimeOwnsStartupCwd + ? resolveWslSessionContext({ + cwd: startupWorkspaceCwd, + shellOverride: startupTerminalRuntimeOptions?.shellOverride, + terminalWindowsWslDistro: startupTerminalRuntimeOptions?.terminalWindowsWslDistro + }) + : undefined + let wslStartupCwdExists: boolean | null = null + let wslWorkspaceCwdExists: boolean | null = null + if (startupWslContext && initiallyResolvedStartupCwd) { + const validationCwd = toWindowsWslPath(initiallyResolvedStartupCwd, startupWslContext.distro) + wslStartupCwdExists = isWslUncPath(validationCwd) + ? await wslUncDirectoryExistsAsync(validationCwd) + : ctx.deps.localStartupCwdDirectoryExists(validationCwd) + if (wslStartupCwdExists === true) { + ctx.prevalidatedCwd = validationCwd + } + if (wslStartupCwdExists === false && startupWorkspaceCwd) { + wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd) + ? await wslUncDirectoryExistsAsync(startupWorkspaceCwd) + : ctx.deps.localStartupCwdDirectoryExists(startupWorkspaceCwd) + if (wslWorkspaceCwdExists === true) { + ctx.prevalidatedCwd = startupWorkspaceCwd + } + } + } + const allowMissingCwdFallback = + requestedMissingCwdFallback && + (!wslRuntimeOwnsStartupCwd || + (wslStartupCwdExists === false && wslWorkspaceCwdExists === true)) + let didFallbackToWorkspaceRootCwd = false + ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd( + args.worktreeId, + args.cwd, + allowMissingCwdFallback + ? { + directoryExists: (path) => + startupWslContext && + wslStartupCwdExists === false && + path === initiallyResolvedStartupCwd + ? false + : startupWslContext && path === startupWorkspaceCwd + ? wslWorkspaceCwdExists === true + : ctx.deps.localStartupCwdDirectoryExists( + process.platform === 'win32' ? normalizeWindowsTerminalCwd(path) : path + ), + onFallbackToWorkspaceRoot: () => { + didFallbackToWorkspaceRootCwd = true + } + } + : undefined + ) + if (didFallbackToWorkspaceRootCwd && wslWorkspaceCwdExists === true && ctx.cwd) { + ctx.prevalidatedCwd = ctx.cwd + } + ctx.startupCwdFallback = + didFallbackToWorkspaceRootCwd && ctx.cwd ? { kind: 'worktree', cwd: ctx.cwd } : undefined + } + ctx.spawnTiming.mark('preflight') + const freshSpawnRecovery = ctx.preAdoptedStablePane + ? undefined + : recoverFreshSpawnProviderRouting(ctx.provider, args.connectionId, args.sessionId) + if (freshSpawnRecovery) { + await freshSpawnRecovery + const previousHiddenMarkId = ctx.preSpawnHiddenMarkId + ctx.isDaemonHostSpawn = + !args.connectionId && + !(ctx.provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(ctx.provider) + ctx.isMintedSessionId = args.sessionId === undefined && ctx.isDaemonHostSpawn + ctx.effectiveSessionId = + args.sessionId ?? (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) + ctx.effectiveSessionAppId = + ctx.effectiveSessionId !== undefined + ? getAppPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.effectiveSessionRelayId = + ctx.effectiveSessionId !== undefined + ? getRelayPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.preSpawnHiddenMarkId = + ctx.initiallyHidden && ctx.isDaemonHostSpawn && ctx.effectiveSessionAppId !== undefined + ? ctx.effectiveSessionAppId + : null + if (previousHiddenMarkId !== ctx.preSpawnHiddenMarkId) { + if (previousHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(previousHiddenMarkId, false) + } + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true) + } + } + } + ctx.isClaudeLaunch = + !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) + if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + ctx.terminalRuntimeOptions = + process.platform === 'win32' && !args.connectionId + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.shellOverride, + settings: ctx.deps.getSettings?.(), + projectRuntime: args.projectRuntime, + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : { shellOverride: args.shellOverride, terminalWindowsWslDistro: null } + const initialShellOverride = ctx.terminalRuntimeOptions.shellOverride + // Why: daemon host-env setup needs a stable id BEFORE provider.spawn so buildPtyHostEnv hooks/Pi cleanup can run; daemon still honors opts.sessionId ?? mint(). + // Note: sessionId is STABLE across daemon restarts by design — do NOT simplify to a fresh UUID per spawn; that orphans reconnectable state. + // Why: only clear ids minted in THIS request on failure — a caller-supplied args.sessionId may name an existing PTY we must not clobber. + ctx.expectedWslDistro = !args.connectionId + ? (resolveWslSessionContext({ + cwd: ctx.cwd, + sessionId: ctx.effectiveSessionId, + shellOverride: ctx.terminalRuntimeOptions.shellOverride, + terminalWindowsWslDistro: ctx.terminalRuntimeOptions.terminalWindowsWslDistro + })?.distro ?? null) + : null + const initialSelectionTarget = getCodexSelectionTargetForPty( + initialShellOverride, + ctx.cwd, + ctx.expectedWslDistro + ) + ctx.claudeAuth = + ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth + ? await ctx.deps.prepareClaudeAuth(initialSelectionTarget) + : null + ctx.spawnTiming.mark('auth') +} diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts new file mode 100644 index 00000000000..748eb5d8f62 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -0,0 +1,79 @@ +import { rejectPaneSpawnReservation } from '../pane/spawn-reservation' +import { ptySizes } from '../delivery/visibility-state' +import { beginPtyIpcSpawn } from './spawn-begin' +import { preparePtyIpcSpawnPreflight } from './spawn-preflight' +import { assemblePtyIpcSpawnEnv } from './spawn-env' +import { buildPtyIpcSpawnOptions } from './spawn-options' +import { executePtyIpcSpawn } from './spawn-execute' +import { commitPtyIpcSpawn } from './spawn-commit' +import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +function releaseAbandonedAgentTeamsLeader(ctx: PtyIpcSpawnState): void { + if (!ctx.agentTeamsLeaderHandle) { + return + } + ctx.deps.runtime?.releaseClaudeAgentTeamsLeaderForHandle?.(ctx.agentTeamsLeaderHandle) + ctx.agentTeamsLeaderHandle = null +} + +function restoreProvisionalPtySize(ctx: PtyIpcSpawnState): void { + if (ctx.effectiveSessionId === undefined) { + return + } + const key = ctx.effectiveSessionAppId ?? ctx.effectiveSessionId + if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) { + ptySizes.set(key, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(key) + } +} + +export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArgs) { + const ctx = createPtyIpcSpawnState(deps, args) + const early = await beginPtyIpcSpawn(ctx) + if (early) { + return early + } + try { + await preparePtyIpcSpawnPreflight(ctx) + await assemblePtyIpcSpawnEnv(ctx) + const earlyReserved = await buildPtyIpcSpawnOptions(ctx).catch((error: unknown) => { + restoreProvisionalPtySize(ctx) + throw error + }) + if (earlyReserved) { + // Why: this request lost the pane to the reservation winner, so its + // pre-allocated leader handle never binds to a PTY. Nothing else can + // evict the team env assembly created for it — exit/close cleanup keys + // off handleByPtyId — so every lost race would leak one team forever. + releaseAbandonedAgentTeamsLeader(ctx) + return earlyReserved + } + await executePtyIpcSpawn(ctx) + return await commitPtyIpcSpawn(ctx) + } catch (err) { + releaseAbandonedAgentTeamsLeader(ctx) + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) + } + if (ctx.pendingRegistrationPtyId) { + deps.runtime?.cancelPendingPtyRegistration?.( + ctx.pendingRegistrationPtyId, + ctx.rejectedRegistrationCandidate?.incarnationId + ) + ctx.pendingRegistrationPtyId = null + } + // Why: once the reservation is created, any later throw — + // spawn failure, persist failure, or a post-spawn helper such as + // seedHeadlessTerminal/registerPty/track — must settle it. Otherwise + // it lingers in paneSpawnReservationsByOwnerKey and every future spawn + // for this pane awaits a promise that never resolves. reject is a + // no-op once the reservation has already resolved. + rejectPaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, err) + throw err + } finally { + ctx.releaseWorktreeSpawn?.() + ctx.finishTerminalInstall() + } +} diff --git a/src/main/ipc/pty/ipc/spawn-state.ts b/src/main/ipc/pty/ipc/spawn-state.ts new file mode 100644 index 00000000000..ee9f5c33cc7 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-state.ts @@ -0,0 +1,159 @@ +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types' +import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' +import type { PtySpawnTiming } from '../../pty-spawn-timing' +import { createPtySpawnTiming } from '../../pty-spawn-timing' +import { noCodexResumeLaunch, type CodexResumeLaunch } from '../host-env/codex-resume' +import type { StablePaneOwner } from '../pane/stable-owner' +import type { PaneSpawnReservation } from '../pane/spawn-reservation' +import { localProvider } from '../provider/registry' +import type { AdoptStablePaneResult, PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +export type PtyIpcSpawnState = { + deps: PtySpawnIpcDeps + args: PtySpawnIpcArgs + spawnTiming: PtySpawnTiming + codexHomeLaunchStartedAt: Date | undefined + codexHomeLaunchStartedSequence: number | undefined + reattachedCodexHomeRoutes: Map + cwd: string | undefined + prevalidatedCwd: string | undefined + startupCwdFallback: { kind: 'worktree'; cwd: string } | undefined + earlyStablePaneOwner: StablePaneOwner | null + earlyWorktreeId: string | undefined + paneSpawnReservationKey: string | null + paneSpawnReservation: PaneSpawnReservation | null + finishTerminalInstall: () => void + result: PtySpawnResult + stablePaneOwner: StablePaneOwner | null + stablePaneBindingPersisted: boolean + rejectedRegistrationCandidate: PtySpawnResult | null + pendingRegistrationPtyId: string | null + reconciledSnapshotSeq: number | null + snapshotKittyFlagsCoverReconciledSeq: boolean + preparedProvisionalExecutionContext: boolean + releaseWorktreeSpawn: (() => void) | undefined + provider: IPtyProvider + preAdoptedStablePane: AdoptStablePaneResult | null + isClaudeLaunch: boolean + claudeAuth: ClaudeRuntimeAuthPreparation | null + terminalRuntimeOptions: { + shellOverride?: string + terminalWindowsWslDistro?: string | null + } + isDaemonHostSpawn: boolean + isMintedSessionId: boolean + effectiveSessionId: string | undefined + effectiveSessionAppId: string | undefined + effectiveSessionRelayId: string | undefined + expectedWslDistro: string | null + baseEnv: Record | undefined + effectiveLaunchConfig: SleepingAgentLaunchConfig | undefined + preAllocatedHandle: string | null + /** Leader handle this spawn allocated Agent Teams state for; must be released if the spawn is abandoned. */ + agentTeamsLeaderHandle: string | null + requestedAgentTeamsPath: string | undefined + agentTeamsEnvToDelete: string[] | undefined + stablePaneKey: string | null + verifiedLeafId: string | null + metadataLeafId: string | null + metadataPaneKey: string | null + legacySpawnPaneKey: { tabId: string; numericPaneId: string; paneKey: string } | null + migrationUnsupportedPaneKey: string | null + reservationPaneKey: string | null + validatedPaneKey: string | null + validatedLeafId: string | null + effectiveShellOverride: string | undefined + nativeWindowsConptySpawn: boolean + codexSelectionTarget: CodexAccountSelectionTarget + codexResumeLaunch: CodexResumeLaunch + launchCommand: string | undefined + env: Record | undefined + selectedCodexHomePath: string | null + spawnEnv: Record | undefined + spawnOptions: PtySpawnOptions + combinedEnvToDelete: string[] | undefined + skipCodexHomeEnv: boolean + stripInheritedOrcaCodexHome: boolean + codexResumeHomeSelected: boolean + hadSessionSizeBeforeAttach: boolean + sessionSizeBeforeAttach: { cols: number; rows: number } | undefined + initiallyHidden: boolean + preSpawnHiddenMarkId: string | null +} + +export function createPtyIpcSpawnState( + deps: PtySpawnIpcDeps, + args: PtySpawnIpcArgs +): PtyIpcSpawnState { + return { + deps, + args, + spawnTiming: createPtySpawnTiming(), + codexHomeLaunchStartedAt: undefined, + codexHomeLaunchStartedSequence: undefined, + reattachedCodexHomeRoutes: new Map(), + cwd: undefined, + prevalidatedCwd: undefined, + startupCwdFallback: undefined, + earlyStablePaneOwner: null, + earlyWorktreeId: undefined, + paneSpawnReservationKey: null, + paneSpawnReservation: null, + finishTerminalInstall: () => {}, + result: { id: '' }, + stablePaneOwner: null, + stablePaneBindingPersisted: false, + rejectedRegistrationCandidate: null, + pendingRegistrationPtyId: null, + reconciledSnapshotSeq: null, + snapshotKittyFlagsCoverReconciledSeq: true, + preparedProvisionalExecutionContext: false, + releaseWorktreeSpawn: undefined, + provider: localProvider, + preAdoptedStablePane: null, + isClaudeLaunch: false, + claudeAuth: null, + terminalRuntimeOptions: {}, + isDaemonHostSpawn: false, + isMintedSessionId: false, + effectiveSessionId: undefined, + effectiveSessionAppId: undefined, + effectiveSessionRelayId: undefined, + expectedWslDistro: null, + baseEnv: undefined, + effectiveLaunchConfig: undefined, + preAllocatedHandle: null, + agentTeamsLeaderHandle: null, + requestedAgentTeamsPath: undefined, + agentTeamsEnvToDelete: undefined, + stablePaneKey: null, + verifiedLeafId: null, + metadataLeafId: null, + metadataPaneKey: null, + legacySpawnPaneKey: null, + migrationUnsupportedPaneKey: null, + reservationPaneKey: null, + validatedPaneKey: null, + validatedLeafId: null, + effectiveShellOverride: undefined, + nativeWindowsConptySpawn: false, + codexSelectionTarget: { runtime: 'host' }, + codexResumeLaunch: noCodexResumeLaunch(undefined), + launchCommand: undefined, + env: undefined, + selectedCodexHomePath: null, + spawnEnv: undefined, + spawnOptions: { cols: args.cols, rows: args.rows }, + combinedEnvToDelete: undefined, + skipCodexHomeEnv: false, + stripInheritedOrcaCodexHome: false, + codexResumeHomeSelected: false, + hadSessionSizeBeforeAttach: false, + sessionSizeBeforeAttach: undefined, + initiallyHidden: false, + preSpawnHiddenMarkId: null + } +} diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts new file mode 100644 index 00000000000..68e1cc62227 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -0,0 +1,123 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' +import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' +import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TerminalStartupCwdMissingDirFallback } from '../../../../shared/terminal-startup-cwd' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { Store } from '../../../persistence' +import type { PtySpawnResult } from '../../../providers/types' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import type { CodexSessionResumePreparation } from '../../../codex/codex-session-resume-home' +import type { + CodexHomePtySpawnedLifecycleArgs, + GetSelectedCodexHomePath, + PrepareClaudeAuth, + PrepareCodexSessionResume +} from '../host-env/types' +import type { CodexResumeLaunch, PreparedCodexResumeHome } from '../host-env/codex-resume' +import type { StablePaneOwner } from '../pane/stable-owner' + +export type PtySpawnIpcArgs = { + cols: number + rows: number + cwd?: string + // Why: fresh local spawns opt into recovering a saved cwd whose dir was deleted (#7239); reattach/remote need exact cwd, so the flag alone isn't sufficient. + cwdFallback?: 'worktree' + env?: Record + envToDelete?: string[] + command?: string + commandDelivery?: 'renderer' | 'provider' + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: unknown + launchAgent?: TuiAgent + startupCommandDelivery?: StartupCommandDelivery + connectionId?: string | null + worktreeId?: string + sessionId?: string + shellOverride?: string + projectRuntime?: ProjectExecutionRuntimeResolution + terminalColorQueryReplies?: { + foreground?: unknown + background?: unknown + } + // Why: hidden-at-spawn declaration (terminal-query-authority.md §races) — main marks hidden before byte zero so the gate owns spawn-time queries. + initiallyHidden?: boolean + // Why: closes the SIGKILL race (INVESTIGATION.md) by letting main sync-flush the binding before pty:spawn returns; only the Ctrl+T daemon-host path threads these. + tabId?: string + leafId?: string + // Why: renderer-threaded launch telemetry (telemetry-plan.md§Agent launch semantics); loosely typed because the main-side schema validator is the single enforcement point. + telemetry?: { + agent_kind?: unknown + launch_source?: unknown + request_kind?: unknown + } +} + +export type AdoptStablePaneArgs = { + cols: number + rows: number + cwd?: string + connectionId?: string | null + worktreeId: string + preAllocatedHandle?: string + tabId: string + leafId: string + ownsPaneSpawnReservation?: true +} + +export type AdoptStablePaneResult = { + result: PtySpawnResult + owner: StablePaneOwner + materialized?: true +} + +export type PtySpawnIpcDeps = { + runtime?: OrcaRuntimeService + store?: Store + getSettings?: () => GlobalSettings + getSelectedCodexHomePath?: GetSelectedCodexHomePath + prepareClaudeAuth?: PrepareClaudeAuth + options?: { + prepareCodexSessionResume?: PrepareCodexSessionResume + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + } + getLocalPtyStartupPromise: (connectionId?: string | null) => Promise | undefined + adoptStablePane: (args: AdoptStablePaneArgs) => Promise + assertFolderWorkspacePtyPathUsable: (worktreeId: string | undefined) => Promise | void + resolvePtySpawnStartupCwd: ( + worktreeId: string | undefined, + cwd: string | undefined, + missingDirFallback?: TerminalStartupCwdMissingDirFallback + ) => string | undefined + localStartupCwdDirectoryExists: (path: string) => boolean + prepareCodexResumeHome: (args: { + connectionId?: string | null + launchAgent?: TuiAgent + providerSession?: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string + }) => PreparedCodexResumeHome | null + noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch + resolveCodexResumeLaunch: ( + command: string | undefined, + prepared: PreparedCodexResumeHome + ) => Promise + reconcileSharedRuntimeResumeHome: ( + resumeHome: Extract, + resolveCurrent: () => string | null + ) => string + stripSequencedStartupResumeArgv: | undefined>( + env: T, + launch: CodexResumeLaunch + ) => T + transitionSpawnHiddenRendererPtyDeliveryState: (id: string, hidden: boolean) => void + trustedTerminalHandleEnv: Set + sendPtySpawnedToRenderer: (id: string) => void + syncPtyBackgroundedDelivery: (id: string, caller: string) => void +} diff --git a/src/main/ipc/pty/ipc/spawn.ts b/src/main/ipc/pty/ipc/spawn.ts new file mode 100644 index 00000000000..6fd5183a36d --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn.ts @@ -0,0 +1,16 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import { runPtyIpcSpawn } from './spawn-run' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { + const ipcMain = getPtyIpc() + const { getLocalPtyStartupPromise } = deps + + ipcMain.handle('pty:spawn', async (_event, args: PtySpawnIpcArgs) => { + const startupPromise = getLocalPtyStartupPromise(args.connectionId) + if (startupPromise) { + await startupPromise + } + return runPtyIpcSpawn(deps, args) + }) +} diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts new file mode 100644 index 00000000000..fbfc09d1d4d --- /dev/null +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -0,0 +1,204 @@ +import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { IPtyProvider } from '../../../providers/types' +import { isPtyWriteUnavailableError } from '../../../providers/pty-write-unavailable-error' +import { + isTerminalInputTooLargeWithDeferredMeasurement, + iterateTerminalInputChunks +} from '../../../../shared/terminal-input' +import { ptyOwnership } from '../provider/ownership-state' +import { tryGetProviderForPty } from '../provider/registry' +import { + interactiveOutputCharsByPty, + lastInputAtByPty, + visibleRendererPtys +} from '../delivery/visibility-state' + +export function isMainWindowPtyIpcEvent( + event: IpcMainEvent | IpcMainInvokeEvent, + mainWindow: BrowserWindow, + mainWebContents: WebContents +): boolean { + return ( + event.sender === mainWebContents && + !mainWindow.isDestroyed() && + !(typeof mainWebContents.isDestroyed === 'function' && mainWebContents.isDestroyed()) + ) +} + +export type PtyWritePayload = { id: string; data: string } +export type PtyViewportClaimPayload = { id: string; cols: number; rows: number } + +export function createPtyWriteInput(deps: { + mainWindow: BrowserWindow + runtime?: OrcaRuntimeService + clearHiddenRendererResizeOutput: (id: string) => void +}): { + writePtyInput: (args: PtyWritePayload) => boolean | Promise + writePtyInputAccepted: (args: PtyWritePayload) => boolean | Promise + isPtyWritePayload: (value: unknown) => value is PtyWritePayload + isPtyViewportClaimPayload: (value: unknown) => value is PtyViewportClaimPayload + isPtyWriteEventFromMainWindow: ( + event: IpcMainEvent | IpcMainInvokeEvent, + mainWebContents: WebContents + ) => boolean +} { + const { mainWindow, runtime, clearHiddenRendererResizeOutput } = deps + + const reportUnavailablePtyWrite = (id: string, error: unknown): void => { + if ( + !isPtyWriteUnavailableError(error) || + mainWindow.isDestroyed() || + (typeof mainWindow.webContents.isDestroyed === 'function' && + mainWindow.webContents.isDestroyed()) + ) { + return + } + mainWindow.webContents.send('pty:writeUnavailable', { id }) + } + + const writePtyProviderInputWithinLimit = ( + provider: IPtyProvider, + id: string, + data: string + ): boolean | Promise => { + const chunks = iterateTerminalInputChunks(data) + const first = chunks.next() + if (first.done) { + provider.write(id, data) + return true + } + const second = chunks.next() + if (second.done) { + provider.write(id, first.value) + return true + } + return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value) + } + + const writePtyProviderInput = ( + provider: IPtyProvider, + id: string, + data: string + ): boolean | Promise => { + try { + const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) + if (typeof tooLarge === 'boolean') { + return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data) + } + return tooLarge + .then((result) => (result ? false : writePtyProviderInputWithinLimit(provider, id, data))) + .catch((error) => { + reportUnavailablePtyWrite(id, error) + return false + }) + } catch (error) { + reportUnavailablePtyWrite(id, error) + return false + } + } + + const writePtyProviderInputChunks = async ( + provider: IPtyProvider, + id: string, + chunks: Iterator, + firstChunk: string, + secondChunk: string + ): Promise => { + try { + let chunk: IteratorResult = { done: false, value: firstChunk } + let nextChunk: IteratorResult = { done: false, value: secondChunk } + while (!chunk.done) { + provider.write(id, chunk.value) + if (!nextChunk.done) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + chunk = nextChunk + nextChunk = chunks.next() + } + return true + } catch (error) { + reportUnavailablePtyWrite(id, error) + return false + } + } + + const isPtyWritePayload = (value: unknown): value is PtyWritePayload => + typeof value === 'object' && + value !== null && + typeof (value as { id?: unknown }).id === 'string' && + (value as { id: string }).id.length > 0 && + typeof (value as { data?: unknown }).data === 'string' + + const isPtyViewportClaimPayload = (value: unknown): value is PtyViewportClaimPayload => + typeof value === 'object' && + value !== null && + typeof (value as { id?: unknown }).id === 'string' && + (value as { id: string }).id.length > 0 && + typeof (value as { cols?: unknown }).cols === 'number' && + Number.isFinite((value as { cols: number }).cols) && + typeof (value as { rows?: unknown }).rows === 'number' && + Number.isFinite((value as { rows: number }).rows) && + (value as { cols: number }).cols > 0 && + (value as { rows: number }).rows > 0 + + const isPtyWriteEventFromMainWindow = ( + event: IpcMainEvent | IpcMainInvokeEvent, + mainWebContents: WebContents + ): boolean => isMainWindowPtyIpcEvent(event, mainWindow, mainWebContents) + + const writePtyInput = (args: PtyWritePayload): boolean | Promise => { + // Why: mobile-presence-lock defense-in-depth — the renderer's onData guard can let one keystroke slip during the state-flip lag, so catch it server-side. See docs/mobile-presence-lock.md. + if (runtime?.getDriver(args.id).kind === 'mobile') { + return false + } + const provider = ptyOwnership.has(args.id) ? tryGetProviderForPty(args.id) : undefined + if (!provider) { + return false + } + try { + const now = performance.now() + lastInputAtByPty.set(args.id, now) + interactiveOutputCharsByPty.set(args.id, 0) + if (visibleRendererPtys.has(args.id)) { + clearHiddenRendererResizeOutput(args.id) + } + return writePtyProviderInput(provider, args.id, args.data) + } catch { + return false + } + } + + const writePtyInputAccepted = (args: PtyWritePayload): boolean | Promise => { + if (runtime?.getDriver(args.id).kind === 'mobile') { + return false + } + // Why: the ack infers Ctrl+C/Escape reached the local PTY; SSH providers are fire-and-forget relay notifications and can't truthfully acknowledge yet. + if (ptyOwnership.get(args.id) !== null) { + return false + } + const provider = tryGetProviderForPty(args.id) + if (!provider?.hasPty?.(args.id)) { + return false + } + try { + const now = performance.now() + lastInputAtByPty.set(args.id, now) + interactiveOutputCharsByPty.set(args.id, 0) + if (visibleRendererPtys.has(args.id)) { + clearHiddenRendererResizeOutput(args.id) + } + return writePtyProviderInput(provider, args.id, args.data) + } catch { + return false + } + } + + return { + writePtyInput, + writePtyInputAccepted, + isPtyWritePayload, + isPtyViewportClaimPayload, + isPtyWriteEventFromMainWindow + } +} diff --git a/src/main/ipc/pty/ipc/write.ts b/src/main/ipc/pty/ipc/write.ts new file mode 100644 index 00000000000..417f2daed70 --- /dev/null +++ b/src/main/ipc/pty/ipc/write.ts @@ -0,0 +1,74 @@ +import type { BrowserWindow } from 'electron' +import { getPtyIpc } from '../../pty-host-bindings' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { createPtyWriteInput } from './write-input' + +export function installPtyWriteIpcHandlers(deps: { + mainWindow: BrowserWindow + runtime?: OrcaRuntimeService + clearHiddenRendererResizeOutput: (id: string) => void +}): void { + const ipcMain = getPtyIpc() + const { mainWindow, runtime } = deps + const { + writePtyInput, + writePtyInputAccepted, + isPtyWritePayload, + isPtyViewportClaimPayload, + isPtyWriteEventFromMainWindow + } = createPtyWriteInput(deps) + + const hostViewportClaimTails = new Map>() + + ipcMain.on('pty:write', (event, args: unknown) => { + if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { + return + } + const claimTail = hostViewportClaimTails.get(args.id) + if (claimTail) { + void claimTail.then((claimed) => (claimed ? writePtyInput(args) : false)) + return + } + writePtyInput(args) + }) + ipcMain.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise => { + if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { + return false + } + const claimTail = hostViewportClaimTails.get(args.id) + return claimTail + ? claimTail.then((claimed) => (claimed ? writePtyInputAccepted(args) : false)) + : writePtyInputAccepted(args) + }) + + ipcMain.removeAllListeners('pty:claimViewport') + ipcMain.on('pty:claimViewport', (event, args: unknown) => { + if ( + !isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || + !runtime || + !isPtyViewportClaimPayload(args) + ) { + return + } + const prior = hostViewportClaimTails.get(args.id) + // Why: two panes can mirror one PTY — never let a later no-op claim replace the in-flight resize that the following host input must await. + const claim = ( + prior + ? prior.then( + () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows), + () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) + ) + : runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) + ).catch((error) => { + // Why: a failed claim silently discards every gated keystroke for this pane. + console.error('[pty] remote desktop host claim failed; gated input will be discarded', error) + return false + }) + hostViewportClaimTails.set(args.id, claim) + void claim.then(() => { + if (hostViewportClaimTails.get(args.id) === claim) { + hostViewportClaimTails.delete(args.id) + } + }) + }) +} diff --git a/src/main/ipc/pty/kill-all.ts b/src/main/ipc/pty/kill-all.ts new file mode 100644 index 00000000000..307bfaeabb2 --- /dev/null +++ b/src/main/ipc/pty/kill-all.ts @@ -0,0 +1,11 @@ +import { LocalPtyProvider } from '../../providers/local-pty-provider' +import { localProvider } from './provider/registry' + +/** + * Kill in-process local PTYs. Daemon-backed PTYs are preserved by daemon disconnect. + */ +export function killAllPty(): void { + if (localProvider instanceof LocalPtyProvider) { + localProvider.killAll() + } +} diff --git a/src/main/ipc/pty/pane/adopt-stable.ts b/src/main/ipc/pty/pane/adopt-stable.ts new file mode 100644 index 00000000000..7c1a7d5c818 --- /dev/null +++ b/src/main/ipc/pty/pane/adopt-stable.ts @@ -0,0 +1,86 @@ +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { Store } from '../../../persistence' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { getProvider } from '../provider/registry' +import { makePaneSpawnReservationKey, paneSpawnReservationsByOwnerKey } from './spawn-reservation' +import { + attachStablePaneOwner, + resolveStablePaneOwner, + stablePaneAdoptionsByOwnerKey +} from './stable-owner' +import type { AdoptStablePaneArgs, AdoptStablePaneResult } from '../ipc/spawn-types' + +export async function adoptStablePane( + runtime: OrcaRuntimeService | undefined, + store: Store | undefined, + args: AdoptStablePaneArgs +): Promise { + const paneKey = makePaneKey(args.tabId, args.leafId) + const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) + const pendingAdoption = ownerKey ? stablePaneAdoptionsByOwnerKey.get(ownerKey) : undefined + if (pendingAdoption) { + return await pendingAdoption + } + const activePaneSpawn = + ownerKey && !args.ownsPaneSpawnReservation + ? paneSpawnReservationsByOwnerKey.get(ownerKey) + : undefined + if (activePaneSpawn) { + const result = await activePaneSpawn.promise + const owner = resolveStablePaneOwner( + runtime, + store, + paneKey, + args.worktreeId, + args.connectionId + ) + if ( + !owner || + owner.ptyId !== result.id || + (owner.incarnationId !== undefined && + result.incarnationId !== undefined && + owner.incarnationId !== result.incarnationId) + ) { + throw new Error('terminal_pane_owner_changed') + } + return { + result: { + ...result, + isReattach: true, + ...(owner.incarnationId ? { incarnationId: owner.incarnationId } : {}) + }, + owner, + materialized: true as const + } + } + const owner = resolveStablePaneOwner(runtime, store, paneKey, args.worktreeId, args.connectionId) + if (!owner) { + return null + } + const adoption = attachStablePaneOwner({ + runtime, + store, + provider: getProvider(args.connectionId), + spawnOptions: { + cols: args.cols, + rows: args.rows, + cwd: args.cwd + }, + owner, + worktreeId: args.worktreeId, + connectionId: args.connectionId, + resolveOwner: () => + resolveStablePaneOwner(runtime, store, paneKey, args.worktreeId, args.connectionId) + }) + if (!ownerKey) { + return await adoption + } + stablePaneAdoptionsByOwnerKey.set(ownerKey, adoption) + try { + return await adoption + } finally { + if (stablePaneAdoptionsByOwnerKey.get(ownerKey) === adoption) { + stablePaneAdoptionsByOwnerKey.delete(ownerKey) + } + } +} diff --git a/src/main/ipc/pty/pane/agent-session-owners.ts b/src/main/ipc/pty/pane/agent-session-owners.ts new file mode 100644 index 00000000000..291eaab6462 --- /dev/null +++ b/src/main/ipc/pty/pane/agent-session-owners.ts @@ -0,0 +1,126 @@ +import type { AgentSessionOwnerBinding } from '../../../../shared/agent-session-host-authority' +import { ClaimedAgentPtyOwnerRegistry } from '../../../../shared/claimed-agent-pty-owner' +import { isPtyIncarnationId } from '../../../../shared/pty-incarnation' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { IPtyProvider, PtySpawnResult } from '../../../providers/types' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' +import { + localProvider, + sshProviders, + tryGetProviderForAgentSessionOwner +} from '../provider/registry' + +// Why: one main process can route the same remote provider namespace through +// multiple SSH relays; coordinate claims above every provider boundary too. +export const agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() +let agentSessionOwnerReconciliation: Promise | null = null +// Why: this reconcile gates spawn; a wedged relay listing must fail closed (rejecting +// with unknown ownership) instead of blocking the spawn path indefinitely. +const OWNER_LISTING_DEADLINE_MS = 5_000 + +// Why: restore payloads (reattach snapshot / cold-restore scrollback / relay +// replay + lastTitle) ride spawn RPC results, never onPtyData, so EVERY spawn +// choke point — renderer pty:spawn and the runtime controller — must seed the +// terminal list/read records or headless/CLI-created reattaches stay blank. +// The runtime's empty-record guard makes a second seed for the same session a +// no-op, so overlapping paths cannot double-apply history. +export function seedTerminalRestoreRecordsFromSpawnResult( + runtime: OrcaRuntimeService | undefined, + result: PtySpawnResult +): void { + const text = + typeof result.snapshot === 'string' && result.snapshot.length > 0 + ? result.snapshot + : typeof result.coldRestore?.scrollback === 'string' && + result.coldRestore.scrollback.length > 0 + ? result.coldRestore.scrollback + : typeof result.replay === 'string' && result.replay.length > 0 + ? result.replay + : undefined + const lastTitle = + typeof result.lastTitle === 'string' && result.lastTitle.length > 0 + ? result.lastTitle + : typeof result.coldRestore?.lastTitle === 'string' && result.coldRestore.lastTitle.length > 0 + ? result.coldRestore.lastTitle + : undefined + if (text !== undefined || lastTitle !== undefined) { + runtime?.seedTerminalRestoreTail?.(result.id, { + ...(text !== undefined ? { text } : {}), + ...(lastTitle !== undefined ? { lastTitle } : {}) + }) + } +} + +export function assertSpawnReplyWasLive(result: PtySpawnResult): void { + if (!result.exitedBeforeSpawnReply) { + return + } + // Why: lower owners can resolve a different canonical id, so controller-local pending ids cannot prove this exit. + throw Object.assign(new Error('agent_session_exited_during_start'), { + agentSessionOperationOutcome: 'unknown' as const + }) +} + +export async function reconcileAgentSessionOwnerListings(): Promise { + if (agentSessionOwnerReconciliation) { + return await agentSessionOwnerReconciliation + } + const reconciliation = (async () => { + const providers: { provider: IPtyProvider; connectionId: string | null }[] = [ + { provider: localProvider, connectionId: null }, + ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) + ] + const deadlineMs = Date.now() + OWNER_LISTING_DEADLINE_MS + const listings = await Promise.all( + providers.map(async ({ provider, connectionId }) => ({ + connectionId, + sessions: await provider.listProcesses({ deadlineMs }).catch(() => { + throw new Error('agent_session_ownership_unknown') + }) + })) + ) + const advertisedOwners: AgentSessionOwnerBinding[] = [] + const advertisedOwnerSessions: { + id: string + connectionId: string | null + incarnationId: string + }[] = [] + for (const { connectionId, sessions } of listings) { + for (const session of sessions) { + const incarnationId = session.incarnationId + let hasAdvertisedOwner = false + for (const owner of session.agentSessionOwners ?? []) { + if (owner.ptyId !== session.id || !isPtyIncarnationId(incarnationId)) { + // Why: a recovered claim without process-incarnation proof cannot safely reject a delayed exit. + throw new Error('agent_session_ownership_unknown') + } + advertisedOwners.push(owner) + hasAdvertisedOwner = true + } + if (hasAdvertisedOwner && isPtyIncarnationId(incarnationId)) { + advertisedOwnerSessions.push({ id: session.id, connectionId, incarnationId }) + } + } + } + agentSessionOwners.reconcileAuthoritative(advertisedOwners, { + // Why: an unregistered relay can still own a live PTY during reconnect; + // only providers that serialize claims may make listing absence authoritative. + isInAuthoritativeScope: (owner) => { + const provider = tryGetProviderForAgentSessionOwner(owner.ptyId) + return provider?.providesAgentSessionOwnerListings?.(owner.ptyId) === true + } + }) + for (const session of advertisedOwnerSessions) { + ptyOwnership.set(session.id, session.connectionId) + ptyIncarnationById.set(session.id, session.incarnationId) + } + })() + agentSessionOwnerReconciliation = reconciliation + try { + await reconciliation + } finally { + if (agentSessionOwnerReconciliation === reconciliation) { + agentSessionOwnerReconciliation = null + } + } +} diff --git a/src/main/ipc/pty/pane/key-state.ts b/src/main/ipc/pty/pane/key-state.ts new file mode 100644 index 00000000000..0a7881140b6 --- /dev/null +++ b/src/main/ipc/pty/pane/key-state.ts @@ -0,0 +1,47 @@ +import { parsePaneKey } from '../../../../shared/stable-pane-id' + +// Why: track spawn-time paneKey so teardown can clear the agent-hooks server's per-paneKey cache, which otherwise grows unbounded as panes come and go. +export const ptyPaneKey = new Map() +// Why: reverse of ptyPaneKey — callers with a paneKey from outside the PTY lifecycle (e.g. agent-hook status routing) need the ptyId; kept in lock-step via the same sites. +export const paneKeyPtyId = new Map() + +export function getPtyIdForPaneKey(paneKey: string): string | undefined { + return paneKeyPtyId.get(paneKey) +} + +// Why: let consumers tear down paneKey-scoped state on PTY exit so their timers can't leak; a callback registry keeps the cross-module dependency narrow. +export type PaneKeyTeardownListener = (paneKey: string) => void +export const paneKeyTeardownListeners = new Set() + +export function registerPaneKeyTeardownListener(listener: PaneKeyTeardownListener): () => void { + paneKeyTeardownListeners.add(listener) + return () => paneKeyTeardownListeners.delete(listener) +} + +export function parseValidPaneKey(paneKey: unknown): ReturnType { + if (typeof paneKey !== 'string' || paneKey.length > 256) { + return null + } + return parsePaneKey(paneKey) +} + +export function isValidPaneKey(paneKey: unknown): paneKey is string { + return parseValidPaneKey(paneKey) !== null +} + +export function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null { + const normalizedPaneKey = typeof paneKey === 'string' ? paneKey.trim() : '' + if (!isValidPaneKey(normalizedPaneKey)) { + return null + } + // Why: a re-registered ptyId would otherwise leave its old paneKey pointing here. + const previousPaneKey = ptyPaneKey.get(ptyId) + if (previousPaneKey && previousPaneKey !== normalizedPaneKey) { + if (paneKeyPtyId.get(previousPaneKey) === ptyId) { + paneKeyPtyId.delete(previousPaneKey) + } + } + ptyPaneKey.set(ptyId, normalizedPaneKey) + paneKeyPtyId.set(normalizedPaneKey, ptyId) + return normalizedPaneKey +} diff --git a/src/main/ipc/pty/pane/launch-authority.ts b/src/main/ipc/pty/pane/launch-authority.ts new file mode 100644 index 00000000000..93f3061f0b3 --- /dev/null +++ b/src/main/ipc/pty/pane/launch-authority.ts @@ -0,0 +1,41 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import { isPtyIncarnationId } from '../../../../shared/pty-incarnation' + +const AGENT_LAUNCH_TOKEN_MAX_LENGTH = 128 + +export function admitRendererAgentLaunchAuthority(args: { + launchToken: unknown + spawnEnv: Record | undefined + launchAgent: unknown + launchConfig: SleepingAgentLaunchConfig | undefined + isReattach: boolean + hasStablePaneOwner: boolean + incarnationId: unknown +}): { launchToken: string; launchAgent: TuiAgent } | null { + if ( + args.isReattach || + args.hasStablePaneOwner || + !args.launchConfig || + !isTuiAgent(args.launchAgent) || + !isPtyIncarnationId(args.incarnationId) || + typeof args.launchToken !== 'string' || + args.launchToken.length === 0 || + args.launchToken.length > AGENT_LAUNCH_TOKEN_MAX_LENGTH || + args.spawnEnv?.ORCA_AGENT_LAUNCH_TOKEN !== args.launchToken + ) { + return null + } + return { launchToken: args.launchToken, launchAgent: args.launchAgent } +} + +export function shouldRefreshNativeClaudeAgentTeamsEnv(args: { + command?: string + launchConfig?: SleepingAgentLaunchConfig +}): boolean { + const capturedCommand = args.launchConfig?.agentCommand?.trim() || args.command?.trim() || '' + const capturedArgs = args.launchConfig?.agentArgs?.trim() ?? '' + const capturedLaunch = `${capturedCommand} ${capturedArgs}`.trim() + return /(^|\s)--teammate-mode(?:=|\s+)auto(?:\s|$)/.test(capturedLaunch) +} diff --git a/src/main/ipc/pty/pane/serializer-state.ts b/src/main/ipc/pty/pane/serializer-state.ts new file mode 100644 index 00000000000..ec3f8dacbc4 --- /dev/null +++ b/src/main/ipc/pty/pane/serializer-state.ts @@ -0,0 +1,71 @@ +import type { WebContents } from 'electron' +import { RendererTerminalSerializerReadiness } from '../../renderer-terminal-serializer-readiness' +import { isValidPaneKey, paneKeyPtyId } from './key-state' + +// Why: renderer pre-declares serializer ownership before pty:spawn to suppress the daemon-snapshot seed; gen tokens prevent paneKey-reuse races on teardown. See docs/mobile-prefer-renderer-scrollback.md. +export let pendingSerializerGenSeq = 0 +export const pendingByPaneKey = new Map< + string, + { gen: number; ownerWebContentsId: number | null } +>() +export const pendingPaneSerializerCleanupRegistered = new Set() +// Why: bind the declaration generation directly to its spawn result. PTY ids +// are reusable and teardown callbacks carry no incarnation token, so teardown +// must never guess which pending renderer generation it owns. +export const pendingPtyIdBySerializerGeneration = new Map() +// Why: hasRendererSerializer probe needs a ptyId-keyed signal; a later spawn starts a fresh incarnation, subscription abort owns waiter cleanup. +export const rendererSerializerReadiness = new RendererTerminalSerializerReadiness() + +export function cleanupPendingPaneSerializersForSender(ownerWebContentsId: number): void { + pendingPaneSerializerCleanupRegistered.delete(ownerWebContentsId) + for (const [paneKey, pending] of pendingByPaneKey) { + if (pending.ownerWebContentsId === ownerWebContentsId) { + pendingByPaneKey.delete(paneKey) + pendingPtyIdBySerializerGeneration.delete(pending.gen) + } + } +} + +export function registerPendingPaneSerializerCleanup(sender: WebContents | undefined): void { + if (!sender || pendingPaneSerializerCleanupRegistered.has(sender.id)) { + return + } + pendingPaneSerializerCleanupRegistered.add(sender.id) + sender.once('destroyed', () => cleanupPendingPaneSerializersForSender(sender.id)) +} + +export function declarePendingPaneSerializer( + paneKey: string, + sender: WebContents | undefined +): number { + const gen = ++pendingSerializerGenSeq + // Why: a sender destroyed before the declaration never fires 'destroyed' again, + // so its entry would strand in pendingByPaneKey and suppress the daemon seed forever. + if (sender?.isDestroyed()) { + cleanupPendingPaneSerializersForSender(sender.id) + return gen + } + registerPendingPaneSerializerCleanup(sender) + const replaced = pendingByPaneKey.get(paneKey) + if (replaced) { + pendingPtyIdBySerializerGeneration.delete(replaced.gen) + } + pendingByPaneKey.set(paneKey, { gen, ownerWebContentsId: sender?.id ?? null }) + const existingPtyId = paneKeyPtyId.get(paneKey) + if (existingPtyId) { + pendingPtyIdBySerializerGeneration.set(gen, existingPtyId) + } + return gen +} + +export function settlePendingPaneSerializer(paneKey: string, gen: number): boolean { + if (pendingByPaneKey.get(paneKey)?.gen !== gen) { + return false + } + pendingByPaneKey.delete(paneKey) + return true +} + +export function hasPendingRendererSerializerForPaneKey(paneKey: string): boolean { + return isValidPaneKey(paneKey) && pendingByPaneKey.has(paneKey) +} diff --git a/src/main/ipc/pty/pane/spawn-reservation.ts b/src/main/ipc/pty/pane/spawn-reservation.ts new file mode 100644 index 00000000000..47c968b9b5a --- /dev/null +++ b/src/main/ipc/pty/pane/spawn-reservation.ts @@ -0,0 +1,115 @@ +import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { PtySpawnResult } from '../../../providers/types' + +export type PaneSpawnReservation = { + promise: Promise + resolve: (result: PaneSpawnReservationResult) => void + reject: (error: unknown) => void +} +export type PaneSpawnReservationResult = { + id: string + launchConfig?: SleepingAgentLaunchConfig + stablePaneOwner?: { + handle: string + tabId: string + leafId: string + } +} & Partial + +// Why: identical pane coordinates on different worktrees or hosts are independent owners. +export const paneSpawnReservationsByOwnerKey = new Map() +export type PendingRuntimePaneCreate = { + count: number + promise: Promise + resolve: () => void +} +export const pendingRuntimePaneCreatesByOwnerKey = new Map() + +export function reservePaneSpawn(paneKey: string): PaneSpawnReservation { + let resolve!: (result: PaneSpawnReservationResult) => void + let reject!: (error: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + promise.catch(() => {}) + const reservation = { promise, resolve, reject } + paneSpawnReservationsByOwnerKey.set(paneKey, reservation) + return reservation +} + +export function clearPaneSpawnReservation( + paneKey: string, + reservation: PaneSpawnReservation +): void { + if (paneSpawnReservationsByOwnerKey.get(paneKey) === reservation) { + paneSpawnReservationsByOwnerKey.delete(paneKey) + } +} + +export function makePaneSpawnReservationKey( + worktreeId: string | undefined, + connectionId: string | null | undefined, + paneKey: string | null | undefined +): string | null { + return paneKey ? JSON.stringify([connectionId ?? null, worktreeId ?? null, paneKey]) : null +} + +export function claimRuntimePaneCreate(ownerKey: string): () => void { + const existing = pendingRuntimePaneCreatesByOwnerKey.get(ownerKey) + if (existing) { + existing.count += 1 + return () => releaseRuntimePaneCreate(ownerKey, existing) + } + let resolve!: () => void + const claim = { + count: 1, + promise: new Promise((done) => { + resolve = done + }), + resolve: () => resolve() + } + pendingRuntimePaneCreatesByOwnerKey.set(ownerKey, claim) + return () => releaseRuntimePaneCreate(ownerKey, claim) +} + +export function releaseRuntimePaneCreate(ownerKey: string, claim: PendingRuntimePaneCreate): void { + if (pendingRuntimePaneCreatesByOwnerKey.get(ownerKey) !== claim) { + return + } + claim.count -= 1 + if (claim.count > 0) { + return + } + pendingRuntimePaneCreatesByOwnerKey.delete(ownerKey) + claim.resolve() +} + +export function rejectPaneSpawnReservation( + paneKey: string | null | undefined, + reservation: PaneSpawnReservation | null | undefined, + error: unknown +): void { + if (!reservation) { + return + } + reservation.reject(error) + if (paneKey) { + clearPaneSpawnReservation(paneKey, reservation) + } +} + +export function resolvePaneSpawnReservation( + paneKey: string | null | undefined, + reservation: PaneSpawnReservation | null | undefined, + response: T +): T { + if (!reservation) { + return response + } + reservation.resolve(response) + if (paneKey) { + clearPaneSpawnReservation(paneKey, reservation) + } + return response +} diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts new file mode 100644 index 00000000000..9442e914e1f --- /dev/null +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -0,0 +1,293 @@ +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import type { Store } from '../../../persistence' +import { retireTerminalSurfaceFromPersistence } from '../../../runtime/mobile-session-terminal-persistence-retirement' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types' +import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' +import { + isDaemonEndpointGoneError, + TerminalHostGoneError, + TerminalSessionOwnerUnverifiedError +} from '../../../daemon/daemon-errors' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' +import { isPtyAlreadyGoneError } from '../provider/liveness' +import { clearProviderPtyState } from '../provider/state-cleanup' + +export type StablePaneOwner = { + handle?: string + tabId: string + leafId: string + ptyId: string + incarnationId?: string + hasPersistedBinding?: true + persistedIncarnationId?: string + runtimeIncarnationId?: string +} +export type StablePaneAdoption = { + result: PtySpawnResult + owner: StablePaneOwner + materialized?: true +} | null +export const stablePaneAdoptionsByOwnerKey = new Map>() + +export function resolvePersistedStablePaneOwner( + store: Store | undefined, + paneKey: string, + worktreeId: string, + connectionId: string | null | undefined +): Pick | null { + if (!store || typeof store.getWorkspaceSession !== 'function') { + return null + } + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return null + } + const session = store.getWorkspaceSession( + connectionId ? toSshExecutionHostId(connectionId) : undefined + ) + const tab = session.tabsByWorktree?.[worktreeId]?.find( + (candidate) => candidate.id === parsed.tabId && candidate.worktreeId === worktreeId + ) + const ptyId = session.terminalLayoutsByTabId?.[parsed.tabId]?.ptyIdsByLeafId?.[parsed.leafId] + if (!tab || typeof ptyId !== 'string' || ptyId.length === 0) { + return null + } + const incarnationId = session.terminalPtyIncarnationsByPaneKey?.[paneKey] + return { + tabId: parsed.tabId, + leafId: parsed.leafId, + ptyId, + ...(incarnationId ? { incarnationId } : {}) + } +} + +export function resolveStablePaneOwner( + runtime: OrcaRuntimeService | undefined, + store: Store | undefined, + paneKey: string | null | undefined, + worktreeId: string | undefined, + connectionId: string | null | undefined +): StablePaneOwner | null { + if (!paneKey || !worktreeId) { + return null + } + let resolved: ReturnType | null = null + let resolvedHandleCandidate: ReturnType | null = null + if (runtime && typeof runtime.resolveTerminalPane === 'function') { + try { + const candidate = runtime.resolveTerminalPane(paneKey, worktreeId) + resolvedHandleCandidate = candidate + resolved = candidate.connected === false ? null : candidate + } catch (error) { + if (!(error instanceof Error && error.message === 'terminal_not_found')) { + throw error + } + } + } + const persisted = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) + if (resolved?.ptyId && persisted && resolved.ptyId !== persisted.ptyId) { + throw new Error('terminal_pane_owner_conflict') + } + const ptyId = resolved?.ptyId ?? persisted?.ptyId + if (!ptyId) { + return null + } + const registeredConnectionId = ptyOwnership.get(ptyId) + const parsedSshId = registeredConnectionId === undefined ? parseAppSshPtyId(ptyId) : null + const ownerConnectionId = registeredConnectionId ?? parsedSshId?.connectionId ?? null + if (ownerConnectionId !== (connectionId ?? null)) { + throw new Error('terminal_pane_owner_host_mismatch') + } + const runtimeIncarnationId = ptyIncarnationById.get(ptyId) + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return null + } + return { + ...(resolvedHandleCandidate?.ptyId === ptyId ? { handle: resolvedHandleCandidate.handle } : {}), + tabId: resolved?.tabId || persisted?.tabId || parsed.tabId, + leafId: resolved?.leafId || persisted?.leafId || parsed.leafId, + ptyId, + ...(runtimeIncarnationId || persisted?.incarnationId + ? { incarnationId: runtimeIncarnationId ?? persisted?.incarnationId } + : {}), + ...(persisted ? { hasPersistedBinding: true as const } : {}), + ...(persisted?.incarnationId ? { persistedIncarnationId: persisted.incarnationId } : {}), + ...(runtimeIncarnationId ? { runtimeIncarnationId } : {}) + } +} + +export function retirePersistedStablePaneOwner( + store: Store | undefined, + owner: StablePaneOwner, + worktreeId: string, + connectionId: string | null | undefined +): boolean { + if (!store) { + return false + } + const paneKey = makePaneKey(owner.tabId, owner.leafId) + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) + if (!current) { + // Why: persistence already dropped this pane binding (an earlier stop retired it while the + // runtime kept history), so there is nothing left to clear — that is a completed retirement, + // not a competing owner. Reporting failure here strands the pane after its PTY is proven dead. + return true + } + if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { + return false + } + const session = store.getWorkspaceSession(hostId) + const retired = retireTerminalSurfaceFromPersistence(session, { + worktreeId, + parentTabId: owner.tabId, + leafId: owner.leafId, + ptyId: owner.ptyId, + ...(current.incarnationId ? { incarnationId: current.incarnationId } : {}) + }) + if (retired === session) { + return false + } + store.setWorkspaceSession(retired, hostId) + store.flushOrThrow() + return true +} + +export type StablePaneSpawnContext = { + runtime: OrcaRuntimeService | undefined + store?: Store + provider: IPtyProvider + spawnOptions: PtySpawnOptions + owner: StablePaneOwner | null + worktreeId?: string + connectionId?: string | null + resolveOwner?: () => StablePaneOwner | null + onFreshSpawn?: (result: PtySpawnResult) => void +} + +export function stablePanePersistenceFence( + owner: StablePaneOwner | null +): { ptyId: string; incarnationId?: string } | undefined { + return owner?.hasPersistedBinding + ? { + ptyId: owner.ptyId, + ...(owner.persistedIncarnationId ? { incarnationId: owner.persistedIncarnationId } : {}) + } + : undefined +} + +export function persistAdmittedStablePaneBinding(args: { + store: Store | undefined + owner: StablePaneOwner | null + result: PtySpawnResult + worktreeId: string | undefined + startupCwd: string | undefined + connectionId: string | null | undefined +}): boolean { + const expectedBinding = stablePanePersistenceFence(args.owner) + if (!args.store || !args.owner || !args.worktreeId || !expectedBinding) { + return false + } + const persisted = args.store.persistPtyBinding( + { + worktreeId: args.worktreeId, + tabId: args.owner.tabId, + leafId: args.owner.leafId, + ptyId: args.result.id, + ...(args.result.incarnationId ? { incarnationId: args.result.incarnationId } : {}), + ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), + expectedBinding + }, + args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined + ) + if (persisted === false) { + throw new Error('terminal_pane_owner_changed') + } + return true +} + +export async function attachStablePaneOwner( + args: StablePaneSpawnContext & { owner: StablePaneOwner } +): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> { + const { owner, provider, runtime, spawnOptions } = args + let result: PtySpawnResult + try { + result = await provider.spawn({ + ...spawnOptions, + sessionId: owner.ptyId, + attachOnly: true, + expectedIncarnationId: owner.runtimeIncarnationId ?? owner.persistedIncarnationId, + expectedIncarnationIsAuthoritative: owner.runtimeIncarnationId !== undefined, + isNewSession: undefined, + command: undefined, + commandDelivery: undefined, + startupCommandDelivery: undefined, + launchAgent: undefined, + startupIngress: undefined, + agentSessionEnsure: undefined, + agentSessionCreateOperationId: undefined, + onPtySpawnCommitted: undefined + }) + } catch (error) { + if (error instanceof TerminalSessionOwnerUnverifiedError) { + throw new Error('terminal_pane_owner_unverified') + } + // Why: translate before paired-runtime RPC strips the socket error's code and syscall. + if (isDaemonEndpointGoneError(error)) { + throw new TerminalHostGoneError() + } + if (!isPtyAlreadyGoneError(error)) { + throw error + } + const ownerBeforeRetire = args.resolveOwner?.() + if ( + ownerBeforeRetire && + (ownerBeforeRetire.ptyId !== owner.ptyId || + ownerBeforeRetire.runtimeIncarnationId !== owner.runtimeIncarnationId || + ownerBeforeRetire.hasPersistedBinding !== owner.hasPersistedBinding || + ownerBeforeRetire.persistedIncarnationId !== owner.persistedIncarnationId) + ) { + throw new Error('terminal_pane_owner_changed') + } + runtime?.onPtyExit(owner.ptyId, 0, owner.incarnationId) + clearProviderPtyState(owner.ptyId) + ptyOwnership.delete(owner.ptyId) + if ( + args.worktreeId && + !retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId) + ) { + throw new Error('terminal_pane_owner_changed') + } + if (args.resolveOwner?.()) { + throw new Error('terminal_pane_owner_changed') + } + return null + } + if ( + result.id !== owner.ptyId || + result.isReattach !== true || + (owner.runtimeIncarnationId !== undefined && + result.incarnationId !== owner.runtimeIncarnationId) || + (result.incarnationId === undefined && owner.incarnationId !== undefined) + ) { + throw new Error('terminal_pane_owner_changed') + } + return { result, owner } +} + +export async function spawnForStablePane( + args: StablePaneSpawnContext +): Promise<{ result: PtySpawnResult; owner: StablePaneOwner | null }> { + if (args.owner) { + const attached = await attachStablePaneOwner({ ...args, owner: args.owner }) + if (attached) { + return attached + } + } + const result = await args.provider.spawn(args.spawnOptions) + args.onFreshSpawn?.(result) + return { result, owner: null } +} diff --git a/src/main/ipc/pty/provider/bind-listeners.ts b/src/main/ipc/pty/provider/bind-listeners.ts new file mode 100644 index 00000000000..8febd1e3534 --- /dev/null +++ b/src/main/ipc/pty/provider/bind-listeners.ts @@ -0,0 +1,111 @@ +import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { isCurrentPtyExit, ptyOwnership } from './ownership-state' +import { + localBackgroundStreamUnsub, + localDataUnsub, + localExitUnsub, + localWriteUnavailableUnsub, + setLocalBackgroundStreamUnsub, + setLocalDataUnsub, + setLocalExitUnsub, + setLocalWriteUnavailableUnsub +} from './listener-lifecycle' +import { localProvider } from './registry' +import { clearProviderPtyState } from './state-cleanup' +import { providerSnapshotRequiredPtys } from '../delivery/visibility-state' +import type { PtyIpcSession } from '../session' + +export function bindProviderListeners(session: PtyIpcSession): void { + localDataUnsub?.() + localExitUnsub?.() + localBackgroundStreamUnsub?.() + localWriteUnavailableUnsub?.() + + // Why: a daemon death takes down every session at once. The provider signals + // each affected pane here so background panes remount + re-attach too, not + // just the pane whose write happened to detect the dead endpoint (STA-2373). + setLocalWriteUnavailableUnsub( + localProvider.onWriteUnavailable?.((payload) => { + if ( + session.mainWindow.isDestroyed() || + (typeof session.mainWindow.webContents.isDestroyed === 'function' && + session.mainWindow.webContents.isDestroyed()) + ) { + return + } + session.mainWindow.webContents.send('pty:writeUnavailable', { id: payload.id }) + }) ?? null + ) + + // Daemon keep-tail thinning facts, in byte order with onData: markers flip transient-fact scan authority; a gap forces renderer restore from the snapshot. + setLocalBackgroundStreamUnsub( + localProvider.onBackgroundStreamEvent?.((payload) => { + if (payload.kind === 'backgroundMarker') { + session.runtime?.setPtyTransientFactDelegation( + payload.id, + payload.background, + payload.scanSeedAnsi, + payload.mode2031PendingSubscribe + ) + return + } + if (payload.kind === 'dataGap') { + providerSnapshotRequiredPtys.add(payload.id) + session.runtime?.notePtyDataGap(payload.id, payload.sequenceChars ?? payload.droppedChars) + session.sendModelRestoreNeededMarker( + payload.id, + 'hidden-drop', + session.runtime?.getPtyOutputSequence(payload.id) + ) + return + } + session.runtime?.emitDaemonPtyTransientFact(payload.id, payload.fact) + }) ?? null + ) + + // Why: daemon providers lack configure().onData, so feed the runtime here or their tail buffer (terminal.read, agent-detection, mobile stream) stays empty. + const isLocalProvider = localProvider instanceof LocalPtyProvider + + setLocalDataUnsub( + localProvider.onData((payload) => { + const rawLength = payload.sequenceChars ?? payload.data.length + const outputSeq = isLocalProvider + ? session.runtime?.getPtyOutputSequence(payload.id) + : session.runtime?.onPtyData( + payload.id, + payload.data, + Date.now(), + rawLength, + payload.transformed + ) + session.acceptPtyDataForRenderer(payload, outputSeq) + }) + ) + setLocalExitUnsub( + localProvider.onExit((payload) => { + if (!isCurrentPtyExit(payload)) { + return + } + if (session.consumeSyntheticKillExit(payload.id)) { + return + } + if (!isLocalProvider) { + clearProviderPtyState(payload.id) + ptyOwnership.delete(payload.id) + markClaudePtyExited(payload.id) + session.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId, { + providerExitObserved: true, + ...(payload.cause ? { cause: payload.cause } : {}) + }) + } + // Why not the whole payload: the exit cause is a main-process fact for the + // runtime's records; the renderer's pty:exit contract stays as it was. + session.sendPtyExitToRenderer({ + id: payload.id, + code: payload.code, + ...(payload.incarnationId ? { incarnationId: payload.incarnationId } : {}) + }) + }) + ) +} diff --git a/src/main/ipc/pty/provider/listener-lifecycle.ts b/src/main/ipc/pty/provider/listener-lifecycle.ts new file mode 100644 index 00000000000..d6cb383c9a3 --- /dev/null +++ b/src/main/ipc/pty/provider/listener-lifecycle.ts @@ -0,0 +1,106 @@ +import type { WebContents } from 'electron' + +// Why: localProvider.onData/onExit return unsubscribe functions. Without +// storing and calling these on re-registration, macOS app re-activation +// creates a new BrowserWindow and re-calls registerPtyHandlers, leaking +// duplicate listeners that forward every event twice. +export let localDataUnsub: (() => void) | null = null +export let localExitUnsub: (() => void) | null = null +export let localBackgroundStreamUnsub: (() => void) | null = null +export let localWriteUnavailableUnsub: (() => void) | null = null +export let didFinishLoadHandler: (() => void) | null = null +export let didFinishLoadWebContents: WebContents | null = null +export let rendererLifecycleResetWebContents: WebContents | null = null +export let rendererLifecycleResetHandler: (() => void) | null = null +// Why: the hidden-delivery gate registries mirror renderer state; a reload/crash destroys owners without unregistering, so they reset when the renderer is replaced (drop memory preserved). +export let rendererGateResetLoadHandler: (() => void) | null = null +export let rendererGateResetGoneHandler: (() => void) | null = null +export let rendererGateResetWebContents: WebContents | null = null +// Why: the backgrounded-delivery dedupe map lives in the registerPtyHandlers closure but teardown funnels through module-scope clearProviderPtyState. +export let clearBackgroundedDeliverySyncForPty: (id: string) => void = () => {} + +export type RendererNavigationDetails = { + isMainFrame: boolean + isSameDocument: boolean +} + +// Why: navigation details identify the triggering frame; querying aggregate load state can misclassify an overlapping subframe load. +export let rendererDidStartNavigationHandler: + | ((details: RendererNavigationDetails) => void) + | null = null + +// Why: Restart daemon must re-bind provider→renderer listeners after replaceDaemonProvider swaps localProvider, else subscribers stay bound to the disposed adapter and new PTY data silently drops. +export let rebindProviderListeners: (() => void) | null = null +export let sshOutputIntakeCleanup: (() => void) | null = null + +export function rebindLocalProviderListeners(): void { + rebindProviderListeners?.() +} + +export function setRebindProviderListeners(fn: (() => void) | null): void { + rebindProviderListeners = fn +} + +export function setClearBackgroundedDeliverySyncForPty(fn: (id: string) => void): void { + clearBackgroundedDeliverySyncForPty = fn +} + +export function setSshOutputIntakeCleanup(fn: (() => void) | null): void { + sshOutputIntakeCleanup = fn +} + +export function setLocalDataUnsub(fn: (() => void) | null): void { + localDataUnsub = fn +} + +export function setLocalExitUnsub(fn: (() => void) | null): void { + localExitUnsub = fn +} + +export function setLocalBackgroundStreamUnsub(fn: (() => void) | null): void { + localBackgroundStreamUnsub = fn +} + +export function setLocalWriteUnavailableUnsub(fn: (() => void) | null): void { + localWriteUnavailableUnsub = fn +} + +export function setDidFinishLoadHandler( + handler: (() => void) | null, + contents: WebContents | null +): void { + didFinishLoadHandler = handler + didFinishLoadWebContents = contents +} + +export function setRendererLifecycleResetState(args: { + contents: WebContents | null + handler: (() => void) | null + navigation: ((details: RendererNavigationDetails) => void) | null +}): void { + rendererLifecycleResetWebContents = args.contents + rendererLifecycleResetHandler = args.handler + rendererDidStartNavigationHandler = args.navigation +} + +export function setRendererGateResetState(args: { + contents: WebContents | null + load: (() => void) | null + gone: (() => void) | null +}): void { + rendererGateResetWebContents = args.contents + rendererGateResetLoadHandler = args.load + rendererGateResetGoneHandler = args.gone +} + +// Why: Restart daemon must detach listeners AFTER synthetic pty:exit events fan out but BEFORE replaceDaemonProvider swaps the adapter; this export narrows that window to the caller. +export function unbindLocalProviderListeners(): void { + localDataUnsub?.() + localExitUnsub?.() + localBackgroundStreamUnsub?.() + localWriteUnavailableUnsub?.() + localDataUnsub = null + localExitUnsub = null + localBackgroundStreamUnsub = null + localWriteUnavailableUnsub = null +} diff --git a/src/main/ipc/pty/provider/liveness.ts b/src/main/ipc/pty/provider/liveness.ts new file mode 100644 index 00000000000..a8c005cce06 --- /dev/null +++ b/src/main/ipc/pty/provider/liveness.ts @@ -0,0 +1,142 @@ +import { isRemoteAgentHooksEnabled } from '../../../../shared/agent-hook-relay' +import type { AgentSessionOwnerBinding } from '../../../../shared/agent-session-host-authority' +import { agentSessionOwnerBindingsEqual } from '../../../../shared/claimed-agent-pty-owner' +import { addNodePtyRecoveryHint } from '../../../daemon/node-pty-error-hints' +import type { Store } from '../../../persistence' +import { isSshPtyNotFoundError } from '../../../providers/ssh-pty-errors' +import type { IPtyProvider } from '../../../providers/types' +import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' +import { ptyIncarnationById, ptyOwnership } from './ownership-state' +import { getRelayPtyId } from './registry' +import { + KEEP_HISTORY_STOP_POLL_MS, + KEEP_HISTORY_STOP_SETTLE_MS +} from '../delivery/visibility-state' +import { clearProviderPtyState } from './state-cleanup' + +export function stripRemotePaneEnvWhenHooksDisabled( + connectionId: string | null | undefined, + env: Record | undefined +): Record | undefined { + if (!connectionId || isRemoteAgentHooksEnabled()) { + return env + } + if ( + !env || + (!('ORCA_PANE_KEY' in env) && + !('ORCA_TAB_ID' in env) && + !('ORCA_WORKTREE_ID' in env) && + !('ORCA_AGENT_LAUNCH_TOKEN' in env)) + ) { + return env + } + const stripped = { ...env } + delete stripped.ORCA_PANE_KEY + delete stripped.ORCA_TAB_ID + delete stripped.ORCA_WORKTREE_ID + delete stripped.ORCA_AGENT_LAUNCH_TOKEN + return stripped +} + +export function normalizeNodePtySpawnError(err: unknown): Error { + const rawMessage = err instanceof Error ? err.message : String(err) + const hintedMessage = addNodePtyRecoveryHint(rawMessage) + if (hintedMessage === rawMessage && err instanceof Error) { + return err + } + if (err instanceof Error) { + // Why: preserve the original stack/name/custom fields while adding the same recovery hint as the pty:spawn path. + err.message = hintedMessage + return err + } + return new Error(hintedMessage) +} + +export function isPtyAlreadyGoneError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err) + return isSshPtyNotFoundError(err) || /Session not found/i.test(message) +} + +export function delay(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) + if (typeof timer.unref === 'function') { + timer.unref() + } + }) +} + +export async function isProviderPtyLive( + provider: IPtyProvider, + ptyId: string, + deadlineMs?: number +): Promise { + // Why: bound the liveness list RPC by the teardown deadline so a wedged daemon + // fails fast; undefined keeps the provider default for all other callers. + return (await provider.listProcesses(deadlineMs !== undefined ? { deadlineMs } : undefined)).some( + (session) => session.id === ptyId + ) +} + +export async function isProviderAgentSessionOwnerLive( + provider: IPtyProvider, + owner: AgentSessionOwnerBinding +): Promise { + const session = (await provider.listProcesses()).find((candidate) => candidate.id === owner.ptyId) + if (!session) { + return false + } + if (provider.providesAgentSessionOwnerListings?.(owner.ptyId) !== true) { + // Why: in-process local owners cannot serialize the controller claim; exact incarnation + // liveness keeps that claim authoritative until the normal PTY exit releases it. + const expectedIncarnation = ptyIncarnationById.get(owner.ptyId) + return expectedIncarnation !== undefined && session.incarnationId === expectedIncarnation + } + return Boolean( + session.agentSessionOwners?.some((candidate) => + agentSessionOwnerBindingsEqual(candidate, owner) + ) + ) +} + +export async function verifyPtyStopped( + provider: IPtyProvider, + ptyId: string, + opts: { keepHistory?: boolean; deadlineMs?: number } | undefined +): Promise { + if (await isProviderPtyLive(provider, ptyId, opts?.deadlineMs)) { + return false + } + if (!opts?.keepHistory) { + return true + } + const settleDeadline = Date.now() + KEEP_HISTORY_STOP_SETTLE_MS + // Why: deadlineMs is absolute, so the settle poll must not outlive the caller's teardown budget. + const deadline = + opts.deadlineMs !== undefined ? Math.min(settleDeadline, opts.deadlineMs) : settleDeadline + while (Date.now() < deadline) { + await delay(Math.min(KEEP_HISTORY_STOP_POLL_MS, deadline - Date.now())) + if (Date.now() >= deadline) { + break + } + if (await isProviderPtyLive(provider, ptyId, deadline)) { + return false + } + } + return true +} + +export function finishPtyShutdown( + id: string, + connectionId: string | null | undefined, + store: Store | undefined +): string | undefined { + const incarnationId = ptyIncarnationById.get(id) + clearProviderPtyState(id) + if (connectionId) { + store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated') + } + ptyOwnership.delete(id) + markClaudePtyExited(id) + return incarnationId +} diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts new file mode 100644 index 00000000000..2051d937c94 --- /dev/null +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -0,0 +1,115 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' +import { isPwshAvailableAsync } from '../../../pwsh' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { + addOrcaWslInteropEnv, + stampWslOrchestrationCompatibilityHost +} from '../../../pty/wsl-orca-env' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' +import { buildPtyHostEnv } from '../host-env/assembly' +import { + getCompatibleSelectedCodexHomePath, + isCodexStatusHooksEnabled, + shouldStripInheritedOrcaCodexHome +} from '../host-env/codex-home' +import type { GetSelectedCodexHomePath } from '../host-env/types' +import { isCurrentPtyExit, ptyOwnership } from './ownership-state' +import { localProvider } from './registry' +import { clearProviderPtyState } from './state-cleanup' + +export function configureLocalPtyProvider(args: { + runtime?: OrcaRuntimeService + getSettings?: () => GlobalSettings + getSelectedCodexHomePath?: GetSelectedCodexHomePath + trustedTerminalHandleEnv: Set +}): void { + // Why: only LocalPtyProvider needs main-process hook injection; daemon-backed providers spawn subprocesses internally. + if (!(localProvider instanceof LocalPtyProvider)) { + return + } + const { runtime, getSettings, getSelectedCodexHomePath, trustedTerminalHandleEnv } = args + localProvider.configure({ + isHistoryEnabled: () => getSettings?.()?.terminalScopeHistoryByWorktree ?? true, + getWindowsShell: () => getSettings?.()?.terminalWindowsShell, + getWindowsPowerShellImplementation: () => + getSettings ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined, + pwshAvailable: () => isPwshAvailableAsync(), + buildSpawnEnv: (id, baseEnv, ctx) => { + const codexSelectionTarget: CodexAccountSelectionTarget = + ctx?.isWsl === true + ? { runtime: 'wsl', wslDistro: ctx.wslDistro ?? null } + : { runtime: 'host' } + const selectedCodexHomePath = getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + ctx?.codexHomePathOverride + ? ctx.codexHomePathOverride.value + : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: ctx?.cwd, + launchAgent: ctx?.launchAgent + }) ?? null) + ) + const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath + const ptySettings = getSettings?.() + const env = buildPtyHostEnv(id, baseEnv, { + isPackaged: getAppEnvironment().isPackaged(), + resourcesPath: process.resourcesPath, + userDataPath: getAppEnvironment().getPath('userData'), + selectedCodexHomePath, + skipCodexHomeEnv, + stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({ + target: codexSelectionTarget, + selectedCodexHomePath, + skipCodexHomeEnv, + settings: ptySettings + }), + launchCommand: ctx?.command, + launchAgent: ctx?.launchAgent, + isWsl: ctx?.isWsl, + wslDistro: ctx?.wslDistro ?? null, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), + codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), + networkProxySettings: ptySettings + }) + // Why: agents need their terminal handle at process start to self-identify in orchestration messages without an extra RPC. + const requestedHandle = baseEnv.ORCA_TERMINAL_HANDLE + const preAllocatedHandle = + requestedHandle && trustedTerminalHandleEnv.has(requestedHandle) + ? requestedHandle + : runtime?.preAllocateHandleForPty(id) + if (requestedHandle && requestedHandle !== preAllocatedHandle) { + delete env.ORCA_TERMINAL_HANDLE + } + if (preAllocatedHandle) { + env.ORCA_TERMINAL_HANDLE = preAllocatedHandle + } + stampWslOrchestrationCompatibilityHost( + env, + runtime?.getOrchestrationCompatibilityHostId?.(), + ctx?.isWsl === true ? ctx.wslDistro : null + ) + if (ctx?.isWsl === true) { + addOrcaWslInteropEnv(env) + } + return env + }, + onSpawned: (id, incarnationId) => runtime?.onPtySpawned(id, incarnationId), + onExit: (id, code, incarnationId, cause) => { + if (!isCurrentPtyExit({ id, incarnationId })) { + return + } + clearProviderPtyState(id) + ptyOwnership.delete(id) + markClaudePtyExited(id) + runtime?.onPtyExit(id, code, incarnationId, { + providerExitObserved: true, + ...(cause ? { cause } : {}) + }) + }, + onData: (id, data, timestamp, sequenceChars, transformed) => + runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed) + }) +} diff --git a/src/main/ipc/pty/provider/ownership-state.ts b/src/main/ipc/pty/provider/ownership-state.ts new file mode 100644 index 00000000000..7d5ef3dcbb7 --- /dev/null +++ b/src/main/ipc/pty/provider/ownership-state.ts @@ -0,0 +1,35 @@ +import { isPtyIncarnationId } from '../../../../shared/pty-incarnation' + +// Why: post-spawn write/resize/kill calls carry only the PTY ID; map it to its connectionId so ops route to the right provider. +export const ptyOwnership = new Map() +export const ptyIncarnationById = new Map() + +export function isCurrentPtyExit(payload: { id: string; incarnationId?: string }): boolean { + const current = ptyIncarnationById.get(payload.id) + return !current || payload.incarnationId === current +} + +export function deletePtyOwnership(id: string): void { + ptyOwnership.delete(id) +} + +export function setPtyOwnership(id: string, connectionId: string | null): void { + ptyOwnership.set(id, connectionId) +} + +export function restorePtyIncarnation(id: string, incarnationId: string): void { + if (!isPtyIncarnationId(incarnationId)) { + throw new Error('Invalid PTY incarnation') + } + ptyIncarnationById.set(id, incarnationId) +} + +export function getPtyIdsForConnection(connectionId: string): string[] { + const ids: string[] = [] + for (const [ptyId, connId] of ptyOwnership) { + if (connId === connectionId) { + ids.push(ptyId) + } + } + return ids +} diff --git a/src/main/ipc/pty/provider/registry.ts b/src/main/ipc/pty/provider/registry.ts new file mode 100644 index 00000000000..85c8a3514db --- /dev/null +++ b/src/main/ipc/pty/provider/registry.ts @@ -0,0 +1,131 @@ +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import type { IPtyProvider } from '../../../providers/types' +import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../../../providers/ssh-pty-id' +import { ptyOwnership } from './ownership-state' + +// ─── Provider Registry ────────────────────────────────────────────── +// Routes PTY operations by connectionId (null = local provider). + +export let localProvider: IPtyProvider = new LocalPtyProvider() +export const sshProviders = new Map() +export const sshProvidersByGeneration = new Map() + +export type RegisteredPtyProvider = { + provider: IPtyProvider + connectionId: string | null +} + +export function registeredPtyProviders(): RegisteredPtyProvider[] { + return [ + { provider: localProvider, connectionId: null }, + ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) + ] +} + +export function getProvider(connectionId: string | null | undefined): IPtyProvider { + if (!connectionId) { + return localProvider + } + const provider = sshProviders.get(connectionId) + if (!provider) { + throw new Error(`No PTY provider for connection "${connectionId}"`) + } + return provider +} + +export function getProviderForPty(ptyId: string): IPtyProvider { + const connectionId = ptyOwnership.get(ptyId) + if (connectionId === undefined) { + const parsedSshId = parseAppSshPtyId(ptyId) + if (parsedSshId) { + // Why: disconnected SSH PTYs retain their encoded owner and must never fall through to the HUB-local provider. + return getProvider(parsedSshId.connectionId) + } + return localProvider + } + return getProvider(connectionId) +} + +export function hasPtyProviderForInspection(ptyId: string): boolean { + // Why: process inspection is background polling; disconnected SSH hosts should read as idle, not raise repeated IPC errors. + const connectionId = ptyOwnership.get(ptyId) + if (connectionId === undefined) { + // Why: mirror getProviderForPty — an unowned id still routes by its encoded SSH owner. + const parsedSshId = parseAppSshPtyId(ptyId) + return !parsedSshId || sshProviders.has(parsedSshId.connectionId) + } + return connectionId === null || sshProviders.has(connectionId) +} + +export function getAppPtyId(connectionId: string | null | undefined, ptyId: string): string { + return connectionId ? toAppSshPtyId(connectionId, ptyId) : ptyId +} + +export function getRelayPtyId(connectionId: string | null | undefined, ptyId: string): string { + return connectionId ? toRelaySshPtyId(connectionId, ptyId) : ptyId +} + +export function tryGetProviderForPty(ptyId: string): IPtyProvider | undefined { + try { + return getProviderForPty(ptyId) + } catch { + return undefined + } +} + +export function closeStartupQueryAuthorityForPty(ptyId: string): void { + try { + void Promise.resolve(tryGetProviderForPty(ptyId)?.closeStartupQueryAuthority?.(ptyId)).catch( + () => {} + ) + } catch { + /* Best-effort handoff; the bounded source deadline remains the fallback. */ + } +} + +export function tryGetProviderForAgentSessionOwner(ptyId: string): IPtyProvider | undefined { + const ownedConnectionId = ptyOwnership.get(ptyId) + const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(ptyId) : null + try { + return getProvider(parsedSshId?.connectionId ?? ownedConnectionId) + } catch { + return undefined + } +} + +/** Register an SSH PTY provider for a connection. */ +export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void { + sshProviders.set(connectionId, provider) + const generation = (provider as { providerGeneration?: number }).providerGeneration + if (Number.isSafeInteger(generation) && generation! > 0) { + sshProvidersByGeneration.set(generation!, provider) + } +} + +/** Remove an SSH PTY provider when a connection is closed. */ +export function unregisterSshPtyProvider(connectionId: string): void { + const provider = sshProviders.get(connectionId) + const generation = (provider as { providerGeneration?: number } | undefined)?.providerGeneration + if (generation !== undefined && sshProvidersByGeneration.get(generation) === provider) { + sshProvidersByGeneration.delete(generation) + } + sshProviders.delete(connectionId) +} + +/** Get the SSH PTY provider for a connection (for dispose on cleanup). */ +export function getSshPtyProvider(connectionId: string): IPtyProvider | undefined { + return sshProviders.get(connectionId) +} + +/** Get the installed PTY provider (for direct access in tests/runtime). + * After daemon init this may be a DaemonPtyAdapter/DaemonPtyRouter, not LocalPtyProvider; + * callers needing LocalPtyProvider-specific methods must type-narrow or import the class. */ +export function getLocalPtyProvider(): IPtyProvider { + return localProvider +} + +/** Replace the local PTY provider with a daemon-backed one. + * Call before registerPtyHandlers so the IPC layer routes through the daemon. */ +export function setLocalPtyProvider(provider: IPtyProvider): void { + localProvider = provider +} diff --git a/src/main/ipc/pty/provider/shutdown-detect.ts b/src/main/ipc/pty/provider/shutdown-detect.ts new file mode 100644 index 00000000000..3972f94880e --- /dev/null +++ b/src/main/ipc/pty/provider/shutdown-detect.ts @@ -0,0 +1,25 @@ +import type { IPtyProvider } from '../../../providers/types' +import { ptyIncarnationById } from './ownership-state' + +export async function shutdownProviderAndDetectExit( + provider: IPtyProvider, + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } +): Promise { + let providerExitObserved = false + const expectedIncarnationId = ptyIncarnationById.get(id) + const unsubscribe = provider.onExit((payload) => { + if ( + payload.id === id && + (!expectedIncarnationId || payload.incarnationId === expectedIncarnationId) + ) { + providerExitObserved = true + } + }) + try { + await provider.shutdown(id, opts) + } finally { + unsubscribe() + } + return providerExitObserved +} diff --git a/src/main/ipc/pty/provider/state-cleanup.ts b/src/main/ipc/pty/provider/state-cleanup.ts new file mode 100644 index 00000000000..72286eb0a4d --- /dev/null +++ b/src/main/ipc/pty/provider/state-cleanup.ts @@ -0,0 +1,124 @@ +import { advertisedUrlWatcher } from '../../../ports/advertised-url-watcher' +import { unregisterPty } from '../../../memory/pty-registry' +import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' +import { forgetCodexPaneAccount } from '../../../codex/codex-pane-account-registry' +import { openCodeHookService } from '../../../opencode/hook-service' +import { piTitlebarExtensionService } from '../../../pi/titlebar-extension-service' +import { agentHookServer } from '../../../agent-hooks/server' +import { clearMigrationUnsupportedPty } from '../../../agent-hooks/migration-unsupported-pty-state' +import { clearNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' +import { + clearHiddenRendererPtyDeliveryState, + isHiddenRendererPty +} from '../../pty-hidden-delivery-gate' +import { agentSessionOwners } from '../pane/agent-session-owners' +import { paneKeyPtyId, paneKeyTeardownListeners, ptyPaneKey } from '../pane/key-state' +import { ptyIncarnationById, ptyOwnership } from './ownership-state' +import { clearBackgroundedDeliverySyncForPty } from './listener-lifecycle' +import { + activeRendererPtys, + deliveredHiddenRendererResizeOutputPtys, + interactiveOutputCharsByPty, + invalidatePendingPtyDrainPolicy, + invalidatePendingPtyDrainPriority, + lastInputAtByPty, + pendingHiddenRendererResizeOutputPtys, + providerSnapshotRequiredPtys, + ptySizes, + rendererVisibilityKnownPtys, + visibleRendererPtys +} from '../delivery/visibility-state' + +/** + * Tear down per-PTY provider-scoped state. + * + * Claimed agent owners remain fenced because the relay process may survive and + * prove the exact same generation after reconnect. + */ +export function clearProviderPtyState( + id: string, + opts: { preserveAgentSessionOwners?: boolean } = {} +): void { + if (!opts.preserveAgentSessionOwners) { + agentSessionOwners.release(id) + // Why: the launch-account record outlives the app, so only a real teardown + // may drop it — a disconnect that can reconnect is not a death, and a reused + // id must never inherit a dead pane's Codex account. + forgetCodexPaneAccount(id) + } + // Why: OpenCode and Pi both allocate PTY-scoped runtime state outside the + // node-pty process table. Centralizing provider cleanup avoids drift where a + // new teardown path forgets to remove one provider's overlay/hook state. + openCodeHookService.clearPty(id) + piTitlebarExtensionService.clearPty(id) + // Why: SSH exit/teardown paths bypass pty.ts's local onExit but still must release Claude account-switch guards. + markClaudePtyExited(id) + ptySizes.delete(id) + ptyIncarnationById.delete(id) + lastInputAtByPty.delete(id) + interactiveOutputCharsByPty.delete(id) + const activeChanged = activeRendererPtys.delete(id) + visibleRendererPtys.delete(id) + rendererVisibilityKnownPtys.delete(id) + pendingHiddenRendererResizeOutputPtys.delete(id) + deliveredHiddenRendererResizeOutputPtys.delete(id) + // Why: every teardown path funnels through here — hidden/interest gate bits must not outlive the PTY or a reused map entry could silently gate a new one. + const deliveryPolicyChanged = isHiddenRendererPty(id) + clearHiddenRendererPtyDeliveryState(id) + if (activeChanged) { + invalidatePendingPtyDrainPriority(id, false) + } + if (deliveryPolicyChanged) { + invalidatePendingPtyDrainPolicy(id, false) + } + clearBackgroundedDeliverySyncForPty(id) + providerSnapshotRequiredPtys.delete(id) + // Why: the Phase-5 ConPTY DA1 spawn record must not leak onto a reused id. + clearNativeWindowsConptyPty(id) + const paneKey = ptyPaneKey.get(id) + const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false + // Why: drop the memory-collector registration so a dead PTY doesn't resolve its dead pid on every snapshot; no-op for never-registered (SSH-owned) PTYs. + unregisterPty(id) + // Why: cover paths that bypass runtime.onPtyExit (SSH reattach/shutdown, daemon spawn-failure) — else the watcher's per-PTY buffer and worktree binding outlive the PTY. + advertisedUrlWatcher.unbindPty(id) + clearMigrationUnsupportedPty(id) + agentHookServer.clearPaneKeyAliasesForPty(id, { + shouldClearStablePaneKey: (stablePaneKey) => { + // Why: when this PTY never rebuilt ptyPaneKey after restart, alias ownership is our only proof — don't erase a newer PTY that now owns the same stable paneKey. + const stablePaneOwner = paneKeyPtyId.get(stablePaneKey) + if (stablePaneOwner && stablePaneOwner !== id) { + return false + } + return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey) + } + }) + // Why: clear the hook server's per-paneKey caches (via the spawn-time paneKey mapping, its only ptyId→paneKey correlation) so dead panes don't accumulate over process lifetime. + if (paneKey) { + if (stillOwnsPaneKey) { + agentHookServer.clearPaneState(paneKey) + paneKeyPtyId.delete(paneKey) + } + ptyPaneKey.delete(id) + if (stillOwnsPaneKey) { + // Why: notify AFTER dropping the paneKey↔ptyId entries so a listener re-reading the map sees post-teardown state; wrap each so one throw can't block the rest. + for (const listener of paneKeyTeardownListeners) { + try { + listener(paneKey) + } catch (err) { + console.error('[pty] paneKey teardown listener threw', err) + } + } + } + } +} + +export function clearPtyOwnershipForConnection(connectionId: string): void { + for (const [ptyId, connId] of ptyOwnership) { + if (connId === connectionId) { + // Why: pane-scoped caches cannot route while disconnected, but claimed + // ownership must survive until reconnect makes absence authoritative. + clearProviderPtyState(ptyId, { preserveAgentSessionOwners: true }) + ptyOwnership.delete(ptyId) + } + } +} diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts new file mode 100644 index 00000000000..82bd55bc854 --- /dev/null +++ b/src/main/ipc/pty/register-handlers.ts @@ -0,0 +1,267 @@ +import type { BrowserWindow } from 'electron' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { Store } from '../../persistence' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { LocalPtyProvider } from '../../providers/local-pty-provider' +import type { TerminalStartupCwdMissingDirFallback } from '../../../shared/terminal-startup-cwd' +import { + getHiddenRendererPtyDeliveryDebug, + resetRendererScopedHiddenPtyDeliveryState +} from '../pty-hidden-delivery-gate' +import { localProvider } from './provider/registry' +import { finishPtyShutdown } from './provider/liveness' +import type { GetSelectedCodexHomePath, PrepareClaudeAuth } from './host-env/types' +import { installPtyInspectIpcHandlers } from './ipc/inspect' +import { installPtyWriteIpcHandlers } from './ipc/write' +import { installPtySpawnIpcHandler } from './ipc/spawn' +import { installPtyRuntimeController } from './runtime/controller' +import { installPtySnapshotIpcHandlers } from './ipc/snapshot' +import { + assertFolderWorkspacePtyPathUsable as assertFolderWorkspacePtyPathUsableImpl, + localStartupCwdDirectoryExists, + resolvePtySpawnStartupCwd as resolvePtySpawnStartupCwdImpl +} from './host-env/spawn-cwd' +import { + setInvalidatePendingPtyDrainPolicy, + setInvalidatePendingPtyDrainPriority, + invalidatePendingPtyDrainPolicy +} from './delivery/visibility-state' +import { + setRebindProviderListeners, + setDidFinishLoadHandler, + setRendererGateResetState +} from './provider/listener-lifecycle' +import { + resetRendererDeliveryAccountingForLifecycleReset, + setResetRendererDeliveryAccountingForLifecycleReset, + clearRendererDispatcherReadyWatchdog +} from './delivery/debug' +import { + registerRendererLifecycleResetHandlers, + clearRendererGateResetHandlers, + clearDidFinishLoadHandler +} from './delivery/lifecycle-reset' +import { createPtyIpcSession, type PtyIpcSessionOptions } from './session' +import { wirePtyIpcSession } from './delivery/wire-session' +import { configureLocalPtyProvider } from './provider/local-configure' +import { bindProviderListeners } from './provider/bind-listeners' +import { installSessionSshOutputIntake } from './delivery/ssh-intake' +import { installPtySerializeBufferIpc } from './ipc/serialize-buffer' +import { installPtyResizeVisibilityIpc } from './ipc/resize-visibility' +import { adoptStablePane } from './pane/adopt-stable' +import { getPtyIpc } from '../pty-host-bindings' +import { + noCodexResumeLaunch, + prepareCodexResumeHome, + reconcileSharedRuntimeResumeHome, + resolveCodexResumeLaunch, + stripSequencedStartupResumeArgv +} from './host-env/codex-resume' + +export function registerPtyHandlers( + mainWindow: BrowserWindow, + runtime?: OrcaRuntimeService, + getSelectedCodexHomePath?: GetSelectedCodexHomePath, + getSettings?: () => GlobalSettings, + prepareClaudeAuth?: PrepareClaudeAuth, + store?: Store, + options?: PtyIpcSessionOptions +): void { + const ipcMain = getPtyIpc() + // Why first: the outgoing session owns the producer pauses, so its real reset must run + // before the bridge is neutralized or a PTY paused during re-registration stays paused. + resetRendererDeliveryAccountingForLifecycleReset() + // Why: a re-registration means a new window owns delivery — cancel the prior closure's watchdog and neutralize its bridged reset so mark-hidden below can't arm a timer against the dead closure. + clearRendererDispatcherReadyWatchdog() + setResetRendererDeliveryAccountingForLifecycleReset(() => {}) + setInvalidatePendingPtyDrainPriority(() => {}) + setInvalidatePendingPtyDrainPolicy(() => {}) + // Why: neutralize rebind at the same moment as drain so a daemon replace in this window cannot attach the old accept/exit closures. + setRebindProviderListeners(() => {}) + registerRendererLifecycleResetHandlers(mainWindow.webContents) + + const getLocalPtyStartupPromise = (connectionId?: string | null): Promise | undefined => { + if (connectionId) { + return undefined + } + // Why: during cold start the daemon provider swap overlaps first paint, so local spawns must wait; SSH/headless don't use the desktop daemon. + return options?.awaitLocalPtyStartup?.() + } + + const getLocalPtyProviderStartupPromise = ( + connectionId?: string | null + ): Promise | undefined => { + if (connectionId) { + return undefined + } + return options?.awaitLocalPtyProviderStartup?.() ?? options?.awaitLocalPtyStartup?.() + } + + // Remove prior handlers so re-registration (e.g. macOS re-activate creating a new window) doesn't double-register. + ipcMain.removeHandler('pty:spawn') + ipcMain.removeHandler('pty:kill') + ipcMain.removeHandler('pty:listSessions') + ipcMain.removeHandler('pty:hasPty') + ipcMain.removeHandler('pty:hasChildProcesses') + ipcMain.removeHandler('pty:getForegroundProcess') + ipcMain.removeHandler('pty:inspectProcess') + ipcMain.removeHandler('pty:confirmForegroundProcess') + ipcMain.removeHandler('pty:getCwd') + ipcMain.removeHandler('pty:getSize') + ipcMain.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities') + ipcMain.removeHandler('pty:declarePendingPaneSerializer') + ipcMain.removeHandler('pty:settlePaneSerializer') + ipcMain.removeHandler('pty:clearPendingPaneSerializer') + ipcMain.removeHandler('pty:reportRendererSerializerReady') + ipcMain.removeHandler('pty:getMainBufferSnapshot') + ipcMain.removeHandler('pty:sideEffectSnapshot') + ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot') + ipcMain.removeHandler('pty:resetRendererDeliveryDebug') + ipcMain.removeHandler('pty:reportRendererDeliveryState') + ipcMain.removeHandler('pty:writeAccepted') + ipcMain.removeAllListeners('pty:write') + ipcMain.removeAllListeners('pty:ackColdRestore') + ipcMain.removeAllListeners('pty:ackData') + ipcMain.removeAllListeners('pty:deliveryResyncResponse') + ipcMain.removeAllListeners('pty:serializeBuffer:response') + + const session = createPtyIpcSession({ + mainWindow, + runtime, + store, + getSettings, + options + }) + wirePtyIpcSession(session) + configureLocalPtyProvider({ + runtime, + getSettings, + getSelectedCodexHomePath, + trustedTerminalHandleEnv: session.trustedTerminalHandleEnv + }) + installSessionSshOutputIntake(session) + bindProviderListeners(session) + setRebindProviderListeners(() => bindProviderListeners(session)) + installPtySerializeBufferIpc(session) + + // Why: reload/crash orphans delivery-interest holds and hidden marks; reset so surviving PTYs aren't stuck force-fed or gated — each pane's first sync re-marks. + clearRendererGateResetHandlers() + const resetRendererPtyDeliveryGateState = (): void => { + const gateDebug = getHiddenRendererPtyDeliveryDebug() + resetRendererScopedHiddenPtyDeliveryState() + if (gateDebug.hiddenDeliveryGatedPtyCount > 0 || gateDebug.deliveryInterestPtyCount > 0) { + invalidatePendingPtyDrainPolicy() + } + // Why: the daemon pacer must not keep throttling ptys whose hidden marks died with the renderer; the fresh renderer's sync re-marks the still-hidden ones. + session.resyncBackgroundedDeliveriesAfterGateReset() + } + setRendererGateResetState({ + contents: mainWindow.webContents, + load: resetRendererPtyDeliveryGateState, + gone: resetRendererPtyDeliveryGateState + }) + mainWindow.webContents.on('did-finish-load', resetRendererPtyDeliveryGateState) + mainWindow.webContents.on('render-process-gone', resetRendererPtyDeliveryGateState) + + // Why: only LocalPtyProvider PTYs (main-process) can be orphaned on reload; daemon sessions survive by design and cleanup would kill them. + clearDidFinishLoadHandler() + if (localProvider instanceof LocalPtyProvider) { + const lp = localProvider + const finishLoadHandler = () => { + // Why: always advance to keep the generation monotonic, but skip the sweep on crash/freeze-recovery reload — it would kill live local PTYs before session restore (#5787). + const generation = lp.advanceGeneration() + if (options?.isRecoveryReloadInFlight?.(mainWindow.webContents.id)) { + return + } + // Why: the retained provider onExit callback is the only physical-exit proof; it clears ownership after the OS reaps it. + lp.killOrphanedPtys(generation - 1) + } + setDidFinishLoadHandler(finishLoadHandler, mainWindow.webContents) + mainWindow.webContents.on('did-finish-load', finishLoadHandler) + } + + const assertFolderWorkspacePtyPathUsable = ( + worktreeId: string | undefined + ): Promise | void => assertFolderWorkspacePtyPathUsableImpl(store, worktreeId) + const resolvePtySpawnStartupCwd = ( + worktreeId: string | undefined, + cwd: string | undefined, + missingDirFallback?: TerminalStartupCwdMissingDirFallback + ): string | undefined => resolvePtySpawnStartupCwdImpl(store, worktreeId, cwd, missingDirFallback) + const prepareCodexResumeHomeBound = ( + args: Parameters[1] + ): ReturnType => + prepareCodexResumeHome(options?.prepareCodexSessionResume, args) + const adoptStablePaneBound = (args: Parameters[2]) => + adoptStablePane(runtime, store, args) + + // Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them. + installPtyRuntimeController({ + runtime, + store, + adoptStablePane: adoptStablePaneBound, + getLocalPtyStartupPromise, + getLocalPtyProviderStartupPromise, + prepareCodexResumeHome: prepareCodexResumeHomeBound, + resolveCodexResumeLaunch, + noCodexResumeLaunch, + reconcileSharedRuntimeResumeHome, + stripSequencedStartupResumeArgv, + assertFolderWorkspacePtyPathUsable, + resolvePtySpawnStartupCwd, + requestSerializedBuffer: session.requestSerializedBuffer, + shutdownProviderAndDetectExit: session.shutdownProviderAndDetectExit, + rememberSyntheticKillExit: session.rememberSyntheticKillExit, + rememberRetiredRejectedPty: session.rememberRetiredRejectedPty, + sendPtyExitToRenderer: session.sendPtyExitToRenderer, + sendPtySpawnedToRenderer: session.sendPtySpawnedToRenderer, + finishPtyShutdown, + getSettings, + getSelectedCodexHomePath, + prepareClaudeAuth, + options, + trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, + retiredRejectedPtyIds: session.retiredRejectedPtyIds, + reversibleStopOwnersByPtyId: session.reversibleStopOwnersByPtyId, + mainWindow + }) + + installPtySnapshotIpcHandlers({ runtime, pendingData: session.pendingData }) + installPtySpawnIpcHandler({ + runtime, + store, + getSettings, + getSelectedCodexHomePath, + prepareClaudeAuth, + options, + getLocalPtyStartupPromise, + adoptStablePane: adoptStablePaneBound, + assertFolderWorkspacePtyPathUsable, + resolvePtySpawnStartupCwd, + localStartupCwdDirectoryExists, + prepareCodexResumeHome: prepareCodexResumeHomeBound, + noCodexResumeLaunch, + resolveCodexResumeLaunch, + reconcileSharedRuntimeResumeHome, + stripSequencedStartupResumeArgv, + transitionSpawnHiddenRendererPtyDeliveryState: + session.transitionSpawnHiddenRendererPtyDeliveryState, + trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, + sendPtySpawnedToRenderer: session.sendPtySpawnedToRenderer, + syncPtyBackgroundedDelivery: session.syncPtyBackgroundedDelivery + }) + installPtyWriteIpcHandlers({ + mainWindow, + runtime, + clearHiddenRendererResizeOutput: session.clearHiddenRendererResizeOutput + }) + installPtyResizeVisibilityIpc(session) + installPtyInspectIpcHandlers({ + store, + runtime, + getLocalPtyProviderStartupPromise, + shutdownProviderAndDetectExit: session.shutdownProviderAndDetectExit, + rememberSyntheticKillExit: session.rememberSyntheticKillExit, + sendPtyExitToRenderer: session.sendPtyExitToRenderer + }) +} diff --git a/src/main/ipc/pty/register-headless-runtime.ts b/src/main/ipc/pty/register-headless-runtime.ts new file mode 100644 index 00000000000..d1a10871178 --- /dev/null +++ b/src/main/ipc/pty/register-headless-runtime.ts @@ -0,0 +1,43 @@ +import type { BrowserWindow } from 'electron' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { Store } from '../../persistence' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { + CodexHomePtySpawnedLifecycleArgs, + GetSelectedCodexHomePath, + PrepareClaudeAuth, + PrepareCodexSessionResume +} from './host-env/types' +import { registerPtyHandlers } from './register-handlers' + +export function registerHeadlessPtyRuntime( + runtime: OrcaRuntimeService, + getSelectedCodexHomePath?: GetSelectedCodexHomePath, + getSettings?: () => GlobalSettings, + prepareClaudeAuth?: PrepareClaudeAuth, + store?: Store, + prepareCodexSessionResume?: PrepareCodexSessionResume, + lifecycle?: { + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + onPtyExit?: (id: string, exitSequence: number) => void + } +): void { + // Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals. + const headlessWindow = { + isDestroyed: () => true, + webContents: { + send: () => {}, + on: () => {}, + removeListener: () => {} + } + } as unknown as BrowserWindow + registerPtyHandlers( + headlessWindow, + runtime, + getSelectedCodexHomePath, + getSettings, + prepareClaudeAuth, + store, + { prepareCodexSessionResume, ...lifecycle } + ) +} diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts new file mode 100644 index 00000000000..4abebc3b28c --- /dev/null +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -0,0 +1,89 @@ +import type { BrowserWindow } from 'electron' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { Store } from '../../../persistence' +import type { IPtyProvider } from '../../../providers/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import type { TerminalStartupCwdMissingDirFallback } from '../../../../shared/terminal-startup-cwd' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import type { CodexSessionResumePreparation } from '../../../codex/codex-session-resume-home' +import type { + CodexHomePtySpawnedLifecycleArgs, + GetSelectedCodexHomePath, + PrepareClaudeAuth, + PrepareCodexSessionResume +} from '../host-env/types' +import type { CodexResumeLaunch, PreparedCodexResumeHome } from '../host-env/codex-resume' +import type { StablePaneOwner } from '../pane/stable-owner' +import type { AdoptStablePaneArgs, AdoptStablePaneResult } from '../ipc/spawn-types' +import type { finishPtyShutdown } from '../provider/liveness' + +export type PtyRuntimeControllerDeps = { + runtime?: OrcaRuntimeService + store?: Store + adoptStablePane: (args: AdoptStablePaneArgs) => Promise + getLocalPtyStartupPromise: (connectionId?: string | null) => Promise | undefined + getLocalPtyProviderStartupPromise: (connectionId?: string | null) => Promise | undefined + prepareCodexResumeHome: (args: { + connectionId?: string | null + launchAgent?: TuiAgent + providerSession?: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string + }) => PreparedCodexResumeHome | null + resolveCodexResumeLaunch: ( + command: string | undefined, + prepared: PreparedCodexResumeHome + ) => Promise + noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch + reconcileSharedRuntimeResumeHome: ( + resumeHome: Extract, + resolveCurrent: () => string | null + ) => string + stripSequencedStartupResumeArgv: | undefined>( + env: T, + launch: CodexResumeLaunch + ) => T + assertFolderWorkspacePtyPathUsable: (worktreeId: string | undefined) => Promise | void + resolvePtySpawnStartupCwd: ( + worktreeId: string | undefined, + cwd: string | undefined, + missingDirFallback?: TerminalStartupCwdMissingDirFallback + ) => string | undefined + requestSerializedBuffer: ( + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + ) => Promise<{ + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + kittyKeyboardFlags?: number + } | null> + shutdownProviderAndDetectExit: ( + provider: IPtyProvider, + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ) => Promise + rememberSyntheticKillExit: (id: string) => void + rememberRetiredRejectedPty: (id: string) => void + sendPtyExitToRenderer: (payload: { id: string; code: number; incarnationId?: string }) => void + sendPtySpawnedToRenderer: (id: string) => void + finishPtyShutdown: typeof finishPtyShutdown + getSettings?: () => GlobalSettings | undefined + getSelectedCodexHomePath?: GetSelectedCodexHomePath + prepareClaudeAuth?: PrepareClaudeAuth + options?: { + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + prepareCodexSessionResume?: PrepareCodexSessionResume + } + trustedTerminalHandleEnv: Set + retiredRejectedPtyIds: Map + reversibleStopOwnersByPtyId: Map + mainWindow: BrowserWindow +} + +export type { StablePaneOwner } diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts new file mode 100644 index 00000000000..4fce4853bc4 --- /dev/null +++ b/src/main/ipc/pty/runtime/controller.ts @@ -0,0 +1,80 @@ +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { claimRuntimePaneCreate, makePaneSpawnReservationKey } from '../pane/spawn-reservation' +import type { PtyRuntimeControllerDeps } from './controller-deps' +import { spawnPtyFromRuntimeController } from './spawn' +import { + killPtyFromRuntimeController, + markReversibleStopsFromRuntimeController, + retireRejectedPtyFromRuntimeController, + stopAndWaitPtyFromRuntimeController +} from './kill' +import { + attachPtyFromRuntimeController, + clearBufferFromRuntimeController, + confirmForegroundProcessFromRuntimeController, + getCwdFromRuntimeController, + getForegroundProcessFromRuntimeController, + getRendererSerializerGenerationFromRuntimeController, + getSizeFromRuntimeController, + hasChildProcessesFromRuntimeController, + hasPtyFromRuntimeController, + hasRendererSerializerFromRuntimeController, + inspectProcessFromRuntimeController, + listProcessesFromRuntimeController, + listProcessesWithHostScopeFromRuntimeController, + probePtyLivenessFromRuntimeController, + resizePtyFromRuntimeController, + serializeProviderBufferFromRuntimeController, + waitForRendererSerializerFromRuntimeController, + writePtyFromRuntimeController +} from './operations' + +export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): void { + const { runtime, adoptStablePane, requestSerializedBuffer } = deps + + runtime?.setPtyController({ + claimStablePaneCreate: (args) => { + const paneKey = makePaneKey(args.tabId, args.leafId) + const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) + return ownerKey ? claimRuntimePaneCreate(ownerKey) : () => {} + }, + adoptStablePane, + spawn: async (args) => spawnPtyFromRuntimeController(deps, args), + write: (ptyId, data) => writePtyFromRuntimeController(ptyId, data), + probePtyLiveness: (ptyId) => probePtyLivenessFromRuntimeController(deps, ptyId), + // Why: subscriber-driven ingestion for daemon sessions no renderer pane + // ever attached. Local daemon sessions only — SSH panes have their own + // lease machinery, and the in-process local provider streams without + // attach. Attach-only and false-on-doubt: never creates or resizes. + attach: (ptyId) => attachPtyFromRuntimeController(deps, ptyId), + kill: (ptyId) => killPtyFromRuntimeController(deps, ptyId), + retireRejectedPty: (ptyId, stopConfirmed) => + retireRejectedPtyFromRuntimeController(deps, ptyId, stopConfirmed), + markReversibleStops: (ptyIds) => markReversibleStopsFromRuntimeController(deps, ptyIds), + stopAndWait: (ptyId, opts) => stopAndWaitPtyFromRuntimeController(deps, ptyId, opts), + getForegroundProcess: (ptyId) => getForegroundProcessFromRuntimeController(ptyId), + inspectProcess: (ptyId) => inspectProcessFromRuntimeController(ptyId), + confirmForegroundProcess: (ptyId) => confirmForegroundProcessFromRuntimeController(ptyId), + getCwd: (ptyId) => getCwdFromRuntimeController(ptyId), + hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId), + clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId), + hasPty: (ptyId) => hasPtyFromRuntimeController(ptyId), + listProcesses: (connectionId, opts) => + listProcessesFromRuntimeController(deps, connectionId, opts), + listProcessesWithHostScope: (opts) => + listProcessesWithHostScopeFromRuntimeController(deps, opts), + serializeBuffer: (ptyId, opts) => { + // Why: mobile xterm must start from the desktop's exact screen state/dimensions before live TUI chunks render correctly. + return requestSerializedBuffer(ptyId, opts) + }, + serializeProviderBuffer: (ptyId, opts) => + serializeProviderBufferFromRuntimeController(ptyId, opts), + hasRendererSerializer: (ptyId) => hasRendererSerializerFromRuntimeController(ptyId), + getRendererSerializerGeneration: (ptyId) => + getRendererSerializerGenerationFromRuntimeController(ptyId), + waitForRendererSerializer: (ptyId, afterGeneration, timeoutMs, signal) => + waitForRendererSerializerFromRuntimeController(ptyId, afterGeneration, timeoutMs, signal), + getSize: (ptyId) => getSizeFromRuntimeController(ptyId), + resize: (ptyId, cols, rows) => resizePtyFromRuntimeController(ptyId, cols, rows) + }) +} diff --git a/src/main/ipc/pty/runtime/kill.ts b/src/main/ipc/pty/runtime/kill.ts new file mode 100644 index 00000000000..3f38ac339dd --- /dev/null +++ b/src/main/ipc/pty/runtime/kill.ts @@ -0,0 +1,311 @@ +import type { IPtyProvider } from '../../../providers/types' +import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict' +import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' +import { ptyOwnership, ptyIncarnationById } from '../provider/ownership-state' +import { getProvider, getProviderForPty } from '../provider/registry' +import { isPtyAlreadyGoneError, delay, verifyPtyStopped } from '../provider/liveness' +import type { PtyRuntimeControllerDeps } from './controller-deps' + +export function killPtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string +): boolean { + const { + runtime, + store, + getLocalPtyProviderStartupPromise, + shutdownProviderAndDetectExit, + rememberSyntheticKillExit, + sendPtyExitToRenderer, + finishPtyShutdown, + retiredRejectedPtyIds + } = deps + runtime?.markPtyStopRequested?.(ptyId) + let connectionId: string | null | undefined = ptyOwnership.get(ptyId) + const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null + connectionId ??= parsedSshId?.connectionId + const killWithCurrentProvider = (): boolean => { + let provider: IPtyProvider + try { + provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId) + } catch { + if (connectionId) { + // Why: runtime/CLI close can target a detached SSH PTY after its + // provider was unregistered. Tombstone the lease so reconnect does + // not revive a terminal the user explicitly closed. + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + runtime?.onPtyExit(ptyId, -1, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) + return false + } + return false + } + // Why: controller is synchronous, but keep ownership until async shutdown proves whether the provider emitted an exit. + void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false }) + .then((providerExitObserved) => { + const retired = retiredRejectedPtyIds.has(ptyId) + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + if (!providerExitObserved && !retired) { + runtime?.onPtyExit(ptyId, -1, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + } + }) + .catch((err) => { + const retired = retiredRejectedPtyIds.has(ptyId) + if (isPtyAlreadyGoneError(err)) { + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + if (!retired) { + runtime?.onPtyExit(ptyId, -1, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + } + return + } + console.warn( + `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` + ) + // Why: close runtime tails without clearing provider ownership, so + // a retry can still target a PTY that survived the failed shutdown. + if (!retired) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } + runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) + } + }) + return true + } + const startupPromise = getLocalPtyProviderStartupPromise(connectionId) + if (startupPromise) { + // Why: select the provider after the daemon swap; the fallback first can report success while orphaning a daemon PTY. + void startupPromise.then(killWithCurrentProvider).catch((err) => { + console.warn( + `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` + ) + if (!retiredRejectedPtyIds.has(ptyId)) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } + runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) + } + }) + return true + } + return killWithCurrentProvider() +} + +export function retireRejectedPtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string, + stopConfirmed: boolean +): void { + const { + runtime, + store, + rememberRetiredRejectedPty, + rememberSyntheticKillExit, + sendPtyExitToRenderer, + finishPtyShutdown + } = deps + rememberRetiredRejectedPty(ptyId) + if (!stopConfirmed) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + 'a follow-up stop was issued but its outcome could not be verified' + ) + if (!ptyOwnership.has(ptyId)) { + return + } + runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: -1, + ...(ptyIncarnationById.get(ptyId) ? { incarnationId: ptyIncarnationById.get(ptyId) } : {}) + }) + return + } + // Why: a completed stop already cleared provider state, tombstoned the lease and told the + // renderer; repeating that double-fires the exit IPC. The runtime still needs code 0 so an + // SSH pane retires for good instead of staying preserved by the stop's negative exit. + if (!ptyOwnership.has(ptyId)) { + runtime?.onPtyExit(ptyId, 0, ptyIncarnationById.get(ptyId)) + return + } + let connectionId: string | null | undefined = ptyOwnership.get(ptyId) + const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null + connectionId ??= parsedSshId?.connectionId + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + runtime?.onPtyExit(ptyId, 0, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: 0, + ...(incarnationId ? { incarnationId } : {}) + }) +} + +export function markReversibleStopsFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyIds: readonly string[] +): () => void { + const { reversibleStopOwnersByPtyId } = deps + for (const ptyId of ptyIds) { + reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1) + } + let released = false + return () => { + if (released) { + return + } + released = true + for (const ptyId of ptyIds) { + const owners = (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) - 1 + if (owners > 0) { + reversibleStopOwnersByPtyId.set(ptyId, owners) + } else { + reversibleStopOwnersByPtyId.delete(ptyId) + } + } + } +} + +export async function stopAndWaitPtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string, + opts?: { keepHistory?: boolean; deadlineMs?: number } +): Promise { + const { + runtime, + store, + getLocalPtyProviderStartupPromise, + shutdownProviderAndDetectExit, + rememberSyntheticKillExit, + sendPtyExitToRenderer, + finishPtyShutdown + } = deps + runtime?.markPtyStopRequested?.(ptyId) + let connectionId: string | null | undefined = ptyOwnership.get(ptyId) + const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null + connectionId ??= parsedSshId?.connectionId + // Why: destructive teardown threads one absolute deadline through every await + // below; each RPC leaf converts it to the remaining time when it issues, so + // sequential RPCs share the budget and cannot overrun the sweep deadline. + const deadlineMs = opts?.deadlineMs + const startupPromise = getLocalPtyProviderStartupPromise(connectionId) + if (startupPromise) { + // Why: exact-stop must resolve the provider after daemon startup just + // like renderer kills, or the fallback can falsely confirm teardown. + if (deadlineMs !== undefined) { + // Why: bound the cold-start await by the teardown deadline instead of the + // 60s startup fail-open cap; fail closed so the sweep records the miss. + const won = await Promise.race([ + // Why: () => false on rejection both fails closed on a startup error and + // keeps the losing branch's rejection from surfacing as unhandled. + startupPromise.then( + () => true, + () => false + ), + delay(Math.max(1, deadlineMs - Date.now())).then(() => false) + ]) + if (!won) { + return false + } + } else { + await startupPromise + } + } + let provider: IPtyProvider + try { + provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId) + } catch { + if (connectionId) { + // Why: an absent SSH provider means there is no live target left to + // await, but the relay lease must still be tombstoned. + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + runtime?.onPtyExit(ptyId, -1, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) + } + return false + } + let providerExitObserved = false + try { + providerExitObserved = await shutdownProviderAndDetectExit(provider, ptyId, { + immediate: true, + keepHistory: opts?.keepHistory ?? false, + deadlineMs + }) + } catch (err) { + if (!isPtyAlreadyGoneError(err)) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } + console.warn( + `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` + ) + return false + } + } + try { + if (!(await verifyPtyStopped(provider, ptyId, opts))) { + runtime?.markPtyLivenessLive?.(ptyId) + return false + } + } catch (err) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } + console.warn( + `[pty] Failed to verify PTY ${ptyId} stopped: ${ + err instanceof Error ? err.message : String(err) + }` + ) + return false + } + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + if (!providerExitObserved) { + // The owning provider's fresh inventory observed absence, so this is a + // death certificate even when its exit event was missed. + runtime?.onPtyExit(ptyId, 0, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: 0, + ...(incarnationId ? { incarnationId } : {}) + }) + } + return true +} diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts new file mode 100644 index 00000000000..745f225312a --- /dev/null +++ b/src/main/ipc/pty/runtime/operations.ts @@ -0,0 +1,259 @@ +import type { IPtyProvider } from '../../../providers/types' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import type { PtyProcessInfo } from '../../../providers/pty-process-info' +import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' +import { + LOCAL_EXECUTION_HOST_ID, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { ptyOwnership } from '../provider/ownership-state' +import { ptySizes } from '../delivery/visibility-state' +import { rendererSerializerReadiness } from '../pane/serializer-state' +import { + getProvider, + getProviderForPty, + localProvider, + registeredPtyProviders +} from '../provider/registry' +import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspection' +import type { PtyRuntimeControllerDeps } from './controller-deps' + +export function writePtyFromRuntimeController(ptyId: string, data: string): boolean { + try { + getProviderForPty(ptyId).write(ptyId, data) + return true + } catch { + return false + } +} + +export async function probePtyLivenessFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string +): Promise { + try { + // Why: no locally routed provider can authoritatively answer for a + // remote host's PTY, so remote-scoped ids stay unknown, never absent. + if (ptyId.startsWith('remote:')) { + return null + } + const connectionId = ptyOwnership.get(ptyId) ?? parseAppSshPtyId(ptyId)?.connectionId + // Why: during cold start the daemon swap is in flight; the pre-swap + // fallback would answer absent for every daemon-owned id. + const startupPromise = deps.getLocalPtyProviderStartupPromise(connectionId) + if (startupPromise) { + await startupPromise + } + const provider = getProviderForPty(ptyId) + if (provider.probePtyLiveness) { + return await provider.probePtyLiveness(ptyId) + } + // Why: the in-process provider is its own sole owner (#12393), so its + // refusal is authoritative; every other probe-less provider is doubt. + if (provider instanceof LocalPtyProvider) { + return provider.hasPty(ptyId) + } + return null + } catch { + return null + } +} + +export async function attachPtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string +): Promise { + if (ptyOwnership.get(ptyId) != null || parseAppSshPtyId(ptyId)) { + return false + } + let provider: IPtyProvider + try { + provider = getProviderForPty(ptyId) + } catch { + return false + } + if (provider !== localProvider || provider instanceof LocalPtyProvider) { + return false + } + try { + const sequenceBeforeProviderAttach = deps.runtime?.getPtyOutputSequence?.(ptyId) ?? 0 + const attachResult = await provider.attach(ptyId) + if (attachResult?.providerSequence) { + deps.runtime?.synchronizePtyOutputSequenceFromProvider?.( + ptyId, + attachResult.providerSequence, + sequenceBeforeProviderAttach + ) + } + return true + } catch { + return false + } +} + +export async function getForegroundProcessFromRuntimeController(ptyId: string) { + try { + return await getProviderForPty(ptyId).getForegroundProcess(ptyId) + } catch { + return null + } +} + +export async function inspectProcessFromRuntimeController(ptyId: string) { + return inspectPtyProviderProcess(getProviderForPty(ptyId), ptyId) +} + +export async function confirmForegroundProcessFromRuntimeController(ptyId: string) { + try { + const provider = getProviderForPty(ptyId) + // Why: cached foreground evidence cannot resolve a fresh shell conflict. + return (await provider.confirmForegroundProcess?.(ptyId)) ?? null + } catch { + return null + } +} + +export async function getCwdFromRuntimeController(ptyId: string) { + try { + const cwd = await getProviderForPty(ptyId).getCwd(ptyId) + return cwd || null + } catch { + return null + } +} + +export async function hasChildProcessesFromRuntimeController(ptyId: string) { + try { + return await getProviderForPty(ptyId).hasChildProcesses(ptyId) + } catch { + return false + } +} + +export async function clearBufferFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string +): Promise { + // Why: desktop xterm and daemon/SSH providers hold separate buffers; clear both so mobile resubscribe can't resurrect cleared history. + deps.mainWindow.webContents.send('pty:clearBuffer:request', { ptyId }) + try { + await getProviderForPty(ptyId).clearBuffer(ptyId) + } catch { + /* best effort: renderer clear still handles local PTYs */ + } +} + +export function hasPtyFromRuntimeController(ptyId: string): boolean | null { + try { + return getProviderForPty(ptyId).hasPty?.(ptyId) ?? null + } catch { + return null + } +} + +function markSshInventoryUnverifiable( + runtime: PtyRuntimeControllerDeps['runtime'], + connectionId: string, + error: unknown +): void { + const reason = error instanceof Error ? error.message : String(error) + for (const [ptyId, ownerConnectionId] of ptyOwnership) { + if (ownerConnectionId === connectionId) { + runtime?.markPtyLivenessUnverifiable?.(ptyId, reason) + } + } +} + +export async function listProcessesWithHostScopeFromRuntimeController( + deps: PtyRuntimeControllerDeps, + opts?: { deadlineMs?: number } +): Promise<{ processes: PtyProcessInfo[]; hostIds: ExecutionHostId[] }> { + const providerSessions = await Promise.all( + registeredPtyProviders().map(async ({ provider, connectionId }) => { + const hostId: ExecutionHostId = connectionId + ? toSshExecutionHostId(connectionId) + : LOCAL_EXECUTION_HOST_ID + try { + return { + processes: await (connectionId ? provider.listProcesses(opts) : provider.listProcesses()), + hostId + } + } catch (error) { + if (!connectionId) { + throw error + } + markSshInventoryUnverifiable(deps.runtime, connectionId, error) + return null + } + }) + ) + const respondingSessions = providerSessions.filter((session) => session !== null) + return { + processes: respondingSessions.flatMap((session) => session.processes), + hostIds: respondingSessions.map((session) => session.hostId) + } +} + +export async function listProcessesFromRuntimeController( + deps: PtyRuntimeControllerDeps, + connectionId?: string | null, + opts?: { deadlineMs?: number } +) { + if (connectionId === null) { + return localProvider.listProcesses() + } + if (connectionId !== undefined) { + try { + return await getProvider(connectionId).listProcesses(opts) + } catch (error) { + markSshInventoryUnverifiable(deps.runtime, connectionId, error) + throw error + } + } + return (await listProcessesWithHostScopeFromRuntimeController(deps, opts)).processes +} + +export function resizePtyFromRuntimeController(ptyId: string, cols: number, rows: number): boolean { + try { + getProviderForPty(ptyId).resize(ptyId, cols, rows) + ptySizes.set(ptyId, { cols, rows }) + return true + } catch { + return false + } +} + +export function hasRendererSerializerFromRuntimeController(ptyId: string): boolean { + // Why: a synchronous probe lets the runtime decide whether to skip the daemon-snapshot seed (renderer will hydrate) or run it (no renderer authoritative). + return rendererSerializerReadiness.has(ptyId) +} + +export function getRendererSerializerGenerationFromRuntimeController(ptyId: string) { + return rendererSerializerReadiness.generation(ptyId) +} + +export function waitForRendererSerializerFromRuntimeController( + ptyId: string, + afterGeneration = 0, + timeoutMs?: number, + signal?: AbortSignal +) { + return rendererSerializerReadiness.wait(ptyId, afterGeneration ?? 0, timeoutMs, signal) +} + +export function getSizeFromRuntimeController(ptyId: string) { + return ptySizes.get(ptyId) ?? null +} + +export async function serializeProviderBufferFromRuntimeController( + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } +) { + try { + // Why: restored daemon PTYs can be live while their desktop pane is unmounted; query the provider model so phone-local navigation works. + return (await getProviderForPty(ptyId).getBufferSnapshot?.(ptyId, opts)) ?? null + } catch { + return null + } +} diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts new file mode 100644 index 00000000000..ecf4f4ebcd4 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -0,0 +1,307 @@ +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' +import { ptySizes } from '../delivery/visibility-state' +import { getRelayPtyId } from '../provider/registry' +import { + shouldSkipCodexHomeEnvForWindowsShell, + recordCodexPaneAccountForSpawn, + codexReattachedHomeRouteField +} from '../host-env/codex-home' +import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' +import { registerPty } from '../../../memory/pty-registry' +import { rememberPaneKeyForPty } from '../pane/key-state' +import { + pendingByPaneKey, + pendingPtyIdBySerializerGeneration, + rendererSerializerReadiness +} from '../pane/serializer-state' +import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' +import { track } from '../../../telemetry/client' +import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' +import { + agentKindSchema, + launchSourceSchema, + requestKindSchema +} from '../../../../shared/telemetry-events' +import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' +import { + isNativeWindowsLocalPtySpawn, + markNativeWindowsConptyPty +} from '../../../runtime/terminal-model-query-authority' +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' +import { clearProviderPtyState } from '../provider/state-cleanup' +import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' +import type { RuntimePtySpawnState } from './spawn-state' + +export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { + const args = ctx.args + try { + ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + store: ctx.hostSessionBinding?.store, + owner: ctx.stablePaneOwner, + result: ctx.result, + worktreeId: ctx.hostSessionBinding?.worktreeId, + startupCwd: ctx.cwd, + connectionId: args.connectionId + }) + } catch (error) { + if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { + throw error + } + console.error('[pty] failed to persist runtime PTY binding after attach:', error) + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } + if (ctx.result.agentSessionEnsure?.disposition === 'adopted') { + const owner = ctx.result.agentSessionEnsure.owner + ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null) + ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle) + if (ctx.result.incarnationId) { + ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) + } + ctx.deps.runtime?.registerPty( + ctx.result.id, + owner.surface.worktreeId, + args.connectionId ?? null, + { + tabId: owner.surface.tabId, + leafId: owner.surface.leafId, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}) + } + ) + if (!args.connectionId) { + ctx.deps.options?.onCodexHomePtySpawned?.({ + id: ctx.result.id, + codexHomePath: ctx.selectedCodexHomePath, + reattached: true, + startedAt: ctx.codexHomeLaunchStartedAt, + startedSequence: ctx.codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField(ctx.reattachedCodexHomeRoutes, ctx.result.id, true), + ...(ctx.env ? { launchEnv: ctx.env } : {}) + }) + } + // Why: the adopted branch returns before the normal settle site, so the + // reservation must be resolved here or every later spawn for this pane + // awaits a promise that never settles. + resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, { + ...ctx.result, + isReattach: true + }) + return { + id: ctx.result.id, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + agentSessionEnsure: ctx.result.agentSessionEnsure + } + } + ptyOwnership.set(ctx.result.id, args.connectionId ?? null) + if (ctx.result.incarnationId) { + ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) + } + // Why: record the native-Windows-local-PTY determination before any byte reaches the emulator, so its ConPTY DA1 override exists from byte zero. + if ( + isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: ctx.daemonShellOverride + }) + ) { + markNativeWindowsConptyPty(ctx.result.id) + } + const relayResultId = getRelayPtyId(args.connectionId, ctx.result.id) + const persistSshLease = (): void => { + if (!ctx.deps.store || !args.connectionId) { + return + } + // Why: SSH leases keep relay ids for remote reconciliation, while session bindings keep app-facing ids for hydration. + ctx.deps.store.upsertSshRemotePtyLease({ + targetId: args.connectionId, + ptyId: relayResultId, + ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), + ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), + ...(typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) + ? { leafId: args.leafId } + : {}), + state: 'attached', + lastAttachedAt: Date.now() + }) + } + if (!ctx.hostSessionBinding) { + persistSshLease() + } + ptySizes.set(ctx.result.id, { cols: args.cols, rows: args.rows }) + if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { + ptySizes.delete(ctx.effectiveSessionAppId) + } + recordCodexPaneAccountForSpawn({ + ptyId: ctx.result.id, + isDaemonHostSpawn: ctx.isDaemonHostSpawn, + isReattach: ctx.result.isReattach === true, + pinnedByResume: ctx.codexResumeHomeSelected, + launchCodexHomePath: ctx.selectedCodexHomePath, + launchEnv: args.env, + target: ctx.codexSelectionTarget, + settings: ctx.deps.getSettings?.() + }) + if (ctx.hostSessionBinding && !ctx.stablePaneBindingPersisted) { + try { + const binding = { + worktreeId: ctx.hostSessionBinding.worktreeId, + tabId: ctx.hostSessionBinding.tabId, + leafId: ctx.hostSessionBinding.leafId, + ptyId: ctx.result.id, + hostAdmittedMembership: true, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), + ...(ctx.hostSessionBinding.expectedSourceBinding + ? { expectedSourceBinding: ctx.hostSessionBinding.expectedSourceBinding } + : {}) + } + const persisted = args.connectionId + ? ctx.hostSessionBinding.store.persistPtyBinding( + binding, + toSshExecutionHostId(args.connectionId) + ) + : ctx.hostSessionBinding.store.persistPtyBinding(binding) + if (persisted === false) { + throw new Error('terminal_split_source_not_found') + } + } catch (err) { + console.error('[pty] failed to persist runtime PTY binding after spawn:', err) + if (!ctx.result.isReattach) { + deletePtyOwnership(ctx.result.id) + try { + await ctx.provider.shutdown(ctx.result.id, { immediate: true }) + } catch (shutdownErr) { + console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) + } + clearProviderPtyState(ctx.result.id) + } + if (err instanceof Error && err.message === 'terminal_split_source_not_found') { + throw err + } + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } + persistSshLease() + } + if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { + ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle) + } + if (args.worktreeId) { + ctx.deps.runtime?.registerPty( + ctx.result.id, + args.worktreeId, + args.connectionId ?? null, + // Why: thread validated pane identity so main can back a pending mobile create even if graph-sync stalls (#7587). + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + ctx.metadataLeafId !== null + ? { + tabId: args.tabId, + leafId: ctx.metadataLeafId, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}) + } + : undefined, + !args.connectionId + ? shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd) + : undefined + ) + } else { + // Why: non-worktree PTYs have no later surface-registration phase to clear admission intent. + ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) + } + // Why: runtime-controller creates (headless serve, CLI, splits) adopt surviving daemon sessions too; without this seed their records stay blank. + seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) + // Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity). + if (!ctx.stablePaneOwner) { + ctx.deps.runtime?.noteTerminalSpawnCommand?.(ctx.result.id, ctx.launchCommand ?? null) + } + if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { + markClaudePtySpawned(ctx.result.id) + } + if (args.telemetry && !ctx.stablePaneOwner) { + const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) + const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) + const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) + if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { + track('agent_started', { + agent_kind: agentKindParse.data, + launch_source: launchSourceParse.data, + request_kind: requestKindParse.data, + ...getCohortAtEmit() + }) + } + } + // Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; record paneKey here too since hook titles and cache cleanup need this reverse lookup. + const paneKey = rememberPaneKeyForPty(ctx.result.id, ctx.env?.ORCA_PANE_KEY) + const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined + const inheritRendererReadiness = + ctx.result.isReattach === true && + !pendingSerializer && + rendererSerializerReadiness.has(ctx.result.id) + rendererSerializerReadiness.beginIncarnation(ctx.result.id, inheritRendererReadiness) + if (paneKey && pendingSerializer) { + pendingPtyIdBySerializerGeneration.set(pendingSerializer.gen, ctx.result.id) + } + if (!args.connectionId) { + registerPty({ + ptyId: ctx.result.id, + worktreeId: args.worktreeId ?? null, + sessionId: ctx.sessionId ?? null, + paneKey, + pid: + typeof ctx.result.pid === 'number' && Number.isFinite(ctx.result.pid) && ctx.result.pid > 0 + ? ctx.result.pid + : null + }) + } + // Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal. + ctx.deps.sendPtySpawnedToRenderer(ctx.result.id) + if (!args.connectionId) { + ctx.deps.options?.onCodexHomePtySpawned?.({ + id: ctx.result.id, + codexHomePath: ctx.selectedCodexHomePath, + startedAt: ctx.codexHomeLaunchStartedAt, + startedSequence: ctx.codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField( + ctx.reattachedCodexHomeRoutes, + ctx.result.id, + ctx.result.isReattach === true + ), + ...(ctx.result.isReattach === true + ? { reattached: true } + : ctx.env + ? { launchEnv: ctx.env } + : {}) + }) + } + const response = { + id: ctx.result.id, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(ctx.stablePaneOwner && (ctx.stablePaneOwner.handle || args.preAllocatedHandle) + ? { + stablePaneOwner: { + handle: ctx.stablePaneOwner.handle ?? args.preAllocatedHandle!, + tabId: ctx.stablePaneOwner.tabId, + leafId: ctx.stablePaneOwner.leafId + } + } + : {}), + ...(ctx.result.agentSessionEnsure ? { agentSessionEnsure: ctx.result.agentSessionEnsure } : {}) + } + resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, { + ...ctx.result, + ...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' && + ctx.reconciledSnapshotSeq !== null && + ctx.snapshotKittyFlagsCoverReconciledSeq + ? { snapshotSeq: ctx.reconciledSnapshotSeq } + : { snapshotKittyKeyboardFlags: undefined }), + isReattach: true + }) + return response +} diff --git a/src/main/ipc/pty/runtime/spawn-early.ts b/src/main/ipc/pty/runtime/spawn-early.ts new file mode 100644 index 00000000000..575f89b22e7 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-early.ts @@ -0,0 +1,71 @@ +import type { PtySpawnResult } from '../../../providers/types' +import { getAppPtyId } from '../provider/registry' +import { allocatePtyLifecycleSequence } from '../host-env/types' +import { snapshotCodexPaneHomeRoutes, codexReattachedHomeRouteField } from '../host-env/codex-home' +import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' +import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' +import type { RuntimePtySpawnState } from './spawn-state' + +export function adoptMaterializedRuntimePtySpawn( + ctx: RuntimePtySpawnState, + startupAlreadyAwaited = false +): Promise | PtySpawnResult | null { + const args = ctx.args + // Why: the re-entry after the startup await must not re-mint the lifecycle + // sequence or re-snapshot routes taken before the barrier. + if (!startupAlreadyAwaited) { + ctx.codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined + ctx.codexHomeLaunchStartedSequence = !args.connectionId + ? allocatePtyLifecycleSequence() + : undefined + ctx.preAdoptedStablePane = args.adoptedStablePane ?? null + ctx.reattachedCodexHomeRoutes = !args.connectionId + ? new Map( + snapshotCodexPaneHomeRoutes([ + ctx.preAdoptedStablePane?.result.id, + args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined + ]) + ) + : new Map() + } + const startupPromise = ctx.deps.getLocalPtyStartupPromise(args.connectionId) + if (startupPromise && !startupAlreadyAwaited) { + return startupPromise.then(() => adoptMaterializedRuntimePtySpawn(ctx, true)) + } + if (!ctx.preAdoptedStablePane?.materialized) { + return null + } + const handle = ctx.preAdoptedStablePane.owner.handle ?? args.preAllocatedHandle + if (!handle) { + throw new Error('terminal_pane_owner_unknown') + } + ctx.result = { + id: ctx.preAdoptedStablePane.result.id, + ...(ctx.preAdoptedStablePane.result.incarnationId + ? { incarnationId: ctx.preAdoptedStablePane.result.incarnationId } + : {}), + ...(typeof ctx.preAdoptedStablePane.result.wslDistro === 'string' + ? { wslDistro: ctx.preAdoptedStablePane.result.wslDistro } + : {}), + stablePaneOwner: { + handle, + tabId: ctx.preAdoptedStablePane.owner.tabId, + leafId: ctx.preAdoptedStablePane.owner.leafId + } + } + ensureWslHookRelayForReattach( + { isReattach: true, wslDistro: ctx.preAdoptedStablePane.result.wslDistro }, + args.connectionId + ) + if (!args.connectionId) { + ctx.deps.options?.onCodexHomePtySpawned?.({ + id: ctx.result.id, + codexHomePath: null, + reattached: true, + startedAt: ctx.codexHomeLaunchStartedAt, + startedSequence: ctx.codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField(ctx.reattachedCodexHomeRoutes, ctx.result.id, true) + }) + } + return ctx.result +} diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts new file mode 100644 index 00000000000..99829f119bb --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -0,0 +1,262 @@ +import type { PtySpawnResult } from '../../../providers/types' +import { ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' +import { ptySizes } from '../delivery/visibility-state' +import { tryGetProviderForAgentSessionOwner } from '../provider/registry' +import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' +import { + agentSessionOwners, + assertSpawnReplyWasLive, + reconcileAgentSessionOwnerListings +} from '../pane/agent-session-owners' +import { spawnForStablePane, resolveStablePaneOwner } from '../pane/stable-owner' +import { clearProviderPtyState } from '../provider/state-cleanup' +import { isProviderAgentSessionOwnerLive, normalizeNodePtySpawnError } from '../provider/liveness' +import { + SSH_SESSION_EXPIRED_ERROR, + isSshPtyIdentityMismatchError +} from '../../../providers/ssh-pty-errors' +import type { RuntimePtySpawnState } from './spawn-state' + +export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise { + const args = ctx.args + const runtime = ctx.deps.runtime + const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn + ctx.releaseWorktreeSpawn = acquireWorktreeSpawn + ? await acquireWorktreeSpawn.call(runtime, args.worktreeId) + : undefined + try { + if (args.preAllocatedHandle) { + ctx.deps.trustedTerminalHandleEnv.add(args.preAllocatedHandle) + } + const stablePaneOwnerCandidate = ctx.preAdoptedStablePane + ? ctx.preAdoptedStablePane.owner + : args.agentSessionEnsure + ? null + : resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ) + const expectedPtyId = + stablePaneOwnerCandidate?.ptyId ?? ctx.effectiveSessionAppId ?? ctx.sessionId + if (expectedPtyId) { + ctx.deps.runtime?.beginPtyRegistration?.(expectedPtyId) + ctx.pendingRegistrationPtyId = expectedPtyId + } + if (ctx.isDaemonHostSpawn && expectedPtyId) { + ctx.preparedProvisionalExecutionContext = + ctx.deps.runtime?.preparePtyExecutionContext?.(expectedPtyId, ctx.expectedWslDistro, { + resetIncarnation: ctx.isNewDaemonSession && !stablePaneOwnerCandidate, + preserveExisting: !ctx.isNewDaemonSession || Boolean(stablePaneOwnerCandidate) + }) ?? false + } + const sequenceBeforeProviderSpawn = expectedPtyId + ? (ctx.deps.runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) + : 0 + const assertClientStillConnected = (): void => { + if (args.signal?.aborted) { + throw new Error('client_disconnected') + } + } + if (args.agentSessionEnsure && !ctx.preAdoptedStablePane) { + // Why: daemon-backed claims can outlive this controller; import all + // proven owners before deciding that an identity is absent. + await reconcileAgentSessionOwnerListings() + const recoveredOwner = agentSessionOwners.find(args.agentSessionEnsure.claim) + if (recoveredOwner && ctx.pendingRegistrationPtyId !== recoveredOwner.ptyId) { + if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.pendingRegistrationPtyId) + } + ctx.deps.runtime?.beginPtyRegistration?.( + recoveredOwner.ptyId, + ptyIncarnationById.get(recoveredOwner.ptyId) + ) + ctx.pendingRegistrationPtyId = recoveredOwner.ptyId + } + let providerResult: PtySpawnResult | null = null + const ensured = await agentSessionOwners.ensure({ + claim: args.agentSessionEnsure.claim, + surface: args.agentSessionEnsure.surface, + spawn: async () => { + assertClientStillConnected() + providerResult = await ctx.provider.spawn(ctx.spawnOptions) + ctx.rejectedRegistrationCandidate = providerResult + // Why: a successful lower-owner return proves physical work committed even if admission sees an early exit. + ctx.reportPtySpawnCommitted() + assertSpawnReplyWasLive(providerResult) + ctx.deps.runtime?.assertPtyRegistrationAllowed?.( + providerResult.id, + providerResult.incarnationId + ) + if (providerResult.incarnationId) { + // Why: local providers cannot serialize controller claims, so liveness proof + // needs the exact incarnation before the registry promotes the new owner. + ptyIncarnationById.set(providerResult.id, providerResult.incarnationId) + } + const providerEnsure = providerResult.agentSessionEnsure + return { + ptyId: providerResult.id, + ...(providerEnsure + ? { + owner: providerEnsure.owner, + disposition: providerEnsure.disposition + } + : {}) + } + }, + isLive: async (owner) => { + const ownerProvider = tryGetProviderForAgentSessionOwner(owner.ptyId) + if (!ownerProvider) { + // Why: a disconnected relay may keep its PTY alive during the + // grace window; missing transport is unknown, never absence. + throw new Error('execution_owner_unavailable') + } + return await isProviderAgentSessionOwnerLive(ownerProvider, owner) + } + }) + ctx.result = providerResult ?? { + id: ensured.owner.ptyId, + isReattach: true, + // Why: adoption from an authoritative listing must preserve the + // incarnation proof used to reject a delayed exit from an older process. + incarnationId: ptyIncarnationById.get(ensured.owner.ptyId) + } + ctx.result.agentSessionEnsure = ensured + } else { + assertClientStillConnected() + const stablePaneSpawn = ctx.preAdoptedStablePane + ? ctx.preAdoptedStablePane + : await spawnForStablePane({ + runtime: ctx.deps.runtime, + store: ctx.deps.store, + provider: ctx.provider, + spawnOptions: ctx.spawnOptions, + owner: stablePaneOwnerCandidate, + worktreeId: args.worktreeId, + connectionId: args.connectionId, + resolveOwner: () => + resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ), + onFreshSpawn: ctx.reportPtySpawnCommitted + }) + ctx.result = stablePaneSpawn.result + ctx.stablePaneOwner = stablePaneSpawn.owner + if ( + ctx.stablePaneOwner && + ctx.isNewDaemonSession && + ctx.effectiveSessionAppId && + ctx.effectiveSessionAppId !== ctx.result.id + ) { + clearProviderPtyState(ctx.effectiveSessionAppId) + } + ctx.rejectedRegistrationCandidate = ctx.result + assertSpawnReplyWasLive(ctx.result) + } + ctx.rejectedRegistrationCandidate ??= ctx.result + if (ctx.pendingRegistrationPtyId !== ctx.result.id) { + if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.pendingRegistrationPtyId) + } + ctx.deps.runtime?.beginPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) + ctx.pendingRegistrationPtyId = ctx.result.id + } + // Why: admission precedes sequence/context state and every durable publication below. + ctx.deps.runtime?.assertPtyRegistrationAllowed?.(ctx.result.id, ctx.result.incarnationId) + if (ctx.result.providerSequence) { + const runtimeSequenceBeforeReconcile = + ctx.deps.runtime?.getPtyOutputSequence?.(ctx.result.id) ?? 0 + // Why kept: this is the reattach boundary in the RENDERER's sequence + // domain, and the daemon snapshot's kitty flags mean nothing without + // the boundary they were proven at. + ctx.reconciledSnapshotSeq = + ctx.deps.runtime?.synchronizePtyOutputSequenceFromProvider?.( + ctx.result.id, + ctx.result.providerSequence, + sequenceBeforeProviderSpawn + ) ?? null + if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) { + ctx.snapshotKittyFlagsCoverReconciledSeq = false + } + } + ensureWslHookRelayForReattach(ctx.result, args.connectionId) + ctx.deps.runtime?.preparePtyExecutionContext?.( + ctx.result.id, + args.connectionId + ? null + : ctx.result.wslDistro === undefined + ? ctx.expectedWslDistro + : ctx.result.wslDistro + ) + } catch (err) { + if ( + (ctx.isNewDaemonSession || ctx.preparedProvisionalExecutionContext) && + ctx.effectiveSessionAppId + ) { + ctx.deps.runtime?.preparePtyExecutionContext?.(ctx.effectiveSessionAppId, null, { + resetIncarnation: true + }) + } + const rawMessage = err instanceof Error ? err.message : String(err) + if (rawMessage === 'agent_session_exited_during_start' && ctx.rejectedRegistrationCandidate) { + ctx.deps.runtime?.releaseRejectedPtyRegistrationFence?.( + ctx.rejectedRegistrationCandidate.id, + ctx.rejectedRegistrationCandidate.incarnationId + ) + } + if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.( + ctx.pendingRegistrationPtyId, + ctx.rejectedRegistrationCandidate?.incarnationId + ) + ctx.pendingRegistrationPtyId = null + } + const spawnError = normalizeNodePtySpawnError(err) + const isIdentityMismatch = + isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage) + const isExpiredSshSession = + Boolean(args.connectionId) && + (spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) || + rawMessage.includes(SSH_SESSION_EXPIRED_ERROR)) + const exitedBeforeSpawnReply = + ctx.rejectedRegistrationCandidate?.exitedBeforeSpawnReply === true + if (ctx.effectiveSessionAppId !== undefined) { + if ( + ctx.hadSessionSizeBeforeAttach && + ctx.sessionSizeBeforeAttach && + (isIdentityMismatch || (!isExpiredSshSession && !exitedBeforeSpawnReply)) + ) { + ptySizes.set(ctx.effectiveSessionAppId, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(ctx.effectiveSessionAppId) + } + } + if (args.connectionId && ctx.effectiveSessionRelayId !== undefined && isExpiredSshSession) { + if (ctx.effectiveSessionAppId !== undefined && !isIdentityMismatch) { + clearProviderPtyState(ctx.effectiveSessionAppId) + deletePtyOwnership(ctx.effectiveSessionAppId) + } + if (!isIdentityMismatch) { + ctx.deps.store?.markSshRemotePtyLease( + args.connectionId, + ctx.effectiveSessionRelayId, + 'expired' + ) + } + } + if (ctx.isNewDaemonSession && ctx.sessionId !== undefined) { + clearProviderPtyState(ctx.sessionId) + } + throw spawnError + } finally { + if (args.preAllocatedHandle) { + ctx.deps.trustedTerminalHandleEnv.delete(args.preAllocatedHandle) + } + } +} diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts new file mode 100644 index 00000000000..fcba7770a39 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -0,0 +1,250 @@ +import type { IPtyProvider, PtySpawnResult } from '../../../providers/types' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { makePaneKey, isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { ptySizes } from '../delivery/visibility-state' +import { CODEX_HOME_ENV_KEYS } from '../host-env/codex-home' +import { + mergePtyEnvDeletions, + removeCodexHomeDeletionRequests, + getInheritedAgentHookEnvKeysToDelete, + getInheritedClaudeSessionStampEnvKeysToDelete +} from '../host-env/pi-agent' +import { promoteAgentTeamsShimPath, deleteRequestedEnvKeys } from '../host-env/path' +import { + routesFreshSpawnsToLocalProvider, + beginPtySpawnForWorktree +} from '../host-env/fresh-spawn-routing' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' +import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' +import { resolveStablePaneOwner } from '../pane/stable-owner' +import { getStartupTerminalColorQueryReplyColors } from '../../terminal-startup-color-query-replies' +import { + makePaneSpawnReservationKey, + reservePaneSpawn, + paneSpawnReservationsByOwnerKey +} from '../pane/spawn-reservation' +import type { RuntimePtySpawnState } from './spawn-state' + +export async function buildRuntimePtySpawnOptions( + ctx: RuntimePtySpawnState +): Promise< + (PtySpawnResult & { stablePaneOwner?: { handle: string; tabId: string; leafId: string } }) | null +> { + const args = ctx.args + + const authEnvToDelete = ctx.claudeAuth?.stripAuthEnv + ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] + : undefined + ctx.spawnOptions = { + cols: args.cols, + rows: args.rows, + cwd: ctx.cwd, + env: ctx.env, + historyIsolationEnabled: ctx.deps.getSettings?.()?.terminalScopeHistoryByWorktree ?? true, + ...(ctx.isNewDaemonSession ? { isNewSession: true } : {}) + } + if (!args.connectionId && !ctx.isDaemonHostSpawn) { + ctx.spawnOptions.codexHomePathOverride = { value: ctx.selectedCodexHomePath } + } + const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) + if (startupTerminalColorQueryReplyColors) { + ctx.spawnOptions.startupIngress = { + colors: startupTerminalColorQueryReplyColors, + deadlineMs: 5_000 + } + } + let ptySpawnCommitReported = false + ctx.reportPtySpawnCommitted = (): void => { + if (ptySpawnCommitReported) { + return + } + ptySpawnCommitReported = true + args.onPtySpawnCommitted?.() + } + ctx.spawnOptions.envToDelete = mergePtyEnvDeletions( + authEnvToDelete, + args.envToDelete ?? [], + // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. + ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], + ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.env) : [], + // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. + getInheritedClaudeSessionStampEnvKeysToDelete(ctx.env) + ) + if (ctx.skipCodexHomeEnv) { + ctx.spawnOptions.envToDelete = mergePtyEnvDeletions( + ctx.spawnOptions.envToDelete, + CODEX_HOME_ENV_KEYS + ) + } else if (ctx.stripInheritedOrcaCodexHome) { + // Why: the daemon owns a persistent inherited environment that may + // differ from main. ORCA_CODEX_HOME asks it to compare/delete the pair. + ctx.spawnOptions.envToDelete = mergePtyEnvDeletions(ctx.spawnOptions.envToDelete, [ + 'ORCA_CODEX_HOME' + ]) + } + if (ctx.codexResumeHomeSelected) { + ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete) + } + deleteRequestedEnvKeys(ctx.env, ctx.spawnOptions.envToDelete) + promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) + if (ctx.launchCommand !== undefined) { + ctx.spawnOptions.command = ctx.launchCommand + } + if (args.commandDelivery !== undefined) { + ctx.spawnOptions.commandDelivery = args.commandDelivery + } + if (args.startupCommandDelivery !== undefined) { + ctx.spawnOptions.startupCommandDelivery = args.startupCommandDelivery + } + if (isTuiAgent(args.launchAgent)) { + ctx.spawnOptions.launchAgent = args.launchAgent + } + if (args.worktreeId !== undefined) { + ctx.spawnOptions.worktreeId = args.worktreeId + } + ctx.hadSessionSizeBeforeAttach = + ctx.effectiveSessionAppId !== undefined ? ptySizes.has(ctx.effectiveSessionAppId) : false + ctx.sessionSizeBeforeAttach = + ctx.effectiveSessionAppId !== undefined ? ptySizes.get(ctx.effectiveSessionAppId) : undefined + if (ctx.sessionId !== undefined) { + ctx.spawnOptions.sessionId = ctx.sessionId + ptySizes.set(ctx.effectiveSessionAppId ?? ctx.sessionId, { cols: args.cols, rows: args.rows }) + } + ctx.materializedPaneKey = ctx.hostSessionBinding + ? makePaneKey(ctx.hostSessionBinding.tabId, ctx.hostSessionBinding.leafId) + : null + ctx.metadataLeafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + ctx.metadataPaneKey = + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + ctx.metadataLeafId + ? makePaneKey(args.tabId, ctx.metadataLeafId) + : null + ctx.spawnIdentityPaneKey = ctx.materializedPaneKey ?? ctx.metadataPaneKey + if (ctx.spawnIdentityPaneKey) { + ctx.spawnOptions.paneKey = ctx.spawnIdentityPaneKey + } + if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) { + ctx.spawnOptions.tabId = args.tabId + } + if (process.platform === 'win32' && !args.connectionId) { + ctx.spawnOptions.shellOverride = ctx.terminalRuntimeOptions.shellOverride + ctx.spawnOptions.terminalWindowsWslDistro = ctx.expectedWslDistro + ctx.spawnOptions.terminalWindowsPowerShellImplementation = ctx.deps.getSettings + ? (ctx.deps.getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') + : undefined + } + if ( + !ctx.preAdoptedStablePane && + args.agentSessionEnsure && + (await (ctx.provider as IPtyProvider).supportsAgentSessionClaims?.()) === false + ) { + // Why: runtime routing must select legacy before dispatch; never downgrade here after it began. + throw new Error('agent_session_claim_unavailable') + } + if ( + !ctx.preAdoptedStablePane && + args.agentSessionCreateOperationId && + (await (ctx.provider as IPtyProvider).supportsAgentSessionCreateOperations?.()) === false + ) { + throw new Error('execution_owner_unavailable') + } + if (!ctx.preAdoptedStablePane && args.agentSessionEnsure) { + ctx.spawnOptions.agentSessionEnsure = args.agentSessionEnsure + } + if (!ctx.preAdoptedStablePane && args.agentSessionCreateOperationId) { + ctx.spawnOptions.agentSessionCreateOperationId = args.agentSessionCreateOperationId + } + if (args.signal) { + ctx.spawnOptions.signal = args.signal + } + if ( + args.onPtySpawnCommitted && + (ctx.provider instanceof LocalPtyProvider || routesFreshSpawnsToLocalProvider(ctx.provider)) + ) { + // Why: local fallback has no lower operation ledger, so commit must be reported at native spawn. + ctx.spawnOptions.onPtySpawnCommitted = ctx.reportPtySpawnCommitted + } + + const resolvedPaneSpawnReservationKey = makePaneSpawnReservationKey( + args.worktreeId, + args.connectionId, + ctx.spawnIdentityPaneKey + ) + if ( + ctx.paneSpawnReservationKey && + ctx.paneSpawnReservationKey !== resolvedPaneSpawnReservationKey + ) { + throw new Error('terminal_pane_identity_changed') + } + if (!ctx.paneSpawnReservationKey) { + ctx.paneSpawnReservationKey = resolvedPaneSpawnReservationKey + const existingPaneSpawn = ctx.spawnIdentityPaneKey + ? paneSpawnReservationsByOwnerKey.get(resolvedPaneSpawnReservationKey!) + : undefined + if (existingPaneSpawn) { + const concurrentResult = await existingPaneSpawn.promise + const concurrentOwner = resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ) + if ( + !concurrentOwner?.handle || + concurrentOwner.ptyId !== concurrentResult.id || + (concurrentOwner.incarnationId !== undefined && + concurrentResult.incarnationId !== undefined && + concurrentOwner.incarnationId !== concurrentResult.incarnationId) + ) { + throw new Error('terminal_pane_owner_unknown') + } + const reattach = { + id: concurrentOwner.ptyId, + ...(concurrentOwner.incarnationId ? { incarnationId: concurrentOwner.incarnationId } : {}), + stablePaneOwner: { + handle: concurrentOwner.handle, + tabId: concurrentOwner.tabId, + leafId: concurrentOwner.leafId + } + } + // Why: the winner owns its id's size; only a distinct losing session id remains provisional. + if (ctx.sessionId !== undefined) { + const provisionalSizeKey = ctx.effectiveSessionAppId ?? ctx.sessionId + if (provisionalSizeKey !== reattach.id) { + if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) { + ptySizes.set(provisionalSizeKey, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(provisionalSizeKey) + } + } + } + return reattach + } + } + ctx.finishTerminalInstall = beginPtySpawnForWorktree(args.worktreeId, ctx.cwd, args.connectionId) + ctx.paneSpawnReservation ??= ctx.paneSpawnReservationKey + ? reservePaneSpawn(ctx.paneSpawnReservationKey) + : null + ctx.stablePaneOwner = null + ctx.stablePaneBindingPersisted = false + ctx.rejectedRegistrationCandidate = null + ctx.pendingRegistrationPtyId = null + // Why hoisted to the reply scope: main reconciles the provider sequence + // deep inside the spawn path, but the pane needs that renderer-domain + // boundary beside the daemon snapshot's kitty flags. + ctx.reconciledSnapshotSeq = null + // False when bytes crossed the data socket during the spawn RPC: the + // reconciled boundary covers them, but the daemon proved its kitty flags + // before they existed, so the claim must not erase what the pane may + // have scanned from those bytes live. + ctx.snapshotKittyFlagsCoverReconciledSeq = true + ctx.preparedProvisionalExecutionContext = false + + return null +} diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts new file mode 100644 index 00000000000..45c3ea1172f --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -0,0 +1,281 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import type { PtySpawnResult } from '../../../providers/types' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { getAppPtyId, getProvider, getRelayPtyId } from '../provider/registry' +import { buildPtyHostEnv } from '../host-env/assembly' +import { + getCompatibleSelectedCodexHomePath, + getCodexSelectionTargetForPty, + resolveCodexHomeAfterManagedAuthReadiness, + shouldSkipCodexHomeEnvForWindowsShell, + shouldStripInheritedOrcaCodexHome, + isCodexStatusHooksEnabled, + codexHomePathsEqual +} from '../host-env/codex-home' +import { promoteAgentTeamsShimPath } from '../host-env/path' +import { + isClaudeLaunchCommand, + recoverFreshSpawnProviderRouting, + routesFreshSpawnsToLocalProvider +} from '../host-env/fresh-spawn-routing' +import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' +import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' +import { + isSafePtySessionId, + mintPtySessionId, + ptySessionIdForAgentCreateOperation +} from '../../../daemon/pty-session-id' +import { resolveWslSessionContext } from '../../../daemon/wsl-session-context' +import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' +import { resolveLocalWindowsTerminalRuntimeOptions } from '../../../../shared/local-windows-terminal-runtime' +import { resolveLocalProjectRuntimeForWorktreeId } from '../../../local-project-runtime-resolution' +import { resolvePathEnvKey } from '../../../pty/windows-environment-path' +import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' +import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery' +import { clearProviderPtyState } from '../provider/state-cleanup' +import type { RuntimePtySpawnState } from './spawn-state' + +export async function prepareRuntimePtySpawn( + ctx: RuntimePtySpawnState +): Promise { + const args = ctx.args + if (!ctx.preAdoptedStablePane) { + const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + if (pathUsable) { + await pathUsable + } + } + ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) + ctx.provider = getProvider(args.connectionId) + const freshSpawnRecovery = ctx.preAdoptedStablePane + ? undefined + : recoverFreshSpawnProviderRouting( + ctx.provider, + args.connectionId, + args.sessionId, + args.isNewSession + ) + if (freshSpawnRecovery) { + await freshSpawnRecovery + } + ctx.isClaudeLaunch = + !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) + if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + // Why: runtime-created terminals carry no renderer-computed projectRuntime; resolve from worktreeId to honor the project's Windows runtime. + ctx.terminalRuntimeOptions = + process.platform === 'win32' && !args.connectionId + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: undefined, + settings: ctx.deps.getSettings?.(), + projectRuntime: resolveLocalProjectRuntimeForWorktreeId(ctx.deps.store, args.worktreeId), + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : { shellOverride: undefined, terminalWindowsWslDistro: null } + ctx.daemonShellOverride = ctx.terminalRuntimeOptions.shellOverride + ctx.isDaemonHostSpawn = + !args.connectionId && + !(ctx.provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(ctx.provider) + ctx.callerRequestedSessionId = args.sessionId?.trim() + ctx.requestedSessionId = + ctx.callerRequestedSessionId ?? + (ctx.isDaemonHostSpawn && args.agentSessionCreateOperationId + ? ptySessionIdForAgentCreateOperation(args.worktreeId, args.agentSessionCreateOperationId) + : undefined) + ctx.sessionId = + ctx.requestedSessionId ?? + (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) + ctx.effectiveSessionRelayId = + ctx.sessionId !== undefined ? getRelayPtyId(args.connectionId, ctx.sessionId) : undefined + ctx.effectiveSessionAppId = + ctx.sessionId !== undefined ? getAppPtyId(args.connectionId, ctx.sessionId) : undefined + ctx.isNewDaemonSession = + !ctx.preAdoptedStablePane && + ctx.isDaemonHostSpawn && + (ctx.callerRequestedSessionId === undefined || args.isNewSession === true) + ctx.expectedWslDistro = !args.connectionId + ? (resolveWslSessionContext({ + cwd: ctx.cwd, + sessionId: ctx.sessionId, + shellOverride: ctx.terminalRuntimeOptions.shellOverride, + terminalWindowsWslDistro: ctx.terminalRuntimeOptions.terminalWindowsWslDistro + })?.distro ?? null) + : null + ctx.codexSelectionTarget = getCodexSelectionTargetForPty( + ctx.daemonShellOverride, + ctx.cwd, + ctx.expectedWslDistro + ) + const codexResumePreparation = ctx.preAdoptedStablePane + ? null + : ctx.deps.prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: ctx.codexSelectionTarget, + launchEnv: args.env, + workspacePath: ctx.cwd + }) + const codexResumeLaunch = codexResumePreparation + ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) + : ctx.deps.noCodexResumeLaunch(ctx.preAdoptedStablePane ? undefined : args.command) + const codexResumeHome = codexResumeLaunch.codexResumeHome + // Why: the drop still applies here, but this controller's result has no field for + // notifyResumeUnavailable — runtime/relay panes start fresh without the notice. + ctx.launchCommand = codexResumeLaunch.command + ctx.claudeAuth = + ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth + ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) + : null + if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { + throw new Error( + 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' + ) + } + + ctx.shouldPersistHostSessionBinding = args.persistHostSessionBinding === true + if (ctx.shouldPersistHostSessionBinding) { + if ( + !ctx.deps.store || + typeof args.worktreeId !== 'string' || + typeof args.tabId !== 'string' || + !isValidTerminalTabId(args.tabId) || + typeof args.leafId !== 'string' || + !isTerminalLeafId(args.leafId) + ) { + throw new Error('Cannot persist runtime PTY binding without worktreeId, tabId, and leafId') + } + ctx.hostSessionBinding = { + store: ctx.deps.store, + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: args.leafId, + ...(args.expectedSourceBinding ? { expectedSourceBinding: args.expectedSourceBinding } : {}) + } + } + const sshScopedEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env) + ctx.env = ctx.claudeAuth ? { ...sshScopedEnv, ...ctx.claudeAuth.envPatch } : sshScopedEnv + ctx.requestedAgentTeamsPath = ctx.env?.ORCA_AGENT_TEAMS_TEAM_ID + ? ctx.env[resolvePathEnvKey(ctx.env, process.platform)] + : undefined + ctx.env = ctx.deps.stripSequencedStartupResumeArgv(ctx.env, codexResumeLaunch) + if (args.preAllocatedHandle) { + ctx.env = { ...ctx.env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } + } + ctx.selectedCodexHomePath = + !ctx.preAdoptedStablePane && !args.connectionId + ? getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + codexResumeHome + ? ctx.deps.reconcileSharedRuntimeResumeHome(codexResumeHome, () => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { + workspacePath: ctx.cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null + ) + ) + : (ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { + workspacePath: ctx.cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null) + ) + : null + if ( + !ctx.preAdoptedStablePane && + args.launchAgent === 'codex' && + ctx.callerRequestedSessionId === undefined + ) { + const resolution = resolveCodexHomeAfterManagedAuthReadiness({ + selectedCodexHomePath: ctx.selectedCodexHomePath, + getSettings: () => ctx.deps.getSettings?.(), + requiredCodexHomePath: codexResumeHome?.codexHomePath, + target: ctx.codexSelectionTarget, + resolveCurrent: () => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { + workspacePath: ctx.cwd, + launchAgent: 'codex' + }) ?? null + ), + resolveAfterUnavailable: (unavailableManagedHomePath) => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { + workspacePath: ctx.cwd, + launchAgent: 'codex', + unavailableManagedHomePath + }) ?? null + ) + }) + ctx.selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution + } + if (args.launchAgent === 'codex' && ctx.selectedCodexHomePath) { + await ensureCodexStateDbBackfillRecoveryStarted(ctx.selectedCodexHomePath) + } + ctx.codexResumeHomeSelected = Boolean( + codexResumeHome && codexHomePathsEqual(ctx.selectedCodexHomePath, codexResumeHome.codexHomePath) + ) + ctx.skipCodexHomeEnv = + ctx.isDaemonHostSpawn && + shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd) && + !ctx.selectedCodexHomePath + const ptySettings = ctx.isDaemonHostSpawn ? ctx.deps.getSettings?.() : undefined + ctx.stripInheritedOrcaCodexHome = + ctx.isDaemonHostSpawn && + shouldStripInheritedOrcaCodexHome({ + target: ctx.codexSelectionTarget, + selectedCodexHomePath: ctx.selectedCodexHomePath, + skipCodexHomeEnv: ctx.skipCodexHomeEnv, + settings: ptySettings + }) + if (ctx.isDaemonHostSpawn && ctx.sessionId && !ctx.preAdoptedStablePane) { + if (!isSafePtySessionId(ctx.sessionId, getAppEnvironment().getPath('userData'))) { + throw new Error('Invalid PTY session id') + } + try { + ctx.env = buildPtyHostEnv(ctx.sessionId, ctx.env ?? {}, { + isPackaged: getAppEnvironment().isPackaged(), + resourcesPath: process.resourcesPath, + userDataPath: getAppEnvironment().getPath('userData'), + selectedCodexHomePath: ctx.selectedCodexHomePath, + skipCodexHomeEnv: ctx.skipCodexHomeEnv, + stripInheritedOrcaCodexHome: ctx.stripInheritedOrcaCodexHome, + launchCommand: ctx.launchCommand, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd), + wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), + codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), + networkProxySettings: ptySettings, + deferGitConfigGuardToDaemon: + ctx.provider.supportsGitCredentialGuardHost?.(ctx.sessionId) === true + }) + stampWslOrchestrationCompatibilityHost( + ctx.env, + ctx.deps.runtime?.getOrchestrationCompatibilityHostId?.(), + ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null + ) + promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) + } catch (error) { + // Why: host-env setup can materialize agent hooks/extensions before failing. + if (ctx.requestedSessionId === undefined) { + clearProviderPtyState(ctx.sessionId) + } + throw error + } + } + + return null +} diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts new file mode 100644 index 00000000000..81878fa029b --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -0,0 +1,185 @@ +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types' +import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' +import type { StablePaneOwner } from '../pane/stable-owner' +import type { PaneSpawnReservation } from '../pane/spawn-reservation' +import type { AdoptStablePaneResult } from '../ipc/spawn-types' +import type { PtyBindingSourceExpectation } from '../../../persistence' +import type { PtyRuntimeControllerDeps } from './controller-deps' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' +import type { + AgentSessionExecutionClaim, + AgentSessionSurfaceBinding +} from '../../../../shared/agent-session-host-authority' +import { localProvider } from '../provider/registry' + +export type RuntimePtySpawnState = { + deps: PtyRuntimeControllerDeps + args: RuntimePtySpawnArgs + codexHomeLaunchStartedAt: Date | undefined + codexHomeLaunchStartedSequence: number | undefined + preAdoptedStablePane: AdoptStablePaneResult | null + reattachedCodexHomeRoutes: Map + cwd: string | undefined + provider: IPtyProvider + isClaudeLaunch: boolean + terminalRuntimeOptions: { shellOverride?: string; terminalWindowsWslDistro?: string | null } + daemonShellOverride: string | undefined + isDaemonHostSpawn: boolean + callerRequestedSessionId: string | undefined + requestedSessionId: string | undefined + sessionId: string | undefined + effectiveSessionRelayId: string | undefined + effectiveSessionAppId: string | undefined + isNewDaemonSession: boolean + expectedWslDistro: string | null + codexSelectionTarget: CodexAccountSelectionTarget + launchCommand: string | undefined + claudeAuth: ClaudeRuntimeAuthPreparation | null + shouldPersistHostSessionBinding: boolean + hostSessionBinding: + | { + store: NonNullable + worktreeId: string + tabId: string + leafId: string + expectedSourceBinding?: PtyBindingSourceExpectation + } + | undefined + env: Record | undefined + requestedAgentTeamsPath: string | undefined + selectedCodexHomePath: string | null + codexResumeHomeSelected: boolean + skipCodexHomeEnv: boolean + stripInheritedOrcaCodexHome: boolean + spawnOptions: PtySpawnOptions + hadSessionSizeBeforeAttach: boolean + sessionSizeBeforeAttach: { cols: number; rows: number } | undefined + materializedPaneKey: string | null + metadataLeafId: string | null + metadataPaneKey: string | null + spawnIdentityPaneKey: string | null + paneSpawnReservationKey: string | null + paneSpawnReservation: PaneSpawnReservation | null + finishTerminalInstall: () => void + result: PtySpawnResult & { + stablePaneOwner?: { handle: string; tabId: string; leafId: string } + agentSessionEnsure?: unknown + } + stablePaneOwner: StablePaneOwner | null + stablePaneBindingPersisted: boolean + rejectedRegistrationCandidate: PtySpawnResult | null + pendingRegistrationPtyId: string | null + reconciledSnapshotSeq: number | null + snapshotKittyFlagsCoverReconciledSeq: boolean + preparedProvisionalExecutionContext: boolean + releaseWorktreeSpawn: (() => void) | undefined + reportPtySpawnCommitted: () => void +} + +export type RuntimePtySpawnArgs = { + cols: number + rows: number + cwd?: string + command?: string + launchAgent?: TuiAgent + commandDelivery?: 'renderer' | 'provider' + startupCommandDelivery?: StartupCommandDelivery + telemetry?: { + agent_kind?: unknown + launch_source?: unknown + request_kind?: unknown + } + env?: Record + envToDelete?: string[] + resumeProviderSession?: AgentProviderSessionMetadata + connectionId?: string | null + worktreeId?: string + preAllocatedHandle?: string + tabId?: string + leafId?: string + sessionId?: string + isNewSession?: boolean + persistHostSessionBinding?: boolean + expectedSourceBinding?: PtyBindingSourceExpectation + terminalColorQueryReplies?: { foreground?: string; background?: string } + agentSessionEnsure?: { + claim: AgentSessionExecutionClaim + surface: AgentSessionSurfaceBinding + } + agentSessionCreateOperationId?: string + signal?: AbortSignal + onPtySpawnCommitted?: () => void + adoptedStablePane?: { + result: PtySpawnResult + owner: { + handle?: string + tabId: string + leafId: string + ptyId: string + incarnationId?: string + } + materialized?: true + } +} + +export function createRuntimePtySpawnState( + deps: PtyRuntimeControllerDeps, + args: RuntimePtySpawnArgs +): RuntimePtySpawnState { + return { + deps, + args, + codexHomeLaunchStartedAt: undefined, + codexHomeLaunchStartedSequence: undefined, + preAdoptedStablePane: null, + reattachedCodexHomeRoutes: new Map(), + cwd: undefined, + provider: localProvider, + isClaudeLaunch: false, + terminalRuntimeOptions: {}, + daemonShellOverride: undefined, + isDaemonHostSpawn: false, + callerRequestedSessionId: undefined, + requestedSessionId: undefined, + sessionId: undefined, + effectiveSessionRelayId: undefined, + effectiveSessionAppId: undefined, + isNewDaemonSession: false, + expectedWslDistro: null, + codexSelectionTarget: { runtime: 'host' }, + launchCommand: undefined, + claudeAuth: null, + shouldPersistHostSessionBinding: false, + hostSessionBinding: undefined, + env: undefined, + requestedAgentTeamsPath: undefined, + selectedCodexHomePath: null, + codexResumeHomeSelected: false, + skipCodexHomeEnv: false, + stripInheritedOrcaCodexHome: false, + spawnOptions: { cols: args.cols, rows: args.rows }, + hadSessionSizeBeforeAttach: false, + sessionSizeBeforeAttach: undefined, + materializedPaneKey: null, + metadataLeafId: null, + metadataPaneKey: null, + spawnIdentityPaneKey: null, + paneSpawnReservationKey: null, + paneSpawnReservation: null, + finishTerminalInstall: () => {}, + result: { id: '' }, + stablePaneOwner: null, + stablePaneBindingPersisted: false, + rejectedRegistrationCandidate: null, + pendingRegistrationPtyId: null, + reconciledSnapshotSeq: null, + snapshotKittyFlagsCoverReconciledSeq: true, + preparedProvisionalExecutionContext: false, + releaseWorktreeSpawn: undefined, + reportPtySpawnCommitted: () => {} + } +} diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts new file mode 100644 index 00000000000..2af050cd796 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -0,0 +1,119 @@ +import type { AgentSessionClaimedSpawnResult } from '../../../../shared/agent-session-host-authority' +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { + makePaneSpawnReservationKey, + paneSpawnReservationsByOwnerKey, + rejectPaneSpawnReservation, + reservePaneSpawn +} from '../pane/spawn-reservation' +import { resolveStablePaneOwner } from '../pane/stable-owner' +import { ptySizes } from '../delivery/visibility-state' +import type { PtyRuntimeControllerDeps } from './controller-deps' +import { adoptMaterializedRuntimePtySpawn } from './spawn-early' +import { prepareRuntimePtySpawn } from './spawn-preflight' +import { buildRuntimePtySpawnOptions } from './spawn-options' +import { executeRuntimePtySpawn } from './spawn-execute' +import { commitRuntimePtySpawn } from './spawn-commit' +import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' + +function toRuntimeSpawnReply(result: { + id: string + incarnationId?: string + wslDistro?: string | null + stablePaneOwner?: { handle: string; tabId: string; leafId: string } + agentSessionEnsure?: AgentSessionClaimedSpawnResult +}) { + return { + id: result.id, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(typeof result.wslDistro === 'string' ? { wslDistro: result.wslDistro } : {}), + ...(result.stablePaneOwner ? { stablePaneOwner: result.stablePaneOwner } : {}), + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) + } +} + +function restoreProvisionalPtySize(ctx: ReturnType): void { + if (ctx.sessionId === undefined) { + return + } + const key = ctx.effectiveSessionAppId ?? ctx.sessionId + if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) { + ptySizes.set(key, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(key) + } +} + +export async function spawnPtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + args: RuntimePtySpawnArgs +) { + const ctx = createRuntimePtySpawnState(deps, args) + if (!args.adoptedStablePane) { + const leafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + const paneKey = + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + leafId + ? makePaneKey(args.tabId, leafId) + : null + const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) + const existingOwner = paneKey + ? resolveStablePaneOwner( + deps.runtime, + deps.store, + paneKey, + args.worktreeId, + args.connectionId + ) + : null + if (ownerKey && !existingOwner && !paneSpawnReservationsByOwnerKey.has(ownerKey)) { + ctx.paneSpawnReservationKey = ownerKey + ctx.paneSpawnReservation = reservePaneSpawn(ownerKey) + } + } + try { + const materializedOrPromise = adoptMaterializedRuntimePtySpawn(ctx) + const materialized = + materializedOrPromise instanceof Promise ? await materializedOrPromise : materializedOrPromise + if (materialized) { + return toRuntimeSpawnReply(materialized) + } + const earlyAdopt = await prepareRuntimePtySpawn(ctx) + if (earlyAdopt) { + return toRuntimeSpawnReply(earlyAdopt) + } + const earlyReserved = await buildRuntimePtySpawnOptions(ctx).catch((error: unknown) => { + restoreProvisionalPtySize(ctx) + throw error + }) + if (earlyReserved) { + return toRuntimeSpawnReply(earlyReserved) + } + await executeRuntimePtySpawn(ctx) + return toRuntimeSpawnReply(await commitRuntimePtySpawn(ctx)) + } catch (err) { + if (ctx.pendingRegistrationPtyId) { + deps.runtime?.cancelPendingPtyRegistration?.( + ctx.pendingRegistrationPtyId, + ctx.rejectedRegistrationCandidate?.incarnationId + ) + ctx.pendingRegistrationPtyId = null + } + // Why: once the reservation is created, any later throw — spawn + // failure, persist failure, or a post-spawn helper such as + // registerPty/rememberPaneKeyForPty/track — must settle it. Otherwise + // it lingers in paneSpawnReservationsByOwnerKey and every future spawn + // for this pane awaits a promise that never resolves. reject is a + // no-op once the reservation has already resolved. + rejectPaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, err) + throw err + } finally { + ctx.releaseWorktreeSpawn?.() + ctx.finishTerminalInstall() + } +} diff --git a/src/main/ipc/pty/session.ts b/src/main/ipc/pty/session.ts new file mode 100644 index 00000000000..423ecabbe3d --- /dev/null +++ b/src/main/ipc/pty/session.ts @@ -0,0 +1,289 @@ +import type { BrowserWindow } from 'electron' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { Store } from '../../persistence' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { IPtyProvider } from '../../providers/types' +import type { LegacySshProjectionSemantics } from '../ssh-pty-legacy-projection' +import type { PtyModelRestoreReason } from '../../../shared/pty-model-restore-marker' +import type { + PtyDeliveryWriteOff, + PtyRendererDeliveryStateReport +} from '../../../shared/pty-renderer-delivery-health' +import type { PtyRendererDeliveryDebugSnapshot } from './delivery/debug' +import { PtyProducerFlowController } from '../pty-producer-flow-control' +import { PtyPendingDataDrainQueue, type PendingPtyData } from '../pty-pending-data-drain-queue' +import type { SshPtyOutputIntake } from '../ssh-pty-output-intake' +import { activeRendererPtys } from './delivery/visibility-state' +import { + isHiddenPtyDeliveryGateEnabled, + shouldDropHiddenRendererPtyData +} from '../pty-hidden-delivery-gate' +import type { CodexHomePtySpawnedLifecycleArgs, PrepareCodexSessionResume } from './host-env/types' +import { tryGetProviderForPty } from './provider/registry' + +export type PtyDataPayload = { + id: string + data: string + seq?: number + rawLength?: number + transformed?: boolean + background?: boolean + droppedOutput?: boolean +} + +export type RendererPtyDeliveryAccounting = { + sentChars: number + ackedChars: number + lastSendAtMs: number + lastAckAtMs: number | null +} + +export type SerializeResult = { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + kittyKeyboardFlags?: number +} | null + +export type PtyIpcSessionOptions = { + prepareCodexSessionResume?: PrepareCodexSessionResume + awaitLocalPtyStartup?: () => Promise + awaitLocalPtyProviderStartup?: () => Promise + // Why: returns true once for the crash-recovery reload so its did-finish-load skips the orphan sweep and keeps live PTYs (#5787). + isRecoveryReloadInFlight?: (webContentsId: number) => boolean + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + onPtyExit?: (id: string, exitSequence: number) => void +} + +export type PtyIpcSession = { + mainWindow: BrowserWindow + runtime?: OrcaRuntimeService + store?: Store + getSettings?: () => GlobalSettings + options?: PtyIpcSessionOptions + pendingData: PtyPendingDataDrainQueue + sshOutputIntake: SshPtyOutputIntake | null + rendererExitingPtyIds: Set + rendererCreditBeforeExitByPty: Map + rendererDeliveryRestoreNeededPtys: Set + pendingOverflowMarkedPtys: Set + rendererDeliveryAccountingByPty: Map + trustedTerminalHandleEnv: Set + flushTimer: ReturnType | null + pendingDataFlushActive: boolean + pendingDataCreditReleasedDuringFlush: boolean + rendererInFlightTotalChars: number + pendingDroppedChars: number + deliveryResyncRequestSerial: number + deliveryResyncOutstandingRequestId: number | null + deliveryResyncTimer: ReturnType | null + deliveryResyncUnansweredWarnLogged: boolean + lastAckReceivedAtMs: number | null + peakPendingChars: number + peakMaxPendingCharsByPty: number + peakRendererInFlightChars: number + peakMaxRendererInFlightCharsByPty: number + ackGatedFlushSkipCount: number + rendererLifecycleResetCount: number + lastLifecycleResetClearedChars: number + rendererDispatcherReadyForcedCount: number + rendererPtyDispatcherReady: boolean + dispatcherReadyWatchdogTimer: ReturnType | null + lastHiddenDropContradictionWarnAtMs: number + pendingDataDropWarnedPtys: Set + producerFlowControl: PtyProducerFlowController + sourceCreditPendingPtys: Set + backgroundedDeliverySyncByPty: Map + syntheticKillExitPtyIds: Map + reversibleStopOwnersByPtyId: Map + retiredRejectedPtyIds: Map + pendingSerializeRequests: Map< + string, + { resolve: (result: SerializeResult) => void; timeout: NodeJS.Timeout } + > + canSendPtyDataToRenderer: (id: string, options?: { interactive?: boolean }) => boolean + schedulePendingDataFlush: (delayMs: number) => void + flushPendingData: () => void + sendPtyDataToRenderer: ( + id: string, + payload: PtyDataPayload, + projectionAdmissionIds?: readonly string[] + ) => { sent: boolean; projectionsTransferred: boolean } + sendModelRestoreNeededMarker: ( + id: string, + reason: PtyModelRestoreReason, + markerSeq: number | undefined + ) => boolean + updateProducerFlowControl: (id: string) => void + applyCumulativeAck: (id: string, processedChars: number) => number + readCurrentPtyRendererDeliveryDebugSnapshot: () => PtyRendererDeliveryDebugSnapshot + clearDeliveryResyncProbe: () => void + clearPendingPtyData: () => void + deletePendingPtyData: (id: string) => void + setPendingPtyData: (id: string, pending: PendingPtyData) => void + clearDispatcherReadyWatchdog: () => void + armDispatcherReadyWatchdog: () => void + acceptPtyDataForRenderer: ( + payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + }, + outputSeq: number | undefined, + projection?: LegacySshProjectionSemantics + ) => void + preparePtyExitForRenderer: (payload: { + id: string + code: number + incarnationId?: string + }) => (() => void) | null + finalizePtyExitForRenderer: (payload: { + id: string + code: number + incarnationId?: string + }) => void + sendPtyExitToRenderer: (payload: { id: string; code: number; incarnationId?: string }) => void + sendPtySpawnedToRenderer: (id: string) => void + requestSerializedBuffer: ( + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + ) => Promise + shutdownProviderAndDetectExit: ( + provider: IPtyProvider, + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ) => Promise + rememberSyntheticKillExit: (id: string) => void + rememberRetiredRejectedPty: (id: string) => void + consumeSyntheticKillExit: (id: string) => boolean + syncPtyBackgroundedDelivery: (id: string, caller: string) => void + resyncBackgroundedDeliveriesAfterGateReset: () => void + transitionHiddenRendererPtyDeliveryState: ( + id: string, + hidden: boolean + ) => { droppable: boolean; droppedWhileHidden: boolean; policyChanged: boolean } + transitionSpawnHiddenRendererPtyDeliveryState: (id: string, hidden: boolean) => void + rendererPtyIsKnownHidden: (id: string) => boolean + clearHiddenRendererResizeOutput: (id: string) => void + clearDeliveredHiddenRendererResizeOutput: (id: string) => void + schedulePendingDataAfterCreditReport: (creditedAny: boolean) => void + writeOffLostRendererDelivery: (report: PtyRendererDeliveryStateReport) => PtyDeliveryWriteOff[] + getRendererInFlightCharsForPty: (id: string) => number +} + +const unsetSessionFn = (): never => { + throw new Error('pty ipc session function used before wiring') +} + +export function createPtyIpcSession(args: { + mainWindow: BrowserWindow + runtime?: OrcaRuntimeService + store?: Store + getSettings?: () => GlobalSettings + options?: PtyIpcSessionOptions +}): PtyIpcSession { + const session: PtyIpcSession = { + mainWindow: args.mainWindow, + runtime: args.runtime, + store: args.store, + getSettings: args.getSettings, + options: args.options, + pendingData: undefined as unknown as PtyPendingDataDrainQueue, + sshOutputIntake: null, + rendererExitingPtyIds: new Set(), + rendererCreditBeforeExitByPty: new Map(), + rendererDeliveryRestoreNeededPtys: new Set(), + pendingOverflowMarkedPtys: new Set(), + rendererDeliveryAccountingByPty: new Map(), + trustedTerminalHandleEnv: new Set(), + flushTimer: null, + pendingDataFlushActive: false, + pendingDataCreditReleasedDuringFlush: false, + rendererInFlightTotalChars: 0, + pendingDroppedChars: 0, + deliveryResyncRequestSerial: 0, + deliveryResyncOutstandingRequestId: null, + deliveryResyncTimer: null, + deliveryResyncUnansweredWarnLogged: false, + lastAckReceivedAtMs: null, + peakPendingChars: 0, + peakMaxPendingCharsByPty: 0, + peakRendererInFlightChars: 0, + peakMaxRendererInFlightCharsByPty: 0, + ackGatedFlushSkipCount: 0, + rendererLifecycleResetCount: 0, + lastLifecycleResetClearedChars: 0, + rendererDispatcherReadyForcedCount: 0, + rendererPtyDispatcherReady: false, + dispatcherReadyWatchdogTimer: null, + lastHiddenDropContradictionWarnAtMs: 0, + pendingDataDropWarnedPtys: new Set(), + producerFlowControl: new PtyProducerFlowController({ + pauseProducer: (id) => tryGetProviderForPty(id)?.pauseProducer?.(id), + resumeProducer: (id) => tryGetProviderForPty(id)?.resumeProducer?.(id) + }), + sourceCreditPendingPtys: new Set(), + backgroundedDeliverySyncByPty: new Map(), + syntheticKillExitPtyIds: new Map(), + reversibleStopOwnersByPtyId: new Map(), + retiredRejectedPtyIds: new Map(), + pendingSerializeRequests: new Map(), + canSendPtyDataToRenderer: unsetSessionFn, + schedulePendingDataFlush: unsetSessionFn, + flushPendingData: unsetSessionFn, + sendPtyDataToRenderer: unsetSessionFn, + sendModelRestoreNeededMarker: unsetSessionFn, + updateProducerFlowControl: unsetSessionFn, + applyCumulativeAck: unsetSessionFn, + readCurrentPtyRendererDeliveryDebugSnapshot: unsetSessionFn, + clearDeliveryResyncProbe: unsetSessionFn, + clearPendingPtyData: unsetSessionFn, + deletePendingPtyData: unsetSessionFn, + setPendingPtyData: unsetSessionFn, + clearDispatcherReadyWatchdog: unsetSessionFn, + armDispatcherReadyWatchdog: unsetSessionFn, + acceptPtyDataForRenderer: unsetSessionFn, + preparePtyExitForRenderer: unsetSessionFn, + finalizePtyExitForRenderer: unsetSessionFn, + sendPtyExitToRenderer: unsetSessionFn, + sendPtySpawnedToRenderer: unsetSessionFn, + requestSerializedBuffer: unsetSessionFn, + shutdownProviderAndDetectExit: unsetSessionFn, + rememberSyntheticKillExit: unsetSessionFn, + rememberRetiredRejectedPty: unsetSessionFn, + consumeSyntheticKillExit: unsetSessionFn, + syncPtyBackgroundedDelivery: unsetSessionFn, + resyncBackgroundedDeliveriesAfterGateReset: unsetSessionFn, + transitionHiddenRendererPtyDeliveryState: unsetSessionFn, + transitionSpawnHiddenRendererPtyDeliveryState: unsetSessionFn, + rendererPtyIsKnownHidden: unsetSessionFn, + clearHiddenRendererResizeOutput: unsetSessionFn, + clearDeliveredHiddenRendererResizeOutput: unsetSessionFn, + schedulePendingDataAfterCreditReport: unsetSessionFn, + writeOffLostRendererDelivery: unsetSessionFn, + getRendererInFlightCharsForPty: unsetSessionFn + } + + session.pendingData = new PtyPendingDataDrainQueue( + (id) => { + const runnableLane = activeRendererPtys.has(id) ? 'active' : 'background' + // Why first: hidden bytes are dropped from main's pending queue even when renderer credit is exhausted. + if (shouldDropHiddenRendererPtyData(id, session.getSettings?.())) { + return runnableLane + } + if ( + !session.rendererPtyDispatcherReady || + !session.canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) }) + ) { + return 'blocked' + } + return runnableLane + }, + () => isHiddenPtyDeliveryGateEnabled(session.getSettings?.()) + ) + + return session +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d1646a58c6d..d571939534c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -30409,6 +30409,12 @@ export class OrcaRuntimeService { }) } + // Why: a leader handle that never binds to a PTY (lost pane race) has no exit + // or close path to evict its team, so the abandoning caller must release it. + releaseClaudeAgentTeamsLeaderForHandle(handle: string): void { + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + } + private waitForNewLeafInTab( tabId: string, existingLeafKeys: Set, diff --git a/src/main/ssh/ssh-connection-sftp-namespace.test.ts b/src/main/ssh/ssh-connection-sftp-namespace.test.ts index e79f16d65e0..dd568e4032c 100644 --- a/src/main/ssh/ssh-connection-sftp-namespace.test.ts +++ b/src/main/ssh/ssh-connection-sftp-namespace.test.ts @@ -296,25 +296,33 @@ describe('SshConnection SFTP namespace resolution', () => { }) it('aborts a transfer stuck in discovery and reports a confirmed channel close', async () => { - const sftp = createFakeSftp({ pendingRealpath: true }) - const conn = connectedTo([sftp]) - const controller = new AbortController() + vi.useFakeTimers() + try { + const sftp = createFakeSftp({ pendingRealpath: true }) + const conn = connectedTo([sftp]) + const controller = new AbortController() - const write = conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { - hostPlatform: linux, - sftpNamespace: fileNamespace('.version'), - signal: controller.signal - }) - await vi.waitFor(() => expect(sftp.realpathCalls).toHaveLength(1)) - controller.abort() + const write = conn + .writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version'), + signal: controller.signal + }) + .catch((err: Error) => err) + await vi.waitFor(() => expect(sftp.realpathCalls).toHaveLength(1)) + controller.abort() + await vi.advanceTimersByTimeAsync(5_000) - const error = (await write.catch((err: Error) => err)) as Error & { - sshChannelCloseConfirmed?: boolean + const error = (await write) as Error & { + sshChannelCloseConfirmed?: boolean + } + expect(error.name).toBe('AbortError') + expect(error.sshChannelCloseConfirmed).toBe(true) + expect(sftp.endCalls).toBe(1) + expect(sftp.writtenPaths).toEqual([]) + } finally { + vi.useRealTimers() } - expect(error.name).toBe('AbortError') - expect(error.sshChannelCloseConfirmed).toBe(true) - expect(sftp.endCalls).toBe(1) - expect(sftp.writtenPaths).toEqual([]) }) it('reports an unconfirmed close when the aborted session never closes', async () => { @@ -389,7 +397,9 @@ describe('SshConnection SFTP namespace resolution', () => { }) .catch((err: Error) => err) await vi.waitFor(() => expect(sftp.pendingRealpathCallbacks).toHaveLength(1)) + vi.useFakeTimers() controller.abort() + await vi.advanceTimersByTimeAsync(5_000) const error = (await write) as Error & { sshChannelCloseConfirmed?: boolean } expect(error).toMatchObject({ @@ -398,13 +408,15 @@ describe('SshConnection SFTP namespace resolution', () => { }) completeRealpath(sftp.pendingRealpathCallbacks[0]!) - await new Promise((resolve) => setImmediate(resolve)) + await Promise.resolve() + await Promise.resolve() expect(unhandledRejection).not.toHaveBeenCalled() expect(sftp.writtenPaths).toEqual([]) expect(sftp.endCalls).toBe(1) } finally { process.off('unhandledRejection', unhandledRejection) + vi.useRealTimers() } } ) From a117bffb47d8ef430d5454f4081caa4863b13d22 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:37:17 -0700 Subject: [PATCH 04/65] perf(renderer): project terminal topology consumers (#16171) --- src/renderer/src/components/Terminal.tsx | 31 +++-- .../dashboard/useRetainedAgents.test.ts | 104 ++++++++++++++- .../components/dashboard/useRetainedAgents.ts | 40 ++++-- .../visible-worktree-activity-inputs.test.ts | 23 ++++ .../visible-worktree-activity-inputs.ts | 91 +++---------- ...terminal-hidden-worktree-retention.test.ts | 102 +++++++++----- .../terminal-hidden-worktree-retention.ts | 26 ++-- .../src/lib/session-write-subscriber.test.ts | 50 +++++++ .../src/lib/session-write-subscriber.ts | 125 +++++------------- .../src/lib/worktree-tab-bucket-projection.ts | 66 +++++++++ 10 files changed, 422 insertions(+), 236 deletions(-) create mode 100644 src/renderer/src/lib/worktree-tab-bucket-projection.ts diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index a05e006df5f..1f035a234eb 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -119,7 +119,7 @@ import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, countEvictionExemptTabRoutes, formatEvictionExemptRouteCounts, - getTerminalWorktreeParkingInputsKey, + createTerminalWorktreeTopologyProjection, hasPendingRetentionSpawnWork, selectForceParkEvictableTabIds, selectRetentionForceParkedTerminalWorktrees, @@ -192,11 +192,13 @@ import { } from './terminal-pane/use-manual-terminal-worktree-parking' import { EDITOR_TAB_CONTENT_TYPES, getEditorCmdSaveFileId } from './editor/editor-cmd-save-target' import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' +import type { WorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' const EditorPanel = lazy(() => import('./editor/EditorPanel')) // Why: gate handler runs after a dialog advances so a stray carry-over click can't act on the next dialog; ~200ms absorbs a physical double-click while staying responsive. const CLOSE_DIALOG_DEBOUNCE_MS = 200 +const EMPTY_TERMINAL_TABS: TerminalTab[] = [] type TerminalStoreSnapshot = ReturnType function showClientCreationActionError(error: unknown): void { @@ -293,8 +295,19 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext { ? 'terminal' : 'app' } +function LiveTerminalTabBar( + props: Omit, 'tabs'> +): React.JSX.Element { + const tabs = useAppStore((state) => state.tabsByWorktree[props.worktreeId] ?? EMPTY_TERMINAL_TABS) + return +} function Terminal(): React.JSX.Element | null { + const terminalTopologyProjectionRef = useRef | null>(null) + terminalTopologyProjectionRef.current ??= createTerminalWorktreeTopologyProjection() const mountedWorktreeIdsRef = useRef(new Set()) // Why an array: browser-guest eviction needs activation order (LRU), not just membership. const browserGuestWorktreeRecencyRef = useRef([]) @@ -325,10 +338,11 @@ function Terminal(): React.JSX.Element | null { getResolvedExecutionHostIdForWorktree(s, renderedActiveWorktreeId) ) const activeView = useAppStore((s) => s.activeView) - const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) - const terminalWorktreeParkingInputsKey = useMemo( - () => getTerminalWorktreeParkingInputsKey(tabsByWorktree), - [tabsByWorktree] + // Why: terminal titles are leaf chrome. The root host only subscribes to + // mount/parking semantics; a real transition publishes fresh tab objects, + // while LiveTerminalTabBar reads title-only updates from the active bucket. + const tabsByWorktree = useAppStore((s) => + terminalTopologyProjectionRef.current!.project(s.tabsByWorktree) ) const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId) const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false) @@ -1184,7 +1198,7 @@ function Terminal(): React.JSX.Element | null { pendingStartupByTabId, pairedRuntimeParkingEnvironmentIds, renderedActiveWorktreeId, - terminalWorktreeParkingInputsKey, + tabsByWorktree, terminalParkingEnabled, terminalParkingRevision, terminalProviderSnapshotCapabilityRevision, @@ -2457,10 +2471,9 @@ function Terminal(): React.JSX.Element | null { !effectiveActiveLayout && titlebarTabsTarget && createPortal( - ): Worktree { return { id: 'wt-1', repoId: 'repo-1', @@ -50,7 +50,8 @@ function makeWorktree(): Worktree { isUnread: false, isPinned: false, sortOrder: 0, - lastActivityAt: 1 + lastActivityAt: 1, + ...overrides } } @@ -227,6 +228,103 @@ describe('collectRetainedAgentsOnDisappear', () => { }) describe('useRetainedAgentsSync', () => { + it('does not build a fleet snapshot for a title-only publication across 300 worktrees', async () => { + const repo = makeRepo() + const worktrees = Array.from({ length: 300 }, (_, index) => + makeWorktree({ id: `wt-${index}`, path: `/repo/wt-${index}` }) + ) + const tabsByWorktree = Object.fromEntries( + worktrees.map((worktree, index) => [ + worktree.id, + [makeTab({ id: `tab-${index}`, worktreeId: worktree.id, title: `Title ${index}` })] + ]) + ) + const retainAgents = vi.fn() + const pruneRetainedAgents = vi.fn() + useAppStore.setState({ + repos: [repo], + worktreesByRepo: { [repo.id]: worktrees }, + tabsByWorktree, + retainAgents, + pruneRetainedAgents + }) + const hook = renderHook(() => useRetainedAgentsSync()) + await act(async () => { + await Promise.resolve() + }) + retainAgents.mockClear() + pruneRetainedAgents.mockClear() + + const changedWorktreeId = 'wt-173' + act(() => { + useAppStore.setState({ + tabsByWorktree: { + ...tabsByWorktree, + [changedWorktreeId]: [ + { + ...tabsByWorktree[changedWorktreeId][0], + title: 'Updated display title' + } + ] + } + }) + }) + await act(async () => { + await Promise.resolve() + }) + + expect(retainAgents).not.toHaveBeenCalled() + expect(pruneRetainedAgents).not.toHaveBeenCalled() + hook.unmount() + }) + + it('uses the freshest tab title when a real agent transition retains a completed row', async () => { + const repo = makeRepo() + const worktree = makeWorktree() + const paneKey = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') + const row = makeAgentRow({ paneKey, state: 'done' }) + useAppStore.setState({ + repos: [repo], + worktreesByRepo: { [repo.id]: [worktree] }, + tabsByWorktree: { + [worktree.id]: [{ ...row.tab, title: '⠋ Codex is thinking' }] + }, + agentStatusByPaneKey: { [paneKey]: row.entry }, + agentStatusEpoch: initialAppState.agentStatusEpoch + 1 + }) + const hook = renderHook(() => useRetainedAgentsSync()) + await act(async () => { + await Promise.resolve() + }) + + act(() => { + useAppStore.setState((state) => ({ + tabsByWorktree: { + [worktree.id]: [ + { + ...state.tabsByWorktree[worktree.id][0], + title: '⠙ Codex is thinking' + } + ] + } + })) + }) + act(() => { + useAppStore.setState((state) => ({ + agentStatusByPaneKey: {}, + agentStatusEpoch: state.agentStatusEpoch + 1 + })) + }) + await act(async () => { + await Promise.resolve() + }) + + expect(useAppStore.getState().retainedAgentsByPaneKey[paneKey]?.tab.title).toBe( + '⠙ Codex is thinking' + ) + hook.unmount() + }) + it('does not re-retain when status removal and tab closure commit before the next retention effect', async () => { const repo = makeRepo() const worktree = makeWorktree() diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index 883da974142..2e39205a84c 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -16,6 +16,10 @@ import { } from '../../../../shared/agent-status-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { + createWorktreeTabBucketProjection, + type WorktreeTabBucketProjection +} from '@/lib/worktree-tab-bucket-projection' // Why: when an agent finishes or its terminal closes, the store cleans up the // explicit status entry and the agent vanishes from the live status set. // Retaining the last-known "done" snapshot in the store lets the inline @@ -35,6 +39,16 @@ type RetainedAgentsSyncInputs = { type RetainedAgentsSyncSnapshotInputs = RetainedAgentsSyncInputs & { now: number } +export function createRetainedAgentsTabTopologyProjection( + onInspectBucket?: (worktreeId: string) => void +) { + return createWorktreeTabBucketProjection({ + projectTab: (tab) => tab, + isSameProjectedTab: (previousTab, nextTab) => + previousTab.id === nextTab.id && previousTab.worktreeId === nextTab.worktreeId, + onInspectBucket + }) +} function paneKeyTabId(paneKey: string): string | null { return parsePaneKey(paneKey)?.tabId ?? null @@ -122,17 +136,22 @@ export function buildRetainedAgentsSyncSnapshot(args: RetainedAgentsSyncSnapshot } export function useRetainedAgentsSync(): void { + const tabTopologyProjectionRef = useRef | null>(null) + tabTopologyProjectionRef.current ??= createRetainedAgentsTabTopologyProjection() const retainAgents = useAppStore((s) => s.retainAgents) const pruneRetainedAgents = useAppStore((s) => s.pruneRetainedAgents) const clearRetentionSuppressedPaneKeys = useAppStore((s) => s.clearRetentionSuppressedPaneKeys) - const [repos, worktreesByRepo, folderWorkspaces, tabsByWorktree, agentStatusEpoch] = useAppStore( + const [repos, worktreesByRepo, folderWorkspaces, tabTopology, agentStatusEpoch] = useAppStore( useShallow( (s) => [ s.repos, s.worktreesByRepo, s.folderWorkspaces, - s.tabsByWorktree, + tabTopologyProjectionRef.current!.project(s.tabsByWorktree), s.agentStatusEpoch ] as const ) @@ -150,11 +169,11 @@ export function useRetainedAgentsSync(): void { now: Date.now() }) - // Why: read retention state via getState() after the cheap ref/epoch gate - // fires. Building the full retention snapshot scans all agents, so do it - // only when live identity/state/freshness/final-done data or worktree - // membership changes. Subscribing to retainedAgentsByPaneKey would create - // a feedback loop because this effect calls retainAgents. + // Why: read retention state via getState() after the cheap topology/epoch + // gate fires. Building the full retention snapshot scans all agents, so + // display-title-only publications must not enter this effect. The fresh + // store read still supplies current tab objects whenever a real topology + // or agent transition does run. const { retainedAgentsByPaneKey: retainedNow, retentionSuppressedPaneKeys } = state const { toRetain, consumedSuppressedPaneKeys } = collectRetainedAgentsOnDisappear({ previousAgents: prevAgentsRef.current, @@ -181,7 +200,7 @@ export function useRetainedAgentsSync(): void { repos, worktreesByRepo, folderWorkspaces, - tabsByWorktree, + tabTopology, agentStatusEpoch, retainAgents, pruneRetainedAgents, @@ -273,9 +292,8 @@ export function collectRetainedAgentsOnDisappear(args: { // Why: prefer the real destination tab — reusing the source tab's title and // launchAgent under a different id would mislabel the retained row. const ownerTab = - ownerTabId === prev.row.tab.id - ? prev.row.tab - : (args.tabIndex?.get(ownerTabId)?.tab ?? { ...prev.row.tab, id: ownerTabId }) + args.tabIndex?.get(ownerTabId)?.tab ?? + (ownerTabId === prev.row.tab.id ? prev.row.tab : { ...prev.row.tab, id: ownerTabId }) // Why: PTY exit can remove the live row before closeTab plants a suppressor; // the closed-tab marker prevents re-retention. if (args.recentlyClosedAgentStatusTabIds[ownerTabId]) { diff --git a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts index c18c3cbceed..50dbfc34644 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { + createVisibleWorktreeTerminalActivityProjection, getVisibleWorktreeBrowserActivityTabs, getVisibleWorktreeTerminalActivityTabs } from './visible-worktree-activity-inputs' @@ -49,6 +50,28 @@ describe('visible worktree activity inputs', () => { expect(second).toBe(first) expect(second['wt-1']).toBe(first['wt-1']) }) + it('inspects only the changed bucket in a 300-worktree title publication', () => { + const inspected: string[] = [] + const projector = createVisibleWorktreeTerminalActivityProjection((worktreeId) => + inspected.push(worktreeId) + ) + const tabs = Object.fromEntries( + Array.from({ length: 300 }, (_, index) => { + const worktreeId = `wt-${index}` + return [worktreeId, [terminalTab(`tab-${index}`, `Title ${index}`)]] + }) + ) + const first = projector.project(tabs) + inspected.length = 0 + const changedWorktreeId = 'wt-173' + const second = projector.project({ + ...tabs, + [changedWorktreeId]: [{ ...tabs[changedWorktreeId][0], title: 'Changed display title' }] + }) + + expect(second).toBe(first) + expect(inspected).toEqual([changedWorktreeId]) + }) it('updates terminal activity projection when tab ids change', () => { const first = getVisibleWorktreeTerminalActivityTabs({ diff --git a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts index 683b984cca8..521632ce4b7 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts @@ -1,94 +1,35 @@ import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { createWorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' export type TerminalActivityTab = Pick export type BrowserActivityTab = Pick -function haveSameProjection( - previous: readonly U[] | undefined, - next: readonly T[], - isSame: (previousTab: U, nextTab: T) => boolean -): boolean { - if (!previous || previous.length !== next.length) { - return false - } - for (let index = 0; index < next.length; index++) { - const previousTab = previous[index] - if (!previousTab || !isSame(previousTab, next[index])) { - return false - } - } - return true +export function createVisibleWorktreeTerminalActivityProjection( + onInspectBucket?: (worktreeId: string) => void +) { + return createWorktreeTabBucketProjection({ + projectTab: (tab) => ({ id: tab.id }), + isSameProjectedTab: (previousTab, nextTab) => previousTab.id === nextTab.id, + onInspectBucket + }) } -function projectTabs( - tabsByWorktree: Record, - previousProjection: Record | null, - projectTab: (tab: T) => U, - isSame: (previousTab: U, nextTab: T) => boolean -): { projection: Record; unchanged: boolean } { - const nextProjection: Record = {} - let unchanged = - previousProjection !== null && - Object.keys(previousProjection).length === Object.keys(tabsByWorktree).length - - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - const previousTabs = previousProjection?.[worktreeId] - if (haveSameProjection(previousTabs, tabs, isSame)) { - nextProjection[worktreeId] = previousTabs as U[] - continue - } - unchanged = false - nextProjection[worktreeId] = tabs.map(projectTab) - } - - return { projection: nextProjection, unchanged } -} - -function projectIdTabs( - tabsByWorktree: Record, - previousProjection: Record | null -): { projection: Record; unchanged: boolean } { - return projectTabs( - tabsByWorktree, - previousProjection, - (tab) => ({ id: tab.id }) as U, - (previousTab, nextTab) => previousTab.id === nextTab.id - ) -} - -let cachedTerminalSource: Record | null = null -let cachedTerminalProjection: Record | null = null +const terminalProjection = createVisibleWorktreeTerminalActivityProjection() export function getVisibleWorktreeTerminalActivityTabs( tabsByWorktree: Record ): Record { - if (cachedTerminalSource === tabsByWorktree && cachedTerminalProjection) { - return cachedTerminalProjection - } - const { projection, unchanged } = projectIdTabs(tabsByWorktree, cachedTerminalProjection) - cachedTerminalSource = tabsByWorktree - if (unchanged && cachedTerminalProjection) { - return cachedTerminalProjection - } - cachedTerminalProjection = projection - return projection + return terminalProjection.project(tabsByWorktree) } -let cachedBrowserSource: Record | null = null -let cachedBrowserProjection: Record | null = null +const browserProjection = createWorktreeTabBucketProjection({ + projectTab: (tab) => ({ id: tab.id }), + isSameProjectedTab: (previousTab, nextTab) => previousTab.id === nextTab.id +}) export function getVisibleWorktreeBrowserActivityTabs( browserTabsByWorktree: Record ): Record { - if (cachedBrowserSource === browserTabsByWorktree && cachedBrowserProjection) { - return cachedBrowserProjection - } - const { projection, unchanged } = projectIdTabs(browserTabsByWorktree, cachedBrowserProjection) - cachedBrowserSource = browserTabsByWorktree - if (unchanged && cachedBrowserProjection) { - return cachedBrowserProjection - } - cachedBrowserProjection = projection - return projection + return browserProjection.project(browserTabsByWorktree) } diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts index d45be73c63e..d3ed7666d4c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { TERMINAL_WORKTREE_PARK_DELAY_MS } from './terminal-hidden-view-parking' import { clearTerminalProviderSnapshotCapabilities, @@ -6,7 +7,7 @@ import { } from '../terminal/terminal-provider-snapshot-capability' import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, - getTerminalWorktreeParkingInputsKey, + createTerminalWorktreeTopologyProjection, hasPendingRetentionSpawnWork, isEvictionExemptTerminalPty, selectForceParkEvictableTabIds, @@ -14,44 +15,77 @@ import { type TerminalWorktreeRetentionCandidate } from './terminal-hidden-worktree-retention' -describe('getTerminalWorktreeParkingInputsKey', () => { - const tabs = { - 'repo::/worktree': [ - { - id: 'tab-1', - ptyId: 'repo::/worktree@@pty-1', - pendingActivationSpawn: undefined, - title: 'Original title' - } - ] +describe('createTerminalWorktreeTopologyProjection', () => { + function tab(worktreeId: string, id = `tab-${worktreeId}`): TerminalTab { + return { + id, + ptyId: `${worktreeId}@@pty-1`, + worktreeId, + title: 'Original title', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } } - it('ignores display-only tab changes', () => { - const retitledTabs = { - 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], title: 'Updated title' }] - } - expect(getTerminalWorktreeParkingInputsKey(retitledTabs)).toBe( - getTerminalWorktreeParkingInputsKey(tabs) + it('keeps the parking dependency stable for a title-only change across 300 worktrees', () => { + const inspected: string[] = [] + const projector = createTerminalWorktreeTopologyProjection((worktreeId) => + inspected.push(worktreeId) ) + const tabs = Object.fromEntries( + Array.from({ length: 300 }, (_, index) => { + const worktreeId = `wt-${index}` + return [worktreeId, [tab(worktreeId)]] + }) + ) + const first = projector.project(tabs) + inspected.length = 0 + const changedWorktreeId = 'wt-173' + const second = projector.project({ + ...tabs, + [changedWorktreeId]: [{ ...tabs[changedWorktreeId][0], title: 'Updated display title' }] + }) + + expect(second).toBe(first) + expect(inspected).toEqual([changedWorktreeId]) }) - it('changes for inputs consumed by the parking policy', () => { - const original = getTerminalWorktreeParkingInputsKey(tabs) - expect( - getTerminalWorktreeParkingInputsKey({ - 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], ptyId: null }] - }) - ).not.toBe(original) - expect( - getTerminalWorktreeParkingInputsKey({ - 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], pendingActivationSpawn: true }] - }) - ).not.toBe(original) - expect( - getTerminalWorktreeParkingInputsKey({ - 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], id: 'tab-2' }] - }) - ).not.toBe(original) + it.each([ + ['PTY assignment', (original: TerminalTab) => ({ ...original, ptyId: null })], + [ + 'pending spawn', + (original: TerminalTab) => ({ ...original, pendingActivationSpawn: true as const }) + ], + ['tab replacement', (original: TerminalTab) => ({ ...original, id: 'tab-2' })], + ['generation remount', (original: TerminalTab) => ({ ...original, generation: 2 })], + ['startup cwd', (original: TerminalTab) => ({ ...original, startupCwd: '/tmp' })] + ])('changes for %s', (_label, mutate) => { + const projector = createTerminalWorktreeTopologyProjection() + const originalTab = tab('wt-1', 'tab-1') + const first = projector.project({ 'wt-1': [originalTab] }) + const second = projector.project({ 'wt-1': [mutate(originalTab)] }) + + expect(second).not.toBe(first) + }) + + it('changes for add, remove, and move transitions', () => { + const projector = createTerminalWorktreeTopologyProjection() + const first = projector.project({ 'wt-1': [tab('wt-1', 'tab-1')], 'wt-2': [] }) + const added = projector.project({ + 'wt-1': [tab('wt-1', 'tab-1'), tab('wt-1', 'tab-2')], + 'wt-2': [] + }) + const removed = projector.project({ 'wt-1': [tab('wt-1', 'tab-2')], 'wt-2': [] }) + const moved = projector.project({ + 'wt-1': [], + 'wt-2': [tab('wt-2', 'tab-2')] + }) + + expect(added).not.toBe(first) + expect(removed).not.toBe(added) + expect(moved).not.toBe(removed) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts index 2024002bfa4..51d43e45376 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts @@ -9,6 +9,7 @@ import { type TerminalColdParkPolicyOverrides } from './terminal-hidden-view-parking' import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { createWorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' // Why these sizes: a retained hidden pane costs a measured ~2.5MB of V8 heap // at the 5k-row default scrollback and ~19MB at 50k (plus per-pane queues), @@ -30,17 +31,20 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' export const TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT = 4 export const TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS = 15 * 60_000 -type TerminalWorktreeParkingTab = Pick - -export function getTerminalWorktreeParkingInputsKey( - tabsByWorktree: Readonly> -): string { - return JSON.stringify( - Object.entries(tabsByWorktree).map(([worktreeId, tabs]) => [ - worktreeId, - tabs.map((tab) => [tab.id, tab.ptyId, tab.pendingActivationSpawn]) - ]) - ) +export function createTerminalWorktreeTopologyProjection( + onInspectBucket?: (worktreeId: string) => void +) { + return createWorktreeTabBucketProjection({ + projectTab: (tab) => tab, + isSameProjectedTab: (previousTab, nextTab) => + previousTab.id === nextTab.id && + previousTab.ptyId === nextTab.ptyId && + previousTab.worktreeId === nextTab.worktreeId && + previousTab.pendingActivationSpawn === nextTab.pendingActivationSpawn && + previousTab.generation === nextTab.generation && + previousTab.startupCwd === nextTab.startupCwd, + onInspectBucket + }) } export function hasPendingRetentionSpawnWork( diff --git a/src/renderer/src/lib/session-write-subscriber.test.ts b/src/renderer/src/lib/session-write-subscriber.test.ts index 3c31296095a..535a151e658 100644 --- a/src/renderer/src/lib/session-write-subscriber.test.ts +++ b/src/renderer/src/lib/session-write-subscriber.test.ts @@ -245,6 +245,56 @@ describe('createSessionWriteSubscriber', () => { expect(persist).not.toHaveBeenCalled() cleanup() }) + it('schedules no session timer for one display-title bucket in a 300-worktree fleet', () => { + const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() + const tabsByWorktree = Object.fromEntries( + Array.from({ length: 300 }, (_, index) => { + const worktreeId = `wt-${index}` + return [ + worktreeId, + [ + { + id: `tab-${index}`, + ptyId: `${worktreeId}@@pty-1`, + worktreeId, + title: '⠋ Codex is thinking', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + ] + }) + ) + const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) + useAppStore.setState({ + workspaceSessionReady: true, + hydrationSucceeded: true, + tabsByWorktree + }) + vi.advanceTimersByTime(200) + persist.mockClear() + expect(vi.getTimerCount()).toBe(0) + + const changedWorktreeId = 'wt-173' + useAppStore.setState({ + tabsByWorktree: { + ...tabsByWorktree, + [changedWorktreeId]: [ + { + ...tabsByWorktree[changedWorktreeId][0], + title: '⠙ Codex is thinking' + } + ] + } + }) + + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(200) + expect(persist).not.toHaveBeenCalled() + cleanup() + }) it('persists ordinary terminal title-only changes', () => { const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() diff --git a/src/renderer/src/lib/session-write-subscriber.ts b/src/renderer/src/lib/session-write-subscriber.ts index 05e49127e81..6e2eda4ed1f 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -3,6 +3,7 @@ import { isDecorativeAgentTitleFrameChange } from '../../../shared/agent-decorat import type { WorkspaceSessionPatch } from '../../../shared/workspace-session-state-types' import { SESSION_RELEVANT_FIELDS, shouldPersistWorkspaceSession } from './workspace-session' import { buildWorkspaceSessionPatch } from './workspace-session-patch' +import { createWorktreeTabBucketProjection } from './worktree-tab-bucket-projection' type SessionRelevantField = (typeof SESSION_RELEVANT_FIELDS)[number] type TabsByWorktree = AppState['tabsByWorktree'] @@ -33,30 +34,12 @@ function terminalTabChangedForSession(prev: TerminalTab, next: TerminalTab): boo } return prev.title !== next.title && !isDecorativeAgentTitleFrameChange(prev.title, next.title) } - -function tabsByWorktreeChangedForSession(prev: TabsByWorktree, next: TabsByWorktree): boolean { - if (prev === next) { - return false - } - const worktreeIds = new Set([...Object.keys(prev), ...Object.keys(next)]) - for (const worktreeId of worktreeIds) { - const prevTabs = prev[worktreeId] ?? [] - const nextTabs = next[worktreeId] ?? [] - if (prevTabs === nextTabs) { - continue - } - if (prevTabs.length !== nextTabs.length) { - return true - } - for (let i = 0; i < prevTabs.length; i += 1) { - const prevTab = prevTabs[i] - const nextTab = nextTabs[i] - if (!prevTab || !nextTab || terminalTabChangedForSession(prevTab, nextTab)) { - return true - } - } - } - return false +function createTerminalSessionTabsProjection() { + return createWorktreeTabBucketProjection({ + projectTab: (tab) => tab, + isSameProjectedTab: (previousTab, nextTab) => + !terminalTabChangedForSession(previousTab, nextTab) + }) } function unifiedTabChangedForSession(prev: UnifiedTab, next: UnifiedTab): boolean { @@ -83,57 +66,11 @@ function unifiedTabChangedForSession(prev: UnifiedTab, next: UnifiedTab): boolea } return !isDecorativeAgentTitleFrameChange(prev.label, next.label) } - -function unifiedTabsByWorktreeChangedForSession( - prev: UnifiedTabsByWorktree, - next: UnifiedTabsByWorktree -): boolean { - if (prev === next) { - return false - } - const worktreeIds = new Set([...Object.keys(prev), ...Object.keys(next)]) - for (const worktreeId of worktreeIds) { - const prevTabs = prev[worktreeId] ?? [] - const nextTabs = next[worktreeId] ?? [] - if (prevTabs === nextTabs) { - continue - } - if (prevTabs.length !== nextTabs.length) { - return true - } - for (let i = 0; i < prevTabs.length; i += 1) { - const prevTab = prevTabs[i] - const nextTab = nextTabs[i] - if (!prevTab || !nextTab || unifiedTabChangedForSession(prevTab, nextTab)) { - return true - } - } - } - return false -} - -function sessionRelevantFieldChanged( - key: SessionRelevantField, - prevValue: unknown, - nextValue: unknown -): boolean { - if (prevValue === nextValue) { - return false - } - if (key === 'tabsByWorktree') { - // Why: focused agent CLIs can emit spinner/title OSC frames many times per - // second. Those labels are live UI chrome, not durable terminal topology. - return tabsByWorktreeChangedForSession(prevValue as TabsByWorktree, nextValue as TabsByWorktree) - } - if (key === 'unifiedTabsByWorktree') { - // Why: terminal live titles are mirrored into unified tab labels, so the - // same decorative frames must not wake unified-tab session persistence. - return unifiedTabsByWorktreeChangedForSession( - prevValue as UnifiedTabsByWorktree, - nextValue as UnifiedTabsByWorktree - ) - } - return true +function createUnifiedSessionTabsProjection() { + return createWorktreeTabBucketProjection({ + projectTab: (tab) => tab, + isSameProjectedTab: (previousTab, nextTab) => !unifiedTabChangedForSession(previousTab, nextTab) + }) } export type WorkspaceSessionWrite = { @@ -166,33 +103,35 @@ export function createSessionWriteSubscriber({ // Why: the subscriber fires on every store update (agent status, usage // refreshes, runtime title ticks, …). Without this gate each fire reset // the debounce, and when it finally expired buildWorkspaceSessionPayload - // crossed 70-110ms with many tabs, tripping setTimeout violations. Compare - // each session-feeding field by reference against the prior snapshot and - // skip both the timer reset and the rebuild when none changed. `null` - // sentinel guarantees the very first fire always proceeds. + // crossed 70-110ms with many tabs, tripping setTimeout violations. Terminal + // and unified maps use durable per-worktree projections, so display frames + // reuse the prior identity while a real session change keeps fresh tabs for + // the eventual getState() patch build. `null` makes the first fire proceed. let prev: Record | null = null const pendingChangedFields = new Set() + const terminalTabsProjection = createTerminalSessionTabsProjection() + const unifiedTabsProjection = createUnifiedSessionTabsProjection() const unsub = store.subscribe((state) => { if (!shouldPersistWorkspaceSession(state)) { return } - const changedFields: SessionRelevantField[] = [] - if (prev === null) { - changedFields.push(...SESSION_RELEVANT_FIELDS) - } else { - for (const key of SESSION_RELEVANT_FIELDS) { - if (sessionRelevantFieldChanged(key, prev[key], state[key])) { - changedFields.push(key) - } - } - } - if (changedFields.length === 0) { - return - } const next: Record = {} for (const key of SESSION_RELEVANT_FIELDS) { - next[key] = state[key] + const value = state[key] + next[key] = + key === 'tabsByWorktree' + ? terminalTabsProjection.project(value as TabsByWorktree) + : key === 'unifiedTabsByWorktree' + ? unifiedTabsProjection.project(value as UnifiedTabsByWorktree) + : value + } + const changedFields = + prev === null + ? [...SESSION_RELEVANT_FIELDS] + : SESSION_RELEVANT_FIELDS.filter((key) => prev?.[key] !== next[key]) + if (changedFields.length === 0) { + return } prev = next for (const field of changedFields) { diff --git a/src/renderer/src/lib/worktree-tab-bucket-projection.ts b/src/renderer/src/lib/worktree-tab-bucket-projection.ts new file mode 100644 index 00000000000..67df99df053 --- /dev/null +++ b/src/renderer/src/lib/worktree-tab-bucket-projection.ts @@ -0,0 +1,66 @@ +export type WorktreeTabBucketProjection = { + project: (source: Readonly>) => Record +} + +export function createWorktreeTabBucketProjection(args: { + projectTab: (tab: T) => P + isSameProjectedTab: (previous: P, next: T) => boolean + onInspectBucket?: (worktreeId: string) => void +}): WorktreeTabBucketProjection { + let previousSource: Readonly> | null = null + let previousProjection: Record | null = null + const projectionBySourceBucket = new WeakMap() + + return { + project(source) { + if (source === previousSource && previousProjection) { + return previousProjection + } + + const sourceKeys = Object.keys(source) + let changed = + previousProjection === null || sourceKeys.length !== Object.keys(previousProjection).length + const nextProjection: Record = {} + + for (const worktreeId of sourceKeys) { + const sourceTabs = source[worktreeId] ?? [] + const previousTabs = previousProjection?.[worktreeId] + let projectedTabs: P[] + + if (previousSource?.[worktreeId] === sourceTabs && previousTabs) { + projectedTabs = previousTabs + } else { + args.onInspectBucket?.(worktreeId) + const cached = projectionBySourceBucket.get(sourceTabs) + if (cached) { + projectedTabs = cached + } else if ( + previousTabs && + previousTabs.length === sourceTabs.length && + sourceTabs.every((tab, index) => { + const previousTab = previousTabs[index] + return previousTab !== undefined && args.isSameProjectedTab(previousTab, tab) + }) + ) { + projectedTabs = previousTabs + } else { + projectedTabs = sourceTabs.map(args.projectTab) + } + projectionBySourceBucket.set(sourceTabs, projectedTabs) + } + + nextProjection[worktreeId] = projectedTabs + if (projectedTabs !== previousTabs) { + changed = true + } + } + + previousSource = source + if (!changed && previousProjection) { + return previousProjection + } + previousProjection = nextProjection + return nextProjection + } + } +} From c139447934520c97024a367e8217bd8fcef6db3d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:37:57 -0700 Subject: [PATCH 05/65] perf(renderer): incrementally index terminal tab owners (#16172) --- .../slices/terminal-tab-owner-index.test.ts | 157 +++++++++++++++ .../store/slices/terminal-tab-owner-index.ts | 185 ++++++++++++++++++ .../slices/terminal-tab-title-batch.test.ts | 59 ++++++ .../store/slices/terminal-tab-title-batch.ts | 29 +-- src/renderer/src/store/slices/terminals.ts | 42 ++-- 5 files changed, 427 insertions(+), 45 deletions(-) create mode 100644 src/renderer/src/store/slices/terminal-tab-owner-index.test.ts create mode 100644 src/renderer/src/store/slices/terminal-tab-owner-index.ts diff --git a/src/renderer/src/store/slices/terminal-tab-owner-index.test.ts b/src/renderer/src/store/slices/terminal-tab-owner-index.test.ts new file mode 100644 index 00000000000..0600296d1d4 --- /dev/null +++ b/src/renderer/src/store/slices/terminal-tab-owner-index.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { createTerminalTabOwnerIndex } from './terminal-tab-owner-index' + +function trackedTab(id: string, onRead: () => void): { id: string } { + return Object.defineProperty({}, 'id', { + configurable: true, + enumerable: true, + get() { + onRead() + return id + } + }) as { id: string } +} + +describe('terminal tab owner index', () => { + it('updates added, removed, and moved buckets without inspecting an unchanged bucket', () => { + const reads = { stable: 0, moving: 0, added: 0, removed: 0 } + const stableBucket = [ + trackedTab('stable-tab', () => { + reads.stable += 1 + }) + ] + const sourceBucket = [ + trackedTab('moving-tab', () => { + reads.moving += 1 + }) + ] + const removedBucket = [ + trackedTab('removed-tab', () => { + reads.removed += 1 + }) + ] + const index = createTerminalTabOwnerIndex() + + index.getOwners({ stable: stableBucket, source: sourceBucket, removed: removedBucket }) + reads.stable = 0 + reads.moving = 0 + reads.added = 0 + reads.removed = 0 + + const owners = index.getOwners({ + stable: stableBucket, + source: [], + destination: [ + trackedTab('moving-tab', () => { + reads.moving += 1 + }) + ], + added: [ + trackedTab('added-tab', () => { + reads.added += 1 + }) + ] + }) + + expect(owners.get('stable-tab')).toBe('stable') + expect(owners.get('moving-tab')).toBe('destination') + expect(owners.get('added-tab')).toBe('added') + expect(owners.has('removed-tab')).toBe(false) + expect(reads).toEqual({ stable: 0, moving: 1, added: 1, removed: 0 }) + }) + + it('adopts a warm metadata-only replacement without fleet visits or owner-map allocation', () => { + let outerKeyVisits = 0 + let bucketVisits = 0 + const observeOuterMap = >(value: T): T => + new Proxy(value, { + ownKeys(target) { + outerKeyVisits += 1 + return Reflect.ownKeys(target) + }, + get(target, property, receiver) { + if (typeof property === 'string' && property.startsWith('wt-')) { + bucketVisits += 1 + } + return Reflect.get(target, property, receiver) + } + }) + const initial = observeOuterMap( + Object.fromEntries( + Array.from({ length: 300 }, (_, index) => [ + `wt-${index}`, + [{ id: `tab-${index}`, title: 'Initial title' }] + ]) + ) + ) + const index = createTerminalTabOwnerIndex() + const owners = index.getOwners(initial) + const next = observeOuterMap({ + ...initial, + 'wt-299': [{ id: 'tab-299', title: 'Changed title' }] + }) + index.adoptMetadataOnlyBucketReplacements(initial, next, ['wt-299']) + outerKeyVisits = 0 + bucketVisits = 0 + + const warmOwners = index.getOwners(next) + + expect(warmOwners).toBe(owners) + expect(warmOwners.get('tab-299')).toBe('wt-299') + expect(outerKeyVisits).toBe(0) + expect(bucketVisits).toBe(0) + }) + + it('replaces the topology on hydration across every workspace key kind', () => { + const index = createTerminalTabOwnerIndex() + index.getOwners({ stale: [{ id: 'stale-tab' }] }) + + const hydrated = { + 'repo-local::/Users/me/project': [{ id: 'local-tab' }], + 'folder:folder-workspace-id': [{ id: 'folder-tab' }], + 'repo-ssh::/srv/project': [{ id: 'ssh-tab' }], + 'repo-wsl::\\\\wsl.localhost\\Ubuntu\\home\\me\\project': [{ id: 'wsl-tab' }], + 'worktree:repo-runtime::/workspace::workspace:paired-runtime-id': [ + { id: 'paired-runtime-tab' } + ] + } + const owners = index.getOwners(hydrated) + + expect(Object.fromEntries(owners)).toEqual({ + 'local-tab': 'repo-local::/Users/me/project', + 'folder-tab': 'folder:folder-workspace-id', + 'ssh-tab': 'repo-ssh::/srv/project', + 'wsl-tab': 'repo-wsl::\\\\wsl.localhost\\Ubuntu\\home\\me\\project', + 'paired-runtime-tab': 'worktree:repo-runtime::/workspace::workspace:paired-runtime-id' + }) + expect(owners.has('stale-tab')).toBe(false) + }) + + it('preserves last-wins object order for duplicate ids when only outer keys reorder', () => { + let idReads = 0 + const firstBucket = [ + trackedTab('duplicate-tab', () => { + idReads += 1 + }), + trackedTab('duplicate-tab', () => { + idReads += 1 + }) + ] + const secondBucket = [ + trackedTab('duplicate-tab', () => { + idReads += 1 + }) + ] + const index = createTerminalTabOwnerIndex() + + expect(index.getOwner({ first: firstBucket, second: secondBucket }, 'duplicate-tab')).toBe( + 'second' + ) + idReads = 0 + + expect(index.getOwner({ second: secondBucket, first: firstBucket }, 'duplicate-tab')).toBe( + 'first' + ) + expect(idReads).toBe(0) + }) +}) diff --git a/src/renderer/src/store/slices/terminal-tab-owner-index.ts b/src/renderer/src/store/slices/terminal-tab-owner-index.ts new file mode 100644 index 00000000000..ee5df24482f --- /dev/null +++ b/src/renderer/src/store/slices/terminal-tab-owner-index.ts @@ -0,0 +1,185 @@ +type TerminalTabOwnerBuckets = Readonly> + +type IndexedBucket = { + source: readonly { id: string }[] + tabIds: ReadonlySet +} + +export type TerminalTabOwnerIndex = { + getOwners(tabsByWorktree: TerminalTabOwnerBuckets): ReadonlyMap + getOwner(tabsByWorktree: TerminalTabOwnerBuckets, tabId: string): string | null + adoptMetadataOnlyBucketReplacements( + previousTabsByWorktree: TerminalTabOwnerBuckets, + nextTabsByWorktree: TerminalTabOwnerBuckets, + replacedWorktreeIds: Iterable + ): void +} + +export function createTerminalTabOwnerIndex(): TerminalTabOwnerIndex { + let source: TerminalTabOwnerBuckets | null = null + let orderedKeys: string[] = [] + const bucketByWorktreeId = new Map() + const worktreeIdsByTabId = new Map>() + const ownerByTabId = new Map() + + const removeBucketMembership = (worktreeId: string, tabIds: ReadonlySet): void => { + for (const tabId of tabIds) { + const worktreeIds = worktreeIdsByTabId.get(tabId) + if (!worktreeIds) { + continue + } + worktreeIds.delete(worktreeId) + if (worktreeIds.size === 0) { + worktreeIdsByTabId.delete(tabId) + } + } + } + + const addBucketMembership = (worktreeId: string, tabIds: ReadonlySet): void => { + for (const tabId of tabIds) { + const worktreeIds = worktreeIdsByTabId.get(tabId) + if (worktreeIds) { + worktreeIds.add(worktreeId) + } else { + worktreeIdsByTabId.set(tabId, new Set([worktreeId])) + } + } + } + + const update = (tabsByWorktree: TerminalTabOwnerBuckets): ReadonlyMap => { + if (source === tabsByWorktree) { + return ownerByTabId + } + + const nextOrderedKeys = Object.keys(tabsByWorktree) + const orderChanged = + orderedKeys.length !== nextOrderedKeys.length || + orderedKeys.some((key, index) => key !== nextOrderedKeys[index]) + const nextKeySet = new Set(nextOrderedKeys) + const affectedTabIds = new Set() + + for (const [worktreeId, indexed] of bucketByWorktreeId) { + if (nextKeySet.has(worktreeId)) { + continue + } + for (const tabId of indexed.tabIds) { + affectedTabIds.add(tabId) + } + removeBucketMembership(worktreeId, indexed.tabIds) + bucketByWorktreeId.delete(worktreeId) + } + + for (const worktreeId of nextOrderedKeys) { + const tabs = tabsByWorktree[worktreeId] ?? [] + const indexed = bucketByWorktreeId.get(worktreeId) + if (indexed?.source === tabs) { + continue + } + + if (indexed) { + for (const tabId of indexed.tabIds) { + affectedTabIds.add(tabId) + } + removeBucketMembership(worktreeId, indexed.tabIds) + } + + // Why: tab arrays are the immutable topology buckets. Reading ids only for a + // replaced bucket keeps a one-worktree title update independent of fleet size. + const tabIds = new Set(tabs.map((tab) => tab.id)) + for (const tabId of tabIds) { + affectedTabIds.add(tabId) + } + bucketByWorktreeId.set(worktreeId, { source: tabs, tabIds }) + addBucketMembership(worktreeId, tabIds) + } + + const orderByWorktreeId = new Map( + nextOrderedKeys.map((worktreeId, index) => [worktreeId, index] as const) + ) + const tabIdsToResolve = orderChanged ? worktreeIdsByTabId.keys() : affectedTabIds + for (const tabId of tabIdsToResolve) { + const worktreeIds = worktreeIdsByTabId.get(tabId) + let owner: string | null = null + let ownerOrder = -1 + for (const worktreeId of worktreeIds ?? []) { + const order = orderByWorktreeId.get(worktreeId) + if (order !== undefined && order > ownerOrder) { + owner = worktreeId + ownerOrder = order + } + } + if (owner === null) { + ownerByTabId.delete(tabId) + } else { + ownerByTabId.set(tabId, owner) + } + } + for (const tabId of affectedTabIds) { + if (!worktreeIdsByTabId.has(tabId)) { + ownerByTabId.delete(tabId) + } + } + + source = tabsByWorktree + orderedKeys = nextOrderedKeys + return ownerByTabId + } + + const adoptMetadataOnlyBucketReplacements = ( + previousTabsByWorktree: TerminalTabOwnerBuckets, + nextTabsByWorktree: TerminalTabOwnerBuckets, + replacedWorktreeIds: Iterable + ): void => { + if (source !== previousTabsByWorktree) { + return + } + for (const worktreeId of replacedWorktreeIds) { + const indexed = bucketByWorktreeId.get(worktreeId) + const previousTabs = previousTabsByWorktree[worktreeId] + const nextTabs = nextTabsByWorktree[worktreeId] + if (!indexed || indexed.source !== previousTabs || !nextTabs) { + // Why: this fast path is valid only for title-only bucket replacements. + // Falling back to the normal diff keeps an unexpected caller mismatch correct. + source = null + return + } + indexed.source = nextTabs + } + // Why: title writes preserve ids and outer key order, so ownership is unchanged. + // Adopting the exact produced map keeps the next hot lookup O(1). + source = nextTabsByWorktree + } + + return { + getOwners: update, + getOwner: (tabsByWorktree, tabId) => update(tabsByWorktree).get(tabId) ?? null, + adoptMetadataOnlyBucketReplacements + } +} + +const sharedTerminalTabOwnerIndex = createTerminalTabOwnerIndex() + +export function getTerminalTabOwners( + tabsByWorktree: TerminalTabOwnerBuckets +): ReadonlyMap { + return sharedTerminalTabOwnerIndex.getOwners(tabsByWorktree) +} + +export function getTerminalTabOwnerWorktreeId( + tabsByWorktree: TerminalTabOwnerBuckets, + tabId: string +): string | null { + return sharedTerminalTabOwnerIndex.getOwner(tabsByWorktree, tabId) +} + +export function adoptTerminalTabOwnerMetadataOnlyBuckets( + previousTabsByWorktree: TerminalTabOwnerBuckets, + nextTabsByWorktree: TerminalTabOwnerBuckets, + replacedWorktreeIds: Iterable +): void { + sharedTerminalTabOwnerIndex.adoptMetadataOnlyBucketReplacements( + previousTabsByWorktree, + nextTabsByWorktree, + replacedWorktreeIds + ) +} diff --git a/src/renderer/src/store/slices/terminal-tab-title-batch.test.ts b/src/renderer/src/store/slices/terminal-tab-title-batch.test.ts index 3a7a1b525ae..e269ffaae86 100644 --- a/src/renderer/src/store/slices/terminal-tab-title-batch.test.ts +++ b/src/renderer/src/store/slices/terminal-tab-title-batch.test.ts @@ -24,6 +24,7 @@ import { seedStore } from './store-test-helpers' import type { GeneratedTabTitleUpdate } from './terminal-tab-title-batch' +import { adoptTerminalTabOwnerMetadataOnlyBuckets } from './terminal-tab-owner-index' const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -100,6 +101,64 @@ describe('terminal tab title batches', () => { } }) + it('inspects only the changed bucket for a warm title change across 300 worktrees', () => { + const store = createTestStore() + const fixture = makeScaleState(300) + seedStore(store, { + worktreesByRepo: { repo1: fixture.worktrees }, + tabsByWorktree: fixture.tabsByWorktree, + unifiedTabsByWorktree: fixture.unifiedTabsByWorktree + }) + store.getState().updateTabTitle('tab-299', 'First warm title') + + const idReadsByWorktree = Array.from({ length: 300 }, () => 0) + for (let index = 0; index < 300; index += 1) { + const tab = store.getState().tabsByWorktree[`wt-${index}`]?.[0] + if (!tab) { + throw new Error(`missing scale tab ${index}`) + } + const id = tab.id + Object.defineProperty(tab, 'id', { + configurable: true, + enumerable: true, + get() { + idReadsByWorktree[index] += 1 + return id + } + }) + } + + let outerKeyVisits = 0 + let bucketVisits = 0 + const currentTabsByWorktree = store.getState().tabsByWorktree + const observedTabsByWorktree = new Proxy(currentTabsByWorktree, { + ownKeys(target) { + outerKeyVisits += 1 + return Reflect.ownKeys(target) + }, + get(target, property, receiver) { + if (typeof property === 'string' && property.startsWith('wt-')) { + bucketVisits += 1 + } + return Reflect.get(target, property, receiver) + } + }) + adoptTerminalTabOwnerMetadataOnlyBuckets(currentTabsByWorktree, observedTabsByWorktree, []) + store.setState({ tabsByWorktree: observedTabsByWorktree }) + outerKeyVisits = 0 + bucketVisits = 0 + + store.getState().updateTabTitle('tab-299', 'Second warm title') + + expect(idReadsByWorktree[299]).toBeGreaterThan(0) + expect(idReadsByWorktree.slice(0, 299).reduce((sum, count) => sum + count, 0)).toBe(0) + expect(outerKeyVisits).toBe(1) + // The unavoidable outer-map copy reads 300 buckets; staging and cache adoption + // read the changed owner twice. A second fleet traversal would add 300 more. + expect(bucketVisits).toBe(302) + expect(store.getState().tabsByWorktree['wt-299']?.[0]?.title).toBe('Second warm title') + }) + it('preserves ordered duplicate updates and last-owner duplicate-id routing', () => { const store = createTestStore() seedStore(store, { diff --git a/src/renderer/src/store/slices/terminal-tab-title-batch.ts b/src/renderer/src/store/slices/terminal-tab-title-batch.ts index 20c51f59967..d0456541af3 100644 --- a/src/renderer/src/store/slices/terminal-tab-title-batch.ts +++ b/src/renderer/src/store/slices/terminal-tab-title-batch.ts @@ -4,6 +4,10 @@ import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stab import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { AppState } from '../types' +import { + adoptTerminalTabOwnerMetadataOnlyBuckets, + getTerminalTabOwners +} from './terminal-tab-owner-index' export type TerminalTabTitleUpdate = { tabId: string; title: string } @@ -48,16 +52,6 @@ function getTabIdFromPaneKey(paneKey: string): string | null { return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null } -function buildOwnerIndex(tabsByWorktree: TitleState['tabsByWorktree']): Map { - const ownerByTabId = new Map() - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - for (const tab of tabs) { - ownerByTabId.set(tab.id, worktreeId) - } - } - return ownerByTabId -} - function getOwnerStage( state: TitleState, stages: Map, @@ -157,14 +151,21 @@ function finishTitleStages( if (sortEpochIncrement > 0) { patch.sortEpoch = state.sortEpoch + sortEpochIncrement } + if (patch.tabsByWorktree) { + adoptTerminalTabOwnerMetadataOnlyBuckets( + state.tabsByWorktree, + patch.tabsByWorktree, + stages.keys() + ) + } return { patch, runtimeGraphChanged: tabsChanged } } export function applyTerminalTabTitleUpdates( state: TitleState, - updates: readonly TerminalTabTitleUpdate[], - ownerByTabId = buildOwnerIndex(state.tabsByWorktree) + updates: readonly TerminalTabTitleUpdate[] ): TitleUpdateResult { + const ownerByTabId = getTerminalTabOwners(state.tabsByWorktree) const stages = new Map() let sortEpochIncrement = 0 for (const { tabId, title } of updates) { @@ -204,9 +205,9 @@ export function applyTerminalTabTitleUpdates( export function applyGeneratedTabTitleUpdates( state: TitleState, - updates: readonly GeneratedTabTitleUpdate[], - ownerByTabId = buildOwnerIndex(state.tabsByWorktree) + updates: readonly GeneratedTabTitleUpdate[] ): TitleUpdateResult { + const ownerByTabId = getTerminalTabOwners(state.tabsByWorktree) if (state.settings?.tabAutoGenerateTitle !== true) { return { patch: null, runtimeGraphChanged: false } } diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 49c2e7f00ed..90cea807728 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -72,6 +72,10 @@ import { type GeneratedTabTitleUpdate, type TerminalTabTitleUpdate } from './terminal-tab-title-batch' +import { + adoptTerminalTabOwnerMetadataOnlyBuckets, + getTerminalTabOwnerWorktreeId +} from './terminal-tab-owner-index' import { dedupeTabOrder, ensureGroup, @@ -309,26 +313,6 @@ function buildRuntimeSessionPlaceholders({ return { repos: nextRepos, worktreesByRepo: nextWorktreesByRepo } } -let terminalTabOwnerCacheSource: Record | null = null -let terminalTabOwnerCache = new Map() - -function getTerminalTabOwnerWorktreeId( - tabsByWorktree: Record, - tabId: string -): string | null { - if (terminalTabOwnerCacheSource !== tabsByWorktree) { - const nextCache = new Map() - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - for (const tab of tabs) { - nextCache.set(tab.id, worktreeId) - } - } - terminalTabOwnerCacheSource = tabsByWorktree - terminalTabOwnerCache = nextCache - } - return terminalTabOwnerCache.get(tabId) ?? null -} - function getTabIdFromPaneKey(paneKey: string): string | null { return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null } @@ -1959,11 +1943,7 @@ export const createTerminalSlice: StateCreator updateTabTitle: (tabId, title) => { set((state) => { - const ownerWorktreeId = getTerminalTabOwnerWorktreeId(state.tabsByWorktree, tabId) - const ownerByTabId = ownerWorktreeId - ? new Map([[tabId, ownerWorktreeId]]) - : new Map() - const result = applyTerminalTabTitleUpdates(state, [{ tabId, title }], ownerByTabId) + const result = applyTerminalTabTitleUpdates(state, [{ tabId, title }]) if (result.runtimeGraphChanged) { scheduleRuntimeGraphSync() } @@ -2000,13 +1980,17 @@ export const createTerminalSlice: StateCreator return s } const ownerTabs = tabs.map((tab) => (tab.id === tabId ? { ...tab, aiVaultTitle } : tab)) + const nextTabsByWorktree = { ...s.tabsByWorktree, [ownerWorktreeId]: ownerTabs } const unifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? [] const nextUnifiedTabs = unifiedTabs.map((tab) => tab.contentType === 'terminal' && tab.entityId === tabId ? { ...tab, aiVaultTitle } : tab ) + adoptTerminalTabOwnerMetadataOnlyBuckets(s.tabsByWorktree, nextTabsByWorktree, [ + ownerWorktreeId + ]) scheduleRuntimeGraphSync() return { - tabsByWorktree: { ...s.tabsByWorktree, [ownerWorktreeId]: ownerTabs }, + tabsByWorktree: nextTabsByWorktree, unifiedTabsByWorktree: { ...s.unifiedTabsByWorktree, [ownerWorktreeId]: nextUnifiedTabs @@ -2036,11 +2020,7 @@ export const createTerminalSlice: StateCreator return } set((latestState) => { - const result = applyGeneratedTabTitleUpdates( - latestState, - [{ paneKey, prompt, options }], - new Map([[tabId, ownerWorktreeId]]) - ) + const result = applyGeneratedTabTitleUpdates(latestState, [{ paneKey, prompt, options }]) if (result.runtimeGraphChanged) { scheduleRuntimeGraphSync() } From a856367db1f4f3fd842c9d184a988dad7fecdad2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:38:18 -0700 Subject: [PATCH 06/65] perf(renderer): gate runtime store projections (#16173) --- .../paired-runtime-parking-capabilities.ts | 57 ++++ .../terminal-hidden-view-parking.test.ts | 40 ++- .../terminal-hidden-view-parking.ts | 17 +- .../use-terminal-tab-cold-parking.test.ts | 63 ++++ .../use-terminal-tab-cold-parking.ts | 10 +- ...ents-runtime-environment-selectors.test.ts | 283 +++++++++++++++++- src/renderer/src/hooks/useIpcEvents.ts | 219 ++++++++++---- src/renderer/src/store/selectors.test.ts | 73 +++++ src/renderer/src/store/selectors.ts | 73 +++-- 9 files changed, 744 insertions(+), 91 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/paired-runtime-parking-capabilities.ts diff --git a/src/renderer/src/components/terminal-pane/paired-runtime-parking-capabilities.ts b/src/renderer/src/components/terminal-pane/paired-runtime-parking-capabilities.ts new file mode 100644 index 00000000000..53e01a03ce8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/paired-runtime-parking-capabilities.ts @@ -0,0 +1,57 @@ +import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' + +type PairedRuntimeParkingCapabilityStatuses = ReadonlyMap< + string, + { status: { capabilities?: readonly string[] } | null | undefined } +> + +type PairedRuntimeParkingEnvironmentIdsCache = { + statuses: PairedRuntimeParkingCapabilityStatuses + environmentIds: ReadonlySet +} + +let pairedRuntimeParkingEnvironmentIdsCache: PairedRuntimeParkingEnvironmentIdsCache | null = null + +function haveSameEnvironmentIds(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) { + return false + } + for (const environmentId of left) { + if (!right.has(environmentId)) { + return false + } + } + return true +} + +export function selectPairedRuntimeParkingEnvironmentIds( + statuses: PairedRuntimeParkingCapabilityStatuses +): ReadonlySet { + const cached = pairedRuntimeParkingEnvironmentIdsCache + if (cached?.statuses === statuses) { + return cached.environmentIds + } + + const capable = new Set() + for (const [environmentId, entry] of statuses) { + if (entry.status?.capabilities?.includes(TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY)) { + capable.add(environmentId) + } + } + const environmentIds = + cached && haveSameEnvironmentIds(cached.environmentIds, capable) + ? cached.environmentIds + : capable + pairedRuntimeParkingEnvironmentIdsCache = { statuses, environmentIds } + return environmentIds +} + +export function selectPairedRuntimeParkingEnvironmentIdsFromState(state: { + runtimeStatusByEnvironmentId: PairedRuntimeParkingCapabilityStatuses +}): ReadonlySet { + return selectPairedRuntimeParkingEnvironmentIds(state.runtimeStatusByEnvironmentId) +} + +export function resetPairedRuntimeParkingEnvironmentIdsCacheForTest(): void { + pairedRuntimeParkingEnvironmentIdsCache = null +} diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts index a93c28ddc01..3a8c66af137 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import { clearTerminalProviderSnapshotCapabilities, synchronizeTerminalProviderSnapshotCapabilities @@ -11,12 +11,17 @@ import { canParkTerminalWorktreeRenderers, isParkRestorableTerminalPty, isSnapshotBackedTerminalPty, + resetPairedRuntimeParkingEnvironmentIdsCacheForTest, selectPairedRuntimeParkingEnvironmentIds, selectColdParkedTerminalTabs, selectColdParkedTerminalWorktrees } from './terminal-hidden-view-parking' describe('selectPairedRuntimeParkingEnvironmentIds', () => { + beforeEach(() => { + resetPairedRuntimeParkingEnvironmentIdsCacheForTest() + }) + it('selects only reachable hosts advertising the paired parking contract', () => { const statuses = new Map([ [ @@ -29,6 +34,39 @@ describe('selectPairedRuntimeParkingEnvironmentIds', () => { expect(selectPairedRuntimeParkingEnvironmentIds(statuses)).toEqual(new Set(['capable'])) }) + + it('shares the capability Set across status-only changes and replaces it on membership changes', () => { + const first = selectPairedRuntimeParkingEnvironmentIds( + new Map([ + ['runtime-a', { status: { capabilities: ['terminal.paired-parking.v1'] } }], + ['runtime-b', { status: { capabilities: ['terminal.multiplex.v1'] } }] + ]) + ) + const statusOnlyUpdate = selectPairedRuntimeParkingEnvironmentIds( + new Map([ + [ + 'runtime-a', + { + status: { + capabilities: ['terminal.paired-parking.v1'], + runtimeId: 'peer-a-reconnected' + } + } + ], + ['runtime-b', { status: { capabilities: ['terminal.multiplex.v1'], appVersion: '1.5.0' } }] + ]) + ) + expect(statusOnlyUpdate).toBe(first) + + const capabilityUpdate = selectPairedRuntimeParkingEnvironmentIds( + new Map([ + ['runtime-a', { status: { capabilities: ['terminal.paired-parking.v1'] } }], + ['runtime-b', { status: { capabilities: ['terminal.paired-parking.v1'] } }] + ]) + ) + expect(capabilityUpdate).not.toBe(first) + expect(capabilityUpdate).toEqual(new Set(['runtime-a', 'runtime-b'])) + }) }) describe('isSnapshotBackedTerminalPty', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts index a6aa4d676a7..b8d1bf7b072 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts @@ -1,7 +1,6 @@ import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { PTY_SESSION_ID_SEPARATOR } from '../../../../shared/pty-session-id-format' -import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import type { TerminalTab } from '../../../../shared/terminal-tab-types' @@ -92,17 +91,11 @@ export type TerminalParkRestorePolicy = { pairedRuntimeParkingEnvironmentIds?: ReadonlySet } -export function selectPairedRuntimeParkingEnvironmentIds( - statuses: ReadonlyMap -): Set { - const capable = new Set() - for (const [environmentId, entry] of statuses) { - if (entry.status?.capabilities?.includes(TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY)) { - capable.add(environmentId) - } - } - return capable -} +export { + resetPairedRuntimeParkingEnvironmentIdsCacheForTest, + selectPairedRuntimeParkingEnvironmentIds, + selectPairedRuntimeParkingEnvironmentIdsFromState +} from './paired-runtime-parking-capabilities' // Why: SSH uses local main's model; paired PTYs are eligible only when their // exact host advertises authoritative bounded restore. diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts index 58f4cbd2c46..b0deee4b3c4 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts @@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { selectColdParkedTerminalTabs } from './terminal-hidden-view-parking' const mocks = vi.hoisted(() => ({ storeState: { @@ -18,6 +19,7 @@ const mocks = vi.hoisted(() => ({ }, exemptTabIds: new Set(), exemptSelectCalls: 0, + coldParkSelectCalls: 0, /** Toggled to churn the park verdict the way the crash cluster does. */ watcherCoverage: true })) @@ -26,6 +28,21 @@ vi.mock('../../store', () => ({ useAppStore: (selector: (state: unknown) => unknown) => selector(mocks.storeState) })) +vi.mock('./terminal-hidden-view-parking', async (importOriginal) => { + const actual = await importOriginal<{ + selectColdParkedTerminalTabs: typeof selectColdParkedTerminalTabs + }>() + return { + ...actual, + selectColdParkedTerminalTabs: ( + args: Parameters[0] + ) => { + mocks.coldParkSelectCalls += 1 + return actual.selectColdParkedTerminalTabs(args) + } + } +}) + vi.mock('./terminal-eviction-exempt-tabs', () => ({ selectEvictionExemptTerminalTabIds: (_worktreeId: string, tabs: readonly { id: string }[]) => { mocks.exemptSelectCalls += 1 @@ -86,6 +103,7 @@ describe('useTerminalTabColdParking measure-clock contract', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(1_000_000) + mocks.coldParkSelectCalls = 0 }) afterEach(() => { @@ -205,6 +223,51 @@ describe('useTerminalTabColdParking measure-clock contract', () => { } }) + it('skips parking scans for capability-equivalent status writes and scans membership changes', () => { + const args = hookArgs(false) + mocks.storeState.runtimeStatusByEnvironmentId = new Map([ + ['runtime-a', { status: { capabilities: ['terminal.multiplex.v1'], runtimeId: 'peer-a' } }] + ]) + const { rerender } = renderHook( + (props: ReturnType) => useTerminalTabColdParking(props), + { initialProps: args } + ) + const initialSelectCalls = mocks.coldParkSelectCalls + + mocks.storeState.runtimeStatusByEnvironmentId = new Map([ + [ + 'runtime-a', + { + status: { + capabilities: ['terminal.multiplex.v1'], + runtimeId: 'peer-a-reconnected', + appVersion: '1.5.0' + } + } + ] + ]) + act(() => { + rerender(args) + }) + expect(mocks.coldParkSelectCalls).toBe(initialSelectCalls) + + mocks.storeState.runtimeStatusByEnvironmentId = new Map([ + [ + 'runtime-a', + { + status: { + capabilities: ['terminal.multiplex.v1', 'terminal.paired-parking.v1'], + runtimeId: 'peer-a-reconnected' + } + } + ] + ]) + act(() => { + rerender(args) + }) + expect(mocks.coldParkSelectCalls).toBe(initialSelectCalls + 1) + }) + // Why: the flip-damping pin removes the tab from the parked set, and every // hysteresis deadline of a long-hidden tab is already past — so the pin // deadline is the only wakeup that can ever re-park it. Without scheduling diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index c078eed66c9..03b93a382e5 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -16,7 +16,7 @@ import { import { getTerminalTabColdParkRecheckDelayMs } from './terminal-cold-park-recheck-deadlines' import { TERMINAL_TAB_COLD_PARK_DELAY_MS, - selectPairedRuntimeParkingEnvironmentIds, + selectPairedRuntimeParkingEnvironmentIdsFromState, selectColdParkedTerminalTabs } from './terminal-hidden-view-parking' import { @@ -105,10 +105,8 @@ export function useTerminalTabColdParking(args: { const terminalSshParkingEnabled = useAppStore( (state) => state.settings?.terminalSshViewParking !== false ) - const runtimeStatusByEnvironmentId = useAppStore((state) => state.runtimeStatusByEnvironmentId) - const pairedRuntimeParkingEnvironmentIds = useMemo( - () => selectPairedRuntimeParkingEnvironmentIds(runtimeStatusByEnvironmentId), - [runtimeStatusByEnvironmentId] + const pairedRuntimeParkingEnvironmentIds = useAppStore( + selectPairedRuntimeParkingEnvironmentIdsFromState ) const sleepingAgentSessionsByPaneKey = useAppStore( (state) => state.sleepingAgentSessionsByPaneKey @@ -254,7 +252,7 @@ export function useTerminalTabColdParking(args: { activeTerminalTabId, isWorktreeActive, pendingStartupByTabId, - runtimeStatusByEnvironmentId, + pairedRuntimeParkingEnvironmentIds, shouldMeasureHiddenWorktree, terminalParkingEnabled, terminalSshParkingEnabled, diff --git a/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts b/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts index 099a962e17e..ad1d318f332 100644 --- a/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts @@ -1,9 +1,17 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { buildRuntimeClientEventEnvironmentKey, + createRuntimeEnvironmentStoreSyncSubscriber, getNewlyConnectedRuntimeEnvironmentIds, getNewlyDisconnectedRuntimeEnvironmentIds, - getRuntimeProjectRefreshEnvironmentIds + getReachableRuntimeEnvironmentIds, + getRuntimeClientEventEnvironmentIds, + getRuntimeProjectRefreshEnvironmentIds, + invalidateRuntimeClientEventReplay +} from './useIpcEvents' +import type { + RuntimeEnvironmentStoreSyncState, + RuntimeEnvironmentStoreSyncSubscriber } from './useIpcEvents' describe('buildRuntimeClientEventEnvironmentKey', () => { @@ -63,3 +71,274 @@ describe('getRuntimeProjectRefreshEnvironmentIds', () => { ).toEqual(['env-a']) }) }) + +type RuntimeEnvironmentStoreSubscriber = RuntimeEnvironmentStoreSyncSubscriber +type RuntimeEnvironmentStoreState = RuntimeEnvironmentStoreSyncState + +function makeRuntimeEnvironmentStoreState(args: { + environments: readonly { id: string; createdAt: number; pairingRevision?: number }[] + statuses: ReadonlyMap + sshStateByEnvironment?: ReadonlyMap + activeEnvironmentId?: string | null + settingsRevision?: number +}): RuntimeEnvironmentStoreState { + return { + runtimeEnvironments: args.environments, + runtimeStatusByEnvironmentId: args.statuses, + sshStateByEnvironment: args.sshStateByEnvironment ?? new Map(), + settings: { + activeRuntimeEnvironmentId: args.activeEnvironmentId ?? null, + terminalFontSize: args.settingsRevision ?? 0 + } + } as unknown as RuntimeEnvironmentStoreState +} + +describe('createRuntimeEnvironmentStoreSyncSubscriber', () => { + it('does no host enumeration or key building for 1,000 unrelated local, SSH, and folder writes', () => { + const environments = Array.from({ length: 100 }, (_, index) => ({ + id: `runtime-${index}`, + createdAt: index + 1 + })) + const statuses = new Map( + environments.map((environment) => [ + environment.id, + { status: { runtimeId: `peer-${environment.id}` }, checkedAt: 1 } + ]) + ) + let currentState = makeRuntimeEnvironmentStoreState({ environments, statuses }) + let hostEnumerations = 0 + let keyBuilds = 0 + let syncs = 0 + const subscriber = createRuntimeEnvironmentStoreSyncSubscriber({ + initialDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds(currentState), + initialReachableEnvironmentIds: getReachableRuntimeEnvironmentIds(currentState), + getDesiredEnvironmentIds: (state) => { + hostEnumerations += 1 + return getRuntimeClientEventEnvironmentIds(state) + }, + getReachableEnvironmentIds: (state) => { + hostEnumerations += 1 + return getReachableRuntimeEnvironmentIds(state) + }, + buildEnvironmentKey: (environmentIds) => { + keyBuilds += 1 + return [...environmentIds].sort().join('\0') + }, + requestProjectRefresh: vi.fn(), + markEnvironmentSshStateStale: vi.fn(), + sync: () => { + syncs += 1 + } + }) + keyBuilds = 0 + + for (let write = 0; write < 1_000; write += 1) { + const previousState = currentState + currentState = { + ...currentState, + // These represent the high-frequency boundaries that must stay outside + // runtime-host discovery: local terminal state, direct SSH state, and + // folder-workspace catalog churn. + settings: { + activeRuntimeEnvironmentId: currentState.settings?.activeRuntimeEnvironmentId ?? null, + terminalFontSize: write + }, + pendingStartupByTabId: { [`local-tab-${write}`]: true }, + sshConnectionStates: new Map([[`direct-ssh-${write}`, { status: 'connected' }]]), + folderWorkspaces: [{ id: `folder-${write}` }] + } as unknown as RuntimeEnvironmentStoreState + subscriber(currentState, previousState) + } + + expect(hostEnumerations).toBe(0) + expect(keyBuilds).toBe(0) + expect(syncs).toBe(0) + }) + + it('syncs each connect, disconnect, re-pair, runtime generation, and nested SSH generation once', () => { + let environments = [ + { id: 'runtime-a', createdAt: 1, pairingRevision: 1 }, + { id: 'runtime-b', createdAt: 2, pairingRevision: 1 } + ] + let statuses = new Map([ + ['runtime-a', { status: { runtimeId: 'peer-a' }, checkedAt: 1 }], + ['runtime-b', { status: null, checkedAt: 1 }] + ]) + let sshStateByEnvironment: ReadonlyMap = new Map([ + ['runtime-a', { targetsHydrated: true }] + ]) + let currentState = makeRuntimeEnvironmentStoreState({ + environments, + statuses, + sshStateByEnvironment, + activeEnvironmentId: 'runtime-a' + }) + const runtimeGenerations = new Map([ + ['runtime-a', 1], + ['runtime-b', 0] + ]) + const sshGenerations = new Map([ + ['runtime-a', 0], + ['runtime-b', 0] + ]) + const pairingRevisions = new Map([ + ['runtime-a', 1], + ['runtime-b', 1] + ]) + const refreshes: string[] = [] + let syncs = 0 + let subscriber: RuntimeEnvironmentStoreSubscriber + const publish = (nextState: RuntimeEnvironmentStoreState): void => { + const previousState = currentState + currentState = nextState + subscriber(nextState, previousState) + } + const buildEnvironmentKey = (environmentIds: string[]): string => + [...new Set(environmentIds)] + .sort() + .map( + (environmentId) => + `${environmentId}:${runtimeGenerations.get(environmentId) ?? 0}:${sshGenerations.get(environmentId) ?? 0}:${pairingRevisions.get(environmentId) ?? 0}` + ) + .join('\0') + + subscriber = createRuntimeEnvironmentStoreSyncSubscriber({ + initialDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds(currentState), + initialReachableEnvironmentIds: getReachableRuntimeEnvironmentIds(currentState), + getDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds, + getReachableEnvironmentIds: getReachableRuntimeEnvironmentIds, + buildEnvironmentKey, + requestProjectRefresh: (environmentId) => refreshes.push(environmentId), + markEnvironmentSshStateStale: (environmentId) => { + sshGenerations.set(environmentId, (sshGenerations.get(environmentId) ?? 0) + 1) + const previousState = currentState + sshStateByEnvironment = new Map(sshStateByEnvironment).set(environmentId, { + targetsHydrated: false + }) + currentState = makeRuntimeEnvironmentStoreState({ + environments, + statuses, + sshStateByEnvironment, + activeEnvironmentId: 'runtime-a' + }) + // Zustand publishes this nested write synchronously. The outer pass must + // absorb its generation instead of enumerating/syncing it a second time. + subscriber(currentState, previousState) + }, + sync: () => { + syncs += 1 + } + }) + + runtimeGenerations.set('runtime-b', 1) + statuses = new Map(statuses).set('runtime-b', { + status: { runtimeId: 'peer-b' }, + checkedAt: 2 + }) + publish( + makeRuntimeEnvironmentStoreState({ + environments, + statuses, + sshStateByEnvironment, + activeEnvironmentId: 'runtime-a' + }) + ) + expect(syncs).toBe(1) + expect(refreshes).toEqual(['runtime-b']) + + statuses = new Map(statuses).set('runtime-a', { status: null, checkedAt: 3 }) + publish( + makeRuntimeEnvironmentStoreState({ + environments, + statuses, + sshStateByEnvironment, + activeEnvironmentId: 'runtime-a' + }) + ) + expect(syncs).toBe(2) + + environments = [environments[0], { ...environments[1], pairingRevision: 2 }] + pairingRevisions.set('runtime-b', 2) + publish( + makeRuntimeEnvironmentStoreState({ + environments, + statuses, + sshStateByEnvironment, + activeEnvironmentId: 'runtime-a' + }) + ) + expect(syncs).toBe(3) + + runtimeGenerations.set('runtime-b', 2) + statuses = new Map(statuses).set('runtime-b', { + status: { runtimeId: 'peer-b-reconnected' }, + checkedAt: 4 + }) + publish( + makeRuntimeEnvironmentStoreState({ + environments, + statuses, + sshStateByEnvironment, + activeEnvironmentId: 'runtime-a' + }) + ) + expect(syncs).toBe(4) + + sshGenerations.set('runtime-b', 1) + sshStateByEnvironment = new Map(sshStateByEnvironment).set('runtime-b', { + targetsHydrated: true + }) + publish( + makeRuntimeEnvironmentStoreState({ + environments, + statuses, + sshStateByEnvironment, + activeEnvironmentId: 'runtime-a' + }) + ) + expect(syncs).toBe(5) + }) +}) + +describe('invalidateRuntimeClientEventReplay', () => { + it('explicitly syncs an advanced SSH generation when stale publication is a no-op and hydration fails', async () => { + const sshStateReference = new Map() + const requestProjectRefresh = vi.fn() + const markEnvironmentSshStateStale = vi.fn() + const sync = vi.fn() + const hydrateEnvironmentSshState = vi + .fn<() => Promise>() + .mockRejectedValue(new Error('runtime stayed unreachable')) + + invalidateRuntimeClientEventReplay({ + getSshStateReference: () => sshStateReference, + requestProjectRefresh, + markEnvironmentSshStateStale, + hydrateEnvironmentSshState, + sync + }) + await Promise.resolve() + + expect(requestProjectRefresh).toHaveBeenCalledOnce() + expect(markEnvironmentSshStateStale).toHaveBeenCalledOnce() + expect(sync).toHaveBeenCalledOnce() + expect(hydrateEnvironmentSshState).toHaveBeenCalledOnce() + }) + + it('leaves tracked bucket publications to the synchronous store subscriber', () => { + let sshStateReference = new Map() + const sync = vi.fn() + + invalidateRuntimeClientEventReplay({ + getSshStateReference: () => sshStateReference, + requestProjectRefresh: vi.fn(), + markEnvironmentSshStateStale: () => { + sshStateReference = new Map() + }, + hydrateEnvironmentSshState: () => Promise.resolve(), + sync + }) + + expect(sync).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 55422204501..95c8278fc52 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -575,14 +575,20 @@ function remountTerminalTabsAwaitingHostHydration(): void { } } -function getActiveRuntimeEnvironmentId(): string | null { - return useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim() || null +export type RuntimeEnvironmentStoreSyncState = Pick< + AppState, + 'runtimeEnvironments' | 'runtimeStatusByEnvironmentId' | 'settings' | 'sshStateByEnvironment' +> + +function getActiveRuntimeEnvironmentId(state: RuntimeEnvironmentStoreSyncState): string | null { + return state.settings?.activeRuntimeEnvironmentId?.trim() || null } -function getRuntimeClientEventEnvironmentIds(): string[] { - const state = useAppStore.getState() +export function getRuntimeClientEventEnvironmentIds( + state: RuntimeEnvironmentStoreSyncState +): string[] { const ids = new Set() - const activeEnvironmentId = getActiveRuntimeEnvironmentId() + const activeEnvironmentId = getActiveRuntimeEnvironmentId(state) if (activeEnvironmentId) { ids.add(activeEnvironmentId) } @@ -595,8 +601,9 @@ function getRuntimeClientEventEnvironmentIds(): string[] { return [...ids] } -function getReachableRuntimeEnvironmentIds(): string[] { - const state = useAppStore.getState() +export function getReachableRuntimeEnvironmentIds( + state: RuntimeEnvironmentStoreSyncState +): string[] { const ids: string[] = [] for (const [environmentId, status] of state.runtimeStatusByEnvironmentId ?? []) { if (status?.status) { @@ -606,6 +613,18 @@ function getReachableRuntimeEnvironmentIds(): string[] { return ids } +export function canSkipRuntimeEnvironmentStoreSync( + state: RuntimeEnvironmentStoreSyncState, + previousState: RuntimeEnvironmentStoreSyncState +): boolean { + return ( + state.runtimeEnvironments === previousState.runtimeEnvironments && + state.runtimeStatusByEnvironmentId === previousState.runtimeStatusByEnvironmentId && + state.sshStateByEnvironment === previousState.sshStateByEnvironment && + getActiveRuntimeEnvironmentId(state) === getActiveRuntimeEnvironmentId(previousState) + ) +} + export function buildRuntimeClientEventEnvironmentKey(environmentIds: string[]): string { return [...new Set(environmentIds)] .sort() @@ -647,6 +666,115 @@ export function getRuntimeProjectRefreshEnvironmentIds(args: { ] } +type RuntimeEnvironmentStoreSyncSubscriberDeps = { + initialDesiredEnvironmentIds: string[] + initialReachableEnvironmentIds: string[] + buildEnvironmentKey: (environmentIds: string[]) => string + getDesiredEnvironmentIds: (state: RuntimeEnvironmentStoreSyncState) => string[] + getReachableEnvironmentIds: (state: RuntimeEnvironmentStoreSyncState) => string[] + requestProjectRefresh: (environmentId: string) => void + markEnvironmentSshStateStale: (environmentId: string) => void + sync: () => void +} + +export type RuntimeEnvironmentStoreSyncSubscriber = ( + state: RuntimeEnvironmentStoreSyncState, + previousState: RuntimeEnvironmentStoreSyncState +) => void + +/** + * Builds the one renderer-wide runtime subscriber. The reference gate runs + * before either host collection is enumerated; key generation remains the + * second gate for relevant-reference writes whose effective subscription set + * did not change. + */ +export function createRuntimeEnvironmentStoreSyncSubscriber( + deps: RuntimeEnvironmentStoreSyncSubscriberDeps +): RuntimeEnvironmentStoreSyncSubscriber { + let desiredEnvironmentIds = deps.initialDesiredEnvironmentIds + let desiredEnvironmentKey = deps.buildEnvironmentKey(desiredEnvironmentIds) + let reachableEnvironmentIds = deps.initialReachableEnvironmentIds + let reachableEnvironmentKey = deps.buildEnvironmentKey(reachableEnvironmentIds) + let handlingStoreWrite = false + + return (state, previousState) => { + // markEnvironmentSshStateStale can synchronously publish its nested SSH + // bucket. The outer pass incorporates that generation before syncing, so a + // re-entrant pass would only enumerate and sync the same transition twice. + if (handlingStoreWrite || canSkipRuntimeEnvironmentStoreSync(state, previousState)) { + return + } + + handlingStoreWrite = true + try { + const nextDesiredEnvironmentIds = deps.getDesiredEnvironmentIds(state) + const nextReachableEnvironmentIds = deps.getReachableEnvironmentIds(state) + const refreshEnvironmentIds = getRuntimeProjectRefreshEnvironmentIds({ + previousDesired: desiredEnvironmentIds, + nextDesired: nextDesiredEnvironmentIds, + previousReachable: reachableEnvironmentIds, + nextReachable: nextReachableEnvironmentIds + }) + const disconnectedEnvironmentIds = getNewlyDisconnectedRuntimeEnvironmentIds( + reachableEnvironmentIds, + nextReachableEnvironmentIds + ) + + desiredEnvironmentIds = nextDesiredEnvironmentIds + reachableEnvironmentIds = nextReachableEnvironmentIds + for (const environmentId of refreshEnvironmentIds) { + deps.requestProjectRefresh(environmentId) + } + for (const environmentId of disconnectedEnvironmentIds) { + deps.markEnvironmentSshStateStale(environmentId) + } + + // Build after disconnect invalidation: marking a mirrored SSH bucket stale + // advances its generation, and the replacement subscription must capture + // that final generation in this same (single) sync. + const nextDesiredEnvironmentKey = deps.buildEnvironmentKey(desiredEnvironmentIds) + const nextReachableEnvironmentKey = deps.buildEnvironmentKey(reachableEnvironmentIds) + if ( + nextDesiredEnvironmentKey === desiredEnvironmentKey && + nextReachableEnvironmentKey === reachableEnvironmentKey + ) { + return + } + desiredEnvironmentKey = nextDesiredEnvironmentKey + reachableEnvironmentKey = nextReachableEnvironmentKey + deps.sync() + } finally { + handlingStoreWrite = false + } + } +} + +type RuntimeClientEventReplayInvalidationDeps = { + getSshStateReference: () => RuntimeEnvironmentStoreSyncState['sshStateByEnvironment'] + requestProjectRefresh: () => void + markEnvironmentSshStateStale: () => void + hydrateEnvironmentSshState: () => Promise + sync: () => void +} + +/** + * Invalidates a replay after the runtime event stream reports a transport gap. + * A tracked SSH bucket publishes synchronously and lets the store subscriber + * sync it; an empty/already-stale bucket has no reference publication, so this + * path must explicitly sync the advanced module-level SSH generation. + */ +export function invalidateRuntimeClientEventReplay( + deps: RuntimeClientEventReplayInvalidationDeps +): void { + deps.requestProjectRefresh() + const previousSshStateReference = deps.getSshStateReference() + deps.markEnvironmentSshStateStale() + if (deps.getSshStateReference() === previousSshStateReference) { + deps.sync() + } + void deps.hydrateEnvironmentSshState().catch(() => {}) +} + function getWorktreeRuntimeEnvironmentId(worktreeId: string | null | undefined): string | null { return getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) } @@ -1066,7 +1194,7 @@ export function useIpcEvents(): void { } const runtimeClientEventsSync = createRuntimeClientEventsSync({ - getDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds, + getDesiredEnvironmentIds: () => getRuntimeClientEventEnvironmentIds(useAppStore.getState()), getSubscriptionKey: (environmentId) => buildRuntimeClientEventEnvironmentKey([environmentId]), subscribe: (environmentId, onEvent, onError) => { const sshGeneration = getEnvironmentSshStateGeneration(environmentId) @@ -1085,62 +1213,53 @@ export function useIpcEvents(): void { }, onError, () => { - // Why: events during a transport gap are lost; a quick reconnect won't flip unreachable, so refetch (#7970). - runtimeProjectRefreshScheduler.request(environmentId) - // Why: sshStateChanged events during the transport gap are lost, so downgrade the possibly-stale bucket, then refetch. - useAppStore.getState().markEnvironmentSshStateStale(environmentId) - void hydrateRuntimeEnvironmentSshState(environmentId, { force: true }).catch(() => {}) + invalidateRuntimeClientEventReplay({ + getSshStateReference: () => useAppStore.getState().sshStateByEnvironment, + requestProjectRefresh: () => runtimeProjectRefreshScheduler.request(environmentId), + markEnvironmentSshStateStale: () => + useAppStore.getState().markEnvironmentSshStateStale(environmentId), + hydrateEnvironmentSshState: () => + hydrateRuntimeEnvironmentSshState(environmentId, { force: true }), + sync: runtimeClientEventsSync.sync + }) } ) }, onEvent: handleRuntimeClientEvent }) - runtimeClientEventsSync.sync() // Why: no on-connect repo fetch (PR #2); seed discovery for connected runtimes or remote projects hide until Add-Project. - let runtimeClientEventEnvironmentIds = getRuntimeClientEventEnvironmentIds() + const initialRuntimeEnvironmentState = useAppStore.getState() + const runtimeClientEventEnvironmentIds = getRuntimeClientEventEnvironmentIds( + initialRuntimeEnvironmentState + ) for (const environmentId of runtimeClientEventEnvironmentIds) { runtimeProjectRefreshScheduler.request(environmentId) } - let runtimeClientEventEnvironmentKey = buildRuntimeClientEventEnvironmentKey( - runtimeClientEventEnvironmentIds + const reachableRuntimeEnvironmentIds = getReachableRuntimeEnvironmentIds( + initialRuntimeEnvironmentState ) - let reachableRuntimeEnvironmentIds = getReachableRuntimeEnvironmentIds() - let reachableRuntimeEnvironmentKey = buildRuntimeClientEventEnvironmentKey( - reachableRuntimeEnvironmentIds - ) - const unsubscribeRuntimeEnvironmentStore = useAppStore.subscribe(() => { - const nextEnvironmentIds = getRuntimeClientEventEnvironmentIds() - const nextKey = buildRuntimeClientEventEnvironmentKey(nextEnvironmentIds) - const nextReachableEnvironmentIds = getReachableRuntimeEnvironmentIds() - const nextReachableKey = buildRuntimeClientEventEnvironmentKey(nextReachableEnvironmentIds) - if ( - nextKey === runtimeClientEventEnvironmentKey && - nextReachableKey === reachableRuntimeEnvironmentKey - ) { - return - } - for (const environmentId of getRuntimeProjectRefreshEnvironmentIds({ - previousDesired: runtimeClientEventEnvironmentIds, - nextDesired: nextEnvironmentIds, - previousReachable: reachableRuntimeEnvironmentIds, - nextReachable: nextReachableEnvironmentIds - })) { - runtimeProjectRefreshScheduler.request(environmentId) - } - for (const environmentId of getNewlyDisconnectedRuntimeEnvironmentIds( - reachableRuntimeEnvironmentIds, - nextReachableEnvironmentIds - )) { + const handleRuntimeEnvironmentStoreWrite = createRuntimeEnvironmentStoreSyncSubscriber({ + initialDesiredEnvironmentIds: runtimeClientEventEnvironmentIds, + initialReachableEnvironmentIds: reachableRuntimeEnvironmentIds, + buildEnvironmentKey: buildRuntimeClientEventEnvironmentKey, + getDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds, + getReachableEnvironmentIds: getReachableRuntimeEnvironmentIds, + requestProjectRefresh: (environmentId) => + runtimeProjectRefreshScheduler.request(environmentId), + markEnvironmentSshStateStale: (environmentId) => { // No-op when the environment has no SSH bucket (e.g. web client). useAppStore.getState().markEnvironmentSshStateStale(environmentId) - } - runtimeClientEventEnvironmentIds = nextEnvironmentIds - runtimeClientEventEnvironmentKey = nextKey - reachableRuntimeEnvironmentIds = nextReachableEnvironmentIds - reachableRuntimeEnvironmentKey = nextReachableKey - runtimeClientEventsSync.sync() + }, + sync: runtimeClientEventsSync.sync }) + const unsubscribeRuntimeEnvironmentStore = useAppStore.subscribe( + handleRuntimeEnvironmentStoreWrite + ) + // Subscribe before the first runtime stream starts: replay invalidation may + // synchronously publish a tracked SSH bucket and relies on this listener to + // replace that subscription exactly once. + runtimeClientEventsSync.sync() unsubs.push(runtimeClientEventsSync.stop) unsubs.push(runtimeProjectRefreshScheduler.stop) diff --git a/src/renderer/src/store/selectors.test.ts b/src/renderer/src/store/selectors.test.ts index f6b8555925a..45278caf40d 100644 --- a/src/renderer/src/store/selectors.test.ts +++ b/src/renderer/src/store/selectors.test.ts @@ -11,6 +11,7 @@ import { getProjectHostSetupProjectionFromState, getWorktreeMapFromState, resetFloatingVisibleTabCountSelectorCacheForTest, + resetFloatingWorkspaceUnreadSelectorCacheForTest, selectRepoByIdForActiveWorkspace, selectFloatingVisibleTabCount, selectFloatingWorkspaceHasUnread @@ -608,6 +609,10 @@ describe('selectFloatingWorkspaceHasUnread', () => { type UnreadState = Parameters[0] + beforeEach(() => { + resetFloatingWorkspaceUnreadSelectorCacheForTest() + }) + function makeState(overrides: Partial): UnreadState { return { tabsByWorktree: {}, @@ -668,4 +673,72 @@ describe('selectFloatingWorkspaceHasUnread', () => { }) expect(selectFloatingWorkspaceHasUnread(state)).toBe(false) }) + + it('shares one scan across consumers and skips 1,000 unrelated writes', () => { + let tabIdReads = 0 + let terminalUnreadReads = 0 + let completionKeyScans = 0 + const tabs = [ + { + get id() { + tabIdReads += 1 + return 'ft1' + }, + title: 'floating', + ptyId: null + } as unknown as TerminalTab + ] + const unreadTerminalTabs = new Proxy>( + {}, + { + get(target, property, receiver) { + if (typeof property === 'string') { + terminalUnreadReads += 1 + } + return Reflect.get(target, property, receiver) + } + } + ) + const unreadAgentCompletionPanes = new Proxy>( + { 'main-tab:leaf-a': true }, + { + ownKeys(target) { + completionKeyScans += 1 + return Reflect.ownKeys(target) + } + } + ) + const state = makeState({ + tabsByWorktree: { [FLOATING]: tabs }, + unreadTerminalTabs, + unreadAgentCompletionPanes + }) + + expect(selectFloatingWorkspaceHasUnread(state)).toBe(false) + const countsAfterFirstConsumer = { + tabIdReads, + terminalUnreadReads, + completionKeyScans + } + expect(selectFloatingWorkspaceHasUnread(state)).toBe(false) + for (let write = 0; write < 1_000; write += 1) { + expect( + selectFloatingWorkspaceHasUnread({ + ...state, + unrelatedStoreRevision: write + } as unknown as UnreadState) + ).toBe(false) + } + expect({ tabIdReads, terminalUnreadReads, completionKeyScans }).toEqual( + countsAfterFirstConsumer + ) + + expect( + selectFloatingWorkspaceHasUnread({ + ...state, + unreadTerminalTabs: { ft1: true } + }) + ).toBe(true) + expect(tabIdReads).toBeGreaterThan(countsAfterFirstConsumer.tabIdReads) + }) }) diff --git a/src/renderer/src/store/selectors.ts b/src/renderer/src/store/selectors.ts index b0f4b21f26f..77c124f562c 100644 --- a/src/renderer/src/store/selectors.ts +++ b/src/renderer/src/store/selectors.ts @@ -118,6 +118,14 @@ type FloatingWorkspaceUnreadState = Pick< AppState, 'tabsByWorktree' | 'unreadTerminalTabs' | 'unreadAgentCompletionPanes' > +type FloatingWorkspaceUnreadCache = { + tabs: NonNullable + unreadTerminalTabs: AppState['unreadTerminalTabs'] + unreadAgentCompletionPanes: AppState['unreadAgentCompletionPanes'] + hasUnread: boolean +} + +let floatingWorkspaceUnreadCache: FloatingWorkspaceUnreadCache | null = null /** * True when any terminal tab in the floating workspace has an unacknowledged @@ -129,32 +137,57 @@ type FloatingWorkspaceUnreadState = Pick< * removed tabs cannot light it). Bells mark `unreadTerminalTabs[tabId]`; * completions mark `unreadAgentCompletionPanes[paneKey]` — both ungated. * - * Returns a primitive boolean, so subscribers re-render only when it flips, and - * the empty-workspace early return keeps the common case O(1) despite Zustand - * rerunning selectors on every write. + * The launcher and floating overlay both stay mounted. Cache their shared + * reference projection so the second consumer and unrelated Zustand writes + * return in O(1) without repeating either unread-map scan. */ export function selectFloatingWorkspaceHasUnread(state: FloatingWorkspaceUnreadState): boolean { - const tabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] - if (!tabs || tabs.length === 0) { - return false + const tabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_TABS + const cached = floatingWorkspaceUnreadCache + if ( + cached && + cached.tabs === tabs && + cached.unreadTerminalTabs === state.unreadTerminalTabs && + cached.unreadAgentCompletionPanes === state.unreadAgentCompletionPanes + ) { + return cached.hasUnread } - const floatingTabIds = new Set() - for (const tab of tabs) { - if (state.unreadTerminalTabs[tab.id]) { - return true + + let hasUnread = false + if (tabs.length > 0) { + const floatingTabIds = new Set() + for (const tab of tabs) { + if (state.unreadTerminalTabs[tab.id]) { + hasUnread = true + break + } + floatingTabIds.add(tab.id) } - floatingTabIds.add(tab.id) - } - // paneKey is `${tabId}:${leafId}` and tabIds never contain ":", so the prefix - // up to the first ":" is the owning tab id. - for (const paneKey of Object.keys(state.unreadAgentCompletionPanes)) { - const separatorIndex = paneKey.indexOf(':') - const tabId = separatorIndex === -1 ? paneKey : paneKey.slice(0, separatorIndex) - if (floatingTabIds.has(tabId)) { - return true + if (!hasUnread) { + // paneKey is `${tabId}:${leafId}` and tabIds never contain ":", so the + // prefix up to the first ":" is the owning tab id. + for (const paneKey of Object.keys(state.unreadAgentCompletionPanes)) { + const separatorIndex = paneKey.indexOf(':') + const tabId = separatorIndex === -1 ? paneKey : paneKey.slice(0, separatorIndex) + if (floatingTabIds.has(tabId)) { + hasUnread = true + break + } + } } } - return false + + floatingWorkspaceUnreadCache = { + tabs, + unreadTerminalTabs: state.unreadTerminalTabs, + unreadAgentCompletionPanes: state.unreadAgentCompletionPanes, + hasUnread + } + return hasUnread +} + +export function resetFloatingWorkspaceUnreadSelectorCacheForTest(): void { + floatingWorkspaceUnreadCache = null } export function getAllWorktreesFromState(state: Pick): Worktree[] { From 2d500278b4ce326cb54407845e0d02b06aee433e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:48:47 -0700 Subject: [PATCH 07/65] build(windows): refuse unpatched node-pty prebuilds Merged after clean CI, Windows packaging verification, and readiness review. --- .github/workflows/pr.yml | 1 + config/electron-builder.config.cjs | 10 +++ config/patches/node-pty@1.1.0.patch | 40 ++++++++++- ...sure-native-runtime-job-ownership.test.mjs | 67 +++++++++++++++++++ config/scripts/ensure-native-runtime.mjs | 2 + config/scripts/ensure-native-runtime.test.mjs | 7 ++ config/scripts/node-pty-job-ownership.cjs | 29 ++++++++ config/scripts/rebuild-native-deps.mjs | 7 +- config/scripts/rebuild-native-deps.test.mjs | 64 +++++++++++++++++- ...verify-packaged-node-pty-job-ownership.cjs | 25 +++++++ ...y-packaged-node-pty-job-ownership.test.mjs | 51 ++++++++++++++ pnpm-lock.yaml | 6 +- 12 files changed, 302 insertions(+), 7 deletions(-) create mode 100644 config/scripts/ensure-native-runtime-job-ownership.test.mjs create mode 100644 config/scripts/node-pty-job-ownership.cjs create mode 100644 config/scripts/verify-packaged-node-pty-job-ownership.cjs create mode 100644 config/scripts/verify-packaged-node-pty-job-ownership.test.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 22fb956a1a5..06e30b1d39c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -642,6 +642,7 @@ jobs: - name: Test Windows-specific boundaries run: >- pnpm exec vitest run --config config/vitest.config.ts + config/scripts/rebuild-native-deps.test.mjs src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts src/shared/child-process/windows-command-line.win32.test.ts diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 33e31aa2605..bf7d5b1382c 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -14,6 +14,9 @@ const { const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs') const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs') const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs') +const { + verifyPackagedNodePtyJobOwnership +} = require('./scripts/verify-packaged-node-pty-job-ownership.cjs') const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs') // Why: dev-channel builds must carry the *release* identity — same bundle id, @@ -266,6 +269,13 @@ module.exports = { const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 } const hostArchEnum = archEnumByNodeArch[process.arch] const canExecuteTargetArch = context.arch === hostArchEnum || context.arch === 4 + if (context.electronPlatformName === 'win32') { + if (process.platform === 'win32' && canExecuteTargetArch) { + verifyPackagedNodePtyJobOwnership(resourcesDir) + } else { + console.log('[verify-packaged-node-pty] skipped cross-platform or cross-arch package') + } + } verifySkillsCliRuntime(join(resourcesDir, 'app.asar.unpacked', 'out'), resourcesDir, { executeCommands: canExecuteTargetArch }) diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index 3daad4fde68..c5c35191f35 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -2,7 +2,12 @@ diff --git a/binding.gyp b/binding.gyp index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644 --- a/binding.gyp +++ b/binding.gyp -@@ -5,9 +5,6 @@ +@@ -1,13 +1,10 @@ + { + 'target_defaults': { + 'dependencies': [ +- " [], + terminateJob: () => true, + assignCurrentProcessToJob: () => true + } +} +const PREBUILD = { + dir: 'prebuilds/win32-x64/', + module: { + startProcess: () => {}, + connect: () => {}, + resize: () => {}, + clear: () => {}, + kill: () => {} + } +} + +describe('assertNodePtyJobOwnership', () => { + it('keeps node-addon-api project paths absolute during Windows source builds', () => { + expect(NODE_PTY_PATCH).toContain( + `+ " { + expect(NODE_PTY_PATCH).toContain("- 'target_name': 'conpty_console_list'") + expect(NODE_PTY_PATCH).toContain("- 'target_name': 'pty'") + }) + + it('rejects the prebuild that shipped without the job exports', () => { + expect(() => + assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD }) + ).toThrow(/listJobProcessIds, terminateJob, assignCurrentProcessToJob/) + }) + + it('names where the bad native came from, so the fix is obvious', () => { + expect(() => + assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD }) + ).toThrow(/prebuilds\/win32-x64/) + }) + + it('accepts a source build carrying the patch', () => { + expect(() => + assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PATCHED }) + ).not.toThrow() + }) + + it.each([ + ['non-Windows hosts', { platform: 'darwin', nativeName: 'pty' }], + ['the pre-ConPTY winpty backend', { platform: 'win32', nativeName: 'pty' }] + ])('stays out of the way on %s', (_case, spec) => { + expect(() => assertNodePtyJobOwnership({ ...spec, native: PREBUILD })).not.toThrow() + }) +}) diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index 941049472fb..b570ac0e7ae 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -7,6 +7,7 @@ import { release } from 'node:os' import { basename, resolve } from 'node:path' const require = createRequire(import.meta.url) +const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs') const scriptPath = import.meta.filename const projectDir = resolve(import.meta.dirname, '../..') const runtime = readRuntimeArg() @@ -277,6 +278,7 @@ function loadNodePtyNativeModule() { // terminal is created, so require('node-pty') alone can miss ABI mismatches. const native = loadNativeModule(nativeName) assertNodePtyWindowsConptyRuntime(native?.dir) + assertNodePtyJobOwnership({ nativeName, native }) if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native?.dir)) { throw new Error( `node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active` diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index 0bc8e5ae077..d1ea104b863 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -14,6 +14,9 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url)) +const sourceNodePtyJobOwnershipPath = fileURLToPath( + new URL('./node-pty-job-ownership.cjs', import.meta.url) +) describe('ensure-native-runtime', () => { it('rechecks Node native modules in fresh child processes after rebuilding', () => { @@ -156,6 +159,10 @@ describe('ensure-native-runtime', () => { function mkTempProject() { const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-')) mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true }) + copyFileSync( + sourceNodePtyJobOwnershipPath, + join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs') + ) return projectDir } diff --git a/config/scripts/node-pty-job-ownership.cjs b/config/scripts/node-pty-job-ownership.cjs new file mode 100644 index 00000000000..35884c1414a --- /dev/null +++ b/config/scripts/node-pty-job-ownership.cjs @@ -0,0 +1,29 @@ +'use strict' + +const NODE_PTY_JOB_EXPORTS = [ + 'listJobProcessIds', + 'terminateJob', + 'assignCurrentProcessToJob' +] + +function assertNodePtyJobOwnership({ nativeName, native, platform = process.platform }) { + if (platform !== 'win32' || nativeName !== 'conpty') { + return + } + const exported = native?.module ?? native + const missing = NODE_PTY_JOB_EXPORTS.filter((name) => typeof exported?.[name] !== 'function') + if (missing.length === 0) { + return + } + throw new Error( + [ + `node-pty's conpty native is missing ${missing.join(', ')}.`, + `Resolved from: ${native?.dir ?? 'unknown'}`, + 'That build cannot own a PTY tree, so terminatePtyJob degrades to "unavailable"', + 'and pane teardown falls back to guessing by PID ancestry.', + 'Rebuild node-pty from source so config/patches/node-pty@1.1.0.patch applies.' + ].join(' ') + ) +} + +module.exports = { assertNodePtyJobOwnership } diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 6d776abd1ca..78e25d3634c 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -475,9 +475,14 @@ function loadNativeModule(moduleName) { } if (moduleName === 'node-pty') { projectRequire('node-pty') + const { assertNodePtyJobOwnership } = projectRequire( + './config/scripts/node-pty-job-ownership.cjs' + ) const { loadNativeModule } = projectRequire('node-pty/lib/utils') - const native = loadNativeModule(getNodePtyNativeModuleName()) + const nativeName = getNodePtyNativeModuleName() + const native = loadNativeModule(nativeName) assertNodePtyWindowsConptyRuntime(native.dir) + assertNodePtyJobOwnership({ nativeName, native }) if (requirePatchedNodePtySourceBuild && !isNodePtyReleaseBuildDir(native.dir)) { throw new Error( 'node-pty resolved to ' + diff --git a/config/scripts/rebuild-native-deps.test.mjs b/config/scripts/rebuild-native-deps.test.mjs index 0ab28de7a65..b158ee57f21 100644 --- a/config/scripts/rebuild-native-deps.test.mjs +++ b/config/scripts/rebuild-native-deps.test.mjs @@ -18,6 +18,9 @@ const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', impo const sourceInstallScriptPath = fileURLToPath( new URL('./install-electron-package-binary.mjs', import.meta.url) ) +const sourceNodePtyJobOwnershipPath = fileURLToPath( + new URL('./node-pty-job-ownership.cjs', import.meta.url) +) describe('rebuild-native-deps Electron install fallback', () => { it('continues non-strict postinstall when Electron retry download fails', () => { @@ -135,6 +138,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' }) writeFakeWindowsRegistry(projectDir) + writeFakeWindowsProcessTree(projectDir) writeFakeNodePtyConptyPayload(projectDir, process.arch) const result = runRebuildScript(projectDir, { @@ -189,6 +193,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) writeFakeLoadableNodePty(projectDir) + writeFakeWindowsProcessTree(projectDir) writeFakeNodePtyConptyPayload(projectDir, process.arch) const result = runRebuildScript(projectDir, { @@ -207,6 +212,36 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { } ) + it.skipIf(process.platform !== 'win32')( + 'rebuilds a loadable ConPTY native that lacks Orca job ownership', + () => { + const projectDir = mkTempProject() + + try { + const rebuildLogPath = join(projectDir, 'electron-rebuild.log') + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + writeFakeLoadableNodePty(projectDir, { ownsPtyJob: false }) + writeFakeWindowsRegistry(projectDir) + writeFakeWindowsProcessTree(projectDir) + + const result = runRebuildScript(projectDir, { + ORCA_REBUILD_TEST_LOG: rebuildLogPath, + npm_config_platform: 'win32', + npm_config_arch: process.arch + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('Rebuilding failed native modules: node-pty') + expect(result.stdout).toContain('missing listJobProcessIds') + const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) + expect(rebuildCall.onlyModules).toEqual(['node-pty']) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + } + ) + it.skipIf(process.platform === 'win32')( 'rebuilds when Electron can load node-pty but patched build artifacts are missing', () => { @@ -305,6 +340,10 @@ function mkTempProject() { sourceInstallScriptPath, join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs') ) + copyFileSync( + sourceNodePtyJobOwnershipPath, + join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs') + ) return projectDir } @@ -495,7 +534,10 @@ function writeFakeNodePtyConptyPayload(projectDir, arch) { writeFileSync(join(sourceDir, 'OpenConsole.exe'), `OpenConsole.exe ${arch}`) } -function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } = {}) { +function writeFakeLoadableNodePty( + projectDir, + { nativeDir = 'prebuilds/pty', ownsPtyJob = true } = {} +) { const nodePtyDir = join(projectDir, 'node_modules', 'node-pty') mkdirSync(join(nodePtyDir, 'lib'), { recursive: true }) writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n') @@ -503,7 +545,19 @@ function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } = join(nodePtyDir, 'lib', 'utils.js'), ` exports.loadNativeModule = function loadNativeModule(nativeName) { - return { dir: ${JSON.stringify(nativeDir)}, module: { nativeName } } + return { + dir: ${JSON.stringify(nativeDir)}, + module: { + nativeName, + ...(${JSON.stringify(ownsPtyJob)} + ? { + listJobProcessIds() {}, + terminateJob() {}, + assignCurrentProcessToJob() {} + } + : {}) + } + } } ` ) @@ -518,6 +572,12 @@ function writeFakeWindowsRegistry(projectDir) { ) } +function writeFakeWindowsProcessTree(projectDir) { + const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') + mkdirSync(processTreeDir, { recursive: true }) + writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n') +} + function writeNodePtyPatchFile(projectDir) { mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true }) writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n') diff --git a/config/scripts/verify-packaged-node-pty-job-ownership.cjs b/config/scripts/verify-packaged-node-pty-job-ownership.cjs new file mode 100644 index 00000000000..5ba21cee8ec --- /dev/null +++ b/config/scripts/verify-packaged-node-pty-job-ownership.cjs @@ -0,0 +1,25 @@ +const { createRequire } = require('node:module') +const { join } = require('node:path') +const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs') + +function loadPackagedConpty(resourcesDir) { + const packagedRequire = createRequire(join(resourcesDir, 'package.json')) + const { loadNativeModule } = packagedRequire('./node_modules/node-pty/lib/utils') + return loadNativeModule('conpty') +} + +function verifyPackagedNodePtyJobOwnership(resourcesDir, options = {}) { + const platform = options.platform ?? process.platform + if (platform !== 'win32') { + return + } + + const native = (options.loadNative ?? loadPackagedConpty)(resourcesDir) + assertNodePtyJobOwnership({ platform, nativeName: 'conpty', native }) + if (!native.dir.replace(/\\/g, '/').includes('build/Release/')) { + throw new Error(`Packaged node-pty resolved to ${native.dir}; expected patched build/Release`) + } + console.log('[verify-packaged-node-pty] OK — packaged ConPTY owns process trees') +} + +module.exports = { verifyPackagedNodePtyJobOwnership } diff --git a/config/scripts/verify-packaged-node-pty-job-ownership.test.mjs b/config/scripts/verify-packaged-node-pty-job-ownership.test.mjs new file mode 100644 index 00000000000..26a33cfbbd6 --- /dev/null +++ b/config/scripts/verify-packaged-node-pty-job-ownership.test.mjs @@ -0,0 +1,51 @@ +import { createRequire } from 'node:module' +import { describe, expect, it, vi } from 'vitest' + +const require = createRequire(import.meta.url) +const { + verifyPackagedNodePtyJobOwnership +} = require('./verify-packaged-node-pty-job-ownership.cjs') + +const PATCHED = { + dir: '../build/Release/', + module: { + listJobProcessIds: () => [], + terminateJob: () => true, + assignCurrentProcessToJob: () => true + } +} + +describe('verifyPackagedNodePtyJobOwnership', () => { + it('accepts the packaged patched ConPTY binding', () => { + expect(() => + verifyPackagedNodePtyJobOwnership('resources', { + platform: 'win32', + loadNative: () => PATCHED + }) + ).not.toThrow() + }) + + it('rejects a packaged upstream prebuild', () => { + expect(() => + verifyPackagedNodePtyJobOwnership('resources', { + platform: 'win32', + loadNative: () => ({ dir: '../prebuilds/win32-x64/', module: {} }) + }) + ).toThrow(/missing listJobProcessIds, terminateJob, assignCurrentProcessToJob/) + }) + + it('requires the patched source-build directory', () => { + expect(() => + verifyPackagedNodePtyJobOwnership('resources', { + platform: 'win32', + loadNative: () => ({ ...PATCHED, dir: '../prebuilds/win32-x64/' }) + }) + ).toThrow(/expected patched build\/Release/) + }) + + it('does not load Windows natives for other targets', () => { + const loadNative = vi.fn() + verifyPackagedNodePtyJobOwnership('resources', { platform: 'linux', loadNative }) + expect(loadNative).not.toHaveBeenCalled() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ee3caf6950..6d739a42d25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,7 @@ patchedDependencies: hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 path: config/patches/lint-staged@16.4.0.patch node-pty@1.1.0: - hash: 710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5 + hash: 9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1 path: config/patches/node-pty@1.1.0.patch importers: @@ -69,7 +69,7 @@ importers: version: 3.3.1 node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5) + version: 1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1) posthog-node: specifier: ^5.33.3 version: 5.33.3 @@ -12046,7 +12046,7 @@ snapshots: node-int64@0.4.0: {} - node-pty@1.1.0(patch_hash=710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5): + node-pty@1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1): dependencies: node-addon-api: 7.1.1 From beb1d4519892b7931f9dc0013dfa321e09f2c945 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:49:01 -0700 Subject: [PATCH 08/65] test(windows): run PTY IPC suites in Windows lane Merged after clean CI, Windows PTY IPC validation, and readiness review. --- .github/workflows/pr.yml | 2 + .../ipc/pty-codex-account-attribution.test.ts | 59 +++++++++++-------- src/main/ipc/pty-ipc-test-constants.ts | 1 + ...-spawn-env-codex-resume-provenance.test.ts | 16 +++-- 4 files changed, 48 insertions(+), 30 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 06e30b1d39c..f44e65ddb47 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -659,6 +659,8 @@ jobs: src/main/runtime/repo-worktree-admin-fingerprint.test.ts src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts src/shared/secure-file-fsync-flags.test.ts + src/main/ipc/pty-codex-account-attribution.test.ts + src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts # Why the :parallel variant: identical to build:release except the three # electron-vite targets overlap instead of running back to back. The Linux package diff --git a/src/main/ipc/pty-codex-account-attribution.test.ts b/src/main/ipc/pty-codex-account-attribution.test.ts index 9b06723e726..353be0f5909 100644 --- a/src/main/ipc/pty-codex-account-attribution.test.ts +++ b/src/main/ipc/pty-codex-account-attribution.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' import { readFileSyncMock, recordCodexPaneAccountMock, forgetCodexPaneAccountMock } from './pty-ipc-mock-registry' -import { TEST_CODEX_HOME, TEST_CODEX_AUTH_JSON } from './pty-ipc-test-constants' +import { TEST_CODEX_HOME, TEST_CODEX_AUTH_JSON, TEST_MANAGED_ROOT } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { registerPtyHandlers, setLocalPtyProvider } from './pty' @@ -52,6 +53,12 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () => import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) ) +const MANAGED_ORIGIN_HOME = join(TEST_MANAGED_ROOT, 'origin', 'home') +const MANAGED_CURRENT_HOME = join(TEST_MANAGED_ROOT, 'current', 'home') +const MANAGED_SHARED_HOME = join(TEST_MANAGED_ROOT, 'shared-mirror', 'home') +const ORIGIN_ROLLOUT = join(MANAGED_ORIGIN_HOME, 'sessions', '2026', '07', '20', 'rollout-a.jsonl') +const SHARED_ROLLOUT = join(MANAGED_SHARED_HOME, 'sessions', '2026', '07', '20', 'rollout-a.jsonl') + describe('registerPtyHandlers', () => { const { handlers, mainWindow } = setupPtyIpcSuite() @@ -145,15 +152,15 @@ describe('registerPtyHandlers', () => { } return '' }) - const resolveHome = vi.fn(() => '/managed/current/home') + const resolveHome = vi.fn(() => MANAGED_CURRENT_HOME) registerPtyHandlers( mainWindow as never, undefined, resolveHome, (() => ({ codexManagedAccounts: [ - { id: 'account-a', managedHomePath: '/managed/origin/home' }, - { id: 'account-b', managedHomePath: '/managed/current/home' } + { id: 'account-a', managedHomePath: MANAGED_ORIGIN_HOME }, + { id: 'account-b', managedHomePath: MANAGED_CURRENT_HOME } ] })) as never, undefined, @@ -161,7 +168,7 @@ describe('registerPtyHandlers', () => { { prepareCodexSessionResume: async () => ({ outcome: 'resume' as const, - codexHomePath: '/managed/origin/home' + codexHomePath: MANAGED_ORIGIN_HOME }) } ) @@ -175,7 +182,7 @@ describe('registerPtyHandlers', () => { resumeProviderSession: { key: 'session_id', id: 'session-a', - transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl' + transcriptPath: ORIGIN_ROLLOUT } }) const rejection = expect(launch).rejects.toThrow( @@ -203,21 +210,21 @@ describe('registerPtyHandlers', () => { const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: 'account-b', codexManagedAccounts: [ - { id: 'account-a', managedHomePath: '/managed/origin/home' }, - { id: 'account-b', managedHomePath: '/managed/current/home' } + { id: 'account-a', managedHomePath: MANAGED_ORIGIN_HOME }, + { id: 'account-b', managedHomePath: MANAGED_CURRENT_HOME } ] }) registerPtyHandlers( mainWindow as never, undefined, - vi.fn(() => '/managed/current/home'), + vi.fn(() => MANAGED_CURRENT_HOME), getSettings as never, undefined, undefined, { prepareCodexSessionResume: async () => ({ outcome: 'resume' as const, - codexHomePath: '/managed/origin/home' + codexHomePath: MANAGED_ORIGIN_HOME }) } ) @@ -231,7 +238,7 @@ describe('registerPtyHandlers', () => { resumeProviderSession: { key: 'session_id', id: 'session-a', - transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl' + transcriptPath: ORIGIN_ROLLOUT } }) @@ -240,7 +247,7 @@ describe('registerPtyHandlers', () => { expect(recordCodexPaneAccountMock.mock.calls).toEqual([ ['pty-resumed', { selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' }] ]) - expect(readFileSyncMock).toHaveBeenCalledWith('/managed/origin/home/auth.json', 'utf8') + expect(readFileSyncMock).toHaveBeenCalledWith(join(MANAGED_ORIGIN_HOME, 'auth.json'), 'utf8') expect(forgetCodexPaneAccountMock).not.toHaveBeenCalled() }) it('leaves a resumed Codex pane unattributed when no account owns its home', async () => { @@ -257,19 +264,19 @@ describe('registerPtyHandlers', () => { } as never) const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: 'account-b', - codexManagedAccounts: [{ id: 'account-b', managedHomePath: '/managed/current/home' }] + codexManagedAccounts: [{ id: 'account-b', managedHomePath: MANAGED_CURRENT_HOME }] }) registerPtyHandlers( mainWindow as never, undefined, - vi.fn(() => '/managed/current/home'), + vi.fn(() => MANAGED_CURRENT_HOME), getSettings as never, undefined, undefined, { prepareCodexSessionResume: async () => ({ outcome: 'resume' as const, - codexHomePath: '/managed/shared-mirror/home' + codexHomePath: MANAGED_SHARED_HOME }) } ) @@ -282,7 +289,7 @@ describe('registerPtyHandlers', () => { resumeProviderSession: { key: 'session_id', id: 'session-a', - transcriptPath: '/managed/shared-mirror/home/sessions/2026/07/20/rollout-a.jsonl' + transcriptPath: SHARED_ROLLOUT } }) @@ -320,22 +327,22 @@ describe('registerPtyHandlers', () => { const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: 'account-b', codexManagedAccounts: [ - { id: 'account-a', managedHomePath: '/managed/origin/home' }, - { id: 'account-b', managedHomePath: '/managed/current/home' } + { id: 'account-a', managedHomePath: MANAGED_ORIGIN_HOME }, + { id: 'account-b', managedHomePath: MANAGED_CURRENT_HOME } ] }) handlers.clear() registerPtyHandlers( mainWindow as never, runtime as never, - vi.fn(() => '/managed/current/home'), + vi.fn(() => MANAGED_CURRENT_HOME), getSettings as never, undefined, undefined, { prepareCodexSessionResume: async () => ({ outcome: 'resume' as const, - codexHomePath: '/managed/origin/home' + codexHomePath: MANAGED_ORIGIN_HOME }) } ) @@ -351,7 +358,7 @@ describe('registerPtyHandlers', () => { resumeProviderSession: { key: 'session_id', id: 'session-a', - transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl' + transcriptPath: ORIGIN_ROLLOUT } }) @@ -361,7 +368,7 @@ describe('registerPtyHandlers', () => { { selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' } ] ]) - expect(readFileSyncMock).toHaveBeenCalledWith('/managed/origin/home/auth.json', 'utf8') + expect(readFileSyncMock).toHaveBeenCalledWith(join(MANAGED_ORIGIN_HOME, 'auth.json'), 'utf8') expect(forgetCodexPaneAccountMock).not.toHaveBeenCalled() }) it('leaves a runtime-controller resumed Codex pane unattributed when no account owns its home', async () => { @@ -389,10 +396,10 @@ describe('registerPtyHandlers', () => { } const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: 'account-b', - codexManagedAccounts: [{ id: 'account-b', managedHomePath: '/managed/current/home' }] + codexManagedAccounts: [{ id: 'account-b', managedHomePath: MANAGED_CURRENT_HOME }] }) handlers.clear() - const resolveHome = vi.fn(() => '/managed/shared-mirror/home') + const resolveHome = vi.fn(() => MANAGED_SHARED_HOME) registerPtyHandlers( mainWindow as never, runtime as never, @@ -403,7 +410,7 @@ describe('registerPtyHandlers', () => { { prepareCodexSessionResume: async () => ({ outcome: 'resume' as const, - codexHomePath: '/managed/shared-mirror/home', + codexHomePath: MANAGED_SHARED_HOME, reconcileSharedRuntimeAuth: true }) } @@ -419,7 +426,7 @@ describe('registerPtyHandlers', () => { resumeProviderSession: { key: 'session_id', id: 'session-a', - transcriptPath: '/managed/shared-mirror/home/sessions/2026/07/20/rollout-a.jsonl' + transcriptPath: SHARED_ROLLOUT } }) diff --git a/src/main/ipc/pty-ipc-test-constants.ts b/src/main/ipc/pty-ipc-test-constants.ts index 003b5165be0..e3eae546ca6 100644 --- a/src/main/ipc/pty-ipc-test-constants.ts +++ b/src/main/ipc/pty-ipc-test-constants.ts @@ -13,6 +13,7 @@ export type PlatformGatedTest = ( export const isWindowsHost = process.platform === 'win32' export const posixOnlyIt: PlatformGatedTest = isWindowsHost ? it.skip : it +export const TEST_MANAGED_ROOT = isWindowsHost ? 'C:\\managed' : '/managed' export const BUNDLED_RESOURCES_PATH = join('/tmp', 'orca-bundled-resources') // Why: this suite forces darwin before every test, including on Linux CI. export const BUNDLED_CLI_PATH = getBundledLauncherPath('darwin', BUNDLED_RESOURCES_PATH) as string diff --git a/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts b/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts index 8dd16f4a63c..56169b208c2 100644 --- a/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts +++ b/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' import { spawnMock } from './pty-ipc-mock-registry' -import { posixOnlyIt } from './pty-ipc-test-constants' +import { posixOnlyIt, TEST_MANAGED_ROOT } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { prepareCodexSessionResume } from '../codex/codex-session-resume-preparation' import { registerPtyHandlers, setLocalPtyProvider, type PrepareCodexSessionResume } from './pty' @@ -55,9 +56,16 @@ describe('registerPtyHandlers', () => { describe('spawn environment', () => { describe('unverifiable Codex resume provenance', () => { const RESUME_SESSION_ID = '019f81b9-19a9-7651-a8d1-352d9420bd11' - const ORIGIN_HOME = '/managed/origin/home' - const OTHER_HOME = '/managed/other/home' - const ORIGIN_ROLLOUT = `${ORIGIN_HOME}/sessions/2026/07/20/rollout-2026-07-20T12-00-00-${RESUME_SESSION_ID}.jsonl` + const ORIGIN_HOME = join(TEST_MANAGED_ROOT, 'origin', 'home') + const OTHER_HOME = join(TEST_MANAGED_ROOT, 'other', 'home') + const ORIGIN_ROLLOUT = join( + ORIGIN_HOME, + 'sessions', + '2026', + '07', + '20', + `rollout-2026-07-20T12-00-00-${RESUME_SESSION_ID}.jsonl` + ) // Why: main's real provenance rule via the same prepareCodexSessionResume that // index.ts calls, so the outcome wiring is exercised rather than restated. This From 4f525f17f5e02025257fd3e31f8edb82d1f80a47 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:06:13 -0700 Subject: [PATCH 09/65] feat(agent-status): add the pane agent identity resolver (#16157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent-status): add the pane agent identity resolver Four ladders answer "which agent is in this pane" independently — the tab icon, the open-tab/search occupant, the sidebar title rows, and the sidebar hook-row fallback — and they disagree. Two consult the terminal title before the launch record, so a string Orca parsed outranks a fact Orca owns. resolvePaneAgentIdentity is the single ranked answer. Two rules, one of which is not an ordering: 1. Evidence is ranked by how directly it observes the process; a display title is last. 2. Each observation carries the runId of the agent run it describes. Evidence from a superseded run is INELIGIBLE, not merely outranked. Rule 2 is the part reordering could never supply. A completed hook naming A plus a title naming B is either a bug (hook right, title stale) or a legitimate pane reclaim (title right) — identical signals, opposite correct answers. Run ids make them different facts: in the bug both belong to the current run; in the reclaim the hook belongs to a previous one. That pair ships as a test asserting the two produce opposite answers from the same evidence. Missing run ids are treated as eligible. Absence means "this peer does not publish them", not "this is stale", so an old host's rows are never blanked. Sibling evidence is opt-in so pane-scoped consumers cannot inherit another pane's agent. No consumer imports this yet; each migrates separately with its own evidence. Verified non-vacuous: reversing the authority order fails 10 of 18 assertions and removing the run filter fails 3. * fix(agent-status): close three resolver contract holes found in review **Duplicate evidence of one source resolved by array order.** `eligible.find(...)` returned the first match, so two live hooks naming different agents were settled by input position — the exact property this resolver exists to remove. The original order-independence test only used DISTINCT sources, so it never exercised it. Conflicting same-class evidence now returns null with `ambiguousAt`, and does NOT fall through to a weaker source: letting a title answer whenever two hooks disagree is worse than saying nothing. **A bare numeric runId collided across authority restarts.** `incarnation` is a total order only within one `authorityId` (agent-status-observation.ts states this), and the id is regenerated per authority instance, so a restarted host counting from its own floor would report `1` and match an unrelated live run 1. The run key now carries its authority, and evidence from a DIFFERENT authority is treated as incomparable — kept, like an absent key — rather than as stale. **Title stayed reachable by consumers that authorize writes.** Ranking it last makes misuse unlikely; `minimumSource` makes it impossible. An action consumer passes `'launch'` and weaker evidence is dropped before ranking, so routing or delivery cannot name a target from a parsed string even by reordering its inputs. Display surfaces omit it and are unaffected. Also restores the generic agent-vocabulary parameter, which lives on the routing branch and was lost when this branch was rebased. Each fix is mutation-verified: first-match restored fails 3, ignoring authority fails 1, dropping the floor fails 2. The authority test was itself vacuous on the first attempt — both sides used `incarnation: 1`, so a resolver ignoring authority still passed on the numeric compare. It now uses differing incarnations. The remaining review finding, that `process > launch` has no freshness bound, is NOT fixed here: it needs an observation timestamp the evidence type does not yet carry. Recorded rather than silently dropped. --- .../pane-agent-identity-resolver.test.ts | 278 ++++++++++++++++++ src/shared/pane-agent-identity-resolver.ts | 163 ++++++++++ 2 files changed, 441 insertions(+) create mode 100644 src/shared/pane-agent-identity-resolver.test.ts create mode 100644 src/shared/pane-agent-identity-resolver.ts diff --git a/src/shared/pane-agent-identity-resolver.test.ts b/src/shared/pane-agent-identity-resolver.test.ts new file mode 100644 index 00000000000..e11ef249c65 --- /dev/null +++ b/src/shared/pane-agent-identity-resolver.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from 'vitest' +import { + PANE_AGENT_EVIDENCE_SOURCES, + type PaneAgentEvidence, + resolvePaneAgentIdentity +} from './pane-agent-identity-resolver' + +const resolve = (evidence: PaneAgentEvidence[], extra = {}) => + resolvePaneAgentIdentity({ evidence, ...extra }) + +const H = 'authority-a' + +describe('resolvePaneAgentIdentity', () => { + describe('a display title is the last thing consulted', () => { + it.each(PANE_AGENT_EVIDENCE_SOURCES.filter((s) => s !== 'title' && s !== 'sibling'))( + 'lets %s outrank a conflicting title', + (source) => { + const result = resolve([ + { source: 'title', agent: 'codex' }, + { source, agent: 'grok' } + ]) + expect(result.agent).toBe('grok') + expect(result.source).toBe(source) + } + ) + + it('uses the title only when nothing else is eligible', () => { + expect(resolve([{ source: 'title', agent: 'codex' }])).toMatchObject({ + agent: 'codex', + source: 'title' + }) + }) + + it('outranks the launch record with nothing weaker than the launch record', () => { + // The tab ladder currently puts the parsed title ABOVE activeLaunchAgent, so a string Orca + // parsed beats a fact Orca owns. That inversion cannot be expressed here. + const result = resolve([ + { source: 'launch', agent: 'claude' }, + { source: 'title', agent: 'gemini' } + ]) + expect(result.agent).toBe('claude') + }) + }) + + describe('run generation separates the bug from the legitimate reclaim', () => { + // Both shapes are `completed hook = A, title = B`. Ordering alone cannot tell them apart. + const shape = (hookRun: number, titleRun: number): PaneAgentEvidence[] => [ + { source: 'completed-hook', agent: 'claude', run: { authorityId: H, incarnation: hookRun } }, + { source: 'title', agent: 'codex', run: { authorityId: H, incarnation: titleRun } } + ] + + it('keeps the completed hook when both belong to the current run', () => { + // The reported bug: nothing new started, so the hook is still the truth. + const result = resolvePaneAgentIdentity({ + evidence: shape(7, 7), + currentRun: { authorityId: H, incarnation: 7 } + }) + expect(result).toMatchObject({ agent: 'claude', source: 'completed-hook' }) + expect(result.supersededSources).toEqual([]) + }) + + it('drops the completed hook once a new run has started', () => { + // The legitimate reclaim: the pane was reused, so run 7's hook describes an agent that is + // no longer there. It is ineligible, not merely outranked. + const result = resolvePaneAgentIdentity({ + evidence: shape(7, 8), + currentRun: { authorityId: H, incarnation: 8 } + }) + expect(result).toMatchObject({ agent: 'codex', source: 'title' }) + expect(result.supersededSources).toEqual(['completed-hook']) + }) + + it('produces opposite answers from identical evidence, given only the run ids', () => { + // The whole point, stated as one assertion. + const bug = resolvePaneAgentIdentity({ + evidence: shape(7, 7), + currentRun: { authorityId: H, incarnation: 7 } + }) + const reclaim = resolvePaneAgentIdentity({ + evidence: shape(7, 8), + currentRun: { authorityId: H, incarnation: 8 } + }) + expect(bug.agent).not.toBe(reclaim.agent) + }) + }) + + describe('mixed-version peers', () => { + it('treats evidence with no run id as eligible', () => { + // An old host publishes no run ids. Treating unknown as stale would blank every row. + const result = resolvePaneAgentIdentity({ + evidence: [{ source: 'completed-hook', agent: 'claude' }], + currentRun: { authorityId: H, incarnation: 9 } + }) + expect(result.agent).toBe('claude') + }) + + it('disables run filtering entirely when the pane has no current run', () => { + const result = resolvePaneAgentIdentity({ + evidence: [ + { source: 'completed-hook', agent: 'claude', run: { authorityId: H, incarnation: 3 } } + ] + }) + expect(result).toMatchObject({ agent: 'claude', supersededSources: [] }) + }) + }) + + describe('siblings are tab-scoped', () => { + it('ignores a sibling by default', () => { + expect(resolve([{ source: 'sibling', agent: 'codex' }]).agent).toBeNull() + }) + + it('accepts a sibling when the caller opts in', () => { + expect(resolve([{ source: 'sibling', agent: 'codex' }], { allowSibling: true }).agent).toBe( + 'codex' + ) + }) + + it('still ranks a sibling above a title', () => { + const result = resolve( + [ + { source: 'title', agent: 'grok' }, + { source: 'sibling', agent: 'codex' } + ], + { allowSibling: true } + ) + expect(result.source).toBe('sibling') + }) + }) + + describe('no eligible evidence', () => { + it('returns null rather than guessing', () => { + expect(resolve([])).toMatchObject({ agent: null, source: null }) + }) + + it('returns null when every source belongs to a superseded run', () => { + const result = resolvePaneAgentIdentity({ + evidence: [ + { source: 'live-hook', agent: 'claude', run: { authorityId: H, incarnation: 1 } }, + { source: 'title', agent: 'codex', run: { authorityId: H, incarnation: 1 } } + ], + currentRun: { authorityId: H, incarnation: 2 } + }) + expect(result.agent).toBeNull() + expect(result.supersededSources).toEqual(['live-hook', 'title']) + }) + }) + + describe('input order does not decide the answer', () => { + it('resolves the same regardless of how evidence is listed', () => { + const evidence: PaneAgentEvidence[] = [ + { source: 'title', agent: 'codex' }, + { source: 'launch', agent: 'grok' }, + { source: 'live-hook', agent: 'claude' } + ] + const forward = resolve([...evidence]) + const reverse = resolve(evidence.toReversed()) + expect(forward).toEqual(reverse) + expect(forward.source).toBe('live-hook') + }) + }) + + describe('two observations of the same class cannot be settled by array order', () => { + // Review finding: `eligible.find(...)` returned the FIRST match, so duplicates of one source + // naming different agents resolved by input order — the exact property this resolver exists to + // remove. The earlier order-independence test only used DISTINCT sources, so it never saw it. + it('returns null when two live hooks name different agents', () => { + const result = resolve([ + { source: 'live-hook', agent: 'claude' }, + { source: 'live-hook', agent: 'codex' } + ]) + expect(result.agent).toBeNull() + expect(result.ambiguousAt).toBe('live-hook') + }) + + it('gives the same answer in either order', () => { + const a: PaneAgentEvidence[] = [ + { source: 'live-hook', agent: 'claude' }, + { source: 'live-hook', agent: 'codex' } + ] + expect(resolve(a)).toEqual(resolve(a.toReversed())) + }) + + it('still resolves when duplicates agree', () => { + expect( + resolve([ + { source: 'live-hook', agent: 'claude' }, + { source: 'live-hook', agent: 'claude' } + ]).agent + ).toBe('claude') + }) + + it('does not fall through to a weaker source on conflict', () => { + // Falling through would let a title answer whenever two hooks disagreed — strictly worse + // than saying nothing. + const result = resolve([ + { source: 'live-hook', agent: 'claude' }, + { source: 'live-hook', agent: 'codex' }, + { source: 'title', agent: 'grok' } + ]) + expect(result.agent).toBeNull() + }) + }) + + describe('run keys from different authorities are incomparable, not stale', () => { + // Review finding: a bare numeric runId collides across authority restarts. A restarted main + // regenerates its authorityId and counts incarnations from its own floor, so `1 === 1` would + // equate two unrelated runs. + it('keeps evidence whose authority differs, even when the incarnations do not match', () => { + // The incarnations must DIFFER for this to discriminate. With both at 1, a resolver that + // ignored authority entirely would still pass on the numeric compare — verified by mutation, + // which is how the first version of this test was caught as vacuous. + const result = resolvePaneAgentIdentity({ + evidence: [ + { source: 'live-hook', agent: 'claude', run: { authorityId: 'host-b', incarnation: 5 } } + ], + currentRun: { authorityId: 'host-a', incarnation: 2 } + }) + expect(result.agent).toBe('claude') + expect(result.supersededSources).toEqual([]) + }) + + it('does not equate the same incarnation number from two authorities', () => { + // A restarted main regenerates authorityId and counts from its own floor, so `1` from + // host-b is not `1` from host-a. Incomparable is treated as unknown, so the evidence is + // kept rather than being read as either current or stale. + const result = resolvePaneAgentIdentity({ + evidence: [ + { source: 'launch', agent: 'codex', run: { authorityId: 'host-b', incarnation: 1 } } + ], + currentRun: { authorityId: 'host-a', incarnation: 1 } + }) + expect(result).toMatchObject({ agent: 'codex', source: 'launch' }) + }) + + it('supersedes only within the same authority', () => { + const result = resolvePaneAgentIdentity({ + evidence: [ + { source: 'live-hook', agent: 'claude', run: { authorityId: 'host-a', incarnation: 1 } } + ], + currentRun: { authorityId: 'host-a', incarnation: 2 } + }) + expect(result.agent).toBeNull() + expect(result.supersededSources).toEqual(['live-hook']) + }) + }) + + describe('an action consumer can refuse to see weak evidence at all', () => { + // Review finding: title remained available to consumers that AUTHORIZE A WRITE. Ranking it + // last makes misuse unlikely; dropping it makes misuse impossible. + it('ignores a title entirely below the floor', () => { + const result = resolve([{ source: 'title', agent: 'codex' }], { minimumSource: 'launch' }) + expect(result.agent).toBeNull() + }) + + it('ignores a sibling below the floor even when opted in', () => { + const result = resolve([{ source: 'sibling', agent: 'codex' }], { + allowSibling: true, + minimumSource: 'launch' + }) + expect(result.agent).toBeNull() + }) + + it('still answers from evidence at or above the floor', () => { + const result = resolve( + [ + { source: 'launch', agent: 'claude' }, + { source: 'title', agent: 'codex' } + ], + { minimumSource: 'launch' } + ) + expect(result).toMatchObject({ agent: 'claude', source: 'launch' }) + }) + + it('leaves display consumers unrestricted when no floor is given', () => { + expect(resolve([{ source: 'title', agent: 'codex' }]).agent).toBe('codex') + }) + }) +}) diff --git a/src/shared/pane-agent-identity-resolver.ts b/src/shared/pane-agent-identity-resolver.ts new file mode 100644 index 00000000000..98342963415 --- /dev/null +++ b/src/shared/pane-agent-identity-resolver.ts @@ -0,0 +1,163 @@ +import type { TuiAgent } from './tui-agent' + +/** + * One place that answers "which agent is in this pane". + * + * Today four ladders answer it independently — the tab icon, the open-tab/search occupant, the + * sidebar title rows, and the sidebar hook-row fallback — and they disagree. Two of them consult + * the terminal title before the launch record, so a string Orca parsed outranks a fact Orca owns. + * + * Two rules make this resolvable where reordering alone could not: + * + * 1. Evidence is ranked by how directly it observes the process, and a display title is last. + * 2. Every observation carries the `runId` of the agent run it belongs to. Evidence from a run + * that has since been replaced is INELIGIBLE rather than merely outranked. + * + * Rule 2 is what separates two situations that are otherwise identical. A completed hook naming + * A plus a title naming B is either a bug (the hook is right, the title is stale) or a legitimate + * reclaim (the pane was reused and B really is there). Same signals, opposite answers. With run + * ids they are different facts: in the bug both belong to the current run, and in the reclaim the + * hook belongs to a previous one. + * + * A run is advanced only on positive evidence that a new agent started in the pane — an accepted + * launch, a recognized command at a shell prompt, a host-confirmed foreground change, a new + * provider session. Never by a title changing, and never by transport loss. + */ +export const PANE_AGENT_EVIDENCE_SOURCES = [ + /** A live provider hook for a turn in progress. The agent is running and said so. */ + 'live-hook', + /** The pane's foreground process, as read on the execution host. */ + 'process', + /** Orca launched, resumed, or accepted a command for this agent. A fact Orca owns. */ + 'launch', + /** A provider hook from a turn that finished. Still authoritative about identity. */ + 'completed-hook', + /** A sleeping session record restored for this pane. */ + 'sleeping-session', + /** Another pane in the same tab. Tab-level surfaces only; never pane-scoped routing. */ + 'sibling', + /** Parsed from the terminal title. A decoration channel; anyone can type an agent's name. */ + 'title' +] as const +export type PaneAgentEvidenceSource = (typeof PANE_AGENT_EVIDENCE_SOURCES)[number] + +/** Authority order, strongest first. Position here is the ONLY place precedence is expressed. */ +const SOURCE_RANK: readonly PaneAgentEvidenceSource[] = PANE_AGENT_EVIDENCE_SOURCES + +/** + * Which agent run a piece of evidence belongs to. + * + * Why the authority and not a bare number: `incarnation` is only a total order WITHIN one + * `authorityId` (see agent-status-observation.ts). The id is regenerated per authority instance, + * so a restarted main, a second machine, and a renderer that parsed bytes itself each count from + * their own floor. Comparing bare numbers across them silently equates unrelated runs — a + * restarted host reporting `incarnation: 1` would match a live run 1 from before the restart. + * + * Runs from different authorities are INCOMPARABLE, not different. + */ +export type PaneAgentRunKey = { + authorityId: string + incarnation: number +} + +export type PaneAgentEvidence = { + source: PaneAgentEvidenceSource + agent: A + /** + * The agent run this evidence describes. Evidence proven to belong to a SUPERSEDED run is + * ineligible. Undefined means unknown — from an old peer that does not publish run keys, or an + * ingress not yet stamped — and is treated as eligible, so a missing field never blanks a row. + */ + run?: PaneAgentRunKey +} + +export type PaneAgentIdentityInput = { + evidence: readonly PaneAgentEvidence[] + /** The pane's current run. Undefined disables run filtering entirely (old peer, mixed version). */ + currentRun?: PaneAgentRunKey + /** + * Refuse to answer from evidence weaker than this rank. Consumers that AUTHORIZE AN ACTION — + * message routing, mailbox delivery, agent-scoped timers — pass `'launch'` so a parsed title or + * a neighbouring pane can never name the target of a write. Display surfaces omit it. + */ + minimumSource?: PaneAgentEvidenceSource + /** + * Pane-scoped consumers must not inherit another pane's agent. Sibling evidence is dropped + * unless the caller is a tab-level surface that opted in. + */ + allowSibling?: boolean +} + +export type PaneAgentIdentity = { + agent: A | null + /** Which class of evidence decided it. Null when nothing eligible remained. */ + source: PaneAgentEvidenceSource | null + /** Set when two equally-ranked sources disagreed, so a caller can tell "nothing" from "conflict". */ + ambiguousAt?: PaneAgentEvidenceSource + /** Evidence discarded because it belongs to a superseded run. Surfaced for diagnostics. */ + supersededSources: readonly PaneAgentEvidenceSource[] +} + +/** + * Resolves one pane's agent from ranked evidence. + * + * Generic over the agent vocabulary: the sidebar speaks the widened `AgentType` and the tab speaks + * the strict `TuiAgent`. Ranking evidence does not depend on which, and a cast at that boundary + * would only hide the mismatch. + * + * Returns null rather than guessing. A pane with no eligible evidence shows no agent, which is + * recoverable; showing the wrong agent is not, and at the action surfaces (orchestration routing, + * mailbox delivery, prompt-cache timers) it is a misdelivery rather than a cosmetic slip. + */ +export function resolvePaneAgentIdentity( + input: PaneAgentIdentityInput +): PaneAgentIdentity { + const superseded: PaneAgentEvidenceSource[] = [] + const floor = input.minimumSource + ? SOURCE_RANK.indexOf(input.minimumSource) + : Number.MAX_SAFE_INTEGER + + const eligible = input.evidence.filter((item) => { + if (item.source === 'sibling' && input.allowSibling !== true) { + return false + } + // Why the floor: an action consumer must not be able to act on a title, at any rank. Dropping + // the evidence entirely rather than ranking it lower makes misuse impossible rather than + // unlikely — a caller cannot accidentally consult it by reordering. + if (SOURCE_RANK.indexOf(item.source) > floor) { + return false + } + if (input.currentRun === undefined || item.run === undefined) { + // Why eligible: absence means "this peer does not publish run keys", not "this is stale". + // Treating unknown as superseded would blank every row from an older host. + return true + } + if (item.run.authorityId !== input.currentRun.authorityId) { + // Why eligible and NOT superseded: runs from different authorities are incomparable, not + // older. A restarted main counts from its own floor, so `incarnation` alone would falsely + // equate unrelated runs. Incomparable evidence is treated as unknown, like an absent key. + return true + } + if (item.run.incarnation === input.currentRun.incarnation) { + return true + } + superseded.push(item.source) + return false + }) + + for (const source of SOURCE_RANK) { + const matches = eligible.filter((item) => item.source === source) + if (matches.length === 0) { + continue + } + const agents = new Set(matches.map((item) => item.agent)) + if (agents.size > 1) { + // Why null and not the first: two observations of the same class naming different agents is + // a genuine conflict, and picking one would make the answer depend on array order — the very + // property this resolver exists to remove. Fall through to nothing rather than guess. + return { agent: null, source: null, ambiguousAt: source, supersededSources: superseded } + } + return { agent: matches[0].agent, source, supersededSources: superseded } + } + return { agent: null, source: null, supersededSources: superseded } +} From 723158a5196b39f1080da6ec0696dd4efcb02f73 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:26:33 -0700 Subject: [PATCH 10/65] feat(workspace-cleanup): blockers become labels, not refusals (#16282) * wip(workspace-cleanup): PR 3 blockers-become-labels, recovered from a dead worker Uncommitted work recovered from a worker that died with 'Agent process stop was requested but never confirmed'. Committed as-is to preserve it; NOT verified yet. * Fix workspace cleanup review regressions * fix(workspace-cleanup): drop filter chips for the safety fields this PR removes The cleanup dialog crashed on every open. My merge of #15300 brought in the applied-filter chips, which read `safety.tiers` and `safety.selectableOnly` -- the exact fields this PR deletes. Electron QA caught it; the chip derivation runs on open, so it threw before anything rendered. Removed the two chip branches, their formatter entries, and the now-dead 'tier' chip-kind label. The test that swept every chip keeps its breadth by using `safety.dismissed`, which survives. Worth noting what did not catch this: typecheck flagged only the test file, not the source, because the QA agent had already patched the source locally without committing. A merge that compiles can still remove a field a caller reads at runtime, and only opening the dialog proved it. --- .../ipc/workspace-cleanup-broad-scan.test.ts | 122 +++-- .../ipc/workspace-cleanup-disconnected-ssh.ts | 2 - .../ipc/workspace-cleanup-scan-primitives.ts | 4 +- src/main/ipc/workspace-cleanup-scan.ts | 4 +- src/main/ipc/workspace-cleanup.test.ts | 2 + ...anupDialog.stale-while-revalidate.test.tsx | 36 +- .../WorkspaceCleanupDialog.tsx | 20 +- .../use-workspace-cleanup-removal.ts | 38 +- ...se-workspace-cleanup-unverified-removal.ts | 153 +++++++ .../workspace-cleanup-active-facets.test.ts | 11 + ...workspace-cleanup-applied-filter-chips.tsx | 6 +- .../workspace-cleanup-background-removal.ts | 8 +- ...rkspace-cleanup-candidate-row-data.test.ts | 81 +++- .../workspace-cleanup-candidate-row-data.ts | 102 +++-- .../workspace-cleanup-candidate-row.tsx | 56 +-- .../workspace-cleanup-confirm-remove.test.tsx | 60 +++ .../workspace-cleanup-confirm-remove.tsx | 31 +- .../workspace-cleanup-facet-filter.test.ts | 117 ++++- .../workspace-cleanup-facet-labels.ts | 14 - .../workspace-cleanup-facet-matchers.ts | 6 - .../workspace-cleanup-facet-sort.test.ts | 56 ++- .../workspace-cleanup-facet-sort.ts | 5 +- .../workspace-cleanup-facets.ts | 13 +- .../workspace-cleanup-flat-list.test.tsx | 45 +- ...orkspace-cleanup-forget-locally-button.tsx | 43 ++ .../workspace-cleanup-git-review-facets.tsx | 19 - ...workspace-cleanup-presentation-fixtures.ts | 11 +- .../workspace-cleanup-row-labels.ts | 9 + .../workspace-cleanup-row-list.tsx | 7 +- .../workspace-cleanup-snapshot-prune-batch.ts | 18 + src/renderer/src/i18n/locales/en.json | 10 +- src/renderer/src/i18n/locales/ko.json | 10 +- src/renderer/src/i18n/locales/zh.json | 10 +- .../slices/workspace-cleanup-browse.test.ts | 2 +- .../workspace-cleanup-preflight-failures.ts | 106 +++++ ...orkspace-cleanup-removal-preflight.test.ts | 55 ++- .../workspace-cleanup-removal-targets.ts | 109 +++-- .../workspace-cleanup-slice-test-harness.ts | 11 +- ...cleanup-unverified-removal-consent.test.ts | 416 ++++++++++++++++++ .../src/store/slices/workspace-cleanup.ts | 382 +++++++++------- .../workspace-cleanup-applied-filters.test.ts | 5 +- .../workspace-cleanup-applied-filters.ts | 13 - ...e-cleanup-browse-state-persistence.test.ts | 29 +- .../workspace-cleanup-facet-rankings.ts | 10 +- src/shared/workspace-cleanup-filter-model.ts | 16 +- .../workspace-cleanup-filter-state-codec.ts | 5 +- src/shared/workspace-cleanup.test.ts | 38 +- src/shared/workspace-cleanup.ts | 43 +- 48 files changed, 1776 insertions(+), 593 deletions(-) create mode 100644 src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-unverified-removal.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-forget-locally-button.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts create mode 100644 src/renderer/src/store/slices/workspace-cleanup-preflight-failures.ts create mode 100644 src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts diff --git a/src/main/ipc/workspace-cleanup-broad-scan.test.ts b/src/main/ipc/workspace-cleanup-broad-scan.test.ts index a5d19d26f7e..070ea461906 100644 --- a/src/main/ipc/workspace-cleanup-broad-scan.test.ts +++ b/src/main/ipc/workspace-cleanup-broad-scan.test.ts @@ -119,8 +119,12 @@ function makeWorktreeMeta(overrides: Partial = {}): WorktreeMeta { const META_BY_WORKTREE_ID: Record = { 'repo-1::/repo': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }), 'repo-1::/repo-old': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }), - 'repo-1::/repo-recent': makeWorktreeMeta({ lastActivityAt: NOW - 2 * DAY_MS }), - 'repo-folder::/folder-workspace': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }) + 'repo-1::/repo-recent': makeWorktreeMeta({ + lastActivityAt: NOW - 2 * DAY_MS + }), + 'repo-folder::/folder-workspace': makeWorktreeMeta({ + lastActivityAt: NOW - 40 * DAY_MS + }) } function makeStore(repos: Repo[] = [REPO], allMeta: Record = {}): Store { @@ -132,16 +136,6 @@ function makeStore(repos: Repo[] = [REPO], allMeta: Record } as unknown as Store } -/** `repo-1::/repo-old` is in the shared fixture map, so pinning it needs a getWorktreeMeta override. */ -function makePinnedStore(): Store { - const pinned = makeWorktreeMeta({ isPinned: true, lastActivityAt: NOW - 40 * DAY_MS }) - return { - ...makeStore(), - getWorktreeMeta: (worktreeId: string) => - worktreeId === 'repo-1::/repo-old' ? pinned : META_BY_WORKTREE_ID[worktreeId] - } as Store -} - describe('workspace cleanup broad scan opt-in', () => { beforeEach(() => { vi.useFakeTimers() @@ -164,7 +158,9 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('emits every worktree when the client opts into the full workspace list', async () => { - const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(makeStore(), { + includeAllWorkspaces: true + }) expect(result.errors).toEqual([]) expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual([ @@ -185,7 +181,9 @@ describe('workspace cleanup broad scan opt-in', () => { worktreeId === 'repo-1::/repo-old' ? runtimeMeta : META_BY_WORKTREE_ID[worktreeId] } as Store - const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(store, { + includeAllWorkspaces: true + }) expect( result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-old') @@ -240,32 +238,54 @@ describe('workspace cleanup broad scan opt-in', () => { ) }) - it('skips git for a pinned workspace on a broad scan, which is the cost saving', async () => { - const store = makePinnedStore() + it('reads git for a pinned target during focused preflight scans', async () => { + const worktreeId = 'repo-1::/repo-old' + const pinnedMeta = makeWorktreeMeta({ + isPinned: true, + lastActivityAt: NOW - 40 * DAY_MS + }) + const store = { + ...makeStore(), + getWorktreeMeta: (id: string) => (id === worktreeId ? pinnedMeta : META_BY_WORKTREE_ID[id]) + } as Store + + const result = await scanWorkspaceCleanup(store, { + worktreeIds: [worktreeId], + refreshActivity: true + }) + + expect(result.candidates[0]?.blockers).toContain('pinned') + expect(getStatusMock).toHaveBeenCalledTimes(1) + }) + + it('continues to skip pinned git reads during broad scans', async () => { + const worktreeId = 'repo-1::/repo-old' + const pinnedMeta = makeWorktreeMeta({ + isPinned: true, + lastActivityAt: NOW - 40 * DAY_MS + }) + const store = { + ...makeStore(), + getWorktreeMeta: (id: string) => (id === worktreeId ? pinnedMeta : META_BY_WORKTREE_ID[id]) + } as Store + listRepoWorktreesMock.mockResolvedValue([GIT_WORKTREES[1]]) await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) - expect(getStatusMock).not.toHaveBeenCalledWith( - expect.stringContaining('/repo-old'), - expect.anything() - ) + expect(getStatusMock).not.toHaveBeenCalled() }) - it('reads git for a pinned workspace on a targeted preflight, which decides force-removal', async () => { - // Why this matters: `pinned` is not a queue blocker, so a pinned idle workspace is - // hand-selectable today, and removal forces whenever git is unknown. Skipping the - // forced read meant it could be force-deleted with no evidence ever obtained. - const worktreeId = 'repo-1::/repo-old' + it('qualifies targeted repo-list failures with the execution host', async () => { + listRepoWorktreesMock.mockRejectedValue(new Error('listing failed')) - const result = await scanWorkspaceCleanup(makePinnedStore(), { worktreeIds: [worktreeId] }) + const result = await scanWorkspaceCleanup(makeStore(), { + worktreeIds: ['repo-1::/repo-old'], + refreshActivity: true + }) - expect(getStatusMock).toHaveBeenCalledWith( - expect.stringContaining('/repo-old'), - expect.anything() - ) - const pinned = result.candidates.find((candidate) => candidate.worktreeId === worktreeId) - expect(pinned?.blockers).toContain('pinned') - expect(pinned?.git.checkedAt).not.toBeNull() + expect(result.errors).toEqual([ + expect.objectContaining({ repoId: 'repo-1', executionHostId: 'local' }) + ]) }) it('keeps the legacy suggestion-only projection when the flag is absent', async () => { @@ -277,7 +297,9 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('reports recent workspaces with no cleanup reasons', async () => { - const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(makeStore(), { + includeAllWorkspaces: true + }) const recent = result.candidates.find( (candidate) => candidate.worktreeId === 'repo-1::/repo-recent' @@ -291,7 +313,9 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('reports the main worktree with a main-worktree blocker', async () => { - const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(makeStore(), { + includeAllWorkspaces: true + }) const main = result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo') expect(main).toMatchObject({ @@ -372,7 +396,9 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('defers git evidence for rows that no cleanup decision can use', async () => { - const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(makeStore(), { + includeAllWorkspaces: true + }) // Why: only the inactive row can ever be queued, so it is the only row worth // a git read; the rest stream immediately with empty evidence. @@ -380,7 +406,12 @@ describe('workspace cleanup broad scan opt-in', () => { expect(getStatusMock).toHaveBeenCalledWith('/repo-old', expect.anything()) expect( result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-recent')?.git - ).toEqual({ clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }) + ).toEqual({ + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + }) expect( result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-old')?.git ).toMatchObject({ clean: true, checkedAt: expect.any(Number) }) @@ -403,7 +434,9 @@ describe('workspace cleanup broad scan opt-in', () => { getWorktreeMeta: () => recentMeta } as unknown as Store - const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(store, { + includeAllWorkspaces: true + }) expect(result.candidates).toHaveLength(recentWorktrees.length) expect(readFileMock).not.toHaveBeenCalled() @@ -471,7 +504,9 @@ describe('workspace cleanup broad scan opt-in', () => { it('lists a repo once for a targeted Git-evidence batch', async () => { const targets = ['repo-1::/repo-recent', 'repo-1::/repo-old'] - const result = await scanWorkspaceCleanup(makeStore(), { worktreeIds: targets }) + const result = await scanWorkspaceCleanup(makeStore(), { + worktreeIds: targets + }) expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1) expect(getStatusMock).toHaveBeenCalledTimes(2) @@ -507,7 +542,9 @@ describe('workspace cleanup broad scan opt-in', () => { it('lists disconnected SSH workspaces only on an opt-in scan', async () => { const sshRepo: Repo = { ...REPO, id: 'repo-ssh', connectionId: 'ssh-1' } const allMeta: Record = { - 'repo-ssh::/remote/recent': makeWorktreeMeta({ lastActivityAt: NOW - 2 * DAY_MS }) + 'repo-ssh::/remote/recent': makeWorktreeMeta({ + lastActivityAt: NOW - 2 * DAY_MS + }) } const legacy = await scanWorkspaceCleanup(makeStore([sshRepo], allMeta)) @@ -574,7 +611,10 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('streams every row through scan progress on an opt-in scan', async () => { - const progress: { scannedWorktreeCount: number; totalWorktreeCount: number }[] = [] + const progress: { + scannedWorktreeCount: number + totalWorktreeCount: number + }[] = [] await scanWorkspaceCleanup( makeStore(), diff --git a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts index 5e3cebf260c..c0e136b7b77 100644 --- a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts +++ b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts @@ -74,8 +74,6 @@ function createDisconnectedSshCandidate( displayName: meta.displayName || basename(path), branch: basename(path), path, - tier: 'protected', - selectedByDefault: false, reasons, blockers: ['ssh-disconnected'], lastActivityAt: meta.lastActivityAt, diff --git a/src/main/ipc/workspace-cleanup-scan-primitives.ts b/src/main/ipc/workspace-cleanup-scan-primitives.ts index 93af63562c2..8054d333e0d 100644 --- a/src/main/ipc/workspace-cleanup-scan-primitives.ts +++ b/src/main/ipc/workspace-cleanup-scan-primitives.ts @@ -1,4 +1,5 @@ import { basename } from 'node:path' +import { getRepoExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import type { WorkspaceCleanupScanError } from '../../shared/workspace-cleanup' @@ -91,7 +92,8 @@ export function createWorkspaceCleanupScanError( return { repoId: repo.id, repoName: repo.displayName || basename(repo.path), - message + message, + executionHostId: getRepoExecutionHostId(repo) } } diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index 278c3ae7ee8..b87667f0a63 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -252,8 +252,8 @@ async function scanRepoWorkspaces( worktree: worktreeWithActivity, scannedAt, provider, - // Why: a row with no inactivity reason can never be queued or selected, - // so full-fleet scans stream it now and let a focused scan read git later. + // Why: full-fleet scans defer git for recently active rows; removal preflight + // forces a fresh read before any selected row can be deleted. skipGit: skipGitWorktreeIds.has(worktreeWithActivity.id) || !isInactive, forceGitCheck: Boolean(targetWorktreeIds), signal diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index b35167906c8..6f5d3d74a9b 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -322,6 +322,7 @@ describe('workspace cleanup scan', () => { { repoId: 'repo-1', repoName: 'Repo', + executionHostId: 'local', message: 'Git could not list worktrees.' } ]) @@ -344,6 +345,7 @@ describe('workspace cleanup scan', () => { { repoId: 'repo-1', repoName: 'Repo', + executionHostId: 'local', message: 'Timed out listing worktrees.' } ] diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx index 5e1ef3b9212..262cc541526 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx @@ -351,7 +351,18 @@ describe('WorkspaceCleanupDialog stale-while-revalidate', () => { }) it('reports selections removed by facet filters', async () => { - installApi(cachedFleet()) + installApi({ + scannedAt: CACHED_AT, + candidates: [ + makeCandidate({ + worktreeId: 'repo1::/tmp/alpha', + displayName: 'alpha', + blockers: ['pinned'] + }), + makeCandidate({ worktreeId: 'repo1::/tmp/beta', displayName: 'beta' }) + ], + errors: [] + }) await renderDialog() await openDialog() @@ -363,7 +374,11 @@ describe('WorkspaceCleanupDialog stale-while-revalidate', () => { ...browse, filters: { ...browse.filters, - safety: { ...browse.filters.safety, tiers: ['review'] } + safety: { + ...browse.filters.safety, + blockers: ['pinned'], + blockerMode: 'none-of' + } } } }) @@ -464,17 +479,16 @@ describe('WorkspaceCleanupDialog stale-while-revalidate', () => { expect(holders.infoToasts).toContain('1 selected workspace no longer exists.') }) - it('keeps selected review rows outside the select-all scope', async () => { + it('keeps selected active workspaces outside the select-all scope', async () => { installApi({ scannedAt: CACHED_AT, candidates: [ makeCandidate({ worktreeId: 'repo1::/tmp/ready', displayName: 'ready' }), makeCandidate({ - worktreeId: 'repo1::/tmp/review', - displayName: 'review', - tier: 'review', - selectedByDefault: false, - git: { clean: null, upstreamAhead: 0, upstreamBehind: 0, checkedAt: null } + worktreeId: 'repo1::/tmp/active', + displayName: 'active', + reasons: [], + blockers: ['active-workspace'] }) ], errors: [] @@ -482,7 +496,7 @@ describe('WorkspaceCleanupDialog stale-while-revalidate', () => { await renderDialog() await openDialog() - await act(async () => rowCheckbox('review')?.click()) + await act(async () => rowCheckbox('active')?.click()) const selectAll = container?.querySelector( '[aria-label="Select 1 safety-checked workspace"]' ) @@ -490,10 +504,10 @@ describe('WorkspaceCleanupDialog stale-while-revalidate', () => { await act(async () => selectAll?.click()) expect(rowCheckbox('ready')?.getAttribute('aria-checked')).toBe('true') - expect(rowCheckbox('review')?.getAttribute('aria-checked')).toBe('true') + expect(rowCheckbox('active')?.getAttribute('aria-checked')).toBe('true') await act(async () => selectAll?.click()) expect(rowCheckbox('ready')?.getAttribute('aria-checked')).toBe('false') - expect(rowCheckbox('review')?.getAttribute('aria-checked')).toBe('true') + expect(rowCheckbox('active')?.getAttribute('aria-checked')).toBe('true') }) }) diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index ecc72781cab..44c71f00b0f 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -41,13 +41,13 @@ import { import { useWorkspaceCleanupFacetRows } from './use-workspace-cleanup-facet-rows' import { useWorkspaceCleanupGitEvidence } from './use-workspace-cleanup-git-evidence' import { useWorkspaceCleanupRowOrder } from './use-workspace-cleanup-row-order' +import { openWorkspaceCleanupForgetLocally } from './workspace-cleanup-forget-locally-button' import { formatVanishedSelectionNotice, formatWithheldSelectionNotice, toggleSetMember } from './workspace-cleanup-selection-model' -/** One filterable list of every workspace Orca knows about. */ export default function WorkspaceCleanupDialog(): React.JSX.Element | null { const lifecycle = useWorkspaceCleanupDialogLifecycle() if (!lifecycle.mountedContent) { @@ -79,8 +79,6 @@ function WorkspaceCleanupDialogContent({ useShallow(selectWorkspaceCleanupDeletionPhases) ) const browse = useWorkspaceCleanupBrowseState() - // Why: facet counts/options are only rendered inside the filter popover; - // tracking its open state lets their O(N) passes skip while it is closed. const [facetPanelOpen, setFacetPanelOpen] = useState(false) const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()) const [rowsScrollElement, setRowsScrollElement] = useState(null) @@ -168,7 +166,9 @@ function WorkspaceCleanupDialogContent({ 'components.workspace.cleanup.browse.measureSizesFailed', 'Could not scan workspace sizes' ), - { description: scanError instanceof Error ? scanError.message : String(scanError) } + { + description: scanError instanceof Error ? scanError.message : String(scanError) + } ) }) }, [mountedRef, refreshWorkspaceSpace]) @@ -234,8 +234,6 @@ function WorkspaceCleanupDialogContent({ if (loading) { return } - // Why: a settled refresh reconciles by host-qualified identity — selections survive it, - // and only rows that truly no longer exist drop (with a visible reason). pruneVanishedSelections() }, [candidateIdentities, deletingIdentities, loading]) @@ -312,13 +310,9 @@ function WorkspaceCleanupDialogContent({ [selectableIdentities, setSelectedIds] ) - // Why: stable per-row handlers so React.memo keeps unchanged CandidateRow - // instances from re-rendering on scan stream-in and selection changes. const openConfirmRemove = removal.openConfirmRemove const handleRemoveRow = useCallback( (candidate: WorkspaceCleanupCandidate) => { - // Why: rows stay actionable while a rescan streams; the confirm-time - // preflight is the safety gate, not the scan state. if (removalInFlightRef.current) { return } @@ -327,7 +321,6 @@ function WorkspaceCleanupDialogContent({ }, [openConfirmRemove, removalInFlightRef, setSelectedIds] ) - const handleViewCandidate = useCallback( (candidate: WorkspaceCleanupCandidate) => { markCandidateViewed(candidate) @@ -362,9 +355,6 @@ function WorkspaceCleanupDialogContent({ loading={loading} scannedAt={scan?.scannedAt ?? null} scanProgress={scanProgress} - // Why: leaving the progress view no longer closes the dialog, so the - // list is reachable mid-batch; a second batch would silently no-op. - // A streaming rescan does NOT gate deletion — preflight does. deleteDisabled={ selectedCount === 0 || removal.removalProgress !== null || removal.removalInFlight } @@ -419,6 +409,8 @@ function WorkspaceCleanupDialogContent({ onView={handleViewCandidate} onIgnore={ignoreCandidate} onRemove={handleRemoveRow} + onForgetLocally={openWorkspaceCleanupForgetLocally} + onDeleteAnyway={removal.confirmUnverifiedRemoval} /> diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts index 43093a10c8c..0bf71f3aee8 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts @@ -14,6 +14,8 @@ import { type WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal' import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' +import { createWorkspaceCleanupSnapshotPruneBatch } from './workspace-cleanup-snapshot-prune-batch' +import { useWorkspaceCleanupUnverifiedRemoval } from './use-workspace-cleanup-unverified-removal' export type WorkspaceCleanupRemovalController = { confirming: boolean @@ -24,11 +26,12 @@ export type WorkspaceCleanupRemovalController = { /** Synchronous guard; state alone lags a second confirm click. */ removalInFlightRef: { current: boolean } /** Keyed by host-qualified identity so a failure marks only the confirmed host's row. */ - rowFailures: Record + rowFailures: Record resetRowFailures: () => void resetForReopen: () => void openConfirmRemove: (candidates: readonly WorkspaceCleanupCandidate[]) => void confirmRemove: () => void + confirmUnverifiedRemoval: (candidate: WorkspaceCleanupCandidate) => void cancelConfirmRemove: () => void backToList: () => void } @@ -60,7 +63,7 @@ export function useWorkspaceCleanupRemoval({ const [deletionPhaseByIdentity, setDeletionPhaseByIdentity] = useState< Record >({}) - const [rowFailures, setRowFailures] = useState>({}) + const [rowFailures, setRowFailures] = useState>({}) const removalInFlightRef = useRef(false) // Why: the dialog stays mounted across cleanup runs, so late settlements from // an earlier batch must not mutate a newer batch's row state. @@ -93,6 +96,13 @@ export function useWorkspaceCleanupRemoval({ [clearWorktreeDeleteState] ) + const confirmUnverifiedRemoval = useWorkspaceCleanupUnverifiedRemoval({ + setRowFailures, + setDeletionPhaseByIdentity, + clearQueuedDeleteState, + onDeselect + }) + const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => { const nextCandidates = filterWorkspaceCleanupRemovalCandidates( candidates, @@ -174,22 +184,7 @@ export function useWorkspaceCleanupRemoval({ removalInFlightRef.current = false } try { - const beginSnapshotPruneBatch = window.api.workspaceCleanup.beginRemovalSnapshotPruneBatch - const recordSnapshotPrune = window.api.workspaceCleanup.recordRemovalSnapshotPrune - const finishSnapshotPruneBatch = window.api.workspaceCleanup.finishRemovalSnapshotPruneBatch - const snapshotPruneBatch = - typeof beginSnapshotPruneBatch === 'function' && - typeof recordSnapshotPrune === 'function' && - typeof finishSnapshotPruneBatch === 'function' - ? (() => { - const batchId = crypto.randomUUID() - return { - batchId, - begin: () => beginSnapshotPruneBatch({ batchId }), - finish: () => finishSnapshotPruneBatch({ batchId }) - } - })() - : undefined + const snapshotPruneBatch = createWorkspaceCleanupSnapshotPruneBatch() startWorkspaceCleanupBackgroundRemoval({ candidates: removableCandidates, removeCandidates, @@ -215,9 +210,9 @@ export function useWorkspaceCleanupRemoval({ }) }, onResult: (result) => { - const nextFailures: Record = {} + const nextFailures: Record = {} for (const failure of result.failures) { - nextFailures[getWorkspaceCleanupFailureIdentity(failure)] = failure.message + nextFailures[getWorkspaceCleanupFailureIdentity(failure)] = failure // Why: defensively covers failures that never reached onRowFailed. clearQueuedDeleteState(failure.worktreeId, failure.executionHostId) } @@ -243,7 +238,7 @@ export function useWorkspaceCleanupRemoval({ delete next[identity] } for (const failure of result.failures) { - next[getWorkspaceCleanupFailureIdentity(failure)] = failure.message + next[getWorkspaceCleanupFailureIdentity(failure)] = failure } return next }) @@ -277,6 +272,7 @@ export function useWorkspaceCleanupRemoval({ resetForReopen, openConfirmRemove, confirmRemove, + confirmUnverifiedRemoval, cancelConfirmRemove, backToList } diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-unverified-removal.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-unverified-removal.ts new file mode 100644 index 00000000000..47063a0ab11 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-unverified-removal.ts @@ -0,0 +1,153 @@ +import { useCallback, type Dispatch, type SetStateAction } from 'react' +import { useMountedRef } from '@/hooks/useMountedRef' +import { useAppStore } from '@/store' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + getWorkspaceCleanupCandidateIdentity, + getWorkspaceCleanupHostIdentity, + resolveWorkspaceCleanupRemovalHostId +} from '../../../../shared/workspace-cleanup-host-identity' +import type { + WorkspaceCleanupFailure, + WorkspaceCleanupRemoveResult +} from '@/store/slices/workspace-cleanup' +import { startWorkspaceCleanupBackgroundRemoval } from './workspace-cleanup-background-removal' +import type { WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row' +import { createWorkspaceCleanupSnapshotPruneBatch } from './workspace-cleanup-snapshot-prune-batch' + +type UnverifiedRemovalArgs = { + setRowFailures: Dispatch>> + setDeletionPhaseByIdentity: Dispatch< + SetStateAction> + > + clearQueuedDeleteState: ( + worktreeId: string, + executionHostId?: WorkspaceCleanupFailure['executionHostId'] + ) => void + onDeselect: (removedIdentities: readonly string[]) => void +} + +export function useWorkspaceCleanupUnverifiedRemoval({ + setRowFailures, + setDeletionPhaseByIdentity, + clearQueuedDeleteState, + onDeselect +}: UnverifiedRemovalArgs): (candidate: WorkspaceCleanupCandidate) => void { + const beginConsent = useAppStore((state) => state.beginUnverifiedRemovalConsent) + const removeCandidates = useAppStore((state) => state.removeWorkspaceCleanupCandidates) + const markQueued = useAppStore((state) => state.markWorktreesQueuedForDeletion) + const mountedRef = useMountedRef() + + return useCallback( + (candidate) => { + const identity = getWorkspaceCleanupCandidateIdentity(candidate) + const attemptId = beginConsent(identity) + if (!attemptId) { + return + } + const hostId = resolveWorkspaceCleanupRemovalHostId(candidate) + setRowFailures((current) => withoutIdentity(current, identity)) + setDeletionPhaseByIdentity((current) => ({ ...current, [identity]: 'queued' })) + markQueued([hostId ? { id: candidate.worktreeId, hostId } : candidate.worktreeId]) + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates, + snapshotPruneBatch: createWorkspaceCleanupSnapshotPruneBatch(), + getRemoveOptions: () => ({ + unverifiedRemovalConsent: { identity, attemptId } + }), + onProgress: () => { + if (mountedRef.current) { + setDeletionPhaseByIdentity((current) => ({ ...current, [identity]: 'deleting' })) + } + }, + onRowFailed: (failure) => { + clearQueuedDeleteState(failure.worktreeId, failure.executionHostId) + // A preflight/timeout failure can arrive before the background + // batch settles; drop the optimistic deleting overlay immediately. + if (mountedRef.current) { + setDeletionPhaseByIdentity((current) => withoutIdentity(current, identity)) + } + }, + onResult: (result) => { + clearQueuedDeleteState(candidate.worktreeId, hostId ?? undefined) + if (mountedRef.current) { + applyResult(result, identity, setRowFailures, setDeletionPhaseByIdentity, onDeselect) + } + }, + onLateResult: (result) => { + if (mountedRef.current) { + setDeletionPhaseByIdentity((current) => withoutIdentity(current, identity)) + applyLateResult(result, setRowFailures, onDeselect) + } + }, + onError: () => { + clearQueuedDeleteState(candidate.worktreeId, hostId ?? undefined) + if (mountedRef.current) { + setDeletionPhaseByIdentity((current) => withoutIdentity(current, identity)) + } + } + }) + }, + [ + beginConsent, + clearQueuedDeleteState, + markQueued, + mountedRef, + onDeselect, + removeCandidates, + setDeletionPhaseByIdentity, + setRowFailures + ] + ) +} + +function applyResult( + result: WorkspaceCleanupRemoveResult, + identity: string, + setFailures: UnverifiedRemovalArgs['setRowFailures'], + setPhases: UnverifiedRemovalArgs['setDeletionPhaseByIdentity'], + onDeselect: UnverifiedRemovalArgs['onDeselect'] +): void { + setPhases((current) => withoutIdentity(current, identity)) + setFailures((current) => addFailures(withoutIdentity(current, identity), result.failures)) + onDeselect(result.removedIdentities) +} + +function applyLateResult( + result: WorkspaceCleanupRemoveResult, + setFailures: UnverifiedRemovalArgs['setRowFailures'], + onDeselect: UnverifiedRemovalArgs['onDeselect'] +): void { + setFailures((current) => { + let next = current + for (const identity of result.removedIdentities) { + next = withoutIdentity(next, identity) + } + return addFailures(next, result.failures) + }) + onDeselect(result.removedIdentities) +} + +function addFailures( + current: Record, + failures: readonly WorkspaceCleanupFailure[] +): Record { + const next = { ...current } + for (const failure of failures) { + next[getWorkspaceCleanupFailureIdentity(failure)] = failure + } + return next +} + +function withoutIdentity(current: Record, identity: string): Record { + const next = { ...current } + delete next[identity] + return next +} + +function getWorkspaceCleanupFailureIdentity(failure: WorkspaceCleanupFailure): string { + return failure.executionHostId + ? getWorkspaceCleanupHostIdentity(failure.executionHostId, failure.worktreeId) + : getWorkspaceCleanupCandidateIdentity({ worktreeId: failure.worktreeId }) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts index 1c45ddbc7ad..107981b5ef3 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model' +import { normalizeWorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state' import { hasActiveWorkspaceCleanupFilters, listActiveWorkspaceCleanupFacetGroups @@ -35,4 +36,14 @@ describe('listActiveWorkspaceCleanupFacetGroups', () => { expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual([]) expect(hasActiveWorkspaceCleanupFilters(filters)).toBe(true) }) + + it('drops retired verdict filters and recovers safety as inactive', () => { + const browse = normalizeWorkspaceCleanupBrowseState({ + filters: { safety: { tiers: ['ready'], selectableOnly: true } }, + sort: { field: 'tier', direction: 'desc' } + }) + + expect(listActiveWorkspaceCleanupFacetGroups(browse.filters)).not.toContain('safety') + expect(browse.sort).toEqual({ field: 'last-activity', direction: 'desc' }) + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-applied-filter-chips.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-applied-filter-chips.tsx index 38d615514cc..18cac15c161 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-applied-filter-chips.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-applied-filter-chips.tsx @@ -121,9 +121,7 @@ function buildAppliedFilterFormatters(): Parameters - translate('components.workspace.cleanup.browse.chip.completelyEmpty', 'Nothing to lose'), - selectableOnly: () => - translate('components.workspace.cleanup.browse.chip.selectableOnly', 'Deletable only') + translate('components.workspace.cleanup.browse.chip.completelyEmpty', 'Nothing to lose') } } @@ -153,8 +151,6 @@ function getChipKindLabel(kind: string): string { return translate('components.workspace.cleanup.browse.chip.kind.repo', 'Repo') case 'blocker': return translate('components.workspace.cleanup.browse.chip.kind.blocker', 'Blocker') - case 'tier': - return translate('components.workspace.cleanup.browse.chip.kind.tier', 'Safety') case 'dismissed': return translate('components.workspace.cleanup.browse.chip.kind.dismissed', 'Ignored') case 'archived': diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts index 0702e40a10f..1fd04777c39 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts @@ -56,6 +56,7 @@ export type WorkspaceCleanupBackgroundRemovalArgs = { begin: () => Promise finish: () => Promise } + getRemoveOptions?: (candidate: WorkspaceCleanupCandidate) => WorkspaceCleanupRemoveOptions } export function startWorkspaceCleanupBackgroundRemoval({ @@ -68,7 +69,8 @@ export function startWorkspaceCleanupBackgroundRemoval({ onRowFailed, removalTimeoutMs = DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS, removalSettlementGraceMs = DEFAULT_WORKSPACE_CLEANUP_SETTLEMENT_GRACE_MS, - snapshotPruneBatch + snapshotPruneBatch, + getRemoveOptions }: WorkspaceCleanupBackgroundRemovalArgs): void { if (candidates.length === 0) { try { @@ -79,7 +81,6 @@ export function startWorkspaceCleanupBackgroundRemoval({ return } - const count = candidates.length const removedIds: string[] = [] const removedIdentities: string[] = [] const failures: WorkspaceCleanupFailure[] = [] @@ -95,7 +96,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ const emitProgress = (): void => { onProgress({ - totalCount: count, + totalCount: candidates.length, processedCount, removedCount: removedIds.length, failedCount: failures.length @@ -199,6 +200,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ const outcome = await waitForWorkspaceCleanupRemovalWithTimeout( removeCandidates([candidate.worktreeId], { approvedCandidates: [candidate], + ...getRemoveOptions?.(candidate), ...(snapshotPruneBatchActive && snapshotPruneBatch ? { snapshotPruneBatchId: snapshotPruneBatch.batchId } : {}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts index 7ed376b10b6..256646080bf 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { - getCandidateFactStatus, + getCandidateFactStatuses, getDirtyGitLabel, shouldShowGitMetadataChip } from './workspace-cleanup-candidate-row-data' @@ -8,26 +8,36 @@ import { makeCandidate } from './workspace-cleanup-presentation-fixtures' describe('workspace cleanup candidate row data', () => { it('shows facts instead of cleanup policy tiers', () => { - expect(getCandidateFactStatus(makeCandidate({ tier: 'ready' }))).toBeNull() - expect(getCandidateFactStatus(makeCandidate({ tier: 'review' }))).toBeNull() - expect( - getCandidateFactStatus(makeCandidate({ tier: 'ready', reasons: ['archived'] })) - ).toMatchObject({ label: 'Archived' }) + expect(getCandidateFactStatuses(makeCandidate({ tier: 'ready' }))).toEqual([]) + expect(getCandidateFactStatuses(makeCandidate({ tier: 'review' }))).toEqual([]) + expect(getCandidateFactStatuses(makeCandidate({ reasons: ['archived'] }))).toContainEqual( + expect.objectContaining({ label: 'Archived' }) + ) }) it('does not duplicate git status blockers as a separate git icon label', () => { const gitStatusError = makeCandidate({ blockers: ['git-status-error'], - git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } }) const unknownBase = makeCandidate({ blockers: ['unknown-base'], - git: { clean: true, upstreamAhead: null, upstreamBehind: null, checkedAt: 1 } + git: { + clean: true, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: 1 + } }) - expect(getDirtyGitLabel(gitStatusError)).toBeNull() + expect(getDirtyGitLabel(gitStatusError)).toBe('Git status check failed') expect(shouldShowGitMetadataChip(gitStatusError)).toBe(false) - expect(getDirtyGitLabel(unknownBase)).toBeNull() + expect(getDirtyGitLabel(unknownBase)).toBe('Git status could not be verified') expect(shouldShowGitMetadataChip(unknownBase)).toBe(false) }) @@ -35,7 +45,12 @@ describe('workspace cleanup candidate row data', () => { expect( shouldShowGitMetadataChip( makeCandidate({ - git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } + git: { + clean: true, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: 1 + } }) ) ).toBe(true) @@ -43,14 +58,31 @@ describe('workspace cleanup candidate row data', () => { it('keeps unpushed risk visible for archived rows', () => { expect( - getCandidateFactStatus( + getCandidateFactStatuses( makeCandidate({ tier: 'review', reasons: ['archived'], - git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + git: { + clean: true, + upstreamAhead: 2, + upstreamBehind: 0, + checkedAt: 1 + } }) ) - ).toMatchObject({ label: 'Unpushed commits' }) + ).toContainEqual(expect.objectContaining({ label: 'Unpushed commits' })) + }) + + it('renders every blocker fact instead of only the first', () => { + expect( + getCandidateFactStatuses(makeCandidate({ blockers: ['pinned', 'git-status-error'] })) + ).toEqual([ + expect.objectContaining({ label: 'Pinned' }), + expect.objectContaining({ + label: 'Git status unavailable', + tone: 'destructive' + }) + ]) }) it('suppresses the git metadata chip when the status pill already names git risk', () => { @@ -58,7 +90,12 @@ describe('workspace cleanup candidate row data', () => { shouldShowGitMetadataChip( makeCandidate({ blockers: ['unpushed-commits'], - git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + git: { + clean: true, + upstreamAhead: 2, + upstreamBehind: 0, + checkedAt: 1 + } }) ) ).toBe(false) @@ -67,7 +104,12 @@ describe('workspace cleanup candidate row data', () => { makeCandidate({ tier: 'review', blockers: [], - git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + git: { + clean: true, + upstreamAhead: 2, + upstreamBehind: 0, + checkedAt: 1 + } }) ) ).toBe(false) @@ -75,7 +117,12 @@ describe('workspace cleanup candidate row data', () => { shouldShowGitMetadataChip( makeCandidate({ blockers: ['dirty-files'], - git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } + git: { + clean: false, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: 1 + } }) ) ).toBe(false) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts index 6b173868f59..75103a98d0c 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts @@ -21,50 +21,56 @@ export function getWorkspaceCleanupBlockerLabels(candidate: WorkspaceCleanupCand return candidate.blockers.map((blocker) => getWorkspaceCleanupBlockerLabel(blocker)) } -export function getCandidateFactStatus(candidate: WorkspaceCleanupCandidate): { +export function getCandidateFactStatuses(candidate: WorkspaceCleanupCandidate): { label: string tone: StatusPillTone -} | null { - if (candidate.blockers.includes('dismissed')) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7', - 'Ignored' - ), - tone: 'neutral' - } - } +}[] { if (candidate.blockers.length > 0) { - return { label: getWorkspaceCleanupBlockerLabel(candidate.blockers[0]), tone: 'neutral' } + return candidate.blockers.map((blocker) => ({ + label: + blocker === 'dismissed' + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7', + 'Ignored' + ) + : getWorkspaceCleanupBlockerLabel(blocker), + tone: blocker === 'git-status-error' || blocker === 'unknown-base' ? 'destructive' : 'neutral' + })) } if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9623a5107d', - 'Unpushed commits' - ), - tone: 'review' - } + return [ + { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9623a5107d', + 'Unpushed commits' + ), + tone: 'review' + } + ] } if (candidate.git.clean === false) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e97e4580c7', - 'Dirty' - ), - tone: 'review' - } + return [ + { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e97e4580c7', + 'Dirty' + ), + tone: 'review' + } + ] } if (candidate.reasons.includes('archived')) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.archivedStatus', - 'Archived' - ), - tone: 'neutral' - } + return [ + { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.archivedStatus', + 'Archived' + ), + tone: 'neutral' + } + ] } - return null + return [] } export function formatGitStatus(candidate: WorkspaceCleanupCandidate): string { @@ -117,11 +123,17 @@ export function formatContextDetails(candidate: WorkspaceCleanupCandidate): stri } export function getDirtyGitLabel(candidate: WorkspaceCleanupCandidate): string | null { - if ( - candidate.blockers.includes('unknown-base') || - candidate.blockers.includes('git-status-error') - ) { - return null + if (candidate.blockers.includes('git-status-error')) { + return translate( + 'components.workspace.cleanup.browse.gitStatusCheckFailed', + 'Git status check failed' + ) + } + if (candidate.blockers.includes('unknown-base')) { + return translate( + 'components.workspace.cleanup.browse.gitStatusUnverified', + 'Git status could not be verified' + ) } if (candidate.blockers.includes('unpushed-commits')) { if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { @@ -142,21 +154,19 @@ export function getDirtyGitLabel(candidate: WorkspaceCleanupCandidate): string | } export function shouldShowGitMetadataChip(candidate: WorkspaceCleanupCandidate): boolean { - return ( - !candidate.blockers.includes('unknown-base') && - !candidate.blockers.includes('git-status-error') && - !hasGitStatusPill(candidate) - ) + return !hasGitStatusPill(candidate) } function hasGitStatusPill(candidate: WorkspaceCleanupCandidate): boolean { if ( candidate.blockers.includes('dirty-files') || - candidate.blockers.includes('unpushed-commits') + candidate.blockers.includes('unpushed-commits') || + candidate.blockers.includes('unknown-base') || + candidate.blockers.includes('git-status-error') ) { return true } - if (candidate.blockers.length > 0 || candidate.tier === 'ready') { + if (candidate.blockers.length > 0) { return false } return (candidate.git.upstreamAhead ?? 0) > 0 || candidate.git.clean === false diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx index d5a7416914e..bec347d570d 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx @@ -33,7 +33,7 @@ import { formatBranchSafetyDetails, formatContextDetails, formatGitStatus, - getCandidateFactStatus, + getCandidateFactStatuses, getContextCount, getDirtyGitLabel, getReviewPillTone, @@ -42,10 +42,13 @@ import { } from './workspace-cleanup-candidate-row-data' import { StatusPill } from './workspace-cleanup-status-pill' import { WorkspaceCleanupMetadataChip } from './workspace-cleanup-metadata-chip' +import { WorkspaceCleanupForgetLocallyButton } from './workspace-cleanup-forget-locally-button' import { getWorkspaceCleanupCandidateAccessibleName, getWorkspaceCleanupCandidateHostLabel } from './workspace-cleanup-host-label' +import { formatCompactActivityLabel, getReviewTooltip } from './workspace-cleanup-row-labels' +import type { WorkspaceCleanupFailure } from '@/store/slices/workspace-cleanup' export type WorkspaceCleanupDeletionPhase = 'deleting' | 'queued' @@ -55,7 +58,7 @@ type CandidateRowProps = { identity: string deletionPhase?: WorkspaceCleanupDeletionPhase expanded: boolean - failure?: string + failure?: WorkspaceCleanupFailure /** A focused git re-scan is in flight, so "Not checked" is provisional. */ gitEvidencePending?: boolean last: boolean @@ -69,6 +72,8 @@ type CandidateRowProps = { selected: boolean onIgnore: (candidate: WorkspaceCleanupCandidate) => void onRemove: (candidate: WorkspaceCleanupCandidate) => void + onForgetLocally?: (candidate: WorkspaceCleanupCandidate) => void + onDeleteAnyway?: (candidate: WorkspaceCleanupCandidate) => void onToggleExpanded: (identity: string) => void onToggleSelected: (identity: string) => void onView: (candidate: WorkspaceCleanupCandidate) => void @@ -95,6 +100,8 @@ export const CandidateRow = React.memo(function CandidateRow({ selected, onIgnore, onRemove, + onForgetLocally, + onDeleteAnyway, onToggleExpanded, onToggleSelected, onView @@ -107,7 +114,7 @@ export const CandidateRow = React.memo(function CandidateRow({ const blockers = getWorkspaceCleanupBlockerLabels(candidate) const contextDetails = formatContextDetails(candidate) const branchSafetyDetails = formatBranchSafetyDetails(candidate) - const factStatus = getCandidateFactStatus(candidate) + const factStatuses = getCandidateFactStatuses(candidate) const dirtyLabel = getDirtyGitLabel(candidate) const gitLabel = getWorkspaceCleanupGitLabel(candidate) const showGitMetadataChip = shouldShowGitMetadataChip(candidate) @@ -178,9 +185,13 @@ export const CandidateRow = React.memo(function CandidateRow({ 'Deleting…' )} - ) : factStatus ? ( - {factStatus.label} - ) : null} + ) : ( + factStatuses.map((status) => ( + + {status.label} + + )) + )} {workspaceStatusLabel ? ( - {failure} + {failure.message} + {failure.canDeleteAnyway && onDeleteAnyway ? ( + + ) : null} ) : null} @@ -377,26 +398,11 @@ export const CandidateRow = React.memo(function CandidateRow({ ) : null} + {candidate.blockers.includes('ssh-disconnected') && onForgetLocally ? ( + + ) : null} ) }) - -function formatCompactActivityLabel(label: string): string { - if (label === 'Just now') { - return 'now' - } - return label.replace(/ ago$/, '') -} - -function getReviewTooltip(reviewInfo: WorkspaceCleanupReviewInfo): string { - const parts = [reviewInfo.label] - if (reviewInfo.state) { - parts.push(reviewInfo.state) - } - if (reviewInfo.title) { - parts.push(reviewInfo.title) - } - return parts.filter(Boolean).join(' · ') -} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx index 03a146f17ea..dbffc7214e5 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx @@ -158,4 +158,64 @@ describe('WorkspaceCleanupConfirmRemove', () => { expect(container.querySelector(`[aria-label="Host: ${hostLabel}"]`)).not.toBeNull() } }) + + it('shows every destructive git risk and the fleet-level force projection', () => { + const failed = makeFacetCandidate({ + worktreeId: 'repo-1::/failed', + blockers: ['pinned', 'git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }) + const unknownBase = makeFacetCandidate({ + worktreeId: 'repo-1::/unknown-base', + blockers: ['unknown-base'] + }) + + act(() => { + root.render( + + ) + }) + + expect(container.textContent).toContain('Pinned') + expect(container.textContent).toContain('Git status unavailable') + expect(container.textContent).toContain('Could not verify unpushed commits') + expect(container.textContent).toContain( + '2 workspaces currently show risk and may need a force delete' + ) + expect( + [...container.querySelectorAll('.text-destructive')].map((node) => node.textContent) + ).toEqual( + expect.arrayContaining(['Git status unavailable', 'Could not verify unpushed commits']) + ) + + act(() => { + root.render( + + ) + }) + expect(container.textContent).toContain( + '1 workspace currently shows risk and may need a force delete' + ) + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx index 51135d63265..c7ccae4a8fe 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx @@ -6,10 +6,13 @@ import { Progress } from '@/components/ui/progress' import { ScrollArea } from '@/components/ui/scroll-area' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + shouldForceWorkspaceCleanupRemoval, + type WorkspaceCleanupCandidate +} from '../../../../shared/workspace-cleanup' import type { WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal' import { - getCandidateFactStatus, + getCandidateFactStatuses, formatContextDetailLabels, getDirtyGitLabel, getReviewPillTone, @@ -60,6 +63,7 @@ export function WorkspaceCleanupConfirmRemove({ }): React.JSX.Element { const [scrollElement, setScrollElement] = React.useState(null) const count = candidates.length + const riskCount = candidates.filter(shouldForceWorkspaceCleanupRemoval).length const deleting = progress !== null const progressValue = progress ? Math.min(100, Math.max(0, (progress.processedCount / progress.totalCount) * 100)) @@ -136,6 +140,21 @@ export function WorkspaceCleanupConfirmRemove({ { value0: count } )} + {riskCount > 0 ? ( +
+ {riskCount === 1 + ? translate( + 'components.workspace.cleanup.browse.forceDeleteProjectionOne', + '{{count}} workspace currently shows risk and may need a force delete', + { count: riskCount } + ) + : translate( + 'components.workspace.cleanup.browse.forceDeleteProjectionMany', + '{{count}} workspaces currently show risk and may need a force delete', + { count: riskCount } + )} +
+ ) : null} - {factStatus ? {factStatus.label} : null} + {factStatuses.map((status) => ( + + {status.label} + + ))} {reviewInfo.label ? ( {reviewInfo.label} ) : null} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts index 5131f2f28f9..7d81a027ed0 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts @@ -26,6 +26,7 @@ import { queryWorkspaceCleanupCandidates } from './workspace-cleanup-query' import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' +import { canQueueWorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' function filters(patch: (state: WorkspaceCleanupFilterState) => void): WorkspaceCleanupFilterState { const state = createDefaultWorkspaceCleanupFilterState() @@ -100,7 +101,10 @@ describe('facet building', () => { it('keeps same-id worktrees and sizes isolated by host', () => { const candidates = [ makeFacetCandidate({ executionHostId: 'local' }), - makeFacetCandidate({ executionHostId: 'ssh:builder', connectionId: 'builder' }) + makeFacetCandidate({ + executionHostId: 'ssh:builder', + connectionId: 'builder' + }) ] const sizes = buildWorkspaceCleanupSizeIndex( [ @@ -130,7 +134,10 @@ describe('facet building', () => { it('does not apply a legacy size to duplicate candidate ids', () => { const candidates = [ makeFacetCandidate({ executionHostId: 'local' }), - makeFacetCandidate({ executionHostId: 'ssh:builder', connectionId: 'builder' }) + makeFacetCandidate({ + executionHostId: 'ssh:builder', + connectionId: 'builder' + }) ] const sizes = buildWorkspaceCleanupSizeIndex( [{ worktreeId: candidates[0]!.worktreeId, status: 'ok', sizeBytes: 10 }], @@ -213,7 +220,9 @@ describe('activity filter', () => { }) it('separates background activity from a real visit', () => { - const busyButUnopened = makeFacets({ candidate: { lastActivityAt: FACET_NOW } }) + const busyButUnopened = makeFacets({ + candidate: { lastActivityAt: FACET_NOW } + }) const state = filters((s) => { s.safety.dismissed = 'any' s.activity.neverVisited = true @@ -333,7 +342,9 @@ describe('agent, git, review, ticket, context, and location filters', () => { it('filters by git state, ahead counts, branch text, prunable and locked', () => { const unpushed = makeFacets({ - candidate: { git: { clean: true, upstreamAhead: 3, upstreamBehind: 1, checkedAt: 1 } } + candidate: { + git: { clean: true, upstreamAhead: 3, upstreamBehind: 1, checkedAt: 1 } + } }) expect(unpushed.gitState).toBe('unpushed') const state = filters((s) => { @@ -366,7 +377,9 @@ describe('agent, git, review, ticket, context, and location filters', () => { }) it('keeps review filtering provider-general and treats draft as a state', () => { - const draftMr = makeFacets({ review: { provider: 'gitlab', state: 'draft', label: 'MR #7' } }) + const draftMr = makeFacets({ + review: { provider: 'gitlab', state: 'draft', label: 'MR #7' } + }) const state = filters((s) => { s.safety.dismissed = 'any' s.review.presence = 'some' @@ -402,7 +415,10 @@ describe('agent, git, review, ticket, context, and location filters', () => { it('filters by local context and completely-empty', () => { const busy = makeFacets({ candidate: { - localContext: { ...makeFacetCandidate().localContext, terminalTabCount: 2 } + localContext: { + ...makeFacetCandidate().localContext, + terminalTabCount: 2 + } } }) const hasContext = filters((s) => { @@ -473,23 +489,90 @@ describe('safety filter', () => { expect(matchesWorkspaceCleanupFilters(dismissed, only, FACET_NOW)).toBe(true) expect(matchesWorkspaceCleanupFilters(makeFacets(), only, FACET_NOW)).toBe(false) }) - - it('restricts to rows the policy would actually let the user delete', () => { - const blocked = makeFacets({ candidate: { blockers: ['dirty-files'], tier: 'review' } }) - const state = filters((s) => { - s.safety.dismissed = 'any' - s.safety.selectableOnly = true - }) - expect(matchesWorkspaceCleanupFilters(blocked, state, FACET_NOW)).toBe(false) - expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(true) - }) }) describe('query pipeline', () => { + it('bulk-selects label rows while preserving explicit exclusions', () => { + const candidates = [ + makeFacetCandidate({ + worktreeId: 'repo-1::/dirty', + blockers: ['dirty-files'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/pinned', + blockers: ['pinned'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/unknown', + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/active', + blockers: ['active-workspace'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/agent', + blockers: ['live-agent'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/ignored', + blockers: ['dismissed'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/main', + blockers: ['main-worktree'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/folder', + blockers: ['folder-repo'] + }), + makeFacetCandidate({ + worktreeId: 'repo-1::/remote', + blockers: ['ssh-disconnected'] + }) + ] + const result = queryWorkspaceCleanupCandidates( + candidates, + { + filters: createDefaultWorkspaceCleanupFilterState(), + sort: { field: 'name', direction: 'asc' } + }, + {}, + FACET_NOW + ) + const selectableIds = result.rows + .filter((row) => result.selectableIdentities.includes(row.identity)) + .map((row) => row.worktreeId) + + expect(selectableIds).toEqual( + expect.arrayContaining(['repo-1::/dirty', 'repo-1::/pinned', 'repo-1::/unknown']) + ) + expect(selectableIds).not.toEqual( + expect.arrayContaining([ + 'repo-1::/active', + 'repo-1::/agent', + 'repo-1::/ignored', + 'repo-1::/main', + 'repo-1::/folder', + 'repo-1::/remote' + ]) + ) + expect(canQueueWorkspaceCleanupCandidate(candidates[3])).toBe(true) + expect(canQueueWorkspaceCleanupCandidate(candidates[4])).toBe(true) + expect(canQueueWorkspaceCleanupCandidate(candidates[5])).toBe(true) + }) + it('reports counts and selectable ids', () => { const rows = [ makeNamedFacets('alpha'), - makeNamedFacets('beta', { candidate: { blockers: ['dirty-files'], tier: 'review' } }) + makeNamedFacets('beta', { + candidate: { blockers: ['dirty-files'], tier: 'review' } + }) ] expect(filterWorkspaceCleanupFacets(rows, ANY, FACET_NOW)).toHaveLength(2) const result = queryWorkspaceCleanupCandidates( diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts index 9ea2cd09adf..98f09a577d2 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-labels.ts @@ -1,6 +1,5 @@ import { translate } from '@/i18n/i18n' import type { HostedReviewProvider } from '../../../../shared/hosted-review' -import type { WorkspaceCleanupTier } from '../../../../shared/workspace-cleanup' import type { WorkspaceCleanupAgentState, WorkspaceCleanupBlockerMode, @@ -49,8 +48,6 @@ export function getWorkspaceCleanupSortFieldLabel(field: WorkspaceCleanupSortFie return translate('components.workspace.cleanup.browse.sort.ticket', 'Ticket') case 'local-context': return translate('components.workspace.cleanup.browse.sort.localContext', 'Open context') - case 'tier': - return translate('components.workspace.cleanup.browse.sort.tier', 'Safety') case 'blocker-count': return translate('components.workspace.cleanup.browse.sort.blockerCount', 'Blockers') } @@ -124,17 +121,6 @@ export function getWorkspaceCleanupTicketSourceLabel(source: WorkspaceCleanupTic } } -export function getWorkspaceCleanupTierLabel(tier: WorkspaceCleanupTier): string { - switch (tier) { - case 'ready': - return translate('components.workspace.cleanup.browse.tier.ready', 'Ready') - case 'review': - return translate('components.workspace.cleanup.browse.tier.review', 'Needs review') - case 'protected': - return translate('components.workspace.cleanup.browse.tier.protected', 'Protected') - } -} - export function getWorkspaceCleanupIdleSignalLabel(signal: WorkspaceCleanupIdleSignal): string { switch (signal) { case 'last-visited': diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts index 988a31c1489..d617e7c5ecb 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-matchers.ts @@ -170,15 +170,9 @@ export function matchesWorkspaceCleanupSafety( facets: WorkspaceCleanupFacets, filter: WorkspaceCleanupSafetyFilter ): boolean { - if (filter.tiers.length > 0 && !filter.tiers.includes(facets.tier)) { - return false - } if (!matchesWorkspaceCleanupTriState(filter.dismissed, facets.isDismissed)) { return false } - if (filter.selectableOnly && !facets.isSelectable) { - return false - } if (filter.blockers.length === 0) { return true } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts index 4da525a1352..fda63e1c0de 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts @@ -60,27 +60,41 @@ describe('ranked fields', () => { it('orders git states from safest to riskiest', () => { const rows = [ makeNamedFacets('unpushed', { - candidate: { git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } } + candidate: { + git: { + clean: true, + upstreamAhead: 2, + upstreamBehind: 0, + checkedAt: 1 + } + } }), makeNamedFacets('clean'), makeNamedFacets('dirty', { - candidate: { git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } } + candidate: { + git: { + clean: false, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: 1 + } + } }), makeNamedFacets('unknown', { - candidate: { git: { clean: null, upstreamAhead: 0, upstreamBehind: 0, checkedAt: null } } + candidate: { + git: { + clean: null, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: null + } + } }) ] expect(names(rows, 'git', 'asc')).toEqual(['clean', 'unknown', 'dirty', 'unpushed']) }) - it('orders tiers ready to protected and agents idle to permission', () => { - const tierRows = [ - makeNamedFacets('protectedRow', { candidate: { tier: 'protected' } }), - makeNamedFacets('readyRow', { candidate: { tier: 'ready' } }), - makeNamedFacets('reviewRow', { candidate: { tier: 'review' } }) - ] - expect(names(tierRows, 'tier', 'asc')).toEqual(['readyRow', 'reviewRow', 'protectedRow']) - + it('orders agents idle to permission', () => { const agentRows = [ makeNamedFacets('permission', { agentStatus: 'permission' }), makeNamedFacets('idle'), @@ -91,8 +105,12 @@ describe('ranked fields', () => { it('breaks equal blocker counts by the worst blocker', () => { const rows = [ - makeNamedFacets('softest', { candidate: { blockers: ['git-status-error'] } }), - makeNamedFacets('hardest', { candidate: { blockers: ['main-worktree'] } }) + makeNamedFacets('softest', { + candidate: { blockers: ['git-status-error'] } + }), + makeNamedFacets('hardest', { + candidate: { blockers: ['main-worktree'] } + }) ] expect(names(rows, 'blocker-count', 'desc')).toEqual(['hardest', 'softest']) }) @@ -101,8 +119,12 @@ describe('ranked fields', () => { describe('tie-breaks', () => { it('falls back to activity, repo, name, then worktree id', () => { const rows = [ - makeNamedFacets('zeta', { candidate: { lastActivityAt: FACET_NOW - DAY } }), - makeNamedFacets('alpha', { candidate: { lastActivityAt: FACET_NOW - 2 * DAY } }) + makeNamedFacets('zeta', { + candidate: { lastActivityAt: FACET_NOW - DAY } + }), + makeNamedFacets('alpha', { + candidate: { lastActivityAt: FACET_NOW - 2 * DAY } + }) ] // Same size on both sides, so only the tie-break chain can order them. expect(names(rows, 'size', 'desc')).toEqual(['alpha', 'zeta']) @@ -111,7 +133,9 @@ describe('tie-breaks', () => { it('keeps tie-broken rows in the same order when direction flips', () => { const rows = [ makeNamedFacets('b', { candidate: { lastActivityAt: FACET_NOW - DAY } }), - makeNamedFacets('a', { candidate: { lastActivityAt: FACET_NOW - 2 * DAY } }) + makeNamedFacets('a', { + candidate: { lastActivityAt: FACET_NOW - 2 * DAY } + }) ] expect(names(rows, 'git', 'asc')).toEqual(names(rows, 'git', 'desc')) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts index 50787aa6bd9..354e3c063e0 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.ts @@ -3,8 +3,7 @@ import { getWorkspaceCleanupAgentRank, getWorkspaceCleanupBlockerSeverity, getWorkspaceCleanupGitRank, - getWorkspaceCleanupReviewRank, - getWorkspaceCleanupTierRank + getWorkspaceCleanupReviewRank } from '../../../../shared/workspace-cleanup-facet-rankings' import type { WorkspaceCleanupSortDirectionState, @@ -161,8 +160,6 @@ function compareNatural( ) case 'local-context': return left.localContextCount - right.localContextCount - case 'tier': - return getWorkspaceCleanupTierRank(left.tier) - getWorkspaceCleanupTierRank(right.tier) case 'blocker-count': return ( left.blockerCount - right.blockerCount || 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 9b983ce6fae..7e44fed5344 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts @@ -4,10 +4,10 @@ import type { WorkspaceStatusDefinition } from '../../../../shared/worktree/type import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity' import { getWorkspaceStatus } from '../../../../shared/workspace-statuses' import { - canSelectWorkspaceCleanupCandidate, + canQueueWorkspaceCleanupCandidate, + WORKSPACE_CLEANUP_BULK_SELECT_EXCLUSIONS, type WorkspaceCleanupBlocker, - type WorkspaceCleanupCandidate, - type WorkspaceCleanupTier + type WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' import { getWorkspaceCleanupGitState } from './workspace-cleanup-filter-sort' import type { @@ -48,7 +48,6 @@ export type WorkspaceCleanupFacets = { path: string branch: string hostId: ExecutionHostId - tier: WorkspaceCleanupTier blockers: readonly WorkspaceCleanupBlocker[] blockerCount: number isDismissed: boolean @@ -119,14 +118,15 @@ export function buildWorkspaceCleanupFacets( path: candidate.path, branch, hostId: worktree?.hostId ?? getWorkspaceCleanupCandidateHostId(candidate), - tier: candidate.tier, blockers: candidate.blockers, blockerCount: candidate.blockers.length, isDismissed: (sources.dismissedIdentities?.has(getWorkspaceCleanupCandidateIdentity(candidate)) ?? false) || candidate.blockers.includes('dismissed'), - isSelectable: canSelectWorkspaceCleanupCandidate(candidate), + isSelectable: + canQueueWorkspaceCleanupCandidate(candidate) && + !candidate.blockers.some((blocker) => WORKSPACE_CLEANUP_BULK_SELECT_EXCLUSIONS.has(blocker)), lastActivityAt: candidate.lastActivityAt, createdAt: toFiniteOrNull(worktree?.createdAt ?? candidate.createdAt), lastVisitedAt: toFiniteOrNull( @@ -230,7 +230,6 @@ function buildSearchText(facets: Omit): st facets.review.title, facets.review.provider, facets.gitState, - facets.tier, ...facets.ticketSources, ...facets.blockers ] diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx index 6a1610b9763..2f36354a286 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx @@ -26,15 +26,25 @@ let container: HTMLDivElement | null = null // One row per bucket the retired tab partition used to hide behind a tab. const FLEET: WorkspaceCleanupFacets[] = [ - makeNamedFacets('ready-one', { candidate: { tier: 'ready' }, sizeBytes: 4 * 1024 * 1024 }), + makeNamedFacets('ready-one', { + candidate: { tier: 'ready' }, + sizeBytes: 4 * 1024 * 1024 + }), makeNamedFacets('needs-review', { candidate: { tier: 'review' } }), makeNamedFacets('protected-one', { - candidate: { tier: 'protected', blockers: ['pinned'], selectedByDefault: false } + candidate: { + tier: 'protected', + blockers: ['pinned'], + selectedByDefault: false + } }), makeNamedFacets('ignored-one', { dismissed: true }) ] -function renderRows(rows: readonly WorkspaceCleanupFacets[]): void { +function renderRows( + rows: readonly WorkspaceCleanupFacets[], + onForgetLocally?: (candidate: WorkspaceCleanupFacets['candidate']) => void +): void { act(() => root?.render( { it('switches the visible rows when filters are applied', () => { const filters = createDefaultWorkspaceCleanupFilterState() - filters.safety.tiers = ['ready'] filters.safety.dismissed = 'exclude' renderRows(query(filters).rows) - expect(renderedNames()).toEqual(['ready-one']) + expect(renderedNames()).not.toContain('ignored-one') - filters.safety.tiers = [] filters.safety.dismissed = 'only' renderRows(query(filters).rows) expect(renderedNames()).toEqual(['ignored-one']) - - filters.safety.dismissed = 'any' - filters.safety.tiers = ['protected'] - renderRows(query(filters).rows) - expect(renderedNames()).toEqual(['protected-one']) }) - it('reports only the rows the user could actually queue for deletion', () => { - expect(query().selectableIdentities).not.toContain( + it('lets select-all include pinned label rows', () => { + expect(query().selectableIdentities).toContain( getWorkspaceCleanupHostIdentity('local', 'repo-1::/repo/protected-one') ) }) + it('offers local removal for a disconnected SSH row without making it selectable', () => { + const onForgetLocally = vi.fn() + const disconnected = makeNamedFacets('disconnected', { + candidate: { blockers: ['ssh-disconnected'] } + }) + + renderRows([disconnected], onForgetLocally) + + expect(container?.querySelector('[aria-label^="Select disconnected"]')).toBeNull() + const forgetButton = container?.querySelector('[aria-label="Remove from Orca"]') + expect(forgetButton).not.toBeNull() + act(() => forgetButton?.click()) + expect(onForgetLocally).toHaveBeenCalledWith(disconnected.candidate) + }) + it('shows status and disk-size facts without expanding a row', () => { renderRows(query().rows) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-forget-locally-button.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-forget-locally-button.tsx new file mode 100644 index 00000000000..a139df881a6 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-forget-locally-button.tsx @@ -0,0 +1,43 @@ +import { Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { resolveWorkspaceCleanupRemovalHostId } from '../../../../shared/workspace-cleanup-host-identity' +import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow' + +export function openWorkspaceCleanupForgetLocally(candidate: WorkspaceCleanupCandidate): void { + runWorktreeDelete(candidate.worktreeId, { + expectedHostId: resolveWorkspaceCleanupRemovalHostId(candidate) ?? undefined + }) +} + +export function WorkspaceCleanupForgetLocallyButton({ + candidate, + onForget +}: { + candidate: WorkspaceCleanupCandidate + onForget: (candidate: WorkspaceCleanupCandidate) => void +}): React.JSX.Element { + const label = translate( + 'auto.components.sidebar.ForgetSshWorkspaceDialog.forget', + 'Remove from Orca' + ) + return ( + + + + + + {label} + + + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx index 3db2300c4c5..337c164a2ba 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-review-facets.tsx @@ -8,12 +8,10 @@ import { WORKSPACE_CLEANUP_PRESENCE_VALUES, WORKSPACE_CLEANUP_REVIEW_STATE_VALUES, WORKSPACE_CLEANUP_TICKET_SOURCE_VALUES, - WORKSPACE_CLEANUP_TIER_VALUES, WORKSPACE_CLEANUP_TRI_STATE_VALUES } from '../../../../shared/workspace-cleanup-facet-rankings' import { getWorkspaceCleanupBlockerLabel } from './workspace-cleanup-candidate-labels' import { - FacetCheckbox, FacetChoice, FacetNumberField, FacetSection, @@ -27,7 +25,6 @@ import { getWorkspaceCleanupReviewProviderLabel, getWorkspaceCleanupReviewStateLabel, getWorkspaceCleanupTicketSourceLabel, - getWorkspaceCleanupTierLabel, getWorkspaceCleanupTriStateLabel } from './workspace-cleanup-facet-labels' import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model' @@ -166,13 +163,6 @@ export function WorkspaceCleanupGitReviewFacets({ matchCount={counts.safety} totalCount={totalCount} > - onPatch('safety', { tiers })} - /> onPatch('safety', { dismissed })} /> - onPatch('safety', { selectableOnly })} - /> ) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts index 17f5f2f7c05..3abf167b57e 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation-fixtures.ts @@ -3,7 +3,10 @@ import { translate } from '@/i18n/i18n' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' -import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + applyWorkspaceCleanupPolicy, + type WorkspaceCleanupCandidate +} from '../../../../shared/workspace-cleanup' import type { WorkspaceCleanupFilters } from './workspace-cleanup-presentation' export const NOW = 1_700_000_000_000 @@ -27,7 +30,7 @@ export const DEFAULT_FILTERS: WorkspaceCleanupFilters = { export function makeCandidate( overrides: Partial = {} ): WorkspaceCleanupCandidate { - return { + return applyWorkspaceCleanupPolicy({ worktreeId: 'repo-1::/repo/alpha', repoId: 'repo-1', repoName: 'Repo', @@ -35,8 +38,6 @@ export function makeCandidate( displayName: 'alpha', branch: 'alpha', path: '/repo/alpha', - tier: 'ready', - selectedByDefault: true, reasons: ['idle-clean'], blockers: [], lastActivityAt: NOW - 40 * 24 * 60 * 60 * 1000, @@ -56,7 +57,7 @@ export function makeCandidate( }, fingerprint: 'fingerprint', ...overrides - } + }) } export function makeWorktree(overrides: Partial = {}): Worktree { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts new file mode 100644 index 00000000000..887cb1c17d0 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-labels.ts @@ -0,0 +1,9 @@ +import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation' + +export function formatCompactActivityLabel(label: string): string { + return label === 'Just now' ? 'now' : label.replace(/ ago$/, '') +} + +export function getReviewTooltip(reviewInfo: WorkspaceCleanupReviewInfo): string { + return [reviewInfo.label, reviewInfo.state, reviewInfo.title].filter(Boolean).join(' · ') +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx index 6c1da8faed2..fe81bf57e08 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-row-list.tsx @@ -7,6 +7,7 @@ import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-lis import { WorkspaceCleanupEmptyState } from './workspace-cleanup-dialog-notices' import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets' import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time' +import type { WorkspaceCleanupFailure } from '@/store/slices/workspace-cleanup' export type WorkspaceCleanupRowListState = { rows: readonly WorkspaceCleanupFacets[] @@ -23,7 +24,7 @@ export type WorkspaceCleanupRowListState = { selectedIds: ReadonlySet gitPendingWorktreeIds: ReadonlySet /** Keyed by host-qualified identity so a failure marks only its own host's row. */ - rowFailures: Record + rowFailures: Record scrollElement: HTMLDivElement | null onClearFilters: () => void onToggleExpanded: (identity: string) => void @@ -31,6 +32,8 @@ export type WorkspaceCleanupRowListState = { onView: (candidate: WorkspaceCleanupCandidate) => void onIgnore: (candidate: WorkspaceCleanupCandidate) => void onRemove: (candidate: WorkspaceCleanupCandidate) => void + onForgetLocally: (candidate: WorkspaceCleanupCandidate) => void + onDeleteAnyway: (candidate: WorkspaceCleanupCandidate) => void } export function WorkspaceCleanupRowList(props: WorkspaceCleanupRowListState): React.JSX.Element { @@ -92,6 +95,8 @@ export function WorkspaceCleanupRowList(props: WorkspaceCleanupRowListState): Re onView={props.onView} onIgnore={props.onIgnore} onRemove={props.onRemove} + onForgetLocally={props.onForgetLocally} + onDeleteAnyway={props.onDeleteAnyway} /> )} /> diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts new file mode 100644 index 00000000000..b633f610d93 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts @@ -0,0 +1,18 @@ +import type { WorkspaceCleanupBackgroundRemovalArgs } from './workspace-cleanup-background-removal' + +export function createWorkspaceCleanupSnapshotPruneBatch(): + | WorkspaceCleanupBackgroundRemovalArgs['snapshotPruneBatch'] + | undefined { + const begin = window.api.workspaceCleanup.beginRemovalSnapshotPruneBatch + const record = window.api.workspaceCleanup.recordRemovalSnapshotPrune + const finish = window.api.workspaceCleanup.finishRemovalSnapshotPruneBatch + if (typeof begin !== 'function' || typeof record !== 'function' || typeof finish !== 'function') { + return undefined + } + const batchId = crypto.randomUUID() + return { + batchId, + begin: () => begin({ batchId }), + finish: () => finish({ batchId }) + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3600bbeb99b..7db71c1d09b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -303,7 +303,10 @@ "9d6e531da6": "Workspace no longer exists.", "changedSinceConfirmation": "Workspace changed after confirmation. Refresh to review it before removing.", "hostCollision": "Error: this workspace exists on multiple hosts at the same path", - "hostUnresolved": "Orca cannot tell which host owns this workspace. Refresh projects and review it again." + "hostUnresolved": "Orca cannot tell which host owns this workspace. Refresh projects and review it again.", + "gitStatusUnavailable": "Orca couldn't check this workspace's git status. Try again, or delete it from its host-specific sidebar or project list.", + "gitStatusUnavailableOlderPeer": "Orca couldn't match this git-status failure to a host. Update the older connected peer, or delete the workspace from its host-specific sidebar or project list.", + "forceNeedsApproval": "Review and confirm this workspace before force deleting it." } }, "worktrees": { @@ -16231,6 +16234,11 @@ "measuringSizes": "Scanning sizes", "measureSizesFailed": "Could not scan workspace sizes", "selectedCount": "{{value0}} selected", + "gitStatusCheckFailed": "Git status check failed", + "gitStatusUnverified": "Git status could not be verified", + "deleteAnyway": "Delete anyway", + "forceDeleteProjectionOne": "{{count}} workspace currently shows risk and may need a force delete", + "forceDeleteProjectionMany": "{{count}} workspaces currently show risk and may need a force delete", "selectionWithheldOne": "1 selected workspace is hidden by the current filters and was deselected.", "selectionWithheld": "{{value0}} selected workspaces are hidden by the current filters and were deselected.", "selectAllCountOne": "Select 1 safety-checked workspace", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 6164f1707bd..8a3a3b3c48b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -232,7 +232,10 @@ "workspace": { "cleanup": { "9d6e531da6": "워크스페이스가 더 이상 존재하지 않습니다.", - "changedSinceConfirmation": "확인 후 워크스페이스가 변경되었습니다. 제거하기 전에 새로 고쳐 검토하세요." + "changedSinceConfirmation": "확인 후 워크스페이스가 변경되었습니다. 제거하기 전에 새로 고쳐 검토하세요.", + "gitStatusUnavailable": "Orca가 이 워크스페이스의 Git 상태를 확인할 수 없습니다. 다시 시도하거나 호스트별 사이드바 또는 프로젝트 목록에서 삭제하세요.", + "gitStatusUnavailableOlderPeer": "Orca가 이 Git 상태 확인 실패를 호스트와 연결할 수 없습니다. 이전 연결 피어를 업데이트하거나 호스트별 사이드바 또는 프로젝트 목록에서 워크스페이스를 삭제하세요.", + "forceNeedsApproval": "강제로 삭제하기 전에 이 워크스페이스를 검토하고 확인하세요." } }, "worktrees": { @@ -14568,6 +14571,11 @@ "githubPullRequestNumber": "PR #{{value0}}" }, "browse": { + "gitStatusCheckFailed": "Git 상태 확인 실패", + "gitStatusUnverified": "Git 상태를 확인할 수 없음", + "deleteAnyway": "그래도 삭제", + "forceDeleteProjectionOne": "현재 워크스페이스 {{count}}개에 위험 요소가 표시되며 강제 삭제가 필요할 수 있습니다", + "forceDeleteProjectionMany": "현재 워크스페이스 {{count}}개에 위험 요소가 표시되며 강제 삭제가 필요할 수 있습니다", "appliedFilters": "적용된 필터", "removeFilter": "{{value0}} 필터 제거", "chip": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 84c05074bb5..dd1899e752c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -232,7 +232,10 @@ "workspace": { "cleanup": { "9d6e531da6": "工作区不再存在。", - "changedSinceConfirmation": "工作区在确认后发生了更改。请刷新并在删除前重新检查。" + "changedSinceConfirmation": "工作区在确认后发生了更改。请刷新并在删除前重新检查。", + "gitStatusUnavailable": "Orca 无法检查此工作区的 Git 状态。请重试,或从对应主机的侧边栏或项目列表中将其删除。", + "gitStatusUnavailableOlderPeer": "Orca 无法将此 Git 状态检查失败与主机匹配。请更新较旧的已连接端,或从对应主机的侧边栏或项目列表中删除该工作区。", + "forceNeedsApproval": "请先检查并确认此工作区,然后再强制删除。" } }, "worktrees": { @@ -14559,6 +14562,11 @@ "githubPullRequestNumber": "PR #{{value0}}" }, "browse": { + "gitStatusCheckFailed": "Git 状态检查失败", + "gitStatusUnverified": "无法验证 Git 状态", + "deleteAnyway": "仍然删除", + "forceDeleteProjectionOne": "当前有 {{count}} 个工作区显示风险,可能需要强制删除", + "forceDeleteProjectionMany": "当前有 {{count}} 个工作区显示风险,可能需要强制删除", "appliedFilters": "已应用的筛选", "removeFilter": "移除筛选 {{value0}}", "chip": { diff --git a/src/renderer/src/store/slices/workspace-cleanup-browse.test.ts b/src/renderer/src/store/slices/workspace-cleanup-browse.test.ts index bcd14783882..760678aa26c 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-browse.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-browse.test.ts @@ -86,7 +86,7 @@ describe('workspace cleanup browse slice', () => { const store = createStore(uiSet) const next = createDefaultWorkspaceCleanupBrowseState() - next.filters.safety.tiers = ['review'] + next.filters.safety.blockers = ['dirty-files'] next.sort = { field: 'size', direction: 'desc' } store.getState().updateWorkspaceCleanupBrowseState(next) await vi.advanceTimersByTimeAsync(WORKSPACE_CLEANUP_BROWSE_PERSIST_DEBOUNCE_MS) diff --git a/src/renderer/src/store/slices/workspace-cleanup-preflight-failures.ts b/src/renderer/src/store/slices/workspace-cleanup-preflight-failures.ts new file mode 100644 index 00000000000..11b3c776468 --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup-preflight-failures.ts @@ -0,0 +1,106 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { + WorkspaceCleanupCandidate, + WorkspaceCleanupScanError, + WorkspaceCleanupUnverifiedRemovalConsent +} from '../../../../shared/workspace-cleanup' +import { shouldForceWorkspaceCleanupRemoval } from '../../../../shared/workspace-cleanup' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupFailure } from './workspace-cleanup' + +type PreflightFailureTarget = { + worktreeId: string + executionHostId: ExecutionHostId | null + displayName: string +} + +export function getWorkspaceCleanupMissingFailure( + target: PreflightFailureTarget +): WorkspaceCleanupFailure { + return { + worktreeId: target.worktreeId, + ...(target.executionHostId ? { executionHostId: target.executionHostId } : {}), + displayName: target.displayName, + message: translate( + 'auto.store.slices.workspace.cleanup.9d6e531da6', + 'Workspace no longer exists.' + ) + } +} + +export function hasWorkspaceCleanupRiskEscalated( + candidate: WorkspaceCleanupCandidate, + approvedCandidate: WorkspaceCleanupCandidate +): boolean { + return ( + (shouldForceWorkspaceCleanupRemoval(candidate) && + !shouldForceWorkspaceCleanupRemoval(approvedCandidate)) || + WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS.some( + (blocker) => + candidate.blockers.includes(blocker) && !approvedCandidate.blockers.includes(blocker) + ) + ) +} + +export function hasValidWorkspaceCleanupUnverifiedConsent( + candidateIdentity: string, + consent: WorkspaceCleanupUnverifiedRemovalConsent | undefined, + getConsentAttemptId: ((identity: string) => string | undefined) | undefined +): boolean { + return Boolean( + consent && + consent.identity === candidateIdentity && + getConsentAttemptId?.(consent.identity) === consent.attemptId + ) +} + +export function getWorkspaceCleanupRepoScanFailure( + target: PreflightFailureTarget, + errors: readonly WorkspaceCleanupScanError[] +): WorkspaceCleanupFailure | null { + const error = errors.find( + (entry) => + entry.repoId === getRepoIdFromWorktreeId(target.worktreeId) && + (!entry.executionHostId || + target.executionHostId === null || + entry.executionHostId === target.executionHostId) + ) + if (!error) { + return null + } + return { + worktreeId: target.worktreeId, + ...(target.executionHostId ? { executionHostId: target.executionHostId } : {}), + displayName: target.displayName, + message: error.executionHostId + ? translate( + 'auto.store.slices.workspace.cleanup.gitStatusUnavailable', + "Orca couldn't check this workspace's git status. Try again, or delete it from its host-specific sidebar or project list." + ) + : translate( + 'auto.store.slices.workspace.cleanup.gitStatusUnavailableOlderPeer', + "Orca couldn't match this git-status failure to a host. Update the older connected peer, or delete the workspace from its host-specific sidebar or project list." + ), + canDeleteAnyway: true + } +} + +export function getWorkspaceCleanupGitUnavailableFailure( + target: PreflightFailureTarget, + candidate: WorkspaceCleanupCandidate +): WorkspaceCleanupFailure { + return { + worktreeId: target.worktreeId, + ...(target.executionHostId ? { executionHostId: target.executionHostId } : {}), + displayName: candidate.displayName, + message: translate( + 'auto.store.slices.workspace.cleanup.gitStatusUnavailable', + "Orca couldn't check this workspace's git status. Try again, or delete it from its host-specific sidebar or project list." + ), + canDeleteAnyway: true + } +} + +// Unlike unknown-base and git-status-error, these facts prove known work is at risk. +const WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS = ['dirty-files', 'unpushed-commits'] as const diff --git a/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts index 6b2d1bbe493..d9e656c3242 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts @@ -37,9 +37,7 @@ describe('workspace cleanup removal and protection', () => { worktreeId: 'repo-c::/other', repoId: 'repo-c', path: '/other', - displayName: 'other', - git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }, - blockers: ['git-status-error'] + displayName: 'other' }) ] const candidateById = new Map(candidates.map((candidate) => [candidate.worktreeId, candidate])) @@ -90,7 +88,7 @@ describe('workspace cleanup removal and protection', () => { // internal-caller compatibility path leaves the host unqualified. expect(removeWorktree).toHaveBeenCalledWith( { id: 'repo-c::/other', executionHostId: null }, - true, + false, { suppressPreservedBranchToast: true } @@ -101,7 +99,11 @@ describe('workspace cleanup removal and protection', () => { it('returns preserved branches for the cleanup batch summary', async () => { const candidate = makeCandidate() installWorkspaceCleanupApi( - vi.fn(async () => ({ scannedAt: NOW, candidates: [candidate], errors: [] })) + vi.fn(async () => ({ + scannedAt: NOW, + candidates: [candidate], + errors: [] + })) ) const removeWorktree = vi.fn().mockResolvedValue({ ok: true, @@ -109,7 +111,11 @@ describe('workspace cleanup removal and protection', () => { }) const store = createCleanupTestStore(removeWorktree) store.setState({ - workspaceCleanupScan: { scannedAt: NOW, candidates: [candidate], errors: [] } + workspaceCleanupScan: { + scannedAt: NOW, + candidates: [candidate], + errors: [] + } } as Partial) await expect( @@ -138,7 +144,11 @@ describe('workspace cleanup removal and protection', () => { it('forwards the snapshot batch through each successful removal', async () => { const candidate = makeCandidate({ executionHostId: 'ssh:ssh-1' }) installWorkspaceCleanupApi( - vi.fn(async () => ({ scannedAt: NOW, candidates: [candidate], errors: [] })) + vi.fn(async () => ({ + scannedAt: NOW, + candidates: [candidate], + errors: [] + })) ) const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) const store = createCleanupTestStore(removeWorktree) @@ -176,8 +186,7 @@ describe('workspace cleanup removal and protection', () => { workspaceCleanupViewedCandidates: { [WORKTREE_ID]: { viewedAt: Date.now(), - fingerprint: 'fingerprint-1', - wasSuggested: true + fingerprint: 'fingerprint-1' } } }), @@ -197,8 +206,7 @@ describe('workspace cleanup removal and protection', () => { workspaceCleanupViewedCandidates: { [WORKTREE_ID]: { viewedAt: Date.now(), - fingerprint: 'fingerprint-1', - wasSuggested: true + fingerprint: 'fingerprint-1' } } }), @@ -335,7 +343,9 @@ describe('workspace cleanup removal and protection', () => { const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) const store = createCleanupTestStore(removeWorktree) - store.setState({ activeWorktreeId: 'repo1::/tmp/other-workspace' } as Partial) + store.setState({ + activeWorktreeId: 'repo1::/tmp/other-workspace' + } as Partial) await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID]) @@ -393,7 +403,12 @@ describe('workspace cleanup removal and protection', () => { executionHostId: 'local', tier: 'review', blockers: ['dirty-files'], - git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW } + git: { + clean: false, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: NOW + } }) const scan = vi.fn().mockResolvedValue({ scannedAt: NOW, @@ -428,7 +443,12 @@ describe('workspace cleanup removal and protection', () => { executionHostId: 'local', tier: 'review', blockers: ['dirty-files'], - git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW } + git: { + clean: false, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: NOW + } }) const scan = vi.fn().mockResolvedValue({ scannedAt: NOW, @@ -462,7 +482,12 @@ describe('workspace cleanup removal and protection', () => { executionHostId: 'local', tier: 'review', blockers: ['git-status-error'], - git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } }) const nowRevealsUnpushed = makeCandidate({ executionHostId: 'local', diff --git a/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts b/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts index 866b05839e2..49f8814ca62 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts @@ -15,7 +15,9 @@ import { canQueueWorkspaceCleanupCandidate, shouldForceWorkspaceCleanupRemoval, WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT, - type WorkspaceCleanupCandidate + type WorkspaceCleanupCandidate, + type WorkspaceCleanupScanError, + type WorkspaceCleanupUnverifiedRemovalConsent } from '../../../../shared/workspace-cleanup' import { getWorkspaceCleanupCandidateIdentity, @@ -25,6 +27,13 @@ import { import { getWorktreeOperationOwnerHostIds } from '@/lib/worktree-operation-route' import { translate } from '@/i18n/i18n' import type { WorkspaceCleanupFailure } from './workspace-cleanup' +import { + getWorkspaceCleanupGitUnavailableFailure, + getWorkspaceCleanupMissingFailure, + getWorkspaceCleanupRepoScanFailure, + hasValidWorkspaceCleanupUnverifiedConsent, + hasWorkspaceCleanupRiskEscalated +} from './workspace-cleanup-preflight-failures' /** Distinct from every ExecutionHostId, so a hostless row cannot alias one. */ const UNQUALIFIED_HOST_BUCKET = Symbol('unqualified-cleanup-host') @@ -165,12 +174,17 @@ export async function preflightWorkspaceCleanupCandidates( enrich: ( candidates: readonly WorkspaceCleanupCandidate[], state: AppState - ) => Promise + ) => Promise, + options: { + unverifiedRemovalConsent?: WorkspaceCleanupUnverifiedRemovalConsent + getConsentAttemptId?: (identity: string) => string | undefined + } = {} ): Promise { // Why: one batched scan per chunk replaces a git worktree-list + activity // read per row; chunks stay under main's silent target truncation limit. const candidatesByIdentity = new Map() const identitiesByWorktreeId = new Map>() + const scanErrors: WorkspaceCleanupScanError[] = [] const worktreeIds = targets.map((target) => target.worktreeId) for (let start = 0; start < worktreeIds.length; start += WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT) { const chunk = worktreeIds.slice(start, start + WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT) @@ -180,6 +194,7 @@ export async function preflightWorkspaceCleanupCandidates( refreshActivity: true }) const enriched = await enrich(scan.candidates, getState()) + scanErrors.push(...scan.errors) for (const candidate of enriched) { const identity = getWorkspaceCleanupCandidateIdentity(candidate) candidatesByIdentity.set(identity, candidate) @@ -189,7 +204,13 @@ export async function preflightWorkspaceCleanupCandidates( } } return targets.map((target) => - evaluateWorkspaceCleanupPreflight(target, candidatesByIdentity, identitiesByWorktreeId) + evaluateWorkspaceCleanupPreflight( + target, + candidatesByIdentity, + identitiesByWorktreeId, + scanErrors, + options + ) ) } @@ -214,13 +235,21 @@ function resolvePreflightCandidate( return { ok: false } } const identity = identities?.values().next().value - return { ok: true, candidate: identity ? candidatesByIdentity.get(identity) : undefined } + return { + ok: true, + candidate: identity ? candidatesByIdentity.get(identity) : undefined + } } export function evaluateWorkspaceCleanupPreflight( target: WorkspaceCleanupRemovalTarget, candidatesByIdentity: ReadonlyMap, - identitiesByWorktreeId: ReadonlyMap> + identitiesByWorktreeId: ReadonlyMap>, + scanErrors: readonly WorkspaceCleanupScanError[] = [], + options: { + unverifiedRemovalConsent?: WorkspaceCleanupUnverifiedRemovalConsent + getConsentAttemptId?: (identity: string) => string | undefined + } = {} ): WorkspaceCleanupPreflightResult { const resolved = resolvePreflightCandidate(target, candidatesByIdentity, identitiesByWorktreeId) if (!resolved.ok) { @@ -229,20 +258,21 @@ export function evaluateWorkspaceCleanupPreflight( failure: ambiguousHostFailure(target.worktreeId, target.displayName).failure } } - const candidate = resolved.candidate + const repoScanFailure = getWorkspaceCleanupRepoScanFailure(target, scanErrors) + const consentReference = resolved.candidate ?? target.approvedCandidate + const hasUnverifiedRemovalConsent = hasValidWorkspaceCleanupUnverifiedConsent( + consentReference ? getWorkspaceCleanupCandidateIdentity(consentReference) : '', + options.unverifiedRemovalConsent, + options.getConsentAttemptId + ) + if (repoScanFailure && !hasUnverifiedRemovalConsent) { + return { ok: false, failure: repoScanFailure } + } + const candidate = + resolved.candidate ?? + (repoScanFailure && hasUnverifiedRemovalConsent ? target.approvedCandidate : undefined) if (!candidate) { - return { - ok: false, - failure: { - worktreeId: target.worktreeId, - ...(target.executionHostId ? { executionHostId: target.executionHostId } : {}), - displayName: target.displayName, - message: translate( - 'auto.store.slices.workspace.cleanup.9d6e531da6', - 'Workspace no longer exists.' - ) - } - } + return { ok: false, failure: getWorkspaceCleanupMissingFailure(target) } } const failure = (message: string): WorkspaceCleanupPreflightResult => ({ ok: false, @@ -260,21 +290,30 @@ export function evaluateWorkspaceCleanupPreflight( : 'Workspace needs another look before removal.' ) } - // Why: this row may be removed minutes after the confirm click. If it now - // needs a force removal the user never approved (new dirt, unpushed work, - // or a git error since confirmation), fail it instead of force-deleting. + const candidateIdentity = getWorkspaceCleanupCandidateIdentity(candidate) + if ( + target.approvedCandidate && + candidateIdentity !== getWorkspaceCleanupCandidateIdentity(target.approvedCandidate) + ) { + return { ok: false, failure: getWorkspaceCleanupMissingFailure(target) } + } + if (candidate.blockers.includes('git-status-error') && !hasUnverifiedRemovalConsent) { + return { + ok: false, + failure: getWorkspaceCleanupGitUnavailableFailure(target, candidate) + } + } + if (!target.approvedCandidate && shouldForceWorkspaceCleanupRemoval(candidate)) { + return failure( + translate( + 'auto.store.slices.workspace.cleanup.forceNeedsApproval', + 'Review and confirm this workspace before force deleting it.' + ) + ) + } const approvedCandidate = target.approvedCandidate if (approvedCandidate) { - const escalatedToForce = - shouldForceWorkspaceCleanupRemoval(candidate) && - !shouldForceWorkspaceCleanupRemoval(approvedCandidate) - // Why: an approved row that was already force-flagged for an unverifiable - // reason must still fail when real dirt/unpushed work is now visible. - const revealedConcreteRisk = WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS.some( - (blocker) => - candidate.blockers.includes(blocker) && !approvedCandidate.blockers.includes(blocker) - ) - if (escalatedToForce || revealedConcreteRisk) { + if (hasWorkspaceCleanupRiskEscalated(candidate, approvedCandidate)) { return failure( translate( 'auto.store.slices.workspace.cleanup.changedSinceConfirmation', @@ -283,9 +322,8 @@ export function evaluateWorkspaceCleanupPreflight( ) } } - const removedIdentity = getWorkspaceCleanupCandidateIdentity(candidate) const sameIdSurvivingHostId = [...(identitiesByWorktreeId.get(target.worktreeId) ?? [])] - .filter((identity) => identity !== removedIdentity) + .filter((identity) => identity !== candidateIdentity) .map((identity) => candidatesByIdentity.get(identity)) .map((otherCandidate) => otherCandidate ? resolveWorkspaceCleanupRemovalHostId(otherCandidate) : null @@ -298,8 +336,3 @@ export function evaluateWorkspaceCleanupPreflight( ...(sameIdSurvivingHostId ? { sameIdSurvivingHostId } : {}) } } - -// Why: dirty-files/unpushed-commits are concrete known work at risk; unknown-base -// and git-status-error only mean "couldn't verify". A row approved while -// unverifiable must still fail if real work becomes visible before removal. -const WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS = ['dirty-files', 'unpushed-commits'] as const diff --git a/src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts b/src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts index 463809f08cf..07f3e660645 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts @@ -1,7 +1,10 @@ import { create } from 'zustand' import { vi } from 'vitest' import type { AppState } from '../types' -import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + applyWorkspaceCleanupPolicy, + type WorkspaceCleanupCandidate +} from '../../../../shared/workspace-cleanup' import { createWorkspaceCleanupSlice } from './workspace-cleanup' export const WORKTREE_ID = 'repo1::/tmp/old-workspace' @@ -10,7 +13,7 @@ export const NOW = 1_700_000_000_000 export function makeCandidate( overrides: Partial = {} ): WorkspaceCleanupCandidate { - return { + return applyWorkspaceCleanupPolicy({ worktreeId: WORKTREE_ID, repoId: 'repo1', repoName: 'Repo 1', @@ -20,8 +23,6 @@ export function makeCandidate( displayName: 'old-workspace', branch: 'old-workspace', path: '/tmp/old-workspace', - tier: 'ready', - selectedByDefault: true, reasons: ['idle-clean'], blockers: [], lastActivityAt: NOW - 30 * 24 * 60 * 60 * 1000, @@ -41,7 +42,7 @@ export function makeCandidate( }, fingerprint: 'fingerprint-1', ...overrides - } + }) } export function makeState(overrides: Partial = {}): AppState { diff --git a/src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts b/src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts new file mode 100644 index 00000000000..1cdf9a5388f --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts @@ -0,0 +1,416 @@ +import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity' +import { describe, expect, it, vi } from 'vitest' +import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' +import { + NOW, + WORKTREE_ID, + createCleanupTestStore, + deferred, + installWorkspaceCleanupApi, + makeCandidate +} from './workspace-cleanup-slice-test-harness' + +describe('workspace cleanup removal verification and consent', () => { + it.each([null, NOW])( + 'fails closed when git evidence is unavailable with checkedAt=%s', + async (checkedAt) => { + const candidate = makeCandidate({ + executionHostId: 'local', + blockers: ['git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt + } + }) + installWorkspaceCleanupApi( + vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [candidate], + errors: [] + }) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [candidate] + }) + + expect(result.failures).toEqual([ + expect.objectContaining({ + worktreeId: WORKTREE_ID, + canDeleteAnyway: true, + message: expect.stringContaining("couldn't check this workspace's git status") + }) + ]) + expect(removeWorktree).not.toHaveBeenCalled() + } + ) + + it('scopes repo-level scan failures to their execution host', async () => { + const local = makeCandidate({ executionHostId: 'local' }) + const remote = makeCandidate({ + executionHostId: 'ssh:remote', + displayName: 'remote' + }) + installWorkspaceCleanupApi( + vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [local], + errors: [ + { + repoId: local.repoId, + repoName: local.repoName, + executionHostId: 'ssh:remote', + message: 'Git could not list worktrees.' + } + ] + }) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + const result = await store + .getState() + .removeWorkspaceCleanupCandidates([WORKTREE_ID, WORKTREE_ID], { + approvedCandidates: [local, remote] + }) + + expect(result.removedIdentities).toEqual([getWorkspaceCleanupCandidateIdentity(local)]) + expect(result.failures).toEqual([ + expect.objectContaining({ + executionHostId: 'ssh:remote', + displayName: 'remote' + }) + ]) + expect(removeWorktree).toHaveBeenCalledTimes(1) + }) + + it('fails every same-repo host when an older peer omits the error host', async () => { + const local = makeCandidate({ executionHostId: 'local' }) + const remote = makeCandidate({ + executionHostId: 'ssh:remote', + displayName: 'remote' + }) + installWorkspaceCleanupApi( + vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [local, remote], + errors: [ + { + repoId: local.repoId, + repoName: local.repoName, + message: 'Git could not list worktrees.' + } + ] + }) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + const result = await store + .getState() + .removeWorkspaceCleanupCandidates([WORKTREE_ID, WORKTREE_ID], { + approvedCandidates: [local, remote] + }) + + expect(result.failures).toHaveLength(2) + expect( + result.failures.every((failure) => failure.message.includes('older connected peer')) + ).toBe(true) + expect(removeWorktree).not.toHaveBeenCalled() + }) + + it('honors one matching unverified-removal consent and rejects its replay', async () => { + const candidate = makeCandidate({ + executionHostId: 'local', + blockers: ['git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }) + installWorkspaceCleanupApi( + vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [candidate], + errors: [] + }) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + const identity = getWorkspaceCleanupCandidateIdentity(candidate) + const attemptId = store.getState().beginUnverifiedRemovalConsent(identity) + expect(attemptId).toEqual(expect.any(String)) + expect(store.getState().beginUnverifiedRemovalConsent(identity)).toBeNull() + + const first = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [candidate], + unverifiedRemovalConsent: { identity, attemptId: attemptId! } + }) + const replay = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [candidate], + unverifiedRemovalConsent: { identity, attemptId: attemptId! } + }) + + expect(first.removedIdentities).toEqual([identity]) + expect(replay.failures).toEqual([expect.objectContaining({ canDeleteAnyway: true })]) + expect(removeWorktree).toHaveBeenCalledTimes(1) + expect(store.getState().beginUnverifiedRemovalConsent(identity)).toEqual(expect.any(String)) + }) + + it("does not let one row's consent authorize a different identity", async () => { + const first = makeCandidate({ + executionHostId: 'local', + blockers: ['git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }) + const second = makeCandidate({ + worktreeId: 'repo1::/tmp/second', + path: '/tmp/second', + displayName: 'second', + executionHostId: 'local', + blockers: ['git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }) + installWorkspaceCleanupApi( + vi.fn(async (args: { worktreeIds?: string[] }) => ({ + scannedAt: NOW, + candidates: [first, second].filter((candidate) => + args.worktreeIds?.includes(candidate.worktreeId) + ), + errors: [] + })) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + const identity = getWorkspaceCleanupCandidateIdentity(first) + const attemptId = store.getState().beginUnverifiedRemovalConsent(identity)! + + const result = await store.getState().removeWorkspaceCleanupCandidates([second.worktreeId], { + approvedCandidates: [second], + unverifiedRemovalConsent: { identity, attemptId } + }) + + expect(result.failures).toEqual([expect.objectContaining({ worktreeId: second.worktreeId })]) + expect(removeWorktree).not.toHaveBeenCalled() + }) + + it('fails closed when the workspace host changes before consent is consumed', async () => { + const approved = makeCandidate({ + executionHostId: 'local', + blockers: ['git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }) + const moved = makeCandidate({ ...approved, executionHostId: 'ssh:remote' }) + installWorkspaceCleanupApi( + vi.fn().mockResolvedValue({ scannedAt: NOW, candidates: [moved], errors: [] }) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + const identity = getWorkspaceCleanupCandidateIdentity(approved) + const attemptId = store.getState().beginUnverifiedRemovalConsent(identity)! + + const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [approved], + unverifiedRemovalConsent: { identity, attemptId } + }) + + expect(result.failures).toEqual([ + expect.objectContaining({ message: 'Workspace no longer exists.' }) + ]) + expect(removeWorktree).not.toHaveBeenCalled() + }) + + it('keeps a live consent bound to its call when dialog state is cleared', async () => { + const candidate = makeCandidate({ + executionHostId: 'local', + blockers: ['git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }) + const scan = deferred() + installWorkspaceCleanupApi(vi.fn(() => scan.promise)) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + const identity = getWorkspaceCleanupCandidateIdentity(candidate) + const attemptId = store.getState().beginUnverifiedRemovalConsent(identity)! + const removal = store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [candidate], + unverifiedRemovalConsent: { identity, attemptId } + }) + + store.setState({ workspaceCleanupScan: null }) + expect(store.getState().beginUnverifiedRemovalConsent(identity)).toBeNull() + scan.resolve({ scannedAt: NOW, candidates: [candidate], errors: [] }) + + await expect(removal).resolves.toEqual( + expect.objectContaining({ removedIdentities: [identity], failures: [] }) + ) + }) + + it('releases consent when an earlier queue guard refuses the row', async () => { + const candidate = makeCandidate({ + executionHostId: 'local', + blockers: ['main-worktree', 'git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }) + installWorkspaceCleanupApi( + vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [candidate], + errors: [] + }) + ) + const store = createCleanupTestStore() + const identity = getWorkspaceCleanupCandidateIdentity(candidate) + const attemptId = store.getState().beginUnverifiedRemovalConsent(identity)! + + const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [candidate], + unverifiedRemovalConsent: { identity, attemptId } + }) + + expect(result.failures).toHaveLength(1) + expect(store.getState().beginUnverifiedRemovalConsent(identity)).toEqual(expect.any(String)) + }) + + it('allows different identities to hold unverified-removal grants concurrently', async () => { + const candidates = [ + makeCandidate({ + executionHostId: 'local', + blockers: ['git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }), + makeCandidate({ + worktreeId: 'repo1::/tmp/second', + path: '/tmp/second', + displayName: 'second', + executionHostId: 'local', + blockers: ['git-status-error'], + git: { + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + } + }) + ] + installWorkspaceCleanupApi( + vi.fn(async (args: { worktreeIds?: string[] }) => ({ + scannedAt: NOW, + candidates: candidates.filter((candidate) => + args.worktreeIds?.includes(candidate.worktreeId) + ), + errors: [] + })) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + const consents = candidates.map((candidate) => { + const identity = getWorkspaceCleanupCandidateIdentity(candidate) + return { + identity, + attemptId: store.getState().beginUnverifiedRemovalConsent(identity)! + } + }) + + const results = await Promise.all( + candidates.map((candidate, index) => + store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId], { + approvedCandidates: [candidate], + unverifiedRemovalConsent: consents[index] + }) + ) + ) + + expect(results.flatMap((result) => result.removedIdentities)).toEqual( + expect.arrayContaining(candidates.map(getWorkspaceCleanupCandidateIdentity)) + ) + expect(removeWorktree).toHaveBeenCalledTimes(2) + }) + + it('requires recorded approval before an id-only force removal', async () => { + const candidate = makeCandidate({ + blockers: ['unknown-base'], + git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW } + }) + installWorkspaceCleanupApi( + vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [candidate], + errors: [] + }) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID]) + + expect(result.failures).toEqual([ + expect.objectContaining({ + message: 'Review and confirm this workspace before force deleting it.' + }) + ]) + expect(removeWorktree).not.toHaveBeenCalled() + }) + + it.each([ + ['dismissed', makeCandidate({ executionHostId: 'local', blockers: ['dismissed'] })], + ['recently active', makeCandidate({ executionHostId: 'local', reasons: [] })] + ])('removes an explicitly selected %s row', async (_label, approvedCandidate) => { + const freshCandidate = makeCandidate({ + ...approvedCandidate, + blockers: approvedCandidate.blockers.filter((blocker) => blocker !== 'dismissed') + }) + installWorkspaceCleanupApi( + vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [freshCandidate], + errors: [] + }) + ) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [approvedCandidate] + }) + + expect(result.removedIds).toEqual([WORKTREE_ID]) + expect(removeWorktree).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index 063bd9d1d4f..36cfc56bc40 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -11,7 +11,6 @@ import { import { WORKSPACE_CLEANUP_CLASSIFIER_VERSION, applyWorkspaceCleanupPolicy, - canSelectWorkspaceCleanupCandidate, shouldForceWorkspaceCleanupRemoval, shouldHideWorkspaceCleanupCandidate, type WorkspaceCleanupBlocker, @@ -19,7 +18,8 @@ import { type WorkspaceCleanupDismissal, type WorkspaceCleanupScanArgs, type WorkspaceCleanupScanProgress, - type WorkspaceCleanupScanResult + type WorkspaceCleanupScanResult, + type WorkspaceCleanupUnverifiedRemovalConsent } from '../../../../shared/workspace-cleanup' import { getWorkspaceCleanupCandidateHostId, @@ -52,6 +52,7 @@ export type WorkspaceCleanupFailure = { executionHostId?: ExecutionHostId displayName: string message: string + canDeleteAnyway?: boolean } export type WorkspaceCleanupRemoveResult = { @@ -66,13 +67,13 @@ export type WorkspaceCleanupRemoveOptions = { // Why: rows are removed long after the confirm click; the confirm-time // candidate records how much git risk the user actually approved. approvedCandidates?: readonly WorkspaceCleanupCandidate[] + unverifiedRemovalConsent?: WorkspaceCleanupUnverifiedRemovalConsent snapshotPruneBatchId?: string } type WorkspaceCleanupViewedCandidate = { viewedAt: number fingerprint: string - wasSuggested: boolean } export type WorkspaceCleanupSlice = { @@ -90,6 +91,7 @@ export type WorkspaceCleanupSlice = { candidates: readonly WorkspaceCleanupCandidate[] ) => Promise resetWorkspaceCleanupDismissals: () => Promise + beginUnverifiedRemovalConsent: (identity: string) => string | null removeWorkspaceCleanupCandidates: ( worktreeIds: readonly string[], options?: WorkspaceCleanupRemoveOptions @@ -163,6 +165,18 @@ const AGENT_PROCESS_NAMES = new Set([ 'opencode' ]) +const unverifiedRemovalConsentByStore = new WeakMap<() => AppState, Map>() + +function getUnverifiedRemovalConsentMap(getState: () => AppState): Map { + const existing = unverifiedRemovalConsentByStore.get(getState) + if (existing) { + return existing + } + const created = new Map() + unverifiedRemovalConsentByStore.set(getState, created) + return created +} + export const createWorkspaceCleanupSlice: StateCreator = ( set, get @@ -228,7 +242,11 @@ export const createWorkspaceCleanupSlice: StateCreator { try { @@ -273,7 +291,10 @@ export const createWorkspaceCleanupSlice: StateCreator { + const unverifiedRemovalConsentByIdentity = getUnverifiedRemovalConsentMap(get) + if (unverifiedRemovalConsentByIdentity.has(identity)) { + return null + } + const attemptId = crypto.randomUUID() + unverifiedRemovalConsentByIdentity.set(identity, attemptId) + return attemptId + }, + removeWorkspaceCleanupCandidates: async (worktreeIds, options) => { - const removedIds: string[] = [] - const removedIdentities = new Set() - const failures: WorkspaceCleanupFailure[] = [] - const preservedBranches: PreservedBranchCleanup[] = [] + const unverifiedRemovalConsentByIdentity = getUnverifiedRemovalConsentMap(get) + const consent = options?.unverifiedRemovalConsent + try { + const removedIds: string[] = [] + const removedIdentities = new Set() + const failures: WorkspaceCleanupFailure[] = [] + const preservedBranches: PreservedBranchCleanup[] = [] - // Why (STA-4343): the confirmed row — not the id — names the host to delete - // on. Everything below carries that owner so nothing re-derives it from the - // active workspace, which owns the same `repoId::path` id on another host. - const targets = resolveWorkspaceCleanupRemovalTargets( - worktreeIds, - get(), - options?.approvedCandidates ? { approvedCandidates: options.approvedCandidates } : {} - ) - const removableTargets: WorkspaceCleanupRemovalTarget[] = [] - for (const target of targets) { - if (target.kind === 'unresolved') { - failures.push(target.failure) - continue + // Why (STA-4343): the confirmed row — not the id — names the host to delete + // on. Everything below carries that owner so nothing re-derives it from the + // active workspace, which owns the same `repoId::path` id on another host. + const targets = resolveWorkspaceCleanupRemovalTargets( + worktreeIds, + get(), + options?.approvedCandidates ? { approvedCandidates: options.approvedCandidates } : {} + ) + const removableTargets: WorkspaceCleanupRemovalTarget[] = [] + for (const target of targets) { + if (target.kind === 'unresolved') { + failures.push(target.failure) + continue + } + removableTargets.push(target) } - removableTargets.push(target) - } - const preflights = await preflightWorkspaceCleanupCandidates( - removableTargets, - get, - (candidates, state) => - enrichWorkspaceCleanupCandidates(candidates, state, { applyDismissals: false }) - ) - const targetsToRemove: { - target: WorkspaceCleanupRemovalTarget - candidate: WorkspaceCleanupCandidate - sameIdSurvivingHostId?: ExecutionHostId - ignoreWorkspaceCleanupScanSurvivors?: boolean - }[] = [] - - for (const preflight of preflights) { - if (!preflight.ok) { - failures.push(preflight.failure) - continue - } - targetsToRemove.push({ - target: preflight.target, - candidate: preflight.candidate, - ...(preflight.sameIdSurvivingHostId - ? { sameIdSurvivingHostId: preflight.sameIdSurvivingHostId } - : {}) - }) - } - const scheduledRemovalIdentities = new Set( - targetsToRemove.map(({ candidate }) => getWorkspaceCleanupCandidateIdentity(candidate)) - ) - for (const pendingRemoval of targetsToRemove) { - if ( - pendingRemoval.sameIdSurvivingHostId && - scheduledRemovalIdentities.has( - getWorkspaceCleanupHostIdentity( - pendingRemoval.sameIdSurvivingHostId, - pendingRemoval.candidate.worktreeId - ) - ) - ) { - delete pendingRemoval.sameIdSurvivingHostId - pendingRemoval.ignoreWorkspaceCleanupScanSurvivors = true - } - } - - // Why: nested workspaces can belong to different repos; parent removal must - // not race child cleanup hooks, PTY teardown, or metadata deletion. - for (const { - target, - candidate, - sameIdSurvivingHostId, - ignoreWorkspaceCleanupScanSurvivors - } of [...targetsToRemove].sort((a, b) => b.candidate.path.length - a.candidate.path.length)) { - const result = await get().removeWorktree( - // The resolved target names the host whose row the user confirmed; the - // removal is routed there instead of to the active workspace's host. - { id: candidate.worktreeId, executionHostId: target.executionHostId }, - shouldForceWorkspaceCleanupRemoval(candidate), - // Why: cleanup reports outcomes in its own summary toasts; per-row - // preserved-branch warnings would stack one toast per removed row. + const preflights = await preflightWorkspaceCleanupCandidates( + removableTargets, + get, + (candidates, state) => + enrichWorkspaceCleanupCandidates(candidates, state, { + applyDismissals: false + }), { - suppressPreservedBranchToast: true, - ...(sameIdSurvivingHostId ? { sameIdSurvivingHostId } : {}), - ...(ignoreWorkspaceCleanupScanSurvivors - ? { ignoreWorkspaceCleanupScanSurvivors: true } - : {}), - ...(options?.snapshotPruneBatchId - ? { snapshotPruneBatchId: options.snapshotPruneBatchId } - : {}) + ...(consent ? { unverifiedRemovalConsent: consent } : {}), + getConsentAttemptId: (identity) => unverifiedRemovalConsentByIdentity.get(identity) } ) - if (result.ok) { - removedIds.push(candidate.worktreeId) - removedIdentities.add(getWorkspaceCleanupCandidateIdentity(candidate)) - if (result.preservedBranch) { - preservedBranches.push({ - worktreeId: candidate.worktreeId, - branchName: result.preservedBranch.branchName, - expectedHead: result.preservedBranch.head, - ...(result.preservedBranch.hostId ? { hostId: result.preservedBranch.hostId } : {}), - ...(result.preservedBranch.runtimeEnvironmentId - ? { runtimeEnvironmentId: result.preservedBranch.runtimeEnvironmentId } - : {}) - }) + const targetsToRemove: { + target: WorkspaceCleanupRemovalTarget + candidate: WorkspaceCleanupCandidate + sameIdSurvivingHostId?: ExecutionHostId + ignoreWorkspaceCleanupScanSurvivors?: boolean + }[] = [] + + for (const preflight of preflights) { + if (!preflight.ok) { + failures.push(preflight.failure) + continue } - } else { - failures.push({ - worktreeId: candidate.worktreeId, - ...(target.executionHostId ? { executionHostId: target.executionHostId } : {}), - displayName: candidate.displayName, - message: result.error + targetsToRemove.push({ + target: preflight.target, + candidate: preflight.candidate, + ...(preflight.sameIdSurvivingHostId + ? { sameIdSurvivingHostId: preflight.sameIdSurvivingHostId } + : {}) }) } - } - - if (removedIds.length > 0) { - invalidateWorkspaceCleanupScanProgress() - let prunableWorktreeIds = new Set(removedIds) - set((state) => { - const remainingCandidates = state.workspaceCleanupScan?.candidates.filter( - (candidate) => !removedIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate)) - ) - // Why: a same-id row on another host survives this removal, and its - // dismissal/viewed marks are keyed by worktree id alone — dropping them - // would resurrect a row the user already ignored. - const survivingWorktreeIds = new Set( - (remainingCandidates ?? []).map((candidate) => candidate.worktreeId) - ) - prunableWorktreeIds = new Set( - removedIds.filter((worktreeId) => !survivingWorktreeIds.has(worktreeId)) - ) - return { - workspaceCleanupLoading: false, - workspaceCleanupScan: - state.workspaceCleanupScan && remainingCandidates - ? { ...state.workspaceCleanupScan, candidates: remainingCandidates } - : state.workspaceCleanupScan, - // Why: dismissals and viewed marks for removed worktrees are dead - // weight in the store and in every persisted-dismissals write. - workspaceCleanupDismissals: pruneWorkspaceCleanupDismissals( - state.workspaceCleanupDismissals, - prunableWorktreeIds - ), - workspaceCleanupViewedCandidates: pruneWorkspaceCleanupRecord( - state.workspaceCleanupViewedCandidates, - prunableWorktreeIds + const scheduledRemovalIdentities = new Set( + targetsToRemove.map(({ candidate }) => getWorkspaceCleanupCandidateIdentity(candidate)) + ) + for (const pendingRemoval of targetsToRemove) { + if ( + pendingRemoval.sameIdSurvivingHostId && + scheduledRemovalIdentities.has( + getWorkspaceCleanupHostIdentity( + pendingRemoval.sameIdSurvivingHostId, + pendingRemoval.candidate.worktreeId + ) ) + ) { + delete pendingRemoval.sameIdSurvivingHostId + pendingRemoval.ignoreWorkspaceCleanupScanSurvivors = true } - }) - if (prunableWorktreeIds.size > 0) { - void window.api.workspaceCleanup - .dismiss({ dismissals: [], removedWorktreeIds: [...prunableWorktreeIds] }) - .catch((error: unknown) => { - console.warn('Failed to prune persisted cleanup dismissals', error) - }) } - } - return { - removedIds, - removedIdentities: [...removedIdentities], - failures, - ...(preservedBranches.length > 0 ? { preservedBranches } : {}) + // Why: nested workspaces can belong to different repos; parent removal must + // not race child cleanup hooks, PTY teardown, or metadata deletion. + for (const { + target, + candidate, + sameIdSurvivingHostId, + ignoreWorkspaceCleanupScanSurvivors + } of [...targetsToRemove].sort((a, b) => b.candidate.path.length - a.candidate.path.length)) { + const result = await get().removeWorktree( + // The resolved target names the host whose row the user confirmed; the + // removal is routed there instead of to the active workspace's host. + { + id: candidate.worktreeId, + executionHostId: target.executionHostId + }, + shouldForceWorkspaceCleanupRemoval(candidate), + // Why: cleanup reports outcomes in its own summary toasts; per-row + // preserved-branch warnings would stack one toast per removed row. + { + suppressPreservedBranchToast: true, + ...(sameIdSurvivingHostId ? { sameIdSurvivingHostId } : {}), + ...(ignoreWorkspaceCleanupScanSurvivors + ? { ignoreWorkspaceCleanupScanSurvivors: true } + : {}), + ...(options?.snapshotPruneBatchId + ? { snapshotPruneBatchId: options.snapshotPruneBatchId } + : {}) + } + ) + if (result.ok) { + removedIds.push(candidate.worktreeId) + removedIdentities.add(getWorkspaceCleanupCandidateIdentity(candidate)) + if (result.preservedBranch) { + preservedBranches.push({ + worktreeId: candidate.worktreeId, + branchName: result.preservedBranch.branchName, + expectedHead: result.preservedBranch.head, + ...(result.preservedBranch.hostId ? { hostId: result.preservedBranch.hostId } : {}), + ...(result.preservedBranch.runtimeEnvironmentId + ? { + runtimeEnvironmentId: result.preservedBranch.runtimeEnvironmentId + } + : {}) + }) + } + } else { + failures.push({ + worktreeId: candidate.worktreeId, + ...(target.executionHostId ? { executionHostId: target.executionHostId } : {}), + displayName: candidate.displayName, + message: result.error + }) + } + } + + if (removedIds.length > 0) { + invalidateWorkspaceCleanupScanProgress() + let prunableWorktreeIds = new Set(removedIds) + set((state) => { + const remainingCandidates = state.workspaceCleanupScan?.candidates.filter( + (candidate) => !removedIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate)) + ) + // Why: a same-id row on another host survives this removal, and its + // dismissal/viewed marks are keyed by worktree id alone — dropping them + // would resurrect a row the user already ignored. + const survivingWorktreeIds = new Set( + (remainingCandidates ?? []).map((candidate) => candidate.worktreeId) + ) + prunableWorktreeIds = new Set( + removedIds.filter((worktreeId) => !survivingWorktreeIds.has(worktreeId)) + ) + return { + workspaceCleanupLoading: false, + workspaceCleanupScan: + state.workspaceCleanupScan && remainingCandidates + ? { + ...state.workspaceCleanupScan, + candidates: remainingCandidates + } + : state.workspaceCleanupScan, + // Why: dismissals and viewed marks for removed worktrees are dead + // weight in the store and in every persisted-dismissals write. + workspaceCleanupDismissals: pruneWorkspaceCleanupDismissals( + state.workspaceCleanupDismissals, + prunableWorktreeIds + ), + workspaceCleanupViewedCandidates: pruneWorkspaceCleanupRecord( + state.workspaceCleanupViewedCandidates, + prunableWorktreeIds + ) + } + }) + if (prunableWorktreeIds.size > 0) { + void window.api.workspaceCleanup + .dismiss({ + dismissals: [], + removedWorktreeIds: [...prunableWorktreeIds] + }) + .catch((error: unknown) => { + console.warn('Failed to prune persisted cleanup dismissals', error) + }) + } + } + + return { + removedIds, + removedIdentities: [...removedIdentities], + failures, + ...(preservedBranches.length > 0 ? { preservedBranches } : {}) + } + } finally { + if ( + consent && + unverifiedRemovalConsentByIdentity.get(consent.identity) === consent.attemptId + ) { + unverifiedRemovalConsentByIdentity.delete(consent.identity) + } } } }) @@ -683,7 +741,11 @@ async function enrichWorkspaceCleanupCandidatesForScan( scanToken: number ): Promise { if (workspaceCleanupEnrichmentCache?.scanToken !== scanToken) { - workspaceCleanupEnrichmentCache = { scanToken, localToken: null, entries: new Map() } + workspaceCleanupEnrichmentCache = { + scanToken, + localToken: null, + entries: new Map() + } } const cache = workspaceCleanupEnrichmentCache const localToken = buildWorkspaceCleanupLocalStateToken(state) @@ -1083,7 +1145,7 @@ function shouldPreserveCleanupInspection( state: AppState ): boolean { const viewed = state.workspaceCleanupViewedCandidates[candidate.worktreeId] - if (!viewed?.wasSuggested || viewed.fingerprint !== candidate.fingerprint) { + if (!viewed || viewed.fingerprint !== candidate.fingerprint) { return false } // Why: View is part of cleanup review. It should not make the same diff --git a/src/shared/workspace-cleanup-applied-filters.test.ts b/src/shared/workspace-cleanup-applied-filters.test.ts index 9cd9fd4afb8..e0d89c4dadf 100644 --- a/src/shared/workspace-cleanup-applied-filters.test.ts +++ b/src/shared/workspace-cleanup-applied-filters.test.ts @@ -16,8 +16,7 @@ const FORMAT = { branchQuery: (v: string) => `Branch ~ ${v}`, pathPrefix: (v: string) => `Path ~ ${v}`, presence: (kind: string, mode: string) => `${kind}:${mode}`, - completelyEmpty: () => 'Nothing to lose', - selectableOnly: () => 'Deletable only' + completelyEmpty: () => 'Nothing to lose' } const list = (f: ReturnType) => @@ -131,7 +130,7 @@ describe('listAppliedWorkspaceCleanupFilters', () => { filters.status.matchStatusless = false filters.git.branchQuery = 'release' filters.location.pathPrefix = '/repos' - filters.safety.selectableOnly = true + filters.safety.dismissed = 'only' let next = filters for (const applied of list(filters)) { diff --git a/src/shared/workspace-cleanup-applied-filters.ts b/src/shared/workspace-cleanup-applied-filters.ts index 449a55f088f..75d4cd4947c 100644 --- a/src/shared/workspace-cleanup-applied-filters.ts +++ b/src/shared/workspace-cleanup-applied-filters.ts @@ -35,7 +35,6 @@ type Formatters = { pathPrefix: (value: string) => string presence: (kind: string, mode: string) => string completelyEmpty: () => string - selectableOnly: () => string } /** @@ -232,24 +231,12 @@ export function listAppliedWorkspaceCleanupFilters( safety: { ...f.safety, blockers: [] } })) } - if (safety.tiers.length > 0) { - add('safety.tiers', format.list('tier', safety.tiers.length), (f) => ({ - ...f, - safety: { ...f.safety, tiers: [] } - })) - } if (safety.dismissed !== 'any') { add('safety.dismissed', format.triState('dismissed', safety.dismissed), (f) => ({ ...f, safety: { ...f.safety, dismissed: 'any' } })) } - if (safety.selectableOnly) { - add('safety.selectableOnly', format.selectableOnly(), (f) => ({ - ...f, - safety: { ...f.safety, selectableOnly: false } - })) - } return applied } diff --git a/src/shared/workspace-cleanup-browse-state-persistence.test.ts b/src/shared/workspace-cleanup-browse-state-persistence.test.ts index d0fad16e1c1..3d48dd1e806 100644 --- a/src/shared/workspace-cleanup-browse-state-persistence.test.ts +++ b/src/shared/workspace-cleanup-browse-state-persistence.test.ts @@ -19,9 +19,17 @@ describe('workspace cleanup browse state persistence', () => { it('round-trips an edited state', () => { const state = createDefaultWorkspaceCleanupBrowseState() state.filters.query = 'checkout' - state.filters.activity = { idleSignal: 'created', idleMinDays: 45, neverVisited: true } - state.filters.safety.tiers = ['review', 'protected'] - state.filters.review = { presence: 'some', states: ['merged'], providers: ['gitlab'] } + state.filters.activity = { + idleSignal: 'created', + idleMinDays: 45, + neverVisited: true + } + state.filters.safety.blockers = ['dirty-files', 'pinned'] + state.filters.review = { + presence: 'some', + states: ['merged'], + providers: ['gitlab'] + } state.sort = { field: 'size', direction: 'desc' } expect(normalizeWorkspaceCleanupBrowseState(throughDisk(state))).toEqual(state) @@ -34,7 +42,11 @@ describe('workspace cleanup browse state persistence', () => { unknownTopLevelField: { anything: true }, filters: { query: 'keep me', - activity: { idleSignal: 'from-the-future', idleMinDays: 30, unknownFacet: 'ignored' }, + activity: { + idleSignal: 'from-the-future', + idleMinDays: 30, + unknownFacet: 'ignored' + }, safety: { tiers: ['ready', 'not-a-tier'] }, unknownGroup: { enabled: true } }, @@ -48,8 +60,13 @@ describe('workspace cleanup browse state persistence', () => { expect(normalized.filters.query).toBe('keep me') expect(normalized.filters.activity.idleMinDays).toBe(30) expect(normalized.filters.activity.idleSignal).toBe('last-visited') - expect(normalized.filters.safety.tiers).toEqual(['ready']) - expect(normalized.sort).toEqual({ field: 'last-activity', direction: 'desc' }) + expect(normalized.filters.safety).toEqual( + createDefaultWorkspaceCleanupBrowseState().filters.safety + ) + expect(normalized.sort).toEqual({ + field: 'last-activity', + direction: 'desc' + }) expect(normalized).not.toHaveProperty('activePresetId') expect(normalized).not.toHaveProperty('customPresets') expect(normalized).not.toHaveProperty('unknownTopLevelField') diff --git a/src/shared/workspace-cleanup-facet-rankings.ts b/src/shared/workspace-cleanup-facet-rankings.ts index 61ebea7e5fa..2455ebd6249 100644 --- a/src/shared/workspace-cleanup-facet-rankings.ts +++ b/src/shared/workspace-cleanup-facet-rankings.ts @@ -10,7 +10,7 @@ import type { WorkspaceCleanupTicketSource, WorkspaceCleanupTriState } from './workspace-cleanup-filter-model' -import type { WorkspaceCleanupBlocker, WorkspaceCleanupTier } from './workspace-cleanup' +import type { WorkspaceCleanupBlocker } from './workspace-cleanup' // Records (not bare arrays) so tsc fails when a union gains a member. const BLOCKER_SEVERITY: Record = { @@ -32,8 +32,6 @@ const BLOCKER_SEVERITY: Record = { dismissed: 10 } -const TIER_RANK: Record = { ready: 0, review: 1, protected: 2 } - const AGENT_RANK: Record = { idle: 0, working: 2, @@ -58,7 +56,6 @@ const REVIEW_RANK: Record = { export const WORKSPACE_CLEANUP_BLOCKER_VALUES = Object.keys( BLOCKER_SEVERITY ) as WorkspaceCleanupBlocker[] -export const WORKSPACE_CLEANUP_TIER_VALUES = Object.keys(TIER_RANK) as WorkspaceCleanupTier[] export const WORKSPACE_CLEANUP_AGENT_STATE_VALUES = Object.keys( AGENT_RANK ) as WorkspaceCleanupAgentState[] @@ -110,7 +107,6 @@ export const WORKSPACE_CLEANUP_SORT_FIELD_VALUES: WorkspaceCleanupSortField[] = 'review', 'ticket', 'local-context', - 'tier', 'blocker-count' ] @@ -118,10 +114,6 @@ export function getWorkspaceCleanupBlockerSeverity(blocker: WorkspaceCleanupBloc return BLOCKER_SEVERITY[blocker] ?? 0 } -export function getWorkspaceCleanupTierRank(tier: WorkspaceCleanupTier): number { - return TIER_RANK[tier] ?? 0 -} - export function getWorkspaceCleanupAgentRank(state: WorkspaceCleanupAgentState): number { return AGENT_RANK[state] ?? 0 } diff --git a/src/shared/workspace-cleanup-filter-model.ts b/src/shared/workspace-cleanup-filter-model.ts index e4cb4af9f89..72d43f96c2d 100644 --- a/src/shared/workspace-cleanup-filter-model.ts +++ b/src/shared/workspace-cleanup-filter-model.ts @@ -1,6 +1,6 @@ import type { ExecutionHostId } from './execution-host' import type { HostedReviewProvider } from './hosted-review' -import type { WorkspaceCleanupBlocker, WorkspaceCleanupTier } from './workspace-cleanup' +import type { WorkspaceCleanupBlocker } from './workspace-cleanup' /** `any` leaves the facet unconstrained; `only`/`exclude` narrow to rows with/without the trait. */ export type WorkspaceCleanupTriState = 'any' | 'only' | 'exclude' @@ -40,7 +40,6 @@ export type WorkspaceCleanupSortField = | 'review' | 'ticket' | 'local-context' - | 'tier' | 'blocker-count' export type WorkspaceCleanupSortDirectionState = 'asc' | 'desc' @@ -117,10 +116,7 @@ export type WorkspaceCleanupLocationFilter = { export type WorkspaceCleanupSafetyFilter = { blockers: WorkspaceCleanupBlocker[] blockerMode: WorkspaceCleanupBlockerMode - tiers: WorkspaceCleanupTier[] dismissed: WorkspaceCleanupTriState - /** Only rows the cleanup policy would actually let the user delete. */ - selectableOnly: boolean } export type WorkspaceCleanupFilterState = { @@ -141,7 +137,11 @@ export type WorkspaceCleanupFilterState = { export function createDefaultWorkspaceCleanupFilterState(): WorkspaceCleanupFilterState { return { query: '', - activity: { idleSignal: 'last-visited', idleMinDays: null, neverVisited: false }, + activity: { + idleSignal: 'last-visited', + idleMinDays: null, + neverVisited: false + }, size: { minBytes: null, maxBytes: null, includeUnsized: true }, status: { workspaceStatuses: [], @@ -167,9 +167,7 @@ export function createDefaultWorkspaceCleanupFilterState(): WorkspaceCleanupFilt safety: { blockers: [], blockerMode: 'none-of', - tiers: [], - dismissed: 'any', - selectableOnly: false + dismissed: 'any' } } } diff --git a/src/shared/workspace-cleanup-filter-state-codec.ts b/src/shared/workspace-cleanup-filter-state-codec.ts index 5af505a281e..f6bc206a005 100644 --- a/src/shared/workspace-cleanup-filter-state-codec.ts +++ b/src/shared/workspace-cleanup-filter-state-codec.ts @@ -11,7 +11,6 @@ import { WORKSPACE_CLEANUP_SORT_DIRECTION_VALUES, WORKSPACE_CLEANUP_SORT_FIELD_VALUES, WORKSPACE_CLEANUP_TICKET_SOURCE_VALUES, - WORKSPACE_CLEANUP_TIER_VALUES, WORKSPACE_CLEANUP_TRI_STATE_VALUES } from './workspace-cleanup-facet-rankings' import { @@ -110,9 +109,7 @@ export function normalizeWorkspaceCleanupFilterState(value: unknown): WorkspaceC WORKSPACE_CLEANUP_BLOCKER_MODE_VALUES, base.safety.blockerMode ), - tiers: asEnumList(safety.tiers, WORKSPACE_CLEANUP_TIER_VALUES), - dismissed: asTriState(safety.dismissed, base.safety.dismissed), - selectableOnly: asBoolean(safety.selectableOnly, base.safety.selectableOnly) + dismissed: asTriState(safety.dismissed, base.safety.dismissed) } } } diff --git a/src/shared/workspace-cleanup.test.ts b/src/shared/workspace-cleanup.test.ts index e1576bf5f86..268c57ca36a 100644 --- a/src/shared/workspace-cleanup.test.ts +++ b/src/shared/workspace-cleanup.test.ts @@ -3,7 +3,6 @@ import { WORKSPACE_CLEANUP_CLASSIFIER_VERSION, applyWorkspaceCleanupPolicy, canQueueWorkspaceCleanupCandidate, - canSelectWorkspaceCleanupCandidate, createWorkspaceCleanupFingerprint, shouldForceWorkspaceCleanupRemoval, shouldHideWorkspaceCleanupCandidate, @@ -60,44 +59,47 @@ describe('workspace cleanup policy', () => { expect(candidate.tier).toBe('ready') expect(candidate.selectedByDefault).toBe(true) - expect(canSelectWorkspaceCleanupCandidate(candidate)).toBe(true) }) - it('requires an inactivity reason before selecting a workspace', () => { + it('keeps legacy verdict fields stable for older clients', () => { const candidate = applyWorkspaceCleanupPolicy(makeCandidate({ reasons: [] })) - expect(canSelectWorkspaceCleanupCandidate(candidate)).toBe(false) expect(candidate.tier).toBe('review') expect(candidate.selectedByDefault).toBe(false) }) - it('keeps not-suggested candidates queueable when git evidence is clean', () => { + it('queues risk-labelled and recently active candidates', () => { const candidate = applyWorkspaceCleanupPolicy(makeCandidate({ blockers: ['unpushed-commits'] })) + const recent = applyWorkspaceCleanupPolicy(makeCandidate({ reasons: [] })) expect(candidate.tier).toBe('protected') expect(candidate.selectedByDefault).toBe(false) - expect(canSelectWorkspaceCleanupCandidate(candidate)).toBe(false) expect(canQueueWorkspaceCleanupCandidate(candidate)).toBe(true) + expect(canQueueWorkspaceCleanupCandidate(recent)).toBe(true) expect(shouldForceWorkspaceCleanupRemoval(candidate)).toBe(true) }) - it('does not queue main worktrees or folder projects for cleanup removal', () => { + it('keeps active and live-agent rows manually queueable', () => { + expect( + canQueueWorkspaceCleanupCandidate(makeCandidate({ blockers: ['active-workspace'] })) + ).toBe(true) + expect(canQueueWorkspaceCleanupCandidate(makeCandidate({ blockers: ['live-agent'] }))).toBe( + true + ) + }) + + it('refuses only operations that cannot be routed', () => { const mainWorktree = applyWorkspaceCleanupPolicy(makeCandidate({ blockers: ['main-worktree'] })) const folderProject = applyWorkspaceCleanupPolicy(makeCandidate({ blockers: ['folder-repo'] })) + const disconnected = applyWorkspaceCleanupPolicy( + makeCandidate({ blockers: ['ssh-disconnected'] }) + ) + const dismissed = applyWorkspaceCleanupPolicy(makeCandidate({ blockers: ['dismissed'] })) expect(canQueueWorkspaceCleanupCandidate(mainWorktree)).toBe(false) expect(canQueueWorkspaceCleanupCandidate(folderProject)).toBe(false) - }) - - it('requires current git status before selecting a workspace', () => { - const candidate = applyWorkspaceCleanupPolicy( - makeCandidate({ - git: { clean: null, checkedAt: null } - }) - ) - - expect(candidate.tier).toBe('review') - expect(canSelectWorkspaceCleanupCandidate(candidate)).toBe(false) + expect(canQueueWorkspaceCleanupCandidate(disconnected)).toBe(false) + expect(canQueueWorkspaceCleanupCandidate(dismissed)).toBe(true) }) it('matches dismissals only for the current classifier fingerprint', () => { diff --git a/src/shared/workspace-cleanup.ts b/src/shared/workspace-cleanup.ts index a1fd9293d8c..a2bedc2d9ed 100644 --- a/src/shared/workspace-cleanup.ts +++ b/src/shared/workspace-cleanup.ts @@ -60,7 +60,9 @@ export type WorkspaceCleanupCandidate = { displayName: string branch: string path: string + /** @deprecated Current renderers ignore this legacy verdict; retained for older paired clients. */ tier: WorkspaceCleanupTier + /** @deprecated Current renderers ignore this legacy verdict; retained for older paired clients. */ selectedByDefault: boolean reasons: WorkspaceCleanupReason[] blockers: WorkspaceCleanupBlocker[] @@ -117,6 +119,8 @@ export type WorkspaceCleanupScanError = { repoId: string repoName: string message: string + /** Optional for compatibility with older hosts that did not qualify scan errors. */ + executionHostId?: ExecutionHostId } export type WorkspaceCleanupScanResult = { @@ -132,6 +136,14 @@ export type WorkspaceCleanupScanProgress = WorkspaceCleanupScanResult & { candidateMode?: 'append' | 'snapshot' } +/** One-shot consent to remove a workspace whose git status could not be read. */ +export type WorkspaceCleanupUnverifiedRemovalConsent = { + /** Host-qualified candidate identity, never a bare worktree id. */ + identity: string + /** Minted for one removal call and released when that call settles. */ + attemptId: string +} + export type WorkspaceCleanupLocalProcessResult = { hasKillableProcesses: boolean | null } @@ -142,7 +154,7 @@ export type WorkspaceCleanupDismissArgs = { removedWorktreeIds?: string[] } -export const WORKSPACE_CLEANUP_HARD_BLOCKERS: ReadonlySet = new Set([ +const LEGACY_WORKSPACE_CLEANUP_HARD_BLOCKERS: ReadonlySet = new Set([ 'main-worktree', 'folder-repo', 'pinned', @@ -164,23 +176,19 @@ export const WORKSPACE_CLEANUP_HARD_BLOCKERS: ReadonlySet = new Set([ 'main-worktree', 'folder-repo', - 'dismissed' + 'ssh-disconnected' ]) export const WORKSPACE_CLEANUP_FORCE_REMOVE_BLOCKERS: ReadonlySet = - new Set(['dirty-files', 'unpushed-commits', 'unknown-base', 'git-status-error']) + new Set(['dirty-files', 'unpushed-commits', 'unknown-base']) -export function isWorkspaceCleanupHardBlocker(blocker: WorkspaceCleanupBlocker): boolean { - return WORKSPACE_CLEANUP_HARD_BLOCKERS.has(blocker) -} +export const WORKSPACE_CLEANUP_BULK_SELECT_EXCLUSIONS: ReadonlySet = + new Set(['active-workspace', 'live-agent', 'dismissed']) export function canQueueWorkspaceCleanupCandidate( - candidate: Pick + candidate: Pick ): boolean { - return ( - candidate.reasons.length > 0 && - !candidate.blockers.some((blocker) => WORKSPACE_CLEANUP_QUEUE_BLOCKERS.has(blocker)) - ) + return !candidate.blockers.some((blocker) => WORKSPACE_CLEANUP_QUEUE_BLOCKERS.has(blocker)) } export function shouldForceWorkspaceCleanupRemoval( @@ -193,22 +201,27 @@ export function shouldForceWorkspaceCleanupRemoval( ) } -export function canSelectWorkspaceCleanupCandidate( +function canSelectWorkspaceCleanupCandidate( candidate: Pick ): boolean { return ( candidate.reasons.length > 0 && candidate.git.clean === true && candidate.git.checkedAt !== null && - !candidate.blockers.some(isWorkspaceCleanupHardBlocker) + !candidate.blockers.some((blocker) => LEGACY_WORKSPACE_CLEANUP_HARD_BLOCKERS.has(blocker)) ) } +type WorkspaceCleanupPolicyInput = Omit & + Partial> + export function applyWorkspaceCleanupPolicy( - candidate: WorkspaceCleanupCandidate + candidate: WorkspaceCleanupPolicyInput ): WorkspaceCleanupCandidate { const canSelect = canSelectWorkspaceCleanupCandidate(candidate) - const hasHardBlocker = candidate.blockers.some(isWorkspaceCleanupHardBlocker) + const hasHardBlocker = candidate.blockers.some((blocker) => + LEGACY_WORKSPACE_CLEANUP_HARD_BLOCKERS.has(blocker) + ) const tier: WorkspaceCleanupTier = hasHardBlocker ? 'protected' : canSelect ? 'ready' : 'review' return { From 56f00afeca00a74c484a022be700c32662211076 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:43:57 -0700 Subject: [PATCH 11/65] fix(sidebar): stop reporting an interrupted agent as done (STA-5357) (#16312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sidebar): stop reporting an interrupted agent as done (STA-5357) An interrupted turn rendered on the worktree card and the tab glyph as `done` — visually identical to a clean completion. A cancelled or dead turn read as finished work, and with several agents it contributed no signal at all, so a sibling that merely finished could hide it entirely. `interrupted` is a flag clamped onto `done` at parse time, never its own state, so the activity summary never even received it: its Pick was {state, workingMode}, and the `done` branch swallowed it into hasLiveDone. Adds the flag end to end — summary, both activity hooks, the resolver, the card glyph and the tab badge — and slots it below permission and above working, which is where SUMMARY_STATE_ORDER already ranked it for the text summary. The card and the expanded agent list now agree. The tab-bar test that asserted the old behavior is flipped rather than deleted: it explicitly pinned `done` to mirror the card, and that premise is gone. * fix(sidebar): rank interrupted below every live state Corrects the precedence from the previous commit. Interrupted means the user pressed Esc or Ctrl+C — a deliberate act, so they already know. It must be DISTINGUISHABLE from done (that is the bug) without being LOUDER than anything live. Moved below done in all three ladders — resolveWorktreeStatus, the tab attention badge, and SUMMARY_STATE_ORDER, which had also ranked it above working. That matches smart-attention, which already classes an interrupted done as 4 (idle), below both done and working; the card, the tab, the summary text and the sort now agree instead of two of them disagreeing. Tests re-pinned to the corrected order, including one of my own from the previous commit that asserted a finished sibling must not mask an interrupted pane — it should, and now does. * fix(status): preserve interrupted outcomes in aggregates --- .../WorktreeJumpPalette.recent-tabs.test.tsx | 6 ++- .../src/components/WorktreeJumpPalette.tsx | 2 +- .../sidebar/StatusIndicator.test.ts | 7 +++ .../components/sidebar/StatusIndicator.tsx | 12 +++++ .../sidebar/use-worktree-activity-status.ts | 3 ++ .../sidebar/use-worktree-activity-statuses.ts | 2 + .../worktree-agent-activity-summary.test.ts | 26 +++++++++ .../worktree-agent-activity-summary.ts | 9 +++- .../worktree-card-agent-summary.test.ts | 14 +++++ .../sidebar/worktree-card-agent-summary.ts | 2 +- .../terminal-tab-activity-status.test.ts | 23 +++++++- .../tab-bar/terminal-tab-activity-status.ts | 23 ++++++-- .../worktree-jump-palette-test-fixtures.ts | 6 ++- .../src/lib/recent-workspace-tab-rows.test.ts | 21 ++++++++ .../src/lib/recent-workspace-tab-rows.ts | 16 +++++- .../lib/worktree-status.interrupted.test.ts | 53 +++++++++++++++++++ src/renderer/src/lib/worktree-status.ts | 10 +++- 17 files changed, 218 insertions(+), 17 deletions(-) create mode 100644 src/renderer/src/lib/worktree-status.interrupted.test.ts diff --git a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx index 9acf590819f..d7bc4ad472c 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx @@ -665,7 +665,7 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { expect(getTabRowIds()).toContain('tab-alpha') }) - it('excludes the current tab when its agent is merely done', async () => { + it.each([undefined, true])('excludes current terminal outcomes', async (interrupted) => { await renderPalette( makeRecentTabState({ activeWorktreeId: 'wt-alpha', @@ -674,7 +674,9 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, activeTabTypeByWorktree: { 'wt-alpha': 'terminal' }, agentStatusByPaneKey: { - [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'done', Date.now()) + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'done', Date.now(), { + interrupted + }) } }) ) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 0d69d1a9a68..25644b7ec51 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -449,7 +449,7 @@ function shouldIncludeOpenTabInRecentSection({ unreadAgentCompletionPanes }) }) - return badge != null && badge !== 'done' + return badge != null && badge !== 'done' && badge !== 'interrupted' } function PaletteRowShortcutBadge({ diff --git a/src/renderer/src/components/sidebar/StatusIndicator.test.ts b/src/renderer/src/components/sidebar/StatusIndicator.test.ts index 89fdcb12f75..c7ebd3a59ec 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.test.ts +++ b/src/renderer/src/components/sidebar/StatusIndicator.test.ts @@ -60,4 +60,11 @@ describe('StatusIndicator', () => { expect(classNames).toContain('bg-emerald-500') }) + + it('renders interrupted distinctly from done', () => { + const classNames = renderDotClassNames('interrupted') + + expect(classNames).toContain('bg-red-500') + expect(classNames).not.toContain('bg-emerald-500') + }) }) diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index 97726487c19..26493bb7d40 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -53,6 +53,18 @@ const StatusIndicator = React.memo(function StatusIndicator({ ) } + if (status === 'interrupted') { + return ( + + + + ) + } + if (status === 'permission') { return ( { expect(summary).toMatchObject({ hasLiveWorking: false, hasLiveMonitoring: true }) }) + it('separates interrupted outcomes from clean completion', () => { + 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: 2, + agentStatusByPaneKey: { + [paneKey]: makeAgentStatusEntry({ + paneKey, + state: 'done', + interrupted: true + }) + }, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + }, + 'repo::/wt-1' + ) + + expect(summary).toMatchObject({ hasInterrupted: true, hasLiveDone: false }) + }) + 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 638341bf8f3..fbb2d93869a 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -16,6 +16,8 @@ export type WorktreeAgentActivitySummary = { hasPermission: boolean hasLiveWorking: boolean hasLiveMonitoring: boolean + /** Fresh interrupted completion, kept separate from clean done outcomes. */ + hasInterrupted: boolean hasLiveDone: boolean hasRetainedDone: boolean agentStatusPaneIdsByTabId: Record> @@ -27,6 +29,7 @@ const EMPTY_SUMMARY: WorktreeAgentActivitySummary = { hasPermission: false, hasLiveWorking: false, hasLiveMonitoring: false, + hasInterrupted: false, hasLiveDone: false, hasRetainedDone: false, agentStatusPaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID @@ -180,6 +183,7 @@ function summariesEqual( previous.hasPermission === next.hasPermission && previous.hasLiveWorking === next.hasLiveWorking && previous.hasLiveMonitoring === next.hasLiveMonitoring && + previous.hasInterrupted === next.hasInterrupted && previous.hasLiveDone === next.hasLiveDone && previous.hasRetainedDone === next.hasRetainedDone && agentStatusPaneIdsByTabIdEqual( @@ -217,10 +221,13 @@ function agentStatusPaneIdsByTabIdEqual( function applyLiveAgentState( summary: WorktreeAgentActivitySummary, - entry: Pick + entry: Pick ): void { if (entry.state === 'blocked' || entry.state === 'waiting') { summary.hasPermission = true + } else if (entry.interrupted === true) { + // Interrupted is encoded as done, so it must be checked first. + summary.hasInterrupted = true } else if (entry.state === 'working') { if (entry.workingMode === 'monitoring') { summary.hasLiveMonitoring = 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 index bbb115c8f65..87096cf9532 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts @@ -55,4 +55,18 @@ describe('worktree card agent summary', () => { /Monitoring background tasks<\/span>]*> - Run background checks<\/span>/ ) }) + + it('lists interrupted outcomes before clean completions', () => { + const done = monitoringAgent() + done.state = 'done' + done.entry.state = 'done' + done.entry.workingMode = undefined + const interrupted = { + ...done, + paneKey: 'tab-1:leaf-2', + entry: { ...done.entry, paneKey: 'tab-1:leaf-2', interrupted: true } + } + + expect(summarizeAgents([done, interrupted], 'Agents')).toBe('Agents: 1 interrupted, 1 done') + }) }) 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 6c66d5cb9d3..8a23b708b6d 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts @@ -11,9 +11,9 @@ export type SummaryAgentGroup = { const SUMMARY_STATE_ORDER: AgentDotState[] = [ 'waiting', 'blocked', - 'interrupted', 'working', 'monitoring', + 'interrupted', 'done', 'idle' ] 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 f173b4261f3..d2bed923686 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 @@ -110,7 +110,7 @@ describe('resolveTerminalTabActivityStatus', () => { ).toBe('done') }) - it('treats an interrupted done as done, matching the worktree card', () => { + it('reports an interrupted done as interrupted, matching the worktree card', () => { const interrupted = entry(FIRST_LEAF_ID, 'done', { interrupted: true }) expect( resolveTerminalTabActivityStatus({ @@ -118,7 +118,22 @@ describe('resolveTerminalTabActivityStatus', () => { agentStatusByPaneKey: { [interrupted.paneKey]: interrupted }, ptyIdsByTabId: LIVE_PTY }) - ).toBe('done') + ).toBe('interrupted') + }) + + it('does not let a finished sibling mask an interrupted outcome', () => { + const interrupted = entry(FIRST_LEAF_ID, 'done', { interrupted: true }) + const finished = entry(SECOND_LEAF_ID, 'done') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { + [interrupted.paneKey]: interrupted, + [finished.paneKey]: finished + }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('interrupted') }) it('falls back to a live working title when hook status is stale', () => { @@ -273,6 +288,9 @@ describe('resolveTerminalTabAttentionBadge', () => { ) expect(resolveTerminalTabAttentionBadge({ status: 'done', hasUnread: true })).toBe('unread') expect(resolveTerminalTabAttentionBadge({ status: 'done', hasUnread: false })).toBe('done') + expect(resolveTerminalTabAttentionBadge({ status: 'interrupted', hasUnread: false })).toBe( + 'interrupted' + ) expect(resolveTerminalTabAttentionBadge({ status: 'active', hasUnread: false })).toBeNull() }) }) @@ -302,6 +320,7 @@ describe('terminalTabActivityToAgentDotState', () => { expect(terminalTabActivityToAgentDotState('monitoring')).toBe('monitoring') expect(terminalTabActivityToAgentDotState('permission')).toBe('permission') expect(terminalTabActivityToAgentDotState('done')).toBe('done') + expect(terminalTabActivityToAgentDotState('interrupted')).toBe('interrupted') expect(terminalTabActivityToAgentDotState('active')).toBeNull() expect(terminalTabActivityToAgentDotState('inactive')).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 c130a602e1a..97f511a1d41 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 @@ -20,6 +20,7 @@ type TerminalTabActivityFlags = { hasPermission: boolean hasLiveWorking: boolean hasLiveMonitoring: boolean + hasInterrupted: boolean hasLiveDone: boolean paneIds: Set } @@ -81,10 +82,10 @@ function getTerminalTabActivityFlags( } else { flags.hasLiveWorking = true } + } else if (entry.interrupted === true) { + // Interrupted is encoded as done, so it must be checked first. + flags.hasInterrupted = 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 - // the smart-sort ordering treats interrupts as idle. flags.hasLiveDone = true } } @@ -103,6 +104,7 @@ function getOrCreateTerminalTabActivityFlags( hasPermission: false, hasLiveWorking: false, hasLiveMonitoring: false, + hasInterrupted: false, hasLiveDone: false, paneIds: new Set() } @@ -164,6 +166,7 @@ export function resolveTerminalTabActivityStatus({ hasPermission: flags?.hasPermission ?? false, hasLiveWorking: flags?.hasLiveWorking ?? false, hasLiveMonitoring: flags?.hasLiveMonitoring ?? false, + hasInterrupted: flags?.hasInterrupted ?? false, hasLiveDone: flags?.hasLiveDone ?? false, // Why: retained/orchestration promotions are worktree-aggregate concerns; // a tab reflects its own live panes and title only. @@ -180,7 +183,13 @@ export function isTerminalTabActivityLive(status: TerminalTabActivityStatus): bo * 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' | 'monitoring' | 'permission' | 'unread' | 'done' +export type TerminalTabAttentionBadge = + | 'working' + | 'monitoring' + | 'permission' + | 'interrupted' + | 'unread' + | 'done' /** * Single priority ladder shared by the tab strip and Cmd+J recent rows: @@ -208,17 +217,21 @@ export function resolveTerminalTabAttentionBadge({ if (status === 'done') { return 'done' } + if (status === 'interrupted') { + return 'interrupted' + } return null } /** Map a container activity status onto AgentStateDot's vocabulary (no unread — that's a bell). */ export function terminalTabActivityToAgentDotState( status: TerminalTabActivityStatus -): 'working' | 'monitoring' | 'permission' | 'done' | null { +): 'working' | 'monitoring' | 'permission' | 'interrupted' | 'done' | null { switch (status) { case 'working': case 'monitoring': case 'permission': + case 'interrupted': case 'done': return status case 'active': diff --git a/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts b/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts index 19c97ada254..dad45ebc01d 100644 --- a/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts +++ b/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts @@ -93,7 +93,8 @@ export const LEAF_ID = '11111111-2222-4333-8444-555555555555' export function makeAgentEntry( tabId: string, state: AgentStatusState, - stateStartedAt: number + stateStartedAt: number, + overrides: Partial = {} ): AgentStatusEntry { return { state, @@ -101,7 +102,8 @@ export function makeAgentEntry( updatedAt: stateStartedAt, stateStartedAt, paneKey: makePaneKey(tabId, LEAF_ID), - stateHistory: [] + stateHistory: [], + ...overrides } } 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 1a8336f323c..54b56a72bb3 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts @@ -312,6 +312,27 @@ describe('orderRecentWorkspaceTabs', () => { }) describe('resolveRecentWorkspaceTabStatus', () => { + it('surfaces an interrupted outcome without promoting its sort class', () => { + const interrupted = entry('interrupted', 'done', NOW - 1_000, { interrupted: true }) + + expect(resolveRecentWorkspaceTabStatus(row('interrupted'), sources([interrupted]), NOW)).toBe( + 'interrupted' + ) + }) + + it('does not let a cleanly finished sibling mask an interruption', () => { + const interrupted = entry('mixed', 'done', NOW - 1_000, { + paneKey: `mixed:${LEAF_ID}`, + interrupted: true + }) + const finished = entry('mixed', 'done', NOW - 2_000, { + paneKey: 'mixed:22222222-2222-4222-8222-222222222222' + }) + + expect( + resolveRecentWorkspaceTabStatus(row('mixed'), sources([interrupted, finished]), NOW) + ).toBe('interrupted') + }) it('maps attention classes onto the sidebar dot vocabulary', () => { const blocked = row('blocked') const done = row('done') diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.ts b/src/renderer/src/lib/recent-workspace-tab-rows.ts index 9167db9b318..f0c66a5eb6c 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.ts @@ -7,10 +7,12 @@ import { type WorktreeAttention } from '@/components/sidebar/smart-attention' import { tabHasLivePty } from './tab-has-live-pty' +import { isExplicitAgentStatusFresh } from './pane-agent-evidence' import type { WorktreeStatus } from './worktree-status' import type { TabGroup } from '../../../shared/tab-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { ExecutionHostId } from '../../../shared/execution-host' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../shared/agent-status-types' import { getWorktreeVisitTimestamp } from './worktree-visit-recency' import { composeWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' @@ -105,7 +107,19 @@ export function resolveRecentWorkspaceTabStatus( ) return hasForegroundWork ? 'working' : 'monitoring' } - if (explicit) { + if (explicit === 'permission') { + return explicit + } + const hasInterrupted = panes.some( + (pane) => + pane.kind === 'hook' && + pane.entry.interrupted === true && + isExplicitAgentStatusFresh(pane.entry, now, AGENT_STATUS_STALE_AFTER_MS) + ) + if (hasInterrupted) { + return 'interrupted' + } + if (explicit === 'done') { return explicit } return tabHasLivePty(paneSources.ptyIdsByTabId, row.terminalTab.id) ? 'active' : 'inactive' diff --git a/src/renderer/src/lib/worktree-status.interrupted.test.ts b/src/renderer/src/lib/worktree-status.interrupted.test.ts new file mode 100644 index 00000000000..76536ff8c7a --- /dev/null +++ b/src/renderer/src/lib/worktree-status.interrupted.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { resolveWorktreeStatus } from './worktree-status' + +const base = { + tabs: [] as never[], + browserTabs: [] as never[], + ptyIdsByTabId: {}, + hasPermission: false, + hasLiveWorking: false, + hasLiveDone: false, + hasRetainedDone: false +} + +describe('resolveWorktreeStatus — interrupted (STA-5357)', () => { + it('reports interrupted rather than done for an interrupted agent', () => { + expect(resolveWorktreeStatus({ ...base, hasInterrupted: true })).toBe('interrupted') + }) + + it('is never the emerald done state on its own', () => { + expect(resolveWorktreeStatus({ ...base, hasInterrupted: true })).not.toBe('done') + }) + + it('yields to a working sibling — the live agent is the louder signal', () => { + expect(resolveWorktreeStatus({ ...base, hasInterrupted: true, hasLiveWorking: true })).toBe( + 'working' + ) + }) + + it('yields to permission — a prompt waiting on the user is more urgent', () => { + expect(resolveWorktreeStatus({ ...base, hasInterrupted: true, hasPermission: true })).toBe( + 'permission' + ) + }) + + it('yields to monitoring — background work is still live', () => { + expect(resolveWorktreeStatus({ ...base, hasInterrupted: true, hasLiveMonitoring: true })).toBe( + 'monitoring' + ) + }) + + it('outranks a cleanly finished sibling', () => { + expect(resolveWorktreeStatus({ ...base, hasInterrupted: true, hasLiveDone: true })).toBe( + 'interrupted' + ) + }) + + it('leaves every other combination alone', () => { + expect(resolveWorktreeStatus({ ...base, hasLiveDone: true })).toBe('done') + expect(resolveWorktreeStatus({ ...base, hasLiveWorking: true })).toBe('working') + expect(resolveWorktreeStatus({ ...base, hasLiveMonitoring: true })).toBe('monitoring') + expect(resolveWorktreeStatus({ ...base, hasPermission: true })).toBe('permission') + }) +}) diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 038e9f8ef11..4e234436958 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -16,6 +16,7 @@ export type WorktreeStatus = | 'working' | 'monitoring' | 'permission' + | 'interrupted' | 'done' | 'inactive' @@ -31,6 +32,7 @@ const STATUS_LABELS: Record = { working: 'Working', monitoring: 'Monitoring background tasks', permission: 'Needs permission', + interrupted: 'Interrupted', done: 'Done', inactive: 'Inactive' } @@ -124,8 +126,7 @@ export function getWorktreeStatusLabel(status: WorktreeStatus): string { } /** - * Apply the WorktreeCard priority overlay (permission > working > done > - * heuristic) on top of the title-heuristic base. Explicit agent rows may + * Apply the WorktreeCard priority overlay on top of the title-heuristic base. Explicit agent rows may * promote the dot; sleep cleanup owns removing stale retained rows. * * Map args are narrowed to this worktree. `hasPermission`/`hasLiveWorking`/ @@ -143,6 +144,7 @@ export function resolveWorktreeStatus(args: { hasPermission: boolean hasLiveWorking: boolean hasLiveMonitoring?: boolean + hasInterrupted?: boolean hasLiveDone: boolean hasRetainedDone: boolean }): WorktreeStatus { @@ -171,6 +173,10 @@ export function resolveWorktreeStatus(args: { if (args.hasLiveMonitoring || heuristic === 'monitoring') { return 'monitoring' } + // Terminal outcomes follow live states, but an interrupted outcome must not collapse into success. + if (args.hasInterrupted) { + return 'interrupted' + } if (args.hasLiveDone || args.hasRetainedDone) { return 'done' } From fba910f2eae78773a324ac17d7f941458db158e5 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:01:18 -0700 Subject: [PATCH 12/65] fix(crash-reporting): scope renderer crash evidence (#16313) --- ...sh-breadcrumb-renderer-attribution.test.ts | 134 ++++++++++++++++++ .../crash-reporting/crash-breadcrumb-store.ts | 88 ++++++++---- .../crash-report-store.test.ts | 3 +- .../crash-reporting/crash-report-store.ts | 4 +- .../durable-crash-breadcrumb.ts | 12 +- .../process-gone-dedupe.test.ts | 17 +++ .../crash-reporting/process-gone-dedupe.ts | 8 +- .../process-gone-recorder.test.ts | 36 +++++ .../crash-reporting/process-gone-recorder.ts | 40 ++++-- src/main/index.ts | 3 +- ...ash-reporting-renderer-breadcrumbs.test.ts | 18 ++- .../crash-reporting-renderer-breadcrumbs.ts | 25 ++-- ...-renderer-error-report-attribution.test.ts | 11 ++ .../crash-reporting-renderer-error-report.ts | 16 ++- src/main/ipc/crash-reporting.ts | 13 +- src/shared/crash-breadcrumb-origin.ts | 4 + src/shared/crash-reporting.ts | 15 +- 17 files changed, 373 insertions(+), 74 deletions(-) create mode 100644 src/main/crash-reporting/crash-breadcrumb-renderer-attribution.test.ts create mode 100644 src/shared/crash-breadcrumb-origin.ts diff --git a/src/main/crash-reporting/crash-breadcrumb-renderer-attribution.test.ts b/src/main/crash-reporting/crash-breadcrumb-renderer-attribution.test.ts new file mode 100644 index 00000000000..3431a35089b --- /dev/null +++ b/src/main/crash-reporting/crash-breadcrumb-renderer-attribution.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { + clearCrashBreadcrumbsForTest, + getCrashBreadcrumbSnapshot, + recordCoalescedCrashBreadcrumb, + recordCrashBreadcrumb +} from './crash-breadcrumb-store' + +describe('renderer crash breadcrumb attribution', () => { + beforeEach(() => { + clearCrashBreadcrumbsForTest() + }) + + it('filters a renderer report to its own breadcrumbs while retaining global evidence', () => { + recordCrashBreadcrumb('app_started', { packaged: true }) + recordCrashBreadcrumb('renderer_a_event', undefined, 'renderer:11') + recordCrashBreadcrumb('renderer_b_event', undefined, 'renderer:22') + + expect(getCrashBreadcrumbSnapshot('renderer:11').map((entry) => entry.name)).toEqual([ + 'app_started', + 'renderer_a_event' + ]) + expect(getCrashBreadcrumbSnapshot('renderer:22').map((entry) => entry.name)).toEqual([ + 'app_started', + 'renderer_b_event' + ]) + expect(getCrashBreadcrumbSnapshot().map((entry) => entry.name)).toEqual([ + 'app_started', + 'renderer_a_event', + 'renderer_b_event' + ]) + }) + + it('does not merge identical coalesced storms from sibling renderers', () => { + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:11\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:11' + }) + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:22\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:22' + }) + + expect(getCrashBreadcrumbSnapshot('renderer:11')).toHaveLength(1) + expect(getCrashBreadcrumbSnapshot('renderer:22')).toHaveLength(1) + expect(getCrashBreadcrumbSnapshot('renderer:11')[0]?.origin).toBe('renderer:11') + expect(getCrashBreadcrumbSnapshot('renderer:22')[0]?.origin).toBe('renderer:22') + }) + + it('retains identical high-water profiles independently per renderer', () => { + const profile = { rendererSurface: 'main', thresholdPct: 80, usedHeapMB: 512 } + recordCrashBreadcrumb('renderer_memory_highwater', profile, 'renderer:11') + recordCrashBreadcrumb('renderer_memory_highwater', profile, 'renderer:22') + + expect(getCrashBreadcrumbSnapshot('renderer:11')[0]?.origin).toBe('renderer:11') + expect(getCrashBreadcrumbSnapshot('renderer:22')[0]?.origin).toBe('renderer:22') + }) + + it('preserves the reporter trail when sibling renderer traffic fills the ring', () => { + for (let index = 0; index < 4; index += 1) { + recordCrashBreadcrumb('renderer_memory_highwater', { + rendererSurface: `surface-${index}`, + thresholdPct: 80 + }) + } + recordCrashBreadcrumb('renderer_a_event', undefined, 'renderer:11') + for (let index = 0; index < 26; index += 1) { + recordCrashBreadcrumb(`renderer_b_event_${index}`, undefined, 'renderer:22') + } + + expect(getCrashBreadcrumbSnapshot('renderer:11').map((entry) => entry.name)).toContain( + 'renderer_a_event' + ) + }) + + it('folds pending repeats using the reporter-specific evidence window', () => { + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:11\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:11' + }) + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:22\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:22' + }) + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:22\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:22' + }) + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:11\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:11' + }) + for (let index = 0; index < 4; index += 1) { + recordCrashBreadcrumb( + 'renderer_memory_highwater', + { rendererSurface: `surface-${index}`, thresholdPct: 80 }, + 'renderer:22' + ) + } + for (let index = 0; index < 25; index += 1) { + recordCrashBreadcrumb(`renderer_b_event_${index}`, undefined, 'renderer:22') + } + + const snapshot = getCrashBreadcrumbSnapshot('renderer:11') + + expect(snapshot).toHaveLength(1) + expect(snapshot[0]?.data).toEqual({ message: 'boom', suppressedSinceLast: 1 }) + + const siblingSnapshot = getCrashBreadcrumbSnapshot('renderer:22') + expect(siblingSnapshot.find((entry) => entry.name === 'renderer_error')?.data).toEqual({ + message: 'boom', + suppressedSinceLast: 1 + }) + }) +}) diff --git a/src/main/crash-reporting/crash-breadcrumb-store.ts b/src/main/crash-reporting/crash-breadcrumb-store.ts index e5f96e8f05e..0a9a71f452e 100644 --- a/src/main/crash-reporting/crash-breadcrumb-store.ts +++ b/src/main/crash-reporting/crash-breadcrumb-store.ts @@ -30,6 +30,7 @@ type CoalescedBreadcrumbState = { emitted?: CrashReportBreadcrumb /** Newest suppressed payload, sanitized only if a snapshot actually asks for it. */ pending?: CrashReportBreadcrumbData + origin?: string } let breadcrumbs: CrashReportBreadcrumb[] = [] @@ -42,19 +43,21 @@ function retainedBreadcrumbKey(breadcrumb: CrashReportBreadcrumb): string | null } const surface = breadcrumb.data?.rendererSurface const threshold = breadcrumb.data?.thresholdPct - return `${breadcrumb.name}:${String(surface)}:${String(threshold)}` + return `${breadcrumb.name}:${String(surface)}:${String(threshold)}:${breadcrumb.origin ?? 'global'}` } /** Returns the stored breadcrumb so coalescing can refresh the entry it owns. */ export function recordCrashBreadcrumb( name: string, - data?: CrashReportBreadcrumbData + data?: CrashReportBreadcrumbData, + origin?: string ): CrashReportBreadcrumb | undefined { const sanitized = sanitizeCrashReportBreadcrumbs([ { createdAt: new Date().toISOString(), name, - data + data, + ...(origin ? { origin } : {}) } ]) const breadcrumb = sanitized?.[0] @@ -85,12 +88,14 @@ export function recordCoalescedCrashBreadcrumb({ name, data, coalesceKey, - minIntervalMs + minIntervalMs, + origin }: { name: string data?: CrashReportBreadcrumbData coalesceKey: string minIntervalMs: number + origin?: string }): { suppressedSinceLast: number } | undefined { const now = monotonicNow() const previous = coalescedBreadcrumbs.get(coalesceKey) @@ -136,7 +141,8 @@ export function recordCoalescedCrashBreadcrumb({ windowStartedAtMs: now, suppressed: 0, carried: suppressedSinceLast, - resolved: 0 + resolved: 0, + ...(origin ? { origin } : {}) } coalescedBreadcrumbs.set(coalesceKey, state) while (coalescedBreadcrumbs.size > MAX_COALESCE_KEYS) { @@ -149,7 +155,8 @@ export function recordCoalescedCrashBreadcrumb({ } state.emitted = recordCrashBreadcrumb( name, - suppressedSinceLast > 0 ? { ...data, suppressedSinceLast } : data + suppressedSinceLast > 0 ? { ...data, suppressedSinceLast } : data, + origin ) return { suppressedSinceLast } } @@ -157,22 +164,29 @@ export function recordCoalescedCrashBreadcrumb({ /** Whether any future snapshot can still include this crumb. The ring only * appends and the retained map only grows toward its cap, so an entry pushed * past the snapshot budget is invisible forever, not just for now. */ -function isCoalescedCrumbStillInEvidence(crumb: CrashReportBreadcrumb): boolean { - const visibleFrom = breadcrumbs.length - (MAX_BREADCRUMBS - retainedBreadcrumbs.size) - const index = breadcrumbs.indexOf(crumb) - if (index !== -1 && index >= visibleFrom) { +function isCoalescedCrumbStillInEvidence( + crumb: CrashReportBreadcrumb, + reporterOrigin?: string +): boolean { + const retained = [...retainedBreadcrumbs.values()].filter((breadcrumb) => + isVisibleToReporter(breadcrumb, reporterOrigin) + ) + if (retained.some((retainedBreadcrumb) => retainedBreadcrumb === crumb)) { return true } - for (const retained of retainedBreadcrumbs.values()) { - if (retained === crumb) { - return true - } - } - return false + const visibleRecent = breadcrumbs.filter((breadcrumb) => + isVisibleToReporter(breadcrumb, reporterOrigin) + ) + return visibleRecent + .slice(-(MAX_BREADCRUMBS - retained.length)) + .some((recentBreadcrumb) => recentBreadcrumb === crumb) } /** Fold a key's newest suppressed payload into the ring entry it owns. */ -function resolvePendingCoalescedBreadcrumb(state: CoalescedBreadcrumbState): void { +function resolvePendingCoalescedBreadcrumb( + state: CoalescedBreadcrumbState, + reporterOrigin?: string +): void { // `data` is optional, so the count—not pending payload presence—marks unresolved work. if (!state.emitted || state.suppressed <= state.resolved) { return @@ -182,7 +196,7 @@ function resolvePendingCoalescedBreadcrumb(state: CoalescedBreadcrumbState): voi // then claim nothing — the burst vanishes from the record entirely. Drop the // handle (keeping `resolved` for folds that landed while it was live) so a // later emit or bounded cleanup can materialize the unclaimed repeats. - if (!isCoalescedCrumbStillInEvidence(state.emitted)) { + if (!isCoalescedCrumbStillInEvidence(state.emitted, reporterOrigin)) { state.emitted = undefined return } @@ -201,30 +215,46 @@ function resolvePendingCoalescedBreadcrumb(state: CoalescedBreadcrumbState): voi /** Resolve into the owned crumb, or emit the unclaimed repeats before bounded * cleanup drops an orphan's last accounting state. */ function preservePendingCoalescedBreadcrumb(state: CoalescedBreadcrumbState): void { - resolvePendingCoalescedBreadcrumb(state) + resolvePendingCoalescedBreadcrumb(state, state.origin) const unresolved = state.suppressed - state.resolved if (state.emitted || unresolved <= 0) { return } - recordCrashBreadcrumb(state.name, { - ...state.pending, - suppressedSinceLast: unresolved - }) + recordCrashBreadcrumb( + state.name, + { ...state.pending, suppressedSinceLast: unresolved }, + state.origin + ) state.resolved = state.suppressed state.pending = undefined } -function resolveAllPendingCoalescedBreadcrumbs(): void { +function resolveAllPendingCoalescedBreadcrumbs(reporterOrigin?: string): void { for (const state of coalescedBreadcrumbs.values()) { - resolvePendingCoalescedBreadcrumb(state) + if (reporterOrigin && state.origin && state.origin !== reporterOrigin) { + continue + } + resolvePendingCoalescedBreadcrumb(state, reporterOrigin) } } -export function getCrashBreadcrumbSnapshot(): CrashReportBreadcrumb[] { - resolveAllPendingCoalescedBreadcrumbs() +function isVisibleToReporter( + breadcrumb: CrashReportBreadcrumb, + reporterOrigin: string | undefined +): boolean { + return !reporterOrigin || !breadcrumb.origin || breadcrumb.origin === reporterOrigin +} + +export function getCrashBreadcrumbSnapshot(reporterOrigin?: string): CrashReportBreadcrumb[] { + resolveAllPendingCoalescedBreadcrumbs(reporterOrigin) // Why: long sessions must retain threshold profiles without growing the 30-entry budget. - const retained = [...retainedBreadcrumbs.values()] - const recent = breadcrumbs.slice(-(MAX_BREADCRUMBS - retained.length)) + const retained = [...retainedBreadcrumbs.values()].filter((breadcrumb) => + isVisibleToReporter(breadcrumb, reporterOrigin) + ) + const visibleRecent = breadcrumbs.filter((breadcrumb) => + isVisibleToReporter(breadcrumb, reporterOrigin) + ) + const recent = visibleRecent.slice(-(MAX_BREADCRUMBS - retained.length)) return [...retained, ...recent] .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) .map((breadcrumb) => ({ diff --git a/src/main/crash-reporting/crash-report-store.test.ts b/src/main/crash-reporting/crash-report-store.test.ts index d594bd2090a..8a99a79df4a 100644 --- a/src/main/crash-reporting/crash-report-store.test.ts +++ b/src/main/crash-reporting/crash-report-store.test.ts @@ -41,7 +41,8 @@ function input(reason = 'crashed'): CrashReportCreateInput { { createdAt: '2026-05-16T01:00:00.000Z', name: 'workspace_opened', - data: { path: '/Users/alice/project', ssh: false } + data: { path: '/Users/alice/project', ssh: false }, + origin: 'renderer:42' } ] } diff --git a/src/main/crash-reporting/crash-report-store.ts b/src/main/crash-reporting/crash-report-store.ts index 64028b8e2fb..143210418ce 100644 --- a/src/main/crash-reporting/crash-report-store.ts +++ b/src/main/crash-reporting/crash-report-store.ts @@ -96,7 +96,9 @@ export class CrashReportStore { createdAt: new Date().toISOString(), status: 'pending', details: sanitizeCrashReportDetails(input.details), - breadcrumbs: sanitizeCrashReportBreadcrumbs(input.breadcrumbs) + breadcrumbs: sanitizeCrashReportBreadcrumbs(input.breadcrumbs)?.map( + ({ origin: _origin, ...breadcrumb }) => breadcrumb + ) } return { reports: [report, ...reports].slice(0, MAX_REPORTS), diff --git a/src/main/crash-reporting/durable-crash-breadcrumb.ts b/src/main/crash-reporting/durable-crash-breadcrumb.ts index e4271fb61d8..ff1c77bc9c6 100644 --- a/src/main/crash-reporting/durable-crash-breadcrumb.ts +++ b/src/main/crash-reporting/durable-crash-breadcrumb.ts @@ -41,11 +41,12 @@ function traceDurableBreadcrumb( export function recordDurableCrashBreadcrumb( name: string, data?: CrashReportBreadcrumbData, - failureCause?: string + failureCause?: string, + origin?: string ): void { const sanitizedName = sanitizeCrashReportString(name) const lifecycleData = buildLifecycleData(data) - recordCrashBreadcrumb(sanitizedName, lifecycleData) + recordCrashBreadcrumb(sanitizedName, lifecycleData, origin) traceDurableBreadcrumb(sanitizedName, lifecycleData, failureCause) } @@ -56,12 +57,14 @@ export function recordCoalescedDurableCrashBreadcrumb({ name, data, coalesceKey, - minIntervalMs + minIntervalMs, + origin }: { name: string data?: CrashReportBreadcrumbData coalesceKey: string minIntervalMs: number + origin?: string }): void { const sanitizedName = sanitizeCrashReportString(name) const lifecycleData = buildLifecycleData(data) @@ -69,7 +72,8 @@ export function recordCoalescedDurableCrashBreadcrumb({ name: sanitizedName, data: lifecycleData, coalesceKey, - minIntervalMs + minIntervalMs, + ...(origin ? { origin } : {}) }) if (!coalesced) { return diff --git a/src/main/crash-reporting/process-gone-dedupe.test.ts b/src/main/crash-reporting/process-gone-dedupe.test.ts index c4a2dc714fc..b3db4b4a0fd 100644 --- a/src/main/crash-reporting/process-gone-dedupe.test.ts +++ b/src/main/crash-reporting/process-gone-dedupe.test.ts @@ -77,6 +77,23 @@ describe('ProcessGoneDedupe', () => { ).toBe(true) }) + it('does not suppress simultaneous crashes from different renderers', () => { + const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 }) + + expect( + dedupe.shouldRecord(getProcessGoneDedupeKey('renderer', 'renderer', 'crashed', 5, 11), 1_000) + ).toBe(true) + expect( + dedupe.shouldRecord(getProcessGoneDedupeKey('renderer', 'renderer', 'crashed', 5, 22), 1_001) + ).toBe(true) + expect( + dedupe.shouldRecord( + getProcessGoneDedupeKey('renderer', 'renderer', 'oom', -536870904, 11), + 1_002 + ) + ).toBe(false) + }) + it('keeps child process crash tuples distinct inside renderer burst windows', () => { const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 }) diff --git a/src/main/crash-reporting/process-gone-dedupe.ts b/src/main/crash-reporting/process-gone-dedupe.ts index b35cccc6576..861e6738e4e 100644 --- a/src/main/crash-reporting/process-gone-dedupe.ts +++ b/src/main/crash-reporting/process-gone-dedupe.ts @@ -80,12 +80,14 @@ export function getProcessGoneDedupeKey( source: 'renderer' | 'child', processType: string, reason: string, - exitCode: number | null + exitCode: number | null, + webContentsId?: number ): string { // Why: one renderer death can surface as crashed/oom/launch-failed in a - // burst. Coalesce that prompt noise while keeping child identities precise. + // burst. Coalesce that prompt noise per renderer while keeping child + // identities precise. if (source === 'renderer') { - return `${source}:${processType}` + return `${source}:${processType}:${webContentsId ?? 'global'}` } return `${source}:${processType}:${reason}:${exitCode ?? 'null'}` } diff --git a/src/main/crash-reporting/process-gone-recorder.test.ts b/src/main/crash-reporting/process-gone-recorder.test.ts index 24749b1bacf..9f564712e24 100644 --- a/src/main/crash-reporting/process-gone-recorder.test.ts +++ b/src/main/crash-reporting/process-gone-recorder.test.ts @@ -114,6 +114,22 @@ describe('recordProcessGoneCrash', () => { expect(sink.flushMock).toHaveBeenCalledOnce() }) + it('keeps suppressed renderer evidence scoped to its renderer', () => { + const dedupe = new ProcessGoneDedupe() + const suppressed = (webContentsId: number) => + event({ reason: 'killed', exitCode: 1, expectedTeardown: 'renderer-reload', webContentsId }) + + recordProcessGoneCrash({ record: vi.fn() } as never, suppressed(11), dedupe) + recordProcessGoneCrash({ record: vi.fn() } as never, suppressed(22), dedupe) + + expect(getCrashBreadcrumbSnapshot('renderer:11')).toEqual([ + expect.objectContaining({ origin: 'renderer:11' }) + ]) + expect(getCrashBreadcrumbSnapshot('renderer:22')).toEqual([ + expect.objectContaining({ origin: 'renderer:22' }) + ]) + }) + it('coalesces a recoverable-service crash loop instead of flushing every event', () => { const record = vi.fn() const dedupe = new ProcessGoneDedupe() @@ -314,6 +330,26 @@ describe('recordProcessGoneCrash', () => { expect(sink.flushMock).toHaveBeenCalledOnce() }) + it('keeps simultaneous renderer reports distinct by webContents identity', async () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + const dedupe = new ProcessGoneDedupe() + + recordProcessGoneCrash( + { record, attachDetails } as never, + event({ webContentsId: 11 }), + dedupe, + noMinidump + ) + recordProcessGoneCrash( + { record, attachDetails } as never, + event({ webContentsId: 22 }), + dedupe, + noMinidump + ) + + await vi.waitFor(() => expect(record).toHaveBeenCalledTimes(2)) + }) + it('still persists the report when the forced trace flush fails', async () => { const record = vi.fn().mockResolvedValue({ id: 'report-1' }) sink.flushMock.mockImplementation(() => { diff --git a/src/main/crash-reporting/process-gone-recorder.ts b/src/main/crash-reporting/process-gone-recorder.ts index 947e8165bb3..ec9d53bdfff 100644 --- a/src/main/crash-reporting/process-gone-recorder.ts +++ b/src/main/crash-reporting/process-gone-recorder.ts @@ -7,6 +7,7 @@ import { type CrashReportBreadcrumbData } from '../../shared/crash-reporting' import { decodePosixWaitStatus, describePosixWaitStatus } from '../../shared/posix-wait-status' +import { rendererCrashBreadcrumbOrigin } from '../../shared/crash-breadcrumb-origin' import type { CrashReportStore } from './crash-report-store' import { getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store' import { @@ -41,6 +42,7 @@ export type ProcessGoneCrashEvent = { exitCode: number | null expectedTeardown: ExpectedTeardownScope details: Record + webContentsId?: number } type CrashReportRecorderStore = Pick @@ -74,6 +76,12 @@ function processGoneBreadcrumbData(event: ProcessGoneCrashEvent) { return buildSuppressedProcessGoneBreadcrumbData(event) } +function processGoneRendererOrigin(event: ProcessGoneCrashEvent): string | undefined { + return event.webContentsId === undefined + ? undefined + : rendererCrashBreadcrumbOrigin(event.webContentsId) +} + // Why: key off the emitted breadcrumb, not the crash-report dedupe key, so two // different recoverable services can never suppress each other's evidence. function suppressedProcessGoneCoalesceKey(data: CrashReportBreadcrumbData): string { @@ -179,11 +187,15 @@ export function recordProcessGoneCrash( // 1459/min) and each suppressed event costs a span plus a forced disk flush, // which both floods the 30-entry ring and evicts the real pre-crash trail. const suppressedData = processGoneBreadcrumbData(event) + const origin = processGoneRendererOrigin(event) recordCoalescedDurableCrashBreadcrumb({ name: 'process_gone_suppressed', data: suppressedData, - coalesceKey: suppressedProcessGoneCoalesceKey(suppressedData), - minIntervalMs: SUPPRESSED_PROCESS_GONE_COALESCE_MS + coalesceKey: origin + ? `${origin}\u0000${suppressedProcessGoneCoalesceKey(suppressedData)}` + : suppressedProcessGoneCoalesceKey(suppressedData), + minIntervalMs: SUPPRESSED_PROCESS_GONE_COALESCE_MS, + ...(origin ? { origin } : {}) }) return } @@ -191,12 +203,19 @@ export function recordProcessGoneCrash( recordDurableCrashBreadcrumb( 'crash_report_store_unavailable', processGoneBreadcrumbData(event), - 'Crash report store unavailable' + 'Crash report store unavailable', + processGoneRendererOrigin(event) ) return } - const key = getProcessGoneDedupeKey(event.source, event.processType, event.reason, event.exitCode) + const key = getProcessGoneDedupeKey( + event.source, + event.processType, + event.reason, + event.exitCode, + event.webContentsId + ) const claim = dedupe.tryClaim(key) if (!claim) { return @@ -209,7 +228,8 @@ export function recordProcessGoneCrash( }, event.processType ) - const breadcrumbs = getCrashBreadcrumbSnapshot() + const breadcrumbs = getCrashBreadcrumbSnapshot(processGoneRendererOrigin(event)) + const reportBreadcrumbs = breadcrumbs?.map(({ origin: _origin, ...breadcrumb }) => breadcrumb) const span = startSpan('electron.process_gone', { attributes: { 'crash.source': event.source, @@ -227,7 +247,7 @@ export function recordProcessGoneCrash( 'app.main_process.launch_id': mainProcessLifecycle.mainProcessLaunchId, 'app.main_process.started_at': mainProcessLifecycle.mainProcessStartedAt, details: crashDetails, - breadcrumbs + breadcrumbs: reportBreadcrumbs } }) // Why: a renderer crash can be followed by another process exit before the @@ -252,7 +272,7 @@ export function recordProcessGoneCrash( electronVersion: process.versions.electron ?? 'unknown', chromeVersion: process.versions.chrome ?? 'unknown', details: crashDetails, - breadcrumbs + breadcrumbs: reportBreadcrumbs }) .then((report) => { // Why: kept off the returned chain so a minidump failure can never reach @@ -268,7 +288,8 @@ export function recordProcessGoneCrash( recordDurableCrashBreadcrumb( 'minidump_signature_attach_failed', processGoneBreadcrumbData(event), - error instanceof Error ? error.message : String(error) + error instanceof Error ? error.message : String(error), + processGoneRendererOrigin(event) ) }) }) @@ -279,7 +300,8 @@ export function recordProcessGoneCrash( recordDurableCrashBreadcrumb( 'crash_report_persist_failed', data, - `${String(data.errorName)}: ${String(data.errorMessage)}` + `${String(data.errorName)}: ${String(data.errorMessage)}`, + processGoneRendererOrigin(event) ) }) } diff --git a/src/main/index.ts b/src/main/index.ts index da9c334c7e1..803ff36fbbc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1905,7 +1905,8 @@ function recordProcessGoneCrash( reason, exitCode, expectedTeardown: getExpectedTeardownScope(webContentsId), - details + details, + ...(webContentsId !== undefined ? { webContentsId } : {}) }) } diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts index f291580db40..41c108a5cbc 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts @@ -76,8 +76,11 @@ function registerHandlersWithStubStore(): void { } as never) } -function emitRendererBreadcrumb(args: unknown): void { - listeners.get('crashReports:recordBreadcrumb')?.(null, args) +function emitRendererBreadcrumb(args: unknown, senderId?: number): void { + listeners.get('crashReports:recordBreadcrumb')?.( + senderId === undefined ? null : { sender: { id: senderId } }, + args + ) } describe('renderer breadcrumb IPC routing', () => { @@ -121,6 +124,17 @@ describe('renderer breadcrumb IPC routing', () => { expect(spanEndMock).toHaveBeenCalledTimes(1) }) + it('routes the trusted sender identity into renderer breadcrumb attribution', () => { + emitRendererBreadcrumb({ name: 'renderer_error', data: { message: 'boom' } }, 42) + + expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + origin: 'renderer:42', + coalesceKey: expect.stringContaining('renderer:42') + }) + ) + }) + it('coalesces renderer rejection breadcrumbs by reason message', () => { emitRendererBreadcrumb({ name: 'renderer_unhandled_rejection', diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts index b6c8f5c51ed..2b2129436fb 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts @@ -125,10 +125,10 @@ function rendererBreadcrumbCoalesceKey( return JSON.stringify([name, message, ...sourceIdentity]) } -export function recordRendererBreadcrumbFromRenderer(args?: { - name?: unknown - data?: unknown -}): void { +export function recordRendererBreadcrumbFromRenderer( + args?: { name?: unknown; data?: unknown }, + origin?: string +): void { if (!args || typeof args.name !== 'string') { return } @@ -136,15 +136,20 @@ export function recordRendererBreadcrumbFromRenderer(args?: { if (COALESCED_RENDERER_BREADCRUMB_NAMES.has(args.name)) { const coalesceKey = rendererBreadcrumbCoalesceKey(args.name, data) if (!coalesceKey) { - recordCrashBreadcrumb(args.name, data) + if (origin) { + recordCrashBreadcrumb(args.name, data, origin) + } else { + recordCrashBreadcrumb(args.name, data) + } recordRendererBreadcrumbTrace(args.name, data) return } const coalesceResult = recordCoalescedCrashBreadcrumb({ name: args.name, data, - coalesceKey, - minIntervalMs: RENDERER_BREADCRUMB_COALESCE_MS + coalesceKey: origin ? `${origin}\u0000${coalesceKey}` : coalesceKey, + minIntervalMs: RENDERER_BREADCRUMB_COALESCE_MS, + ...(origin ? { origin } : {}) }) // Why: tracing every suppressed duplicate would preserve the same // serialization and disk churn that breadcrumb coalescing removes. @@ -157,7 +162,11 @@ export function recordRendererBreadcrumbFromRenderer(args?: { ) } } else { - recordCrashBreadcrumb(args.name, data) + if (origin) { + recordCrashBreadcrumb(args.name, data, origin) + } else { + recordCrashBreadcrumb(args.name, data) + } recordRendererBreadcrumbTrace(args.name, data) } } diff --git a/src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts b/src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts index 5592ca7ef5d..03a272a64a1 100644 --- a/src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts +++ b/src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts @@ -97,4 +97,15 @@ describe('renderer error report attribution', () => { expect(text).toContain('boundary_id') expect(text.indexOf('Attribution: unreliable')).toBeLessThan(text.indexOf('Details:')) }) + + it('does not dedupe identical errors from separate renderer windows', async () => { + const first = makeStore() + const second = makeStore() + + const firstResult = await recordRendererErrorReport(first.store, baseArgs(), 11) + const secondResult = await recordRendererErrorReport(second.store, baseArgs(), 22) + + expect(firstResult).toMatchObject({ ok: true, deduped: false }) + expect(secondResult).toMatchObject({ ok: true, deduped: false }) + }) }) diff --git a/src/main/ipc/crash-reporting-renderer-error-report.ts b/src/main/ipc/crash-reporting-renderer-error-report.ts index 3fac1f73302..581cb62d8fb 100644 --- a/src/main/ipc/crash-reporting-renderer-error-report.ts +++ b/src/main/ipc/crash-reporting-renderer-error-report.ts @@ -11,6 +11,7 @@ import { UNRELIABLE_BOUNDARY_ATTRIBUTION_NOTE } from '../../shared/react-update-depth-attribution' import type { CrashReportStore } from '../crash-reporting/crash-report-store' +import { rendererCrashBreadcrumbOrigin } from '../../shared/crash-breadcrumb-origin' import { getCrashBreadcrumbSnapshot } from '../crash-reporting/crash-breadcrumb-store' export const recentRendererErrorReportKeys = new Map() @@ -119,8 +120,12 @@ function pruneRendererErrorReportKeys(now: number): void { } } -function getRendererErrorReportKey(args: ReactErrorBoundaryReportArgs): string { +function getRendererErrorReportKey( + args: ReactErrorBoundaryReportArgs, + webContentsId?: number +): string { return JSON.stringify({ + webContentsId, boundaryId: args.boundaryId, surface: args.surface, errorName: args.errorName, @@ -131,7 +136,8 @@ function getRendererErrorReportKey(args: ReactErrorBoundaryReportArgs): string { export async function recordRendererErrorReport( store: CrashReportStore, - args: unknown + args: unknown, + webContentsId?: number ): Promise { const normalized = normalizeRendererErrorReportArgs(args) if (!normalized) { @@ -140,7 +146,7 @@ export async function recordRendererErrorReport( const now = Date.now() pruneRendererErrorReportKeys(now) - const key = getRendererErrorReportKey(normalized) + const key = getRendererErrorReportKey(normalized, webContentsId) if (now - (recentRendererErrorReportKeys.get(key) ?? 0) < RENDERER_ERROR_DEDUPE_MS) { return { ok: true, report: null, deduped: true } } @@ -186,7 +192,9 @@ export async function recordRendererErrorReport( }, // Why: React render failures are recoverable only because a boundary // caught them; persist the same recent app breadcrumbs as native crashes. - breadcrumbs: getCrashBreadcrumbSnapshot() + breadcrumbs: getCrashBreadcrumbSnapshot( + webContentsId === undefined ? undefined : rendererCrashBreadcrumbOrigin(webContentsId) + ) }) return { ok: true, report, deduped: false } diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index 95939a5f139..9b2d11cad7c 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -5,6 +5,7 @@ import { formatCrashReportText } from '../../shared/crash-reporting' import type { CrashReportStore } from '../crash-reporting/crash-report-store' +import { rendererCrashBreadcrumbOrigin } from '../../shared/crash-breadcrumb-origin' import { assertClipboardTextWriteWithinLimit, isClipboardTextWriteTooLargeError @@ -64,8 +65,12 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { ipcMain.removeAllListeners('crashReports:recordBreadcrumb') ipcMain.on( 'crashReports:recordBreadcrumb', - (_event, args?: { name?: unknown; data?: unknown }) => { - recordRendererBreadcrumbFromRenderer(args) + (event, args?: { name?: unknown; data?: unknown }) => { + const senderId = event?.sender?.id + recordRendererBreadcrumbFromRenderer( + args, + typeof senderId === 'number' ? rendererCrashBreadcrumbOrigin(senderId) : undefined + ) } ) @@ -94,9 +99,9 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { ) ipcMain.removeHandler('crashReports:recordRendererError') - ipcMain.handle('crashReports:recordRendererError', async (_event, args: unknown) => { + ipcMain.handle('crashReports:recordRendererError', async (event, args: unknown) => { try { - return await recordRendererErrorReport(store, args) + return await recordRendererErrorReport(store, args, event?.sender?.id) } catch (error) { console.error('[crash-reporting] Failed to record renderer error report:', error) return { ok: false, error: 'Failed to record renderer error report.' } diff --git a/src/shared/crash-breadcrumb-origin.ts b/src/shared/crash-breadcrumb-origin.ts new file mode 100644 index 00000000000..c9d6e1a45c6 --- /dev/null +++ b/src/shared/crash-breadcrumb-origin.ts @@ -0,0 +1,4 @@ +/** Single origin label shared by renderer breadcrumb recording and snapshots. */ +export function rendererCrashBreadcrumbOrigin(webContentsId: number): string { + return `renderer:${webContentsId}` +} diff --git a/src/shared/crash-reporting.ts b/src/shared/crash-reporting.ts index ac017162c91..830449bcc9d 100644 --- a/src/shared/crash-reporting.ts +++ b/src/shared/crash-reporting.ts @@ -19,11 +19,10 @@ export type CrashReportBreadcrumb = { createdAt: string name: string data?: CrashReportBreadcrumbData + origin?: string } -export type CrashReportBreadcrumbInput = { - createdAt: string - name: string +export type CrashReportBreadcrumbInput = Omit & { data?: Record } @@ -44,7 +43,6 @@ export type CrashReportRecord = { details: Record breadcrumbs?: CrashReportBreadcrumb[] } - export type UncapturedCrashReportContext = { createdAt: string appVersion: string @@ -54,7 +52,6 @@ export type UncapturedCrashReportContext = { electronVersion: string chromeVersion: string } - export type CrashReportCreateInput = Omit< CrashReportRecord, 'id' | 'createdAt' | 'status' | 'details' | 'breadcrumbs' @@ -62,7 +59,6 @@ export type CrashReportCreateInput = Omit< details: Record breadcrumbs?: CrashReportBreadcrumbInput[] } - export type ReactErrorBoundarySurface = | 'app-root' | 'web-root' @@ -217,7 +213,6 @@ export function sanitizeCrashReportBreadcrumbs( if (!breadcrumbs || breadcrumbs.length === 0) { return undefined } - const sanitized = breadcrumbs .slice(-MAX_BREADCRUMBS) .map((breadcrumb): CrashReportBreadcrumb | null => { @@ -225,10 +220,14 @@ export function sanitizeCrashReportBreadcrumbs( return null } const data = breadcrumb.data ? sanitizeCrashReportDetails(breadcrumb.data) : {} + const origin = breadcrumb.origin + ? sanitizeCrashReportString(breadcrumb.origin).slice(0, 80) + : '' return { createdAt: sanitizeCrashReportString(breadcrumb.createdAt), name: sanitizeCrashReportString(breadcrumb.name).slice(0, MAX_BREADCRUMB_NAME_LENGTH), - ...(Object.keys(data).length > 0 ? { data } : {}) + ...(Object.keys(data).length > 0 ? { data } : {}), + ...(origin ? { origin } : {}) } }) .filter((breadcrumb): breadcrumb is CrashReportBreadcrumb => breadcrumb !== null) From b55836d0c42c8627e5a0b1eba4a9d953d1ae1d05 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:14:20 -0700 Subject: [PATCH 13/65] fix(popover): use non-passive wheel listener (#16309) * fix(popover): use non-passive wheel listener * fix(popover): preserve wheel handler cancellation and ref cleanup * fix(popover): bridge descendant wheel cancellation --- .../cmd-j/PaletteFilterFieldOptions.tsx | 2 +- .../components/cmd-j/PaletteFilterMenu.tsx | 4 +- .../src/components/ui/popover-content-ref.ts | 44 ----- .../ui/popover-wheel-scroll.test.tsx | 180 ++++++++++++++---- .../src/components/ui/popover.test.tsx | 56 ------ src/renderer/src/components/ui/popover.tsx | 174 ++++++++++------- 6 files changed, 253 insertions(+), 207 deletions(-) delete mode 100644 src/renderer/src/components/ui/popover-content-ref.ts delete mode 100644 src/renderer/src/components/ui/popover.test.tsx diff --git a/src/renderer/src/components/cmd-j/PaletteFilterFieldOptions.tsx b/src/renderer/src/components/cmd-j/PaletteFilterFieldOptions.tsx index cfea2c37f67..95372319a33 100644 --- a/src/renderer/src/components/cmd-j/PaletteFilterFieldOptions.tsx +++ b/src/renderer/src/components/cmd-j/PaletteFilterFieldOptions.tsx @@ -275,7 +275,7 @@ export function PaletteFilterFieldOptions({ aria-label={group.heading} tabIndex={-1} onKeyDown={handleListKeyDown} - className="popover-scroll-content scrollbar-sleek overflow-y-auto py-1" + className="scrollbar-sleek overflow-y-auto py-1" style={{ maxHeight: FILTER_OPTION_LIST_MAX_HEIGHT }} >
diff --git a/src/renderer/src/components/cmd-j/PaletteFilterMenu.tsx b/src/renderer/src/components/cmd-j/PaletteFilterMenu.tsx index d352363e630..d9f8b77ac7a 100644 --- a/src/renderer/src/components/cmd-j/PaletteFilterMenu.tsx +++ b/src/renderer/src/components/cmd-j/PaletteFilterMenu.tsx @@ -25,7 +25,7 @@ function CategoryRoot({ onOpenField: (field: PaletteFilterField) => void }): React.JSX.Element { return ( - + {groups.map((group) => { const selectedCount = group.selected.length @@ -183,7 +183,7 @@ export default function PaletteFilterMenu({ collisionBoundary={portalContainer ?? undefined} onKeyDown={handleKeyDown} onCloseAutoFocus={handleCloseAutoFocus} - className="w-[290px] p-0" + className="popover-wheel-scroll w-[290px] p-0" > {activeGroup == null ? ( diff --git a/src/renderer/src/components/ui/popover-content-ref.ts b/src/renderer/src/components/ui/popover-content-ref.ts deleted file mode 100644 index cbdf783471d..00000000000 --- a/src/renderer/src/components/ui/popover-content-ref.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as React from 'react' -import type { Popover as PopoverPrimitive } from 'radix-ui' - -type PopoverContentRef = React.ComponentProps['ref'] - -export function updatePopoverContentRef( - forwardedRef: PopoverContentRef | undefined, - node: HTMLDivElement | null, - cancelWheelFrames: () => void -): (() => void) | undefined { - if (node === null) { - cancelWheelFrames() - if (typeof forwardedRef === 'function') { - forwardedRef(null) - } else if (forwardedRef) { - forwardedRef.current = null - } - return undefined - } - - if (typeof forwardedRef === 'function') { - const cleanup = forwardedRef(node) - // Why: React callback refs may return cleanup; wrapping the Radix ref must - // preserve that cleanup instead of replacing it with a null callback. - return () => { - cancelWheelFrames() - if (typeof cleanup === 'function') { - cleanup() - } else { - forwardedRef(null) - } - } - } - - if (forwardedRef) { - forwardedRef.current = node - } - return () => { - cancelWheelFrames() - if (forwardedRef) { - forwardedRef.current = null - } - } -} diff --git a/src/renderer/src/components/ui/popover-wheel-scroll.test.tsx b/src/renderer/src/components/ui/popover-wheel-scroll.test.tsx index c369d3d2d2c..dcf1a804df5 100644 --- a/src/renderer/src/components/ui/popover-wheel-scroll.test.tsx +++ b/src/renderer/src/components/ui/popover-wheel-scroll.test.tsx @@ -1,5 +1,12 @@ // @vitest-environment happy-dom -import { act } from 'react' +import { + act, + createRef, + type Ref, + type RefCallback, + type WheelEvent as ReactWheelEvent, + type WheelEventHandler +} from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Popover, PopoverContent, PopoverTrigger } from './popover' @@ -13,13 +20,31 @@ function makeScrollable(el: HTMLElement, scrollHeight: number, clientHeight: num Object.defineProperty(el, 'clientHeight', { value: clientHeight, configurable: true }) } -function wheel(el: HTMLElement, deltaY: number): void { +function wheel(el: HTMLElement, deltaY: number): WheelEvent { + const event = new WheelEvent('wheel', { deltaY, bubbles: true, cancelable: true }) act(() => { - el.dispatchEvent(new WheelEvent('wheel', { deltaY, bubbles: true, cancelable: true })) + el.dispatchEvent(event) }) + return event } -function renderPopover(contentClassName: string, nested: boolean): void { +function renderPopover( + contentClassName: string, + nested: boolean, + { + onWheel, + onWheelCapture, + innerOnWheel, + contentRef, + portalContainer + }: { + onWheel?: WheelEventHandler + onWheelCapture?: WheelEventHandler + innerOnWheel?: WheelEventHandler + contentRef?: Ref + portalContainer?: HTMLElement + } = {} +): void { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -27,13 +52,23 @@ function renderPopover(contentClassName: string, nested: boolean): void { root!.render( open - + {nested ? (
-
tall
+
+ tall +
) : ( -
tall
+
+ tall +
)}
@@ -42,17 +77,8 @@ function renderPopover(contentClassName: string, nested: boolean): void { } describe('PopoverContent wheel shim', () => { - let rafCallbacks: FrameRequestCallback[] - beforeEach(() => { root = null - // The shim defers the scroll write to rAF; capture and flush it explicitly. - rafCallbacks = [] - vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { - rafCallbacks.push(cb) - return rafCallbacks.length - }) - vi.stubGlobal('cancelAnimationFrame', () => {}) }) afterEach(() => { @@ -63,14 +89,6 @@ describe('PopoverContent wheel shim', () => { vi.unstubAllGlobals() }) - const flushFrames = (): void => { - const pending = rafCallbacks - rafCallbacks = [] - for (const cb of pending) { - cb(0) - } - } - it('scrolls a nested viewport when the content itself cannot scroll', () => { // The workspace-cleanup Filters panel: a flex column whose PopoverContent is // overflow-hidden, with a ScrollArea viewport above a pinned footer. @@ -80,11 +98,11 @@ describe('PopoverContent wheel shim', () => { makeScrollable(content, 800, 400) makeScrollable(viewport, 1000, 400) - wheel(document.querySelector('[data-testid="inner"]')!, 120) - flushFrames() + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) expect(viewport.scrollTop).toBe(120) expect(content.scrollTop).toBe(0) + expect(event.defaultPrevented).toBe(true) }) it('still scrolls the content itself when it is the scroller', () => { @@ -95,10 +113,10 @@ describe('PopoverContent wheel shim', () => { content.style.overflowY = 'auto' makeScrollable(content, 1000, 400) - wheel(document.querySelector('[data-testid="inner"]')!, 120) - flushFrames() + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) expect(content.scrollTop).toBe(120) + expect(event.defaultPrevented).toBe(true) }) it('leaves popovers that did not opt in alone', () => { @@ -106,10 +124,10 @@ describe('PopoverContent wheel shim', () => { const viewport = document.querySelector('[data-testid="viewport"]')! makeScrollable(viewport, 1000, 400) - wheel(document.querySelector('[data-testid="inner"]')!, 120) - flushFrames() + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) expect(viewport.scrollTop).toBe(0) + expect(event.defaultPrevented).toBe(false) }) it('ignores vertically clipped elements and horizontal-only scrollers', () => { @@ -124,11 +142,11 @@ describe('PopoverContent wheel shim', () => { Object.defineProperty(viewport, 'scrollWidth', { value: 1000, configurable: true }) Object.defineProperty(viewport, 'clientWidth', { value: 400, configurable: true }) - wheel(document.querySelector('[data-testid="inner"]')!, 120) - flushFrames() + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) expect(content.scrollTop).toBe(0) expect(viewport.scrollTop).toBe(0) + expect(event.defaultPrevented).toBe(false) }) it('does nothing when no element in the target chain can scroll', () => { @@ -140,11 +158,11 @@ describe('PopoverContent wheel shim', () => { makeScrollable(content, 1000, 400) makeScrollable(viewport, 1000, 400) - wheel(document.querySelector('[data-testid="inner"]')!, 120) - flushFrames() + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) expect(content.scrollTop).toBe(0) expect(viewport.scrollTop).toBe(0) + expect(event.defaultPrevented).toBe(false) }) it('runs for the shim-only marker, which carries no styling', () => { @@ -157,9 +175,99 @@ describe('PopoverContent wheel shim', () => { makeScrollable(content, 400, 400) makeScrollable(viewport, 1000, 400) - wheel(document.querySelector('[data-testid="inner"]')!, 120) - flushFrames() + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) expect(viewport.scrollTop).toBe(120) + expect(event.defaultPrevented).toBe(true) + }) + + it('lets a consumer prevent the shim scroll', () => { + const onWheel = vi.fn((event: ReactWheelEvent) => { + Object.defineProperty(event.nativeEvent, 'preventDefault', { value: vi.fn() }) + event.preventDefault() + }) + const portalContainer = document.createElement('div') + document.body.appendChild(portalContainer) + renderPopover('popover-wheel-scroll', true, { onWheel, portalContainer }) + const viewport = document.querySelector('[data-testid="viewport"]')! + viewport.style.overflowY = 'auto' + makeScrollable(viewport, 1000, 400) + + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) + + expect(onWheel).toHaveBeenCalledOnce() + expect(event.defaultPrevented).toBe(false) + expect(viewport.scrollTop).toBe(0) + }) + + it('respects consumer capture cancellation', () => { + const onWheelCapture = vi.fn((event: ReactWheelEvent) => { + Object.defineProperty(event.nativeEvent, 'preventDefault', { value: vi.fn() }) + event.preventDefault() + }) + renderPopover('popover-wheel-scroll', true, { onWheelCapture }) + const viewport = document.querySelector('[data-testid="viewport"]')! + viewport.style.overflowY = 'auto' + makeScrollable(viewport, 1000, 400) + + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) + + expect(onWheelCapture).toHaveBeenCalledOnce() + expect(event.defaultPrevented).toBe(false) + expect(viewport.scrollTop).toBe(0) + }) + + it('respects descendant cancellation', () => { + const innerOnWheel = vi.fn((event: ReactWheelEvent) => { + // Model React's passive delegated wheel event: synthetic cancellation does not + // update the native event observed by the shim. + Object.defineProperty(event.nativeEvent, 'preventDefault', { value: vi.fn() }) + event.preventDefault() + }) + renderPopover('popover-wheel-scroll', true, { innerOnWheel }) + const viewport = document.querySelector('[data-testid="viewport"]')! + viewport.style.overflowY = 'auto' + makeScrollable(viewport, 1000, 400) + + const event = wheel(document.querySelector('[data-testid="inner"]')!, 120) + + expect(innerOnWheel).toHaveBeenCalledOnce() + expect(event.defaultPrevented).toBe(false) + expect(viewport.scrollTop).toBe(0) + }) + + it('preserves callback ref cleanup', () => { + const cleanup = vi.fn() + const contentRef: RefCallback = vi.fn(() => cleanup) + renderPopover('', false, { contentRef }) + + expect(contentRef).toHaveBeenCalledTimes(1) + expect(contentRef).toHaveBeenCalledWith( + document.querySelector('[data-slot="popover-content"]') + ) + + act(() => root!.unmount()) + root = null + expect(cleanup).toHaveBeenCalledOnce() + expect(contentRef).toHaveBeenCalledTimes(1) + }) + + it('clears callback refs that do not return cleanup', () => { + const callbackRef = vi.fn((_node: HTMLDivElement | null): void => {}) + renderPopover('', false, { contentRef: callbackRef }) + + act(() => root!.unmount()) + root = null + expect(callbackRef).toHaveBeenLastCalledWith(null) + }) + + it('clears object refs on detach', () => { + const objectRef = createRef() + renderPopover('', false, { contentRef: objectRef }) + expect(objectRef.current).toBeInstanceOf(HTMLDivElement) + + act(() => root!.unmount()) + root = null + expect(objectRef.current).toBeNull() }) }) diff --git a/src/renderer/src/components/ui/popover.test.tsx b/src/renderer/src/components/ui/popover.test.tsx deleted file mode 100644 index 3d84a1f8e33..00000000000 --- a/src/renderer/src/components/ui/popover.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { updatePopoverContentRef } from './popover-content-ref' - -describe('updatePopoverContentRef', () => { - it('preserves callback ref cleanup when content detaches', () => { - const node = {} as HTMLDivElement - const cancelWheelFrames = vi.fn() - const cleanup = vi.fn() - const ref = vi.fn(() => cleanup) - - const detach = updatePopoverContentRef(ref, node, cancelWheelFrames) - detach?.() - - expect(ref).toHaveBeenCalledTimes(1) - expect(ref).toHaveBeenCalledWith(node) - expect(cancelWheelFrames).toHaveBeenCalledTimes(1) - expect(cleanup).toHaveBeenCalledTimes(1) - }) - - it('clears callback refs that do not return cleanup', () => { - const node = {} as HTMLDivElement - const cancelWheelFrames = vi.fn() - const ref = vi.fn() - - const detach = updatePopoverContentRef(ref, node, cancelWheelFrames) - detach?.() - - expect(ref).toHaveBeenNthCalledWith(1, node) - expect(ref).toHaveBeenNthCalledWith(2, null) - expect(cancelWheelFrames).toHaveBeenCalledTimes(1) - }) - - it('clears object refs when content detaches', () => { - const node = {} as HTMLDivElement - const cancelWheelFrames = vi.fn() - const ref: { current: HTMLDivElement | null } = { current: null } - - const detach = updatePopoverContentRef(ref, node, cancelWheelFrames) - expect(ref.current).toBe(node) - - detach?.() - - expect(ref.current).toBeNull() - expect(cancelWheelFrames).toHaveBeenCalledTimes(1) - }) - - it('cancels pending frames when React supplies a null node', () => { - const cancelWheelFrames = vi.fn() - const ref: { current: HTMLDivElement | null } = { current: {} as HTMLDivElement } - - updatePopoverContentRef(ref, null, cancelWheelFrames) - - expect(ref.current).toBeNull() - expect(cancelWheelFrames).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/renderer/src/components/ui/popover.tsx b/src/renderer/src/components/ui/popover.tsx index f76bc2aee8a..01365ba665b 100644 --- a/src/renderer/src/components/ui/popover.tsx +++ b/src/renderer/src/components/ui/popover.tsx @@ -4,7 +4,9 @@ import * as React from 'react' import { Popover as PopoverPrimitive } from 'radix-ui' import { cn } from '@/lib/utils' -import { updatePopoverContentRef } from './popover-content-ref' + +// React delegates wheel passively, so native defaultPrevented may not reflect synthetic cancellation. +const consumerPreventedWheelEvents = new WeakSet() function Popover(props: React.ComponentProps) { return @@ -22,7 +24,10 @@ function PopoverAnchor(props: React.ComponentProps node.clientHeight) { @@ -36,6 +41,75 @@ function resolvePopoverScroller(target: EventTarget | null, content: HTMLElement return null } +function handlePopoverWheel(event: WheelEvent, content: HTMLDivElement): void { + if ( + event.defaultPrevented || + consumerPreventedWheelEvents.has(event) || + !(event.target instanceof Node) || + !content.contains(event.target) + ) { + return + } + + // Why two markers: `popover-scroll-content` also imposes a 15rem max-height and its + // own overflow, while `popover-wheel-scroll` opts into the shim alone. + if ( + !content.classList.contains('popover-scroll-content') && + !content.classList.contains('popover-wheel-scroll') + ) { + return + } + + const el = resolvePopoverScroller(event.target, content) + if (!el) { + return + } + + const delta = + event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? event.deltaY * 16 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? event.deltaY * el.clientHeight + : event.deltaY + const maxScrollTop = el.scrollHeight - el.clientHeight + const nextScrollTop = Math.max(0, Math.min(maxScrollTop, el.scrollTop + delta)) + + // Why: Radix dialog scroll-lock swallows native wheel scrolling in portaled popovers. + if (nextScrollTop !== el.scrollTop) { + event.preventDefault() + el.scrollTop = nextScrollTop + } +} + +type PopoverContentRef = React.ComponentProps['ref'] + +function attachPopoverContent( + content: HTMLDivElement, + portalContainer: HTMLElement | null | undefined, + forwardedRef: PopoverContentRef | undefined +): () => void { + // React delegates portal events here first, preserving onWheel-before-shim ordering. + const wheelTarget = portalContainer ?? content.ownerDocument.body + const handleWheel = (event: WheelEvent): void => handlePopoverWheel(event, content) + wheelTarget.addEventListener('wheel', handleWheel, { passive: false }) + + const refCleanup = typeof forwardedRef === 'function' ? forwardedRef(content) : undefined + if (forwardedRef && typeof forwardedRef !== 'function') { + forwardedRef.current = content + } + + return () => { + wheelTarget.removeEventListener('wheel', handleWheel) + if (typeof refCleanup === 'function') { + refCleanup() + } else if (typeof forwardedRef === 'function') { + forwardedRef(null) + } else if (forwardedRef) { + forwardedRef.current = null + } + } +} + function PopoverContent({ className, align = 'center', @@ -43,82 +117,45 @@ function PopoverContent({ portalContainer, style, onWheel, + onWheelCapture, ref: forwardedRef, ...props }: React.ComponentProps & { portalContainer?: HTMLElement | null }) { - const wheelFrameIdsRef = React.useRef>(new Set()) - - const cancelWheelFrames = React.useCallback(() => { - for (const frameId of wheelFrameIdsRef.current) { - cancelAnimationFrame(frameId) - } - wheelFrameIdsRef.current.clear() - }, []) - - const setContentRef = React.useCallback( - (node: HTMLDivElement | null) => { - // Why: the wheel shim schedules frames against the content node; cancel - // them when Radix removes that node instead of from a passive Effect. - return updatePopoverContentRef(forwardedRef, node, cancelWheelFrames) - }, - [cancelWheelFrames, forwardedRef] - ) - - const handleWheel = React.useCallback( - (event: React.WheelEvent) => { + const handleConsumerWheel = React.useCallback( + (event: React.WheelEvent): void => { onWheel?.(event) if (event.defaultPrevented) { - return - } - - const content = event.currentTarget - // Why two markers: `popover-scroll-content` also imposes a 15rem max-height and its - // own overflow, which a popover that manages its own layout (a fixed-height flex - // column over a pinned footer) must not inherit. `popover-wheel-scroll` opts into - // the shim alone. - if ( - !content.classList.contains('popover-scroll-content') && - !content.classList.contains('popover-wheel-scroll') - ) { - return - } - - // Why resolve rather than use currentTarget: a popover whose content is a flex - // column with a nested viewport (a ScrollArea above a pinned footer) is itself - // overflow-hidden, so the scroller is a descendant under the pointer. - const el = resolvePopoverScroller(event.target, content) - if (!el) { - return - } - - const delta = - event.deltaMode === WheelEvent.DOM_DELTA_LINE - ? event.deltaY * 16 - : event.deltaMode === WheelEvent.DOM_DELTA_PAGE - ? event.deltaY * el.clientHeight - : event.deltaY - const maxScrollTop = el.scrollHeight - el.clientHeight - const nextScrollTop = Math.max(0, Math.min(maxScrollTop, el.scrollTop + delta)) - - // Why: issue drawers are Radix dialogs with scroll-lock. These popovers - // are portaled outside the dialog subtree, so native wheel scrolling is - // swallowed even though the scrollbar can be dragged. - if (nextScrollTop !== el.scrollTop) { - const previousScrollTop = el.scrollTop - event.stopPropagation() - const frameId = requestAnimationFrame(() => { - wheelFrameIdsRef.current.delete(frameId) - if (el.scrollTop === previousScrollTop) { - el.scrollTop = nextScrollTop - } - }) - wheelFrameIdsRef.current.add(frameId) + consumerPreventedWheelEvents.add(event.nativeEvent) } }, [onWheel] ) + const handleConsumerWheelCapture = React.useCallback( + (event: React.WheelEvent): void => { + onWheelCapture?.(event) + if (event.defaultPrevented) { + consumerPreventedWheelEvents.add(event.nativeEvent) + } + }, + [onWheelCapture] + ) + + const setContentRef = React.useCallback( + (node: HTMLDivElement | null) => { + if (node) { + return attachPopoverContent(node, portalContainer, forwardedRef) + } + if (typeof forwardedRef === 'function') { + forwardedRef(null) + } else if (forwardedRef) { + forwardedRef.current = null + } + return undefined + }, + [forwardedRef, portalContainer] + ) return ( @@ -144,7 +181,8 @@ function PopoverContent({ WebkitAppRegion: 'no-drag' } as React.CSSProperties } - onWheel={handleWheel} + onWheel={handleConsumerWheel} + onWheelCapture={handleConsumerWheelCapture} {...props} /> From 701b957bc19ca6c22ea420b7a23f162aab88d156 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:06:18 -0700 Subject: [PATCH 14/65] perf(renderer): narrow appearance store subscriptions (#16322) --- .../use-document-appearance.test.tsx | 78 +++++++++++++++++++ .../src/app-shell/use-document-appearance.ts | 15 ++-- 2 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/app-shell/use-document-appearance.test.tsx diff --git a/src/renderer/src/app-shell/use-document-appearance.test.tsx b/src/renderer/src/app-shell/use-document-appearance.test.tsx new file mode 100644 index 00000000000..b06b9c7517c --- /dev/null +++ b/src/renderer/src/app-shell/use-document-appearance.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../shared/constants' +import { useAppStore } from '../store' + +const mocks = vi.hoisted(() => ({ + applyDocumentTheme: vi.fn(), + buildAppFontFamily: vi.fn((fontFamily: string | null | undefined) => fontFamily ?? '') +})) + +vi.mock('../lib/document-theme', () => ({ + applyDocumentTheme: mocks.applyDocumentTheme +})) + +vi.mock('@/lib/app-font-family', () => ({ + buildAppFontFamily: mocks.buildAppFontFamily +})) + +vi.mock('../runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync: vi.fn() +})) + +import { useDocumentAppearance } from './use-document-appearance' + +const initialState = useAppStore.getState() + +describe('useDocumentAppearance', () => { + beforeEach(() => { + mocks.applyDocumentTheme.mockReset() + mocks.buildAppFontFamily.mockClear() + useAppStore.setState({ + settings: { + ...getDefaultSettings('/tmp'), + theme: 'dark' + } + }) + }) + + afterEach(() => { + useAppStore.setState(initialState, true) + }) + + it('ignores unrelated settings object replacements', () => { + const { unmount } = renderHook(() => useDocumentAppearance()) + + expect(mocks.applyDocumentTheme).toHaveBeenCalledTimes(1) + expect(mocks.buildAppFontFamily).toHaveBeenCalledTimes(1) + + act(() => { + const settings = useAppStore.getState().settings! + useAppStore.setState({ + settings: { ...settings, editorAutoSave: !settings.editorAutoSave } + }) + }) + + expect(mocks.applyDocumentTheme).toHaveBeenCalledTimes(1) + expect(mocks.buildAppFontFamily).toHaveBeenCalledTimes(1) + unmount() + }) + + it('still applies changed theme and font values', () => { + const { unmount } = renderHook(() => useDocumentAppearance()) + + act(() => { + const settings = useAppStore.getState().settings! + useAppStore.setState({ + settings: { ...settings, theme: 'light', appFontFamily: 'Monaco' } + }) + }) + + expect(mocks.applyDocumentTheme).toHaveBeenLastCalledWith('light') + expect(mocks.applyDocumentTheme).toHaveBeenCalledTimes(2) + expect(mocks.buildAppFontFamily).toHaveBeenLastCalledWith('Monaco') + expect(mocks.buildAppFontFamily).toHaveBeenCalledTimes(2) + unmount() + }) +}) diff --git a/src/renderer/src/app-shell/use-document-appearance.ts b/src/renderer/src/app-shell/use-document-appearance.ts index 7f0c3f8af72..3d4c8e23daf 100644 --- a/src/renderer/src/app-shell/use-document-appearance.ts +++ b/src/renderer/src/app-shell/use-document-appearance.ts @@ -6,17 +6,18 @@ import { useAppStore } from '../store' /** Applies the settings-driven theme and app font to the document root. */ export function useDocumentAppearance(): void { - const settings = useAppStore((s) => s.settings) + const theme = useAppStore((s) => s.settings?.theme) + const appFontFamily = useAppStore((s) => s.settings?.appFontFamily) useEffect(() => { - if (!settings) { + if (!theme) { return } - if (settings.theme === 'dark') { + if (theme === 'dark') { applyDocumentTheme('dark') return undefined - } else if (settings.theme === 'light') { + } else if (theme === 'light') { applyDocumentTheme('light') return undefined } @@ -30,12 +31,12 @@ export function useDocumentAppearance(): void { } mq.addEventListener('change', handler) return () => mq.removeEventListener('change', handler) - }, [settings]) + }, [theme]) useEffect(() => { document.documentElement.style.setProperty( '--app-font-family', - buildAppFontFamily(settings?.appFontFamily) + buildAppFontFamily(appFontFamily) ) - }, [settings?.appFontFamily]) + }, [appFontFamily]) } From 7e7947665cf26f16e296120ca1403c2dc047775c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:11:56 -0700 Subject: [PATCH 15/65] fix(relay): gate relay control work on proven broker liveness (#16021) --- ...ktop-relay-service-broker-liveness.test.ts | 108 ++++++++++++++++++ .../runtime/relay/desktop-relay-service.ts | 3 +- .../relay-auth-coordinator-recovery.test.ts | 34 ++++++ .../relay/relay-auth-coordinator.test.ts | 12 +- .../runtime/relay/relay-auth-coordinator.ts | 14 ++- 5 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts diff --git a/src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts b/src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts new file mode 100644 index 00000000000..7eac4f1f324 --- /dev/null +++ b/src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaCloudAuthConfig } from '../../orca-profiles/profile-cloud-auth-config' +import type { OrcaRuntimeRpcServer } from '../runtime-rpc' + +const fakes = vi.hoisted(() => ({ + readRelayAuthContext: vi.fn(), + brokers: [] as { live: boolean }[] +})) + +vi.mock('./relay-auth-context', () => ({ readRelayAuthContext: fakes.readRelayAuthContext })) + +vi.mock('./relay-session-broker', () => { + // Mirrors the real broker: a control that died rejects with relay_control_not_active. + class RelaySessionBroker { + live = true + readonly hostId = 'relay-host-1' + readonly ownerIdentityKey = 'user-1\0profile-1\0org-1' + readonly endpoint = { v: 1 as const, relayHostId: 'relay-host-1' } + isLive(): boolean { + return this.live + } + closeNow(): void { + this.live = false + } + async createPairingRelay(relayDeviceId: string): Promise { + if (!this.live) { + throw new Error('relay_control_not_active') + } + return { v: 1, relayHostId: this.hostId, relayDeviceId, inviteExpiresAt: 0 } + } + static connect = vi.fn(async () => { + const broker = new RelaySessionBroker() + fakes.brokers.push(broker) + return broker + }) + } + return { RelaySessionBroker } +}) + +import { DesktopRelayService } from './desktop-relay-service' + +function service(): DesktopRelayService { + fakes.brokers.length = 0 + fakes.readRelayAuthContext.mockResolvedValue({ + identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' }, + accessToken: 'access-1', + relayEntitled: true + }) + const runtimeRpc = { + getE2EEKeypair: () => ({ + publicKey: new Uint8Array(32).fill(7), + secretKey: new Uint8Array(32).fill(9), + publicKeyB64: 'x' + }), + getMobileSocketWiring: () => ({ attachTransport: () => () => {} }), + getRelayRevokeOutbox: () => ({ pendingFor: () => [], remove: vi.fn() }), + getDeviceRegistry: () => ({ + listDevices: () => [], + getDevice: () => ({ deviceId: 'device-1', scope: 'mobile' }), + getMobilePairingConnectionMode: () => 'automatic' + }) + } as unknown as OrcaRuntimeRpcServer + return new DesktopRelayService({ + authConfig: { + relayDirectorUrl: 'https://relay.example.test', + relayTokenEndpoint: 'https://login.example.test/relay-token' + } as OrcaCloudAuthConfig, + userDataPath: '/tmp/orca-relay-liveness-test', + appVersion: '1.4.188', + runtimeRpc, + onStatus: () => {} + }) +} + +describe('DesktopRelayService broker liveness', () => { + it('pairs through a replacement when the owned broker control died', async () => { + // Why: ownership stays 'valid' after a control socket dies, so the stale + // handle otherwise reaches create_pairing_relay and fails the pairing. + const relayService = service() + try { + await expect(relayService.createPairingRelay('device-1')).resolves.toMatchObject({ + binding: { relayDeviceId: 'device-1' } + }) + expect(fakes.brokers).toHaveLength(1) + + fakes.brokers[0]!.live = false + + await expect(relayService.createPairingRelay('device-2')).resolves.toMatchObject({ + binding: { relayDeviceId: 'device-2' } + }) + expect(fakes.brokers).toHaveLength(2) + expect(fakes.brokers[1]!.live).toBe(true) + } finally { + relayService.stop() + } + }) + + it('keeps using a live broker instead of replacing it', async () => { + const relayService = service() + try { + await relayService.createPairingRelay('device-1') + await relayService.createPairingRelay('device-2') + expect(fakes.brokers).toHaveLength(1) + } finally { + relayService.stop() + } + }) +}) diff --git a/src/main/runtime/relay/desktop-relay-service.ts b/src/main/runtime/relay/desktop-relay-service.ts index 6ce62270750..55b993bd5a6 100644 --- a/src/main/runtime/relay/desktop-relay-service.ts +++ b/src/main/runtime/relay/desktop-relay-service.ts @@ -297,8 +297,7 @@ export class DesktopRelayService { } private async activeBrokerForDemand(): Promise { - const broker = - this.coordinator.getActiveBroker() ?? (await this.coordinator.waitForActiveBroker()) + const broker = this.coordinator.getLiveBroker() ?? (await this.coordinator.waitForLiveBroker()) return broker instanceof RelaySessionBroker ? broker : null } diff --git a/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts b/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts index 8bfc71688e0..1cbdb600568 100644 --- a/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts +++ b/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts @@ -259,6 +259,40 @@ describe('RelayAuthCoordinator transient recovery', () => { }) describe('RelayAuthCoordinator liveness safety net', () => { + it('withholds a dead broker from control work while identity matching still sees it', async () => { + vi.useFakeTimers() + const dead = { closeNow: vi.fn(), isLive: () => false } + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker: vi.fn().mockResolvedValue(dead), + onStatus: vi.fn(), + random: () => 0.5 + }) + + coordinator.reconcile() + await vi.advanceTimersByTimeAsync(0) + expect(coordinator.getActiveBroker()).toBe(dead) + expect(coordinator.getLiveBroker()).toBeNull() + }) + + it('treats a broker that cannot report liveness as usable, not dead', async () => { + // Why: unverifiable is not exited — only a broker that proves its control + // died is withheld; silence must never cost a working relay. + vi.useFakeTimers() + const unverifiable = { closeNow: vi.fn() } + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker: vi.fn().mockResolvedValue(unverifiable), + onStatus: vi.fn(), + random: () => 0.5 + }) + + coordinator.reconcile() + await vi.advanceTimersByTimeAsync(0) + expect(coordinator.getLiveBroker()).toBe(unverifiable) + await expect(coordinator.waitForLiveBroker()).resolves.toBe(unverifiable) + }) + it('reopens a broker that died leaving no retry timer behind', async () => { // Why: an auth refresh failing past token expiry closes the broker with // no timer; only ensureLive (periodic/power-resume) can revive it. diff --git a/src/main/runtime/relay/relay-auth-coordinator.test.ts b/src/main/runtime/relay/relay-auth-coordinator.test.ts index 005b33fb8df..819ccf15da5 100644 --- a/src/main/runtime/relay/relay-auth-coordinator.test.ts +++ b/src/main/runtime/relay/relay-auth-coordinator.test.ts @@ -26,7 +26,7 @@ describe('RelayAuthCoordinator', () => { onStatus: (status) => statuses.push(status) }) coordinator.reconcile() - await coordinator.waitForActiveBroker() + await coordinator.waitForLiveBroker() expect(openBroker).not.toHaveBeenCalled() expect(statuses.at(-1)).toBe('standby') }) @@ -43,10 +43,10 @@ describe('RelayAuthCoordinator', () => { lingerMs: 250 }) coordinator.reconcile() - await coordinator.waitForActiveBroker() + await coordinator.waitForLiveBroker() demanded = true coordinator.reconcile() - await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker) + await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker) demanded = false coordinator.reconcile() await vi.waitFor(() => expect(statuses.at(-1)).toBe('standby')) @@ -65,7 +65,7 @@ describe('RelayAuthCoordinator', () => { lingerMs: 20 }) coordinator.reconcile() - await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker) + await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker) demanded = false coordinator.reconcile() demanded = true @@ -85,10 +85,10 @@ describe('RelayAuthCoordinator', () => { lingerMs: 10_000 }) coordinator.reconcile() - await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker) + await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker) current = { ...context, identity: { ...context.identity, profileId: 'profile-2' } } coordinator.reconcile() - await coordinator.waitForActiveBroker() + await coordinator.waitForLiveBroker() expect(broker.closeNow).toHaveBeenCalledOnce() }) diff --git a/src/main/runtime/relay/relay-auth-coordinator.ts b/src/main/runtime/relay/relay-auth-coordinator.ts index f6539842f8a..ae439a320d9 100644 --- a/src/main/runtime/relay/relay-auth-coordinator.ts +++ b/src/main/runtime/relay/relay-auth-coordinator.ts @@ -88,10 +88,18 @@ export class RelayAuthCoordinator { this.options.onStatus('offline') } + // Raw ownership handle for identity matching (revoke routing); control work uses getLiveBroker. getActiveBroker(): CoordinatedRelayBroker | null { return this.ownership?.valid ? this.ownership.broker : null } + // Why: ownership stays valid across a control death, so control work must + // apply the same liveness gate reconcile does; unprovable liveness stays usable. + getLiveBroker(): CoordinatedRelayBroker | null { + const broker = this.getActiveBroker() + return broker && (broker.isLive?.() ?? true) ? broker : null + } + // Why: some broker deaths end with no retry timer — an auth refresh that // fails past token expiry (laptop sleep), or a transient context read that // returned null at open. Periodic/power-resume callers use this as a @@ -108,16 +116,16 @@ export class RelayAuthCoordinator { this.beginReconcile(false) } - async waitForActiveBroker(): Promise { + async waitForLiveBroker(): Promise { while (!this.stopped) { - const broker = this.getActiveBroker() + const broker = this.getLiveBroker() if (broker) { return broker } const pending = this.latestReconcile await pending if (pending === this.latestReconcile) { - return this.getActiveBroker() + return this.getLiveBroker() } } return null From 31562c5b27df6c6848a085edee18ca7a26fa4a22 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:12:42 -0700 Subject: [PATCH 16/65] fix(windows): attach interactive login children to console input Verified on native Windows awin at the exact PR head with Electron CDP/Playwright: the Claude sign-in console is visible, cancellation after console launch restores Add Account state, and the login process/PID/temp cleanup completes. --- src/cli/handlers/account.test.ts | 59 +++++++ src/cli/handlers/account.ts | 19 ++- ...aude-account-service-login-process.test.ts | 45 +++++- .../claude-windows-interactive-login.test.ts | 148 ++++++++++++++++++ src/main/claude-accounts/service.ts | 80 +++++++--- .../codex-windows-interactive-login.test.ts | 96 ++++++++++++ src/main/codex-accounts/service.ts | 50 ++++-- src/main/win32-utils.test.ts | 42 +++++ src/main/win32-utils.ts | 1 + src/shared/windows-batch-spawn.ts | 29 +++- src/shared/windows-console-input.test.ts | 60 +++++++ src/shared/windows-console-input.ts | 68 ++++++++ .../windows-interactive-login-spawn.test.ts | 97 ++++++++++++ src/shared/windows-interactive-login-spawn.ts | 98 ++++++++++++ 14 files changed, 842 insertions(+), 50 deletions(-) create mode 100644 src/main/claude-accounts/claude-windows-interactive-login.test.ts create mode 100644 src/main/codex-accounts/codex-windows-interactive-login.test.ts create mode 100644 src/shared/windows-console-input.test.ts create mode 100644 src/shared/windows-console-input.ts create mode 100644 src/shared/windows-interactive-login-spawn.test.ts create mode 100644 src/shared/windows-interactive-login-spawn.ts diff --git a/src/cli/handlers/account.test.ts b/src/cli/handlers/account.test.ts index 8642df27ea4..8916b124fbc 100644 --- a/src/cli/handlers/account.test.ts +++ b/src/cli/handlers/account.test.ts @@ -12,6 +12,7 @@ const { resolveCliCommandMock, rmSyncMock, spawnMock, + stdioForWindowsInteractiveChildMock, writeKeychainMock } = vi.hoisted(() => ({ deleteKeychainMock: vi.fn(), @@ -20,6 +21,7 @@ const { resolveCliCommandMock: vi.fn(), rmSyncMock: vi.fn(), spawnMock: vi.fn(), + stdioForWindowsInteractiveChildMock: vi.fn(), writeKeychainMock: vi.fn() })) @@ -45,6 +47,9 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({ getVersionManagerBinPaths: getVersionManagerBinPathsMock, resolveCliCommand: resolveCliCommandMock })) +vi.mock('../../shared/windows-console-input', () => ({ + stdioForWindowsInteractiveChild: stdioForWindowsInteractiveChildMock +})) import { ACCOUNT_HANDLERS } from './account' import type { HandlerContext } from '../dispatch' @@ -105,6 +110,10 @@ describe('account CLI handlers', () => { beforeEach(() => { Object.defineProperty(process, 'platform', originalPlatform) spawnMock.mockReset().mockImplementation(() => successfulChild()) + stdioForWindowsInteractiveChildMock.mockReset().mockImplementation((json: boolean) => ({ + stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'], + dispose: vi.fn() + })) resolveCliCommandMock.mockReset().mockImplementation((command: string) => command) getVersionManagerBinPathsMock.mockReset().mockReturnValue([]) readKeychainMock.mockReset().mockResolvedValue(null) @@ -159,6 +168,56 @@ describe('account CLI handlers', () => { }) }) + it('passes Windows console device handles to the login child instead of inheriting Electron stdio', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const dispose = vi.fn() + stdioForWindowsInteractiveChildMock.mockReturnValue({ + stdio: [11, 'inherit', 'inherit'], + dispose + }) + + await ACCOUNT_HANDLERS['account add'](context('codex')) + + expect(spawnMock).toHaveBeenCalledWith( + 'codex', + ['login', '--device-auth'], + expect.objectContaining({ stdio: [11, 'inherit', 'inherit'] }) + ) + expect(dispose).toHaveBeenCalledTimes(1) + }) + + it('disposes the Windows console input fd when spawn throws synchronously', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const dispose = vi.fn() + stdioForWindowsInteractiveChildMock.mockReturnValue({ + stdio: [11, 'inherit', 'inherit'], + dispose + }) + spawnMock.mockImplementationOnce(() => { + throw new Error('invalid stdio') + }) + + await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow('invalid stdio') + + expect(dispose).toHaveBeenCalledOnce() + }) + + it('keeps JSON login prompts off the CLI stdout envelope when console fds are attached', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + stdioForWindowsInteractiveChildMock.mockReturnValue({ + stdio: [11, process.stderr, 'inherit'], + dispose: vi.fn() + }) + + await ACCOUNT_HANDLERS['account add'](context('codex', true)) + + expect(spawnMock).toHaveBeenCalledWith( + 'codex', + ['login', '--device-auth'], + expect.objectContaining({ stdio: [11, process.stderr, 'inherit'] }) + ) + }) + it('routes Windows package-manager shims through the safe cmd launcher', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) resolveCliCommandMock.mockReturnValue('C:\\tools\\codex.cmd') diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index 0f7f95bc17e..4f5a23164a9 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -21,6 +21,7 @@ import { UnsafeWindowsBatchArgumentsError, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../../shared/windows-batch-spawn' +import { stdioForWindowsInteractiveChild } from '../../shared/windows-console-input' import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import type { RuntimeStatus } from '../../shared/runtime-types' import type { @@ -113,12 +114,18 @@ async function runAgentLoginInTerminal( return } const env = addAgentNodePaths({ ...stripElectronRunAsNode(process.env), ...extraEnv }) - const child = spawn(spawnCmd, spawnArgs, { - // Why: JSON mode reserves stdout for the response envelope while keeping - // the interactive login attached to the user's terminal via stderr. - stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'], - env - }) + const consoleStdio = stdioForWindowsInteractiveChild(json) + let child: ReturnType + try { + child = spawn(spawnCmd, spawnArgs, { + // Why: JSON mode reserves stdout for the response envelope while keeping + // the interactive login attached to the user's terminal via stderr. + stdio: consoleStdio.stdio, + env + }) + } finally { + consoleStdio.dispose() + } session.child = child child.once('error', (error) => rejectPromise( diff --git a/src/main/claude-accounts/claude-account-service-login-process.test.ts b/src/main/claude-accounts/claude-account-service-login-process.test.ts index cb387a659d5..43e051a95fd 100644 --- a/src/main/claude-accounts/claude-account-service-login-process.test.ts +++ b/src/main/claude-accounts/claude-account-service-login-process.test.ts @@ -6,6 +6,7 @@ import { join } from 'node:path' import { PassThrough } from 'node:stream' import type { ClaudeManagedAccount } from '../../shared/managed-account-types' import { readActiveClaudeKeychainCredentials } from './keychain' +import { getCmdExePath } from '../../shared/windows-batch-spawn' import { createService, resetClaudeKeychainMocks, @@ -516,7 +517,24 @@ describe('ClaudeAccountService credential capture', () => { const destroyStdin = vi.spyOn(child.stdin, 'destroy') const taskkill = new EventEmitter() const spawnMock = vi.fn((command: string) => (command === 'taskkill.exe' ? taskkill : child)) + const cleanupInteractiveLogin = vi.fn() + let publishTerminationPid: (pid: number) => void = () => {} + const buildInteractiveLoginSpawn = vi.fn(() => ({ + command: getCmdExePath(), + args: ['/d', '/c', 'start', '', '/wait', 'claude', 'auth', 'login', '--claudeai'], + stdio: 'ignore' as const, + windowsHide: true, + cleanup: cleanupInteractiveLogin, + getTerminationPid: () => null, + waitForTerminationPid: () => + new Promise((resolve) => { + publishTerminationPid = resolve + }) + })) vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ + buildWindowsHostInteractiveLoginSpawn: buildInteractiveLoginSpawn + })) try { const { ClaudeAccountService } = await import('./service') @@ -548,28 +566,43 @@ describe('ClaudeAccountService credential capture', () => { const addPromise = service.addAccount() await vi.waitFor(() => { + expect(buildInteractiveLoginSpawn).toHaveBeenCalledWith('claude', [ + 'auth', + 'login', + '--claudeai' + ]) expect(spawnMock).toHaveBeenCalledWith( - process.env.ComSpec ?? 'cmd.exe', - ['/d', '/v:off', '/s', '/c', '""claude" "auth" "login" "--claudeai""'], - expect.objectContaining({ shell: false, windowsVerbatimArguments: true }) + getCmdExePath(), + ['/d', '/c', 'start', '', '/wait', 'claude', 'auth', 'login', '--claudeai'], + expect.objectContaining({ stdio: 'ignore', windowsHide: true }) ) }) expect(service.cancelPendingLogin()).toBe(true) const rejection = expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.') expect(child.kill).not.toHaveBeenCalled() - expect(spawnMock).toHaveBeenCalledWith( + expect(spawnMock).not.toHaveBeenCalledWith( 'taskkill.exe', - ['/pid', '1234', '/t', '/f'], - expect.objectContaining({ stdio: 'ignore', windowsHide: true }) + expect.anything(), + expect.anything() ) + publishTerminationPid(9876) + await vi.waitFor(() => { + expect(spawnMock).toHaveBeenCalledWith( + 'taskkill.exe', + ['/pid', '9876', '/t', '/f'], + expect.objectContaining({ stdio: 'ignore', windowsHide: true }) + ) + }) expect(destroyStdin).not.toHaveBeenCalled() taskkill.emit('close', 0) await rejection expect(destroyStdin).toHaveBeenCalledTimes(1) + expect(cleanupInteractiveLogin).toHaveBeenCalledTimes(1) expect(service.cancelPendingLogin()).toBe(false) } finally { vi.doUnmock('node:child_process') + vi.doUnmock('../../shared/windows-interactive-login-spawn') } }) }) diff --git a/src/main/claude-accounts/claude-windows-interactive-login.test.ts b/src/main/claude-accounts/claude-windows-interactive-login.test.ts new file mode 100644 index 00000000000..69ca24c21a8 --- /dev/null +++ b/src/main/claude-accounts/claude-windows-interactive-login.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { getCmdExePath } from '../../shared/windows-batch-spawn' +import { createService, restorePlatform, setPlatform } from './claude-account-service-test-harness' + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-claude-windows-login-test' + } +})) + +vi.mock('../codex-cli/command', () => ({ + resolveClaudeCommand: vi.fn(() => 'C:\\Tools\\claude.cmd') +})) + +describe('Claude Windows host interactive login', () => { + afterEach(() => { + restorePlatform() + }) + + it('resolves a Windows auth login whose child has no piped streams', async () => { + setPlatform('win32') + vi.resetModules() + const child = new EventEmitter() as EventEmitter & { + stdout: null + stderr: null + kill: ReturnType + pid: number + } + child.stdout = null + child.stderr = null + child.kill = vi.fn() + child.pid = 4242 + const spawnMock = vi.fn(() => { + queueMicrotask(() => child.emit('exit', 0)) + return child + }) + const buildInteractiveLoginSpawn = vi.fn(() => ({ + command: getCmdExePath(), + args: [ + '/d', + '/c', + 'start', + '', + '/wait', + 'C:\\Tools\\claude.cmd', + 'auth', + 'login', + '--claudeai' + ], + stdio: 'ignore' as const, + windowsHide: true + })) + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ + buildWindowsHostInteractiveLoginSpawn: buildInteractiveLoginSpawn + })) + + try { + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + createService() as never, + createService() as never, + createService() as never + ) + await ( + service as unknown as { + runClaudeCommand( + args: string[], + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, + timeoutMs: number + ): Promise + } + ).runClaudeCommand( + ['auth', 'login', '--claudeai'], + { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, + 1000 + ) + + expect(buildInteractiveLoginSpawn).toHaveBeenCalledWith('C:\\Tools\\claude.cmd', [ + 'auth', + 'login', + '--claudeai' + ]) + expect(spawnMock).toHaveBeenCalledWith( + getCmdExePath(), + expect.arrayContaining(['start', '', '/wait']), + expect.objectContaining({ + stdio: 'ignore', + windowsHide: true + }) + ) + expect(child.kill).not.toHaveBeenCalled() + } finally { + vi.doUnmock('node:child_process') + vi.doUnmock('../../shared/windows-interactive-login-spawn') + } + }) + + it('times out a denied Windows login that stays alive without piped output', async () => { + setPlatform('win32') + vi.resetModules() + vi.useFakeTimers() + const child = new EventEmitter() as EventEmitter & { + stdout: null + stderr: null + kill: ReturnType + pid: number + } + child.stdout = null + child.stderr = null + child.kill = vi.fn() + child.pid = 0 + const spawnMock = vi.fn(() => child) + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + + try { + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + createService() as never, + createService() as never, + createService() as never + ) + const login = ( + service as unknown as { + runClaudeCommand( + args: string[], + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, + timeoutMs: number + ): Promise + } + ).runClaudeCommand( + ['auth', 'login', '--claudeai'], + { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, + 1000 + ) + const rejection = expect(login).rejects.toThrow('Claude sign-in took too long to finish.') + + await vi.advanceTimersByTimeAsync(3_000) + + await rejection + expect(child.kill).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + vi.doUnmock('node:child_process') + } + }) +}) diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 2e3206eab69..88e3712fde8 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -35,6 +35,7 @@ import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' import { runWslProcess } from '../wsl/wsl-runner' import { buildWindowsCommandInvocation } from './windows-command-invocation' +import { buildWindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn' import { getClaudeSelectionTargetForAccount, getSelectedClaudeAccountIdForTarget, @@ -1043,8 +1044,27 @@ export class ClaudeAccountService { options?: { allowFailure?: boolean; signal?: AbortSignal; keepStdinOpen?: boolean } ): Promise { return new Promise((resolvePromise, rejectPromise) => { - const spawnConfig = - configDir.linuxPath && configDir.wslDistro + const isWindowsHostInteractiveLogin = + process.platform === 'win32' && + configDir.linuxPath === null && + configDir.wslDistro === null && + args[0] === 'auth' && + args[1] === 'login' + const interactiveLogin = isWindowsHostInteractiveLogin + ? buildWindowsHostInteractiveLoginSpawn(resolveClaudeCommand(), args) + : null + const spawnConfig = interactiveLogin + ? { + command: interactiveLogin.command, + args: interactiveLogin.args, + env: { + ...process.env, + CLAUDE_CONFIG_DIR: configDir.windowsPath + }, + shell: false, + windowsVerbatimArguments: false + } + : configDir.linuxPath && configDir.wslDistro ? { command: 'wsl.exe', args: [ @@ -1082,10 +1102,11 @@ export class ClaudeAccountService { // Why: Claude's browser auth can bind its callback lifetime to stdin. // Keeping stdin open prevents hidden managed-login runs from tearing down // the local callback server before the browser returns. - stdio: [options?.keepStdinOpen ? 'pipe' : 'ignore', 'pipe', 'pipe'], + stdio: interactiveLogin + ? interactiveLogin.stdio + : [options?.keepStdinOpen ? 'pipe' : 'ignore', 'pipe', 'pipe'], shell: spawnConfig.shell, - // On Windows this is wsl.exe or a cmd invocation; without the flag it - // opens a console and steals foreground on every managed login (#10488). + // Why: hide only the outer wrapper; start creates the visible login console. windowsHide: true, windowsVerbatimArguments: spawnConfig.windowsVerbatimArguments, env: spawnConfig.env, @@ -1095,7 +1116,7 @@ export class ClaudeAccountService { }) const stdout = child.stdout const stderr = child.stderr - if (!stdout || !stderr) { + if (!interactiveLogin && (!stdout || !stderr)) { if (options?.keepStdinOpen) { child.stdin?.destroy() } @@ -1130,17 +1151,18 @@ export class ClaudeAccountService { clearTimeout(timeout) timeout = null } - stdout.off('data', appendOutput) - stderr.off('data', appendOutput) + stdout?.off('data', appendOutput) + stderr?.off('data', appendOutput) child.off('error', onError) child.off(completionEvent, onDone) options?.signal?.removeEventListener('abort', onAbort) + interactiveLogin?.cleanup?.() if (options?.keepStdinOpen) { child.stdin?.destroy() } if (completesOnExit) { - stdout.destroy() - stderr.destroy() + stdout?.destroy() + stderr?.destroy() } } const settle = (callback: () => void): void => { @@ -1159,11 +1181,15 @@ export class ClaudeAccountService { return } terminationPending = true - if (process.platform === 'win32' && child.pid) { - const taskkill = spawn('taskkill.exe', ['/pid', String(child.pid), '/t', '/f'], { - stdio: 'ignore', - windowsHide: true - }) + const killWindowsTree = (windowsTerminationPid: number): void => { + const taskkill = spawn( + 'taskkill.exe', + ['/pid', String(windowsTerminationPid), '/t', '/f'], + { + stdio: 'ignore', + windowsHide: true + } + ) let taskkillFinished = false const finishTaskkill = (succeeded: boolean): void => { if (taskkillFinished) { @@ -1182,9 +1208,27 @@ export class ClaudeAccountService { }, WINDOWS_TASKKILL_TIMEOUT_MS) taskkill.once('error', () => finishTaskkill(false)) taskkill.once('close', (code) => finishTaskkill(code === 0)) + } + if (process.platform === 'win32') { + const resolveTerminationPid = interactiveLogin?.waitForTerminationPid + ? interactiveLogin.waitForTerminationPid() + : Promise.resolve(interactiveLogin?.getTerminationPid?.() ?? child.pid ?? null) + void resolveTerminationPid + .then((windowsTerminationPid) => { + if (windowsTerminationPid) { + killWindowsTree(windowsTerminationPid) + return + } + child.kill() + afterKill() + }) + .catch(() => { + child.kill() + afterKill() + }) return } - if (process.platform !== 'win32' && child.pid) { + if (child.pid) { try { process.kill(-child.pid) afterKill() @@ -1229,8 +1273,8 @@ export class ClaudeAccountService { }) } - stdout.on('data', appendOutput) - stderr.on('data', appendOutput) + stdout?.on('data', appendOutput) + stderr?.on('data', appendOutput) child.on('error', onError) // Native Windows browsers can inherit these pipes and indefinitely delay close. child.on(completionEvent, onDone) diff --git a/src/main/codex-accounts/codex-windows-interactive-login.test.ts b/src/main/codex-accounts/codex-windows-interactive-login.test.ts new file mode 100644 index 00000000000..f9ceb64b4d3 --- /dev/null +++ b/src/main/codex-accounts/codex-windows-interactive-login.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import type * as NodeOs from 'node:os' +import { getCmdExePath } from '../../shared/windows-batch-spawn' +import { + createRateLimits, + createRuntimeHome, + createSettings, + createStore, + registerCodexAccountsTestHomes, + testState +} from './service-test-harness' + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +describe('Codex Windows host interactive login', () => { + registerCodexAccountsTestHomes() + + it('resolves a Windows login whose child has no piped streams', async () => { + vi.resetModules() + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + const child = new EventEmitter() as EventEmitter & { + stdout: null + stderr: null + kill: ReturnType + pid: number + } + child.stdout = null + child.stderr = null + child.kill = vi.fn() + child.pid = 4242 + const spawnMock = vi.fn(() => { + queueMicrotask(() => child.emit('close', 0)) + return child + }) + const buildInteractiveLoginSpawn = vi.fn(() => ({ + command: getCmdExePath(), + args: ['/d', '/c', 'start', '', '/wait', 'C:\\Tools\\codex.cmd', 'login'], + stdio: 'ignore' as const, + windowsHide: true + })) + vi.doMock('node:child_process', () => ({ + execFileSync: vi.fn(), + spawn: spawnMock + })) + vi.doMock('../codex-cli/command', () => ({ + resolveCodexCommand: () => 'C:\\Tools\\codex.cmd' + })) + vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ + buildWindowsHostInteractiveLoginSpawn: buildInteractiveLoginSpawn + })) + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + createStore(createSettings()) as never, + createRateLimits() as never, + createRuntimeHome() as never + ) + await ( + service as unknown as { + runCodexLogin(managedHomePath: string): Promise + } + ).runCodexLogin(testState.fakeHomeDir) + + expect(buildInteractiveLoginSpawn).toHaveBeenCalledWith('C:\\Tools\\codex.cmd', ['login']) + expect(spawnMock).toHaveBeenCalledWith( + getCmdExePath(), + expect.arrayContaining(['start', '', '/wait']), + expect.objectContaining({ + stdio: 'ignore', + windowsHide: true + }) + ) + expect(child.kill).not.toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', originalPlatform) + vi.doUnmock('node:child_process') + vi.doUnmock('../codex-cli/command') + vi.doUnmock('../../shared/windows-interactive-login-spawn') + } + }) +}) diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index cd790207823..5fd0a5ef649 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -6,6 +6,10 @@ import { dirname, join, resolve, sep } from 'node:path' import { homedir } from 'node:os' import { app } from 'electron' import { getSpawnArgsForWindows } from '../win32-utils' +import { + buildWindowsHostInteractiveLoginSpawn, + type WindowsHostInteractiveLoginSpawn +} from '../../shared/windows-interactive-login-spawn' import type { CodexManagedAccount, CodexManagedAccountSummary, @@ -211,10 +215,14 @@ function removeManagedHomeTreeSync(targetPath: string): void { }) } -function killLoginProcessTree(child: ChildProcess): void { +function killLoginProcessTree( + child: ChildProcess, + interactiveLogin?: WindowsHostInteractiveLoginSpawn | null +): void { + const terminationPid = interactiveLogin?.getTerminationPid?.() ?? child.pid if ( process.platform === 'win32' && - typeof child.pid === 'number' && + typeof terminationPid === 'number' && child.exitCode === null && child.signalCode === null ) { @@ -222,7 +230,7 @@ function killLoginProcessTree(child: ChildProcess): void { // Why: child.kill() only reaches the direct child (cmd.exe for npm .cmd // shims); taskkill /t also ends codex descendants whose open handles on // the managed home make post-login file operations fail with ENOTEMPTY. - execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + execFileSync('taskkill', ['/pid', String(terminationPid), '/t', '/f'], { windowsHide: true, timeout: WINDOWS_LOGIN_TREE_KILL_TIMEOUT_MS, stdio: 'ignore' @@ -1711,12 +1719,20 @@ export class CodexAccountService { command: 'wsl.exe', args: buildWslCodexLoginArgs(wslInfo.distro, wslInfo.linuxPath), env: process.env, - codexCommand: 'codex' + codexCommand: 'codex', + interactiveLogin: null } : (() => { const codexCommand = resolveCodexCommand() - // Why: Windows codex may be a .cmd/.bat; spawn+shell:true would trigger DEP0190, so invoke cmd.exe /c explicitly. - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, ['login']) + // Why: Windows host login needs a real console; otherwise inherit/hide + // leaves the child unable to read a paste-code / device-auth prompt. + const interactiveLogin = + process.platform === 'win32' + ? buildWindowsHostInteractiveLoginSpawn(codexCommand, ['login']) + : null + const { spawnCmd, spawnArgs } = interactiveLogin + ? { spawnCmd: interactiveLogin.command, spawnArgs: interactiveLogin.args } + : getSpawnArgsForWindows(codexCommand, ['login']) return { command: spawnCmd, args: spawnArgs, @@ -1724,12 +1740,15 @@ export class CodexAccountService { ...process.env, CODEX_HOME: managedHomePath }, - codexCommand + codexCommand, + interactiveLogin } })() const child = spawn(spawnConfig.command, spawnConfig.args, { - stdio: ['ignore', 'pipe', 'pipe'], - // Why: prevents a console window flash for .cmd/.bat entrypoints routed through cmd.exe on Windows. + stdio: spawnConfig.interactiveLogin + ? spawnConfig.interactiveLogin.stdio + : ['ignore', 'pipe', 'pipe'], + // Why: hide the outer wrapper only. A dedicated login console stays visible. windowsHide: true, env: spawnConfig.env }) @@ -1761,10 +1780,11 @@ export class CodexAccountService { clearTimeout(postAuthExitTimeout) postAuthExitTimeout = null } - child.stdout.off('data', appendOutput) - child.stderr.off('data', appendOutput) + child.stdout?.off('data', appendOutput) + child.stderr?.off('data', appendOutput) child.off('error', onError) child.off('close', onClose) + spawnConfig.interactiveLogin?.cleanup?.() } const settle = (callback: () => void): void => { @@ -1778,7 +1798,7 @@ export class CodexAccountService { const timeoutError = new Error('Codex sign-in took too long to finish. Please try again.') timeout = setTimeout(() => { - killLoginProcessTree(child) + killLoginProcessTree(child, spawnConfig.interactiveLogin) settle(() => { rejectPromise(timeoutError) }) @@ -1799,7 +1819,7 @@ export class CodexAccountService { } postAuthExitTimeout = setTimeout(() => { loginTreeKilledAfterAuth = true - killLoginProcessTree(child) + killLoginProcessTree(child, spawnConfig.interactiveLogin) }, WINDOWS_LOGIN_POST_AUTH_EXIT_GRACE_MS) }, WINDOWS_LOGIN_AUTH_POLL_INTERVAL_MS) } @@ -1845,8 +1865,8 @@ export class CodexAccountService { }) } - child.stdout.on('data', appendOutput) - child.stderr.on('data', appendOutput) + child.stdout?.on('data', appendOutput) + child.stderr?.on('data', appendOutput) child.on('error', onError) child.on('close', onClose) }) diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index 381fef6643e..4fa9436e19e 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -6,9 +6,11 @@ import { getCmdExePath, getRegExePath, getSpawnArgsForWindows, + wrapWindowsStartWait, isPermissionError, isWindowsBatchScript, resolveWindowsCommand, + UnsafeWindowsBatchArgumentsError, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from './win32-utils' @@ -119,6 +121,46 @@ describe('getSpawnArgsForWindows', () => { }) }) + it('wraps an interactive login in start /wait with an empty title argv', () => { + withPlatform('win32', () => { + const { spawnCmd, spawnArgs } = wrapWindowsStartWait(getCmdExePath(), [ + '/d', + '/c', + 'C:\\Tools\\claude.cmd', + 'auth', + 'login', + '--claudeai' + ]) + expect(spawnCmd).toBe(getCmdExePath()) + expect(spawnArgs).toEqual([ + '/d', + '/c', + 'start', + '', + '/wait', + getCmdExePath(), + '/d', + '/c', + 'C:\\Tools\\claude.cmd', + 'auth', + 'login', + '--claudeai' + ]) + expect(spawnArgs[3]).toBe('') + expect(spawnArgs).not.toContain('/B') + expect(spawnArgs).not.toContain('""') + }) + }) + + it('rejects cmd metacharacters in executable paths passed through start /wait', () => { + expect(() => wrapWindowsStartWait('C:\\Users\\A%B\\codex.exe', ['login'])).toThrow( + UnsafeWindowsBatchArgumentsError + ) + expect(() => wrapWindowsStartWait('C:\\Tools\\codex.exe', ['log&in'])).toThrow( + UnsafeWindowsBatchArgumentsError + ) + }) + it('leaves .exe GUI launches alone even when detachedGui is requested', () => { withPlatform('win32', () => { const { spawnCmd, spawnArgs } = getSpawnArgsForWindows( diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index 151dc9306f0..5798f22be5a 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -5,6 +5,7 @@ import { existsSync } from 'node:fs' export { getCmdExePath, getSpawnArgsForWindows, + wrapWindowsStartWait, isWindowsBatchScript, WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL, diff --git a/src/shared/windows-batch-spawn.ts b/src/shared/windows-batch-spawn.ts index d8312d2ec27..3abebf3af69 100644 --- a/src/shared/windows-batch-spawn.ts +++ b/src/shared/windows-batch-spawn.ts @@ -39,6 +39,12 @@ function hasUnsafeWindowsBatchSyntax(value: string): boolean { return UNSAFE_WINDOWS_BATCH_SYNTAX.test(value) } +function assertWindowsCmdSafeTokens(values: readonly string[]): void { + if (values.some(hasUnsafeWindowsBatchSyntax)) { + throw new UnsafeWindowsBatchArgumentsError() + } +} + export type GetSpawnArgsForWindowsOptions = { /** * GUI launchers (Open In apps) should not leave a lingering Command Prompt. @@ -58,11 +64,7 @@ export function getSpawnArgsForWindows( options: GetSpawnArgsForWindowsOptions = {} ): { spawnCmd: string; spawnArgs: string[] } { if (isWindowsBatchScript(command)) { - for (const value of [command, ...args]) { - if (hasUnsafeWindowsBatchSyntax(value)) { - throw new UnsafeWindowsBatchArgumentsError() - } - } + assertWindowsCmdSafeTokens([command, ...args]) // Why: separate argv entries let Node quote spaces without breaking cmd. if (options.detachedGui) { @@ -87,3 +89,20 @@ export function getSpawnArgsForWindows( } return { spawnCmd: command, spawnArgs: args } } + +/** + * Inverse of `detachedGui`'s `start "" /B`: a visible console that waits. + * Title is the empty argv entry so libuv emits `""` rather than `"\"\""`. + */ +export function wrapWindowsStartWait( + spawnCmd: string, + spawnArgs: string[] +): { spawnCmd: string; spawnArgs: string[] } { + // Why: `start` reparses every target through cmd.exe, including .exe paths. + assertWindowsCmdSafeTokens([spawnCmd, ...spawnArgs]) + const cmdExePath = getCmdExePath() + return { + spawnCmd: cmdExePath, + spawnArgs: ['/d', '/c', 'start', '', '/wait', spawnCmd, ...spawnArgs] + } +} diff --git a/src/shared/windows-console-input.test.ts b/src/shared/windows-console-input.test.ts new file mode 100644 index 00000000000..9a8b0aeb372 --- /dev/null +++ b/src/shared/windows-console-input.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import { + openWindowsConsoleInput, + stdioForWindowsInteractiveChild, + WINDOWS_CONSOLE_INPUT_DEVICE +} from './windows-console-input' + +describe('openWindowsConsoleInput', () => { + it('returns inherit off Windows without opening the device', () => { + const openSync = vi.fn() + expect(openWindowsConsoleInput({ platform: 'darwin', openSync })).toBe('inherit') + expect(openWindowsConsoleInput({ platform: 'linux', openSync })).toBe('inherit') + expect(openSync).not.toHaveBeenCalled() + }) + + it('opens CONIN$ read-write and disposes the fd once', () => { + const openSync = vi.fn(() => 11) + const closeSync = vi.fn() + const opened = openWindowsConsoleInput({ platform: 'win32', openSync, closeSync }) + expect(opened).not.toBe('inherit') + if (opened === 'inherit') { + return + } + expect(openSync).toHaveBeenCalledWith(WINDOWS_CONSOLE_INPUT_DEVICE, 'r+') + expect(opened.fd).toBe(11) + opened.dispose() + opened.dispose() + expect(closeSync).toHaveBeenCalledOnce() + expect(closeSync).toHaveBeenCalledWith(11) + }) + + it('returns inherit when the console device cannot be opened', () => { + const openSync = vi.fn(() => { + throw new Error('no console') + }) + expect(openWindowsConsoleInput({ platform: 'win32', openSync })).toBe('inherit') + }) +}) + +describe('stdioForWindowsInteractiveChild', () => { + it('keeps JSON stdout on the CLI envelope stream', () => { + const { stdio, dispose } = stdioForWindowsInteractiveChild(true, { + platform: 'win32', + openSync: vi.fn(() => 11), + closeSync: vi.fn() + }) + expect(stdio).toEqual([11, process.stderr, 'inherit']) + dispose() + }) + + it('preserves inherited output outside JSON mode', () => { + const { stdio, dispose } = stdioForWindowsInteractiveChild(false, { + platform: 'win32', + openSync: vi.fn(() => 11), + closeSync: vi.fn() + }) + expect(stdio).toEqual([11, 'inherit', 'inherit']) + dispose() + }) +}) diff --git a/src/shared/windows-console-input.ts b/src/shared/windows-console-input.ts new file mode 100644 index 00000000000..0b846276a17 --- /dev/null +++ b/src/shared/windows-console-input.ts @@ -0,0 +1,68 @@ +import { closeSync as fsCloseSync, openSync as fsOpenSync } from 'node:fs' + +export const WINDOWS_CONSOLE_INPUT_DEVICE = '\\\\.\\CONIN$' + +export type WindowsConsoleInput = { + fd: number + dispose: () => void +} + +type OpenWindowsConsoleInputDeps = { + platform?: NodeJS.Platform + openSync?: (path: string, flags: string) => number + closeSync?: (fd: number) => void +} + +type WindowsInteractiveChildStdio = [ + number | 'inherit', + typeof process.stderr | 'inherit', + 'inherit' +] + +/** Opens the real Windows console input device instead of Electron's inherited stdin. */ +export function openWindowsConsoleInput( + deps: OpenWindowsConsoleInputDeps = {} +): WindowsConsoleInput | 'inherit' { + const platform = deps.platform ?? process.platform + if (platform !== 'win32') { + return 'inherit' + } + const openSync = deps.openSync ?? ((path: string, flags: string) => fsOpenSync(path, flags)) + const closeSync = deps.closeSync ?? fsCloseSync + try { + const fd = openSync(WINDOWS_CONSOLE_INPUT_DEVICE, 'r+') + let disposed = false + return { + fd, + dispose: () => { + if (disposed) { + return + } + disposed = true + try { + closeSync(fd) + } catch { + // Descriptor ownership may already have transferred during spawn. + } + } + } + } catch { + return 'inherit' + } +} + +export function stdioForWindowsInteractiveChild( + json: boolean, + deps: OpenWindowsConsoleInputDeps = {} +): { stdio: WindowsInteractiveChildStdio; dispose: () => void } { + const opened = openWindowsConsoleInput(deps) + return opened === 'inherit' + ? { + stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'], + dispose: () => {} + } + : { + stdio: [opened.fd, json ? process.stderr : 'inherit', 'inherit'], + dispose: opened.dispose + } +} diff --git a/src/shared/windows-interactive-login-spawn.test.ts b/src/shared/windows-interactive-login-spawn.test.ts new file mode 100644 index 00000000000..07cde964095 --- /dev/null +++ b/src/shared/windows-interactive-login-spawn.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest' +import { existsSync, writeFileSync } from 'node:fs' +import { getCmdExePath } from './windows-batch-spawn' +import { buildWindowsHostInteractiveLoginSpawn } from './windows-interactive-login-spawn' + +function withWindows(fn: () => T): T { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + return fn() + } finally { + Object.defineProperty(process, 'platform', platform) + } +} + +function encodedValue(value: string): string { + return `Read-OrcaValue '${Buffer.from(value).toString('base64')}'` +} + +function pidFilePathFromSpawnArgs(args: string[]): string { + const script = Buffer.from(args[11] ?? '', 'base64').toString('utf16le') + const encodedPath = script.match( + /WriteAllText\(\(Read-OrcaValue '([^']+)'\), \[string\]\$PID\)/ + )?.[1] + if (!encodedPath) { + throw new Error('PID relay path is missing') + } + return Buffer.from(encodedPath, 'base64').toString('utf8') +} + +describe('buildWindowsHostInteractiveLoginSpawn', () => { + it('relays a batch login through a visible, PID-addressable console', () => { + const spawn = withWindows(() => + buildWindowsHostInteractiveLoginSpawn('C:\\Tools\\claude.cmd', [ + 'auth', + 'login', + '--claudeai' + ]) + ) + expect(spawn.command).toBe(getCmdExePath()) + expect(spawn.args.slice(0, 5)).toEqual(['/d', '/c', 'start', '', '/wait']) + expect(spawn.args[5]).toMatch(/WindowsPowerShell\\v1\.0\\powershell\.exe$/i) + expect(spawn.args.slice(6, 11)).toEqual([ + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-EncodedCommand' + ]) + + const script = Buffer.from(spawn.args[11] ?? '', 'base64').toString('utf16le') + expect(script).toContain('[string]$PID') + expect(script).toContain(encodedValue(getCmdExePath())) + expect(script).toContain(encodedValue('C:\\Tools\\claude.cmd')) + expect(script).toContain(encodedValue('--claudeai')) + const pidFilePath = pidFilePathFromSpawnArgs(spawn.args) + + expect(spawn.stdio).toBe('ignore') + expect(spawn.windowsHide).toBe(true) + expect(spawn.getTerminationPid()).toBeNull() + writeFileSync(pidFilePath, '2468') + expect(spawn.getTerminationPid()).toBe(2468) + spawn.cleanup() + expect(existsSync(pidFilePath)).toBe(false) + }) + + it('routes executable logins through the same waiting console boundary', () => { + const spawn = withWindows(() => + buildWindowsHostInteractiveLoginSpawn('C:\\Tools\\codex.exe', ['login']) + ) + const script = Buffer.from(spawn.args[11] ?? '', 'base64').toString('utf16le') + expect(script).toContain(encodedValue('C:\\Tools\\codex.exe')) + expect(script).toContain(encodedValue('login')) + spawn.cleanup() + }) + + it('waits for the relay PID when cancellation races console startup', async () => { + vi.useFakeTimers() + const spawn = withWindows(() => + buildWindowsHostInteractiveLoginSpawn('C:\\Tools\\claude.exe', ['auth', 'login']) + ) + try { + const pendingPid = spawn.waitForTerminationPid() + expect(spawn.getTerminationPid()).toBeNull() + + await vi.advanceTimersByTimeAsync(100) + const pidFilePath = pidFilePathFromSpawnArgs(spawn.args) + writeFileSync(pidFilePath, '8642') + await vi.advanceTimersByTimeAsync(25) + + await expect(pendingPid).resolves.toBe(8642) + } finally { + spawn.cleanup() + vi.useRealTimers() + } + }) +}) diff --git a/src/shared/windows-interactive-login-spawn.ts b/src/shared/windows-interactive-login-spawn.ts new file mode 100644 index 00000000000..1068607e38f --- /dev/null +++ b/src/shared/windows-interactive-login-spawn.ts @@ -0,0 +1,98 @@ +import { randomUUID } from 'node:crypto' +import { readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, win32 } from 'node:path' +import { getSpawnArgsForWindows, wrapWindowsStartWait } from './windows-batch-spawn' + +export type WindowsHostInteractiveLoginSpawn = { + command: string + args: string[] + stdio: 'ignore' + windowsHide: boolean + cleanup: () => void + getTerminationPid: () => number | null + waitForTerminationPid: () => Promise +} + +const PID_RELAY_WAIT_TIMEOUT_MS = 2_000 +const PID_RELAY_POLL_INTERVAL_MS = 25 + +function encodeUtf8(value: string): string { + return Buffer.from(value, 'utf8').toString('base64') +} + +function buildPidRelayScript(command: string, args: string[], pidFilePath: string): string { + const decode = + 'function Read-OrcaValue([string]$Value) { [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value)) }' + const encodedArgs = args.map((arg) => `(Read-OrcaValue '${encodeUtf8(arg)}')`).join(',') + return [ + decode, + `$Command = Read-OrcaValue '${encodeUtf8(command)}'`, + `$Arguments = @(${encodedArgs})`, + `[IO.File]::WriteAllText((Read-OrcaValue '${encodeUtf8(pidFilePath)}'), [string]$PID)`, + '& $Command @Arguments', + 'if ($null -eq $LASTEXITCODE) { exit 0 }', + 'exit $LASTEXITCODE' + ].join('; ') +} + +function readPidFile(pidFilePath: string): number | null { + try { + const pid = Number.parseInt(readFileSync(pidFilePath, 'utf8').trim(), 10) + return Number.isSafeInteger(pid) && pid > 0 ? pid : null + } catch { + return null + } +} + +function waitForPidFile(pidFilePath: string): Promise { + const current = readPidFile(pidFilePath) + if (current !== null) { + return Promise.resolve(current) + } + const deadline = Date.now() + PID_RELAY_WAIT_TIMEOUT_MS + return new Promise((resolve) => { + const poll = (): void => { + const pid = readPidFile(pidFilePath) + if (pid !== null || Date.now() >= deadline) { + resolve(pid) + return + } + setTimeout(poll, PID_RELAY_POLL_INTERVAL_MS) + } + setTimeout(poll, PID_RELAY_POLL_INTERVAL_MS) + }) +} + +export function buildWindowsHostInteractiveLoginSpawn( + command: string, + args: string[] +): WindowsHostInteractiveLoginSpawn { + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, args) + const pidFilePath = join(tmpdir(), `orca-interactive-login-${randomUUID()}.pid`) + const powershell = win32.join( + process.env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ) + const script = buildPidRelayScript(spawnCmd, spawnArgs, pidFilePath) + const wrapped = wrapWindowsStartWait(powershell, [ + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-EncodedCommand', + Buffer.from(script, 'utf16le').toString('base64') + ]) + return { + command: wrapped.spawnCmd, + args: wrapped.spawnArgs, + stdio: 'ignore', + windowsHide: true, + cleanup: () => rmSync(pidFilePath, { force: true }), + getTerminationPid: () => readPidFile(pidFilePath), + waitForTerminationPid: () => waitForPidFile(pidFilePath) + } +} From c83499fc8cafb9947bde42c463ed419d4e4ecb23 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:14:12 -0700 Subject: [PATCH 17/65] Keep sidebar position when deleting active worktree (#16040) --- ...active-worktree-focus-after-delete.test.ts | 10 +- .../active-worktree-focus-after-delete.ts | 3 +- .../viewport/use-row-measurement.ts | 8 + .../use-row-removal-animation.test.ts | 99 +++++ .../viewport/use-row-removal-animation.ts | 121 +++++++ tests/e2e/helpers/orca-app.ts | 10 +- ...tree-active-delete-scroll-position.spec.ts | 338 ++++++++++++++++++ 7 files changed, 582 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.ts create mode 100644 tests/e2e/worktree-active-delete-scroll-position.spec.ts diff --git a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts index eb075805183..9c0b3a67449 100644 --- a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts +++ b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts @@ -97,7 +97,7 @@ describe('prepareActiveWorktreeFocusAfterDelete', () => { simulateDelete('wt-del', true) commit() - expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-b') + expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-b', { revealInSidebar: false }) }) it('falls back to the base/primary worktree when no other workspace remains', () => { @@ -108,7 +108,7 @@ describe('prepareActiveWorktreeFocusAfterDelete', () => { simulateDelete('wt-del', true) commit() - expect(activateAndRevealWorktree).toHaveBeenCalledWith('main') + expect(activateAndRevealWorktree).toHaveBeenCalledWith('main', { revealInSidebar: false }) }) it('does not re-focus a sibling hosted on a torn-down runtime-owned SSH target', () => { @@ -140,7 +140,9 @@ describe('prepareActiveWorktreeFocusAfterDelete', () => { simulateDelete('wt-del', true) commit() - expect(activateAndRevealWorktree).toHaveBeenCalledWith('main-1') + expect(activateAndRevealWorktree).toHaveBeenCalledWith('main-1', { + revealInSidebar: false + }) }) it('does not steal focus when the deleted worktree was not the active one', () => { @@ -229,7 +231,7 @@ describe('prepareActiveWorktreeFocusAfterDelete', () => { mocks.state.deleteStateByWorktreeId = { 'wt-a': { isDeleting: true } } commit() - expect(activateAndRevealWorktree).toHaveBeenCalledWith('main') + expect(activateAndRevealWorktree).toHaveBeenCalledWith('main', { revealInSidebar: false }) }) it('does not steal focus when a non-worktree workspace is active', () => { diff --git a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts index 214b6c2c72c..8b499005a17 100644 --- a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts +++ b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts @@ -86,7 +86,8 @@ function focusNextWorktreeAfterActiveDelete( } const nextWorktreeId = pickNextWorktreeIdAfterDelete(state, repoId, deletedWorktreeId) if (nextWorktreeId) { - activateAndRevealWorktree(nextWorktreeId) + // Keep successor focus from replacing the deleted row's spatial context. + activateAndRevealWorktree(nextWorktreeId, { revealInSidebar: false }) } } diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts index 5526c2d39c9..ff812a950be 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts @@ -14,6 +14,7 @@ import { import { getRenderRowKey } from '../listing/render-row' import type { RenderRow } from '../listing/render-row' import type { WorktreeListVirtualizer } from './use-virtualizer' +import { useVirtualRowRemovalAnimation } from './use-row-removal-animation' const recordKeyCountCache = new WeakMap, number>() @@ -123,5 +124,12 @@ export function useVirtualRowMeasurementSync(args: { virtualizer }) + useVirtualRowRemovalAnimation({ + renderRows, + rekeyedRowKeys: lineageRowRekeys, + scrollRef, + virtualItems + }) + return { virtualItems, measureVirtualRowElement } } diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.test.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.test.ts new file mode 100644 index 00000000000..23980e38d39 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + buildVirtualRowRemovalMotions, + type VirtualRowLayoutSnapshot +} from './use-row-removal-animation' + +function snapshot(args: { + identities: string[] + scrollTop: number + starts: [string, number][] +}): VirtualRowLayoutSnapshot { + return { + rowIdentityKeys: new Set(args.identities), + scrollTop: args.scrollTop, + startsByKey: new Map(args.starts) + } +} + +describe('buildVirtualRowRemovalMotions', () => { + it('moves surviving rows from their pre-delete viewport positions', () => { + const motions = buildVirtualRowRemovalMotions({ + previous: snapshot({ + identities: ['wt:a', 'wt:b', 'wt:c'], + scrollTop: 100, + starts: [ + ['wt:a', 100], + ['wt:b', 220], + ['wt:c', 340] + ] + }), + current: snapshot({ + identities: ['wt:a', 'wt:c'], + scrollTop: 100, + starts: [ + ['wt:a', 100], + ['wt:c', 220] + ] + }), + rekeyedRowKeys: new Map() + }) + + expect(motions).toEqual([{ key: 'wt:c', deltaY: 120 }]) + }) + + it('does not double-move rows when anchor restoration offsets a deletion above the viewport', () => { + const motions = buildVirtualRowRemovalMotions({ + previous: snapshot({ + identities: ['wt:deleted', 'wt:a'], + scrollTop: 240, + starts: [['wt:a', 240]] + }), + current: snapshot({ + identities: ['wt:a'], + scrollTop: 120, + starts: [['wt:a', 120]] + }), + rekeyedRowKeys: new Map() + }) + + expect(motions).toEqual([]) + }) + + it('follows a surviving row through a lineage rekey', () => { + const motions = buildVirtualRowRemovalMotions({ + previous: snapshot({ + identities: ['wt:parent', 'wt:child'], + scrollTop: 0, + starts: [['lineage-group:parent', 100]] + }), + current: snapshot({ + identities: ['wt:parent'], + scrollTop: 0, + starts: [['wt:parent', 100]] + }), + rekeyedRowKeys: new Map([['lineage-group:parent', 'wt:parent']]) + }) + + expect(motions).toEqual([]) + }) + + it('ignores additions and measurement-only movement', () => { + const previous = snapshot({ + identities: ['wt:a'], + scrollTop: 0, + starts: [['wt:a', 100]] + }) + expect( + buildVirtualRowRemovalMotions({ + previous, + current: snapshot({ + identities: ['wt:a', 'wt:b'], + scrollTop: 0, + starts: [['wt:a', 140]] + }), + rekeyedRowKeys: new Map() + }) + ).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.ts new file mode 100644 index 00000000000..1f94b590d6d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.ts @@ -0,0 +1,121 @@ +import { useLayoutEffect, useMemo, useRef } from 'react' +import type React from 'react' +import type { VirtualItem } from '@tanstack/react-virtual' +import type { RenderRow } from '../listing/render-row' +import { getRenderRowKey } from '../listing/render-row' + +export const WORKTREE_ROW_REMOVAL_ANIMATION_MS = 180 + +export type VirtualRowLayoutSnapshot = { + rowIdentityKeys: ReadonlySet + scrollTop: number + startsByKey: ReadonlyMap +} + +export type VirtualRowRemovalMotion = { + deltaY: number + key: string +} + +export function getSidebarRowIdentityKeys(rows: readonly RenderRow[]): ReadonlySet { + const keys = new Set() + for (const row of rows) { + if (row.type === 'lineage-group') { + row.rows.forEach((member) => keys.add(`wt:${member.rowKey}`)) + } else { + keys.add(getRenderRowKey(row)) + } + } + return keys +} + +export function buildVirtualRowRemovalMotions(args: { + previous: VirtualRowLayoutSnapshot | null + current: VirtualRowLayoutSnapshot + rekeyedRowKeys: ReadonlyMap +}): VirtualRowRemovalMotion[] { + const { previous, current, rekeyedRowKeys } = args + if (previous === null || previous.rowIdentityKeys === current.rowIdentityKeys) { + return [] + } + let removedRow = false + for (const key of previous.rowIdentityKeys) { + if (!current.rowIdentityKeys.has(key)) { + removedRow = true + break + } + } + if (!removedRow) { + return [] + } + + const previousKeyByCurrentKey = new Map() + rekeyedRowKeys.forEach((currentKey, previousKey) => { + previousKeyByCurrentKey.set(currentKey, previousKey) + }) + const motions: VirtualRowRemovalMotion[] = [] + current.startsByKey.forEach((currentStart, key) => { + const previousKey = previous.startsByKey.has(key) ? key : previousKeyByCurrentKey.get(key) + const previousStart = previousKey ? previous.startsByKey.get(previousKey) : undefined + if (previousStart === undefined) { + return + } + const deltaY = previousStart - previous.scrollTop - (currentStart - current.scrollTop) + if (Math.abs(deltaY) > 0.5) { + motions.push({ deltaY, key }) + } + }) + return motions +} + +export function useVirtualRowRemovalAnimation(args: { + renderRows: readonly RenderRow[] + rekeyedRowKeys: ReadonlyMap + scrollRef: React.RefObject + virtualItems: readonly VirtualItem[] +}): void { + const { renderRows, rekeyedRowKeys, scrollRef, virtualItems } = args + const previousSnapshotRef = useRef(null) + const rowIdentityKeys = useMemo(() => getSidebarRowIdentityKeys(renderRows), [renderRows]) + + useLayoutEffect(() => { + const scrollElement = scrollRef.current + if (!scrollElement) { + return + } + const current: VirtualRowLayoutSnapshot = { + rowIdentityKeys, + scrollTop: scrollElement.scrollTop, + startsByKey: new Map(virtualItems.map((item) => [String(item.key), item.start])) + } + const motions = buildVirtualRowRemovalMotions({ + previous: previousSnapshotRef.current, + current, + rekeyedRowKeys + }) + previousSnapshotRef.current = current + if (motions.length === 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + return + } + + const elementsByKey = new Map( + Array.from( + scrollElement.querySelectorAll('[data-worktree-virtual-row-key]') + ).map((element) => [element.dataset.worktreeVirtualRowKey ?? '', element]) + ) + for (const motion of motions) { + const element = elementsByKey.get(motion.key) + if (!element || element.hasAttribute('data-worktree-sticky-header-active')) { + continue + } + const content = element.firstElementChild + if (!(content instanceof HTMLElement)) { + continue + } + content.animate([{ translate: `0 ${motion.deltaY}px` }, { translate: '0 0' }], { + duration: WORKTREE_ROW_REMOVAL_ANIMATION_MS, + easing: 'cubic-bezier(0.16, 1, 0.3, 1)' + }) + } + }, [rekeyedRowKeys, rowIdentityKeys, scrollRef, virtualItems]) +} diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index 434a3297df3..73c6659e13e 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -47,6 +47,8 @@ type OrcaTestFixtures = { // Why: most E2E specs need a ready project before assertions start. Golden // first-run specs opt out so they can prove the zero-project onboarding path. seedTestRepo: boolean + // Synthetic-list specs need only the primary checkout; switching specs keep the two-row default. + minimumSeededWorktreeCount: number // Why: spec-scoped launch env. Mutating process.env at spec module scope // leaks into other specs when a worker reloads files without replaying the // first spec's afterAll; per-test launch env cannot leak. @@ -273,13 +275,17 @@ export const test = base.extend({ // Default: dismiss the onboarding overlay so it doesn't intercept clicks. dismissOnboarding: [true, { option: true }], seedTestRepo: [true, { option: true }], + minimumSeededWorktreeCount: [2, { option: true }], launchEnv: [{}, { option: true }], orcaAppExtraEnv: [{}, { option: true }], orcaAppExtraArgs: [[], { option: true }], // Test-scoped: grab the first BrowserWindow, add the test repo, and wait // until the session is fully ready with a worktree active. - sharedPage: async ({ electronApp, seedTestRepo, testRepoPath }, provideFixture) => { + sharedPage: async ( + { electronApp, minimumSeededWorktreeCount, seedTestRepo, testRepoPath }, + provideFixture + ) => { // Why: the Electron app may take a while to create the first window, // especially on cold start with no prior dev userData. Isolated per-test // profiles make late-suite launches slower, so use the full test budget. @@ -379,7 +385,7 @@ export const test = base.extend({ message: 'seeded e2e worktrees did not load' } ) - .toBeGreaterThanOrEqual(2) + .toBeGreaterThanOrEqual(minimumSeededWorktreeCount) // Wait for workspaceSessionReady to become true await page.waitForFunction( diff --git a/tests/e2e/worktree-active-delete-scroll-position.spec.ts b/tests/e2e/worktree-active-delete-scroll-position.spec.ts new file mode 100644 index 00000000000..1b9d4bc3283 --- /dev/null +++ b/tests/e2e/worktree-active-delete-scroll-position.spec.ts @@ -0,0 +1,338 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +const TARGET_INDEX = 24 +const SYNTHETIC_COUNT = 40 +const VISUAL_PROOF_PAUSE_MS = 1_200 +const POST_REMOVAL_SAMPLE_FRAMES = 20 +const MAX_REMOVAL_WAIT_FRAMES = 300 + +test.use({ minimumSeededWorktreeCount: 1 }) + +type RowRemovalFrame = { + animationCount: number + belowTop: number | null + scrollTop: number + targetExists: boolean +} + +async function pauseForVisualProof(page: Page): Promise { + if (process.env.ORCA_E2E_RECORD_VIDEO === '1') { + await page.waitForTimeout(VISUAL_PROOF_PAUSE_MS) + } +} + +async function seedActiveDeletionRows(page: Page): Promise<{ + belowId: string + successorId: string + targetId: string +}> { + return page.evaluate( + ({ count, targetIndex }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const repo = state.repos[0] + const source = repo + ? state.worktreesByRepo[repo.id]?.find((worktree) => worktree.isMainWorktree) + : null + if (!repo || !source || !state.settings) { + throw new Error('Expected a seeded e2e worktree and hydrated settings') + } + + const now = Date.now() + const worktrees = Array.from({ length: count }, (_, index) => { + const suffix = String(index).padStart(2, '0') + return { + ...source, + id: `${repo.id}::active-delete-${suffix}`, + instanceId: `active-delete-${suffix}`, + path: source.path, + displayName: `Active delete row ${suffix}`, + branch: `active-delete-${suffix}`, + isMainWorktree: false, + isPinned: false, + isUnread: false, + sortOrder: count - index, + manualOrder: count - index, + lastActivityAt: now - index, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null + } + }) + const target = worktrees[targetIndex] + const below = worktrees[targetIndex + 1] + const successor = worktrees[0] + if (!target || !below || !successor) { + throw new Error('Synthetic worktree fixture is too small') + } + const targetId = target.id + const belowId = below.id + const successorId = successor.id + + store.setState({ + activeRepoId: repo.id, + activeView: 'terminal', + activeWorktreeId: targetId, + activeWorkspaceKey: `worktree:${targetId}`, + filterRepoIds: [], + groupBy: 'none', + hideDefaultBranchWorkspace: false, + lastVisitedAtByWorktreeId: { [successorId]: now + 1_000 }, + pendingRevealSidebarRow: null, + pendingRevealWorktree: null, + repos: state.repos.map((candidate) => + candidate.id === repo.id + ? { + ...candidate, + hookSettings: { + mode: candidate.hookSettings?.mode ?? 'auto', + ...candidate.hookSettings, + scripts: { + archive: candidate.hookSettings?.scripts.archive ?? '', + setup: 'true' + } + } + } + : candidate + ), + settings: { ...state.settings, skipDeleteWorktreeConfirm: true }, + setupScriptPromptDismissedRepoIds: [`generation-v1:local\0${repo.id}`], + showActiveOnly: false, + showSleepingWorkspaces: true, + sidebarOpen: true, + sortBy: 'manual', + worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: worktrees }, + removeWorktree: async (target) => { + const id = typeof target === 'string' ? target : target.id + store.setState((current) => ({ + activeWorktreeId: current.activeWorktreeId === id ? null : current.activeWorktreeId, + activeWorkspaceKey: current.activeWorktreeId === id ? null : current.activeWorkspaceKey, + worktreesByRepo: { + ...current.worktreesByRepo, + [repo.id]: (current.worktreesByRepo[repo.id] ?? []).filter( + (worktree) => worktree.id !== id + ) + } + })) + return { ok: true } + } + }) + return { belowId, successorId, targetId } + }, + { count: SYNTHETIC_COUNT, targetIndex: TARGET_INDEX } + ) +} + +async function prepareScrolledActiveRow(page: Page, targetId: string): Promise { + const target = page.locator( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]` + ) + const scroller = page.locator('[data-worktree-sidebar]') + await expect + .poll(async () => { + if ((await target.count()) > 0) { + return true + } + await scroller.evaluate((element) => { + element.scrollTop = Math.min( + element.scrollHeight, + element.scrollTop + Math.max(100, element.clientHeight / 2) + ) + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + return false + }) + .toBe(true) + await target.evaluate((element) => element.scrollIntoView({ block: 'center' })) + await target.evaluate((element) => { + const scroller = element.closest('[data-worktree-sidebar]') + if (!scroller) { + throw new Error('Worktree sidebar is unavailable') + } + const targetOffset = element.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop += targetOffset - 160 + scroller.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect(target).toBeVisible() + await expect(target).toHaveAttribute('aria-current', 'page') +} + +async function startRowRemovalSampling( + page: Page, + targetId: string, + belowId: string +): Promise { + await page.evaluate( + ({ belowId, maxRemovalWaitFrames, postRemovalSampleFrames, targetId }) => { + const sample = async (): Promise => { + const readFrame = (): RowRemovalFrame => { + const scroller = document.querySelector('[data-worktree-sidebar]') + const below = document.querySelector( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(belowId)}]` + ) + const targetExists = Boolean( + document.querySelector( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]` + ) + ) + return { + animationCount: + below?.closest('[data-worktree-virtual-row]')?.firstElementChild?.getAnimations() + .length ?? 0, + belowTop: below?.getBoundingClientRect().top ?? null, + scrollTop: scroller?.scrollTop ?? 0, + targetExists + } + } + const frames: RowRemovalFrame[] = [readFrame()] + let framesAfterRemoval = 0 + for (let index = 0; index < maxRemovalWaitFrames + postRemovalSampleFrames; index += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + const frame = readFrame() + frames.push(frame) + framesAfterRemoval = frame.targetExists ? 0 : framesAfterRemoval + 1 + if (framesAfterRemoval >= postRemovalSampleFrames) { + break + } + } + return frames + } + Reflect.set(window, '__activeDeleteRowRemovalFrames', sample()) + }, + { + belowId, + maxRemovalWaitFrames: MAX_REMOVAL_WAIT_FRAMES, + postRemovalSampleFrames: POST_REMOVAL_SAMPLE_FRAMES, + targetId + } + ) +} + +async function finishRowRemovalSampling(page: Page): Promise { + return page.evaluate(async () => { + const pending = Reflect.get(window, '__activeDeleteRowRemovalFrames') + if (!(pending instanceof Promise)) { + throw new Error('Row removal sampling was not started') + } + return pending + }) +} + +test('deleting the active scrolled worktree preserves position and closes the row gap', async ({ + orcaPage +}) => { + await waitForSessionReady(orcaPage) + await orcaPage.setViewportSize({ width: 1_200, height: 800 }) + const { belowId, successorId, targetId } = await seedActiveDeletionRows(orcaPage) + await prepareScrolledActiveRow(orcaPage, targetId) + const target = orcaPage.locator( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]` + ) + const below = orcaPage.locator( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(belowId)}]` + ) + await pauseForVisualProof(orcaPage) + await target.evaluate((element) => { + const scope = element.querySelector( + '[data-worktree-context-menu-scope="worktree"]' + ) + if (!scope) { + throw new Error('Worktree context-menu scope is unavailable') + } + scope.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + button: 2, + cancelable: true, + clientX: scope.getBoundingClientRect().left + 10, + clientY: scope.getBoundingClientRect().top + 10 + }) + ) + }) + const deleteItem = orcaPage.getByRole('menuitem', { name: 'Delete', exact: true }) + await expect(deleteItem).toBeVisible() + await expect(deleteItem).toBeInViewport() + await pauseForVisualProof(orcaPage) + await startRowRemovalSampling(orcaPage, targetId, belowId) + await deleteItem.click() + + await expect(target).toHaveCount(0) + await expect(below).toBeVisible() + await expect + .poll(() => orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId ?? null)) + .toBe(successorId) + const frames = await finishRowRemovalSampling(orcaPage) + await pauseForVisualProof(orcaPage) + const mountedTops = frames.flatMap((frame) => (frame.belowTop === null ? [] : [frame.belowTop])) + const firstRemovedFrame = frames.findIndex((frame) => !frame.targetExists) + const scrollTopBeforeDelete = frames[0]?.scrollTop + if (scrollTopBeforeDelete === undefined) { + throw new Error('Row removal sampler recorded no pre-delete frame') + } + + expect(firstRemovedFrame).toBeGreaterThan(0) + expect(frames.slice(firstRemovedFrame).every((frame) => !frame.targetExists)).toBe(true) + expect(Math.max(...frames.map((frame) => frame.animationCount))).toBeGreaterThan(0) + expect(Math.max(...mountedTops) - Math.min(...mountedTops)).toBeGreaterThan(30) + expect(Math.max(...frames.map((frame) => frame.scrollTop))).toBeLessThanOrEqual( + scrollTopBeforeDelete + 1 + ) + expect(Math.min(...frames.map((frame) => frame.scrollTop))).toBeGreaterThanOrEqual( + scrollTopBeforeDelete - 1 + ) + await expect( + orcaPage.locator(`[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(successorId)}]`) + ).toHaveCount(0) +}) + +test('reduced motion removes the active row without animating its neighbor', async ({ + orcaPage +}) => { + await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) + await waitForSessionReady(orcaPage) + const { belowId, targetId } = await seedActiveDeletionRows(orcaPage) + await prepareScrolledActiveRow(orcaPage, targetId) + + const animationCount = await orcaPage.evaluate( + async ({ belowId, targetId }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + const repoId = repo.id + const worktrees = state.worktreesByRepo[repoId] + if (!worktrees) { + throw new Error('Expected seeded e2e worktrees') + } + store.setState({ + activeWorktreeId: null, + activeWorkspaceKey: null, + worktreesByRepo: { + ...state.worktreesByRepo, + [repoId]: worktrees.filter((worktree) => worktree.id !== targetId) + } + }) + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + const below = document.querySelector( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(belowId)}]` + ) + return ( + below?.closest('[data-worktree-virtual-row]')?.firstElementChild?.getAnimations().length ?? + 0 + ) + }, + { belowId, targetId } + ) + + expect(animationCount).toBe(0) +}) From 50438041a88834b950426462b3e191b4f68db05e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:19:48 -0700 Subject: [PATCH 18/65] fix(rate-limits): safely surface Codex RPC exit reasons (#16023) --- ...codex-fetcher-rpc-exit-diagnostics.test.ts | 140 ++++++++++++++++++ src/main/rate-limits/codex-fetcher.ts | 32 +++- .../commit-message-text-generation.ts | 37 +---- .../codex-account-auth-warning.test.ts | 26 ++++ src/shared/commit-message-agent-output.ts | 27 ++++ src/shared/commit-message-prompt.ts | 3 +- 6 files changed, 227 insertions(+), 38 deletions(-) create mode 100644 src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts diff --git a/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts b/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts new file mode 100644 index 00000000000..ee874ef5942 --- /dev/null +++ b/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts @@ -0,0 +1,140 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as Win32Utils from '../win32-utils' +import { isCodexAuthError } from '../../shared/codex-auth-errors' + +const { getSpawnArgsForWindowsMock, ptySpawnMock, resolveCodexCommandMock } = vi.hoisted(() => ({ + getSpawnArgsForWindowsMock: vi.fn(), + ptySpawnMock: vi.fn(), + resolveCodexCommandMock: vi.fn() +})) + +vi.mock('../win32-utils', async (importOriginal) => ({ + ...(await importOriginal()), + getSpawnArgsForWindows: getSpawnArgsForWindowsMock +})) + +vi.mock('../codex-cli/command', () => ({ + resolveCodexCommand: resolveCodexCommandMock +})) + +vi.mock('node-pty', () => ({ + spawn: ptySpawnMock +})) + +vi.mock('./codex-auth-presence', () => ({ + probeCodexAuthPresence: vi.fn(() => 'present') +})) + +vi.mock('../codex/codex-state-db', () => ({ + isCodexStateDbBackfillPending: vi.fn(() => false) +})) + +vi.mock('../codex/codex-state-db-backfill-recovery', () => ({ + startCodexStateDbBackfillRecoveryInBackground: vi.fn(() => Promise.resolve(null)) +})) + +import { fetchCodexRateLimits } from './codex-fetcher' + +// Dies the way a real app-server does: complains on stderr, then exits nonzero +// once the client has spoken, so the initialize write can never race an EPIPE. +const STUB_CODEX_SOURCE = ` +process.stdin.on('data', () => { + process.stderr.write(process.env.ORCA_STUB_CODEX_STDERR ?? '') + process.exit(Number(process.env.ORCA_STUB_CODEX_EXIT_CODE ?? '1')) +}) +` + +let tempRoot: string +let stubPath: string + +function runStub(stderr: string, exitCode: number): Promise<{ error: string | null }> { + process.env.ORCA_STUB_CODEX_STDERR = stderr + process.env.ORCA_STUB_CODEX_EXIT_CODE = String(exitCode) + return fetchCodexRateLimits({ allowPtyFallback: false }) +} + +describe('Codex RPC exit diagnostics', () => { + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'orca-codex-rpc-exit-')) + stubPath = join(tempRoot, 'codex-exit-stub.cjs') + writeFileSync(stubPath, STUB_CODEX_SOURCE) + resolveCodexCommandMock.mockReturnValue('codex') + getSpawnArgsForWindowsMock.mockImplementation((_command: string, args: string[]) => ({ + spawnCmd: process.execPath, + spawnArgs: [stubPath, ...args] + })) + }) + + afterEach(() => { + delete process.env.ORCA_STUB_CODEX_STDERR + delete process.env.ORCA_STUB_CODEX_EXIT_CODE + rmSync(tempRoot, { recursive: true, force: true }) + vi.clearAllMocks() + }) + + it('names the exit code and what the child reported', async () => { + const result = await runStub( + "error: invalid value 'untrusted' for '--ask-for-approval '\n" + + " [possible values: on-request, never]\n\nFor more information, try '--help'.\n", + 2 + ) + + expect(result.error).toContain('Codex RPC process exited (exit code 2)') + expect(result.error).toContain("invalid value 'untrusted'") + expect(result.error).toContain("try '--help'") + }) + + it('names the exit code when the child says nothing', async () => { + await expect(runStub('', 1)).resolves.toMatchObject({ + error: 'Codex RPC process exited (exit code 1)', + status: 'error' + }) + }) + + it('classifies a dead refresh token so the re-auth warning fires', async () => { + const result = await runStub( + '2026-08-20T18:04:11.221Z INFO codex_core::auth: refreshing ChatGPT tokens\n' + + 'ERROR: The ChatGPT access token could not be refreshed; please sign in again.\n', + 1 + ) + + expect(result.error).toBe('Your ChatGPT session could not be refreshed. Please sign in again.') + // The renderer's re-auth warning gates on exactly this predicate; + // codex-account-auth-warning.test.ts asserts the surfaced string end-to-end. + expect(isCodexAuthError(result.error)).toBe(true) + }) + + it('keeps the re-auth phrase that path redaction would swallow', async () => { + const result = await runStub( + `ERROR: /Users/haris/.codex/auth.json is stale — please sign in again.\n`, + 1 + ) + + expect(isCodexAuthError(result.error)).toBe(true) + }) + + it('redacts paths and secrets a dying child prints, and bounds the line', async () => { + const result = await runStub( + `ERROR: could not read /Users/moveoplus/.codex/auth.json (token=sk-${'a'.repeat(40)})\n` + + `${'x'.repeat(5_000)}\n`, + 3 + ) + + expect(result.error).not.toContain('moveoplus') + expect(result.error).not.toContain('sk-aaaa') + expect(result.error?.length ?? 0).toBeLessThan(500) + }) + + it('keeps the final failure after startup banners', async () => { + const result = await runStub( + 'Welcome to WSL\nLoading shell profile\nERROR: unknown option --obsolete\n', + 2 + ) + + expect(result.error).toContain('Welcome to WSL') + expect(result.error).toContain('ERROR: unknown option --obsolete') + }) +}) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 62cab4b94bb..dc18e715fdd 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -25,6 +25,11 @@ import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' import { parseWslUncPath } from '../../shared/wsl-paths' import { extractCodexAuthError, isCodexAuthError } from '../../shared/codex-auth-errors' +import { + excerptAgentFailureOutput, + sanitizeAgentFailureDetail +} from '../../shared/commit-message-agent-output' +import { redactString } from '../observability/redactor' import { buildWslExecArgs, buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' import { getHiddenRateLimitWslCwdSetupCommands, @@ -863,19 +868,42 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise\\]+\\(?:[^\s"'`<>\\]+(?:\s+[^\s"'`<>\\]+)*(?=\\)\\)*[^\s"'`<>\\]+/g, - '[path]' - ) - // Only backslashes may repeat: JSON provider bodies double them - // (`C:\\Users\\name\\…`), while a URL's `://` must stay single so remedy - // links like `https://…` survive redaction. - .replace( - /[A-Za-z]:(?:\\+|\/)(?:[^\s"'`<>\\/|:*?]+(?:\s+[^\s"'`<>\\/|:*?]+)*(?=[\\/])(?:\\+|\/))*[^\s"'`<>\\/|:*?]+/g, - '[path]' - ) - // Why: require ≥2 segments (one internal `/`) so provider remedy tokens like - // `/login` survive while multi-segment paths (`/Users/name/repo`) still redact. - // `=:,` prefixes catch key=/path value:/path list,/path shapes in provider bodies. - .replace( - /(^|[\s"'`(=:,])\/(?:[^\s"'`<>/]+(?:\s+[^\s"'`<>/]+)*(?=\/)\/)+[^\s"'`<>/]+/g, - '$1[path]' - ) - return redacted.length > 240 ? `${redacted.slice(0, 240).trimEnd()}...` : redacted -} - function userFacingUnsafeWindowsBatchArgs(label: string): string { return `${label} cannot be run as a Windows batch command with the prompt in argv. Remove {prompt} so Orca sends the prompt on stdin.` } diff --git a/src/renderer/src/components/settings/codex-account-auth-warning.test.ts b/src/renderer/src/components/settings/codex-account-auth-warning.test.ts index 4d1631fbaad..d0062884c4c 100644 --- a/src/renderer/src/components/settings/codex-account-auth-warning.test.ts +++ b/src/renderer/src/components/settings/codex-account-auth-warning.test.ts @@ -38,6 +38,32 @@ describe('codex account auth warning', () => { ).toBe(false) }) + it('warns re-auth when the app-server quits over a dead refresh token', () => { + expect( + getCodexAccountAuthWarning({ + limits: codexLimits('Your ChatGPT session could not be refreshed. Please sign in again.'), + target: { runtime: 'host', wslDistro: null }, + runtime: { runtime: 'host' }, + activeAccountId: 'account-1', + accountId: 'account-1' + }) + ).toBe('stale-sign-in') + }) + + it('does not warn re-auth when the app-server quits over bad argv', () => { + expect( + getCodexAccountAuthWarning({ + limits: codexLimits( + "Codex RPC process exited (exit code 2): error: invalid value 'untrusted' for '--ask-for-approval '" + ), + target: { runtime: 'host', wslDistro: null }, + runtime: { runtime: 'host' }, + activeAccountId: 'account-1', + accountId: 'account-1' + }) + ).toBeNull() + }) + it('matches the active host account on the current rate-limit target', () => { expect( getCodexAccountAuthWarning({ diff --git a/src/shared/commit-message-agent-output.ts b/src/shared/commit-message-agent-output.ts index cbda4a42bde..27c0d201a7d 100644 --- a/src/shared/commit-message-agent-output.ts +++ b/src/shared/commit-message-agent-output.ts @@ -158,6 +158,33 @@ export function excerptAgentFailureOutput(stdout: string, stderr: string): strin return composeTwoEndExcerpt(headLines, tailLine) } +export function sanitizeAgentFailureDetail(detail: string | null): string | null { + // Cf covers bidi overrides that could visually reorder persisted, client-synced text. + const trimmed = detail + ?.replace(/[\p{Cc}\p{Cf}]+/gu, ' ') + .replace(/\s+/g, ' ') + .trim() + if (!trimmed) { + return null + } + const redacted = trimmed + .replace( + /\\\\[^\s"'`<>\\]+\\(?:[^\s"'`<>\\]+(?:\s+[^\s"'`<>\\]+)*(?=\\)\\)*[^\s"'`<>\\]+/g, + '[path]' + ) + // JSON may double Windows separators; URL separators must remain single. + .replace( + /[A-Za-z]:(?:\\+|\/)(?:[^\s"'`<>\\/|:*?]+(?:\s+[^\s"'`<>\\/|:*?]+)*(?=[\\/])(?:\\+|\/))*[^\s"'`<>\\/|:*?]+/g, + '[path]' + ) + // Keep single-segment remedies such as /login while redacting filesystem paths. + .replace( + /(^|[\s"'`(=:,])\/(?:[^\s"'`<>/]+(?:\s+[^\s"'`<>/]+)*(?=\/)\/)+[^\s"'`<>/]+/g, + '$1[path]' + ) + return redacted.length > 240 ? `${redacted.slice(0, 240).trimEnd()}...` : redacted +} + function composeTwoEndExcerpt(headLines: string[], tailLine: string | null): string { const headPart = truncateExcerptPart(headLines.join(' '), FAILURE_EXCERPT_HEAD_BUDGET) // Repeated lines (spinner/retry frames) would otherwise show twice. diff --git a/src/shared/commit-message-prompt.ts b/src/shared/commit-message-prompt.ts index 164f4da6327..7fb9da76d66 100644 --- a/src/shared/commit-message-prompt.ts +++ b/src/shared/commit-message-prompt.ts @@ -19,7 +19,8 @@ Staged diff: export { cleanGeneratedCommitMessage, - excerptAgentFailureOutput + excerptAgentFailureOutput, + sanitizeAgentFailureDetail } from './commit-message-agent-output' /** Builds the final prompt sent to the agent. The custom suffix is appended verbatim From 1a7934c54f484c5ba1bf087d9f5455d739c6d3c0 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 24 Aug 2026 18:21:22 -0700 Subject: [PATCH 19/65] Fix deleted remote worktree reappearing due to host ID mismatch (#16039) * Fix deleted remote worktree reappearing due to host ID mismatch Paired clients and servers may use different spellings for execution hosts (e.g., client 'runtime:env-1' vs server 'local'). Resolve these spellings before worktree.rm and worktree.forceDeleteBranch to prevent failures and orphaned worktrees. * Validate runtime kind before comparing environment IDs Ensure parsed host IDs are actually runtime environments before accessing their environmentId property. Fixes incorrect host ID matching during worktree cleanup that caused deleted remote worktrees to reappear. * Use hostId directly when same-ID surviving host exists When a surviving host has the same ID as the deleted worktree's original host, use the hostId directly instead of qualifying it through the runtime call host. This prevents worktrees from reappearing due to host ID mismatch. --------- Co-authored-by: m4air --- .../rpc/methods/paired-caller-host-id.test.ts | 59 ++++++++++++ .../rpc/methods/paired-caller-host-id.ts | 34 +++++++ .../worktree-rm-host-qualification.test.ts | 93 ++++++++++++++++++- .../runtime/rpc/methods/worktree-schemas.ts | 2 +- src/main/runtime/rpc/methods/worktree.ts | 20 +++- .../worktrees-remote-runtime-removal.test.ts | 63 ++++++++++++- .../teardown/dispatch-worktree-removal.ts | 21 ++++- .../teardown/force-delete-preserved-branch.ts | 12 ++- 8 files changed, 293 insertions(+), 11 deletions(-) create mode 100644 src/main/runtime/rpc/methods/paired-caller-host-id.test.ts create mode 100644 src/main/runtime/rpc/methods/paired-caller-host-id.ts diff --git a/src/main/runtime/rpc/methods/paired-caller-host-id.test.ts b/src/main/runtime/rpc/methods/paired-caller-host-id.test.ts new file mode 100644 index 00000000000..551e5f5cacb --- /dev/null +++ b/src/main/runtime/rpc/methods/paired-caller-host-id.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { resolvePairedCallerHostId } from './paired-caller-host-id' + +function repos(...hostIds: (string | undefined)[]): Repo[] { + return hostIds.map((executionHostId, index) => ({ + id: 'repo-1', + path: `/repo-${index}`, + executionHostId + })) as unknown as Repo[] +} + +const SELECTOR = 'id:repo-1::/repo/wt' + +describe('resolvePairedCallerHostId', () => { + it('passes through our own vocabulary and an absent qualifier', () => { + expect(resolvePairedCallerHostId(() => repos('local'), SELECTOR, 'local')).toBe('local') + expect(resolvePairedCallerHostId(() => repos('ssh:a'), SELECTOR, 'ssh:a')).toBe('ssh:a') + expect(resolvePairedCallerHostId(() => repos('local'), SELECTOR, undefined)).toBeUndefined() + }) + + it('answers with `local` when nothing carries the client-minted stamp', () => { + expect(resolvePairedCallerHostId(() => repos('local'), SELECTOR, 'runtime:env-1')).toBe('local') + expect(resolvePairedCallerHostId(() => repos('ssh:a'), SELECTOR, 'runtime:env-1')).toBe('local') + expect(resolvePairedCallerHostId(() => [], SELECTOR, 'runtime:env-1')).toBe('local') + }) + + it('keeps a stamp this store still uses for its own repo', () => { + expect(resolvePairedCallerHostId(() => repos('runtime:env-1'), SELECTOR, 'runtime:env-1')).toBe( + 'runtime:env-1' + ) + }) + + it('ignores another repo id carrying the other spelling', () => { + const otherRepo = { id: 'repo-2', path: '/other' } as unknown as Repo + expect( + resolvePairedCallerHostId( + () => [...repos('runtime:env-1'), otherRepo], + SELECTOR, + 'runtime:env-1' + ) + ).toBe('runtime:env-1') + }) + + it('refuses to guess when one repo id carries both spellings', () => { + expect(() => + resolvePairedCallerHostId(() => repos('local', 'runtime:env-1'), SELECTOR, 'runtime:env-1') + ).toThrow('ambiguous across hosts') + }) + + it('falls back to every repo when the selector names no repo id', () => { + expect( + resolvePairedCallerHostId(() => repos('runtime:env-1'), 'feature/x', 'runtime:env-1') + ).toBe('runtime:env-1') + expect(resolvePairedCallerHostId(() => repos('local'), 'feature/x', 'runtime:env-1')).toBe( + 'local' + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/paired-caller-host-id.ts b/src/main/runtime/rpc/methods/paired-caller-host-id.ts new file mode 100644 index 00000000000..da418c57479 --- /dev/null +++ b/src/main/runtime/rpc/methods/paired-caller-host-id.ts @@ -0,0 +1,34 @@ +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import { splitWorktreeId } from '../../../../shared/worktree/id' + +/** Maps a paired client's runtime-local host spelling to this host's repo spelling. */ +export function resolvePairedCallerHostId( + getRepos: () => readonly Repo[], + worktreeSelector: string, + callerHostId: ExecutionHostId | undefined +): ExecutionHostId | undefined { + if (!callerHostId || parseExecutionHostId(callerHostId)?.kind !== 'runtime') { + return callerHostId + } + const repoId = splitWorktreeId( + worktreeSelector.startsWith('id:') ? worktreeSelector.slice(3) : worktreeSelector + )?.repoId + const spellings = new Set( + getRepos() + .filter((repo) => (repoId ? repo.id === repoId : true)) + .map((repo) => getRepoExecutionHostId(repo)) + .filter((hostId) => hostId === LOCAL_EXECUTION_HOST_ID || hostId === callerHostId) + ) + if (spellings.size > 1) { + throw new Error( + `Workspace identity is ambiguous across hosts: ${worktreeSelector}. Retry with an explicit host.` + ) + } + return [...spellings][0] ?? LOCAL_EXECUTION_HOST_ID +} diff --git a/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts b/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts index d394caa30cd..68f8b040479 100644 --- a/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts +++ b/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts @@ -4,14 +4,18 @@ import type { RpcRequest } from '../core' import { RpcDispatcher } from '../dispatcher' import { WORKTREE_METHODS } from './worktree' -function makeRuntime(): OrcaRuntimeService { +function makeRuntime(repoHostIds: (string | undefined)[] = ['local']): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + listRepos: () => + repoHostIds.map((executionHostId) => ({ id: 'repo-1', path: '/repo', executionHostId })), showManagedWorktree: vi.fn().mockResolvedValue({ id: 'wt-1', hostId: 'local' }), removeManagedWorktree: vi.fn().mockResolvedValue({}) } as unknown as OrcaRuntimeService } +const WORKTREE_ID = 'repo-1::/repo/wt' + function makeRequest(params: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method: 'worktree.rm', params } } @@ -35,6 +39,93 @@ describe('worktree.rm host qualification', () => { expect(response).toMatchObject({ ok: true, result: { removed: true } }) }) + it("resolves a paired client's own name for this host to our spelling", async () => { + const runtime = makeRuntime(['local']) + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest({ + worktree: `id:${WORKTREE_ID}`, + hostId: 'runtime:env-1', + force: true, + runHooks: false + }) + ) + + expect(runtime.removeManagedWorktree).toHaveBeenCalledWith( + `id:${WORKTREE_ID}`, + true, + false, + false, + 'local' + ) + expect(response).toMatchObject({ ok: true, result: { removed: true } }) + }) + + it('keeps a client-minted stamp this store still uses for its own repo', async () => { + const runtime = makeRuntime(['runtime:env-1']) + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + await dispatcher.dispatch( + makeRequest({ + worktree: `id:${WORKTREE_ID}`, + hostId: 'runtime:env-1', + force: true, + runHooks: false + }) + ) + + expect(runtime.removeManagedWorktree).toHaveBeenCalledWith( + `id:${WORKTREE_ID}`, + true, + false, + false, + 'runtime:env-1' + ) + }) + + it('fails closed when the repo id carries both spellings', async () => { + const runtime = makeRuntime(['local', 'runtime:env-1']) + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest({ + worktree: `id:${WORKTREE_ID}`, + hostId: 'runtime:env-1', + force: true, + runHooks: false + }) + ) + + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('ambiguous across hosts') } + }) + expect(runtime.removeManagedWorktree).not.toHaveBeenCalled() + }) + + it('leaves an ssh qualifier alone, since we do proxy those onward', async () => { + const runtime = makeRuntime(['ssh:target-a']) + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + await dispatcher.dispatch( + makeRequest({ + worktree: `id:${WORKTREE_ID}`, + hostId: 'ssh:target-a', + force: true, + runHooks: false + }) + ) + + expect(runtime.removeManagedWorktree).toHaveBeenCalledWith( + `id:${WORKTREE_ID}`, + true, + false, + false, + 'ssh:target-a' + ) + }) + it.each([['bogus'], ['ssh:'], ['runtime:'], [''], [null], [42]])( 'fails closed when an explicit host id is malformed: %j', async (hostId) => { diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index ce02c032daf..53419d80e18 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -174,7 +174,7 @@ export const WorktreeRemove = WorktreeSelector.extend({ }) export const WorktreeForceDeleteBranch = WorktreeSelector.extend({ - hostId: OptionalString, + hostId: OptionalExecutionHostId, branchName: z .unknown() .transform((v) => (typeof v === 'string' ? v : '')) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 7503d65d623..080d68a71a6 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -6,6 +6,7 @@ import { import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-provenance' import { defineMethod, type RpcMethod } from '../core' import { buildManagedWorktreeCreateArgs } from './worktree-create-args' +import { resolvePairedCallerHostId } from './paired-caller-host-id' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' import { WorktreeCreate, WorktreePrefetchCreateBase } from './worktree-create-schemas' @@ -204,10 +205,15 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: 'worktree.rm', params: WorktreeRemove, handler: async (params, { runtime }) => { + // Translate a paired client's runtime-local host spelling before host-qualified reads. + let resolvedHostId = resolvePairedCallerHostId( + () => runtime.listRepos(), + params.worktree, + params.hostId + ) // Older mobile clients omit hostId, so resolve through the ambiguity gate // before pinning removal. An ambiguous selector still fails closed: two // hosts own the id and an unqualified client cannot say which it meant. - let resolvedHostId = params.hostId if (!resolvedHostId) { try { resolvedHostId = (await runtime.showManagedWorktree(params.worktree)).hostId @@ -237,18 +243,24 @@ export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.forceDeleteBranch', params: WorktreeForceDeleteBranch, - handler: async (params, { runtime }) => - params.hostId + handler: async (params, { runtime }) => { + const hostId = resolvePairedCallerHostId( + () => runtime.listRepos(), + params.worktree, + params.hostId + ) + return hostId ? runtime.forceDeletePreservedBranch( params.worktree, params.branchName, params.expectedHead, - params.hostId + hostId ) : runtime.forceDeletePreservedBranch( params.worktree, params.branchName, params.expectedHead ) + } }) ] diff --git a/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts b/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts index c086d79d94c..528c9a977d5 100644 --- a/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts +++ b/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts @@ -60,7 +60,6 @@ describe('worktree remote runtime mutations', () => { method: 'worktree.rm', params: { worktree: `id:${wt.id}`, - hostId: 'runtime:env-1', force: false, allowUnverifiedPtyStop: false, runHooks: true @@ -82,6 +81,68 @@ describe('worktree remote runtime mutations', () => { expect(store.getState().worktreesByRepo.repo1).toEqual([]) }) + it('cleans up a preserved branch through the same host qualifier the removal used', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + hostId: 'runtime:env-1' + }) + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'rpc-rm', + ok: true, + result: { preservedBranch: { branchName: 'feature/x', head: 'saved-head' } }, + _meta: { runtimeId: 'runtime-remote' } + }) + .mockResolvedValueOnce({ + id: 'rpc-force-delete-branch', + ok: true, + result: { deleted: true }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + trustedOrcaHooks: { repo1: { all: { approvedAt: 1 } } }, + worktreesByRepo: { repo1: [wt] } + } as Partial) + + await store.getState().removeWorktree({ id: wt.id, executionHostId: 'runtime:env-1' }) + + const forceResult = await store + .getState() + .forceDeletePreservedBranch(wt.id, 'feature/x', 'saved-head', { + hostId: 'runtime:env-1', + runtimeEnvironmentId: 'env-1' + }) + + expect(forceResult).toEqual({ ok: true, deleted: true }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'worktree.rm', + params: { + worktree: `id:${wt.id}`, + force: undefined, + allowUnverifiedPtyStop: false, + runHooks: true + }, + timeoutMs: 60_000, + expectedEnvironmentPairingRevision: undefined, + expectedRuntimeId: undefined + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'worktree.forceDeleteBranch', + params: { + worktree: `id:${wt.id}`, + branchName: 'feature/x', + expectedHead: 'saved-head' + }, + timeoutMs: 15_000 + }) + }) + it('does not clean up a hidden same-id VM owned by another host', async () => { const store = createTestStore() const wt = makeWorktree({ diff --git a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts index bea7c631825..2ca0212b08f 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts @@ -1,4 +1,4 @@ -import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host' import type { RemoveWorktreeResult } from '../../../../../../shared/worktree/create-types' import { callRuntimeRpc, type getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '../../../../runtime/runtime-worktree-selector' @@ -40,12 +40,14 @@ export async function dispatchWorktreeRemoval(args: { ...snapshotPruneBatch }) } + const effectiveHostId = + options?.sameIdSurvivingHostId != null ? hostId : qualifyRuntimeCallHost(target, hostId) return callRuntimeRpc( target, 'worktree.rm', { worktree: toRuntimeWorktreeSelector(worktreeId), - ...(hostId ? { hostId } : {}), + ...(effectiveHostId ? { hostId: effectiveHostId } : {}), force, allowUnverifiedPtyStop: options?.allowUnverifiedPtyStop === true, runHooks: !skipArchive @@ -53,3 +55,18 @@ export async function dispatchWorktreeRemoval(args: { { timeoutMs: 60_000 } ) } + +function qualifyRuntimeCallHost( + target: ReturnType, + hostId: ExecutionHostId | undefined +): ExecutionHostId | undefined { + const parsedHost = parseExecutionHostId(hostId) + if ( + target.kind === 'environment' && + parsedHost?.kind === 'runtime' && + parsedHost.environmentId === target.environmentId + ) { + return undefined + } + return hostId +} diff --git a/src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts b/src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts index d00df69e8c2..5875371035b 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts @@ -1,4 +1,5 @@ import type { WorktreeSlice } from '../../worktree-helpers' +import { parseExecutionHostId } from '../../../../../../shared/execution-host' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' @@ -60,12 +61,19 @@ export function createForceDeletePreservedBranch( const target = retainedTarget?.target ?? getActiveRuntimeTarget(settingsForWorktreeOwner(get(), worktreeId)) + const parsedCleanupHost = parseExecutionHostId(cleanupHostId) + const effectiveHostId = + target.kind === 'environment' && + parsedCleanupHost?.kind === 'runtime' && + parsedCleanupHost.environmentId === target.environmentId + ? undefined + : cleanupHostId const result = await (target.kind === 'local' ? window.api.worktrees.forceDeletePreservedBranch({ worktreeId, branchName, expectedHead, - ...(cleanupHostId ? { hostId: cleanupHostId } : {}) + ...(effectiveHostId ? { hostId: effectiveHostId } : {}) }) : callRuntimeRpc( target, @@ -74,7 +82,7 @@ export function createForceDeletePreservedBranch( worktree: toRuntimeWorktreeSelector(worktreeId), branchName, expectedHead, - ...(cleanupHostId ? { hostId: cleanupHostId } : {}) + ...(effectiveHostId ? { hostId: effectiveHostId } : {}) }, { timeoutMs: 15_000 } )) From cc4801320a75f2fd87f67454e13dae7a63117097 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:24:32 -0700 Subject: [PATCH 20/65] fix(terminal): stop stale multiplex stream handles from swallowing input (#16325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(terminal): pin park/reveal re-subscribe on a shared multiplexer Investigating STA-5098. Parking a mirrored remote tab closes its stream while a sibling tab keeps the multiplexer alive, so the reveal re-subscribes on an instance that already retired a stream. This came back green, which exonerates the multiplexer as the cause of STA-5098 — the wedge is above it. Kept as a contract guard; the header says explicitly that it is not coverage for that ticket. * fix(terminal): stop stale multiplex stream handles from swallowing input A stream handle whose record was dropped (park close, or a reconnect that clears the stream table) kept reporting success: sendFrame gates only on socket readiness, never on stream membership. The host drops those frames for an unknown stream id, so a revealed cold-parked remote pane looked connected while the PTY never saw a byte and never painted (STA-5098). Reporting success also defeated the transport's own recovery — it re-sends input over terminal.send when the stream refuses it, which never ran. Guard the three public senders on stream membership, the check close() and setOutputPaused already use, and drain input queued behind a viewport claim on every stream install rather than only a still-pending claim. Withdraws the parked-reveal re-subscribe test: its fake host answered Subscribe with an immediate snapshot, so it could not fail. * chore(terminal): tighten the stale-stream comments --- .../remote-runtime-pty-transport.ts | 31 ++++--- .../remote-runtime-terminal-multiplexer.ts | 11 ++- ...ntime-terminal-stale-stream-frames.test.ts | 87 +++++++++++++++++++ 3 files changed, 115 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/runtime/remote-runtime-terminal-stale-stream-frames.test.ts diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 7d50b9f48de..5e0e42715ca 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -377,6 +377,20 @@ export function createRemoteRuntimePtyTransport( pendingClaimQueryReplyCount += 1 } } + // Why: clearing the claim flag without draining strands the queued bytes. + const flushPendingClaimInput = (stream: RemoteRuntimeMultiplexedTerminal): void => { + const queued = pendingClaimInput + pendingViewportClaim = false + pendingClaimInput = [] + pendingClaimQueryReplyCount = 0 + for (const segment of queued) { + stream.sendInput(segment.text) + } + for (const resolve of viewportClaimReadyWaiters) { + resolve(true) + } + viewportClaimReadyWaiters.clear() + } // Why: tab/leaf ids are shared by paired viewers; the instance suffix keeps one viewer's refresh off peer records. const clientId = `desktop:${tabId ?? 'tab'}:${leafId ?? 'leaf'}:${createBrowserUuid()}` const terminalCreateMutationId = createBrowserUuid() @@ -1338,8 +1352,8 @@ export function createRemoteRuntimePtyTransport( } const stream = getCurrentMultiplexedStream(targetHandle) if (claim ? stream?.claimViewport(cols, rows) : stream?.resize(cols, rows)) { - if (claim) { - pendingViewportClaim = false + if (claim && stream) { + flushPendingClaimInput(stream) } return } @@ -1953,17 +1967,6 @@ export function createRemoteRuntimePtyTransport( // Why: a viewport change during the subscribe round-trip hit the no-op one-shot fallback; replay the latest viewport so the PTY isn't stuck at subscribe-time size. if (pendingViewportClaim && desiredViewport) { nextStream.claimViewport(desiredViewport.cols, desiredViewport.rows) - pendingViewportClaim = false - const queuedInput = pendingClaimInput - pendingClaimInput = [] - pendingClaimQueryReplyCount = 0 - for (const segment of queuedInput) { - nextStream.sendInput(segment.text) - } - for (const resolve of viewportClaimReadyWaiters) { - resolve(true) - } - viewportClaimReadyWaiters.clear() } else if ( desiredViewport && (desiredViewport.cols !== subscribedViewport?.cols || @@ -1971,6 +1974,8 @@ export function createRemoteRuntimePtyTransport( ) { nextStream.resize(desiredViewport.cols, desiredViewport.rows) } + // Why: a live claim may already have cleared the flag, so drain on every install. + flushPendingClaimInput(nextStream) } const transport: PtyTransport = { diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 183838fdc2f..7b0d7cb3a8c 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -464,14 +464,18 @@ class RemoteRuntimeTerminalMultiplexer { const stream: RemoteRuntimeMultiplexedTerminal = { streamId, - sendInput: (text) => this.sendInput(state, text), + sendInput: (text) => this.isRegisteredStream(state) && this.sendInput(state, text), resize: (cols, rows) => + this.isRegisteredStream(state) && this.sendFrame( streamId, TerminalStreamOpcode.Resize, encodeTerminalStreamJson({ cols, rows }) ), claimViewport: (cols, rows) => { + if (!this.isRegisteredStream(state)) { + return false + } const claimed = this.sendFrame( streamId, TerminalStreamOpcode.ClaimViewport, @@ -1181,6 +1185,11 @@ class RemoteRuntimeTerminalMultiplexer { ) } + // Why: sendFrame gates on readiness alone; a dropped handle would still report success. + private isRegisteredStream(stream: RemoteRuntimeMultiplexedTerminalState): boolean { + return this.streams.get(stream.streamId) === stream + } + private sendInput(stream: RemoteRuntimeMultiplexedTerminalState, text: string): boolean { const sent = this.sendFrame( stream.streamId, diff --git a/src/renderer/src/runtime/remote-runtime-terminal-stale-stream-frames.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-stale-stream-frames.test.ts new file mode 100644 index 00000000000..ccde2d69c9d --- /dev/null +++ b/src/renderer/src/runtime/remote-runtime-terminal-stale-stream-frames.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import { + getRemoteRuntimeTerminalMultiplexer, + resetRemoteRuntimeTerminalMultiplexersForTests, + type RemoteRuntimeMultiplexedTerminal +} from './remote-runtime-terminal-multiplexer' +import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' + +// Why: sendFrame gates on socket readiness alone, so a dropped stream handle reported success +// while the host discarded the frames for an unknown stream id. + +type SubscribeCallbacks = { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { message: string }) => void + onClose?: () => void +} + +describe('remote terminal stale stream frames', () => { + let sent: Uint8Array[] + + beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeTerminalMultiplexersForTests() + replaceRuntimeEnvironmentRevisions([]) + sent = [] + + const subscribe = vi.fn(async (_args: unknown, callbacks: SubscribeCallbacks) => { + queueMicrotask(() => callbacks.onResponse({ ok: true, result: { type: 'ready' } })) + return { + unsubscribe: vi.fn(), + sendBinary: (bytes: Uint8Array) => { + sent.push(bytes) + } + } + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { subscribe } } }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + async function subscribeStream(terminal: string): Promise { + const stream = await getRemoteRuntimeTerminalMultiplexer('env-1').subscribeTerminal({ + terminal, + client: { id: 'desktop-1', type: 'desktop' }, + callbacks: { onData: () => {}, onSnapshot: () => {} } + }) + await Promise.resolve() + return stream + } + + function inputTextOnWire(): string { + return sent + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Input) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + } + + function opcodeCount(opcode: TerminalStreamOpcode): number { + return sent.filter((bytes) => decodeTerminalStreamFrame(bytes)?.opcode === opcode).length + } + + it('refuses input and viewport frames from a closed stream while a sibling keeps the socket live', async () => { + const parked = await subscribeStream('terminal-1') + // A sibling stream keeps the multiplexer connected, so `ready` stays true after the close. + await subscribeStream('terminal-2') + + parked.close() + sent = [] + + expect(parked.sendInput('never-delivered\r')).toBe(false) + expect(parked.claimViewport(80, 24)).toBe(false) + expect(parked.resize(80, 24)).toBe(false) + + expect(inputTextOnWire()).toBe('') + expect(opcodeCount(TerminalStreamOpcode.ClaimViewport)).toBe(0) + expect(opcodeCount(TerminalStreamOpcode.Resize)).toBe(0) + }) +}) From c60d2ba895853947da4db7d058f4b71dabc82ad9 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:32:52 -0700 Subject: [PATCH 21/65] fix(agent-resume): stop ghost resume tabs after finished turns (#16308) --- ...finished-agent-resume-resurrection.test.ts | 173 ++++++++++++++ .../src/lib/sleeping-agent-pane-ownership.ts | 9 +- .../wake-sleeping-agents-live-done.test.ts | 67 ++++++ .../agent-status-provider-session.test.ts | 118 +++++++++- .../slices/agent-status-quit-capture.test.ts | 6 +- src/renderer/src/store/slices/agent-status.ts | 29 ++- ...eted-worker-retirement-resume.unit.test.ts | 4 +- tests/e2e/finished-agent-ghost-resume.spec.ts | 217 ++++++++++++++++++ 8 files changed, 610 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/lib/finished-agent-resume-resurrection.test.ts create mode 100644 src/renderer/src/lib/wake-sleeping-agents-live-done.test.ts create mode 100644 tests/e2e/finished-agent-ghost-resume.spec.ts diff --git a/src/renderer/src/lib/finished-agent-resume-resurrection.test.ts b/src/renderer/src/lib/finished-agent-resume-resurrection.test.ts new file mode 100644 index 00000000000..d0132f53175 --- /dev/null +++ b/src/renderer/src/lib/finished-agent-resume-resurrection.test.ts @@ -0,0 +1,173 @@ +/** + * A LOCAL workspace agent that FINISHED its turn must keep its resume identity + * without being treated as unfinished work. + * + * `retainsResumableRecoveryIdentity` (store/slices/agent-status.ts) records a + * completed turn so a cold restore after an abrupt app death re-enters the agent + * instead of a bare shell (#9454). It used to do that by restating `done` as + * `state: 'working'`, which left nothing able to tell "finished" from + * "interrupted": once the pane was killed, activation opened a fresh tab running + * `--resume` for every completed agent. + * + * No paired runtime here: the host-mirror park added in #15644 gates on a web + * surface tab id, so this path never reaches it. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { useAppStore } from '@/store' +import { isPassiveCompletedHibernationEvidence } from './sleeping-agent-pane-ownership' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' + +const initialAppStoreState = useAppStore.getState() +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const WORKTREE_ID = 'wt-local' +const TAB_ID = 'tab-reviewer' +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) +const SESSION_ID = 'ses_fdc9b294effeBRR2JwiALSLpwy' + +afterEach(() => { + useAppStore.setState(initialAppStoreState, true) +}) + +/** A local codex pane with a live PTY, mid-turn. */ +function seedLiveLocalCodexPane(): void { + useAppStore.setState({ + activeWorktreeId: WORKTREE_ID, + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: TAB_ID, + ptyId: 'pty-1', + worktreeId: WORKTREE_ID, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + launchAgent: 'codex' + } + ] + }, + activeTabIdByWorktree: { [WORKTREE_ID]: TAB_ID }, + ptyIdsByTabId: { [TAB_ID]: ['pty-1'] }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } + } + } + } as never) +} + +function reportTurnFinished(interrupted = false): void { + useAppStore.getState().setAgentStatus( + PANE_KEY, + { + state: 'done', + agentType: 'codex', + prompt: 'review the diff', + ...(interrupted ? { interrupted: true } : {}) + } as never, + 'Codex', + { updatedAt: 1000, stateStartedAt: 1000 }, + { tabId: TAB_ID, worktreeId: WORKTREE_ID, terminalHandle: 'pty-1' } as never, + { providerSession: { key: 'session_id', id: SESSION_ID } } as never + ) +} + +describe('a finished local agent', () => { + it('keeps completed quit records resumable', () => { + expect( + isPassiveCompletedHibernationEvidence({ + paneKey: 'quit-tab:quit-leaf', + tabId: 'quit-tab', + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: SESSION_ID }, + prompt: '', + state: 'done', + origin: 'quit', + capturedAt: 1, + updatedAt: 1 + }) + ).toBe(false) + }) + + it('keeps its resume identity and is recorded as completed history', () => { + seedLiveLocalCodexPane() + + reportTurnFinished() + + const record = useAppStore.getState().sleepingAgentSessionsByPaneKey[PANE_KEY] + expect(record, 'a finished codex turn leaves a resume record').toBeDefined() + expect(record?.state, 'the done turn stays done').toBe('done') + expect(record?.origin).toBe('live') + // The identity a cold restore needs survives; only the turn text is dropped. + expect(record?.agent).toBe('codex') + expect(record?.providerSession).toEqual({ key: 'session_id', id: SESSION_ID }) + expect( + isPassiveCompletedHibernationEvidence(record!), + 'a finished agent must read as history, or activation restarts it' + ).toBe(true) + }) + + it('keeps an interrupted done turn resumable after its pane is killed', () => { + seedLiveLocalCodexPane() + reportTurnFinished(true) + + const record = useAppStore.getState().sleepingAgentSessionsByPaneKey[PANE_KEY] + expect(record?.state).toBe('done') + expect(record?.interrupted).toBe(true) + expect(isPassiveCompletedHibernationEvidence(record!)).toBe(false) + + useAppStore.setState({ + tabsByWorktree: { [WORKTREE_ID]: [] }, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {} + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree(WORKTREE_ID) + + expect(launched).toBe(1) + const state = useAppStore.getState() + const resumedTab = state.tabsByWorktree[WORKTREE_ID]?.[0] + expect(resumedTab?.launchAgent).toBe('codex') + expect(state.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true) + }) + + it('is not respawned into a new tab once its pane is killed', () => { + seedLiveLocalCodexPane() + reportTurnFinished() + + // `orca terminal stop` / app death: the PTY and pane go, the record stays. + useAppStore.setState({ + tabsByWorktree: { [WORKTREE_ID]: [] }, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {} + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree(WORKTREE_ID) + + expect(launched, 'a finished agent must never be respawned').toBe(0) + const state = useAppStore.getState() + expect(state.tabsByWorktree[WORKTREE_ID] ?? []).toEqual([]) + // The orphaned record is retired rather than left queued for the next visit. + expect(state.sleepingAgentSessionsByPaneKey[PANE_KEY]).toBeUndefined() + }) + + it('still holds its resume identity for a cold restore while its pane exists', () => { + seedLiveLocalCodexPane() + reportTurnFinished() + + // The pane survives (app relaunch restored the tab): #9454's crash-recovery + // case. The record must NOT be cleared, or the pane cold-restores to a shell. + const launched = resumeSleepingAgentSessionsForWorktree(WORKTREE_ID) + + expect(launched, 'an owned pane resumes in place, never in a new tab').toBe(0) + const record = useAppStore.getState().sleepingAgentSessionsByPaneKey[PANE_KEY] + expect(record, 'the pane still owns a record to cold-restore from').toBeDefined() + expect(record?.providerSession).toEqual({ key: 'session_id', id: SESSION_ID }) + }) +}) diff --git a/src/renderer/src/lib/sleeping-agent-pane-ownership.ts b/src/renderer/src/lib/sleeping-agent-pane-ownership.ts index 4ebed782f56..95a6c988b31 100644 --- a/src/renderer/src/lib/sleeping-agent-pane-ownership.ts +++ b/src/renderer/src/lib/sleeping-agent-pane-ownership.ts @@ -17,8 +17,15 @@ export function getProviderSessionClaimKey(record: SleepingAgentSessionRecord): : base } +// Why quit is excluded: it is an explicit request to keep resumable work. A +// live interrupted checkpoint is also active work; interrupted worktree-sleep +// records retain their existing passive/cleanup semantics. export function isPassiveCompletedHibernationEvidence(record: SleepingAgentSessionRecord): boolean { - return record.origin !== 'quit' && record.origin !== 'live' && record.state === 'done' + return ( + record.origin !== 'quit' && + !(record.origin === 'live' && record.interrupted === true) && + record.state === 'done' + ) } function getLegacyPaneTabId(record: SleepingAgentSessionRecord): string | null { diff --git a/src/renderer/src/lib/wake-sleeping-agents-live-done.test.ts b/src/renderer/src/lib/wake-sleeping-agents-live-done.test.ts new file mode 100644 index 00000000000..8479bb52d30 --- /dev/null +++ b/src/renderer/src/lib/wake-sleeping-agents-live-done.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, + type BackgroundMountTerminalWorktreeDetail +} from '@/constants/terminal' + +const { resumeSpy, clearSleepingAgentSessionsByPaneKey } = vi.hoisted(() => ({ + resumeSpy: vi.fn(() => 0), + clearSleepingAgentSessionsByPaneKey: vi.fn() +})) +vi.mock('./resume-sleeping-agent-session', () => ({ + resumeSleepingAgentSessionsForWorktree: resumeSpy +})) + +let sleepingRecords: Record> = {} +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + sleepingAgentSessionsByPaneKey: sleepingRecords, + tabsByWorktree: {}, + clearSleepingAgentSessionsByPaneKey + }) + } +})) + +import { wakeSleepingAgentsForWorktreeInBackground } from './wake-sleeping-agents-in-background' + +afterEach(() => { + sleepingRecords = {} + clearSleepingAgentSessionsByPaneKey.mockClear() + resumeSpy.mockClear() +}) + +describe('background wake of a finished live checkpoint', () => { + it('mounts its saved tab as passive history without launching a resume tab', () => { + sleepingRecords = { + 'tab-done:leaf-1': { + paneKey: 'tab-done:leaf-1', + tabId: 'tab-done', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'finished-session' }, + state: 'done', + origin: 'live', + capturedAt: 1, + updatedAt: 1 + } + } + const mounted: BackgroundMountTerminalWorktreeDetail[] = [] + const onMount = (event: Event): void => { + mounted.push((event as CustomEvent).detail) + } + window.addEventListener(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onMount) + + try { + wakeSleepingAgentsForWorktreeInBackground('wt-1') + } finally { + window.removeEventListener(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onMount) + } + + expect(mounted).toEqual([{ worktreeId: 'wt-1', tabIds: ['tab-done'] }]) + expect(resumeSpy).toHaveBeenCalledOnce() + expect(resumeSpy.mock.results[0]?.value).toBe(0) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status-provider-session.test.ts b/src/renderer/src/store/slices/agent-status-provider-session.test.ts index b4a9e14842b..2ad173eac6d 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session.test.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session.test.ts @@ -371,7 +371,7 @@ describe('recordAgentProviderSession', () => { agent, providerSession, connectionId: 'ssh-connection-1', - state: 'working', + state: 'done', origin: 'live' }) @@ -395,6 +395,120 @@ describe('recordAgentProviderSession', () => { } ) + it('does not turn a completed recovery record back into working on a same-session update', () => { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + } + } as Partial) + const providerSession = makePiCompatibleProviderSession('pi') + + store + .getState() + .recordAgentProviderSession( + 'tab-1:leaf-1', + 'pi', + providerSession, + { updatedAt: 10 }, + { tabId: 'tab-1', worktreeId: 'wt-1', connectionId: 'ssh-connection-1' } + ) + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'working', prompt: 'finish the task', agentType: 'pi' }, + 'Pi', + { updatedAt: 20, stateStartedAt: 20 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'done', prompt: 'finish the task', agentType: 'pi', interrupted: true }, + 'Pi', + { updatedAt: 30, stateStartedAt: 30 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) + + store + .getState() + .recordAgentProviderSession( + 'tab-1:leaf-1', + 'pi', + providerSession, + { updatedAt: 40 }, + { tabId: 'tab-1', worktreeId: 'wt-1', connectionId: 'ssh-connection-1' } + ) + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({ + providerSession, + state: 'done', + interrupted: true, + origin: 'live' + }) + }) + + it('does not downgrade a quit recovery record on a same-session update', () => { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + } + } as Partial) + const providerSession = makePiCompatibleProviderSession('pi') + + store + .getState() + .recordAgentProviderSession( + 'tab-1:leaf-1', + 'pi', + providerSession, + { updatedAt: 10 }, + { tabId: 'tab-1', worktreeId: 'wt-1', connectionId: 'ssh-connection-1' } + ) + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'working', prompt: 'finish the task', agentType: 'pi' }, + 'Pi', + { updatedAt: 20, stateStartedAt: 20 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'done', prompt: 'finish the task', agentType: 'pi' }, + 'Pi', + { updatedAt: 30, stateStartedAt: 30 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) + store.getState().captureAllSleepingAgentSessions('quit') + + store + .getState() + .recordAgentProviderSession( + 'tab-1:leaf-1', + 'pi', + providerSession, + { updatedAt: 40 }, + { tabId: 'tab-1', worktreeId: 'wt-1', connectionId: 'ssh-connection-1' } + ) + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({ + providerSession, + state: 'done', + origin: 'quit' + }) + }) + it.each(PI_COMPATIBLE_CASES)( 'keeps a completed $label session resumable through quit capture', ({ agent, label }) => { @@ -450,7 +564,7 @@ describe('recordAgentProviderSession', () => { agent, providerSession, connectionId: 'ssh-connection-1', - state: 'working', + state: 'done', origin: 'quit' }) diff --git a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts index 061daea2f21..33e43e6dff2 100644 --- a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts +++ b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts @@ -687,8 +687,8 @@ describe('captureAllSleepingAgentSessions', () => { }) // Why: a finished resumable-agent turn leaves the TUI alive at its prompt, so the persisted - // recovery anchor must survive `done` (state remapped to 'working' to stay cold-restore - // eligible) — else logout→relaunch cold-restores to a bare shell instead of `--resume` (#9454). + // recovery anchor must survive `done` without relabeling completed work as pending — else + // logout→relaunch cold-restores to a bare shell instead of `--resume` (#9454). // Covers claude (the reported agent) and codex; previously this was Pi-only. it.each([ ['claude', 'Claude'], @@ -722,7 +722,7 @@ describe('captureAllSleepingAgentSessions', () => { agent: agentType, providerSession, origin: 'live', - state: 'working' + state: 'done' }) // Launch config is still cleared on done: tokens must no longer authorize config reuse. expect(store.getState().agentLaunchConfigByPaneKey['tab-1:leaf-1']).toBeUndefined() diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 96476583f77..8dbd29172e9 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -611,6 +611,7 @@ function sleepingRecordFromEntry(args: { worktreeId: args.worktreeId, agent, providerSession: args.entry.providerSession, + ...(args.entry.connectionId !== undefined ? { connectionId: args.entry.connectionId } : {}), prompt: args.entry.prompt, state: args.entry.state, capturedAt: args.capturedAt, @@ -897,11 +898,14 @@ function recoveryRecordMatches( if (!existing) { return false } + // Why: completion or interruption must replace a pre-status working checkpoint. return ( existing.origin === next.origin && existing.agent === next.agent && existing.worktreeId === next.worktreeId && existing.tabId === next.tabId && + existing.state === next.state && + existing.interrupted === next.interrupted && agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) && launchConfigsEqual(existing.launchConfig, next.launchConfig) ) @@ -2017,6 +2021,13 @@ export const createAgentStatusSlice: StateCreator { expect(useAppStore.getState().ptyIdsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toMatchObject({ origin: 'live', - state: 'working', + state: 'done', providerSession: { key: 'session_id', id: PROVIDER_SESSION_ID } }) await releaseCompletedWorker('exited') diff --git a/tests/e2e/finished-agent-ghost-resume.spec.ts b/tests/e2e/finished-agent-ghost-resume.spec.ts new file mode 100644 index 00000000000..5d135161f08 --- /dev/null +++ b/tests/e2e/finished-agent-ghost-resume.spec.ts @@ -0,0 +1,217 @@ +/** + * A LOCAL agent that FINISHED its turn must not be respawned when the app + * reopens the workspace. + * + * A completed turn is persisted with its state rewritten to 'working' and + * origin 'live' (store/slices/agent-status.ts, `retainsResumableRecoveryIdentity`) + * so an abrupt app death cold-restores into the agent instead of a bare shell + * (#9454). Nothing downstream can then tell "finished" from "interrupted": once + * the pane is gone, worktree activation reads the record as unfinished work and + * opens a fresh tab running `--resume`. Killing the PTY removes the pane but + * never the record, so `orca terminal stop`, a crash, or a pty-exit tab close + * all leave one queued respawn per finished agent. + * + * Run: + * pnpm exec playwright test tests/e2e/finished-agent-ghost-resume.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless --workers=1 + */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { + execInTerminal, + waitForActivePaneHookDescriptor, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { createHostRendererTerminalTab } from './helpers/host-created-terminal-retention-oracle' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' + +const PROVIDER_SESSION_ID = 'e2e-finished-agent-session' + +type PersistedRecord = { + state?: unknown + origin?: unknown + providerSession?: { id?: unknown } + launchConfig?: { agentCommand?: string; agentArgs?: string; agentEnv?: Record } +} + +function readPersistedRecords(userDataDir: string): Record { + const dataPath = path.join( + userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID, + 'orca-data.json' + ) + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { + workspaceSession?: { sleepingAgentSessionsByPaneKey?: Record } + } + return data.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {} +} + +/** Make the resume hermetic: the respawned tab echoes instead of running codex. */ +function stubPersistedResumeCommand(userDataDir: string): PersistedRecord { + const dataPath = path.join( + userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID, + 'orca-data.json' + ) + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { + workspaceSession?: { sleepingAgentSessionsByPaneKey?: Record } + } + const record = Object.values(data.workspaceSession?.sleepingAgentSessionsByPaneKey ?? {}).find( + (candidate) => candidate.providerSession?.id === PROVIDER_SESSION_ID + ) + if (!record) { + throw new Error('Expected the finished agent turn to leave a persisted record') + } + record.launchConfig = { agentCommand: 'echo', agentArgs: '', agentEnv: {} } + writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8') + return record +} + +test.describe.configure({ mode: 'serial' }) + +test('does not respawn an agent whose turn already finished', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() + if (!repoPath || !existsSync(repoPath)) { + test.skip(true, 'Global setup did not produce a seeded test repo') + return + } + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const firstLaunch = await session.launch() + firstApp = firstLaunch.app + const page = await firstApp.firstWindow() + const worktreeId = await attachRepoAndOpenTerminal(page, repoPath) + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) + + const marker = `FINISHED_AGENT_${Date.now()}` + const descriptor = await waitForActivePaneHookDescriptor(page) + const ptyId = await waitForActivePanePtyId(page) + const transcriptPath = session.seedCodexResumeRollout(PROVIDER_SESSION_ID, repoPath) + await execInTerminal(page, ptyId, `echo ${marker}`) + await waitForTerminalOutput(page, marker) + + // The agent reports its turn FINISHED — the ordinary end of an agent run. + await page.evaluate( + ({ paneKey, worktreeId: wtId, providerSessionId, transcriptPath }) => { + window.__store + ?.getState() + .setAgentStatus( + paneKey, + { state: 'done', prompt: 'review the diff', agentType: 'codex' }, + 'Codex', + undefined, + { worktreeId: wtId }, + { providerSession: { key: 'session_id', id: providerSessionId, transcriptPath } } + ) + }, + { + paneKey: descriptor.paneKey, + worktreeId: descriptor.worktreeId, + providerSessionId: PROVIDER_SESSION_ID, + transcriptPath + } + ) + + // The finished turn keeps its resume identity without restating done as work. + const liveRecord = await page.evaluate((paneKey) => { + const record = window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] + return record ? { state: record.state, origin: record.origin } : null + }, descriptor.paneKey) + expect(liveRecord, 'a finished turn leaves a resume record').not.toBeNull() + expect(liveRecord?.state, 'the done turn stays done').toBe('done') + expect(liveRecord?.origin).toBe('live') + + // A second, ordinary terminal: the reported workspaces were never empty, and + // an empty one does not reactivate on relaunch at all. + const survivingTabId = await createHostRendererTerminalTab(page, worktreeId) + + // The PTY dies and its tab closes with it — `orca terminal stop`, a crash, + // or the pty-exit auto close. This reason deliberately keeps the record. + const tabId = await page.evaluate( + (wtId) => (window.__store?.getState().tabsByWorktree[wtId] ?? [])[0]?.id ?? null, + worktreeId + ) + expect(tabId, 'the agent pane must have a tab to close').not.toBeNull() + expect(tabId).not.toBe(survivingTabId) + await page.evaluate( + (id) => window.__store?.getState().closeTab(id, { reason: 'pty-exit' }), + tabId! + ) + await expect + .poll( + async () => + page.evaluate( + (wtId) => (window.__store?.getState().tabsByWorktree[wtId] ?? []).map((tab) => tab.id), + worktreeId + ), + { timeout: 15_000, message: 'the pty-exit close never removed the agent tab' } + ) + .toEqual([survivingTabId]) + + await session.close(firstApp) + firstApp = null + + // The record outlived the pane it belonged to. + const persisted = readPersistedRecords(session.userDataDir) + const survivor = Object.values(persisted).find( + (candidate) => candidate.providerSession?.id === PROVIDER_SESSION_ID + ) + expect(survivor, 'killing the pane left the resume record behind').toBeDefined() + stubPersistedResumeCommand(session.userDataDir) + + // Reopening the workspace. + const secondLaunch = await session.launch() + secondApp = secondLaunch.app + await waitForSessionReady(secondLaunch.page) + await expect + .poll( + async () => secondLaunch.page.evaluate(() => window.__store?.getState().activeWorktreeId), + { timeout: 15_000 } + ) + .toBe(worktreeId) + await ensureTerminalVisible(secondLaunch.page) + + // THE CLAIM: no tab was opened to resume an agent that had already finished. + const respawned = await secondLaunch.page.evaluate((wtId) => { + const state = window.__store?.getState() + const tabs = state?.tabsByWorktree[wtId] ?? [] + return tabs.map((tab) => ({ + id: tab.id, + launchAgent: tab.launchAgent ?? null, + startup: state?.pendingStartupByTabId[tab.id]?.command ?? null, + banner: state?.pendingStartupByTabId[tab.id]?.showSessionRestoredBanner ?? false + })) + }, worktreeId) + const resumeTabs = respawned.filter( + (tab) => tab.launchAgent === 'codex' || tab.startup?.includes(PROVIDER_SESSION_ID) + ) + expect(resumeTabs, `a finished agent was respawned: ${JSON.stringify(respawned)}`).toEqual([]) + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +}) From 0bfd3808f6b3d85c01bf4a752f473f66668fb50f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:33:35 -0700 Subject: [PATCH 22/65] test(pty): pin the renderer-liveness guard STA-2373 relies on (STA-5373) (#16345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(pty): pin the renderer-liveness guard STA-2373 relies on (STA-5373) STA-5373 reported that #15927 dropped the `webContents.isDestroyed()` half of the guard #10065 added for STA-2373, and that the app therefore dies when a daemon death fans out to every pane. The guard is present on main: #15172 restored it at both senders when it split the PTY monolith, and a per-file count across v1.4.188..HEAD shows only relocation (pty.ts:4 -> write-input.ts:2 + bind-listeners.ts:2). The reported 4 -> 0 was scoped to `src/main/ipc/pty.ts`, now a 39-line barrel. No production fix is needed. What was real is that nothing pinned the guard — it survived #15927 only because #15172 happened to re-expand it — and the shared PTY test fake made that invisible: its `webContents` had no `isDestroyed` at all, so both guards passed vacuously in every suite. That is also why they are written defensively as `typeof ... === 'function' && ...`. - Give the fake an `isDestroyed` mock, re-stubbed to `false` each beforeEach rather than left `undefined`, so "alive" is stated rather than accidental. - Cover both senders red/green: the daemon-death fan-out (bind-listeners.ts:37) and the per-write reporter (write-input.ts:57). Verified red against a window-only guard and green with the real one. The per-write case needs a chunked write. A single-chunk write is already fenced by `isPtyWriteEventFromMainWindow`, which rejects the event once the WebContents is gone; only the multi-chunk path yields a macrotask mid-write, letting the renderer die after the sender check passes. That is the sole route to this sender with a dead WebContents, and what makes its own guard load-bearing. Also deletes `src/main/ipc/pty-renderer-surface.ts`: zero importers repo-wide, and its `isRendererGone` is exactly the weakened predicate. Its comment claims the headless path "now passes null", but register-headless-runtime.ts:26 still fakes `{ isDestroyed: () => true }` — #15172 rolled that back too. Adopting it would reintroduce STA-5373 for real. Verified: 17 tests across the two pty write suites; full src/main/ipc run 3156 pass (4 pre-existing @parcel/watcher failures in filesystem-watcher-real and worktree-base-directory-poller, unrelated and failing identically on a pristine tree); pnpm typecheck clean; oxlint clean. * docs(pty): correct headless-path comments the #15172 rebase left stale #15927 made `registerPtyHandlers` accept `BrowserWindow | null` so the headless path could pass null instead of faking a window. #15172 reverted that signature while splitting the PTY monolith, but the comments describing it survived, so three of them now document an API that does not exist. - orcad-entry.ts: the module docstring claimed orcad installs its controller via `registerPtyHandlers(null, …)`. It uses `registerHeadlessPtyRuntime`, and null is not accepted. It also claimed desktop surfaces are "declared rather than faked" — true except for the renderer window, which is still faked. - register-headless-runtime.ts: record why the fake is safe rather than leaving `isDestroyed: () => true` looking arbitrary. It is load-bearing: every renderer-liveness guard reads it and skips, so no send is attempted. Also gives the fake a `webContents.isDestroyed`. The real guards check both, and a missing method reads as "alive" — the same gap this PR fixes in the test fake. No behavior change: the window-level check already short-circuits. Verified: 45 tests across the liveness-guard, startup-barrier, management and kill/exit suites; pnpm typecheck clean; oxlint clean. --- src/main/ipc/pty-ipc-suite-environment.ts | 24 ++- .../ipc/pty-renderer-liveness-guard.test.ts | 167 ++++++++++++++++++ src/main/ipc/pty-renderer-surface.ts | 37 ---- src/main/ipc/pty/register-headless-runtime.ts | 4 + src/main/orcad/orcad-entry.ts | 10 +- 5 files changed, 198 insertions(+), 44 deletions(-) create mode 100644 src/main/ipc/pty-renderer-liveness-guard.test.ts delete mode 100644 src/main/ipc/pty-renderer-surface.ts diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts index 5e5a9c819f7..95c7dfe3eea 100644 --- a/src/main/ipc/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty-ipc-suite-environment.ts @@ -54,7 +54,15 @@ import { __resetShellStartupEnvCache } from '../pty/shell-startup-env' import { _resetWslCachesForTests } from '../wsl' /** The mocked webContents each suite asserts sends against. */ -export type PtyIpcTestWebContents = { on: Mock; send: Mock; removeListener: Mock } +export type PtyIpcTestWebContents = { + on: Mock + send: Mock + removeListener: Mock + // Why real Electron has this: webContents can be destroyed a beat before its BrowserWindow + // during close, so renderer-liveness guards check both. Omitting it here let those guards + // pass vacuously in every suite (STA-2373 / STA-5373). + isDestroyed: Mock +} /** The mocked BrowserWindow handed to registerPtyHandlers. */ export type PtyIpcTestMainWindow = { @@ -83,12 +91,18 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { webContents: { on: vi.fn(), send: vi.fn(), - removeListener: vi.fn() + removeListener: vi.fn(), + isDestroyed: vi.fn(() => false) } } const mainWindowIpcEvent = { sender: mainWindow.webContents } const foreignWindowIpcEvent = { - sender: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() } + sender: { + on: vi.fn(), + send: vi.fn(), + removeListener: vi.fn(), + isDestroyed: vi.fn(() => false) + } } const envScope = createPtyIpcProcessEnvScope() @@ -155,6 +169,10 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() mainWindow.webContents.removeListener.mockReset() + // Why re-stub, not just reset: a bare mockReset returns undefined, which reads as "alive" + // by accident rather than by intent, and hides a test that forgot to restore liveness. + mainWindow.webContents.isDestroyed.mockReset() + mainWindow.webContents.isDestroyed.mockReturnValue(false) // Why: hidden-delivery gate state is module-level (PTY-keyed), so tests must not leak hidden bits across cases. _resetHiddenRendererPtyDeliveryGateForTest() __resetShellStartupEnvCache() diff --git a/src/main/ipc/pty-renderer-liveness-guard.test.ts b/src/main/ipc/pty-renderer-liveness-guard.test.ts new file mode 100644 index 00000000000..08a925067d8 --- /dev/null +++ b/src/main/ipc/pty-renderer-liveness-guard.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' +import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../shared/terminal-input' +import { registerPtyHandlers } from './pty' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +/** + * A destroyed WebContents under a still-alive BrowserWindow is the STA-2373 state: + * `webContents` can die a beat before its window during close (see createMainWindow.ts + * forceRepaint), and `webContents.send` throws `Object has been destroyed` there. An + * uncaught throw in the main process is fatal — the app exits with every terminal in it, + * clean exit code and no crash report — so both `pty:writeUnavailable` senders must check + * the WebContents, not just the window. #15927 briefly reduced these to a window-only + * check; these tests fail against that regression (STA-5373). + */ +describe('pty:writeUnavailable renderer-liveness guard', () => { + const { + handlers, + mainWindow, + mainWindowIpcEvent, + installDaemonTestProvider, + getPtyWriteListener + } = setupPtyIpcSuite() + + async function spawnDaemonPty(): Promise { + const result = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { + id: string + } + mainWindow.webContents.send.mockClear() + return result.id + } + + describe('the per-write sender', () => { + it('asks the renderer to remount while the WebContents is alive', async () => { + installDaemonTestProvider({ + write: vi.fn(() => { + throw new PtyWriteUnavailableError('daemon generation lost') + }) + }) + registerPtyHandlers(mainWindow as never) + const id = await spawnDaemonPty() + + getPtyWriteListener()(mainWindowIpcEvent, { id, data: 'x' }) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:writeUnavailable', { id }) + }) + + // Why a chunked write: the single-chunk path is already fenced by + // isPtyWriteEventFromMainWindow, which rejects the event outright once the WebContents + // is gone. A multi-chunk write awaits a macrotask between chunks, so the sender check + // passes while alive and the renderer dies mid-write — the only way this sender is + // reached with a destroyed WebContents, and what makes its own guard load-bearing. + it('stays silent when the WebContents dies mid-write under a live window', async () => { + let writeCount = 0 + installDaemonTestProvider({ + write: vi.fn(() => { + writeCount += 1 + if (writeCount < 2) { + return + } + mainWindow.webContents.isDestroyed.mockReturnValue(true) + throw new PtyWriteUnavailableError('daemon generation lost') + }) + }) + registerPtyHandlers(mainWindow as never) + const id = await spawnDaemonPty() + + getPtyWriteListener()(mainWindowIpcEvent, { + id, + data: 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 3) + }) + // The chunk loop yields a macrotask between chunks; wait for the failing one. + await vi.waitFor(() => expect(writeCount).toBeGreaterThan(1)) + + expect(mainWindow.webContents.isDestroyed).toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:writeUnavailable', + expect.anything() + ) + }) + }) + + describe('the daemon-death fan-out', () => { + /** Registers handlers and returns the fan-out callback the provider hands the session. */ + function installProviderCapturingFanout(): (payload: { id: string }) => void { + let fanout: ((payload: { id: string }) => void) | null = null + installDaemonTestProvider({ + onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => { + fanout = callback + return () => {} + }) + }) + registerPtyHandlers(mainWindow as never) + if (!fanout) { + throw new Error('registerPtyHandlers did not subscribe to onWriteUnavailable') + } + return fanout + } + + it('remounts every signalled pane while the WebContents is alive', () => { + const fanout = installProviderCapturingFanout() + + fanout({ id: 'pane-1' }) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:writeUnavailable', { + id: 'pane-1' + }) + }) + + it('stays silent when the WebContents is destroyed under a live window', () => { + const fanout = installProviderCapturingFanout() + mainWindow.webContents.isDestroyed.mockReturnValue(true) + + fanout({ id: 'pane-1' }) + + expect(mainWindow.webContents.isDestroyed).toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:writeUnavailable', + expect.anything() + ) + }) + }) +}) diff --git a/src/main/ipc/pty-renderer-surface.ts b/src/main/ipc/pty-renderer-surface.ts deleted file mode 100644 index 09fff75e681..00000000000 --- a/src/main/ipc/pty-renderer-surface.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { BrowserWindow, WebContents } from 'electron' - -/** - * The renderer surface the PTY handlers talk to, which may not exist. - * - * Why: `orca serve` — and a future Node-only backend — run the same PTY handlers - * with no window. That used to be faked: `registerHeadlessPtyRuntime` built a - * `BrowserWindow` whose `isDestroyed()` returned true and whose `webContents.send` - * was a no-op, purely to satisfy the type — the "looks fine, silently lies" shape - * this codebase rejects elsewhere, and what forced an `electron` value import into a - * path that needs none. It now passes `null`. - * - * An absent renderer is semantically identical to a destroyed one — every call site - * already guards on `isDestroyed()` and skips — so model it as `null` and say so. - */ - -/** True when there is no renderer, or it is gone. Callers already treat these the same. */ -export function isRendererGone(window: BrowserWindow | null): boolean { - return window === null || window.isDestroyed() -} - -/** Send to the renderer if one is listening. Absent renderer drops the message, as a destroyed one does. */ -export function sendToRenderer( - window: BrowserWindow | null, - channel: string, - payload?: unknown -): void { - if (isRendererGone(window)) { - return - } - window!.webContents.send(channel, payload) -} - -/** The renderer's WebContents, or null. Used for identity checks and listener registration. */ -export function rendererWebContents(window: BrowserWindow | null): WebContents | null { - return isRendererGone(window) ? null : window!.webContents -} diff --git a/src/main/ipc/pty/register-headless-runtime.ts b/src/main/ipc/pty/register-headless-runtime.ts index d1a10871178..f380482a079 100644 --- a/src/main/ipc/pty/register-headless-runtime.ts +++ b/src/main/ipc/pty/register-headless-runtime.ts @@ -23,9 +23,13 @@ export function registerHeadlessPtyRuntime( } ): void { // Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals. + // Why a fake rather than null: `registerPtyHandlers` takes a non-null BrowserWindow. `isDestroyed: () => true` + // is what makes that safe — every renderer-liveness guard reads it and skips, so no send is ever attempted. + // Keep `webContents.isDestroyed` in step with it: guards check both, and a missing method reads as "alive". const headlessWindow = { isDestroyed: () => true, webContents: { + isDestroyed: () => true, send: () => {}, on: () => {}, removeListener: () => {} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index bab68282e53..94a7f4267da 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -2,12 +2,14 @@ * `orcad` — the Orca runtime served from plain Node, with no Electron. * * Installs the Node host adapters, constructs the same `OrcaRuntimeService` the - * desktop uses, installs a PTY controller via `registerPtyHandlers(null, …)`, and + * desktop uses, installs a PTY controller via `registerHeadlessPtyRuntime`, and * serves runtime RPC. See docs/design/node-only-runtime-backend.html. * * The desktop-only surfaces are deliberately left uninstalled: no notifications, no - * renderer window, no browser panes. Those are declared rather than faked — see - * `runtime-desktop-surface.ts` and `pty-host-bindings.ts`. + * renderer window, no browser panes. Most are declared rather than faked — see + * `runtime-desktop-surface.ts` and `pty-host-bindings.ts`. The renderer window is the + * exception: `registerPtyHandlers` takes a non-null `BrowserWindow`, so the headless + * path still fakes one that reports itself destroyed. */ import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' @@ -144,7 +146,7 @@ export async function startOrcad(options: OrcadOptions = {}): Promise 'blocked' }) - // Why the headless entry point rather than registerPtyHandlers(null, …): this is the + // Why the headless entry point rather than registerPtyHandlers directly: this is the // same call `--serve` makes, and it threads the store through. Without the store the // handlers install fine and every terminal.create then fails at persistence time. // From 5c116b6ec2798eaab907898a5d19bebab8947508 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:45:47 -0700 Subject: [PATCH 23/65] fix(startup): stop the PATH seed pinning nvm to its newest install (#16314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(startup): stop the PATH seed pinning nvm to its newest install patchPackagedProcessPath prepends the newest nvm version dir to process.env.PATH, then hydrateShellPath probes the login shell with that same env. nvm's startup `use` honors whatever node is already on PATH instead of the user's `default` alias, so the probe returns a PATH pinned to the newest install and every terminal pane inherits it. A user whose newest nvm node is a bare install then loses every global CLI (codex, claude, gemini, vercel...) inside Orca while they still resolve in Ghostty/Terminal, which start from the bare GUI PATH and fall through to `default`. Probe with the PATH the process launched with. Windows already gets this through WindowsShellPathOwnership. * test(startup): pin the platform in the probe-env test shellProbeEnv short-circuits on win32, so the POSIX-only assertion failed for anyone running the suite on Windows. Matches the convention in hydrate-shell-path.windows.test.ts. Also narrows the win32 exemption comment: WindowsShellPathOwnership snapshots its baseline after the seeds land, so it does not unwind them. The exemption holds because Windows keys PATH as `Path` and no Windows seed pins a node version. * fix(startup): give win32 the same probe insulation, keyed by Path The previous commit exempted win32 on the stated grounds that no Windows seed pins a node version. That is wrong: getVersionManagerDirectories calls getNvmVersionDirectories on every platform, so a Git Bash user whose nvm uses the POSIX ~/.nvm/versions/node layout gets the newest version dir seeded on Windows too, and the -ilc Git Bash probe inherits it. Record the PATH key alongside the value and overwrite that entry in place, so Windows never carries both `Path` and `PATH` — which was the only real reason to skip win32. * fix(startup): snapshot the launch PATH at module load, log probe failures Three loose ends from the review, folded in rather than deferred. The probe's clean PATH was handed over by an explicit recordLaunchPath call from the seeding site, so the invariant lived across three files and a refactor that moved the seed call would silently re-pin nvm. Snapshot PATH during module init instead: that runs while the import graph is evaluated, strictly before any statement in main's body, so it cannot observe the seeds and there is no call ordering left to break. All seven importers are static, so no lazy import can defeat it. A test asserts the probe ignores later process.env mutation, and fails if the live read is reintroduced. A failed startup probe leaves the seeded newest-nvm dir in front and said nothing, so the population whose rc files blow the 5s budget hit the original symptom with no diagnosable trace. Log the failureReason. The probe is an interactive login shell, so rc files that exec into a multiplexer or start a heavy prompt can outrun that budget with no way to opt out. Set ORCA_SHELL_PATH_PROBE=1 so they can take a fast path. * fix(startup): drop the other-cased PATH key from the Windows probe env Caught by running the suite on a real Windows machine, not a mocked platform. The spread of process.env is a plain, case-sensitive object, while Windows resolves env names case-insensitively. Writing the captured `Path` back onto it left the seeded value still live under `PATH`, so the probe shell could read either one — the exact duplicate-key hazard the win32 branch was supposed to prevent. Drop any other-cased variant of the key before writing. * fix(startup): preserve launch PATH across app restarts --- src/main/app-relaunch.test.ts | 28 +++++++- src/main/app-relaunch.ts | 3 +- src/main/index.ts | 7 ++ src/main/startup/hydrate-shell-path.test.ts | 53 +++++++++++++++ src/main/startup/hydrate-shell-path.ts | 74 ++++++++++++++++++++- src/main/updater.quit-and-install.test.ts | 26 +++++++- src/main/updater.ts | 5 +- 7 files changed, 191 insertions(+), 5 deletions(-) diff --git a/src/main/app-relaunch.test.ts b/src/main/app-relaunch.test.ts index 26c98c98bb3..4ba8c6ce5a0 100644 --- a/src/main/app-relaunch.test.ts +++ b/src/main/app-relaunch.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { appRelaunchMock, recordDurableCrashBreadcrumbMock } = vi.hoisted(() => ({ appRelaunchMock: vi.fn(), @@ -11,12 +11,24 @@ vi.mock('./crash-reporting/durable-crash-breadcrumb', () => ({ })) import { relaunchApp } from './app-relaunch' +import { _resetHydrateShellPathCache, _setLaunchPathForTests } from './startup/hydrate-shell-path' beforeEach(() => { appRelaunchMock.mockReset() recordDurableCrashBreadcrumbMock.mockReset() }) +const originalPath = process.env.PATH + +afterEach(() => { + _resetHydrateShellPathCache() + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } +}) + describe('relaunchApp', () => { it('durably records the reason before scheduling the replacement process', () => { relaunchApp('gpu-fallback', { processReason: 'crashed', exitCode: 5 }) @@ -32,4 +44,18 @@ describe('relaunchApp', () => { appRelaunchMock.mock.invocationCallOrder[0] ) }) + + it('does not carry Orca PATH seeds into the replacement process', () => { + process.env.PATH = '/seeded/newest-nvm/bin:/usr/bin' + _setLaunchPathForTests('/usr/bin') + let inheritedPath: string | undefined + appRelaunchMock.mockImplementation(() => { + inheritedPath = process.env.PATH + }) + + relaunchApp('renderer-request') + + expect(inheritedPath).toBe('/usr/bin') + expect(process.env.PATH).toBe('/seeded/newest-nvm/bin:/usr/bin') + }) }) diff --git a/src/main/app-relaunch.ts b/src/main/app-relaunch.ts index ab7a725c847..ce96af1e96f 100644 --- a/src/main/app-relaunch.ts +++ b/src/main/app-relaunch.ts @@ -1,6 +1,7 @@ import { app } from 'electron' import type { CrashReportBreadcrumbData } from '../shared/crash-reporting' import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb' +import { runWithLaunchPath } from './startup/hydrate-shell-path' export type AppRelaunchReason = | 'admin-restart' @@ -13,5 +14,5 @@ export function relaunchApp(reason: AppRelaunchReason, data?: CrashReportBreadcr // Why: the current process can exit immediately after app.relaunch(), so // persist the cause before Electron schedules the replacement process. recordDurableCrashBreadcrumb('app_relaunch_requested', { ...data, reason }) - app.relaunch() + runWithLaunchPath(() => app.relaunch()) } diff --git a/src/main/index.ts b/src/main/index.ts index 803ff36fbbc..0a47efb9335 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -699,7 +699,14 @@ if (app.isPackaged && process.platform !== 'win32') { void hydrateShellPath().then((result) => { if (result.ok) { mergePathSegments(result.segments) + return } + // Why: on failure the seeded fallbacks stay in front, which for an nvm user means + // the newest install rather than their `default`. Name the reason so that shows up + // in a log bundle instead of as an unexplained missing CLI. + console.warn( + `[shell-path] login-shell probe failed (${result.failureReason}); using seeded PATH` + ) }) } configureDevUserDataPath(is.dev) diff --git a/src/main/startup/hydrate-shell-path.test.ts b/src/main/startup/hydrate-shell-path.test.ts index f096dd03ed3..60c8dc0bd17 100644 --- a/src/main/startup/hydrate-shell-path.test.ts +++ b/src/main/startup/hydrate-shell-path.test.ts @@ -6,6 +6,7 @@ import { _resetHydrateShellPathCache, hydrateShellPath, mergePathSegments, + _setLaunchPathForTests, type HydrationResult } from './hydrate-shell-path' @@ -39,6 +40,7 @@ describe('hydrateShellPath', () => { }) afterEach(() => { + vi.restoreAllMocks() if (originalPath === undefined) { delete process.env.PATH } else { @@ -177,6 +179,57 @@ describe('hydrateShellPath', () => { vi.useRealTimers() } }) + + async function captureProbeEnv(shell = '/bin/bash'): Promise { + const proc = createMockShellProcess() + spawnMock.mockReturnValue(proc) + const resultPromise = hydrateShellPath({ shellOverride: shell, force: true }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + proc.emit('close', 0) + await resultPromise + return spawnMock.mock.calls[0][2].env + } + + it('probes with the launch PATH so seeded fallbacks cannot pin nvm', async () => { + process.env.PATH = `/home/u/.nvm/versions/node/v26.7.0/bin${delimiter}/usr/bin` + _setLaunchPathForTests('/usr/bin') + + expect((await captureProbeEnv()).PATH).toBe('/usr/bin') + }) + + it('uses the PATH captured at module load, not a later mutation of process.env', async () => { + // Why: this is the anti-regression guard. patchPackagedProcessPath mutates + // process.env.PATH after this module is evaluated; the probe must not see it. + const seeded = `/seeded/newest-nvm/bin${delimiter}/usr/bin` + process.env.PATH = seeded + + expect((await captureProbeEnv()).PATH).not.toBe(seeded) + }) + + it('sets a probe marker so rc files can take a fast path', async () => { + expect((await captureProbeEnv()).ORCA_SHELL_PATH_PROBE).toBe('1') + }) + + it('overwrites the captured key in place so Windows never carries both Path and PATH', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const originalWindowsPath = process.env.Path + delete process.env.PATH + process.env.PATH = 'C:\\Users\\u\\.nvm\\versions\\node\\v22.9.0\\bin;C:\\Windows' + process.env.Path = 'C:\\Users\\u\\.nvm\\versions\\node\\v22.9.0\\bin;C:\\Windows' + _setLaunchPathForTests('C:\\Windows', 'Path') + + try { + const env = await captureProbeEnv('C:/Git/bin/bash.exe') + expect(env.Path).toBe('C:\\Windows') + expect(env.PATH).toBeUndefined() + } finally { + if (originalWindowsPath === undefined) { + delete process.env.Path + } else { + process.env.Path = originalWindowsPath + } + } + }) }) describe('mergePathSegments', () => { diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index db41fe3f462..3deeb30a738 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -32,6 +32,23 @@ export type HydrationResult = let cached: Promise | null = null let probeQueue = Promise.resolve() +// Why: patchPackagedProcessPath seeds a version manager's newest install dir +// ahead of PATH. nvm's startup `use` honors whatever node is already on PATH over +// the user's `default` alias, so a probe inheriting that seed comes back pinned to +// the newest install and every terminal loses the global CLIs installed under +// `default`. Snapshot PATH here instead: module init runs while the import graph is +// evaluated, strictly before any statement in main's body, so it cannot observe the +// seeds — and unlike an explicit hand-off from the seeding site, there is no call +// ordering left for a later refactor to break. Windows keys it `Path`, and a Git Bash +// user whose nvm uses the POSIX ~/.nvm/versions/node layout is seeded there too, so +// capture the key alongside the value. +const LAUNCH_PATH_KEY = + process.platform === 'win32' && process.env.Path !== undefined ? 'Path' : 'PATH' +const LAUNCH_PATH = process.env[LAUNCH_PATH_KEY] ?? null +// Why: rc files that exec into a multiplexer or start a heavy prompt can outrun the +// probe budget. This lets them detect the probe and take a fast path. +const PROBE_MARKER_ENV_VAR = 'ORCA_SHELL_PATH_PROBE' +let launchPathOverride: { key: string; value: string } | null = null let configuredWindowsShell = 'powershell.exe' let configuredWindowsGitBashPath: string | null = null let configuredWindowsFallbackShell: string | null = null @@ -42,6 +59,7 @@ const windowsPathOwnership = new WindowsShellPathOwnership() export function _resetHydrateShellPathCache(): void { cached = null probeQueue = Promise.resolve() + launchPathOverride = null configuredWindowsShell = 'powershell.exe' configuredWindowsGitBashPath = null configuredWindowsFallbackShell = null @@ -94,6 +112,60 @@ function parseCapturedPath(stdout: string, pathDelimiter: string = delimiter): s ] } +/** @internal - tests need a launch PATH the runner's own environment cannot supply. */ +export function _setLaunchPathForTests(value: string, key = 'PATH'): void { + launchPathOverride = { key, value } +} + +function isLaunchPathKey(name: string, key: string): boolean { + return process.platform === 'win32' ? name.toLowerCase() === key.toLowerCase() : name === key +} + +function applyLaunchPath(env: NodeJS.ProcessEnv, key: string, value: string | null): void { + if (value === null) { + return + } + // Why: Windows resolves env names case-insensitively even though a spread is + // a plain object, so never leave both the captured key and another casing. + for (const name of Object.keys(env)) { + if (name !== key && isLaunchPathKey(name, key)) { + delete env[name] + } + } + env[key] = value +} + +function shellProbeEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, [PROBE_MARKER_ENV_VAR]: '1' } + const key = launchPathOverride?.key ?? LAUNCH_PATH_KEY + const value = launchPathOverride?.value ?? LAUNCH_PATH + applyLaunchPath(env, key, value) + return env +} + +/** Run a synchronous launcher without passing Orca's seeded PATH to its child. */ +export function runWithLaunchPath(action: () => T): T { + const key = launchPathOverride?.key ?? LAUNCH_PATH_KEY + const value = launchPathOverride?.value ?? LAUNCH_PATH + if (value === null) { + return action() + } + const previous = Object.entries(process.env).filter(([name]) => isLaunchPathKey(name, key)) + applyLaunchPath(process.env, key, value) + try { + return action() + } finally { + for (const name of Object.keys(process.env)) { + if (isLaunchPathKey(name, key)) { + delete process.env[name] + } + } + for (const [name, previousValue] of previous) { + process.env[name] = previousValue + } + } +} + function shellPathProbe(shell: string): { args: string[]; pathDelimiter: string } { if (process.platform !== 'win32') { const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'` @@ -125,7 +197,7 @@ function spawnShellAndReadPath(shell: string): Promise { // it layer its own rc files on top. Do NOT forward stdio — some shells // (oh-my-zsh setups, powerlevel10k) print a lot to stderr on startup, // and we don't want that in Orca's console. - env: process.env, + env: shellProbeEnv(), stdio: ['ignore', 'pipe', 'ignore'], detached: false, windowsHide: true diff --git a/src/main/updater.quit-and-install.test.ts b/src/main/updater.quit-and-install.test.ts index 2ec23352bf2..945538b3895 100644 --- a/src/main/updater.quit-and-install.test.ts +++ b/src/main/updater.quit-and-install.test.ts @@ -12,6 +12,10 @@ const { resetUpdaterMocks } = await vi.hoisted(async () => (await import('./updater-test-harness')).createUpdaterMocks()) +const { launchPathScope } = vi.hoisted(() => ({ + launchPathScope: { active: false, calls: 0 } +})) + vi.mock('electron', () => moduleFactories.electron()) vi.mock('electron-updater', () => moduleFactories.electronUpdater()) vi.mock('./electron-updater-loader', () => moduleFactories.electronUpdaterLoader()) @@ -25,10 +29,23 @@ vi.mock('./update-install-exit-watchdog', () => moduleFactories.updateInstallExi vi.mock('./updater-prerelease-feed', () => moduleFactories.updaterPrereleaseFeed()) vi.mock('./local-builds/local-build-switch', () => moduleFactories.localBuildSwitch()) vi.mock('./local-builds/local-build-feed-server', () => moduleFactories.localBuildFeedServer()) +vi.mock('./startup/hydrate-shell-path', () => ({ + runWithLaunchPath: (action: () => unknown): unknown => { + launchPathScope.active = true + launchPathScope.calls += 1 + try { + return action() + } finally { + launchPathScope.active = false + } + } +})) describe('updater', () => { beforeEach(() => { resetUpdaterMocks() + launchPathScope.active = false + launchPathScope.calls = 0 }) it('still surfaces updater error events while a download is in flight', async () => { @@ -118,8 +135,12 @@ describe('updater', () => { expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(2) }) - it('defers quitAndInstall through the shared main-process entrypoint', async () => { + it('defers quitAndInstall and runs its launcher within the launch PATH scope', async () => { vi.useFakeTimers() + let launcherSawLaunchPathScope = false + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + launcherSawLaunchPathScope = launchPathScope.active + }) const mainWindow = { webContents: { send: vi.fn() } } const { setupAutoUpdater, quitAndInstall } = await import('./updater') @@ -135,6 +156,9 @@ describe('updater', () => { await vi.advanceTimersByTimeAsync(1) expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(false, true) + expect(launcherSawLaunchPathScope).toBe(true) + expect(launchPathScope.calls).toBe(1) + expect(launchPathScope.active).toBe(false) }) it('runs pre-quit cleanup before local PTY cleanup during update install', async () => { diff --git a/src/main/updater.ts b/src/main/updater.ts index 640906197c8..912c681cfad 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -21,6 +21,7 @@ import { killAllPty } from './ipc/pty' import { withUpdaterSpan } from './observability/instrumentation' import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader' import { writeMainThreadDiagnosticMarker } from './diagnostics/main-thread-churn-probe' +import { runWithLaunchPath } from './startup/hydrate-shell-path' import { beginMacUpdateDownload, deferMacQuitUntilInstallerReady, @@ -788,7 +789,9 @@ async function performQuitAndInstall(): Promise { // Why: BaseUpdater logs child stderr but drops it from the 'error' event, so retain it for the span of this call. beginLinuxPackageInstallDiagnosticCapture(getTrackedLinuxPackageArtifact()?.path ?? null) try { - getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) + runWithLaunchPath(() => + getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) + ) } finally { const diagnostic = endLinuxPackageInstallDiagnosticCapture() // Why: a synchronous 'error' already consumed and reset this attempt; re-stashing would leak it into the next one. From 0b66daffcc18a5f107888d011df3f79685033e87 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:51:40 -0700 Subject: [PATCH 24/65] refactor(cli): split orchestration handlers (#16139) --- config/max-lines-baseline.txt | 1 - .../orchestration-module-boundaries.test.ts | 166 ++ src/cli/handlers/orchestration.ts | 1349 +---------------- .../handlers/orchestration/check-keepalive.ts | 32 + .../orchestration/dispatch-handlers.ts | 84 + .../handlers/orchestration/gate-handlers.ts | 60 + .../orchestration/message-check-handler.ts | 120 ++ .../orchestration/message-inbox-handlers.ts | 64 + .../handlers/orchestration/message-payload.ts | 60 + .../orchestration/message-send-handler.ts | 137 ++ .../orchestration/mutation-request.ts | 21 + .../handlers/orchestration/numeric-flags.ts | 23 + .../orchestration/question-handler.ts | 109 ++ .../handlers/orchestration/reset-handler.ts | 29 + .../handlers/orchestration/run-handlers.ts | 85 ++ .../orchestration/runtime-compatibility.ts | 42 + .../handlers/orchestration/task-handlers.ts | 114 ++ .../orchestration/terminal-identity.ts | 151 ++ .../orchestration/worker-launch-handler.ts | 70 + .../worker-observation-handlers.ts | 61 + .../handlers/orchestration/worker-output.ts | 68 + .../orchestration/worker-terminal-handlers.ts | 126 ++ 22 files changed, 1649 insertions(+), 1323 deletions(-) create mode 100644 src/cli/handlers/orchestration-module-boundaries.test.ts create mode 100644 src/cli/handlers/orchestration/check-keepalive.ts create mode 100644 src/cli/handlers/orchestration/dispatch-handlers.ts create mode 100644 src/cli/handlers/orchestration/gate-handlers.ts create mode 100644 src/cli/handlers/orchestration/message-check-handler.ts create mode 100644 src/cli/handlers/orchestration/message-inbox-handlers.ts create mode 100644 src/cli/handlers/orchestration/message-payload.ts create mode 100644 src/cli/handlers/orchestration/message-send-handler.ts create mode 100644 src/cli/handlers/orchestration/mutation-request.ts create mode 100644 src/cli/handlers/orchestration/numeric-flags.ts create mode 100644 src/cli/handlers/orchestration/question-handler.ts create mode 100644 src/cli/handlers/orchestration/reset-handler.ts create mode 100644 src/cli/handlers/orchestration/run-handlers.ts create mode 100644 src/cli/handlers/orchestration/runtime-compatibility.ts create mode 100644 src/cli/handlers/orchestration/task-handlers.ts create mode 100644 src/cli/handlers/orchestration/terminal-identity.ts create mode 100644 src/cli/handlers/orchestration/worker-launch-handler.ts create mode 100644 src/cli/handlers/orchestration/worker-observation-handlers.ts create mode 100644 src/cli/handlers/orchestration/worker-output.ts create mode 100644 src/cli/handlers/orchestration/worker-terminal-handlers.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index e601ddf611e..e6763232f8f 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -3,7 +3,6 @@ # split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines"). # Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only) inline src/cli/handlers/automations.ts -inline src/cli/handlers/orchestration.ts inline src/cli/help.ts inline src/main/agent-hooks/server.ts inline src/main/automations/external-manager.ts diff --git a/src/cli/handlers/orchestration-module-boundaries.test.ts b/src/cli/handlers/orchestration-module-boundaries.test.ts new file mode 100644 index 00000000000..baca7e38b41 --- /dev/null +++ b/src/cli/handlers/orchestration-module-boundaries.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../format', () => ({ printResult: vi.fn() })) + +import { printResult } from '../format' +import { HANDLER_GROUPS } from '../handler-group-manifest' +import { ORCHESTRATION_HANDLERS } from './orchestration' +import { + ORCHESTRATION_DISPATCH_HANDLER, + ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS +} from './orchestration/dispatch-handlers' +import { getOptionalStructuredMessagePayload } from './orchestration/message-payload' +import { getOptionalPositiveIntegerValueFlag } from './orchestration/numeric-flags' +import { formatWorkerRead, formatWorkerRelease } from './orchestration/worker-output' + +describe('extracted orchestration flag parsing', () => { + it('serializes phase and omits empty CSV entries', () => { + const payload = getOptionalStructuredMessagePayload( + new Map([ + ['phase', 'verification'], + ['files-modified', ' src/a.ts, ,src/b.ts, '] + ]) + ) + + expect(JSON.parse(payload ?? '')).toEqual({ + filesModified: ['src/a.ts', 'src/b.ts'], + phase: 'verification' + }) + }) + + it('rejects unsafe positive integers before dispatch', () => { + expect(() => + getOptionalPositiveIntegerValueFlag( + new Map([['timeout-ms', '9007199254740992']]), + 'timeout-ms' + ) + ).toThrow('Invalid positive safe integer for --timeout-ms') + }) +}) + +it('composes handlers in the canonical command order', () => { + const declared = HANDLER_GROUPS.find((group) => group.name === 'orchestration')?.keys + expect(Object.keys(ORCHESTRATION_HANDLERS)).toEqual(declared) +}) + +describe('extracted orchestration worker formatting', () => { + it('renders terminal tails without changing line boundaries', () => { + expect( + formatWorkerRead({ + dispatchId: 'dispatch_1', + terminal: { tail: ['first', '', 'third'] } + } as never) + ).toBe('first\n\nthird') + }) + + it('renders transcript blocks and survives unserializable tool input', () => { + const circular: { self?: unknown } = {} + circular.self = circular + + expect( + formatWorkerRead({ + source: 'transcript', + transcript: { + messages: [ + { + id: 'message_1', + role: 'assistant', + blocks: [ + { type: 'text', text: 'working' }, + { type: 'tool-call', name: 'inspect', input: circular }, + { type: 'tool-result', output: 'failed', isError: true }, + { type: 'image-ref', url: 'https://example.test/proof.png' } + ], + timestamp: null, + source: 'transcript' + } + ] + } + } as never) + ).toBe( + '[assistant] working\n[tool inspect] [unserializable input]\n[tool result error] failed\n[image] https://example.test/proof.png' + ) + }) + + it('renders release evidence in receipt order', () => { + expect( + formatWorkerRelease({ + dispatchId: 'dispatch_1', + state: 'release_unknown', + reason: 'host_unreachable', + processAction: 'unverifiable', + archive: { source: 'transcript', status: 'saved' }, + lastError: 'transport closed', + recovery: 'Reconnect and retry.' + }) + ).toBe( + 'Worker dispatch_1 terminal [release_unknown] reason=host_unreachable process=unverifiable\n' + + 'archive transcript [saved]\ntransport closed\nReconnect and retry.' + ) + }) +}) + +describe('extracted orchestration dispatch handlers', () => { + const call = vi.fn() + + beforeEach(() => { + call.mockReset() + vi.mocked(printResult).mockReset() + }) + + it('allows a dry run without a recipient and returns only its preamble', async () => { + call.mockResolvedValue({ + result: { dispatch: null, dryRun: true, preamble: 'preview preamble' } + }) + + await ORCHESTRATION_DISPATCH_HANDLER['orchestration dispatch']({ + flags: new Map([ + ['task', 'task_1'], + ['from', 'terminal_coordinator'], + ['dry-run', true], + ['return-preamble', true] + ]), + client: { call }, + cwd: '/repo', + json: false + } as never) + + expect(call).toHaveBeenCalledWith('orchestration.dispatch', { + task: 'task_1', + run: undefined, + to: undefined, + from: 'terminal_coordinator', + inject: undefined, + dryRun: true, + returnPreamble: true, + devMode: false + }) + expect(renderedValue({ dispatch: null, dryRun: true, preamble: 'preview preamble' })).toBe( + 'preview preamble' + ) + }) + + it('does not resolve coordinator identity for a non-preamble dispatch lookup', async () => { + call.mockResolvedValue({ result: { dispatch: null } }) + + await ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS['orchestration dispatch-show']({ + flags: new Map([['task', 'task_1']]), + client: { call }, + cwd: '/repo', + json: false + } as never) + + expect(call).toHaveBeenCalledWith('orchestration.dispatchShow', { + task: 'task_1', + preamble: undefined, + from: undefined, + devMode: false + }) + expect(renderedValue({ dispatch: null })).toBe('No dispatch context found.') + }) +}) + +function renderedValue(value: unknown): string { + const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as (result: unknown) => string + return formatter(value) +} diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index 43fffc54b8c..f1f1c70bc39 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -1,1327 +1,32 @@ -/* eslint-disable max-lines -- Why: orchestration CLI handlers share flag-parsing helpers and dispatch/preamble logic; splitting by verb would fragment the RuntimeClient call shape without reducing complexity. */ import type { CommandHandler } from '../dispatch' -import type { RuntimeClient } from '../runtime-client' -import { printResult } from '../format' +import { ORCHESTRATION_CHECK_HANDLER } from './orchestration/message-check-handler' import { - getOptionalPositiveIntegerFlag, - getOptionalStringFlag, - getRequiredStringFlag -} from '../flags' -import { RuntimeClientError } from '../runtime-client' -import { requireWorkerDoneSettlement } from './orchestration-worker-settlement' -import { getTerminalHandle } from '../selectors' -import { - clampOrchestrationAskTimeoutMs, - resolveOrchestrationAskClientTimeoutMs -} from '../../shared/orchestration-ask-timeout' -import { abbreviateOrchestrationTasks } from '../../shared/orchestration-task-summary' -import { parsePositiveSafeIntegerText } from '../../shared/timer-delay' -import type { - OrchestrationWorkerReadResult, - OrchestrationWorkerReadSource -} from '../../shared/orchestration-worker-output' -import type { NativeChatMessage } from '../../shared/native-chat-types' -import type { RuntimeStatus, RuntimeTerminalRead } from '../../shared/runtime-types' -import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../shared/protocol-version' -import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract' -import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../shared/orchestration-run-pagination' -import { - formatMessageReadOnlyTag, - formatOrchestrationCheckText, - prepareOrchestrationCheckOutput, - type LegacyCompatibilityResult, - type OrchestrationMessageSummary as MessageSummary -} from '../../shared/orchestration-check-output' -import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery' - -// Why: 15 s is well under Claude Code's ~2 min Bash-tool silence budget while keeping log volume low. See design doc §3.4. -const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000 -function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { - return `${type} messages belong to one exact Dispatch and cannot target a group address.` -} - -// Why: test-only escape hatch so subprocess tests avoid the full 15 s window; bogus values fall back to the default. -function resolveKeepaliveIntervalMs(): number { - const raw = process.env.ORCA_KEEPALIVE_INTERVAL_MS ?? process.env.ORCA_HEARTBEAT_INTERVAL_MS - if (!raw) { - return DEFAULT_KEEPALIVE_INTERVAL_MS - } - const parsed = Number(raw) - if (!Number.isFinite(parsed) || parsed <= 0) { - return DEFAULT_KEEPALIVE_INTERVAL_MS - } - return parsed -} - -function startCheckKeepalive(deadlineMs: number | undefined): () => void { - const startedAt = Date.now() - const interval = setInterval(() => { - const payload = { - _keepalive: true, - // Why: retain the old marker for scripts still filtering it while callers migrate to _keepalive. - _heartbeat: true, - elapsedMs: Date.now() - startedAt, - deadlineMs: deadlineMs ?? null - } - // Why: process.stderr.write is line-flushed per-call in Node; a buffered writer would hold keepalives until exit. See §3.4. - process.stderr.write(`${JSON.stringify(payload)}\n`) - }, resolveKeepaliveIntervalMs()) - if (typeof interval.unref === 'function') { - interval.unref() - } - return () => clearInterval(interval) -} - -// Why: mirrors TaskStatus (orchestration/types.ts) so the CLI surfaces an enum-aware error before the generic RPC message. -const TASK_STATUS_VALUES = [ - 'pending', - 'ready', - 'dispatched', - 'completed', - 'failed', - 'blocked' -] as const - -// Why: mirrors WorkerTerminalListState (orchestration/types.ts) so a bad --terminal-state fails before the RPC. -const WORKER_TERMINAL_LIST_STATES = [ - 'active', - 'reclaimable', - 'retained', - 'release_pending', - 'release_unknown', - 'released' -] as const - -type LifecycleSendResult = - | { - action: 'completed' | 'failed' - authority?: 'run_home' | 'worker_server_legacy' - } - | { action: 'settled'; outcome: 'succeeded' | 'failed'; duplicate?: boolean } - | { action: 'rejected'; code: string; reason: string } - -type SendRecipientWarning = { - code: string - recipient: string - message: string -} - -type OrchestrationSendResult = - | { - message: { id: string; run_id?: string } - lifecycle?: LifecycleSendResult - warnings?: SendRecipientWarning[] - } - | { - messages: { id: string }[] - recipients: number - warnings?: SendRecipientWarning[] - } - | { - relay: { - messageId: string - sequence: number - dispatchId: string - destination?: 'run_home' | 'worker' - accepted: true - } - lifecycle?: LifecycleSendResult - warnings?: SendRecipientWarning[] - } - -function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' { - const configured = process.env.ORCA_CLI_COMMAND - if (configured === 'orca' || configured === 'orca-ide' || configured === 'orca-dev') { - return configured - } - return process.platform === 'linux' ? 'orca-ide' : 'orca' -} - -function resolvePackagedWindowsCompatibilityCommand(): 'orca' | 'orca-ide' | undefined { - if (process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER !== '1') { - return undefined - } - const command = process.env.ORCA_CLI_COMMAND - if (command === 'orca' || command === 'orca-ide') { - return command - } - throw new RuntimeClientError( - 'invalid_argument', - 'The packaged Orca launcher did not provide a valid resume command. No question was created.' - ) -} - -async function flushStdout(): Promise { - await new Promise((resolve, reject) => { - process.stdout.write('', (error) => { - if (error) { - reject(error) - } else { - resolve() - } - }) - }) -} - -function getOptionalStructuredMessagePayload( - flags: Map -): string | undefined { - const rawPayload = getOptionalStringFlag(flags, 'payload') - const taskId = getOptionalStringFlag(flags, 'task-id') - const dispatchId = getOptionalStringFlag(flags, 'dispatch-id') - const outcome = getOptionalStringFlag(flags, 'outcome') - const filesModified = getOptionalStringFlag(flags, 'files-modified') - const reportPath = getOptionalStringFlag(flags, 'report-path') - const phase = getOptionalStringFlag(flags, 'phase') - const hasStructuredPayload = - taskId !== undefined || - dispatchId !== undefined || - outcome !== undefined || - filesModified !== undefined || - reportPath !== undefined || - phase !== undefined - if (!hasStructuredPayload) { - return rawPayload - } - if (rawPayload !== undefined) { - throw new RuntimeClientError( - 'invalid_argument', - 'Use either --payload or structured payload flags, not both.' - ) - } - // Why: raw JSON args are fragile in Windows PowerShell; these flags avoid shell-specific quoting. - const payload: Record = {} - if (taskId) { - payload.taskId = taskId - } - if (dispatchId) { - payload.dispatchId = dispatchId - } - if (outcome) { - if (outcome !== 'succeeded' && outcome !== 'failed') { - throw new RuntimeClientError( - 'invalid_argument', - 'Invalid --outcome. Expected succeeded or failed.' - ) - } - payload.outcome = outcome - } - if (filesModified) { - payload.filesModified = filesModified - .split(',') - .map((file) => file.trim()) - .filter(Boolean) - } - if (reportPath) { - payload.reportPath = reportPath - } - if (phase) { - payload.phase = phase - } - return JSON.stringify(payload) -} - -async function resolveOrchestrationTerminalHandle( - flags: Map, - cwd: string, - client: Parameters[0]['client'], - flagName: 'from' | 'terminal', - options: { validateEnvHandle?: boolean } = {} -): Promise { - const explicit = getOptionalStringFlag(flags, flagName) - if (explicit) { - return explicit - } - const envHandle = process.env.ORCA_TERMINAL_HANDLE - if (envHandle && envHandle.length > 0) { - if (flagName === 'from' && options.validateEnvHandle) { - // Why: long-lived shells can retain a stale ORCA_TERMINAL_HANDLE after remint; don't bake it into coordinator preambles. - const live = await isLiveTerminalHandle(envHandle, client) - if (!live) { - const reminted = await resolveOrchestrationPaneTerminalHandle(client) - if (reminted) { - return reminted - } - throwNoActiveSenderTerminal() - } - } - return envHandle - } - if (flagName === 'from') { - return await resolveImplicitOrchestrationSender(flags, cwd, client) - } - return await getTerminalHandle(flags, cwd, client) -} - -async function isLiveTerminalHandle( - handle: string, - client: Parameters[0]['client'] -): Promise { - try { - await client.call('terminal.show', { terminal: handle }) - return true - } catch (err) { - if (isStaleTerminalIdentityError(err)) { - return false - } - throw err - } -} - -function getClientErrorCode(err: unknown): string | undefined { - if (!err || typeof err !== 'object') { - return undefined - } - const code = (err as { code?: unknown }).code - return typeof code === 'string' ? code : undefined -} - -function isStaleTerminalIdentityError(err: unknown): boolean { - const code = getClientErrorCode(err) - return code === 'terminal_handle_stale' || code === 'terminal_gone' -} - -function isNoActiveTerminalError(err: unknown): boolean { - return getClientErrorCode(err) === 'no_active_terminal' -} - -async function resolveOrchestrationPaneTerminalHandle( - client: Parameters[0]['client'], - options: { optional?: boolean } = {} -): Promise { - const paneKey = process.env.ORCA_PANE_KEY - if (!paneKey || paneKey.length === 0) { - return undefined - } - try { - // Why: pane-key reminting preserves caller identity; focus-based active-terminal fallback can point at a different pane. - const response = await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', { - paneKey - }) - return response.result.terminal.handle - } catch (err) { - if ( - isPaneRemintUnavailableError(err) || - (options.optional === true && isOptionalPaneRemintUnavailableError(err)) - ) { - return undefined - } - throw err - } -} - -function isPaneRemintUnavailableError(err: unknown): boolean { - const code = getClientErrorCode(err) - const message = getClientErrorMessage(err) - return ( - code === 'terminal_not_found' || - code === 'terminal_handle_stale' || - code === 'terminal_gone' || - message === 'terminal_not_found' || - message === 'terminal_handle_stale' || - message === 'terminal_gone' - ) -} - -function isOptionalPaneRemintUnavailableError(err: unknown): boolean { - return getClientErrorCode(err) === 'runtime_unavailable' -} - -function getClientErrorMessage(err: unknown): string | undefined { - if (err instanceof Error) { - return err.message - } - if (!err || typeof err !== 'object') { - return undefined - } - const message = (err as { message?: unknown }).message - return typeof message === 'string' ? message : undefined -} - -async function resolveCoordinatorTerminalHandle( - flags: Map, - cwd: string, - client: Parameters[0]['client'] -): Promise { - return await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from', { - validateEnvHandle: true - }) -} - -async function resolveImplicitOrchestrationSender( - flags: Map, - cwd: string, - client: Parameters[0]['client'] -): Promise { - try { - return await getTerminalHandle(flags, cwd, client) - } catch (err) { - if (!isNoActiveTerminalError(err)) { - throw err - } - throwNoActiveSenderTerminal() - } -} - -function throwNoActiveSenderTerminal(): never { - throw new RuntimeClientError( - 'no_active_sender_terminal', - 'Could not determine the sender terminal for this orchestration command. ' + - 'Pass --from or run the command inside a live Orca terminal with ORCA_TERMINAL_HANDLE set.' - ) -} - -function isDevCliInvocation(): boolean { - return ( - process.env.ORCA_DEV_CLI_INVOCATION === '1' || - (process.env.ORCA_USER_DATA_PATH?.includes('orca-dev') ?? false) - ) -} - -function getOptionalPositiveIntegerValueFlag( - flags: Map, - name: string -): number | undefined { - if (!flags.has(name)) { - return undefined - } - const raw = flags.get(name) - if (typeof raw !== 'string' || raw.length === 0) { - throw new RuntimeClientError('invalid_argument', `Missing value for --${name}.`) - } - const value = parsePositiveSafeIntegerText(raw) - if (value === null) { - throw new RuntimeClientError( - 'invalid_argument', - `Invalid positive safe integer for --${name}: ${raw}` - ) - } - return value -} - -function rejectLifecycleGroupRecipient(type: string | undefined, to: string): void { - if ((type === 'worker_done' || type === 'heartbeat') && to.startsWith('@')) { - throw new RuntimeClientError('invalid_argument', getLifecycleGroupRecipientError(type)) - } -} - -function callMutation( - client: RuntimeClient, - flags: Map, - method: string, - params: unknown, - options?: { timeoutMs?: number; orchestrationCapability?: string } -) { - const requestId = getOptionalStringFlag(flags, 'retry-request') - const result = requestId - ? client.call(method, params, { ...options, orchestrationRequestId: requestId }) - : options - ? client.call(method, params, options) - : client.call(method, params) - return result.catch((error) => { - throw orchestrationMutationRecoveryError(error) - }) -} - -type LegacyWorkerReadResult = { - dispatchId: string - terminal: RuntimeTerminalRead -} - -function formatWorkerRead(value: OrchestrationWorkerReadResult | LegacyWorkerReadResult): string { - if (!('source' in value) || value.source === 'terminal') { - return value.terminal.tail.join('\n') - } - return value.transcript.messages.map(formatWorkerTranscriptMessage).join('\n\n') -} - -function formatWorkerTranscriptMessage(message: NativeChatMessage): string { - const blocks = message.blocks.map((block) => { - if (block.type === 'text') { - return block.text - } - if (block.type === 'tool-call') { - return `[tool ${block.name}] ${safeJson(block.input)}` - } - if (block.type === 'tool-result') { - return `[tool result${block.isError ? ' error' : ''}] ${block.output}` - } - return block.url ? `[image] ${block.url}` : `[image omitted]` - }) - return `[${message.role}] ${blocks.join('\n')}`.trimEnd() -} - -type WorkerReleaseReceipt = { - dispatchId: string - state: string - reason?: string - processAction: string - archive: { source: string | null; status: string | null } | null - recovery?: string - lastError?: string -} - -function formatWorkerRelease(value: WorkerReleaseReceipt): string { - const head = `Worker ${value.dispatchId} terminal [${value.state}]` - const lines = [ - `${head}${value.reason ? ` reason=${value.reason}` : ''} process=${value.processAction}` - ] - if (value.archive) { - lines.push(`archive ${value.archive.source ?? 'none'} [${value.archive.status ?? 'unknown'}]`) - } - if (value.lastError) { - lines.push(value.lastError) - } - if (value.recovery) { - lines.push(value.recovery) - } - return lines.join('\n') -} - -function safeJson(value: unknown): string { - try { - return JSON.stringify(value) - } catch { - return '[unserializable input]' - } -} + ORCHESTRATION_DISPATCH_HANDLER, + ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS +} from './orchestration/dispatch-handlers' +import { ORCHESTRATION_GATE_HANDLERS } from './orchestration/gate-handlers' +import { ORCHESTRATION_INBOX_HANDLERS } from './orchestration/message-inbox-handlers' +import { ORCHESTRATION_QUESTION_HANDLER } from './orchestration/question-handler' +import { ORCHESTRATION_RESET_HANDLER } from './orchestration/reset-handler' +import { ORCHESTRATION_RUN_HANDLERS } from './orchestration/run-handlers' +import { ORCHESTRATION_SEND_HANDLER } from './orchestration/message-send-handler' +import { ORCHESTRATION_TASK_HANDLERS } from './orchestration/task-handlers' +import { ORCHESTRATION_WORKER_LAUNCH_HANDLER } from './orchestration/worker-launch-handler' +import { ORCHESTRATION_WORKER_OBSERVATION_HANDLERS } from './orchestration/worker-observation-handlers' +import { ORCHESTRATION_WORKER_TERMINAL_HANDLERS } from './orchestration/worker-terminal-handlers' export const ORCHESTRATION_HANDLERS: Record = { - 'orchestration run-create': async ({ flags, client, cwd, json }) => { - const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await callMutation<{ - run: { id: string; objective: string; consumer_generation: number } - }>(client, flags, 'orchestration.runCreate', { - objective: getRequiredStringFlag(flags, 'objective'), - from - }) - printResult(result, json, (r) => `Run ${r.run.id} created and bound: ${r.run.objective}`) - }, - - 'orchestration run-use': async ({ flags, client, cwd, json }) => { - const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await callMutation<{ - run: { id: string; objective: string; consumer_generation: number } - }>(client, flags, 'orchestration.runUse', { - id: getRequiredStringFlag(flags, 'id'), - from, - ...(flags.has('takeover-legacy') ? { takeoverLegacy: true } : {}) - }) - printResult(result, json, (r) => `Using Run ${r.run.id}: ${r.run.objective}`) - }, - - 'orchestration run-current': async ({ flags, client, cwd, json }) => { - const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await client.call<{ - run: { id: string; objective: string } | null - }>('orchestration.runCurrent', { from }) - printResult(result, json, (r) => - r.run ? `${r.run.id} ${r.run.objective}` : 'No Run is bound to this terminal.' - ) - }, - - 'orchestration run-list': async ({ flags, client, json }) => { - const result = await client.call<{ - runs: { id: string; objective: string; legacy: number }[] - nextCursor: string | null - }>('orchestration.runList', { - limit: getOptionalPositiveIntegerFlag(flags, 'limit') ?? ORCHESTRATION_RUN_PAGE_LIMIT, - cursor: getOptionalStringFlag(flags, 'cursor') - }) - printResult(result, json, (r) => { - const rows = - r.runs.length === 0 - ? 'No Runs found.' - : r.runs - .map( - (run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}` - ) - .join('\n') - return r.nextCursor ? `${rows}\nMore Runs: --cursor ${r.nextCursor}` : rows - }) - }, - - 'orchestration run-show': async ({ flags, client, json }) => { - const result = await client.call<{ - run: { - id: string - objective: string - consumer_generation: number - legacy: number - created_at: string - } - }>('orchestration.runShow', { id: getRequiredStringFlag(flags, 'id') }) - printResult( - result, - json, - (r) => - `${r.run.id}${r.run.legacy ? ' [legacy, inspect only]' : ''} ${r.run.objective}\n` + - `consumer generation ${r.run.consumer_generation}; created ${r.run.created_at}` - ) - }, - - 'orchestration send': async ({ flags, client, cwd, json }) => { - const to = getOptionalStringFlag(flags, 'to') - const type = getOptionalStringFlag(flags, 'type') - if (to) { - rejectLifecycleGroupRecipient(type, to) - } - const outcome = getOptionalStringFlag(flags, 'outcome') - if (type !== 'worker_done' && outcome !== undefined) { - throw new RuntimeClientError( - 'invalid_argument', - '--outcome is only valid with --type worker_done.' - ) - } - - if ( - (type === 'worker_done' || type === 'heartbeat') && - !getOptionalStringFlag(flags, 'from') && - !process.env.ORCA_TERMINAL_HANDLE - ) { - // Why: focus isn't lifecycle authority — an identity-less subprocess must fail closed rather than guess the worker. - throwNoActiveSenderTerminal() - } - - // Why: lifecycle senders preserve ORCA_TERMINAL_HANDLE across restarts for older runtimes. - const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') - const sendParams = { - from, - to, - run: getOptionalStringFlag(flags, 'run'), - subject: getRequiredStringFlag(flags, 'subject'), - body: getOptionalStringFlag(flags, 'body'), - type, - priority: getOptionalStringFlag(flags, 'priority'), - threadId: getOptionalStringFlag(flags, 'thread-id'), - payload: getOptionalStructuredMessagePayload(flags), - // Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it. - senderPaneKey: process.env.ORCA_PANE_KEY || undefined, - waitForLifecycleSettlement: type === 'worker_done' ? true : undefined, - devMode: isDevCliInvocation() - } - const dispatchCapability = getOptionalStringFlag(flags, 'dispatch-capability') - const result = await callMutation( - client, - flags, - 'orchestration.send', - sendParams, - dispatchCapability ? { orchestrationCapability: dispatchCapability } : undefined - ) - await requireWorkerDoneSettlement(client, type, sendParams.payload, result.result) - if ('lifecycle' in result.result && result.result.lifecycle?.action === 'rejected') { - throw new RuntimeClientError(result.result.lifecycle.code, result.result.lifecycle.reason) - } - printResult(result, json, (r) => { - const warnings = 'warnings' in r ? (r.warnings ?? []) : [] - const withWarnings = (line: string): string => - warnings.length > 0 - ? [line, ...warnings.map((warning) => `Warning: ${warning.message}`)].join('\n') - : line - if ('message' in r) { - return withWarnings(`Sent ${r.message.id}`) - } - if ('relay' in r) { - if (r.relay.destination === 'worker') { - return withWarnings( - `Queued ${r.relay.messageId} for worker Dispatch ${r.relay.dispatchId}` - ) - } - return withWarnings( - `Queued ${r.relay.messageId} for Run home (Dispatch ${r.relay.dispatchId})` - ) - } - return withWarnings(`Sent ${r.messages.length} messages to ${r.recipients} recipients`) - }) - }, - - 'orchestration check': async ({ flags, client, cwd, json }) => { - const wait = flags.has('wait') - const peek = flags.has('peek') - // Why: enforce mode exclusivity client-side — older runtimes strip unknown peek and run --unread --peek as destructive mark-read. - if ([flags.has('unread'), peek, flags.has('all')].filter(Boolean).length > 1) { - throw new RuntimeClientError( - 'invalid_argument', - 'Choose at most one message read mode: --unread, --peek, or --all.' - ) - } - const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') - const explicitTerminal = getOptionalStringFlag(flags, 'terminal') - const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') - - // Why: Claude Code auto-backgrounds subprocesses silent ~2 min; emit JSON keepalives to stderr (stdout stays one payload). See §3.4. - const stopKeepalive = wait ? startCheckKeepalive(timeoutMs) : null - type CheckResult = { - messages: MessageSummary[] - count: number - formatted?: string - deliveryId?: string | null - runId?: string - timedOut?: boolean - cancelled?: boolean - connectionLost?: boolean - legacyCompatibility?: LegacyCompatibilityResult - } - let result: Awaited>> - try { - result = await callMutation(client, flags, 'orchestration.check', { - terminal, - terminalPaneKey: explicitTerminal ? undefined : process.env.ORCA_PANE_KEY || undefined, - // Why: peek also sends unread:false so pre-peek runtimes degrade to non-consuming all mode instead of destructive mark-read. - unread: flags.has('unread') ? true : peek ? false : undefined, - peek: peek ? true : undefined, - all: flags.has('all') ? true : undefined, - types: getOptionalStringFlag(flags, 'types'), - format: flags.has('format') ? true : undefined, - inject: flags.has('inject') ? true : undefined, - compatibilityCliCommand: resolveCompatibilityCliCommand(), - run: getOptionalStringFlag(flags, 'run'), - ack: getOptionalStringFlag(flags, 'ack'), - wait: wait ? true : undefined, - timeoutMs - }) - } finally { - stopKeepalive?.() - } - if (peek) { - const rawRowCount = result.result.messages.length - const unreadOnly = result.result.messages.filter((m) => m.read !== 1) - const removedReadRows = unreadOnly.length !== rawRowCount - // Why: read rows mean a pre-peek runtime ran the all mode and returned instead of blocking; can't honor --wait, so fail loudly. - if (wait && removedReadRows && unreadOnly.length === 0) { - throw new RuntimeClientError( - 'peek_wait_unsupported', - 'The connected runtime does not support --peek with --wait; upgrade the runtime or use --wait without --peek.' - ) - } - // Why: pre-peek runtimes cap the all mode at 100 rows; a full page may hide older unread — warn on stderr. - if (removedReadRows && rawRowCount >= 100) { - console.error( - 'Warning: this runtime returned only its newest 100 messages for --peek; older unread messages may be missing. Upgrade the runtime for exact peek results.' - ) - } - result = { - ...result, - result: { - ...result.result, - // Why: a pre-peek runtime builds `formatted` from all rows; drop it so output matches the filtered peek set. - ...(removedReadRows ? { formatted: undefined } : {}), - messages: unreadOnly, - count: unreadOnly.length - } - } - } - result = { - ...result, - result: prepareOrchestrationCheckOutput(result.result, terminal, flags.has('format')) - } - printResult(result, json, (r) => formatOrchestrationCheckText(r, terminal)) - const compatibilityAck = result.result.legacyCompatibility?.ackMessageIds - if (compatibilityAck && compatibilityAck.length > 0) { - await flushStdout() - await client.call('orchestration.check', { - terminal, - compatibilityAck: JSON.stringify({ - messageIds: compatibilityAck, - types: getOptionalStringFlag(flags, 'types') - ?.split(',') - .map((type) => type.trim()) - .filter(Boolean) - }) - }) - } - }, - - 'orchestration reply': async ({ flags, client, cwd, json }) => { - const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') - const result = await callMutation<{ message: { id: string } }>( - client, - flags, - 'orchestration.reply', - { - id: getRequiredStringFlag(flags, 'id'), - body: getRequiredStringFlag(flags, 'body'), - run: getOptionalStringFlag(flags, 'run'), - from - } - ) - printResult(result, json, (r) => `Replied ${r.message.id}`) - }, - - 'orchestration inbox': async ({ flags, client, json }) => { - const full = flags.has('full') - const result = await client.call<{ - messages: MessageSummary[] - count: number - }>('orchestration.inbox', { - limit: getOptionalPositiveIntegerFlag(flags, 'limit'), - terminal: getOptionalStringFlag(flags, 'terminal') - }) - printResult(result, json, (r) => { - if (r.count === 0) { - return 'No messages.' - } - // Why: default output omits body/payload for at-a-glance sweeps; --full prints them for auditing. - return r.messages - .map((m) => { - const head = `${m.id}${formatMessageReadOnlyTag(m)} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"` - if (!full) { - return head - } - const parts = [head] - if (m.body && m.body.length > 0) { - parts.push(m.body) - } - if (m.payload) { - parts.push(`[payload] ${m.payload}`) - } - return parts.join('\n') - }) - .join(full ? '\n\n' : '\n') - }) - }, - - 'orchestration task-create': async ({ flags, client, cwd, json }) => { - const callerTerminalHandle = await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await callMutation<{ task: { id: string; status: string } }>( - client, - flags, - 'orchestration.taskCreate', - { - spec: getRequiredStringFlag(flags, 'spec'), - taskTitle: getOptionalStringFlag(flags, 'task-title'), - displayName: getOptionalStringFlag(flags, 'display-name'), - deps: getOptionalStringFlag(flags, 'deps'), - parent: getOptionalStringFlag(flags, 'parent'), - run: getOptionalStringFlag(flags, 'run'), - callerTerminalHandle - } - ) - printResult(result, json, (r) => `Created ${r.task.id} [${r.task.status}]`) - }, - - 'orchestration task-list': async ({ flags, client, cwd, json }) => { - const brief = flags.has('brief') - const run = getOptionalStringFlag(flags, 'run') - const callerTerminalHandle = run - ? undefined - : await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await client.call<{ - tasks: { - id: string - spec: string - task_title?: string | null - display_name?: string | null - status: string - assignee_handle?: string | null - dispatch_id?: string | null - spec_truncated?: boolean - }[] - count: number - runId?: string - legacyReadOnly?: boolean - }>('orchestration.taskList', { - status: getOptionalStringFlag(flags, 'status'), - ready: flags.has('ready') ? true : undefined, - brief: brief ? true : undefined, - run, - callerTerminalHandle - }) - // Why: only older runtimes (no spec_truncated) skip server-side abbreviation and need this client-side fallback. - const needsClientAbbreviation = - brief && result.result.tasks.some((task) => task.spec_truncated === undefined) - const output = needsClientAbbreviation - ? { - ...result, - result: { ...result.result, tasks: abbreviateOrchestrationTasks(result.result.tasks) } - } - : result - printResult(output, json, (r) => { - if (r.count === 0) { - return r.legacyReadOnly ? 'No legacy tasks (read-only).' : 'No tasks.' - } - const tasks = r.tasks - .map((t) => { - const label = t.display_name ?? t.task_title ?? t.spec - const head = `${t.id} [${t.status}] ${label.slice(0, 60)}` - if (t.status === 'dispatched' && t.assignee_handle) { - return `${head} -> ${t.assignee_handle} (${t.dispatch_id ?? '?'})` - } - return head - }) - .join('\n') - return r.legacyReadOnly ? `Legacy Run ${r.runId} (read-only)\n${tasks}` : tasks - }) - }, - - 'orchestration task-update': async ({ flags, client, cwd, json }) => { - const status = getRequiredStringFlag(flags, 'status') - if (!TASK_STATUS_VALUES.includes(status as (typeof TASK_STATUS_VALUES)[number])) { - throw new RuntimeClientError( - 'invalid_argument', - `invalid status '${status}', expected one of: ${TASK_STATUS_VALUES.join(', ')}` - ) - } - const result = await callMutation<{ task: { id: string; status: string } }>( - client, - flags, - 'orchestration.taskUpdate', - { - id: getRequiredStringFlag(flags, 'id'), - status, - result: getOptionalStringFlag(flags, 'result'), - run: getOptionalStringFlag(flags, 'run'), - callerTerminalHandle: await resolveCoordinatorTerminalHandle(flags, cwd, client) - } - ) - printResult(result, json, (r) => `Updated ${r.task.id} -> ${r.task.status}`) - }, - - 'orchestration worker-start': async ({ flags, client, cwd, json }) => { - const model = getOptionalStringFlag(flags, 'model') - const effort = getOptionalStringFlag(flags, 'effort') - if (model || effort) { - const status = await client.call('status.get') - if ( - !status.result.capabilities?.includes( - ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY - ) - ) { - throw new RuntimeClientError( - 'incompatible_runtime', - 'The connected Orca runtime does not support worker model or effort overrides. Update or restart Orca and try again.' - ) - } - } - const result = await callMutation<{ - runId: string - taskId: string - dispatchId: string - state: string - failedStage?: string - lastError?: string - warning?: string - effects: unknown[] - residualResources: unknown[] - }>(client, flags, 'orchestration.workerStart', { - task: getRequiredStringFlag(flags, 'task'), - on: getOptionalStringFlag(flags, 'on'), - worktree: getOptionalStringFlag(flags, 'worktree'), - name: getOptionalStringFlag(flags, 'name'), - repo: getOptionalStringFlag(flags, 'repo'), - baseBranch: getOptionalStringFlag(flags, 'base-branch'), - displayName: getOptionalStringFlag(flags, 'display-name'), - comment: getOptionalStringFlag(flags, 'comment'), - setup: getOptionalStringFlag(flags, 'setup'), - agent: getOptionalStringFlag(flags, 'agent'), - model, - effort, - terminal: getOptionalStringFlag(flags, 'terminal'), - retryOf: getOptionalStringFlag(flags, 'retry-of'), - timeoutMs: getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms'), - run: getOptionalStringFlag(flags, 'run'), - from: await resolveCoordinatorTerminalHandle(flags, cwd, client), - devMode: isDevCliInvocation() - }) - if (result.result.state !== 'ready') { - process.exitCode = 1 - } - printResult(result, json, (worker) => { - const base = `Worker ${worker.dispatchId} [${worker.state}] for ${worker.taskId}` - if (worker.lastError) { - return `${base}\n${worker.failedStage ?? 'start'}: ${worker.lastError}` - } - return worker.warning ? `${base}\nWarning: ${worker.warning}` : base - }) - }, - - 'orchestration worker-show': async ({ flags, client, json }) => { - const result = await client.call<{ - dispatch: { id: string; task_id: string; status: string } - worker: { state: string; stage: string; agent_terminal_handle: string | null } - observation?: { agentWait?: { source: string; reason?: string } | null } - }>('orchestration.workerShow', { - dispatch: getRequiredStringFlag(flags, 'dispatch') - }) - printResult(result, json, (value) => { - const base = `${value.dispatch.id} task=${value.dispatch.task_id} [${value.worker.state}] stage=${value.worker.stage}` - // Why a whole line and not a suffix: this is the one state where the lane is healthy - // and still needs a person, so it must not read as another status token. An absent - // field is unknown, which must not print the same as an evaluated "none". - if (value.observation === undefined || !('agentWait' in value.observation)) { - return `${base}\nInteractive wait: unknown (not evaluated)` - } - const wait = value.observation.agentWait - return wait - ? `${base}\nWaiting on a human: ${wait.reason ?? 'interactive prompt'} (via ${wait.source})` - : `${base}\nInteractive wait: none` - }) - }, - - 'orchestration worker-read': async ({ flags, client, json }) => { - const cursorFlag = getOptionalStringFlag(flags, 'cursor') - const cursor = - cursorFlag !== undefined && /^\d+$/.test(cursorFlag) - ? Number.parseInt(cursorFlag, 10) - : cursorFlag - const source = getOptionalStringFlag(flags, 'source') - if (source && !['auto', 'transcript', 'terminal'].includes(source)) { - throw new RuntimeClientError( - 'invalid_argument', - '--source must be auto, transcript, or terminal' - ) - } - const result = await client.call( - 'orchestration.workerRead', - { - dispatch: getRequiredStringFlag(flags, 'dispatch'), - cursor, - limit: getOptionalPositiveIntegerFlag(flags, 'limit'), - source: source as OrchestrationWorkerReadSource | undefined - } - ) - printResult(result, json, formatWorkerRead) - }, - - 'orchestration worker-stop': async ({ flags, client, json }) => { - const result = await callMutation<{ - dispatchId: string - state: string - processAction: string - lastError?: string - warning?: string - }>(client, flags, 'orchestration.workerStop', { - dispatch: getRequiredStringFlag(flags, 'dispatch') - }) - if (result.result.state === 'stop_unknown') { - process.exitCode = 1 - } - printResult( - result, - json, - (value) => - `Worker ${value.dispatchId} [${value.state}] process=${value.processAction}${value.lastError ? `\n${value.lastError}` : ''}${value.warning ? `\nWarning: ${value.warning}` : ''}` - ) - }, - - 'orchestration worker-abandon': async ({ flags, client, json }) => { - const result = await callMutation<{ - dispatchId: string - state: string - warning: string - }>(client, flags, 'orchestration.workerAbandon', { - dispatch: getRequiredStringFlag(flags, 'dispatch') - }) - printResult( - result, - json, - (value) => `Worker ${value.dispatchId} [${value.state}]\nWarning: ${value.warning}` - ) - }, - - 'orchestration worker-release': async ({ flags, client, json }) => { - const result = await callMutation( - client, - flags, - 'orchestration.workerRelease', - { dispatch: getRequiredStringFlag(flags, 'dispatch') } - ) - // Why: only an unprovable close is a failure; retained/pending/already-released are settled answers. - if (result.result.state === 'release_unknown') { - process.exitCode = 1 - } - printResult(result, json, formatWorkerRelease) - }, - - 'orchestration worker-retain': async ({ flags, client, json }) => { - const result = await callMutation( - client, - flags, - 'orchestration.workerRetain', - { dispatch: getRequiredStringFlag(flags, 'dispatch') } - ) - if (result.result.state === 'release_unknown') { - process.exitCode = 1 - } - printResult(result, json, formatWorkerRelease) - }, - - 'orchestration worker-list': async ({ flags, client, json }) => { - const terminalState = getOptionalStringFlag(flags, 'terminal-state') - if ( - terminalState && - !WORKER_TERMINAL_LIST_STATES.includes( - terminalState as (typeof WORKER_TERMINAL_LIST_STATES)[number] - ) - ) { - throw new RuntimeClientError( - 'invalid_argument', - `invalid --terminal-state '${terminalState}', expected one of: ${WORKER_TERMINAL_LIST_STATES.join(', ')}` - ) - } - const result = await client.call<{ - workers: { - dispatchId: string - taskId: string - runId: string - workerState: string - dispatchStatus: string - agentTerminalHandle: string | null - terminalState: string | null - resource: unknown - }[] - counts: Record - }>('orchestration.workerList', { - run: getOptionalStringFlag(flags, 'run'), - terminalState - }) - printResult(result, json, (r) => { - if (r.workers.length === 0) { - return 'No workers found.' - } - const rows = r.workers - .map( - (w) => - `${w.dispatchId} task=${w.taskId} [${w.workerState}] terminal=${w.terminalState ?? 'none'}` - ) - .join('\n') - const counts = Object.entries(r.counts) - .map(([state, count]) => `${state}=${count}`) - .join(' ') - return counts ? `${rows}\nTerminals: ${counts}` : rows - }) - }, - - 'orchestration dispatch': async ({ flags, client, cwd, json }) => { - const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) - const dryRun = flags.has('dry-run') ? true : undefined - const returnPreamble = flags.has('return-preamble') ? true : undefined - // Why: --to is only required for non-dry-run; the RPC handler re-enforces. - const to = dryRun ? getOptionalStringFlag(flags, 'to') : getRequiredStringFlag(flags, 'to') - const result = await callMutation<{ - dispatch: { id: string; task_id: string; status: string } | null - injected?: boolean - dryRun?: boolean - preamble?: string - }>(client, flags, 'orchestration.dispatch', { - task: getRequiredStringFlag(flags, 'task'), - run: getOptionalStringFlag(flags, 'run'), - to, - from, - inject: flags.has('inject') ? true : undefined, - dryRun, - returnPreamble, - devMode: isDevCliInvocation() - }) - printResult(result, json, (r) => { - if (r.dryRun) { - return r.preamble ?? '' - } - const base = `Dispatched ${r.dispatch?.task_id} -> ${r.dispatch?.id} [${r.dispatch?.status}]` - return r.preamble ? `${base}\n\n--- Preamble ---\n${r.preamble}` : base - }) - }, - - 'orchestration ask': async ({ flags, client, cwd, json }) => { - const parsedTimeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') - const timeoutMs = clampOrchestrationAskTimeoutMs(parsedTimeoutMs) - const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') - const question = getOptionalStringFlag(flags, 'question') - const resume = getOptionalStringFlag(flags, 'resume') - if ((question ? 1 : 0) + (resume ? 1 : 0) !== 1) { - throw new RuntimeClientError( - 'invalid_argument', - 'Choose exactly one of --question or --resume.' - ) - } - if (resume && flags.has('options')) { - throw new RuntimeClientError( - 'invalid_argument', - '--options is only valid when creating a new question.' - ) - } - const result = await callMutation<{ - answer: string | null - messageId: string | null - threadId: string - timedOut: boolean - timeoutMs?: number - cancelled?: boolean - connectionLost?: boolean - answerMessageId?: string | null - legacyCompatibility?: LegacyCompatibilityResult - }>( - client, - flags, - 'orchestration.ask', - { - to: getOptionalStringFlag(flags, 'to'), - run: getOptionalStringFlag(flags, 'run'), - question, - resume, - options: getOptionalStringFlag(flags, 'options'), - timeoutMs: parsedTimeoutMs === undefined ? undefined : timeoutMs, - from, - compatibilityCliCommand: resolveCompatibilityCliCommand(), - compatibilityWindowsCommand: resolvePackagedWindowsCompatibilityCommand() - }, - // Why: extend past timeoutMs so the RPC transport's 60s default doesn't abort before the runtime's own timeout resolves. - { - timeoutMs: resolveOrchestrationAskClientTimeoutMs(parsedTimeoutMs), - orchestrationCapability: getOptionalStringFlag(flags, 'dispatch-capability') - } - ) - // Why: bypass printResult so --json emits a bare JSON object (no envelope) pipeable via `jq -r .answer`, unlike other verbs. - if (json) { - console.log(JSON.stringify(result.result)) - } else if (result.result.legacyCompatibility?.resumeRequired) { - console.log(`Question ${result.result.messageId} committed.`) - console.log(`Resume with: ${result.result.legacyCompatibility.resumeCommand}`) - } else if (result.result.answer !== null) { - console.log(result.result.answer) - } - if (result.result.legacyCompatibility?.resumeRequired) { - await flushStdout() - process.exitCode = 75 - return - } - const answerAck = result.result.legacyCompatibility?.answerAcknowledgement - if (answerAck && result.result.answer !== null) { - await flushStdout() - await client.call('orchestration.check', { - terminal: from, - compatibilityQuestionAck: JSON.stringify(answerAck) - }) - } - if (result.result.timedOut) { - if (!json) { - // Why: report the server's effective budget — it clamps large values, so the requested one would overstate the wait. - const waitedMs = result.result.timeoutMs ?? timeoutMs - console.error(`ask timeout after ${waitedMs}ms (thread ${result.result.threadId})`) - } - process.exitCode = 1 - } - if (result.result.cancelled) { - if (!json) { - console.error( - result.result.connectionLost - ? `ask connection closed (question ${result.result.messageId})` - : `ask cancelled (question ${result.result.messageId})` - ) - } - process.exitCode = 1 - } - }, - - 'orchestration dispatch-show': async ({ flags, client, cwd, json }) => { - const showPreamble = flags.has('preamble') ? true : undefined - // Why: resolve --from so the previewed preamble embeds a real coordinator handle like an actual dispatch. - const from = showPreamble - ? await resolveCoordinatorTerminalHandle(flags, cwd, client) - : undefined - const result = await client.call<{ - dispatch: { id: string; task_id: string; status: string } | null - preamble?: string - }>('orchestration.dispatchShow', { - task: getRequiredStringFlag(flags, 'task'), - preamble: showPreamble, - from, - devMode: isDevCliInvocation() - }) - printResult(result, json, (r) => { - if (r.preamble && showPreamble) { - return r.preamble - } - if (!r.dispatch) { - return 'No dispatch context found.' - } - return `${r.dispatch.id} task=${r.dispatch.task_id} [${r.dispatch.status}]` - }) - }, - - 'orchestration coordinator-start': async () => { - throw new RuntimeClientError( - 'orchestration_migration_required', - 'The legacy automatic coordinator command is retired. No effects were applied.', - orchestrationMigrationData('command_retired') - ) - }, - - 'orchestration coordinator-stop': async () => { - throw new RuntimeClientError( - 'orchestration_migration_required', - 'The legacy automatic coordinator command is retired. No effects were applied.', - orchestrationMigrationData('command_retired') - ) - }, - - 'orchestration gate-create': async ({ flags, client, cwd, json }) => { - const result = await callMutation<{ - gate: { id: string; task_id: string; status: string } - }>(client, flags, 'orchestration.gateCreate', { - task: getRequiredStringFlag(flags, 'task'), - question: getRequiredStringFlag(flags, 'question'), - options: getOptionalStringFlag(flags, 'options'), - // Why: gates are Run-scoped, so the coordinator handle is the caller identity the runtime authorizes against. - from: await resolveCoordinatorTerminalHandle(flags, cwd, client) - }) - printResult( - result, - json, - (r) => `Gate ${r.gate.id} created for task ${r.gate.task_id} [${r.gate.status}]` - ) - }, - - 'orchestration gate-resolve': async ({ flags, client, cwd, json }) => { - const result = await callMutation<{ - gate: { id: string; task_id: string; status: string; resolution: string } - }>(client, flags, 'orchestration.gateResolve', { - id: getRequiredStringFlag(flags, 'id'), - resolution: getRequiredStringFlag(flags, 'resolution'), - from: await resolveCoordinatorTerminalHandle(flags, cwd, client) - }) - printResult(result, json, (r) => `Gate ${r.gate.id} resolved: ${r.gate.resolution}`) - }, - - 'orchestration gate-list': async ({ flags, client, cwd, json }) => { - const run = getOptionalStringFlag(flags, 'run') - // Why: named runs remain inspectable without a pane; only implicit runs resolve identity. - const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await client.call<{ - gates: { id: string; task_id: string; question: string; status: string }[] - count: number - runId?: string - }>('orchestration.gateList', { - task: getOptionalStringFlag(flags, 'task'), - status: getOptionalStringFlag(flags, 'status'), - run, - from - }) - printResult(result, json, (r) => { - if (r.gates.length === 0) { - return 'No gates found.' - } - return r.gates - .map((g) => `${g.id} task=${g.task_id} [${g.status}] "${g.question}"`) - .join('\n') - }) - }, - - 'orchestration reset': async ({ flags, client, json }) => { - const scopeCount = [flags.has('all'), flags.has('tasks'), flags.has('messages')].filter( - Boolean - ).length - if (scopeCount !== 1) { - throw new RuntimeClientError( - 'invalid_argument', - 'Choose exactly one reset scope: --all, --tasks, or --messages.' - ) - } - const result = await callMutation<{ reset: string }>(client, flags, 'orchestration.reset', { - all: flags.has('all') ? true : undefined, - tasks: flags.has('tasks') ? true : undefined, - messages: flags.has('messages') ? true : undefined - }) - printResult(result, json, (r) => `Reset: ${r.reset}`) - } + ...ORCHESTRATION_RUN_HANDLERS, + ...ORCHESTRATION_SEND_HANDLER, + ...ORCHESTRATION_CHECK_HANDLER, + ...ORCHESTRATION_INBOX_HANDLERS, + ...ORCHESTRATION_TASK_HANDLERS, + ...ORCHESTRATION_WORKER_LAUNCH_HANDLER, + ...ORCHESTRATION_WORKER_OBSERVATION_HANDLERS, + ...ORCHESTRATION_WORKER_TERMINAL_HANDLERS, + ...ORCHESTRATION_DISPATCH_HANDLER, + ...ORCHESTRATION_QUESTION_HANDLER, + ...ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS, + ...ORCHESTRATION_GATE_HANDLERS, + ...ORCHESTRATION_RESET_HANDLER } diff --git a/src/cli/handlers/orchestration/check-keepalive.ts b/src/cli/handlers/orchestration/check-keepalive.ts new file mode 100644 index 00000000000..a87f6065428 --- /dev/null +++ b/src/cli/handlers/orchestration/check-keepalive.ts @@ -0,0 +1,32 @@ +const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000 + +// Why: test-only escape hatch so subprocess tests avoid the full 15 s window; bogus values fall back to the default. +function resolveKeepaliveIntervalMs(): number { + const raw = process.env.ORCA_KEEPALIVE_INTERVAL_MS ?? process.env.ORCA_HEARTBEAT_INTERVAL_MS + if (!raw) { + return DEFAULT_KEEPALIVE_INTERVAL_MS + } + const parsed = Number(raw) + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_KEEPALIVE_INTERVAL_MS + } + return parsed +} + +export function startCheckKeepalive(deadlineMs: number | undefined): () => void { + const startedAt = Date.now() + const interval = setInterval(() => { + const payload = { + _keepalive: true, + // Why: retain the old marker for scripts still filtering it while callers migrate to _keepalive. + _heartbeat: true, + elapsedMs: Date.now() - startedAt, + deadlineMs: deadlineMs ?? null + } + process.stderr.write(`${JSON.stringify(payload)}\n`) + }, resolveKeepaliveIntervalMs()) + if (typeof interval.unref === 'function') { + interval.unref() + } + return () => clearInterval(interval) +} diff --git a/src/cli/handlers/orchestration/dispatch-handlers.ts b/src/cli/handlers/orchestration/dispatch-handlers.ts new file mode 100644 index 00000000000..afe79ab8e2f --- /dev/null +++ b/src/cli/handlers/orchestration/dispatch-handlers.ts @@ -0,0 +1,84 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { orchestrationMigrationData } from '../../../shared/orchestration-rpc-contract' +import { callOrchestrationMutation } from './mutation-request' +import { isDevCliInvocation } from './runtime-compatibility' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_DISPATCH_HANDLER: Record = { + 'orchestration dispatch': async ({ flags, client, cwd, json }) => { + const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const dryRun = flags.has('dry-run') ? true : undefined + const returnPreamble = flags.has('return-preamble') ? true : undefined + // Why: --to is only required for non-dry-run; the RPC handler re-enforces. + const to = dryRun ? getOptionalStringFlag(flags, 'to') : getRequiredStringFlag(flags, 'to') + const result = await callOrchestrationMutation<{ + dispatch: { id: string; task_id: string; status: string } | null + injected?: boolean + dryRun?: boolean + preamble?: string + }>(client, flags, 'orchestration.dispatch', { + task: getRequiredStringFlag(flags, 'task'), + run: getOptionalStringFlag(flags, 'run'), + to, + from, + inject: flags.has('inject') ? true : undefined, + dryRun, + returnPreamble, + devMode: isDevCliInvocation() + }) + printResult(result, json, (value) => { + if (value.dryRun) { + return value.preamble ?? '' + } + const base = `Dispatched ${value.dispatch?.task_id} -> ${value.dispatch?.id} [${value.dispatch?.status}]` + return value.preamble ? `${base}\n\n--- Preamble ---\n${value.preamble}` : base + }) + } +} + +export const ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS: Record = { + 'orchestration dispatch-show': async ({ flags, client, cwd, json }) => { + const showPreamble = flags.has('preamble') ? true : undefined + // Why: a preview must embed the same real coordinator handle as an actual dispatch. + const from = showPreamble + ? await resolveCoordinatorTerminalHandle(flags, cwd, client) + : undefined + const result = await client.call<{ + dispatch: { id: string; task_id: string; status: string } | null + preamble?: string + }>('orchestration.dispatchShow', { + task: getRequiredStringFlag(flags, 'task'), + preamble: showPreamble, + from, + devMode: isDevCliInvocation() + }) + printResult(result, json, (value) => { + if (value.preamble && showPreamble) { + return value.preamble + } + if (!value.dispatch) { + return 'No dispatch context found.' + } + return `${value.dispatch.id} task=${value.dispatch.task_id} [${value.dispatch.status}]` + }) + }, + + 'orchestration coordinator-start': async () => { + throw new RuntimeClientError( + 'orchestration_migration_required', + 'The legacy automatic coordinator command is retired. No effects were applied.', + orchestrationMigrationData('command_retired') + ) + }, + + 'orchestration coordinator-stop': async () => { + throw new RuntimeClientError( + 'orchestration_migration_required', + 'The legacy automatic coordinator command is retired. No effects were applied.', + orchestrationMigrationData('command_retired') + ) + } +} diff --git a/src/cli/handlers/orchestration/gate-handlers.ts b/src/cli/handlers/orchestration/gate-handlers.ts new file mode 100644 index 00000000000..27d3cc1e3b7 --- /dev/null +++ b/src/cli/handlers/orchestration/gate-handlers.ts @@ -0,0 +1,60 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { callOrchestrationMutation } from './mutation-request' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_GATE_HANDLERS: Record = { + 'orchestration gate-create': async ({ flags, client, cwd, json }) => { + const result = await callOrchestrationMutation<{ + gate: { id: string; task_id: string; status: string } + }>(client, flags, 'orchestration.gateCreate', { + task: getRequiredStringFlag(flags, 'task'), + question: getRequiredStringFlag(flags, 'question'), + options: getOptionalStringFlag(flags, 'options'), + // Why: gates are Run-scoped, so the coordinator handle is the authorized caller identity. + from: await resolveCoordinatorTerminalHandle(flags, cwd, client) + }) + printResult( + result, + json, + (value) => + `Gate ${value.gate.id} created for task ${value.gate.task_id} [${value.gate.status}]` + ) + }, + + 'orchestration gate-resolve': async ({ flags, client, cwd, json }) => { + const result = await callOrchestrationMutation<{ + gate: { id: string; task_id: string; status: string; resolution: string } + }>(client, flags, 'orchestration.gateResolve', { + id: getRequiredStringFlag(flags, 'id'), + resolution: getRequiredStringFlag(flags, 'resolution'), + from: await resolveCoordinatorTerminalHandle(flags, cwd, client) + }) + printResult(result, json, (value) => `Gate ${value.gate.id} resolved: ${value.gate.resolution}`) + }, + + 'orchestration gate-list': async ({ flags, client, cwd, json }) => { + const run = getOptionalStringFlag(flags, 'run') + // Why: named runs remain inspectable without a pane; only implicit runs resolve identity. + const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await client.call<{ + gates: { id: string; task_id: string; question: string; status: string }[] + count: number + runId?: string + }>('orchestration.gateList', { + task: getOptionalStringFlag(flags, 'task'), + status: getOptionalStringFlag(flags, 'status'), + run, + from + }) + printResult(result, json, (value) => { + if (value.gates.length === 0) { + return 'No gates found.' + } + return value.gates + .map((gate) => `${gate.id} task=${gate.task_id} [${gate.status}] "${gate.question}"`) + .join('\n') + }) + } +} diff --git a/src/cli/handlers/orchestration/message-check-handler.ts b/src/cli/handlers/orchestration/message-check-handler.ts new file mode 100644 index 00000000000..f2af827ee04 --- /dev/null +++ b/src/cli/handlers/orchestration/message-check-handler.ts @@ -0,0 +1,120 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import type { RuntimeRpcSuccess } from '../../runtime-client' +import { + formatOrchestrationCheckText, + prepareOrchestrationCheckOutput, + type LegacyCompatibilityResult, + type OrchestrationMessageSummary as MessageSummary +} from '../../../shared/orchestration-check-output' +import { startCheckKeepalive } from './check-keepalive' +import { callOrchestrationMutation } from './mutation-request' +import { getOptionalPositiveIntegerValueFlag } from './numeric-flags' +import { flushOrchestrationStdout, resolveCompatibilityCliCommand } from './runtime-compatibility' +import { resolveOrchestrationTerminalHandle } from './terminal-identity' + +type CheckResult = { + messages: MessageSummary[] + count: number + formatted?: string + deliveryId?: string | null + runId?: string + timedOut?: boolean + cancelled?: boolean + connectionLost?: boolean + legacyCompatibility?: LegacyCompatibilityResult +} + +export const ORCHESTRATION_CHECK_HANDLER: Record = { + 'orchestration check': async ({ flags, client, cwd, json }) => { + const wait = flags.has('wait') + const peek = flags.has('peek') + // Why: older runtimes strip unknown peek and run --unread --peek as destructive mark-read. + if ([flags.has('unread'), peek, flags.has('all')].filter(Boolean).length > 1) { + throw new RuntimeClientError( + 'invalid_argument', + 'Choose at most one message read mode: --unread, --peek, or --all.' + ) + } + const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') + const explicitTerminal = getOptionalStringFlag(flags, 'terminal') + const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') + const stopKeepalive = wait ? startCheckKeepalive(timeoutMs) : null + let result: Awaited>> + try { + result = await callOrchestrationMutation(client, flags, 'orchestration.check', { + terminal, + terminalPaneKey: explicitTerminal ? undefined : process.env.ORCA_PANE_KEY || undefined, + // Why: old runtimes degrade peek to non-consuming all mode instead of destructive mark-read. + unread: flags.has('unread') ? true : peek ? false : undefined, + peek: peek ? true : undefined, + all: flags.has('all') ? true : undefined, + types: getOptionalStringFlag(flags, 'types'), + format: flags.has('format') ? true : undefined, + inject: flags.has('inject') ? true : undefined, + compatibilityCliCommand: resolveCompatibilityCliCommand(), + run: getOptionalStringFlag(flags, 'run'), + ack: getOptionalStringFlag(flags, 'ack'), + wait: wait ? true : undefined, + timeoutMs + }) + } finally { + stopKeepalive?.() + } + if (peek) { + result = filterLegacyPeekResult(result, wait) + } + result = { + ...result, + result: prepareOrchestrationCheckOutput(result.result, terminal, flags.has('format')) + } + printResult(result, json, (value) => formatOrchestrationCheckText(value, terminal)) + const compatibilityAck = result.result.legacyCompatibility?.ackMessageIds + if (compatibilityAck && compatibilityAck.length > 0) { + await flushOrchestrationStdout() + await client.call('orchestration.check', { + terminal, + compatibilityAck: JSON.stringify({ + messageIds: compatibilityAck, + types: getOptionalStringFlag(flags, 'types') + ?.split(',') + .map((type) => type.trim()) + .filter(Boolean) + }) + }) + } + } +} + +function filterLegacyPeekResult( + result: RuntimeRpcSuccess, + wait: boolean +): RuntimeRpcSuccess { + const rawRowCount = result.result.messages.length + const unreadOnly = result.result.messages.filter((message) => message.read !== 1) + const removedReadRows = unreadOnly.length !== rawRowCount + // Why: read rows prove a pre-peek runtime cannot honor wait, so fail instead of returning early. + if (wait && removedReadRows && unreadOnly.length === 0) { + throw new RuntimeClientError( + 'peek_wait_unsupported', + 'The connected runtime does not support --peek with --wait; upgrade the runtime or use --wait without --peek.' + ) + } + if (removedReadRows && rawRowCount >= 100) { + console.error( + 'Warning: this runtime returned only its newest 100 messages for --peek; older unread messages may be missing. Upgrade the runtime for exact peek results.' + ) + } + return { + ...result, + result: { + ...result.result, + // Why: a pre-peek runtime formats all rows, which no longer matches the filtered set. + ...(removedReadRows ? { formatted: undefined } : {}), + messages: unreadOnly, + count: unreadOnly.length + } + } +} diff --git a/src/cli/handlers/orchestration/message-inbox-handlers.ts b/src/cli/handlers/orchestration/message-inbox-handlers.ts new file mode 100644 index 00000000000..c15c4edfafa --- /dev/null +++ b/src/cli/handlers/orchestration/message-inbox-handlers.ts @@ -0,0 +1,64 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../../flags' +import { + formatMessageReadOnlyTag, + type OrchestrationMessageSummary as MessageSummary +} from '../../../shared/orchestration-check-output' +import { callOrchestrationMutation } from './mutation-request' +import { resolveOrchestrationTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_INBOX_HANDLERS: Record = { + 'orchestration reply': async ({ flags, client, cwd, json }) => { + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + const result = await callOrchestrationMutation<{ message: { id: string } }>( + client, + flags, + 'orchestration.reply', + { + id: getRequiredStringFlag(flags, 'id'), + body: getRequiredStringFlag(flags, 'body'), + run: getOptionalStringFlag(flags, 'run'), + from + } + ) + printResult(result, json, (value) => `Replied ${value.message.id}`) + }, + + 'orchestration inbox': async ({ flags, client, json }) => { + const full = flags.has('full') + const result = await client.call<{ + messages: MessageSummary[] + count: number + }>('orchestration.inbox', { + limit: getOptionalPositiveIntegerFlag(flags, 'limit'), + terminal: getOptionalStringFlag(flags, 'terminal') + }) + printResult(result, json, (value) => { + if (value.count === 0) { + return 'No messages.' + } + // Why: default output omits body/payload for at-a-glance sweeps; --full prints them for auditing. + return value.messages + .map((message) => { + const head = `${message.id}${formatMessageReadOnlyTag(message)} ${message.from_handle} -> ${message.to_handle ?? '?'}: "${message.subject}"` + if (!full) { + return head + } + const parts = [head] + if (message.body && message.body.length > 0) { + parts.push(message.body) + } + if (message.payload) { + parts.push(`[payload] ${message.payload}`) + } + return parts.join('\n') + }) + .join(full ? '\n\n' : '\n') + }) + } +} diff --git a/src/cli/handlers/orchestration/message-payload.ts b/src/cli/handlers/orchestration/message-payload.ts new file mode 100644 index 00000000000..3c1e36b2ed8 --- /dev/null +++ b/src/cli/handlers/orchestration/message-payload.ts @@ -0,0 +1,60 @@ +import { getOptionalStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' + +export function getOptionalStructuredMessagePayload( + flags: Map +): string | undefined { + const rawPayload = getOptionalStringFlag(flags, 'payload') + const taskId = getOptionalStringFlag(flags, 'task-id') + const dispatchId = getOptionalStringFlag(flags, 'dispatch-id') + const outcome = getOptionalStringFlag(flags, 'outcome') + const filesModified = getOptionalStringFlag(flags, 'files-modified') + const reportPath = getOptionalStringFlag(flags, 'report-path') + const phase = getOptionalStringFlag(flags, 'phase') + const hasStructuredPayload = + taskId !== undefined || + dispatchId !== undefined || + outcome !== undefined || + filesModified !== undefined || + reportPath !== undefined || + phase !== undefined + if (!hasStructuredPayload) { + return rawPayload + } + if (rawPayload !== undefined) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --payload or structured payload flags, not both.' + ) + } + // Why: raw JSON args are fragile in Windows PowerShell; these flags avoid shell-specific quoting. + const payload: Record = {} + if (taskId) { + payload.taskId = taskId + } + if (dispatchId) { + payload.dispatchId = dispatchId + } + if (outcome) { + if (outcome !== 'succeeded' && outcome !== 'failed') { + throw new RuntimeClientError( + 'invalid_argument', + 'Invalid --outcome. Expected succeeded or failed.' + ) + } + payload.outcome = outcome + } + if (filesModified) { + payload.filesModified = filesModified + .split(',') + .map((file) => file.trim()) + .filter(Boolean) + } + if (reportPath) { + payload.reportPath = reportPath + } + if (phase) { + payload.phase = phase + } + return JSON.stringify(payload) +} diff --git a/src/cli/handlers/orchestration/message-send-handler.ts b/src/cli/handlers/orchestration/message-send-handler.ts new file mode 100644 index 00000000000..088d1361e69 --- /dev/null +++ b/src/cli/handlers/orchestration/message-send-handler.ts @@ -0,0 +1,137 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { requireWorkerDoneSettlement } from '../orchestration-worker-settlement' +import { getOptionalStructuredMessagePayload } from './message-payload' +import { callOrchestrationMutation } from './mutation-request' +import { isDevCliInvocation } from './runtime-compatibility' +import { + resolveOrchestrationTerminalHandle, + throwNoActiveSenderTerminal +} from './terminal-identity' + +type LifecycleSendResult = + | { + action: 'completed' | 'failed' + authority?: 'run_home' | 'worker_server_legacy' + } + | { action: 'settled'; outcome: 'succeeded' | 'failed'; duplicate?: boolean } + | { action: 'rejected'; code: string; reason: string } + +type SendRecipientWarning = { + code: string + recipient: string + message: string +} + +type OrchestrationSendResult = + | { + message: { id: string; run_id?: string } + lifecycle?: LifecycleSendResult + warnings?: SendRecipientWarning[] + } + | { + messages: { id: string }[] + recipients: number + warnings?: SendRecipientWarning[] + } + | { + relay: { + messageId: string + sequence: number + dispatchId: string + destination?: 'run_home' | 'worker' + accepted: true + } + lifecycle?: LifecycleSendResult + warnings?: SendRecipientWarning[] + } + +function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { + return `${type} messages belong to one exact Dispatch and cannot target a group address.` +} + +function rejectLifecycleGroupRecipient(type: string | undefined, to: string): void { + if ((type === 'worker_done' || type === 'heartbeat') && to.startsWith('@')) { + throw new RuntimeClientError('invalid_argument', getLifecycleGroupRecipientError(type)) + } +} + +export const ORCHESTRATION_SEND_HANDLER: Record = { + 'orchestration send': async ({ flags, client, cwd, json }) => { + const to = getOptionalStringFlag(flags, 'to') + const type = getOptionalStringFlag(flags, 'type') + if (to) { + rejectLifecycleGroupRecipient(type, to) + } + const outcome = getOptionalStringFlag(flags, 'outcome') + if (type !== 'worker_done' && outcome !== undefined) { + throw new RuntimeClientError( + 'invalid_argument', + '--outcome is only valid with --type worker_done.' + ) + } + if ( + (type === 'worker_done' || type === 'heartbeat') && + !getOptionalStringFlag(flags, 'from') && + !process.env.ORCA_TERMINAL_HANDLE + ) { + // Why: focus isn't lifecycle authority — an identity-less subprocess must fail closed rather than guess the worker. + throwNoActiveSenderTerminal() + } + + // Why: lifecycle senders preserve ORCA_TERMINAL_HANDLE across restarts for older runtimes. + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + const sendParams = { + from, + to, + run: getOptionalStringFlag(flags, 'run'), + subject: getRequiredStringFlag(flags, 'subject'), + body: getOptionalStringFlag(flags, 'body'), + type, + priority: getOptionalStringFlag(flags, 'priority'), + threadId: getOptionalStringFlag(flags, 'thread-id'), + payload: getOptionalStructuredMessagePayload(flags), + // Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it. + senderPaneKey: process.env.ORCA_PANE_KEY || undefined, + waitForLifecycleSettlement: type === 'worker_done' ? true : undefined, + devMode: isDevCliInvocation() + } + const dispatchCapability = getOptionalStringFlag(flags, 'dispatch-capability') + const result = await callOrchestrationMutation( + client, + flags, + 'orchestration.send', + sendParams, + dispatchCapability ? { orchestrationCapability: dispatchCapability } : undefined + ) + await requireWorkerDoneSettlement(client, type, sendParams.payload, result.result) + if ('lifecycle' in result.result && result.result.lifecycle?.action === 'rejected') { + throw new RuntimeClientError(result.result.lifecycle.code, result.result.lifecycle.reason) + } + printResult(result, json, (value) => { + const warnings = 'warnings' in value ? (value.warnings ?? []) : [] + const withWarnings = (line: string): string => + warnings.length > 0 + ? [line, ...warnings.map((warning) => `Warning: ${warning.message}`)].join('\n') + : line + if ('message' in value) { + return withWarnings(`Sent ${value.message.id}`) + } + if ('relay' in value) { + if (value.relay.destination === 'worker') { + return withWarnings( + `Queued ${value.relay.messageId} for worker Dispatch ${value.relay.dispatchId}` + ) + } + return withWarnings( + `Queued ${value.relay.messageId} for Run home (Dispatch ${value.relay.dispatchId})` + ) + } + return withWarnings( + `Sent ${value.messages.length} messages to ${value.recipients} recipients` + ) + }) + } +} diff --git a/src/cli/handlers/orchestration/mutation-request.ts b/src/cli/handlers/orchestration/mutation-request.ts new file mode 100644 index 00000000000..98b44e4046c --- /dev/null +++ b/src/cli/handlers/orchestration/mutation-request.ts @@ -0,0 +1,21 @@ +import type { RuntimeClient } from '../../runtime-client' +import { getOptionalStringFlag } from '../../flags' +import { orchestrationMutationRecoveryError } from '../../orchestration-mutation-recovery' + +export function callOrchestrationMutation( + client: RuntimeClient, + flags: Map, + method: string, + params: unknown, + options?: { timeoutMs?: number; orchestrationCapability?: string } +) { + const requestId = getOptionalStringFlag(flags, 'retry-request') + const result = requestId + ? client.call(method, params, { ...options, orchestrationRequestId: requestId }) + : options + ? client.call(method, params, options) + : client.call(method, params) + return result.catch((error) => { + throw orchestrationMutationRecoveryError(error) + }) +} diff --git a/src/cli/handlers/orchestration/numeric-flags.ts b/src/cli/handlers/orchestration/numeric-flags.ts new file mode 100644 index 00000000000..9d3a142150c --- /dev/null +++ b/src/cli/handlers/orchestration/numeric-flags.ts @@ -0,0 +1,23 @@ +import { parsePositiveSafeIntegerText } from '../../../shared/timer-delay' +import { RuntimeClientError } from '../../runtime-client' + +export function getOptionalPositiveIntegerValueFlag( + flags: Map, + name: string +): number | undefined { + if (!flags.has(name)) { + return undefined + } + const raw = flags.get(name) + if (typeof raw !== 'string' || raw.length === 0) { + throw new RuntimeClientError('invalid_argument', `Missing value for --${name}.`) + } + const value = parsePositiveSafeIntegerText(raw) + if (value === null) { + throw new RuntimeClientError( + 'invalid_argument', + `Invalid positive safe integer for --${name}: ${raw}` + ) + } + return value +} diff --git a/src/cli/handlers/orchestration/question-handler.ts b/src/cli/handlers/orchestration/question-handler.ts new file mode 100644 index 00000000000..1801f7f9b3c --- /dev/null +++ b/src/cli/handlers/orchestration/question-handler.ts @@ -0,0 +1,109 @@ +import type { CommandHandler } from '../../dispatch' +import { getOptionalStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { + clampOrchestrationAskTimeoutMs, + resolveOrchestrationAskClientTimeoutMs +} from '../../../shared/orchestration-ask-timeout' +import type { LegacyCompatibilityResult } from '../../../shared/orchestration-check-output' +import { callOrchestrationMutation } from './mutation-request' +import { getOptionalPositiveIntegerValueFlag } from './numeric-flags' +import { + flushOrchestrationStdout, + resolveCompatibilityCliCommand, + resolvePackagedWindowsCompatibilityCommand +} from './runtime-compatibility' +import { resolveOrchestrationTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_QUESTION_HANDLER: Record = { + 'orchestration ask': async ({ flags, client, cwd, json }) => { + const parsedTimeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') + const timeoutMs = clampOrchestrationAskTimeoutMs(parsedTimeoutMs) + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + const question = getOptionalStringFlag(flags, 'question') + const resume = getOptionalStringFlag(flags, 'resume') + if ((question ? 1 : 0) + (resume ? 1 : 0) !== 1) { + throw new RuntimeClientError( + 'invalid_argument', + 'Choose exactly one of --question or --resume.' + ) + } + if (resume && flags.has('options')) { + throw new RuntimeClientError( + 'invalid_argument', + '--options is only valid when creating a new question.' + ) + } + const result = await callOrchestrationMutation<{ + answer: string | null + messageId: string | null + threadId: string + timedOut: boolean + timeoutMs?: number + cancelled?: boolean + connectionLost?: boolean + answerMessageId?: string | null + legacyCompatibility?: LegacyCompatibilityResult + }>( + client, + flags, + 'orchestration.ask', + { + to: getOptionalStringFlag(flags, 'to'), + run: getOptionalStringFlag(flags, 'run'), + question, + resume, + options: getOptionalStringFlag(flags, 'options'), + timeoutMs: parsedTimeoutMs === undefined ? undefined : timeoutMs, + from, + compatibilityCliCommand: resolveCompatibilityCliCommand(), + compatibilityWindowsCommand: resolvePackagedWindowsCompatibilityCommand() + }, + // Why: extend past timeoutMs so transport does not abort before the runtime resolves its timeout. + { + timeoutMs: resolveOrchestrationAskClientTimeoutMs(parsedTimeoutMs), + orchestrationCapability: getOptionalStringFlag(flags, 'dispatch-capability') + } + ) + // Why: ask JSON is intentionally a bare object for `jq -r .answer`, unlike other verbs. + if (json) { + console.log(JSON.stringify(result.result)) + } else if (result.result.legacyCompatibility?.resumeRequired) { + console.log(`Question ${result.result.messageId} committed.`) + console.log(`Resume with: ${result.result.legacyCompatibility.resumeCommand}`) + } else if (result.result.answer !== null) { + console.log(result.result.answer) + } + if (result.result.legacyCompatibility?.resumeRequired) { + await flushOrchestrationStdout() + process.exitCode = 75 + return + } + const answerAck = result.result.legacyCompatibility?.answerAcknowledgement + if (answerAck && result.result.answer !== null) { + await flushOrchestrationStdout() + await client.call('orchestration.check', { + terminal: from, + compatibilityQuestionAck: JSON.stringify(answerAck) + }) + } + if (result.result.timedOut) { + if (!json) { + // Why: report the server's clamped effective budget rather than overstating the wait. + const waitedMs = result.result.timeoutMs ?? timeoutMs + console.error(`ask timeout after ${waitedMs}ms (thread ${result.result.threadId})`) + } + process.exitCode = 1 + } + if (result.result.cancelled) { + if (!json) { + console.error( + result.result.connectionLost + ? `ask connection closed (question ${result.result.messageId})` + : `ask cancelled (question ${result.result.messageId})` + ) + } + process.exitCode = 1 + } + } +} diff --git a/src/cli/handlers/orchestration/reset-handler.ts b/src/cli/handlers/orchestration/reset-handler.ts new file mode 100644 index 00000000000..536cc9e8933 --- /dev/null +++ b/src/cli/handlers/orchestration/reset-handler.ts @@ -0,0 +1,29 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { RuntimeClientError } from '../../runtime-client' +import { callOrchestrationMutation } from './mutation-request' + +export const ORCHESTRATION_RESET_HANDLER: Record = { + 'orchestration reset': async ({ flags, client, json }) => { + const scopeCount = [flags.has('all'), flags.has('tasks'), flags.has('messages')].filter( + Boolean + ).length + if (scopeCount !== 1) { + throw new RuntimeClientError( + 'invalid_argument', + 'Choose exactly one reset scope: --all, --tasks, or --messages.' + ) + } + const result = await callOrchestrationMutation<{ reset: string }>( + client, + flags, + 'orchestration.reset', + { + all: flags.has('all') ? true : undefined, + tasks: flags.has('tasks') ? true : undefined, + messages: flags.has('messages') ? true : undefined + } + ) + printResult(result, json, (value) => `Reset: ${value.reset}`) + } +} diff --git a/src/cli/handlers/orchestration/run-handlers.ts b/src/cli/handlers/orchestration/run-handlers.ts new file mode 100644 index 00000000000..7c77913df15 --- /dev/null +++ b/src/cli/handlers/orchestration/run-handlers.ts @@ -0,0 +1,85 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../../flags' +import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../shared/orchestration-run-pagination' +import { callOrchestrationMutation } from './mutation-request' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_RUN_HANDLERS: Record = { + 'orchestration run-create': async ({ flags, client, cwd, json }) => { + const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await callOrchestrationMutation<{ + run: { id: string; objective: string; consumer_generation: number } + }>(client, flags, 'orchestration.runCreate', { + objective: getRequiredStringFlag(flags, 'objective'), + from + }) + printResult(result, json, (r) => `Run ${r.run.id} created and bound: ${r.run.objective}`) + }, + + 'orchestration run-use': async ({ flags, client, cwd, json }) => { + const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await callOrchestrationMutation<{ + run: { id: string; objective: string; consumer_generation: number } + }>(client, flags, 'orchestration.runUse', { + id: getRequiredStringFlag(flags, 'id'), + from, + ...(flags.has('takeover-legacy') ? { takeoverLegacy: true } : {}) + }) + printResult(result, json, (r) => `Using Run ${r.run.id}: ${r.run.objective}`) + }, + + 'orchestration run-current': async ({ flags, client, cwd, json }) => { + const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await client.call<{ + run: { id: string; objective: string } | null + }>('orchestration.runCurrent', { from }) + printResult(result, json, (r) => + r.run ? `${r.run.id} ${r.run.objective}` : 'No Run is bound to this terminal.' + ) + }, + + 'orchestration run-list': async ({ flags, client, json }) => { + const result = await client.call<{ + runs: { id: string; objective: string; legacy: number }[] + nextCursor: string | null + }>('orchestration.runList', { + limit: getOptionalPositiveIntegerFlag(flags, 'limit') ?? ORCHESTRATION_RUN_PAGE_LIMIT, + cursor: getOptionalStringFlag(flags, 'cursor') + }) + printResult(result, json, (r) => { + const rows = + r.runs.length === 0 + ? 'No Runs found.' + : r.runs + .map( + (run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}` + ) + .join('\n') + return r.nextCursor ? `${rows}\nMore Runs: --cursor ${r.nextCursor}` : rows + }) + }, + + 'orchestration run-show': async ({ flags, client, json }) => { + const result = await client.call<{ + run: { + id: string + objective: string + consumer_generation: number + legacy: number + created_at: string + } + }>('orchestration.runShow', { id: getRequiredStringFlag(flags, 'id') }) + printResult( + result, + json, + (r) => + `${r.run.id}${r.run.legacy ? ' [legacy, inspect only]' : ''} ${r.run.objective}\n` + + `consumer generation ${r.run.consumer_generation}; created ${r.run.created_at}` + ) + } +} diff --git a/src/cli/handlers/orchestration/runtime-compatibility.ts b/src/cli/handlers/orchestration/runtime-compatibility.ts new file mode 100644 index 00000000000..e4076534daa --- /dev/null +++ b/src/cli/handlers/orchestration/runtime-compatibility.ts @@ -0,0 +1,42 @@ +import { RuntimeClientError } from '../../runtime-client' + +export function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' { + const configured = process.env.ORCA_CLI_COMMAND + if (configured === 'orca' || configured === 'orca-ide' || configured === 'orca-dev') { + return configured + } + return process.platform === 'linux' ? 'orca-ide' : 'orca' +} + +export function resolvePackagedWindowsCompatibilityCommand(): 'orca' | 'orca-ide' | undefined { + if (process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER !== '1') { + return undefined + } + const command = process.env.ORCA_CLI_COMMAND + if (command === 'orca' || command === 'orca-ide') { + return command + } + throw new RuntimeClientError( + 'invalid_argument', + 'The packaged Orca launcher did not provide a valid resume command. No question was created.' + ) +} + +export async function flushOrchestrationStdout(): Promise { + await new Promise((resolve, reject) => { + process.stdout.write('', (error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) +} + +export function isDevCliInvocation(): boolean { + return ( + process.env.ORCA_DEV_CLI_INVOCATION === '1' || + (process.env.ORCA_USER_DATA_PATH?.includes('orca-dev') ?? false) + ) +} diff --git a/src/cli/handlers/orchestration/task-handlers.ts b/src/cli/handlers/orchestration/task-handlers.ts new file mode 100644 index 00000000000..c4c943261e8 --- /dev/null +++ b/src/cli/handlers/orchestration/task-handlers.ts @@ -0,0 +1,114 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { abbreviateOrchestrationTasks } from '../../../shared/orchestration-task-summary' +import { callOrchestrationMutation } from './mutation-request' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' + +const TASK_STATUS_VALUES = [ + 'pending', + 'ready', + 'dispatched', + 'completed', + 'failed', + 'blocked' +] as const + +export const ORCHESTRATION_TASK_HANDLERS: Record = { + 'orchestration task-create': async ({ flags, client, cwd, json }) => { + const callerTerminalHandle = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await callOrchestrationMutation<{ task: { id: string; status: string } }>( + client, + flags, + 'orchestration.taskCreate', + { + spec: getRequiredStringFlag(flags, 'spec'), + taskTitle: getOptionalStringFlag(flags, 'task-title'), + displayName: getOptionalStringFlag(flags, 'display-name'), + deps: getOptionalStringFlag(flags, 'deps'), + parent: getOptionalStringFlag(flags, 'parent'), + run: getOptionalStringFlag(flags, 'run'), + callerTerminalHandle + } + ) + printResult(result, json, (r) => `Created ${r.task.id} [${r.task.status}]`) + }, + + 'orchestration task-list': async ({ flags, client, cwd, json }) => { + const brief = flags.has('brief') + const run = getOptionalStringFlag(flags, 'run') + const callerTerminalHandle = run + ? undefined + : await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await client.call<{ + tasks: { + id: string + spec: string + task_title?: string | null + display_name?: string | null + status: string + assignee_handle?: string | null + dispatch_id?: string | null + spec_truncated?: boolean + }[] + count: number + runId?: string + legacyReadOnly?: boolean + }>('orchestration.taskList', { + status: getOptionalStringFlag(flags, 'status'), + ready: flags.has('ready') ? true : undefined, + brief: brief ? true : undefined, + run, + callerTerminalHandle + }) + // Why: only older runtimes (no spec_truncated) skip server-side abbreviation and need this client-side fallback. + const needsClientAbbreviation = + brief && result.result.tasks.some((task) => task.spec_truncated === undefined) + const output = needsClientAbbreviation + ? { + ...result, + result: { ...result.result, tasks: abbreviateOrchestrationTasks(result.result.tasks) } + } + : result + printResult(output, json, (r) => { + if (r.count === 0) { + return r.legacyReadOnly ? 'No legacy tasks (read-only).' : 'No tasks.' + } + const tasks = r.tasks + .map((task) => { + const label = task.display_name ?? task.task_title ?? task.spec + const head = `${task.id} [${task.status}] ${label.slice(0, 60)}` + if (task.status === 'dispatched' && task.assignee_handle) { + return `${head} -> ${task.assignee_handle} (${task.dispatch_id ?? '?'})` + } + return head + }) + .join('\n') + return r.legacyReadOnly ? `Legacy Run ${r.runId} (read-only)\n${tasks}` : tasks + }) + }, + + 'orchestration task-update': async ({ flags, client, cwd, json }) => { + const status = getRequiredStringFlag(flags, 'status') + if (!TASK_STATUS_VALUES.includes(status as (typeof TASK_STATUS_VALUES)[number])) { + throw new RuntimeClientError( + 'invalid_argument', + `invalid status '${status}', expected one of: ${TASK_STATUS_VALUES.join(', ')}` + ) + } + const result = await callOrchestrationMutation<{ task: { id: string; status: string } }>( + client, + flags, + 'orchestration.taskUpdate', + { + id: getRequiredStringFlag(flags, 'id'), + status, + result: getOptionalStringFlag(flags, 'result'), + run: getOptionalStringFlag(flags, 'run'), + callerTerminalHandle: await resolveCoordinatorTerminalHandle(flags, cwd, client) + } + ) + printResult(result, json, (r) => `Updated ${r.task.id} -> ${r.task.status}`) + } +} diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts new file mode 100644 index 00000000000..5da11afac1d --- /dev/null +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -0,0 +1,151 @@ +import type { RuntimeClient } from '../../runtime-client' +import { getOptionalStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { getTerminalHandle } from '../../selectors' + +export async function resolveOrchestrationTerminalHandle( + flags: Map, + cwd: string, + client: RuntimeClient, + flagName: 'from' | 'terminal', + options: { validateEnvHandle?: boolean } = {} +): Promise { + const explicit = getOptionalStringFlag(flags, flagName) + if (explicit) { + return explicit + } + const envHandle = process.env.ORCA_TERMINAL_HANDLE + if (envHandle && envHandle.length > 0) { + if (flagName === 'from' && options.validateEnvHandle) { + // Why: long-lived shells can retain a stale ORCA_TERMINAL_HANDLE after remint; don't bake it into coordinator preambles. + const live = await isLiveTerminalHandle(envHandle, client) + if (!live) { + const reminted = await resolveOrchestrationPaneTerminalHandle(client) + if (reminted) { + return reminted + } + throwNoActiveSenderTerminal() + } + } + return envHandle + } + if (flagName === 'from') { + return await resolveImplicitOrchestrationSender(flags, cwd, client) + } + return await getTerminalHandle(flags, cwd, client) +} + +async function isLiveTerminalHandle(handle: string, client: RuntimeClient): Promise { + try { + await client.call('terminal.show', { terminal: handle }) + return true + } catch (err) { + if (isStaleTerminalIdentityError(err)) { + return false + } + throw err + } +} + +function getClientErrorCode(err: unknown): string | undefined { + if (!err || typeof err !== 'object') { + return undefined + } + const code = (err as { code?: unknown }).code + return typeof code === 'string' ? code : undefined +} + +function isStaleTerminalIdentityError(err: unknown): boolean { + const code = getClientErrorCode(err) + return code === 'terminal_handle_stale' || code === 'terminal_gone' +} + +function isNoActiveTerminalError(err: unknown): boolean { + return getClientErrorCode(err) === 'no_active_terminal' +} + +async function resolveOrchestrationPaneTerminalHandle( + client: RuntimeClient, + options: { optional?: boolean } = {} +): Promise { + const paneKey = process.env.ORCA_PANE_KEY + if (!paneKey || paneKey.length === 0) { + return undefined + } + try { + // Why: pane-key reminting preserves caller identity; focus-based active-terminal fallback can point at a different pane. + const response = await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', { + paneKey + }) + return response.result.terminal.handle + } catch (err) { + if ( + isPaneRemintUnavailableError(err) || + (options.optional === true && isOptionalPaneRemintUnavailableError(err)) + ) { + return undefined + } + throw err + } +} + +function isPaneRemintUnavailableError(err: unknown): boolean { + const code = getClientErrorCode(err) + const message = getClientErrorMessage(err) + return ( + code === 'terminal_not_found' || + code === 'terminal_handle_stale' || + code === 'terminal_gone' || + message === 'terminal_not_found' || + message === 'terminal_handle_stale' || + message === 'terminal_gone' + ) +} + +function isOptionalPaneRemintUnavailableError(err: unknown): boolean { + return getClientErrorCode(err) === 'runtime_unavailable' +} + +function getClientErrorMessage(err: unknown): string | undefined { + if (err instanceof Error) { + return err.message + } + if (!err || typeof err !== 'object') { + return undefined + } + const message = (err as { message?: unknown }).message + return typeof message === 'string' ? message : undefined +} + +export async function resolveCoordinatorTerminalHandle( + flags: Map, + cwd: string, + client: RuntimeClient +): Promise { + return await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from', { + validateEnvHandle: true + }) +} + +async function resolveImplicitOrchestrationSender( + flags: Map, + cwd: string, + client: RuntimeClient +): Promise { + try { + return await getTerminalHandle(flags, cwd, client) + } catch (err) { + if (!isNoActiveTerminalError(err)) { + throw err + } + throwNoActiveSenderTerminal() + } +} + +export function throwNoActiveSenderTerminal(): never { + throw new RuntimeClientError( + 'no_active_sender_terminal', + 'Could not determine the sender terminal for this orchestration command. ' + + 'Pass --from or run the command inside a live Orca terminal with ORCA_TERMINAL_HANDLE set.' + ) +} diff --git a/src/cli/handlers/orchestration/worker-launch-handler.ts b/src/cli/handlers/orchestration/worker-launch-handler.ts new file mode 100644 index 00000000000..a6161e61ef0 --- /dev/null +++ b/src/cli/handlers/orchestration/worker-launch-handler.ts @@ -0,0 +1,70 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { callOrchestrationMutation } from './mutation-request' +import { getOptionalPositiveIntegerValueFlag } from './numeric-flags' +import { isDevCliInvocation } from './runtime-compatibility' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record = { + 'orchestration worker-start': async ({ flags, client, cwd, json }) => { + const model = getOptionalStringFlag(flags, 'model') + const effort = getOptionalStringFlag(flags, 'effort') + if (model || effort) { + const status = await client.call('status.get') + if ( + !status.result.capabilities?.includes( + ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY + ) + ) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'The connected Orca runtime does not support worker model or effort overrides. Update or restart Orca and try again.' + ) + } + } + const result = await callOrchestrationMutation<{ + runId: string + taskId: string + dispatchId: string + state: string + failedStage?: string + lastError?: string + warning?: string + effects: unknown[] + residualResources: unknown[] + }>(client, flags, 'orchestration.workerStart', { + task: getRequiredStringFlag(flags, 'task'), + on: getOptionalStringFlag(flags, 'on'), + worktree: getOptionalStringFlag(flags, 'worktree'), + name: getOptionalStringFlag(flags, 'name'), + repo: getOptionalStringFlag(flags, 'repo'), + baseBranch: getOptionalStringFlag(flags, 'base-branch'), + displayName: getOptionalStringFlag(flags, 'display-name'), + comment: getOptionalStringFlag(flags, 'comment'), + setup: getOptionalStringFlag(flags, 'setup'), + agent: getOptionalStringFlag(flags, 'agent'), + model, + effort, + terminal: getOptionalStringFlag(flags, 'terminal'), + retryOf: getOptionalStringFlag(flags, 'retry-of'), + timeoutMs: getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms'), + run: getOptionalStringFlag(flags, 'run'), + from: await resolveCoordinatorTerminalHandle(flags, cwd, client), + devMode: isDevCliInvocation() + }) + if (result.result.state !== 'ready') { + process.exitCode = 1 + } + printResult(result, json, (worker) => { + const base = `Worker ${worker.dispatchId} [${worker.state}] for ${worker.taskId}` + if (worker.lastError) { + return `${base}\n${worker.failedStage ?? 'start'}: ${worker.lastError}` + } + return worker.warning ? `${base}\nWarning: ${worker.warning}` : base + }) + } +} diff --git a/src/cli/handlers/orchestration/worker-observation-handlers.ts b/src/cli/handlers/orchestration/worker-observation-handlers.ts new file mode 100644 index 00000000000..29841f7390f --- /dev/null +++ b/src/cli/handlers/orchestration/worker-observation-handlers.ts @@ -0,0 +1,61 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import type { + OrchestrationWorkerReadResult, + OrchestrationWorkerReadSource +} from '../../../shared/orchestration-worker-output' +import { formatWorkerRead, type LegacyWorkerReadResult } from './worker-output' + +export const ORCHESTRATION_WORKER_OBSERVATION_HANDLERS: Record = { + 'orchestration worker-show': async ({ flags, client, json }) => { + const result = await client.call<{ + dispatch: { id: string; task_id: string; status: string } + worker: { state: string; stage: string; agent_terminal_handle: string | null } + observation?: { agentWait?: { source: string; reason?: string } | null } + }>('orchestration.workerShow', { + dispatch: getRequiredStringFlag(flags, 'dispatch') + }) + printResult(result, json, (value) => { + const base = `${value.dispatch.id} task=${value.dispatch.task_id} [${value.worker.state}] stage=${value.worker.stage}` + // Why: absent means unknown on older runtimes, distinct from an evaluated null wait. + if (value.observation === undefined || !('agentWait' in value.observation)) { + return `${base}\nInteractive wait: unknown (not evaluated)` + } + const wait = value.observation.agentWait + return wait + ? `${base}\nWaiting on a human: ${wait.reason ?? 'interactive prompt'} (via ${wait.source})` + : `${base}\nInteractive wait: none` + }) + }, + + 'orchestration worker-read': async ({ flags, client, json }) => { + const cursorFlag = getOptionalStringFlag(flags, 'cursor') + const cursor = + cursorFlag !== undefined && /^\d+$/.test(cursorFlag) + ? Number.parseInt(cursorFlag, 10) + : cursorFlag + const source = getOptionalStringFlag(flags, 'source') + if (source && !['auto', 'transcript', 'terminal'].includes(source)) { + throw new RuntimeClientError( + 'invalid_argument', + '--source must be auto, transcript, or terminal' + ) + } + const result = await client.call( + 'orchestration.workerRead', + { + dispatch: getRequiredStringFlag(flags, 'dispatch'), + cursor, + limit: getOptionalPositiveIntegerFlag(flags, 'limit'), + source: source as OrchestrationWorkerReadSource | undefined + } + ) + printResult(result, json, formatWorkerRead) + } +} diff --git a/src/cli/handlers/orchestration/worker-output.ts b/src/cli/handlers/orchestration/worker-output.ts new file mode 100644 index 00000000000..572f33117dd --- /dev/null +++ b/src/cli/handlers/orchestration/worker-output.ts @@ -0,0 +1,68 @@ +import type { NativeChatMessage } from '../../../shared/native-chat-types' +import type { RuntimeTerminalRead } from '../../../shared/runtime-types' +import type { OrchestrationWorkerReadResult } from '../../../shared/orchestration-worker-output' + +export type LegacyWorkerReadResult = { + dispatchId: string + terminal: RuntimeTerminalRead +} + +export function formatWorkerRead( + value: OrchestrationWorkerReadResult | LegacyWorkerReadResult +): string { + if (!('source' in value) || value.source === 'terminal') { + return value.terminal.tail.join('\n') + } + return value.transcript.messages.map(formatWorkerTranscriptMessage).join('\n\n') +} + +function formatWorkerTranscriptMessage(message: NativeChatMessage): string { + const blocks = message.blocks.map((block) => { + if (block.type === 'text') { + return block.text + } + if (block.type === 'tool-call') { + return `[tool ${block.name}] ${safeJson(block.input)}` + } + if (block.type === 'tool-result') { + return `[tool result${block.isError ? ' error' : ''}] ${block.output}` + } + return block.url ? `[image] ${block.url}` : `[image omitted]` + }) + return `[${message.role}] ${blocks.join('\n')}`.trimEnd() +} + +export type WorkerReleaseReceipt = { + dispatchId: string + state: string + reason?: string + processAction: string + archive: { source: string | null; status: string | null } | null + recovery?: string + lastError?: string +} + +export function formatWorkerRelease(value: WorkerReleaseReceipt): string { + const head = `Worker ${value.dispatchId} terminal [${value.state}]` + const lines = [ + `${head}${value.reason ? ` reason=${value.reason}` : ''} process=${value.processAction}` + ] + if (value.archive) { + lines.push(`archive ${value.archive.source ?? 'none'} [${value.archive.status ?? 'unknown'}]`) + } + if (value.lastError) { + lines.push(value.lastError) + } + if (value.recovery) { + lines.push(value.recovery) + } + return lines.join('\n') +} + +function safeJson(value: unknown): string { + try { + return JSON.stringify(value) + } catch { + return '[unserializable input]' + } +} diff --git a/src/cli/handlers/orchestration/worker-terminal-handlers.ts b/src/cli/handlers/orchestration/worker-terminal-handlers.ts new file mode 100644 index 00000000000..20e14c1da7f --- /dev/null +++ b/src/cli/handlers/orchestration/worker-terminal-handlers.ts @@ -0,0 +1,126 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { callOrchestrationMutation } from './mutation-request' +import { formatWorkerRelease, type WorkerReleaseReceipt } from './worker-output' + +const WORKER_TERMINAL_LIST_STATES = [ + 'active', + 'reclaimable', + 'retained', + 'release_pending', + 'release_unknown', + 'released' +] as const + +export const ORCHESTRATION_WORKER_TERMINAL_HANDLERS: Record = { + 'orchestration worker-stop': async ({ flags, client, json }) => { + const result = await callOrchestrationMutation<{ + dispatchId: string + state: string + processAction: string + lastError?: string + warning?: string + }>(client, flags, 'orchestration.workerStop', { + dispatch: getRequiredStringFlag(flags, 'dispatch') + }) + if (result.result.state === 'stop_unknown') { + process.exitCode = 1 + } + printResult( + result, + json, + (value) => + `Worker ${value.dispatchId} [${value.state}] process=${value.processAction}${value.lastError ? `\n${value.lastError}` : ''}${value.warning ? `\nWarning: ${value.warning}` : ''}` + ) + }, + + 'orchestration worker-abandon': async ({ flags, client, json }) => { + const result = await callOrchestrationMutation<{ + dispatchId: string + state: string + warning: string + }>(client, flags, 'orchestration.workerAbandon', { + dispatch: getRequiredStringFlag(flags, 'dispatch') + }) + printResult( + result, + json, + (value) => `Worker ${value.dispatchId} [${value.state}]\nWarning: ${value.warning}` + ) + }, + + 'orchestration worker-release': async ({ flags, client, json }) => { + const result = await callOrchestrationMutation( + client, + flags, + 'orchestration.workerRelease', + { dispatch: getRequiredStringFlag(flags, 'dispatch') } + ) + // Why: only an unprovable close is a failure; retained/pending/already-released are settled answers. + if (result.result.state === 'release_unknown') { + process.exitCode = 1 + } + printResult(result, json, formatWorkerRelease) + }, + + 'orchestration worker-retain': async ({ flags, client, json }) => { + const result = await callOrchestrationMutation( + client, + flags, + 'orchestration.workerRetain', + { dispatch: getRequiredStringFlag(flags, 'dispatch') } + ) + if (result.result.state === 'release_unknown') { + process.exitCode = 1 + } + printResult(result, json, formatWorkerRelease) + }, + + 'orchestration worker-list': async ({ flags, client, json }) => { + const terminalState = getOptionalStringFlag(flags, 'terminal-state') + if ( + terminalState && + !WORKER_TERMINAL_LIST_STATES.includes( + terminalState as (typeof WORKER_TERMINAL_LIST_STATES)[number] + ) + ) { + throw new RuntimeClientError( + 'invalid_argument', + `invalid --terminal-state '${terminalState}', expected one of: ${WORKER_TERMINAL_LIST_STATES.join(', ')}` + ) + } + const result = await client.call<{ + workers: { + dispatchId: string + taskId: string + runId: string + workerState: string + dispatchStatus: string + agentTerminalHandle: string | null + terminalState: string | null + resource: unknown + }[] + counts: Record + }>('orchestration.workerList', { + run: getOptionalStringFlag(flags, 'run'), + terminalState + }) + printResult(result, json, (value) => { + if (value.workers.length === 0) { + return 'No workers found.' + } + const rows = value.workers + .map( + (worker) => + `${worker.dispatchId} task=${worker.taskId} [${worker.workerState}] terminal=${worker.terminalState ?? 'none'}` + ) + .join('\n') + const counts = Object.entries(value.counts) + .map(([state, count]) => `${state}=${count}`) + .join(' ') + return counts ? `${rows}\nTerminals: ${counts}` : rows + }) + } +} From 4371aaf722775abac5eefb14211542a57a49244d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:51:57 -0700 Subject: [PATCH 25/65] refactor provider clients into domain modules (#16168) --- config/max-lines-baseline.txt | 3 - src/main/gitlab/client.test.ts | 4 + src/main/gitlab/client.ts | 1405 +---------------- src/main/gitlab/gitlab-auth-and-rate-limit.ts | 187 +++ src/main/gitlab/merge-request-list.ts | 164 ++ src/main/gitlab/merge-request-lookup.ts | 183 +++ .../merge-request-project-resolution.ts | 33 + .../gitlab/merge-request-review-mutations.ts | 283 ++++ .../gitlab/merge-request-state-mutations.ts | 143 ++ src/main/gitlab/merge-request-update.ts | 84 + src/main/gitlab/pipeline-job-mutations.ts | 128 ++ src/main/gitlab/work-item-queries.ts | 252 +++ src/main/jira/issues.test.ts | 2 +- src/main/jira/issues.ts | 1259 +-------------- src/main/jira/jira-issue-comments.ts | 146 ++ src/main/jira/jira-issue-create-metadata.ts | 160 ++ src/main/jira/jira-issue-mapping.ts | 210 +++ src/main/jira/jira-issue-media.ts | 104 ++ src/main/jira/jira-issue-mutations.ts | 146 ++ src/main/jira/jira-issue-read.ts | 121 ++ src/main/jira/jira-issue-search.ts | 122 ++ src/main/jira/jira-project-queries.ts | 47 + src/main/jira/jira-read-failure.ts | 80 + src/main/jira/jira-record-pages.ts | 96 ++ src/main/jira/jira-transition-queries.ts | 118 ++ src/main/linear/linear-custom-view-graphql.ts | 114 ++ src/main/linear/linear-custom-view-queries.ts | 169 ++ .../linear/linear-project-collection-read.ts | 71 + src/main/linear/linear-project-graphql.ts | 207 +++ .../linear/linear-project-issue-queries.ts | 82 + src/main/linear/linear-project-models.ts | 285 ++++ src/main/linear/linear-project-nodes.ts | 161 ++ src/main/linear/linear-project-queries.ts | 196 +++ .../linear/linear-project-team-queries.ts | 77 + src/main/linear/projects.test.ts | 2 + src/main/linear/projects.ts | 1273 +-------------- 36 files changed, 4234 insertions(+), 3883 deletions(-) create mode 100644 src/main/gitlab/gitlab-auth-and-rate-limit.ts create mode 100644 src/main/gitlab/merge-request-list.ts create mode 100644 src/main/gitlab/merge-request-lookup.ts create mode 100644 src/main/gitlab/merge-request-project-resolution.ts create mode 100644 src/main/gitlab/merge-request-review-mutations.ts create mode 100644 src/main/gitlab/merge-request-state-mutations.ts create mode 100644 src/main/gitlab/merge-request-update.ts create mode 100644 src/main/gitlab/pipeline-job-mutations.ts create mode 100644 src/main/gitlab/work-item-queries.ts create mode 100644 src/main/jira/jira-issue-comments.ts create mode 100644 src/main/jira/jira-issue-create-metadata.ts create mode 100644 src/main/jira/jira-issue-mapping.ts create mode 100644 src/main/jira/jira-issue-media.ts create mode 100644 src/main/jira/jira-issue-mutations.ts create mode 100644 src/main/jira/jira-issue-read.ts create mode 100644 src/main/jira/jira-issue-search.ts create mode 100644 src/main/jira/jira-project-queries.ts create mode 100644 src/main/jira/jira-read-failure.ts create mode 100644 src/main/jira/jira-record-pages.ts create mode 100644 src/main/jira/jira-transition-queries.ts create mode 100644 src/main/linear/linear-custom-view-graphql.ts create mode 100644 src/main/linear/linear-custom-view-queries.ts create mode 100644 src/main/linear/linear-project-collection-read.ts create mode 100644 src/main/linear/linear-project-graphql.ts create mode 100644 src/main/linear/linear-project-issue-queries.ts create mode 100644 src/main/linear/linear-project-models.ts create mode 100644 src/main/linear/linear-project-nodes.ts create mode 100644 src/main/linear/linear-project-queries.ts create mode 100644 src/main/linear/linear-project-team-queries.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index e6763232f8f..4d0d6233471 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -32,7 +32,6 @@ inline src/main/github/pr-refresh-coordinator.ts inline src/main/github/project-view.ts inline src/main/github/project-view/mutations.ts inline src/main/github/work-item-details.ts -inline src/main/gitlab/client.ts inline src/main/index.ts inline src/main/ipc/filesystem-watcher.ts inline src/main/ipc/filesystem.ts @@ -41,10 +40,8 @@ inline src/main/ipc/repos.ts inline src/main/ipc/ssh.ts inline src/main/ipc/worktree-remote.ts inline src/main/ipc/worktrees.ts -inline src/main/jira/issues.ts inline src/main/keybindings/keybinding-file.ts inline src/main/linear/issues.ts -inline src/main/linear/projects.ts inline src/main/memory/collector.ts inline src/main/opencode/hook-service.ts inline src/main/persistence/loading-store/store.ts diff --git a/src/main/gitlab/client.test.ts b/src/main/gitlab/client.test.ts index 038083def0e..1d932d5c5fa 100644 --- a/src/main/gitlab/client.test.ts +++ b/src/main/gitlab/client.test.ts @@ -50,6 +50,9 @@ describe('gitlab client — viewer & paste-URL lookup', () => { username: 'alice', email: 'alice@example.com' }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['api', 'user']) + expect(acquireMock).toHaveBeenCalledOnce() + expect(releaseMock).toHaveBeenCalledOnce() }) it('coerces a missing email to null', async () => { @@ -65,6 +68,7 @@ describe('gitlab client — viewer & paste-URL lookup', () => { it('returns null when glab fails', async () => { glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not authenticated')) await expect(getAuthenticatedViewer()).resolves.toBeNull() + expect(releaseMock).toHaveBeenCalledOnce() }) it('returns null when username is empty', async () => { diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index 50c541c0213..9f50ebbaf5d 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -1,1378 +1,33 @@ -/* eslint-disable max-lines -- co-locates GitLab MR/issue/work-item operations sharing one acquire/release pattern. */ -import type { ClassifiedError } from '../../shared/classified-error' -import type { - GetGitLabRateLimitResult, - GitLabAssignableUser, - GitLabAuthDiagnostic, - GitLabDiscussionResolveResult, - GitLabJobTraceResult, - GitLabMRInlineCommentInput, - GitLabMRReviewersUpdateResult, - GitLabPagedResult, - GitLabPipelineJob, - GitLabRateLimitSnapshot, - GitLabRetryJobResult, - GitLabTodo, - GitLabViewer, - GitLabWorkItem, - ListMergeRequestsResult, - MRComment, - MRInfo, - MRListState -} from '../../shared/gitlab-types' -import type { IssueSourcePreference } from '../../shared/repo-types' -import { derivePipelineStatus, mapIssueToWorkItem, mapMRInfo, mapMRToWorkItem } from './mappers' -import { - acquire, - classifyGlabError, - classifyJobLogError, - classifyListFetchError, - isMissingJobLogError, - getGlabKnownHosts, - getProjectRef, - getProjectRefForRemote, - glabHostnameArgs, - glabRepoExecOptions, - glabApiWithHeaders, - glabExecFileAsync, - parseGlabAuthStatusHosts, - parseGlabJsonList, - release, - resolveIssueSource, - type LocalGitExecOptions, - type ProjectRef -} from './gl-utils' -import { rememberGlabKnownHosts } from './gitlab-known-host-probe' -import type { IssueListState } from './issues' -import { - hasHostedReviewLocalGitOptions, - getHostedReviewLocalGitOptions, - type HostedReviewExecutionOptions -} from '../source-control/hosted-review-git-options' -import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch' - -// Why: glab REST addresses projects by URL-encoded path; escapes slashes for nested groups. -function encodedProject(projectPath: string): string { - return encodeURIComponent(projectPath) -} - -const GITLAB_RATE_LIMIT_CACHE_TTL_MS = 30_000 -const GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES = 64 -const gitLabRateLimitCache = new Map() - -type HostedReviewLocalGitOptions = ReturnType - -function hostedReviewLocalGitOptionArgs( - options: HostedReviewExecutionOptions = {} -): [] | [HostedReviewLocalGitOptions] { - return hasHostedReviewLocalGitOptions(options) ? [getHostedReviewLocalGitOptions(options)] : [] -} - -/** - * Get the authenticated GitLab viewer. - * Returns null when glab is unavailable, unauthenticated, or the lookup fails. - */ -export async function getAuthenticatedViewer(): Promise { - await acquire() - try { - const { stdout } = await glabExecFileAsync(['api', 'user']) - const viewer = JSON.parse(stdout) as { username?: string; email?: string | null } - if (!viewer.username?.trim()) { - return null - } - return { - username: viewer.username.trim(), - email: viewer.email?.trim() || null - } - } catch { - return null - } finally { - release() - } -} - -export async function diagnoseAuth(): Promise { - const envTokenInProcess = process.env.GITLAB_TOKEN - ? 'GITLAB_TOKEN' - : process.env.GLAB_TOKEN - ? 'GLAB_TOKEN' - : null - try { - // Why: a host-global diagnostic must not wake an unrelated default WSL distro. - const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { - allowDefaultWslFallback: false - }) - const output = `${stdout}\n${stderr}` - const hosts = parseGlabAuthStatusHosts(output) - // Why: refreshing auth must advance the provider cache key past a stale null result. - rememberGlabKnownHosts(hosts) - return { - glabAvailable: true, - authenticated: - /logged in|authenticated|token/i.test(output) && !/not logged in/i.test(output), - hosts, - activeHost: hosts[0] ?? null, - envTokenInProcess, - error: null - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { - glabAvailable: !/ENOENT|not found|spawn/i.test(message), - authenticated: false, - hosts: [], - activeHost: null, - envTokenInProcess, - error: message - } - } -} - -function parseRateLimitHeader( - headers: Record, - keys: readonly string[] -): number | null { - for (const key of keys) { - const parsed = Number.parseInt(headers[key], 10) - if (Number.isFinite(parsed)) { - return parsed - } - } - return null -} - -function parseRateLimitResetAt(headers: Record): number | null { - const numeric = parseRateLimitHeader(headers, ['ratelimit-reset', 'x-ratelimit-reset']) - if (numeric !== null) { - return numeric - } - const resetTime = headers['ratelimit-resettime'] ?? headers['x-ratelimit-resettime'] - if (!resetTime) { - return null - } - const millis = Date.parse(resetTime) - return Number.isFinite(millis) ? Math.floor(millis / 1000) : null -} - -function parseGitLabRateLimitSnapshot( - headers: Record, - host: string | null -): GitLabRateLimitSnapshot { - const limit = parseRateLimitHeader(headers, ['ratelimit-limit', 'x-ratelimit-limit']) - const remaining = parseRateLimitHeader(headers, ['ratelimit-remaining', 'x-ratelimit-remaining']) - const resetAt = parseRateLimitResetAt(headers) - return { - host, - fetchedAt: Date.now(), - rest: - limit === null && remaining === null && resetAt === null - ? null - : { - limit: limit ?? 0, - remaining: remaining ?? 0, - resetAt - } - } -} - -/** @internal — test-only */ -export function _resetGitLabRateLimitCache(): void { - gitLabRateLimitCache.clear() -} - -/** @internal — test-only */ -export function _getGitLabRateLimitCacheSize(): number { - return gitLabRateLimitCache.size -} - -function pruneGitLabRateLimitCache(now = Date.now()): void { - for (const [cacheKey, snapshot] of gitLabRateLimitCache) { - if (now - snapshot.fetchedAt >= GITLAB_RATE_LIMIT_CACHE_TTL_MS) { - gitLabRateLimitCache.delete(cacheKey) - } - } - while (gitLabRateLimitCache.size > GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES) { - const oldestKey = gitLabRateLimitCache.keys().next().value - if (oldestKey === undefined) { - break - } - gitLabRateLimitCache.delete(oldestKey) - } -} - -function rememberGitLabRateLimitSnapshot( - cacheKey: string, - snapshot: GitLabRateLimitSnapshot -): void { - pruneGitLabRateLimitCache() - // Why: self-managed hostnames come from repo config; keep this cache bounded across many transient hosts. - gitLabRateLimitCache.delete(cacheKey) - gitLabRateLimitCache.set(cacheKey, snapshot) - pruneGitLabRateLimitCache() -} - -export async function getRateLimit(options?: { - force?: boolean - host?: string | null -}): Promise { - const host = options?.host?.trim() || null - const cacheKey = host ?? 'default' - pruneGitLabRateLimitCache() - const cached = gitLabRateLimitCache.get(cacheKey) - if (!options?.force && cached && Date.now() - cached.fetchedAt < GITLAB_RATE_LIMIT_CACHE_TTL_MS) { - return { ok: true, snapshot: cached } - } - - await acquire() - try { - // Why: GitLab exposes REST budget headers inconsistently; a null bucket means this host omitted them. - const args = host ? ['--hostname', host, 'user'] : ['user'] - const { headers } = await glabApiWithHeaders(args) - const snapshot = parseGitLabRateLimitSnapshot(headers, host) - rememberGitLabRateLimitSnapshot(cacheKey, snapshot) - return { ok: true, snapshot } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - return { ok: false, error: message } - } finally { - release() - } -} - -/** Resolve a project's full GitLab project ref (host + path); null for non-GitLab remotes. */ -export async function getProjectSlug( - repoPath: string, - connectionId?: string | null, - options: HostedReviewExecutionOptions = {} -): Promise { - const localGitArgs = hostedReviewLocalGitOptionArgs(options) - const knownHosts = await getGlabKnownHosts(connectionId, localGitArgs[0]) - return getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) -} - -/** - * Fetch a single merge request with pipeline status rolled up. - * Returns null when the MR doesn't exist or glab fails. - */ -export async function getMergeRequest( - repoPath: string, - iid: number, - connectionId?: string | null, - options: HostedReviewExecutionOptions = {} -): Promise { - const localGitArgs = hostedReviewLocalGitOptionArgs(options) - const localGitOptions = localGitArgs[0] ?? {} - const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) - const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) - await acquire() - try { - const args = projectRef - ? [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}` - ] - : ['mr', 'view', String(iid), '--output', 'json'] - const { stdout } = await glabExecFileAsync( - args, - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) as Parameters[0] & { - head_pipeline?: { status?: string } | null - pipeline?: { status?: string } | null - } - // Why: older GitLab instances expose `pipeline` instead of `head_pipeline`; try both. - const pipelineStatus = derivePipelineStatus(data.head_pipeline ?? data.pipeline ?? null) - return mapMRInfo(data, pipelineStatus) - } catch { - return null - } finally { - release() - } -} - -/** - * Find the merge request whose source branch matches the given name. - * Returns the most recently updated MR for the branch, or null when none exists. - */ -export async function getMergeRequestForBranch( - repoPath: string, - branch: string, - linkedMRIid?: number | null, - connectionId?: string | null, - options: HostedReviewExecutionOptions = {}, - // Why: when true, a failed lookup throws instead of returning null, so callers never report a false not_found. - throwOnFailure = false -): Promise { - const branchName = branch.replace(/^refs\/heads\//, '') - if (!branchName && linkedMRIid == null) { - return null - } - const localGitArgs = hostedReviewLocalGitOptionArgs(options) - const localGitOptions = localGitArgs[0] ?? {} - const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) - const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) - if (!projectRef) { - return null - } - await acquire() - try { - if (branchName) { - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - // Why: GitLab does not proactively recompute merge status on list endpoints, so this row - // can sit at `unchecked` forever — and the sidebar merge button gates on MERGEABLE. Ask - // for the async recalculation (best-effort; ignored for non-Developers when - // `restrict_merge_status_recheck` is on) so polling converges instead of stalling. - `projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1&with_merge_status_recheck=true` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) as (Parameters[0] & { - head_pipeline?: { status?: string } | null - pipeline?: { status?: string } | null - })[] - if (Array.isArray(data) && data.length > 0) { - const raw = data[0] - // Why: older GitLab list payloads expose `pipeline` instead of `head_pipeline`. - const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) - const info = mapMRInfo(raw, pipelineStatus) - // Why (#9171): discard a non-open implicit branch match on the repo - // default branch and fall through to the linked-iid fallback below. - const hideOnDefaultBranch = await shouldHideNonOpenReviewOnDefaultBranch({ - state: info.state, - reviewNumber: info.number, - linkedReviewNumber: linkedMRIid, - branchName, - repoPath, - connectionId, - localGitOptions - }) - if (!hideOnDefaultBranch) { - return info - } - } - } - if (typeof linkedMRIid !== 'number') { - return null - } - // Why: create-from-MR worktrees may rename the branch; fall back to the durable linked iid. - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const raw = JSON.parse(stdout) as Parameters[0] & { - head_pipeline?: { status?: string } | null - pipeline?: { status?: string } | null - } - const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) - return mapMRInfo(raw, pipelineStatus) - } catch (error) { - if (throwOnFailure) { - throw error - } - return null - } finally { - release() - } -} - -/** - * Like getMergeRequestForBranch but throws glab failures instead of returning null, so callers report 'unavailable' not a false "not found". - */ -export function getMergeRequestForBranchOrThrow( - repoPath: string, - branch: string, - linkedMRIid?: number | null, - connectionId?: string | null, - options: HostedReviewExecutionOptions = {} -): Promise { - return getMergeRequestForBranch(repoPath, branch, linkedMRIid, connectionId, options, true) -} - -function mrListStateFlags(state: MRListState): string[] { - switch (state) { - case 'opened': - return [] - case 'merged': - return ['--merged'] - case 'closed': - return ['--closed'] - case 'all': - return ['--all'] - } -} - -/** List merge requests for a project via glab CLI, which handles self-hosted auth and project selection. */ -export async function listMergeRequests( - repoPath: string, - state: MRListState = 'opened', - page = 1, - perPage = 20, - preference?: IssueSourcePreference, - query?: string, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) - // Why: MRs live on the user's fork (origin); route through the preference resolver so fork workflows share plumbing. - const { source: projectRef } = await resolveIssueSource( - repoPath, - preference, - knownHosts, - connectionId, - localGitOptions - ) - if (!projectRef) { - if (connectionId) { - // Why: SSH-backed repos have no local cwd; a cwd-less glab could resolve an unrelated project. - return { - items: [], - page, - perPage, - totalCount: 0, - totalPages: 0, - error: { - type: 'not_found', - message: 'No GitLab project found for this repository.' - } - } - } - // Why: fallback — let glab infer the project from cwd when the host isn't in getGlabKnownHosts. - const stateFlag = mrListStateFlags(state) - // Why: apply the same search as the API path, else queries are silently ignored on cwd-inferred repos (#6263). - const searchFlag = query?.trim() ? ['--search', query.trim()] : [] - await acquire() - try { - const { stdout } = await glabExecFileAsync( - [ - 'mr', - 'list', - '--output', - 'json', - '--per-page', - String(perPage), - '--page', - String(page), - '--order', - 'updated_at', - '--sort', - 'desc', - ...stateFlag, - ...searchFlag - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = parseGlabJsonList[0]>(stdout) - return { - items: data.map((d) => mapMRToWorkItem(d, 'unknown')), - page, - perPage, - // Why: the CLI omits x-total headers, so totals are approximate. - totalCount: data.length, - totalPages: data.length < perPage ? page : page + 1 - } - } catch (err) { - return { - items: [], - page, - perPage, - totalCount: 0, - totalPages: 0, - error: classifyListFetchError(err) - } - } finally { - release() - } - } - // Why: GitLab's API uses an absent state param to mean "any"; drop it for 'all'. - const stateParam = state === 'all' ? '' : `&state=${state}` - const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' - const path = - `projects/${encodedProject(projectRef.path)}/merge_requests?` + - `page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc&with_merge_status_recheck=false${stateParam}${searchParam}` - const repoId = projectRef.path - - await acquire() - try { - const { body, headers } = await glabApiWithHeaders( - [...glabHostnameArgs(projectRef, connectionId), path], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = parseGlabJsonList[0]>(body) - return { - items: data.map((d) => mapMRToWorkItem(d, repoId, projectRef)), - page, - perPage, - totalCount: parseHeaderInt(headers['x-total'], 0), - // Why: GitLab may omit x-total-pages for 'all' or large per_page; fall back to ceil(total/perPage). - totalPages: - parseHeaderInt(headers['x-total-pages'], 0) || - Math.max(1, Math.ceil(parseHeaderInt(headers['x-total'], 0) / perPage)) - } - } catch (err) { - return { - items: [], - page, - perPage, - totalCount: 0, - totalPages: 0, - error: classifyListFetchError(err) - } - } finally { - release() - } -} - -function parseHeaderInt(value: string | undefined, fallback: number): number { - if (!value) { - return fallback - } - const parsed = Number.parseInt(value, 10) - return Number.isFinite(parsed) ? parsed : fallback -} - -/** - * Fetch a work item (MR or issue) by explicit project ref + iid + type. - * Used by the paste-URL flow, where the URL determines the project directly. - */ -export async function getWorkItemByProjectRef( - repoPath: string, - projectRef: ProjectRef, - iid: number, - type: 'issue' | 'mr', - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - await acquire() - try { - const resource = type === 'mr' ? 'merge_requests' : 'issues' - const { stdout } = await glabExecFileAsync( - [ - 'api', - // Why: preserve the pasted URL's explicit host so cwd remotes can't redirect the lookup. - ...(projectRef.host ? ['--hostname', projectRef.host] : []), - `projects/${encodedProject(projectRef.path)}/${resource}/${iid}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) - if (type === 'mr') { - return mapMRToWorkItem(data, projectRef.path, projectRef) - } - return mapIssueToWorkItem(data, projectRef.path, projectRef) - } catch { - return null - } finally { - release() - } -} - -function mrStateToIssueState(state: MRListState): IssueListState | null { - // Why: issues have no 'merged' state; return null so the issues fetch is skipped, not mis-mapped. - switch (state) { - case 'opened': - return 'opened' - case 'closed': - return 'closed' - case 'all': - return 'all' - case 'merged': - return null - } -} - -export async function listWorkItems( - repoPath: string, - state: MRListState = 'opened', - page = 1, - perPage = 20, - preference?: IssueSourcePreference, - query?: string, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise> { - const issueState = mrStateToIssueState(state) - const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) - const { source: projectRef } = await resolveIssueSource( - repoPath, - preference, - knownHosts, - connectionId, - localGitOptions - ) - if (!projectRef) { - return { - items: [], - page, - perPage, - totalCount: 0, - totalPages: 0, - error: { - type: 'not_found', - message: 'No GitLab project found for this repository.' - } - } - } - // Why: fan out both reads so latency is the slower of the two, not the sum. - // Why read the raw issues API (not listIssues): IssueInfo strips updated_at, which the combined sort needs. - const [mrs, issues] = await Promise.all([ - listMergeRequests( - repoPath, - state, - page, - perPage, - preference, - query, - connectionId, - localGitOptions - ), - issueState === null - ? Promise.resolve({ - items: [] as GitLabWorkItem[], - error: undefined as ClassifiedError | undefined - }) - : fetchIssuesAsWorkItems( - repoPath, - projectRef, - issueState, - page, - perPage, - query, - connectionId, - localGitOptions - ) - ]) - const merged = [...mrs.items, ...issues.items].sort((a, b) => - (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') - ) - // Why: MR-side error wins — issues can be disabled per project, making its error less informative. - const error: ClassifiedError | undefined = mrs.error ?? issues.error - return { - items: merged, - page, - perPage, - // Why: approximate totals — GitLab can't paginate across MRs+issues as one set, so MR totals stand in. - totalCount: mrs.totalCount, - totalPages: mrs.totalPages, - ...(error ? { error } : {}) - } -} - -export async function fetchIssuesAsWorkItems( - repoPath: string, - projectRef: ProjectRef, - state: IssueListState, - page: number, - perPage: number, - query?: string, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ items: GitLabWorkItem[]; error: ClassifiedError | undefined }> { - await acquire() - try { - const stateParam = state === 'all' ? '' : `&state=${state}` - const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/issues?page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}${searchParam}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = parseGlabJsonList[0]>(stdout) - return { - items: data.map((d) => mapIssueToWorkItem(d, projectRef.path, projectRef)), - error: undefined - } - } catch (err) { - return { - items: [], - error: classifyListFetchError(err) - } - } finally { - release() - } -} - -/** - * List the authenticated user's GitLab todos (gitlab.com/dashboard/todos). - * User-scoped, so cwd is irrelevant; repoPath only satisfies the IPC handler's path-validation guard. - */ -export async function listTodos( - repoPath: string, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const projectRef = await getProjectRef( - repoPath, - await getGlabKnownHosts(connectionId, localGitOptions), - connectionId, - localGitOptions - ) - if (connectionId && !projectRef) { - return [] - } - await acquire() - try { - // Why: per_page=50 keeps this cross-project view cheap; the UI only shows top-priority todos. - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...(projectRef ? glabHostnameArgs(projectRef, connectionId) : []), - 'todos?state=pending&per_page=50' - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - type RESTTodo = { - id?: number - action_name?: string - target_type?: string - target?: { - iid?: number - title?: string - web_url?: string - } | null - target_url?: string - author?: { username?: string | null; avatar_url?: string | null } | null - project?: { path_with_namespace?: string } | null - updated_at?: string - state?: string - } - const data = JSON.parse(stdout) as RESTTodo[] - return data.map((t) => ({ - id: t.id ?? 0, - actionName: t.action_name ?? '', - targetType: t.target_type ?? '', - targetIid: typeof t.target?.iid === 'number' ? t.target.iid : null, - targetTitle: t.target?.title ?? '', - targetUrl: t.target_url ?? t.target?.web_url ?? '', - projectPath: t.project?.path_with_namespace ?? '', - authorUsername: t.author?.username ?? '', - authorAvatarUrl: t.author?.avatar_url ?? '', - updatedAt: t.updated_at ?? '', - state: t.state === 'done' ? 'done' : 'pending' - })) - } catch { - // Why: silent empty-list on auth/network failure matches the read-side surface; caller UI signals connectivity. - return [] - } finally { - release() - } -} - -// ── MR mutations ────────────────────────────────────────────────── - -async function withProjectRef( - repoPath: string, - preference: IssueSourcePreference | undefined, - connectionId: string | null | undefined, - explicitProjectRef: ProjectRef | null | undefined, - fn: (projectRef: ProjectRef, repoFlag: string) => Promise, - fallback: T, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const projectRef = - explicitProjectRef ?? - ( - await resolveIssueSource( - repoPath, - preference, - await getGlabKnownHosts(connectionId, localGitOptions), - connectionId, - localGitOptions - ) - ).source - if (!projectRef) { - return fallback - } - return fn(projectRef, projectRef.path) -} - -export async function closeMR( - repoPath: string, - iid: number, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - return withProjectRef<{ ok: true } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef, repoFlag) => { - await acquire() - try { - await glabExecFileAsync( - [ - 'mr', - 'close', - String(iid), - '-R', - repoFlag, - ...glabHostnameArgs(projectRef, connectionId) - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - // Why: glab exits non-zero when the MR is already closed — treat as success. - if (msg.toLowerCase().includes('already')) { - return { ok: true } - } - return { ok: false, error: msg } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function reopenMR( - repoPath: string, - iid: number, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - return withProjectRef<{ ok: true } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef, repoFlag) => { - await acquire() - try { - await glabExecFileAsync( - [ - 'mr', - 'reopen', - String(iid), - '-R', - repoFlag, - ...glabHostnameArgs(projectRef, connectionId) - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - if (msg.toLowerCase().includes('already')) { - return { ok: true } - } - return { ok: false, error: msg } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function mergeMR( - repoPath: string, - iid: number, - method: 'merge' | 'squash' | 'rebase' = 'merge', - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - return withProjectRef<{ ok: true } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef, repoFlag) => { - await acquire() - try { - // Why: omitting both --squash and --rebase yields a regular merge commit. - const methodFlag = - method === 'squash' ? ['--squash'] : method === 'rebase' ? ['--rebase'] : [] - await glabExecFileAsync( - [ - 'mr', - 'merge', - String(iid), - '-R', - repoFlag, - '--yes', - ...methodFlag, - ...glabHostnameArgs(projectRef, connectionId) - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true } - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function addMRComment( - repoPath: string, - iid: number, - body: string, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { - return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - await acquire() - try { - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'POST', - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/notes`, - '-f', - `body=${body}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) as { - id?: number - author?: { username?: string; avatar_url?: string; state?: string } | null - body?: string - created_at?: string - } - return { - ok: true, - comment: { - id: data.id ?? Date.now(), - author: data.author?.username ?? 'You', - authorAvatarUrl: data.author?.avatar_url ?? '', - body: data.body ?? body, - createdAt: data.created_at ?? new Date().toISOString(), - url: '', - isBot: data.author?.state === 'bot' - } - } - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function addMRInlineComment( - repoPath: string, - iid: number, - input: GitLabMRInlineCommentInput, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { - return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - const body = input.body.trim() - if (!body) { - return { ok: false, error: 'Comment body is required' } - } - await acquire() - try { - const oldPath = input.oldPath ?? input.path - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'POST', - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/discussions`, - '-f', - `body=${body}`, - '-f', - 'position[position_type]=text', - '-f', - `position[base_sha]=${input.baseSha}`, - '-f', - `position[start_sha]=${input.startSha}`, - '-f', - `position[head_sha]=${input.headSha}`, - '-f', - `position[old_path]=${oldPath}`, - '-f', - `position[new_path]=${input.path}`, - '-f', - `position[new_line]=${input.line}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) as { - id?: string - notes?: { - id?: number - author?: { username?: string; avatar_url?: string; state?: string } | null - body?: string - created_at?: string - position?: { new_path?: string; new_line?: number } | null - }[] - } - const note = data.notes?.[0] - return { - ok: true, - comment: { - id: note?.id ?? Date.now(), - author: note?.author?.username ?? 'You', - authorAvatarUrl: note?.author?.avatar_url ?? '', - body: note?.body ?? body, - createdAt: note?.created_at ?? new Date().toISOString(), - url: '', - threadId: data.id, - isResolved: false, - isBot: note?.author?.state === 'bot', - path: note?.position?.new_path ?? input.path, - line: note?.position?.new_line ?? input.line - } - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGlabError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function resolveMRDiscussion( - repoPath: string, - iid: number, - discussionId: string, - resolved: boolean, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - return withProjectRef( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - const trimmedDiscussionId = discussionId.trim() - if (!trimmedDiscussionId) { - return { ok: false, error: 'Discussion id is required' } - } - await acquire() - try { - // Why: GitLab resolves/reopens the whole discussion thread, not a single note. - await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'PUT', - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/discussions/${encodeURIComponent(trimmedDiscussionId)}`, - '-f', - `resolved=${resolved ? 'true' : 'false'}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGlabError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -function mapRetriedPipelineJob( - data: { - id?: number - pipeline?: { id?: number | null } | null - name?: string - stage?: string - status?: string - web_url?: string - duration?: number | null - }, - fallbackJobId: number -): GitLabPipelineJob { - return { - id: data.id ?? fallbackJobId, - ...(typeof data.pipeline?.id === 'number' ? { pipelineId: data.pipeline.id } : {}), - name: data.name ?? '', - stage: data.stage ?? '', - status: data.status ?? '', - webUrl: data.web_url ?? '', - duration: typeof data.duration === 'number' ? data.duration : null - } -} - -function mapGitLabReviewer(raw: { - id?: number - username?: string | null - name?: string | null - avatar_url?: string | null - state?: string | null -}): GitLabAssignableUser | null { - if (!raw.username) { - return null - } - return { - ...(typeof raw.id === 'number' ? { id: raw.id } : {}), - username: raw.username, - name: raw.name ?? null, - avatarUrl: raw.avatar_url ?? '', - ...(raw.state !== undefined ? { state: raw.state } : {}) - } -} - -export async function updateMRReviewers( - repoPath: string, - iid: number, - reviewerIds: number[], - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - return withProjectRef( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - await acquire() - try { - const fields = - reviewerIds.length > 0 - ? reviewerIds.flatMap((id) => ['-f', `reviewer_ids[]=${id}`]) - : ['-f', 'reviewer_ids='] - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'PUT', - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, - ...fields - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) as { reviewers?: Parameters[0][] } - return { - ok: true, - reviewers: (data.reviewers ?? []) - .map(mapGitLabReviewer) - .filter((u): u is GitLabAssignableUser => !!u) - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGlabError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function getJobTrace( - repoPath: string, - jobId: number, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - return withProjectRef( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - await acquire() - try { - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/trace` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true, trace: stdout } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - // A job with no log is an empty log, not a failure: surfacing the 404 would - // pin an error on the Checks row for a job canceled before it started. - if (isMissingJobLogError(msg)) { - return { ok: true, trace: '' } - } - return { ok: false, error: classifyJobLogError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function retryJob( - repoPath: string, - jobId: number, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - return withProjectRef( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - await acquire() - try { - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'POST', - `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/retry` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const trimmed = stdout.trim() - return { - ok: true, - ...(trimmed ? { job: mapRetriedPipelineJob(JSON.parse(trimmed), jobId) } : {}) - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGlabError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function updateMR( - repoPath: string, - iid: number, - updates: { - title?: string - body?: string - addLabels?: string[] - removeLabels?: string[] - }, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - return withProjectRef<{ ok: true } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - const fields: string[] = [] - const title = updates.title?.trim() - if (updates.title !== undefined) { - if (!title) { - return { ok: false, error: 'Title is required' } - } - fields.push(`title=${title}`) - } - if (updates.body !== undefined) { - fields.push(`description=${updates.body}`) - } - const addLabels = (updates.addLabels ?? []).filter((label) => label.trim().length > 0) - const removeLabels = (updates.removeLabels ?? []).filter((label) => label.trim().length > 0) - if (addLabels.length > 0) { - fields.push(`add_labels=${addLabels.join(',')}`) - } - if (removeLabels.length > 0) { - fields.push(`remove_labels=${removeLabels.join(',')}`) - } - if (fields.length === 0) { - return { ok: true } - } - - await acquire() - try { - await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'PUT', - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, - ...fields.flatMap((field) => ['-f', field]) - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGlabError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -/** Re-export so callers don't need to know the gl-utils module split. */ -export { _resetProjectRefCache } from './gl-utils' +export { + _getGitLabRateLimitCacheSize, + _resetGitLabRateLimitCache, + diagnoseAuth, + getAuthenticatedViewer, + getRateLimit +} from './gitlab-auth-and-rate-limit' +export { + getMergeRequest, + getMergeRequestForBranch, + getMergeRequestForBranchOrThrow, + getProjectSlug +} from './merge-request-lookup' +export { listMergeRequests } from './merge-request-list' +export { + fetchIssuesAsWorkItems, + getWorkItemByProjectRef, + listTodos, + listWorkItems +} from './work-item-queries' +export { closeMR, mergeMR, reopenMR } from './merge-request-state-mutations' +export { + addMRComment, + addMRInlineComment, + resolveMRDiscussion, + updateMRReviewers +} from './merge-request-review-mutations' +export { getJobTrace, retryJob } from './pipeline-job-mutations' +export { updateMR } from './merge-request-update' +export { _resetProjectRefCache, getProjectRefForRemote } from './gl-utils' export { addIssueComment, createIssue, getIssue, listIssues } from './issues' export { updateIssue } from './issue-update' export { listAssignableUsers, listLabels } from './project-label-and-member-lookup' - -// Re-exported so paste-URL call sites don't import getProjectRefForRemote from gl-utils directly. -export { getProjectRefForRemote } diff --git a/src/main/gitlab/gitlab-auth-and-rate-limit.ts b/src/main/gitlab/gitlab-auth-and-rate-limit.ts new file mode 100644 index 00000000000..58947d4b307 --- /dev/null +++ b/src/main/gitlab/gitlab-auth-and-rate-limit.ts @@ -0,0 +1,187 @@ +import type { + GetGitLabRateLimitResult, + GitLabAuthDiagnostic, + GitLabRateLimitSnapshot, + GitLabViewer +} from '../../shared/gitlab-types' +import { + acquire, + glabApiWithHeaders, + glabExecFileAsync, + parseGlabAuthStatusHosts, + release +} from './gl-utils' +import { rememberGlabKnownHosts } from './gitlab-known-host-probe' + +const GITLAB_RATE_LIMIT_CACHE_TTL_MS = 30_000 +const GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES = 64 +const gitLabRateLimitCache = new Map() + +export async function getAuthenticatedViewer(): Promise { + await acquire() + try { + const { stdout } = await glabExecFileAsync(['api', 'user']) + const viewer = JSON.parse(stdout) as { username?: string; email?: string | null } + if (!viewer.username?.trim()) { + return null + } + return { + username: viewer.username.trim(), + email: viewer.email?.trim() || null + } + } catch { + return null + } finally { + release() + } +} + +export async function diagnoseAuth(): Promise { + const envTokenInProcess = process.env.GITLAB_TOKEN + ? 'GITLAB_TOKEN' + : process.env.GLAB_TOKEN + ? 'GLAB_TOKEN' + : null + try { + // Why: a host-global diagnostic must not wake an unrelated default WSL distro. + const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { + allowDefaultWslFallback: false + }) + const output = `${stdout}\n${stderr}` + const hosts = parseGlabAuthStatusHosts(output) + // Why: refreshing auth must advance the provider cache key past a stale null result. + rememberGlabKnownHosts(hosts) + return { + glabAvailable: true, + authenticated: + /logged in|authenticated|token/i.test(output) && !/not logged in/i.test(output), + hosts, + activeHost: hosts[0] ?? null, + envTokenInProcess, + error: null + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + glabAvailable: !/ENOENT|not found|spawn/i.test(message), + authenticated: false, + hosts: [], + activeHost: null, + envTokenInProcess, + error: message + } + } +} + +function parseRateLimitHeader( + headers: Record, + keys: readonly string[] +): number | null { + for (const key of keys) { + const parsed = Number.parseInt(headers[key], 10) + if (Number.isFinite(parsed)) { + return parsed + } + } + return null +} + +function parseRateLimitResetAt(headers: Record): number | null { + const numeric = parseRateLimitHeader(headers, ['ratelimit-reset', 'x-ratelimit-reset']) + if (numeric !== null) { + return numeric + } + const resetTime = headers['ratelimit-resettime'] ?? headers['x-ratelimit-resettime'] + if (!resetTime) { + return null + } + const millis = Date.parse(resetTime) + return Number.isFinite(millis) ? Math.floor(millis / 1000) : null +} + +function parseGitLabRateLimitSnapshot( + headers: Record, + host: string | null +): GitLabRateLimitSnapshot { + const limit = parseRateLimitHeader(headers, ['ratelimit-limit', 'x-ratelimit-limit']) + const remaining = parseRateLimitHeader(headers, ['ratelimit-remaining', 'x-ratelimit-remaining']) + const resetAt = parseRateLimitResetAt(headers) + return { + host, + fetchedAt: Date.now(), + rest: + limit === null && remaining === null && resetAt === null + ? null + : { + limit: limit ?? 0, + remaining: remaining ?? 0, + resetAt + } + } +} + +/** @internal — test-only */ +export function _resetGitLabRateLimitCache(): void { + gitLabRateLimitCache.clear() +} + +/** @internal — test-only */ +export function _getGitLabRateLimitCacheSize(): number { + return gitLabRateLimitCache.size +} + +function pruneGitLabRateLimitCache(now = Date.now()): void { + for (const [cacheKey, snapshot] of gitLabRateLimitCache) { + if (now - snapshot.fetchedAt >= GITLAB_RATE_LIMIT_CACHE_TTL_MS) { + gitLabRateLimitCache.delete(cacheKey) + } + } + while (gitLabRateLimitCache.size > GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES) { + const oldestKey = gitLabRateLimitCache.keys().next().value + if (oldestKey === undefined) { + break + } + gitLabRateLimitCache.delete(oldestKey) + } +} + +function rememberGitLabRateLimitSnapshot( + cacheKey: string, + snapshot: GitLabRateLimitSnapshot +): void { + pruneGitLabRateLimitCache() + // Why: self-managed hostnames come from repo config; keep this cache bounded across many transient hosts. + gitLabRateLimitCache.delete(cacheKey) + gitLabRateLimitCache.set(cacheKey, snapshot) + pruneGitLabRateLimitCache() +} + +export async function getRateLimit(options?: { + force?: boolean + host?: string | null +}): Promise { + const host = options?.host?.trim() || null + const cacheKey = host ?? 'default' + pruneGitLabRateLimitCache() + const cached = gitLabRateLimitCache.get(cacheKey) + if (!options?.force && cached && Date.now() - cached.fetchedAt < GITLAB_RATE_LIMIT_CACHE_TTL_MS) { + return { ok: true, snapshot: cached } + } + + await acquire() + try { + // Why: GitLab exposes REST budget headers inconsistently; a null bucket means this host omitted them. + const args = host ? ['--hostname', host, 'user'] : ['user'] + const { headers } = await glabApiWithHeaders(args) + const snapshot = parseGitLabRateLimitSnapshot(headers, host) + rememberGitLabRateLimitSnapshot(cacheKey, snapshot) + return { ok: true, snapshot } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } finally { + release() + } +} + +/** Resolve a project's full GitLab project ref (host + path); null for non-GitLab remotes. */ diff --git a/src/main/gitlab/merge-request-list.ts b/src/main/gitlab/merge-request-list.ts new file mode 100644 index 00000000000..b8a2e7148bf --- /dev/null +++ b/src/main/gitlab/merge-request-list.ts @@ -0,0 +1,164 @@ +import type { ListMergeRequestsResult, MRListState } from '../../shared/gitlab-types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import { mapMRToWorkItem } from './mappers' +import { + acquire, + classifyListFetchError, + getGlabKnownHosts, + glabApiWithHeaders, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + parseGlabJsonList, + release, + resolveIssueSource, + type LocalGitExecOptions +} from './gl-utils' +import { encodedProject } from './project-path-encoding' + +function mrListStateFlags(state: MRListState): string[] { + switch (state) { + case 'opened': + return [] + case 'merged': + return ['--merged'] + case 'closed': + return ['--closed'] + case 'all': + return ['--all'] + } +} + +/** List merge requests for a project via glab CLI, which handles self-hosted auth and project selection. */ +export async function listMergeRequests( + repoPath: string, + state: MRListState = 'opened', + page = 1, + perPage = 20, + preference?: IssueSourcePreference, + query?: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) + // Why: MRs live on the user's fork (origin); route through the preference resolver so fork workflows share plumbing. + const { source: projectRef } = await resolveIssueSource( + repoPath, + preference, + knownHosts, + connectionId, + localGitOptions + ) + if (!projectRef) { + if (connectionId) { + // Why: SSH-backed repos have no local cwd; a cwd-less glab could resolve an unrelated project. + return { + items: [], + page, + perPage, + totalCount: 0, + totalPages: 0, + error: { + type: 'not_found', + message: 'No GitLab project found for this repository.' + } + } + } + // Why: fallback — let glab infer the project from cwd when the host isn't in getGlabKnownHosts. + const stateFlag = mrListStateFlags(state) + // Why: apply the same search as the API path, else queries are silently ignored on cwd-inferred repos (#6263). + const searchFlag = query?.trim() ? ['--search', query.trim()] : [] + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'mr', + 'list', + '--output', + 'json', + '--per-page', + String(perPage), + '--page', + String(page), + '--order', + 'updated_at', + '--sort', + 'desc', + ...stateFlag, + ...searchFlag + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = parseGlabJsonList[0]>(stdout) + return { + items: data.map((d) => mapMRToWorkItem(d, 'unknown')), + page, + perPage, + // Why: the CLI omits x-total headers, so totals are approximate. + totalCount: data.length, + totalPages: data.length < perPage ? page : page + 1 + } + } catch (err) { + return { + items: [], + page, + perPage, + totalCount: 0, + totalPages: 0, + error: classifyListFetchError(err) + } + } finally { + release() + } + } + // Why: GitLab's API uses an absent state param to mean "any"; drop it for 'all'. + const stateParam = state === 'all' ? '' : `&state=${state}` + const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' + const path = + `projects/${encodedProject(projectRef.path)}/merge_requests?` + + `page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc&with_merge_status_recheck=false${stateParam}${searchParam}` + const repoId = projectRef.path + + await acquire() + try { + const { body, headers } = await glabApiWithHeaders( + [...glabHostnameArgs(projectRef, connectionId), path], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = parseGlabJsonList[0]>(body) + return { + items: data.map((d) => mapMRToWorkItem(d, repoId, projectRef)), + page, + perPage, + totalCount: parseHeaderInt(headers['x-total'], 0), + // Why: GitLab may omit x-total-pages for 'all' or large per_page; fall back to ceil(total/perPage). + totalPages: + parseHeaderInt(headers['x-total-pages'], 0) || + Math.max(1, Math.ceil(parseHeaderInt(headers['x-total'], 0) / perPage)) + } + } catch (err) { + return { + items: [], + page, + perPage, + totalCount: 0, + totalPages: 0, + error: classifyListFetchError(err) + } + } finally { + release() + } +} + +function parseHeaderInt(value: string | undefined, fallback: number): number { + if (!value) { + return fallback + } + const parsed = Number.parseInt(value, 10) + return Number.isFinite(parsed) ? parsed : fallback +} + +/** + * Fetch a work item (MR or issue) by explicit project ref + iid + type. + * Used by the paste-URL flow, where the URL determines the project directly. + */ diff --git a/src/main/gitlab/merge-request-lookup.ts b/src/main/gitlab/merge-request-lookup.ts new file mode 100644 index 00000000000..fa2909d3aff --- /dev/null +++ b/src/main/gitlab/merge-request-lookup.ts @@ -0,0 +1,183 @@ +import type { MRInfo } from '../../shared/gitlab-types' +import { derivePipelineStatus, mapMRInfo } from './mappers' +import { + acquire, + getGlabKnownHosts, + getProjectRef, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + release, + type ProjectRef +} from './gl-utils' +import { encodedProject } from './project-path-encoding' +import { + hasHostedReviewLocalGitOptions, + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../source-control/hosted-review-git-options' +import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch' + +type HostedReviewLocalGitOptions = ReturnType + +function hostedReviewLocalGitOptionArgs( + options: HostedReviewExecutionOptions = {} +): [] | [HostedReviewLocalGitOptions] { + return hasHostedReviewLocalGitOptions(options) ? [getHostedReviewLocalGitOptions(options)] : [] +} + +export async function getProjectSlug( + repoPath: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const localGitArgs = hostedReviewLocalGitOptionArgs(options) + const knownHosts = await getGlabKnownHosts(connectionId, localGitArgs[0]) + return getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) +} + +/** + * Fetch a single merge request with pipeline status rolled up. + * Returns null when the MR doesn't exist or glab fails. + */ +export async function getMergeRequest( + repoPath: string, + iid: number, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const localGitArgs = hostedReviewLocalGitOptionArgs(options) + const localGitOptions = localGitArgs[0] ?? {} + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) + const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) + await acquire() + try { + const args = projectRef + ? [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}` + ] + : ['mr', 'view', String(iid), '--output', 'json'] + const { stdout } = await glabExecFileAsync( + args, + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) as Parameters[0] & { + head_pipeline?: { status?: string } | null + pipeline?: { status?: string } | null + } + // Why: older GitLab instances expose `pipeline` instead of `head_pipeline`; try both. + const pipelineStatus = derivePipelineStatus(data.head_pipeline ?? data.pipeline ?? null) + return mapMRInfo(data, pipelineStatus) + } catch { + return null + } finally { + release() + } +} + +/** + * Find the merge request whose source branch matches the given name. + * Returns the most recently updated MR for the branch, or null when none exists. + */ +export async function getMergeRequestForBranch( + repoPath: string, + branch: string, + linkedMRIid?: number | null, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {}, + // Why: when true, a failed lookup throws instead of returning null, so callers never report a false not_found. + throwOnFailure = false +): Promise { + const branchName = branch.replace(/^refs\/heads\//, '') + if (!branchName && linkedMRIid == null) { + return null + } + const localGitArgs = hostedReviewLocalGitOptionArgs(options) + const localGitOptions = localGitArgs[0] ?? {} + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) + const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) + if (!projectRef) { + return null + } + await acquire() + try { + if (branchName) { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + // Why: GitLab does not proactively recompute merge status on list endpoints, so this row + // can sit at `unchecked` forever — and the sidebar merge button gates on MERGEABLE. Ask + // for the async recalculation (best-effort; ignored for non-Developers when + // `restrict_merge_status_recheck` is on) so polling converges instead of stalling. + `projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1&with_merge_status_recheck=true` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) as (Parameters[0] & { + head_pipeline?: { status?: string } | null + pipeline?: { status?: string } | null + })[] + if (Array.isArray(data) && data.length > 0) { + const raw = data[0] + // Why: older GitLab list payloads expose `pipeline` instead of `head_pipeline`. + const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) + const info = mapMRInfo(raw, pipelineStatus) + // Why (#9171): discard a non-open implicit branch match on the repo + // default branch and fall through to the linked-iid fallback below. + const hideOnDefaultBranch = await shouldHideNonOpenReviewOnDefaultBranch({ + state: info.state, + reviewNumber: info.number, + linkedReviewNumber: linkedMRIid, + branchName, + repoPath, + connectionId, + localGitOptions + }) + if (!hideOnDefaultBranch) { + return info + } + } + } + if (typeof linkedMRIid !== 'number') { + return null + } + // Why: create-from-MR worktrees may rename the branch; fall back to the durable linked iid. + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const raw = JSON.parse(stdout) as Parameters[0] & { + head_pipeline?: { status?: string } | null + pipeline?: { status?: string } | null + } + const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) + return mapMRInfo(raw, pipelineStatus) + } catch (error) { + if (throwOnFailure) { + throw error + } + return null + } finally { + release() + } +} + +/** + * Like getMergeRequestForBranch but throws glab failures instead of returning null, so callers report 'unavailable' not a false "not found". + */ +export function getMergeRequestForBranchOrThrow( + repoPath: string, + branch: string, + linkedMRIid?: number | null, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + return getMergeRequestForBranch(repoPath, branch, linkedMRIid, connectionId, options, true) +} diff --git a/src/main/gitlab/merge-request-project-resolution.ts b/src/main/gitlab/merge-request-project-resolution.ts new file mode 100644 index 00000000000..4a28a3644ed --- /dev/null +++ b/src/main/gitlab/merge-request-project-resolution.ts @@ -0,0 +1,33 @@ +import type { IssueSourcePreference } from '../../shared/repo-types' +import { + getGlabKnownHosts, + resolveIssueSource, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' + +export async function withProjectRef( + repoPath: string, + preference: IssueSourcePreference | undefined, + connectionId: string | null | undefined, + explicitProjectRef: ProjectRef | null | undefined, + fn: (projectRef: ProjectRef, repoFlag: string) => Promise, + fallback: T, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const projectRef = + explicitProjectRef ?? + ( + await resolveIssueSource( + repoPath, + preference, + await getGlabKnownHosts(connectionId, localGitOptions), + connectionId, + localGitOptions + ) + ).source + if (!projectRef) { + return fallback + } + return fn(projectRef, projectRef.path) +} diff --git a/src/main/gitlab/merge-request-review-mutations.ts b/src/main/gitlab/merge-request-review-mutations.ts new file mode 100644 index 00000000000..0b27e0026c4 --- /dev/null +++ b/src/main/gitlab/merge-request-review-mutations.ts @@ -0,0 +1,283 @@ +import type { + GitLabAssignableUser, + GitLabDiscussionResolveResult, + GitLabMRInlineCommentInput, + GitLabMRReviewersUpdateResult, + MRComment +} from '../../shared/gitlab-types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import { + acquire, + classifyGlabError, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + release, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' +import { encodedProject } from './project-path-encoding' +import { withProjectRef } from './merge-request-project-resolution' + +export async function addMRComment( + repoPath: string, + iid: number, + body: string, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { + return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'POST', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/notes`, + '-f', + `body=${body}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) as { + id?: number + author?: { username?: string; avatar_url?: string; state?: string } | null + body?: string + created_at?: string + } + return { + ok: true, + comment: { + id: data.id ?? Date.now(), + author: data.author?.username ?? 'You', + authorAvatarUrl: data.author?.avatar_url ?? '', + body: data.body ?? body, + createdAt: data.created_at ?? new Date().toISOString(), + url: '', + isBot: data.author?.state === 'bot' + } + } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +export async function addMRInlineComment( + repoPath: string, + iid: number, + input: GitLabMRInlineCommentInput, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { + return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + const body = input.body.trim() + if (!body) { + return { ok: false, error: 'Comment body is required' } + } + await acquire() + try { + const oldPath = input.oldPath ?? input.path + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'POST', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/discussions`, + '-f', + `body=${body}`, + '-f', + 'position[position_type]=text', + '-f', + `position[base_sha]=${input.baseSha}`, + '-f', + `position[start_sha]=${input.startSha}`, + '-f', + `position[head_sha]=${input.headSha}`, + '-f', + `position[old_path]=${oldPath}`, + '-f', + `position[new_path]=${input.path}`, + '-f', + `position[new_line]=${input.line}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) as { + id?: string + notes?: { + id?: number + author?: { username?: string; avatar_url?: string; state?: string } | null + body?: string + created_at?: string + position?: { new_path?: string; new_line?: number } | null + }[] + } + const note = data.notes?.[0] + return { + ok: true, + comment: { + id: note?.id ?? Date.now(), + author: note?.author?.username ?? 'You', + authorAvatarUrl: note?.author?.avatar_url ?? '', + body: note?.body ?? body, + createdAt: note?.created_at ?? new Date().toISOString(), + url: '', + threadId: data.id, + isResolved: false, + isBot: note?.author?.state === 'bot', + path: note?.position?.new_path ?? input.path, + line: note?.position?.new_line ?? input.line + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +export async function resolveMRDiscussion( + repoPath: string, + iid: number, + discussionId: string, + resolved: boolean, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + return withProjectRef( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + const trimmedDiscussionId = discussionId.trim() + if (!trimmedDiscussionId) { + return { ok: false, error: 'Discussion id is required' } + } + await acquire() + try { + // Why: GitLab resolves/reopens the whole discussion thread, not a single note. + await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'PUT', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/discussions/${encodeURIComponent(trimmedDiscussionId)}`, + '-f', + `resolved=${resolved ? 'true' : 'false'}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +function mapGitLabReviewer(raw: { + id?: number + username?: string | null + name?: string | null + avatar_url?: string | null + state?: string | null +}): GitLabAssignableUser | null { + if (!raw.username) { + return null + } + return { + ...(typeof raw.id === 'number' ? { id: raw.id } : {}), + username: raw.username, + name: raw.name ?? null, + avatarUrl: raw.avatar_url ?? '', + ...(raw.state !== undefined ? { state: raw.state } : {}) + } +} + +export async function updateMRReviewers( + repoPath: string, + iid: number, + reviewerIds: number[], + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + return withProjectRef( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + await acquire() + try { + const fields = + reviewerIds.length > 0 + ? reviewerIds.flatMap((id) => ['-f', `reviewer_ids[]=${id}`]) + : ['-f', 'reviewer_ids='] + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'PUT', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, + ...fields + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) as { reviewers?: Parameters[0][] } + return { + ok: true, + reviewers: (data.reviewers ?? []) + .map(mapGitLabReviewer) + .filter((u): u is GitLabAssignableUser => !!u) + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} diff --git a/src/main/gitlab/merge-request-state-mutations.ts b/src/main/gitlab/merge-request-state-mutations.ts new file mode 100644 index 00000000000..fb5d33bb670 --- /dev/null +++ b/src/main/gitlab/merge-request-state-mutations.ts @@ -0,0 +1,143 @@ +import type { IssueSourcePreference } from '../../shared/repo-types' +import { + acquire, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + release, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' +import { withProjectRef } from './merge-request-project-resolution' + +export async function closeMR( + repoPath: string, + iid: number, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + return withProjectRef<{ ok: true } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef, repoFlag) => { + await acquire() + try { + await glabExecFileAsync( + [ + 'mr', + 'close', + String(iid), + '-R', + repoFlag, + ...glabHostnameArgs(projectRef, connectionId) + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + // Why: glab exits non-zero when the MR is already closed — treat as success. + if (msg.toLowerCase().includes('already')) { + return { ok: true } + } + return { ok: false, error: msg } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +export async function reopenMR( + repoPath: string, + iid: number, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + return withProjectRef<{ ok: true } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef, repoFlag) => { + await acquire() + try { + await glabExecFileAsync( + [ + 'mr', + 'reopen', + String(iid), + '-R', + repoFlag, + ...glabHostnameArgs(projectRef, connectionId) + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (msg.toLowerCase().includes('already')) { + return { ok: true } + } + return { ok: false, error: msg } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +export async function mergeMR( + repoPath: string, + iid: number, + method: 'merge' | 'squash' | 'rebase' = 'merge', + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + return withProjectRef<{ ok: true } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef, repoFlag) => { + await acquire() + try { + // Why: omitting both --squash and --rebase yields a regular merge commit. + const methodFlag = + method === 'squash' ? ['--squash'] : method === 'rebase' ? ['--rebase'] : [] + await glabExecFileAsync( + [ + 'mr', + 'merge', + String(iid), + '-R', + repoFlag, + '--yes', + ...methodFlag, + ...glabHostnameArgs(projectRef, connectionId) + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} diff --git a/src/main/gitlab/merge-request-update.ts b/src/main/gitlab/merge-request-update.ts new file mode 100644 index 00000000000..0c82cfcf8ca --- /dev/null +++ b/src/main/gitlab/merge-request-update.ts @@ -0,0 +1,84 @@ +import type { IssueSourcePreference } from '../../shared/repo-types' +import { + acquire, + classifyGlabError, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + release, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' +import { encodedProject } from './project-path-encoding' +import { withProjectRef } from './merge-request-project-resolution' + +export async function updateMR( + repoPath: string, + iid: number, + updates: { + title?: string + body?: string + addLabels?: string[] + removeLabels?: string[] + }, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + return withProjectRef<{ ok: true } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + const fields: string[] = [] + const title = updates.title?.trim() + if (updates.title !== undefined) { + if (!title) { + return { ok: false, error: 'Title is required' } + } + fields.push(`title=${title}`) + } + if (updates.body !== undefined) { + fields.push(`description=${updates.body}`) + } + const addLabels = (updates.addLabels ?? []).filter((label) => label.trim().length > 0) + const removeLabels = (updates.removeLabels ?? []).filter((label) => label.trim().length > 0) + if (addLabels.length > 0) { + fields.push(`add_labels=${addLabels.join(',')}`) + } + if (removeLabels.length > 0) { + fields.push(`remove_labels=${removeLabels.join(',')}`) + } + if (fields.length === 0) { + return { ok: true } + } + + await acquire() + try { + await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'PUT', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, + ...fields.flatMap((field) => ['-f', field]) + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +/** Re-export so callers don't need to know the gl-utils module split. */ diff --git a/src/main/gitlab/pipeline-job-mutations.ts b/src/main/gitlab/pipeline-job-mutations.ts new file mode 100644 index 00000000000..48cd2b2e6c5 --- /dev/null +++ b/src/main/gitlab/pipeline-job-mutations.ts @@ -0,0 +1,128 @@ +import type { + GitLabJobTraceResult, + GitLabPipelineJob, + GitLabRetryJobResult +} from '../../shared/gitlab-types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import { + acquire, + classifyGlabError, + classifyJobLogError, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + isMissingJobLogError, + release, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' +import { encodedProject } from './project-path-encoding' +import { withProjectRef } from './merge-request-project-resolution' + +function mapRetriedPipelineJob( + data: { + id?: number + pipeline?: { id?: number | null } | null + name?: string + stage?: string + status?: string + web_url?: string + duration?: number | null + }, + fallbackJobId: number +): GitLabPipelineJob { + return { + id: data.id ?? fallbackJobId, + ...(typeof data.pipeline?.id === 'number' ? { pipelineId: data.pipeline.id } : {}), + name: data.name ?? '', + stage: data.stage ?? '', + status: data.status ?? '', + webUrl: data.web_url ?? '', + duration: typeof data.duration === 'number' ? data.duration : null + } +} + +export async function getJobTrace( + repoPath: string, + jobId: number, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + return withProjectRef( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/trace` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true, trace: stdout } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + // A job with no log is an empty log, not a failure: surfacing the 404 would + // pin an error on the Checks row for a job canceled before it started. + if (isMissingJobLogError(msg)) { + return { ok: true, trace: '' } + } + return { ok: false, error: classifyJobLogError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +export async function retryJob( + repoPath: string, + jobId: number, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + return withProjectRef( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'POST', + `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/retry` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const trimmed = stdout.trim() + return { + ok: true, + ...(trimmed ? { job: mapRetriedPipelineJob(JSON.parse(trimmed), jobId) } : {}) + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} diff --git a/src/main/gitlab/work-item-queries.ts b/src/main/gitlab/work-item-queries.ts new file mode 100644 index 00000000000..4e6e3c3fc53 --- /dev/null +++ b/src/main/gitlab/work-item-queries.ts @@ -0,0 +1,252 @@ +import type { ClassifiedError } from '../../shared/classified-error' +import type { + GitLabPagedResult, + GitLabTodo, + GitLabWorkItem, + MRListState +} from '../../shared/gitlab-types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import { mapIssueToWorkItem, mapMRToWorkItem } from './mappers' +import { + acquire, + classifyListFetchError, + getGlabKnownHosts, + getProjectRef, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + parseGlabJsonList, + release, + resolveIssueSource, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' +import { encodedProject } from './project-path-encoding' +import type { IssueListState } from './issues' +import { listMergeRequests } from './merge-request-list' + +export async function getWorkItemByProjectRef( + repoPath: string, + projectRef: ProjectRef, + iid: number, + type: 'issue' | 'mr', + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + await acquire() + try { + const resource = type === 'mr' ? 'merge_requests' : 'issues' + const { stdout } = await glabExecFileAsync( + [ + 'api', + // Why: preserve the pasted URL's explicit host so cwd remotes can't redirect the lookup. + ...(projectRef.host ? ['--hostname', projectRef.host] : []), + `projects/${encodedProject(projectRef.path)}/${resource}/${iid}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) + if (type === 'mr') { + return mapMRToWorkItem(data, projectRef.path, projectRef) + } + return mapIssueToWorkItem(data, projectRef.path, projectRef) + } catch { + return null + } finally { + release() + } +} + +function mrStateToIssueState(state: MRListState): IssueListState | null { + // Why: issues have no 'merged' state; return null so the issues fetch is skipped, not mis-mapped. + switch (state) { + case 'opened': + return 'opened' + case 'closed': + return 'closed' + case 'all': + return 'all' + case 'merged': + return null + } +} + +export async function listWorkItems( + repoPath: string, + state: MRListState = 'opened', + page = 1, + perPage = 20, + preference?: IssueSourcePreference, + query?: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise> { + const issueState = mrStateToIssueState(state) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) + const { source: projectRef } = await resolveIssueSource( + repoPath, + preference, + knownHosts, + connectionId, + localGitOptions + ) + if (!projectRef) { + return { + items: [], + page, + perPage, + totalCount: 0, + totalPages: 0, + error: { + type: 'not_found', + message: 'No GitLab project found for this repository.' + } + } + } + // Why: fan out both reads so latency is the slower of the two, not the sum. + // Why read the raw issues API (not listIssues): IssueInfo strips updated_at, which the combined sort needs. + const [mrs, issues] = await Promise.all([ + listMergeRequests( + repoPath, + state, + page, + perPage, + preference, + query, + connectionId, + localGitOptions + ), + issueState === null + ? Promise.resolve({ + items: [] as GitLabWorkItem[], + error: undefined as ClassifiedError | undefined + }) + : fetchIssuesAsWorkItems( + repoPath, + projectRef, + issueState, + page, + perPage, + query, + connectionId, + localGitOptions + ) + ]) + const merged = [...mrs.items, ...issues.items].sort((a, b) => + (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') + ) + // Why: MR-side error wins — issues can be disabled per project, making its error less informative. + const error: ClassifiedError | undefined = mrs.error ?? issues.error + return { + items: merged, + page, + perPage, + // Why: approximate totals — GitLab can't paginate across MRs+issues as one set, so MR totals stand in. + totalCount: mrs.totalCount, + totalPages: mrs.totalPages, + ...(error ? { error } : {}) + } +} + +export async function fetchIssuesAsWorkItems( + repoPath: string, + projectRef: ProjectRef, + state: IssueListState, + page: number, + perPage: number, + query?: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ items: GitLabWorkItem[]; error: ClassifiedError | undefined }> { + await acquire() + try { + const stateParam = state === 'all' ? '' : `&state=${state}` + const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/issues?page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}${searchParam}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = parseGlabJsonList[0]>(stdout) + return { + items: data.map((d) => mapIssueToWorkItem(d, projectRef.path, projectRef)), + error: undefined + } + } catch (err) { + return { + items: [], + error: classifyListFetchError(err) + } + } finally { + release() + } +} + +/** + * List the authenticated user's GitLab todos (gitlab.com/dashboard/todos). + * User-scoped, so cwd is irrelevant; repoPath only satisfies the IPC handler's path-validation guard. + */ +export async function listTodos( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const projectRef = await getProjectRef( + repoPath, + await getGlabKnownHosts(connectionId, localGitOptions), + connectionId, + localGitOptions + ) + if (connectionId && !projectRef) { + return [] + } + await acquire() + try { + // Why: per_page=50 keeps this cross-project view cheap; the UI only shows top-priority todos. + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...(projectRef ? glabHostnameArgs(projectRef, connectionId) : []), + 'todos?state=pending&per_page=50' + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + type RESTTodo = { + id?: number + action_name?: string + target_type?: string + target?: { + iid?: number + title?: string + web_url?: string + } | null + target_url?: string + author?: { username?: string | null; avatar_url?: string | null } | null + project?: { path_with_namespace?: string } | null + updated_at?: string + state?: string + } + const data = JSON.parse(stdout) as RESTTodo[] + return data.map((t) => ({ + id: t.id ?? 0, + actionName: t.action_name ?? '', + targetType: t.target_type ?? '', + targetIid: typeof t.target?.iid === 'number' ? t.target.iid : null, + targetTitle: t.target?.title ?? '', + targetUrl: t.target_url ?? t.target?.web_url ?? '', + projectPath: t.project?.path_with_namespace ?? '', + authorUsername: t.author?.username ?? '', + authorAvatarUrl: t.author?.avatar_url ?? '', + updatedAt: t.updated_at ?? '', + state: t.state === 'done' ? 'done' : 'pending' + })) + } catch { + // Why: silent empty-list on auth/network failure matches the read-side surface; caller UI signals connectivity. + return [] + } finally { + release() + } +} diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index 2972fa6b531..46c7a430537 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -387,7 +387,7 @@ describe('Jira issue operations', () => { ]) expect(String(jiraRequestMock.mock.calls[0][1])).toContain( - '/rest/api/3/issue/createmeta/10000/issuetypes/10001?' + '/rest/api/3/issue/createmeta/10000/issuetypes/10001?maxResults=100&startAt=0' ) }) diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 976862c0e96..7529c2131da 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -1,1246 +1,13 @@ -/* eslint-disable max-lines -- Why: Jira task reads and mutations share ADF - mapping, multi-site fan-out, and auth-clearing behavior; keeping the API - boundary together avoids subtle drift between operations. */ -import type { - JiraComment, - JiraCreateField, - JiraCreateFieldAllowedValue, - JiraCreateIssueArgs, - JiraCreateIssueResult, - JiraIssue, - JiraIssueFilter, - JiraIssueType, - JiraIssueUpdate, - JiraMutationResult, - JiraPriority, - JiraProject, - JiraProjectStatusOrder, - JiraSite, - JiraSiteSelection, - JiraStatus, - JiraTransition, - JiraUser -} from '../../shared/jira-types' -import { acquire, release } from './request-queue' -import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' -import { clearToken, getClients, isAuthError } from './client' -import { - adfToMarkdownText, - collectAdfMediaAttrs, - textToAdf, - type AdfToMarkdownOptions, - type JiraAdfMediaAttrs -} from './adf-markdown' -import { - extractAttachmentContentIdsFromHtml, - selectPreferredAttachmentIds, - warnIfMediaResolutionIncomplete -} from './attachment-discovery' -import { - createMediaMarkdownResolver, - loadIssueImageAttachments, - type MediaResolutionStats -} from './attachment-images' -import { JiraSummaryLookupError } from '../../shared/jira-summary-lookup' - -const ISSUE_FIELDS = [ - 'summary', - 'description', - 'project', - 'issuetype', - 'status', - 'assignee', - 'reporter', - 'priority', - 'labels', - 'created', - 'updated' -] - -// Why: list/typeahead only need identity + metadata; description ADF parse is the hot-path cost. -const ISSUE_LIST_FIELDS = ISSUE_FIELDS.filter((field) => field !== 'description') - -// Why: detail reads need attachment metadata so inline ADF media can be resolved -// to downloadable image content; list/search omit this for payload size. -const ISSUE_DETAIL_FIELDS = [...ISSUE_FIELDS, 'attachment'] -// `created`/`updated` are required: mapJiraIssue falls back to "now" when they're absent, -// which would silently report the lookup time as the issue's timestamps. -const ISSUE_SUMMARY_FIELDS = ['summary', 'project', 'issuetype', 'status', 'created', 'updated'] -const ISSUE_SUMMARY_TIMEOUT_MS = 30_000 -const ISSUE_SEARCH_TIMEOUT_MS = 30_000 - -type JiraRecord = Record - -type JiraSearchResponse = { - issues?: JiraRecord[] -} - -type JiraPagedResponse = { - startAt?: number - maxResults?: number - total?: number - isLast?: boolean - values?: T[] - issueTypes?: T[] - comments?: T[] - fields?: T[] | Record -} - -type JiraPageItemKey = 'values' | 'issueTypes' | 'comments' - -function clampLimit(limit: number | undefined, fallback = 30): number { - return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100) -} - -type JiraIssueSearchFailure = { - error: unknown - auth: boolean -} - -/** Run against one signal that trips on the caller's abort or the request deadline. */ -async function withJiraDeadline( - signal: AbortSignal | undefined, - timeoutMs: number, - run: (deadlineSignal: AbortSignal) => Promise -): Promise { - const controller = new AbortController() - const abort = (): void => controller.abort() - signal?.addEventListener('abort', abort, { once: true }) - if (signal?.aborted) { - controller.abort() - } - const timer = setTimeout(abort, timeoutMs) - try { - return await run(controller.signal) - } finally { - clearTimeout(timer) - signal?.removeEventListener('abort', abort) - } -} - -function settleJiraSummaryRead(read: Promise, signal: AbortSignal): Promise { - if (signal.aborted) { - return Promise.reject(new Error('Jira summary lookup aborted')) - } - return new Promise((resolve, reject) => { - const handleAbort = (): void => { - cleanup() - reject(new Error('Jira summary lookup aborted')) - } - const cleanup = (): void => signal.removeEventListener('abort', handleAbort) - signal.addEventListener('abort', handleAbort, { once: true }) - void read.then( - (value) => { - cleanup() - resolve(value) - }, - (error: unknown) => { - cleanup() - reject(error) - } - ) - }) -} - -function getErrorStatus(error: unknown): number | null { - if (!error || typeof error !== 'object' || !('status' in error)) { - return null - } - const status = (error as { status?: unknown }).status - return typeof status === 'number' && Number.isFinite(status) ? status : null -} - -function toIssueSearchFailureError(error: unknown): unknown { - const status = getErrorStatus(error) - if ( - status === null || - !(error instanceof Error) || - error.message.startsWith(`Error ${status}:`) - ) { - return error - } - return new Error(`Error ${status}: ${error.message}`) -} - -function shouldSurfaceSiteFailure( - selection: JiraSiteSelection | null | undefined, - entryCount: number -): boolean { - // getClients can resolve an omitted selection to the persisted 'all' choice; - // multi-entry reads need the same resilient fan-out policy as explicit 'all'. - return selection !== 'all' && entryCount <= 1 -} - -function asRecord(value: unknown): JiraRecord { - return value && typeof value === 'object' ? (value as JiraRecord) : {} -} - -function asString(value: unknown, fallback = ''): string { - return typeof value === 'string' ? value : fallback -} - -function asIdentifier(value: unknown): string { - if (typeof value === 'string') { - return value - } - return typeof value === 'number' && Number.isFinite(value) ? String(value) : '' -} - -function asStringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === 'string') - : [] -} - -function asFiniteNumber(value: unknown): number | null { - return typeof value === 'number' && Number.isFinite(value) ? value : null -} - -function getPageItems(response: JiraPagedResponse, key: JiraPageItemKey): T[] { - const keyedItems = response[key] - if (Array.isArray(keyedItems)) { - return keyedItems - } - return response.values ?? [] -} - -function shouldFetchNextPage( - response: JiraPagedResponse, - startAt: number, - items: T[], - requestedMaxResults: number -): boolean { - if (response.isLast === true || items.length === 0) { - return false - } - const total = asFiniteNumber(response.total) - const pageSize = asFiniteNumber(response.maxResults) - if (total !== null) { - return startAt + items.length < total && (pageSize ?? requestedMaxResults) > 0 - } - if (response.isLast === false) { - return (pageSize ?? requestedMaxResults) > 0 - } - return pageSize !== null && items.length >= pageSize -} - -async function fetchPagedRecords( - entry: JiraClientForSite, - key: JiraPageItemKey, - pathForPage: (startAt: number, maxResults: number) => string, - maxResults = 100 -): Promise { - const records: JiraRecord[] = [] - let startAt = 0 - for (let guard = 0; guard < 100; guard += 1) { - const response = await jiraRequest>( - entry, - pathForPage(startAt, maxResults) - ) - const items = getPageItems(response, key) - records.push(...items) - if (!shouldFetchNextPage(response, startAt, items, maxResults)) { - break - } - startAt += asFiniteNumber(response.maxResults) ?? maxResults - } - return records -} - -function avatarUrl(value: unknown): string | undefined { - const avatars = asRecord(value) - return ( - asString(avatars['48x48']) || - asString(avatars['32x32']) || - asString(avatars['24x24']) || - undefined - ) -} - -function mapUser(value: unknown): JiraUser | undefined { - const user = asRecord(value) - // Server/DC users have no accountId; name (login) and key are its stable ids. - const accountId = asString(user.accountId) || asString(user.name) || asString(user.key) - if (!accountId) { - return undefined - } - return { - accountId, - displayName: asString(user.displayName, 'Unknown'), - email: typeof user.emailAddress === 'string' ? user.emailAddress : undefined, - avatarUrl: avatarUrl(user.avatarUrls) - } -} - -function mapProject(value: unknown, site?: JiraSite): JiraProject { - const project = asRecord(value) - return { - id: asString(project.id), - key: asString(project.key), - name: asString(project.name, asString(project.key)), - siteId: site?.id, - siteName: site?.displayName - } -} - -function mapIssueType(value: unknown): JiraIssueType { - const issueType = asRecord(value) - return { - id: asString(issueType.id), - name: asString(issueType.name, 'Issue'), - description: asString(issueType.description) || undefined, - iconUrl: asString(issueType.iconUrl) || undefined, - subtask: typeof issueType.subtask === 'boolean' ? issueType.subtask : undefined - } -} - -function mapCreateFieldAllowedValue(value: unknown): JiraCreateFieldAllowedValue { - const option = asRecord(value) - return { - id: asString(option.id) || undefined, - value: asString(option.value) || undefined, - name: asString(option.name) || undefined - } -} - -function mapCreateField(value: unknown, fallbackKey = ''): JiraCreateField | null { - const field = asRecord(value) - const schema = asRecord(field.schema) - const key = - asString(field.key) || - asString(field.fieldId) || - asString(field.id) || - asString(field.fieldKey) || - fallbackKey - if (!key) { - return null - } - const allowedValues = Array.isArray(field.allowedValues) - ? field.allowedValues.map(mapCreateFieldAllowedValue) - : undefined - return { - key, - name: asString(field.name, key), - required: field.required === true, - schema: { - type: asString(schema.type) || undefined, - items: asString(schema.items) || undefined, - custom: asString(schema.custom) || undefined - }, - allowedValues - } -} - -function getCreateFieldRecords(response: JiraPagedResponse): JiraRecord[] { - if (Array.isArray(response.values)) { - return response.values - } - if (Array.isArray(response.fields)) { - return response.fields - } - if (response.fields && typeof response.fields === 'object') { - return Object.entries(response.fields).map(([key, value]) => ({ - key, - ...asRecord(value) - })) - } - return [] -} - -function mapPriority(value: unknown): JiraPriority | undefined { - const priority = asRecord(value) - const id = asString(priority.id) - if (!id) { - return undefined - } - return { - id, - name: asString(priority.name, 'Priority'), - iconUrl: asString(priority.iconUrl) || undefined - } -} - -function mapStatus(value: unknown): JiraStatus { - const status = asRecord(value) - const category = asRecord(status.statusCategory) - return { - id: asString(status.id), - name: asString(status.name, 'Unknown'), - categoryKey: asString(category.key, 'undefined'), - categoryName: asString(category.name, 'No Category'), - colorName: asString(category.colorName) || undefined - } -} - -function issueUrl(site: JiraSite, key: string): string { - return `${site.siteUrl}/browse/${encodeURIComponent(key)}` -} - -// REST v2 (Server/DC) bodies are plain text; v3 (Cloud) requires ADF documents. -function toBodyText(site: JiraSite, text: string): unknown { - return site.authType === 'server' ? text : textToAdf(text) -} - -export function mapJiraIssue( - site: JiraSite, - raw: JiraRecord, - adfOptions?: AdfToMarkdownOptions -): JiraIssue { - const fields = asRecord(raw.fields) - const key = asString(raw.key) - return { - id: asString(raw.id, key), - key, - siteId: site.id, - siteName: site.displayName, - title: asString(fields.summary, key || 'Untitled issue'), - description: adfToMarkdownText(fields.description, adfOptions), - url: issueUrl(site, key), - project: mapProject(fields.project, site), - issueType: mapIssueType(fields.issuetype), - status: mapStatus(fields.status), - labels: asStringArray(fields.labels), - assignee: mapUser(fields.assignee), - reporter: mapUser(fields.reporter), - priority: mapPriority(fields.priority), - createdAt: asString(fields.created, new Date().toISOString()), - updatedAt: asString(fields.updated, new Date().toISOString()) - } -} - -type MediaRequest = { - attachmentField: unknown - preferredIds: string[] - needCount: number - fallbackRan: boolean - issueKey: string -} - -/** Pooled: HTML/ADF selection only — no binary downloads. */ -function collectIssueMediaRequest(raw: JiraRecord): MediaRequest | undefined { - const fields = asRecord(raw.fields) - const renderedFields = asRecord(raw.renderedFields) - const htmlIds = extractAttachmentContentIdsFromHtml( - asString(renderedFields.description) || undefined - ) - const mediaAttrs = collectAdfMediaAttrs(fields.description) - const selection = selectPreferredAttachmentIds({ - renderedHtmlIds: htmlIds, - attachmentField: fields.attachment, - mediaAttrs - }) - if (selection.needCount === 0 && selection.preferredIds.length === 0) { - return undefined - } - return { - attachmentField: fields.attachment, - preferredIds: selection.preferredIds, - needCount: selection.needCount, - fallbackRan: selection.fallbackRan, - issueKey: asString(raw.key) - } -} - -type PreparedMedia = { - options: AdfToMarkdownOptions - stats: MediaResolutionStats - request: MediaRequest -} - -/** Unpooled: binary downloads + resolver (outside the Jira API semaphore). */ -async function prepareMediaResolver( - client: JiraClientForSite, - request: MediaRequest -): Promise { - if (request.preferredIds.length === 0) { - warnIfMediaResolutionIncomplete({ - siteId: client.site.id, - issueKey: request.issueKey, - needCount: request.needCount, - preferredIdCount: 0, - resolvedCount: 0, - fallbackRan: request.fallbackRan - }) - return undefined - } - const images = await loadIssueImageAttachments( - client, - request.attachmentField, - request.preferredIds - ) - if (images.length === 0) { - warnIfMediaResolutionIncomplete({ - siteId: client.site.id, - issueKey: request.issueKey, - needCount: request.needCount, - preferredIdCount: request.preferredIds.length, - resolvedCount: 0, - fallbackRan: request.fallbackRan - }) - return undefined - } - const stats: MediaResolutionStats = { attachmentResolvedCount: 0 } - const resolveMedia = createMediaMarkdownResolver(images, request.preferredIds, stats) - return { - options: { resolveMedia }, - stats, - request - } -} - -function flushMediaResolutionWarn(client: JiraClientForSite, prepared: PreparedMedia): void { - warnIfMediaResolutionIncomplete({ - siteId: client.site.id, - issueKey: prepared.request.issueKey, - needCount: prepared.request.needCount, - preferredIdCount: prepared.request.preferredIds.length, - resolvedCount: prepared.stats.attachmentResolvedCount, - fallbackRan: prepared.request.fallbackRan - }) -} - -function sortAndLimitIssues(issues: JiraIssue[], limit: number): JiraIssue[] { - return issues - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) - .slice(0, limit) -} - -function filterToJql(filter: JiraIssueFilter): string { - if (filter === 'assigned') { - return 'assignee = currentUser() AND resolution = Unresolved ORDER BY updated DESC' - } - if (filter === 'reported') { - return 'reporter = currentUser() AND resolution = Unresolved ORDER BY updated DESC' - } - if (filter === 'done') { - return 'assignee = currentUser() AND resolution IS NOT EMPTY ORDER BY updated DESC' - } - return 'resolution = Unresolved ORDER BY updated DESC' -} - -async function searchIssuesForClient( - entry: JiraClientForSite, - jql: string, - limit: number, - signal?: AbortSignal -): Promise { - // Server/DC only has the classic /search resource; /search/jql is Cloud-only. - const searchPath = - entry.site.authType === 'server' - ? `${apiBasePath(entry.site)}/search` - : '/rest/api/3/search/jql' - const result = await jiraRequest(entry, searchPath, { - method: 'POST', - body: JSON.stringify({ - jql, - maxResults: limit, - fields: ISSUE_LIST_FIELDS - }), - signal - }) - return (result.issues ?? []).map((issue) => mapJiraIssue(entry.site, issue)) -} - -export async function listIssues( - filter: JiraIssueFilter = 'assigned', - limit = 30, - siteId?: JiraSiteSelection | null -): Promise { - return searchIssues(filterToJql(filter), limit, siteId) -} - -export async function searchIssues( - jql: string, - limit = 30, - siteId?: JiraSiteSelection | null, - signal?: AbortSignal -): Promise { - const entries = getClients(siteId) - if (entries.length === 0 || !jql.trim()) { - return [] - } - const safeLimit = clampLimit(limit) - const failures: (JiraIssueSearchFailure | undefined)[] = Array.from({ length: entries.length }) - const surfaceSiteFailure = shouldSurfaceSiteFailure(siteId, entries.length) - const results = await withJiraDeadline(signal, ISSUE_SEARCH_TIMEOUT_MS, (requestSignal) => - Promise.all( - entries.map(async (entry, index) => { - // Why: queueing on an abandoned search would keep occupying the shared Jira pool. - await acquire(requestSignal) - try { - return await searchIssuesForClient(entry, jql.trim(), safeLimit, requestSignal) - } catch (error) { - if (requestSignal.aborted) { - // Abandoned by the caller: not a site failure, so don't clear tokens or mask a real one. - throw error - } - const authFailure = isAuthError(error) - if (authFailure) { - clearToken(entry.site.id) - } - if (surfaceSiteFailure) { - throw toIssueSearchFailureError(error) - } - console.warn('[jira] searchIssues failed:', error) - failures[index] = { error: toIssueSearchFailureError(error), auth: authFailure } - return [] as JiraIssue[] - } finally { - release() - } - }) - ) - ) - // 'all' fan-out: only surface an error when every connected site failed, so a - // partial success (or a genuinely empty result) is not reported as an error. - const recordedFailures = failures.filter( - (failure): failure is JiraIssueSearchFailure => failure !== undefined - ) - if (recordedFailures.length === entries.length) { - throw (recordedFailures.find((failure) => !failure.auth) ?? recordedFailures[0]).error - } - return entries.length === 1 - ? results.flat().slice(0, safeLimit) - : sortAndLimitIssues(results.flat(), safeLimit) -} - -export async function getIssue( - key: string, - siteId?: JiraSiteSelection | null -): Promise { - const entries = getClients(siteId) - for (const entry of entries) { - let mediaRequest: MediaRequest | undefined - let issue: JiraRecord | undefined - let held = false - try { - await acquire() - held = true - const params = new URLSearchParams({ - fields: ISSUE_DETAIL_FIELDS.join(','), - expand: 'renderedFields' - }) - issue = await jiraRequest( - entry, - `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?${params.toString()}` - ) - // Why: keep only JSON under the pool; binary downloads fan out after release. - mediaRequest = collectIssueMediaRequest(issue) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - if (shouldSurfaceSiteFailure(siteId, entries.length)) { - throw error - } - } else { - console.warn('[jira] getIssue failed:', error) - } - continue - } finally { - if (held) { - held = false - release() - } - } - - try { - if (!issue) { - continue - } - const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined - const mapped = mapJiraIssue(entry.site, issue, prepared?.options) - if (prepared) { - flushMediaResolutionWarn(entry, prepared) - } - return mapped - } catch (error) { - console.warn('[jira] getIssue media load failed:', error) - return mapJiraIssue(entry.site, issue) - } - } - return null -} - -export async function getIssueSummary( - key: string, - siteId: string, - signal?: AbortSignal -): Promise { - let entries: JiraClientForSite[] - try { - entries = getClients(siteId) - } catch (error) { - throw new JiraSummaryLookupError('auth', error) - } - const entry = entries.find((candidate) => candidate.site.id === siteId) - if (!entry) { - throw new JiraSummaryLookupError('disconnected') - } - - return withJiraDeadline(signal, ISSUE_SUMMARY_TIMEOUT_MS, async (requestSignal) => { - await acquire(requestSignal) - try { - const params = new URLSearchParams({ fields: ISSUE_SUMMARY_FIELDS.join(',') }) - const issue = await settleJiraSummaryRead( - jiraRequest( - entry, - `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?${params.toString()}`, - { signal: requestSignal } - ), - requestSignal - ) - return mapJiraIssue(entry.site, issue) - } catch (error) { - if (isAuthError(error)) { - throw new JiraSummaryLookupError('auth', error) - } - if (getErrorStatus(error) === 404) { - throw new JiraSummaryLookupError('not-found', error) - } - throw new JiraSummaryLookupError('read-failed', error) - } finally { - release() - } - }) -} - -export async function createIssue(args: JiraCreateIssueArgs): Promise { - const entry = getClients(args.siteId)[0] - if (!entry) { - return { ok: false, error: 'Not connected to Jira.' } - } - const title = args.title.trim() - if (!title) { - return { ok: false, error: 'Title is required.' } - } - - await acquire() - try { - const fields: JiraRecord = { - project: { id: args.projectId }, - issuetype: { id: args.issueTypeId }, - summary: title - } - if (args.description?.trim()) { - fields.description = toBodyText(entry.site, args.description.trim()) - } - for (const [fieldKey, value] of Object.entries(args.customFields ?? {})) { - if (!fieldKey || value === undefined || value === null || value === '') { - continue - } - fields[fieldKey] = value - } - const created = await jiraRequest<{ id: string; key: string; self: string }>( - entry, - `${apiBasePath(entry.site)}/issue`, - { - method: 'POST', - body: JSON.stringify({ fields }) - } - ) - return { ok: true, id: created.id, key: created.key, url: issueUrl(entry.site, created.key) } - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - return { ok: false, error: error instanceof Error ? error.message : 'Failed to create issue.' } - } finally { - release() - } -} - -export async function updateIssue( - key: string, - updates: JiraIssueUpdate, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return { ok: false, error: 'Not connected to Jira.' } - } - await acquire() - try { - const fields: JiraRecord = {} - if (updates.title !== undefined) { - fields.summary = updates.title - } - if (updates.labels !== undefined) { - fields.labels = updates.labels - } - if (updates.priorityId !== undefined) { - fields.priority = updates.priorityId ? { id: updates.priorityId } : null - } - const issueBase = `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}` - if (Object.keys(fields).length > 0) { - await jiraRequest(entry, issueBase, { - method: 'PUT', - body: JSON.stringify({ fields }) - }) - } - if (updates.assigneeAccountId !== undefined) { - // Server/DC identifies assignees by username (`name`), not accountId; - // mapUser stores the Server username in the accountId slot. - const assigneeBody = - entry.site.authType === 'server' - ? { name: updates.assigneeAccountId } - : { accountId: updates.assigneeAccountId } - await jiraRequest(entry, `${issueBase}/assignee`, { - method: 'PUT', - body: JSON.stringify(assigneeBody) - }) - } - if (updates.transitionId) { - await jiraRequest(entry, `${issueBase}/transitions`, { - method: 'POST', - body: JSON.stringify({ transition: { id: updates.transitionId } }) - }) - } - return { ok: true } - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - return { ok: false, error: error instanceof Error ? error.message : 'Failed to update issue.' } - } finally { - release() - } -} - -export async function addIssueComment( - key: string, - body: string, - siteId?: string | null -): Promise<{ ok: true; id: string } | { ok: false; error: string }> { - const entry = getClients(siteId)[0] - if (!entry) { - return { ok: false, error: 'Not connected to Jira.' } - } - await acquire() - try { - const comment = await jiraRequest<{ id: string }>( - entry, - `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment`, - { - method: 'POST', - body: JSON.stringify({ body: toBodyText(entry.site, body) }) - } - ) - return { ok: true, id: comment.id } - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - return { ok: false, error: error instanceof Error ? error.message : 'Failed to add comment.' } - } finally { - release() - } -} - -function mapComment(raw: JiraRecord, adfOptions?: AdfToMarkdownOptions): JiraComment { - return { - id: asString(raw.id), - body: adfToMarkdownText(raw.body, adfOptions), - createdAt: asString(raw.created, new Date().toISOString()), - updatedAt: asString(raw.updated) || undefined, - user: mapUser(raw.author) - } -} - -/** - * Pooled comment media collect: attachment metadata JSON stays under the semaphore. - * Residual: Server/DC comment bodies are wiki markup, not ADF — this only fixes - * the lookup path; wiki `!filename!` is not rendered as media. - */ -async function collectCommentMediaRequest( - client: JiraClientForSite, - key: string, - comments: JiraRecord[] -): Promise { - const htmlIds: string[] = [] - const seen = new Set() - const mediaAttrs: JiraAdfMediaAttrs[] = [] - for (const comment of comments) { - for (const id of extractAttachmentContentIdsFromHtml(asString(comment.renderedBody))) { - if (!seen.has(id)) { - seen.add(id) - htmlIds.push(id) - } - } - mediaAttrs.push(...collectAdfMediaAttrs(comment.body)) - } - - const needingCount = mediaAttrs.filter( - (attrs) => !(attrs.url && /^https?:\/\//i.test(attrs.url)) - ).length - // Why: selectPreferredAttachmentIds yields nothing without attachment-needing media, so - // HTML ids alone can never produce a download — skip the extra metadata request entirely. - if (needingCount === 0) { - return undefined - } - - // Why: comment media usually references issue-level attachments; pull them once - // for the whole thread. Use apiBasePath so Server/DC does not 404 on /rest/api/3. - let attachmentField: unknown - try { - const issue = await jiraRequest( - client, - `${apiBasePath(client.site)}/issue/${encodeURIComponent(key)}?fields=attachment` - ) - attachmentField = asRecord(issue.fields).attachment - } catch (error) { - console.warn('[jira] comment attachment lookup failed:', error) - return undefined - } - - const selection = selectPreferredAttachmentIds({ - renderedHtmlIds: htmlIds, - attachmentField, - mediaAttrs - }) - if (selection.needCount === 0 && selection.preferredIds.length === 0) { - return undefined - } - return { - attachmentField, - preferredIds: selection.preferredIds, - needCount: selection.needCount, - fallbackRan: selection.fallbackRan, - issueKey: key - } -} - -export async function getIssueComments( - key: string, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - - let comments: JiraRecord[] = [] - let mediaRequest: MediaRequest | undefined - let held = false - try { - await acquire() - held = true - comments = await fetchPagedRecords(entry, 'comments', (startAt, maxResults) => { - const params = new URLSearchParams({ - maxResults: String(maxResults), - orderBy: 'created', - startAt: String(startAt), - expand: 'renderedBody' - }) - return `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment?${params.toString()}` - }) - mediaRequest = await collectCommentMediaRequest(entry, key, comments) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] getIssueComments failed:', error) - return [] - } finally { - if (held) { - held = false - release() - } - } - - try { - const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined - const mapped = comments.map((comment) => mapComment(comment, prepared?.options)) - if (prepared) { - flushMediaResolutionWarn(entry, prepared) - } - return mapped - } catch (error) { - console.warn('[jira] getIssueComments media load failed:', error) - return comments.map((comment) => mapComment(comment)) - } -} - -export async function listProjects(siteId?: JiraSiteSelection | null): Promise { - const entries = getClients(siteId) - if (entries.length === 0) { - return [] - } - const results = await Promise.all( - entries.map(async (entry) => { - await acquire() - try { - // Server/DC has no /project/search resource; /project returns the - // full list as a plain (unpaged) array. - const projects = - entry.site.authType === 'server' - ? await jiraRequest(entry, `${apiBasePath(entry.site)}/project`) - : await fetchPagedRecords(entry, 'values', (startAt, maxResults) => { - const params = new URLSearchParams({ - maxResults: String(maxResults), - startAt: String(startAt) - }) - return `/rest/api/3/project/search?${params.toString()}` - }) - return projects.map((project) => mapProject(project, entry.site)) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - if (shouldSurfaceSiteFailure(siteId, entries.length)) { - throw error - } - } else { - console.warn('[jira] listProjects failed:', error) - } - return [] - } finally { - release() - } - }) - ) - return results.flat().sort((a, b) => a.name.localeCompare(b.name)) -} - -export async function listIssueTypes( - projectIdOrKey: string, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - await acquire() - try { - const issueTypes = await fetchPagedRecords(entry, 'issueTypes', (startAt, maxResults) => { - const params = new URLSearchParams({ - maxResults: String(maxResults), - startAt: String(startAt) - }) - // Per-project createmeta paths exist on Server/DC from Jira 8.4 onward. - return `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( - projectIdOrKey - )}/issuetypes?${params.toString()}` - }) - return issueTypes.map(mapIssueType) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] listIssueTypes failed:', error) - return [] - } finally { - release() - } -} - -export async function listCreateFields( - projectIdOrKey: string, - issueTypeId: string, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - await acquire() - try { - const fields: JiraCreateField[] = [] - let startAt = 0 - const maxResults = 100 - for (let guard = 0; guard < 100; guard += 1) { - const params = new URLSearchParams({ - maxResults: String(maxResults), - startAt: String(startAt) - }) - const response = await jiraRequest>( - entry, - `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( - projectIdOrKey - )}/issuetypes/${encodeURIComponent(issueTypeId)}?${params.toString()}` - ) - const records = getCreateFieldRecords(response) - fields.push( - ...records - .map((record) => mapCreateField(record)) - .filter((field): field is JiraCreateField => field !== null) - ) - if (!shouldFetchNextPage(response, startAt, records, maxResults)) { - break - } - startAt += asFiniteNumber(response.maxResults) ?? maxResults - } - return fields - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] listCreateFields failed:', error) - return [] - } finally { - release() - } -} - -export async function listPriorities(siteId?: string | null): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - await acquire() - try { - const response = await jiraRequest(entry, `${apiBasePath(entry.site)}/priority`) - return response.map(mapPriority).filter((priority): priority is JiraPriority => !!priority) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] listPriorities failed:', error) - return [] - } finally { - release() - } -} - -export async function listAssignableUsers( - key: string, - query?: string, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - const isServer = entry.site.authType === 'server' - const params = new URLSearchParams({ issueKey: key, maxResults: '50' }) - if (query?.trim()) { - // Server/DC filters assignable users by `username`; `query` is Cloud-only. - params.set(isServer ? 'username' : 'query', query.trim()) - } - await acquire() - try { - const response = await jiraRequest( - entry, - `${apiBasePath(entry.site)}/user/assignable/search?${params.toString()}` - ) - return response.map(mapUser).filter((user): user is JiraUser => !!user) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] listAssignableUsers failed:', error) - return [] - } finally { - release() - } -} - -export async function listTransitions( - key: string, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - await acquire() - try { - const response = await jiraRequest<{ transitions?: JiraRecord[] }>( - entry, - `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/transitions` - ) - return (response.transitions ?? []).map((transition) => ({ - id: asString(transition.id), - name: asString(transition.name), - to: mapStatus(transition.to) - })) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] listTransitions failed:', error) - return [] - } finally { - release() - } -} - -export async function getProjectStatusOrder( - projectKey: string, - siteId?: string | null -): Promise { - // Why: an omitted site can resolve to the persisted "all" selection; board - // metadata is only truthful when exactly one Jira connection owns the project. - const entries = getClients(siteId) - const entry = entries.length === 1 ? entries[0] : undefined - if (!entry) { - return { statusIdsByColumn: [] } - } - await acquire() - try { - // Why: without an explicit board picker there is no truthful way to choose - // among multiple project boards, so ambiguous projects keep alphabetical order. - const params = new URLSearchParams({ projectKeyOrId: projectKey, maxResults: '2' }) - const boardsResponse = await jiraRequest>( - entry, - `/rest/agile/1.0/board?${params.toString()}` - ) - const boards = boardsResponse.values ?? [] - const boardCount = asFiniteNumber(boardsResponse.total) - const singleBoardIsProven = - boards.length === 1 && - boardsResponse.isLast !== false && - (boardCount === 1 || (boardCount === null && boardsResponse.isLast === true)) - const boardId = singleBoardIsProven ? asIdentifier(asRecord(boards[0]).id) : '' - if (!boardId) { - return { statusIdsByColumn: [] } - } - - const configResponse = await jiraRequest( - entry, - `/rest/agile/1.0/board/${encodeURIComponent(boardId)}/configuration` - ) - const columns = asRecord(asRecord(configResponse).columnConfig).columns - if (!Array.isArray(columns)) { - return { statusIdsByColumn: [] } - } - - // Why: issues already carry status names and IDs, so returning board IDs - // avoids a second metadata request and keeps duplicate names unambiguous. - const seenStatusIds = new Set() - const statusIdsByColumn: string[][] = [] - for (const column of columns) { - const statuses = asRecord(column).statuses - if (!Array.isArray(statuses)) { - continue - } - const columnStatusIds: string[] = [] - for (const status of statuses) { - const statusId = asIdentifier(asRecord(status).id) - if (statusId && !seenStatusIds.has(statusId)) { - seenStatusIds.add(statusId) - columnStatusIds.push(statusId) - } - } - if (columnStatusIds.length > 0) { - statusIdsByColumn.push(columnStatusIds) - } - } - return { statusIdsByColumn } - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] getProjectStatusOrder failed:', error) - return { statusIdsByColumn: [] } - } finally { - release() - } -} +export { mapJiraIssue } from './jira-issue-mapping' +export { listIssues, searchIssues } from './jira-issue-search' +export { getIssue, getIssueSummary } from './jira-issue-read' +export { addIssueComment, createIssue, updateIssue } from './jira-issue-mutations' +export { getIssueComments } from './jira-issue-comments' +export { listProjects } from './jira-project-queries' +export { + listAssignableUsers, + listCreateFields, + listIssueTypes, + listPriorities +} from './jira-issue-create-metadata' +export { getProjectStatusOrder, listTransitions } from './jira-transition-queries' diff --git a/src/main/jira/jira-issue-comments.ts b/src/main/jira/jira-issue-comments.ts new file mode 100644 index 00000000000..c81ce300dd4 --- /dev/null +++ b/src/main/jira/jira-issue-comments.ts @@ -0,0 +1,146 @@ +import type { JiraComment } from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { + adfToMarkdownText, + collectAdfMediaAttrs, + type AdfToMarkdownOptions, + type JiraAdfMediaAttrs +} from './adf-markdown' +import { + extractAttachmentContentIdsFromHtml, + selectPreferredAttachmentIds +} from './attachment-discovery' +import { mapUser } from './jira-issue-mapping' +import { + flushMediaResolutionWarn, + prepareMediaResolver, + type MediaRequest +} from './jira-issue-media' +import { asRecord, asString, fetchPagedRecords, type JiraRecord } from './jira-record-pages' + +function mapComment(raw: JiraRecord, adfOptions?: AdfToMarkdownOptions): JiraComment { + return { + id: asString(raw.id), + body: adfToMarkdownText(raw.body, adfOptions), + createdAt: asString(raw.created, new Date().toISOString()), + updatedAt: asString(raw.updated) || undefined, + user: mapUser(raw.author) + } +} + +/** + * Pooled comment media collect: attachment metadata JSON stays under the semaphore. + * Residual: Server/DC comment bodies are wiki markup, not ADF — this only fixes + * the lookup path; wiki `!filename!` is not rendered as media. + */ +async function collectCommentMediaRequest( + client: JiraClientForSite, + key: string, + comments: JiraRecord[] +): Promise { + const htmlIds: string[] = [] + const seen = new Set() + const mediaAttrs: JiraAdfMediaAttrs[] = [] + for (const comment of comments) { + for (const id of extractAttachmentContentIdsFromHtml(asString(comment.renderedBody))) { + if (!seen.has(id)) { + seen.add(id) + htmlIds.push(id) + } + } + mediaAttrs.push(...collectAdfMediaAttrs(comment.body)) + } + + const needingCount = mediaAttrs.filter( + (attrs) => !(attrs.url && /^https?:\/\//i.test(attrs.url)) + ).length + // Why: selectPreferredAttachmentIds yields nothing without attachment-needing media, so + // HTML ids alone can never produce a download — skip the extra metadata request entirely. + if (needingCount === 0) { + return undefined + } + + // Why: comment media usually references issue-level attachments; pull them once + // for the whole thread. Use apiBasePath so Server/DC does not 404 on /rest/api/3. + let attachmentField: unknown + try { + const issue = await jiraRequest( + client, + `${apiBasePath(client.site)}/issue/${encodeURIComponent(key)}?fields=attachment` + ) + attachmentField = asRecord(issue.fields).attachment + } catch (error) { + console.warn('[jira] comment attachment lookup failed:', error) + return undefined + } + + const selection = selectPreferredAttachmentIds({ + renderedHtmlIds: htmlIds, + attachmentField, + mediaAttrs + }) + if (selection.needCount === 0 && selection.preferredIds.length === 0) { + return undefined + } + return { + attachmentField, + preferredIds: selection.preferredIds, + needCount: selection.needCount, + fallbackRan: selection.fallbackRan, + issueKey: key + } +} + +export async function getIssueComments( + key: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + + let comments: JiraRecord[] = [] + let mediaRequest: MediaRequest | undefined + let held = false + try { + await acquire() + held = true + comments = await fetchPagedRecords(entry, 'comments', (startAt, maxResults) => { + const params = new URLSearchParams({ + maxResults: String(maxResults), + orderBy: 'created', + startAt: String(startAt), + expand: 'renderedBody' + }) + return `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment?${params.toString()}` + }) + mediaRequest = await collectCommentMediaRequest(entry, key, comments) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] getIssueComments failed:', error) + return [] + } finally { + if (held) { + held = false + release() + } + } + + try { + const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined + const mapped = comments.map((comment) => mapComment(comment, prepared?.options)) + if (prepared) { + flushMediaResolutionWarn(entry, prepared) + } + return mapped + } catch (error) { + console.warn('[jira] getIssueComments media load failed:', error) + return comments.map((comment) => mapComment(comment)) + } +} diff --git a/src/main/jira/jira-issue-create-metadata.ts b/src/main/jira/jira-issue-create-metadata.ts new file mode 100644 index 00000000000..e3c5338bd49 --- /dev/null +++ b/src/main/jira/jira-issue-create-metadata.ts @@ -0,0 +1,160 @@ +import type { + JiraCreateField, + JiraIssueType, + JiraPriority, + JiraUser +} from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { + getCreateFieldRecords, + mapCreateField, + mapIssueType, + mapPriority, + mapUser +} from './jira-issue-mapping' +import { + asFiniteNumber, + fetchPagedRecords, + shouldFetchNextPage, + type JiraPagedResponse, + type JiraRecord +} from './jira-record-pages' + +export async function listIssueTypes( + projectIdOrKey: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + await acquire() + try { + const issueTypes = await fetchPagedRecords(entry, 'issueTypes', (startAt, maxResults) => { + const params = new URLSearchParams({ + maxResults: String(maxResults), + startAt: String(startAt) + }) + // Per-project createmeta paths exist on Server/DC from Jira 8.4 onward. + return `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( + projectIdOrKey + )}/issuetypes?${params.toString()}` + }) + return issueTypes.map(mapIssueType) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listIssueTypes failed:', error) + return [] + } finally { + release() + } +} + +export async function listCreateFields( + projectIdOrKey: string, + issueTypeId: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + await acquire() + try { + const fields: JiraCreateField[] = [] + let startAt = 0 + const maxResults = 100 + for (let guard = 0; guard < 100; guard += 1) { + const params = new URLSearchParams({ + maxResults: String(maxResults), + startAt: String(startAt) + }) + const response = await jiraRequest>( + entry, + `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( + projectIdOrKey + )}/issuetypes/${encodeURIComponent(issueTypeId)}?${params.toString()}` + ) + const records = getCreateFieldRecords(response) + fields.push( + ...records + .map((record) => mapCreateField(record)) + .filter((field): field is JiraCreateField => field !== null) + ) + if (!shouldFetchNextPage(response, startAt, records, maxResults)) { + break + } + startAt += asFiniteNumber(response.maxResults) ?? maxResults + } + return fields + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listCreateFields failed:', error) + return [] + } finally { + release() + } +} + +export async function listPriorities(siteId?: string | null): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + await acquire() + try { + const response = await jiraRequest(entry, `${apiBasePath(entry.site)}/priority`) + return response.map(mapPriority).filter((priority): priority is JiraPriority => !!priority) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listPriorities failed:', error) + return [] + } finally { + release() + } +} + +export async function listAssignableUsers( + key: string, + query?: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + const isServer = entry.site.authType === 'server' + const params = new URLSearchParams({ issueKey: key, maxResults: '50' }) + if (query?.trim()) { + // Server/DC filters assignable users by `username`; `query` is Cloud-only. + params.set(isServer ? 'username' : 'query', query.trim()) + } + await acquire() + try { + const response = await jiraRequest( + entry, + `${apiBasePath(entry.site)}/user/assignable/search?${params.toString()}` + ) + return response.map(mapUser).filter((user): user is JiraUser => !!user) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listAssignableUsers failed:', error) + return [] + } finally { + release() + } +} diff --git a/src/main/jira/jira-issue-mapping.ts b/src/main/jira/jira-issue-mapping.ts new file mode 100644 index 00000000000..cc7c9357eaf --- /dev/null +++ b/src/main/jira/jira-issue-mapping.ts @@ -0,0 +1,210 @@ +import type { + JiraCreateField, + JiraCreateFieldAllowedValue, + JiraIssue, + JiraIssueType, + JiraPriority, + JiraProject, + JiraSite, + JiraStatus, + JiraUser +} from '../../shared/jira-types' +import { adfToMarkdownText, textToAdf, type AdfToMarkdownOptions } from './adf-markdown' +import { + asRecord, + asString, + asStringArray, + type JiraPagedResponse, + type JiraRecord +} from './jira-record-pages' + +export const ISSUE_FIELDS = [ + 'summary', + 'description', + 'project', + 'issuetype', + 'status', + 'assignee', + 'reporter', + 'priority', + 'labels', + 'created', + 'updated' +] + +// Why: list/typeahead only need identity + metadata; description ADF parse is the hot-path cost. +export const ISSUE_LIST_FIELDS = ISSUE_FIELDS.filter((field) => field !== 'description') + +// Why: detail reads need attachment metadata so inline ADF media can be resolved +// to downloadable image content; list/search omit this for payload size. +export const ISSUE_DETAIL_FIELDS = [...ISSUE_FIELDS, 'attachment'] +// `created`/`updated` are required: mapJiraIssue falls back to "now" when they're absent, +// which would silently report the lookup time as the issue's timestamps. +export const ISSUE_SUMMARY_FIELDS = [ + 'summary', + 'project', + 'issuetype', + 'status', + 'created', + 'updated' +] +export function avatarUrl(value: unknown): string | undefined { + const avatars = asRecord(value) + return ( + asString(avatars['48x48']) || + asString(avatars['32x32']) || + asString(avatars['24x24']) || + undefined + ) +} + +export function mapUser(value: unknown): JiraUser | undefined { + const user = asRecord(value) + // Server/DC users have no accountId; name (login) and key are its stable ids. + const accountId = asString(user.accountId) || asString(user.name) || asString(user.key) + if (!accountId) { + return undefined + } + return { + accountId, + displayName: asString(user.displayName, 'Unknown'), + email: typeof user.emailAddress === 'string' ? user.emailAddress : undefined, + avatarUrl: avatarUrl(user.avatarUrls) + } +} + +export function mapProject(value: unknown, site?: JiraSite): JiraProject { + const project = asRecord(value) + return { + id: asString(project.id), + key: asString(project.key), + name: asString(project.name, asString(project.key)), + siteId: site?.id, + siteName: site?.displayName + } +} + +export function mapIssueType(value: unknown): JiraIssueType { + const issueType = asRecord(value) + return { + id: asString(issueType.id), + name: asString(issueType.name, 'Issue'), + description: asString(issueType.description) || undefined, + iconUrl: asString(issueType.iconUrl) || undefined, + subtask: typeof issueType.subtask === 'boolean' ? issueType.subtask : undefined + } +} + +export function mapCreateFieldAllowedValue(value: unknown): JiraCreateFieldAllowedValue { + const option = asRecord(value) + return { + id: asString(option.id) || undefined, + value: asString(option.value) || undefined, + name: asString(option.name) || undefined + } +} + +export function mapCreateField(value: unknown, fallbackKey = ''): JiraCreateField | null { + const field = asRecord(value) + const schema = asRecord(field.schema) + const key = + asString(field.key) || + asString(field.fieldId) || + asString(field.id) || + asString(field.fieldKey) || + fallbackKey + if (!key) { + return null + } + const allowedValues = Array.isArray(field.allowedValues) + ? field.allowedValues.map(mapCreateFieldAllowedValue) + : undefined + return { + key, + name: asString(field.name, key), + required: field.required === true, + schema: { + type: asString(schema.type) || undefined, + items: asString(schema.items) || undefined, + custom: asString(schema.custom) || undefined + }, + allowedValues + } +} + +export function getCreateFieldRecords(response: JiraPagedResponse): JiraRecord[] { + if (Array.isArray(response.values)) { + return response.values + } + if (Array.isArray(response.fields)) { + return response.fields + } + if (response.fields && typeof response.fields === 'object') { + return Object.entries(response.fields).map(([key, value]) => ({ + key, + ...asRecord(value) + })) + } + return [] +} + +export function mapPriority(value: unknown): JiraPriority | undefined { + const priority = asRecord(value) + const id = asString(priority.id) + if (!id) { + return undefined + } + return { + id, + name: asString(priority.name, 'Priority'), + iconUrl: asString(priority.iconUrl) || undefined + } +} + +export function mapStatus(value: unknown): JiraStatus { + const status = asRecord(value) + const category = asRecord(status.statusCategory) + return { + id: asString(status.id), + name: asString(status.name, 'Unknown'), + categoryKey: asString(category.key, 'undefined'), + categoryName: asString(category.name, 'No Category'), + colorName: asString(category.colorName) || undefined + } +} + +export function issueUrl(site: JiraSite, key: string): string { + return `${site.siteUrl}/browse/${encodeURIComponent(key)}` +} + +// REST v2 (Server/DC) bodies are plain text; v3 (Cloud) requires ADF documents. +export function toBodyText(site: JiraSite, text: string): unknown { + return site.authType === 'server' ? text : textToAdf(text) +} + +export function mapJiraIssue( + site: JiraSite, + raw: JiraRecord, + adfOptions?: AdfToMarkdownOptions +): JiraIssue { + const fields = asRecord(raw.fields) + const key = asString(raw.key) + return { + id: asString(raw.id, key), + key, + siteId: site.id, + siteName: site.displayName, + title: asString(fields.summary, key || 'Untitled issue'), + description: adfToMarkdownText(fields.description, adfOptions), + url: issueUrl(site, key), + project: mapProject(fields.project, site), + issueType: mapIssueType(fields.issuetype), + status: mapStatus(fields.status), + labels: asStringArray(fields.labels), + assignee: mapUser(fields.assignee), + reporter: mapUser(fields.reporter), + priority: mapPriority(fields.priority), + createdAt: asString(fields.created, new Date().toISOString()), + updatedAt: asString(fields.updated, new Date().toISOString()) + } +} diff --git a/src/main/jira/jira-issue-media.ts b/src/main/jira/jira-issue-media.ts new file mode 100644 index 00000000000..743717a320f --- /dev/null +++ b/src/main/jira/jira-issue-media.ts @@ -0,0 +1,104 @@ +import { collectAdfMediaAttrs, type AdfToMarkdownOptions } from './adf-markdown' +import { + extractAttachmentContentIdsFromHtml, + selectPreferredAttachmentIds, + warnIfMediaResolutionIncomplete +} from './attachment-discovery' +import { + createMediaMarkdownResolver, + loadIssueImageAttachments, + type MediaResolutionStats +} from './attachment-images' +import type { JiraClientForSite } from './authenticated-request' +import { asRecord, asString, type JiraRecord } from './jira-record-pages' + +export type MediaRequest = { + attachmentField: unknown + preferredIds: string[] + needCount: number + fallbackRan: boolean + issueKey: string +} + +/** Pooled: HTML/ADF selection only — no binary downloads. */ +export function collectIssueMediaRequest(raw: JiraRecord): MediaRequest | undefined { + const fields = asRecord(raw.fields) + const renderedFields = asRecord(raw.renderedFields) + const htmlIds = extractAttachmentContentIdsFromHtml( + asString(renderedFields.description) || undefined + ) + const mediaAttrs = collectAdfMediaAttrs(fields.description) + const selection = selectPreferredAttachmentIds({ + renderedHtmlIds: htmlIds, + attachmentField: fields.attachment, + mediaAttrs + }) + if (selection.needCount === 0 && selection.preferredIds.length === 0) { + return undefined + } + return { + attachmentField: fields.attachment, + preferredIds: selection.preferredIds, + needCount: selection.needCount, + fallbackRan: selection.fallbackRan, + issueKey: asString(raw.key) + } +} + +export type PreparedMedia = { + options: AdfToMarkdownOptions + stats: MediaResolutionStats + request: MediaRequest +} + +/** Unpooled: binary downloads + resolver (outside the Jira API semaphore). */ +export async function prepareMediaResolver( + client: JiraClientForSite, + request: MediaRequest +): Promise { + if (request.preferredIds.length === 0) { + warnIfMediaResolutionIncomplete({ + siteId: client.site.id, + issueKey: request.issueKey, + needCount: request.needCount, + preferredIdCount: 0, + resolvedCount: 0, + fallbackRan: request.fallbackRan + }) + return undefined + } + const images = await loadIssueImageAttachments( + client, + request.attachmentField, + request.preferredIds + ) + if (images.length === 0) { + warnIfMediaResolutionIncomplete({ + siteId: client.site.id, + issueKey: request.issueKey, + needCount: request.needCount, + preferredIdCount: request.preferredIds.length, + resolvedCount: 0, + fallbackRan: request.fallbackRan + }) + return undefined + } + const stats: MediaResolutionStats = { attachmentResolvedCount: 0 } + const resolveMedia = createMediaMarkdownResolver(images, request.preferredIds, stats) + return { + options: { resolveMedia }, + stats, + request + } +} + +export function flushMediaResolutionWarn(client: JiraClientForSite, prepared: PreparedMedia): void { + warnIfMediaResolutionIncomplete({ + siteId: client.site.id, + issueKey: prepared.request.issueKey, + needCount: prepared.request.needCount, + preferredIdCount: prepared.request.preferredIds.length, + resolvedCount: prepared.stats.attachmentResolvedCount, + fallbackRan: prepared.request.fallbackRan + }) +} diff --git a/src/main/jira/jira-issue-mutations.ts b/src/main/jira/jira-issue-mutations.ts new file mode 100644 index 00000000000..29b5fd7a679 --- /dev/null +++ b/src/main/jira/jira-issue-mutations.ts @@ -0,0 +1,146 @@ +import type { + JiraCreateIssueArgs, + JiraCreateIssueResult, + JiraIssueUpdate, + JiraMutationResult +} from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { issueUrl, toBodyText } from './jira-issue-mapping' +import type { JiraRecord } from './jira-record-pages' + +export async function createIssue(args: JiraCreateIssueArgs): Promise { + const entry = getClients(args.siteId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Jira.' } + } + const title = args.title.trim() + if (!title) { + return { ok: false, error: 'Title is required.' } + } + + await acquire() + try { + const fields: JiraRecord = { + project: { id: args.projectId }, + issuetype: { id: args.issueTypeId }, + summary: title + } + if (args.description?.trim()) { + fields.description = toBodyText(entry.site, args.description.trim()) + } + for (const [fieldKey, value] of Object.entries(args.customFields ?? {})) { + if (!fieldKey || value === undefined || value === null || value === '') { + continue + } + fields[fieldKey] = value + } + const created = await jiraRequest<{ id: string; key: string; self: string }>( + entry, + `${apiBasePath(entry.site)}/issue`, + { + method: 'POST', + body: JSON.stringify({ fields }) + } + ) + return { ok: true, id: created.id, key: created.key, url: issueUrl(entry.site, created.key) } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + return { ok: false, error: error instanceof Error ? error.message : 'Failed to create issue.' } + } finally { + release() + } +} + +export async function updateIssue( + key: string, + updates: JiraIssueUpdate, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Jira.' } + } + await acquire() + try { + const fields: JiraRecord = {} + if (updates.title !== undefined) { + fields.summary = updates.title + } + if (updates.labels !== undefined) { + fields.labels = updates.labels + } + if (updates.priorityId !== undefined) { + fields.priority = updates.priorityId ? { id: updates.priorityId } : null + } + const issueBase = `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}` + if (Object.keys(fields).length > 0) { + await jiraRequest(entry, issueBase, { + method: 'PUT', + body: JSON.stringify({ fields }) + }) + } + if (updates.assigneeAccountId !== undefined) { + // Server/DC identifies assignees by username (`name`), not accountId; + // mapUser stores the Server username in the accountId slot. + const assigneeBody = + entry.site.authType === 'server' + ? { name: updates.assigneeAccountId } + : { accountId: updates.assigneeAccountId } + await jiraRequest(entry, `${issueBase}/assignee`, { + method: 'PUT', + body: JSON.stringify(assigneeBody) + }) + } + if (updates.transitionId) { + await jiraRequest(entry, `${issueBase}/transitions`, { + method: 'POST', + body: JSON.stringify({ transition: { id: updates.transitionId } }) + }) + } + return { ok: true } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + return { ok: false, error: error instanceof Error ? error.message : 'Failed to update issue.' } + } finally { + release() + } +} + +export async function addIssueComment( + key: string, + body: string, + siteId?: string | null +): Promise<{ ok: true; id: string } | { ok: false; error: string }> { + const entry = getClients(siteId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Jira.' } + } + await acquire() + try { + const comment = await jiraRequest<{ id: string }>( + entry, + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment`, + { + method: 'POST', + body: JSON.stringify({ body: toBodyText(entry.site, body) }) + } + ) + return { ok: true, id: comment.id } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + return { ok: false, error: error instanceof Error ? error.message : 'Failed to add comment.' } + } finally { + release() + } +} diff --git a/src/main/jira/jira-issue-read.ts b/src/main/jira/jira-issue-read.ts new file mode 100644 index 00000000000..16889c50b3b --- /dev/null +++ b/src/main/jira/jira-issue-read.ts @@ -0,0 +1,121 @@ +import type { JiraIssue, JiraSiteSelection } from '../../shared/jira-types' +import { JiraSummaryLookupError } from '../../shared/jira-summary-lookup' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { ISSUE_DETAIL_FIELDS, ISSUE_SUMMARY_FIELDS, mapJiraIssue } from './jira-issue-mapping' +import { + collectIssueMediaRequest, + flushMediaResolutionWarn, + prepareMediaResolver, + type MediaRequest +} from './jira-issue-media' +import type { JiraRecord } from './jira-record-pages' +import { + getErrorStatus, + settleJiraSummaryRead, + shouldSurfaceSiteFailure, + withJiraDeadline +} from './jira-read-failure' + +const ISSUE_SUMMARY_TIMEOUT_MS = 30_000 + +export async function getIssue( + key: string, + siteId?: JiraSiteSelection | null +): Promise { + const entries = getClients(siteId) + for (const entry of entries) { + let mediaRequest: MediaRequest | undefined + let issue: JiraRecord | undefined + let held = false + try { + await acquire() + held = true + const params = new URLSearchParams({ + fields: ISSUE_DETAIL_FIELDS.join(','), + expand: 'renderedFields' + }) + issue = await jiraRequest( + entry, + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?${params.toString()}` + ) + // Why: keep only JSON under the pool; binary downloads fan out after release. + mediaRequest = collectIssueMediaRequest(issue) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + if (shouldSurfaceSiteFailure(siteId, entries.length)) { + throw error + } + } else { + console.warn('[jira] getIssue failed:', error) + } + continue + } finally { + if (held) { + held = false + release() + } + } + + try { + if (!issue) { + continue + } + const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined + const mapped = mapJiraIssue(entry.site, issue, prepared?.options) + if (prepared) { + flushMediaResolutionWarn(entry, prepared) + } + return mapped + } catch (error) { + console.warn('[jira] getIssue media load failed:', error) + return mapJiraIssue(entry.site, issue) + } + } + return null +} + +export async function getIssueSummary( + key: string, + siteId: string, + signal?: AbortSignal +): Promise { + let entries: JiraClientForSite[] + try { + entries = getClients(siteId) + } catch (error) { + throw new JiraSummaryLookupError('auth', error) + } + const entry = entries.find((candidate) => candidate.site.id === siteId) + if (!entry) { + throw new JiraSummaryLookupError('disconnected') + } + + return withJiraDeadline(signal, ISSUE_SUMMARY_TIMEOUT_MS, async (requestSignal) => { + await acquire(requestSignal) + try { + const params = new URLSearchParams({ fields: ISSUE_SUMMARY_FIELDS.join(',') }) + const issue = await settleJiraSummaryRead( + jiraRequest( + entry, + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?${params.toString()}`, + { signal: requestSignal } + ), + requestSignal + ) + return mapJiraIssue(entry.site, issue) + } catch (error) { + if (isAuthError(error)) { + throw new JiraSummaryLookupError('auth', error) + } + if (getErrorStatus(error) === 404) { + throw new JiraSummaryLookupError('not-found', error) + } + throw new JiraSummaryLookupError('read-failed', error) + } finally { + release() + } + }) +} diff --git a/src/main/jira/jira-issue-search.ts b/src/main/jira/jira-issue-search.ts new file mode 100644 index 00000000000..743342f7727 --- /dev/null +++ b/src/main/jira/jira-issue-search.ts @@ -0,0 +1,122 @@ +import type { JiraIssue, JiraIssueFilter, JiraSiteSelection } from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { ISSUE_LIST_FIELDS, mapJiraIssue } from './jira-issue-mapping' +import type { JiraSearchResponse } from './jira-record-pages' +import { + shouldSurfaceSiteFailure, + toIssueSearchFailureError, + withJiraDeadline, + type JiraIssueSearchFailure +} from './jira-read-failure' + +const ISSUE_SEARCH_TIMEOUT_MS = 30_000 + +function clampLimit(limit: number | undefined, fallback = 30): number { + return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100) +} + +function sortAndLimitIssues(issues: JiraIssue[], limit: number): JiraIssue[] { + return issues + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) + .slice(0, limit) +} + +function filterToJql(filter: JiraIssueFilter): string { + if (filter === 'assigned') { + return 'assignee = currentUser() AND resolution = Unresolved ORDER BY updated DESC' + } + if (filter === 'reported') { + return 'reporter = currentUser() AND resolution = Unresolved ORDER BY updated DESC' + } + if (filter === 'done') { + return 'assignee = currentUser() AND resolution IS NOT EMPTY ORDER BY updated DESC' + } + return 'resolution = Unresolved ORDER BY updated DESC' +} + +async function searchIssuesForClient( + entry: JiraClientForSite, + jql: string, + limit: number, + signal?: AbortSignal +): Promise { + // Server/DC only has the classic /search resource; /search/jql is Cloud-only. + const searchPath = + entry.site.authType === 'server' + ? `${apiBasePath(entry.site)}/search` + : '/rest/api/3/search/jql' + const result = await jiraRequest(entry, searchPath, { + method: 'POST', + body: JSON.stringify({ + jql, + maxResults: limit, + fields: ISSUE_LIST_FIELDS + }), + signal + }) + return (result.issues ?? []).map((issue) => mapJiraIssue(entry.site, issue)) +} + +export async function listIssues( + filter: JiraIssueFilter = 'assigned', + limit = 30, + siteId?: JiraSiteSelection | null +): Promise { + return searchIssues(filterToJql(filter), limit, siteId) +} + +export async function searchIssues( + jql: string, + limit = 30, + siteId?: JiraSiteSelection | null, + signal?: AbortSignal +): Promise { + const entries = getClients(siteId) + if (entries.length === 0 || !jql.trim()) { + return [] + } + const safeLimit = clampLimit(limit) + const failures: (JiraIssueSearchFailure | undefined)[] = Array.from({ length: entries.length }) + const surfaceSiteFailure = shouldSurfaceSiteFailure(siteId, entries.length) + const results = await withJiraDeadline(signal, ISSUE_SEARCH_TIMEOUT_MS, (requestSignal) => + Promise.all( + entries.map(async (entry, index) => { + // Why: queueing on an abandoned search would keep occupying the shared Jira pool. + await acquire(requestSignal) + try { + return await searchIssuesForClient(entry, jql.trim(), safeLimit, requestSignal) + } catch (error) { + if (requestSignal.aborted) { + // Abandoned by the caller: not a site failure, so don't clear tokens or mask a real one. + throw error + } + const authFailure = isAuthError(error) + if (authFailure) { + clearToken(entry.site.id) + } + if (surfaceSiteFailure) { + throw toIssueSearchFailureError(error) + } + console.warn('[jira] searchIssues failed:', error) + failures[index] = { error: toIssueSearchFailureError(error), auth: authFailure } + return [] as JiraIssue[] + } finally { + release() + } + }) + ) + ) + // 'all' fan-out: only surface an error when every connected site failed, so a + // partial success (or a genuinely empty result) is not reported as an error. + const recordedFailures = failures.filter( + (failure): failure is JiraIssueSearchFailure => failure !== undefined + ) + if (recordedFailures.length === entries.length) { + throw (recordedFailures.find((failure) => !failure.auth) ?? recordedFailures[0]).error + } + return entries.length === 1 + ? results.flat().slice(0, safeLimit) + : sortAndLimitIssues(results.flat(), safeLimit) +} diff --git a/src/main/jira/jira-project-queries.ts b/src/main/jira/jira-project-queries.ts new file mode 100644 index 00000000000..3c5c6d7b63d --- /dev/null +++ b/src/main/jira/jira-project-queries.ts @@ -0,0 +1,47 @@ +import type { JiraProject, JiraSiteSelection } from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { mapProject } from './jira-issue-mapping' +import { fetchPagedRecords, type JiraRecord } from './jira-record-pages' +import { shouldSurfaceSiteFailure } from './jira-read-failure' + +export async function listProjects(siteId?: JiraSiteSelection | null): Promise { + const entries = getClients(siteId) + if (entries.length === 0) { + return [] + } + const results = await Promise.all( + entries.map(async (entry) => { + await acquire() + try { + // Server/DC has no /project/search resource; /project returns the + // full list as a plain (unpaged) array. + const projects = + entry.site.authType === 'server' + ? await jiraRequest(entry, `${apiBasePath(entry.site)}/project`) + : await fetchPagedRecords(entry, 'values', (startAt, maxResults) => { + const params = new URLSearchParams({ + maxResults: String(maxResults), + startAt: String(startAt) + }) + return `/rest/api/3/project/search?${params.toString()}` + }) + return projects.map((project) => mapProject(project, entry.site)) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + if (shouldSurfaceSiteFailure(siteId, entries.length)) { + throw error + } + } else { + console.warn('[jira] listProjects failed:', error) + } + return [] + } finally { + release() + } + }) + ) + return results.flat().sort((a, b) => a.name.localeCompare(b.name)) +} diff --git a/src/main/jira/jira-read-failure.ts b/src/main/jira/jira-read-failure.ts new file mode 100644 index 00000000000..e87011d12b9 --- /dev/null +++ b/src/main/jira/jira-read-failure.ts @@ -0,0 +1,80 @@ +import type { JiraSiteSelection } from '../../shared/jira-types' + +export type JiraIssueSearchFailure = { + error: unknown + auth: boolean +} + +/** Run against one signal that trips on the caller's abort or the request deadline. */ +export async function withJiraDeadline( + signal: AbortSignal | undefined, + timeoutMs: number, + run: (deadlineSignal: AbortSignal) => Promise +): Promise { + const controller = new AbortController() + const abort = (): void => controller.abort() + signal?.addEventListener('abort', abort, { once: true }) + if (signal?.aborted) { + controller.abort() + } + const timer = setTimeout(abort, timeoutMs) + try { + return await run(controller.signal) + } finally { + clearTimeout(timer) + signal?.removeEventListener('abort', abort) + } +} + +export function settleJiraSummaryRead(read: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.reject(new Error('Jira summary lookup aborted')) + } + return new Promise((resolve, reject) => { + const handleAbort = (): void => { + cleanup() + reject(new Error('Jira summary lookup aborted')) + } + const cleanup = (): void => signal.removeEventListener('abort', handleAbort) + signal.addEventListener('abort', handleAbort, { once: true }) + void read.then( + (value) => { + cleanup() + resolve(value) + }, + (error: unknown) => { + cleanup() + reject(error) + } + ) + }) +} + +export function getErrorStatus(error: unknown): number | null { + if (!error || typeof error !== 'object' || !('status' in error)) { + return null + } + const status = (error as { status?: unknown }).status + return typeof status === 'number' && Number.isFinite(status) ? status : null +} + +export function toIssueSearchFailureError(error: unknown): unknown { + const status = getErrorStatus(error) + if ( + status === null || + !(error instanceof Error) || + error.message.startsWith(`Error ${status}:`) + ) { + return error + } + return new Error(`Error ${status}: ${error.message}`) +} + +export function shouldSurfaceSiteFailure( + selection: JiraSiteSelection | null | undefined, + entryCount: number +): boolean { + // getClients can resolve an omitted selection to the persisted 'all' choice; + // multi-entry reads need the same resilient fan-out policy as explicit 'all'. + return selection !== 'all' && entryCount <= 1 +} diff --git a/src/main/jira/jira-record-pages.ts b/src/main/jira/jira-record-pages.ts new file mode 100644 index 00000000000..c6bcba6ea9f --- /dev/null +++ b/src/main/jira/jira-record-pages.ts @@ -0,0 +1,96 @@ +import { jiraRequest, type JiraClientForSite } from './authenticated-request' + +export type JiraRecord = Record + +export type JiraSearchResponse = { + issues?: JiraRecord[] +} + +export type JiraPagedResponse = { + startAt?: number + maxResults?: number + total?: number + isLast?: boolean + values?: T[] + issueTypes?: T[] + comments?: T[] + fields?: T[] | Record +} + +export type JiraPageItemKey = 'values' | 'issueTypes' | 'comments' + +export function asRecord(value: unknown): JiraRecord { + return value && typeof value === 'object' ? (value as JiraRecord) : {} +} + +export function asString(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback +} + +export function asIdentifier(value: unknown): string { + if (typeof value === 'string') { + return value + } + return typeof value === 'number' && Number.isFinite(value) ? String(value) : '' +} + +export function asStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : [] +} + +export function asFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +export function getPageItems(response: JiraPagedResponse, key: JiraPageItemKey): T[] { + const keyedItems = response[key] + if (Array.isArray(keyedItems)) { + return keyedItems + } + return response.values ?? [] +} + +export function shouldFetchNextPage( + response: JiraPagedResponse, + startAt: number, + items: T[], + requestedMaxResults: number +): boolean { + if (response.isLast === true || items.length === 0) { + return false + } + const total = asFiniteNumber(response.total) + const pageSize = asFiniteNumber(response.maxResults) + if (total !== null) { + return startAt + items.length < total && (pageSize ?? requestedMaxResults) > 0 + } + if (response.isLast === false) { + return (pageSize ?? requestedMaxResults) > 0 + } + return pageSize !== null && items.length >= pageSize +} + +export async function fetchPagedRecords( + entry: JiraClientForSite, + key: JiraPageItemKey, + pathForPage: (startAt: number, maxResults: number) => string, + maxResults = 100 +): Promise { + const records: JiraRecord[] = [] + let startAt = 0 + for (let guard = 0; guard < 100; guard += 1) { + const response = await jiraRequest>( + entry, + pathForPage(startAt, maxResults) + ) + const items = getPageItems(response, key) + records.push(...items) + if (!shouldFetchNextPage(response, startAt, items, maxResults)) { + break + } + startAt += asFiniteNumber(response.maxResults) ?? maxResults + } + return records +} diff --git a/src/main/jira/jira-transition-queries.ts b/src/main/jira/jira-transition-queries.ts new file mode 100644 index 00000000000..2e40a905981 --- /dev/null +++ b/src/main/jira/jira-transition-queries.ts @@ -0,0 +1,118 @@ +import type { JiraProjectStatusOrder, JiraTransition } from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { mapStatus } from './jira-issue-mapping' +import { + asFiniteNumber, + asIdentifier, + asRecord, + asString, + type JiraPagedResponse, + type JiraRecord +} from './jira-record-pages' + +export async function listTransitions( + key: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + await acquire() + try { + const response = await jiraRequest<{ transitions?: JiraRecord[] }>( + entry, + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/transitions` + ) + return (response.transitions ?? []).map((transition) => ({ + id: asString(transition.id), + name: asString(transition.name), + to: mapStatus(transition.to) + })) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listTransitions failed:', error) + return [] + } finally { + release() + } +} + +export async function getProjectStatusOrder( + projectKey: string, + siteId?: string | null +): Promise { + // Why: an omitted site can resolve to the persisted "all" selection; board + // metadata is only truthful when exactly one Jira connection owns the project. + const entries = getClients(siteId) + const entry = entries.length === 1 ? entries[0] : undefined + if (!entry) { + return { statusIdsByColumn: [] } + } + await acquire() + try { + // Why: without an explicit board picker there is no truthful way to choose + // among multiple project boards, so ambiguous projects keep alphabetical order. + const params = new URLSearchParams({ projectKeyOrId: projectKey, maxResults: '2' }) + const boardsResponse = await jiraRequest>( + entry, + `/rest/agile/1.0/board?${params.toString()}` + ) + const boards = boardsResponse.values ?? [] + const boardCount = asFiniteNumber(boardsResponse.total) + const singleBoardIsProven = + boards.length === 1 && + boardsResponse.isLast !== false && + (boardCount === 1 || (boardCount === null && boardsResponse.isLast === true)) + const boardId = singleBoardIsProven ? asIdentifier(asRecord(boards[0]).id) : '' + if (!boardId) { + return { statusIdsByColumn: [] } + } + + const configResponse = await jiraRequest( + entry, + `/rest/agile/1.0/board/${encodeURIComponent(boardId)}/configuration` + ) + const columns = asRecord(asRecord(configResponse).columnConfig).columns + if (!Array.isArray(columns)) { + return { statusIdsByColumn: [] } + } + + // Why: issues already carry status names and IDs, so returning board IDs + // avoids a second metadata request and keeps duplicate names unambiguous. + const seenStatusIds = new Set() + const statusIdsByColumn: string[][] = [] + for (const column of columns) { + const statuses = asRecord(column).statuses + if (!Array.isArray(statuses)) { + continue + } + const columnStatusIds: string[] = [] + for (const status of statuses) { + const statusId = asIdentifier(asRecord(status).id) + if (statusId && !seenStatusIds.has(statusId)) { + seenStatusIds.add(statusId) + columnStatusIds.push(statusId) + } + } + if (columnStatusIds.length > 0) { + statusIdsByColumn.push(columnStatusIds) + } + } + return { statusIdsByColumn } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] getProjectStatusOrder failed:', error) + return { statusIdsByColumn: [] } + } finally { + release() + } +} diff --git a/src/main/linear/linear-custom-view-graphql.ts b/src/main/linear/linear-custom-view-graphql.ts new file mode 100644 index 00000000000..8cdfd96dddd --- /dev/null +++ b/src/main/linear/linear-custom-view-graphql.ts @@ -0,0 +1,114 @@ +import { ORCA_ISSUE_FIELDS, ORCA_PROJECT_FIELDS } from './linear-project-graphql' + +export const CUSTOM_VIEWS_QUERY = ` + query OrcaLinearCustomViews( + $first: Int, + $filter: CustomViewFilter, + $orderBy: PaginationOrderBy + ) { + customViews(first: $first, filter: $filter, orderBy: $orderBy) { + nodes { + id + name + description + modelName + color + icon + shared + slugId + createdAt + updatedAt + team { + id + name + key + } + owner { + id + displayName + avatarUrl + } + creator { + id + displayName + avatarUrl + } + } + pageInfo { + hasNextPage + } + } + } +` + +export const CUSTOM_VIEW_QUERY = ` + query OrcaLinearCustomView($id: String!) { + customView(id: $id) { + id + name + description + modelName + color + icon + shared + slugId + createdAt + updatedAt + team { + id + name + key + } + owner { + id + displayName + avatarUrl + } + creator { + id + displayName + avatarUrl + } + } + } +` + +export const CUSTOM_VIEW_ISSUES_QUERY = ` + query OrcaLinearCustomViewIssues( + $id: String!, + $first: Int, + $after: String, + $orderBy: PaginationOrderBy + ) { + customView(id: $id) { + id + modelName + issues(first: $first, after: $after, orderBy: $orderBy) { + nodes { + ${ORCA_ISSUE_FIELDS} + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +` + +export const CUSTOM_VIEW_PROJECTS_QUERY = ` + query OrcaLinearCustomViewProjects($id: String!, $first: Int, $orderBy: PaginationOrderBy) { + customView(id: $id) { + id + modelName + projects(first: $first, orderBy: $orderBy) { + nodes { + ${ORCA_PROJECT_FIELDS} + } + pageInfo { + hasNextPage + } + } + } + } +` diff --git a/src/main/linear/linear-custom-view-queries.ts b/src/main/linear/linear-custom-view-queries.ts new file mode 100644 index 00000000000..fe225e644d3 --- /dev/null +++ b/src/main/linear/linear-custom-view-queries.ts @@ -0,0 +1,169 @@ +import type { LinearIssue } from '../../shared/linear/issue-types' +import type { + LinearCustomViewModel, + LinearCustomViewSummary, + LinearProjectSummary +} from '../../shared/linear/project-types' +import type { + LinearCollectionResult, + LinearConcreteWorkspaceId, + LinearWorkspaceSelection +} from '../../shared/linear/workspace-types' +import { clampLinearIssueListLimit } from '../../shared/linear/issue-read-limits' +import { acquire, release } from './linear-request-concurrency' +import { clearToken } from './linear-token-store' +import { getClients, isAuthError } from './client' +import { + CUSTOM_VIEW_ISSUES_QUERY, + CUSTOM_VIEW_PROJECTS_QUERY, + CUSTOM_VIEW_QUERY, + CUSTOM_VIEWS_QUERY +} from './linear-custom-view-graphql' +import type { CustomViewConnectionResponse, LinearRawVariables } from './linear-project-nodes' +import { + clampLimit, + coalesce, + mapCustomViewForWorkspace, + mapCustomViewModel, + mapProjectForWorkspace, + normalizeConcreteWorkspaceId +} from './linear-project-models' +import { readCollection, readConcreteCollection } from './linear-project-collection-read' +import { readIssueConnectionPages } from './linear-project-issue-queries' + +export async function listCustomViews( + model: LinearCustomViewModel, + limit = 20, + workspaceId?: LinearWorkspaceSelection | null, + force = false +): Promise> { + const first = clampLimit(limit) + const key = `listCustomViews:${workspaceId ?? 'default'}:${model}:${first}` + const filter = { modelName: { eq: model === 'project' ? 'Project' : 'Issue' } } + return readCollection( + key, + workspaceId, + async (entry) => { + const result = await entry.client.client.rawRequest< + CustomViewConnectionResponse, + LinearRawVariables + >(CUSTOM_VIEWS_QUERY, { first, filter, orderBy: 'updatedAt' }) + const connection = result.data?.customViews + return { + items: (connection?.nodes ?? []) + .map((view) => mapCustomViewForWorkspace(entry, view)) + .filter((view): view is LinearCustomViewSummary => !!view && view.model === model), + hasMore: !!connection?.pageInfo?.hasNextPage + } + }, + force + ) +} + +export async function getCustomView( + viewId: string, + model: LinearCustomViewModel, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise { + const id = viewId.trim() + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + if (!id) { + throw new Error('Custom view ID is required') + } + const key = `getCustomView:${concreteWorkspaceId}:${model}:${id}` + return coalesce( + key, + async () => { + const entries = getClients(concreteWorkspaceId) + const entry = entries[0] + if (!entry) { + return null + } + await acquire() + try { + const result = await entry.client.client.rawRequest< + CustomViewConnectionResponse, + LinearRawVariables + >(CUSTOM_VIEW_QUERY, { id }) + const view = result.data?.customView + const mapped = view ? mapCustomViewForWorkspace(entry, view) : null + return mapped?.model === model ? mapped : null + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } + throw error + } finally { + release() + } + }, + force + ) +} + +export async function listCustomViewIssues( + viewId: string, + limit = 20, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise> { + const id = viewId.trim() + if (!id) { + throw new Error('Custom view ID is required') + } + const first = clampLinearIssueListLimit(limit) + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + return readConcreteCollection( + `listCustomViewIssues:${concreteWorkspaceId}:${id}:${first}`, + concreteWorkspaceId, + async (entry) => { + return readIssueConnectionPages(entry, first, async (page) => { + const result = await entry.client.client.rawRequest< + CustomViewConnectionResponse, + LinearRawVariables + >(CUSTOM_VIEW_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' }) + const view = result.data?.customView + if (mapCustomViewModel(view?.modelName) !== 'issue') { + throw new Error('Custom view does not contain issues') + } + return view?.issues + }) + }, + force + ) +} + +export async function listCustomViewProjects( + viewId: string, + limit = 20, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise> { + const id = viewId.trim() + if (!id) { + throw new Error('Custom view ID is required') + } + const first = clampLimit(limit) + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + return readConcreteCollection( + `listCustomViewProjects:${concreteWorkspaceId}:${id}:${first}`, + concreteWorkspaceId, + async (entry) => { + const result = await entry.client.client.rawRequest< + CustomViewConnectionResponse, + LinearRawVariables + >(CUSTOM_VIEW_PROJECTS_QUERY, { id, first, orderBy: 'updatedAt' }) + const view = result.data?.customView + if (mapCustomViewModel(view?.modelName) !== 'project') { + throw new Error('Custom view does not contain projects') + } + const connection = view?.projects + return { + items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), + hasMore: !!connection?.pageInfo?.hasNextPage + } + }, + force + ) +} diff --git a/src/main/linear/linear-project-collection-read.ts b/src/main/linear/linear-project-collection-read.ts new file mode 100644 index 00000000000..d9924e46346 --- /dev/null +++ b/src/main/linear/linear-project-collection-read.ts @@ -0,0 +1,71 @@ +import type { + LinearCollectionResult, + LinearConcreteWorkspaceId, + LinearWorkspaceSelection +} from '../../shared/linear/workspace-types' +import { acquire, release } from './linear-request-concurrency' +import { clearToken } from './linear-token-store' +import { getClients, isAuthError, type LinearClientForWorkspace } from './client' +import { + coalesce, + normalizeConcreteWorkspaceId, + shouldFailWholeRequest, + workspaceError +} from './linear-project-models' + +export async function readCollection( + key: string, + workspaceId: LinearWorkspaceSelection | null | undefined, + load: (entry: LinearClientForWorkspace) => Promise>, + force = false +): Promise> { + return coalesce( + key, + async () => { + const entries = getClients(workspaceId) + if (entries.length === 0) { + return { items: [] } + } + + const results = await Promise.all( + entries.map(async (entry) => { + await acquire() + try { + return await load(entry) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } else { + console.warn('[linear] project/view read failed:', error) + } + if (shouldFailWholeRequest(workspaceId)) { + throw error + } + return { items: [], errors: [workspaceError(entry, error)] } + } finally { + release() + } + }) + ) + + return { + items: results.flatMap((result) => result.items), + errors: results.flatMap((result) => result.errors ?? []).length + ? results.flatMap((result) => result.errors ?? []) + : undefined, + hasMore: results.some((result) => result.hasMore) + } + }, + force + ) +} + +export async function readConcreteCollection( + key: string, + workspaceId: LinearConcreteWorkspaceId, + load: (entry: LinearClientForWorkspace) => Promise>, + force = false +): Promise> { + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + return readCollection(key, concreteWorkspaceId, load, force) +} diff --git a/src/main/linear/linear-project-graphql.ts b/src/main/linear/linear-project-graphql.ts new file mode 100644 index 00000000000..bd85f70e819 --- /dev/null +++ b/src/main/linear/linear-project-graphql.ts @@ -0,0 +1,207 @@ +export const ORCA_PROJECT_FIELDS = ` + id + slugId + name + description + content + url + color + icon + health + priority + priorityLabel + progress + scope + issueCountHistory + completedIssueCountHistory + startDate + targetDate + createdAt + updatedAt + completedAt + canceledAt + startedAt + status { + id + name + type + color + } + lead { + id + displayName + avatarUrl + } + members(first: 10) { + nodes { + id + displayName + avatarUrl + } + } + teams(first: 10) { + nodes { + id + name + key + } + } + labels(first: 20) { + nodes { + id + name + color + } + } +` + +export const ORCA_PROJECT_DETAIL_FIELDS = ` + ${ORCA_PROJECT_FIELDS} + projectMilestones(first: 20) { + nodes { + id + name + status + targetDate + progress + } + } + externalLinks(first: 20) { + nodes { + id + label + url + } + } + lastUpdate { + id + body + health + url + createdAt + updatedAt + user { + id + displayName + avatarUrl + } + } +` + +export const ORCA_ISSUE_FIELDS = ` + id + identifier + title + description + url + priority + estimate + updatedAt + labelIds + state { + name + type + color + } + team { + id + name + key + } + assignee { + id + displayName + avatarUrl + } + labels(first: 50) { + nodes { + id + name + } + } +` + +export const PROJECTS_QUERY = ` + query OrcaLinearProjects($first: Int, $filter: ProjectFilter, $orderBy: PaginationOrderBy) { + projects(first: $first, filter: $filter, orderBy: $orderBy) { + nodes { + ${ORCA_PROJECT_FIELDS} + } + pageInfo { + hasNextPage + } + } + } +` + +export const SEARCH_PROJECTS_QUERY = ` + query OrcaLinearProjectSearch($term: String!, $first: Int, $after: String) { + searchProjects(term: $term, first: $first, after: $after) { + nodes { + ${ORCA_PROJECT_FIELDS} + } + pageInfo { + hasNextPage + endCursor + } + } + } +` + +export const PROJECT_QUERY = ` + query OrcaLinearProject($id: String!) { + project(id: $id) { + ${ORCA_PROJECT_DETAIL_FIELDS} + } + } +` + +export const CREATE_PROJECT_MUTATION = ` + mutation OrcaLinearProjectCreate($input: ProjectCreateInput!) { + projectCreate(input: $input) { + success + project { + ${ORCA_PROJECT_DETAIL_FIELDS} + } + } + } +` + +export const PROJECT_ISSUES_QUERY = ` + query OrcaLinearProjectIssues( + $id: String!, + $first: Int, + $after: String, + $orderBy: PaginationOrderBy + ) { + project(id: $id) { + issues(first: $first, after: $after, orderBy: $orderBy) { + nodes { + ${ORCA_ISSUE_FIELDS} + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +` + +export const PROJECT_TEAMS_QUERY = ` + query OrcaLinearProjectTeams($id: String!, $first: Int, $after: String) { + project(id: $id) { + teams(first: $first, after: $after) { + nodes { + id + name + key + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +` diff --git a/src/main/linear/linear-project-issue-queries.ts b/src/main/linear/linear-project-issue-queries.ts new file mode 100644 index 00000000000..a2ced4a878b --- /dev/null +++ b/src/main/linear/linear-project-issue-queries.ts @@ -0,0 +1,82 @@ +import type { LinearIssue } from '../../shared/linear/issue-types' +import type { + LinearCollectionResult, + LinearConcreteWorkspaceId +} from '../../shared/linear/workspace-types' +import { + LINEAR_ISSUE_API_PAGE_SIZE_MAX, + clampLinearIssueListLimit +} from '../../shared/linear/issue-read-limits' +import type { LinearClientForWorkspace } from './client' +import { PROJECT_ISSUES_QUERY } from './linear-project-graphql' +import type { + LinearConnection, + LinearIssueNode, + LinearRawVariables, + ProjectIssueConnectionResponse +} from './linear-project-nodes' +import { mapIssueForWorkspace, normalizeConcreteWorkspaceId } from './linear-project-models' +import { readConcreteCollection } from './linear-project-collection-read' + +export async function readIssueConnectionPages( + entry: LinearClientForWorkspace, + limit: number, + loadConnection: (variables: { + first: number + after?: string + }) => Promise | null | undefined> +): Promise> { + const items: LinearIssue[] = [] + let after: string | undefined + let hasMore = false + + while (items.length < limit) { + // Why: Linear returns issue connections in pages of up to 50; expanded + // Orca reads must follow cursors to show more than one backend page. + const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - items.length) + const connection = await loadConnection(after ? { first, after } : { first }) + const nodes = connection?.nodes ?? [] + items.push(...nodes.map((issue) => mapIssueForWorkspace(entry, issue))) + hasMore = Boolean(connection?.pageInfo?.hasNextPage) + + const nextCursor = connection?.pageInfo?.endCursor ?? undefined + if (!hasMore || !nextCursor || nextCursor === after || nodes.length === 0) { + break + } + after = nextCursor + } + + return { items, hasMore } +} + +export async function listProjectIssues( + projectId: string, + limit = 20, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise> { + const id = projectId.trim() + if (!id) { + throw new Error('Project ID is required') + } + const first = clampLinearIssueListLimit(limit) + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + return readConcreteCollection( + `listProjectIssues:${concreteWorkspaceId}:${id}:${first}`, + concreteWorkspaceId, + async (entry) => { + return readIssueConnectionPages(entry, first, async (page) => { + const result = await entry.client.client.rawRequest< + ProjectIssueConnectionResponse, + LinearRawVariables + >(PROJECT_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' }) + const project = result.data?.project + if (!project) { + throw new Error('Project was not found') + } + return project.issues + }) + }, + force + ) +} diff --git a/src/main/linear/linear-project-models.ts b/src/main/linear/linear-project-models.ts new file mode 100644 index 00000000000..38371e70b1f --- /dev/null +++ b/src/main/linear/linear-project-models.ts @@ -0,0 +1,285 @@ +import type { LinearIssue } from '../../shared/linear/issue-types' +import type { + LinearCustomViewModel, + LinearCustomViewSummary, + LinearProjectDetail, + LinearProjectMemberSummary, + LinearProjectSummary +} from '../../shared/linear/project-types' +import type { + LinearConcreteWorkspaceId, + LinearWorkspaceError, + LinearWorkspaceSelection +} from '../../shared/linear/workspace-types' +import { isAuthError, type LinearClientForWorkspace } from './client' +import type { + LinearCustomViewNode, + LinearIssueNode, + LinearProjectNode, + LinearUserNode +} from './linear-project-nodes' + +const inFlight = new Map>() +export const LINEAR_PROJECT_API_PAGE_SIZE_MAX = 50 + +export function clampLimit(limit = 20): number { + return Math.min(Math.max(1, Math.floor(limit)), LINEAR_PROJECT_API_PAGE_SIZE_MAX) +} + +export function coalesce(key: string, load: () => Promise, force = false): Promise { + const existing = inFlight.get(key) as Promise | undefined + if (existing && !force) { + return existing + } + const promise = load().finally(() => { + if (inFlight.get(key) === promise) { + inFlight.delete(key) + } + }) + inFlight.set(key, promise) + return promise +} + +export function normalizeConcreteWorkspaceId(workspaceId: unknown): LinearConcreteWorkspaceId { + if (typeof workspaceId !== 'string' || !workspaceId.trim() || workspaceId === 'all') { + throw new Error('Concrete Linear workspace ID is required') + } + return workspaceId.trim() +} + +export function workspaceError( + entry: LinearClientForWorkspace, + error: unknown +): LinearWorkspaceError { + if (isAuthError(error)) { + return { + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + type: 'auth', + message: 'Linear authentication expired for this workspace.' + } + } + + const record = error as { name?: string; message?: string; status?: number; response?: unknown } + const message = record.message || 'Linear request failed.' + const status = + typeof record.status === 'number' + ? record.status + : typeof (record.response as { status?: unknown } | undefined)?.status === 'number' + ? ((record.response as { status: number }).status as number) + : undefined + const name = record.name ?? '' + + if (status === 429 || /rate/i.test(name)) { + return { + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + type: 'rate_limited', + message + } + } + if ((typeof status === 'number' && status >= 500) || /network/i.test(name)) { + return { + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + type: 'network', + message + } + } + + return { + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + type: 'unknown', + message + } +} + +export function shouldFailWholeRequest( + selection: LinearWorkspaceSelection | null | undefined +): boolean { + return selection !== 'all' +} + +export function lastNumericValue(values?: number[] | null): number | undefined { + const last = values?.at(-1) + return typeof last === 'number' ? last : undefined +} + +export function mapUser(user?: LinearUserNode | null): LinearProjectMemberSummary | undefined { + if (!user?.id) { + return undefined + } + return { + id: user.id, + displayName: user.displayName ?? '', + avatarUrl: user.avatarUrl ?? undefined + } +} + +export function mapProjectForWorkspace( + entry: LinearClientForWorkspace, + project: LinearProjectNode +): LinearProjectSummary { + return { + id: project.id, + slugId: project.slugId ?? undefined, + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + name: project.name, + url: project.url ?? undefined, + color: project.color ?? undefined, + icon: project.icon ?? undefined, + description: project.description ?? undefined, + content: project.content ?? undefined, + status: project.status + ? { + id: project.status.id, + name: project.status.name ?? '', + type: project.status.type ?? undefined, + color: project.status.color ?? undefined + } + : undefined, + health: project.health ?? null, + priority: project.priority ?? null, + priorityLabel: project.priorityLabel ?? null, + lead: mapUser(project.lead), + members: project.members?.nodes + ?.map(mapUser) + .filter((user): user is LinearProjectMemberSummary => !!user), + teams: project.teams?.nodes?.map((team) => ({ + id: team.id, + name: team.name ?? '', + key: team.key ?? undefined + })), + labels: project.labels?.nodes?.map((label) => ({ + id: label.id, + name: label.name ?? '', + color: label.color ?? undefined + })), + startDate: project.startDate ?? null, + targetDate: project.targetDate ?? null, + createdAt: project.createdAt ?? undefined, + updatedAt: project.updatedAt ?? undefined, + completedAt: project.completedAt ?? null, + canceledAt: project.canceledAt ?? null, + startedAt: project.startedAt ?? null, + progress: project.progress ?? null, + scope: project.scope ?? null, + issueCount: lastNumericValue(project.issueCountHistory), + completedIssueCount: lastNumericValue(project.completedIssueCountHistory) + } +} + +export function mapProjectDetailForWorkspace( + entry: LinearClientForWorkspace, + project: LinearProjectNode +): LinearProjectDetail { + return { + ...mapProjectForWorkspace(entry, project), + milestones: project.projectMilestones?.nodes?.map((milestone) => ({ + id: milestone.id, + name: milestone.name ?? '', + status: milestone.status ?? undefined, + targetDate: milestone.targetDate ?? null, + progress: milestone.progress ?? null + })), + resources: project.externalLinks?.nodes + ?.filter((link) => link.url) + .map((link) => ({ + id: link.id, + title: link.label || link.url || 'Link', + url: link.url!, + type: 'link' + })), + latestUpdate: project.lastUpdate + ? { + id: project.lastUpdate.id, + body: project.lastUpdate.body ?? undefined, + health: project.lastUpdate.health ?? null, + url: project.lastUpdate.url ?? undefined, + createdAt: project.lastUpdate.createdAt ?? undefined, + updatedAt: project.lastUpdate.updatedAt ?? undefined, + user: mapUser(project.lastUpdate.user) + } + : undefined + } +} + +export function mapIssueForWorkspace( + entry: LinearClientForWorkspace, + issue: LinearIssueNode +): LinearIssue { + const labelNodes = issue.labels?.nodes ?? [] + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description ?? undefined, + url: issue.url, + state: { + name: issue.state?.name ?? '', + type: issue.state?.type ?? '', + color: issue.state?.color ?? '' + }, + team: { + id: issue.team?.id ?? '', + name: issue.team?.name ?? '', + key: issue.team?.key ?? '' + }, + labels: labelNodes.map((label) => label.name), + labelIds: issue.labelIds ?? labelNodes.map((label) => label.id), + assignee: mapUser(issue.assignee), + estimate: issue.estimate ?? null, + priority: issue.priority, + updatedAt: issue.updatedAt, + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName + } +} + +export function mapCustomViewModel(modelName?: string | null): LinearCustomViewModel | null { + const normalized = modelName?.toLowerCase() + if (normalized === 'issue') { + return 'issue' + } + if (normalized === 'project') { + return 'project' + } + return null +} + +export function mapCustomViewForWorkspace( + entry: LinearClientForWorkspace, + view: LinearCustomViewNode +): LinearCustomViewSummary | null { + const model = mapCustomViewModel(view.modelName) + if (!model) { + return null + } + return { + id: view.id, + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + name: view.name, + description: view.description ?? undefined, + model, + url: view.slugId + ? `https://linear.app/${entry.workspace.organizationUrlKey}/view/${view.slugId}` + : undefined, + color: view.color ?? undefined, + icon: view.icon ?? undefined, + shared: view.shared ?? undefined, + team: view.team + ? { + id: view.team.id, + name: view.team.name ?? undefined, + key: view.team.key ?? undefined + } + : undefined, + owner: mapUser(view.owner), + creator: mapUser(view.creator), + createdAt: view.createdAt ?? undefined, + updatedAt: view.updatedAt ?? undefined + } +} diff --git a/src/main/linear/linear-project-nodes.ts b/src/main/linear/linear-project-nodes.ts new file mode 100644 index 00000000000..2796ca35d94 --- /dev/null +++ b/src/main/linear/linear-project-nodes.ts @@ -0,0 +1,161 @@ +export type LinearRawVariables = Record + +export type PageInfoNode = { + hasNextPage?: boolean | null + endCursor?: string | null +} + +export type LinearConnection = { + nodes?: T[] | null + pageInfo?: PageInfoNode | null +} + +export type LinearUserNode = { + id: string + displayName?: string | null + avatarUrl?: string | null +} + +export type LinearProjectNode = { + id: string + slugId?: string | null + name: string + description?: string | null + content?: string | null + url?: string | null + color?: string | null + icon?: string | null + health?: string | null + priority?: number | null + priorityLabel?: string | null + progress?: number | null + scope?: number | null + issueCountHistory?: number[] | null + completedIssueCountHistory?: number[] | null + startDate?: string | null + targetDate?: string | null + createdAt?: string | null + updatedAt?: string | null + completedAt?: string | null + canceledAt?: string | null + startedAt?: string | null + status?: { + id: string + name?: string | null + type?: string | null + color?: string | null + } | null + lead?: LinearUserNode | null + members?: LinearConnection | null + teams?: LinearConnection<{ id: string; name?: string | null; key?: string | null }> | null + labels?: LinearConnection<{ id: string; name?: string | null; color?: string | null }> | null + projectMilestones?: LinearConnection<{ + id: string + name?: string | null + status?: string | null + targetDate?: string | null + progress?: number | null + }> | null + externalLinks?: LinearConnection<{ + id: string + label?: string | null + url?: string | null + }> | null + lastUpdate?: { + id: string + body?: string | null + health?: string | null + url?: string | null + createdAt?: string | null + updatedAt?: string | null + user?: LinearUserNode | null + } | null +} + +export type LinearIssueNode = { + id: string + identifier: string + title: string + description?: string | null + url: string + estimate?: number | null + priority: number + updatedAt: string + labelIds?: string[] | null + state?: { + name?: string | null + type?: string | null + color?: string | null + } | null + team?: { + id?: string | null + name?: string | null + key?: string | null + } | null + assignee?: LinearUserNode | null + labels?: LinearConnection<{ id: string; name: string }> | null +} + +export type LinearCustomViewNode = { + id: string + name: string + description?: string | null + modelName?: string | null + color?: string | null + icon?: string | null + shared?: boolean | null + slugId?: string | null + createdAt?: string | null + updatedAt?: string | null + team?: { id: string; name?: string | null; key?: string | null } | null + owner?: LinearUserNode | null + creator?: LinearUserNode | null +} + +export type ProjectConnectionResponse = { + projects?: LinearConnection | null + searchProjects?: LinearConnection | null + project?: LinearProjectNode | null +} + +export type ProjectIssueConnectionResponse = { + project?: { + issues?: LinearConnection | null + } | null +} + +export type ProjectTeamsResponse = { + project?: { + teams?: LinearConnection<{ id: string; name?: string | null; key?: string | null }> | null + } | null +} + +export type CustomViewConnectionResponse = { + customViews?: LinearConnection | null + customView?: + | (LinearCustomViewNode & { + issues?: LinearConnection | null + projects?: LinearConnection | null + }) + | null +} + +export type ProjectMutationResponse = { + projectCreate?: { + success?: boolean | null + project?: LinearProjectNode | null + } | null +} + +export type LinearProjectCreateInput = { + name: string + description?: string + content?: string + teamIds: string[] + leadId?: string + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string +} diff --git a/src/main/linear/linear-project-queries.ts b/src/main/linear/linear-project-queries.ts new file mode 100644 index 00000000000..961d2f642ba --- /dev/null +++ b/src/main/linear/linear-project-queries.ts @@ -0,0 +1,196 @@ +import type { LinearProjectDetail, LinearProjectSummary } from '../../shared/linear/project-types' +import type { + LinearCollectionResult, + LinearConcreteWorkspaceId, + LinearWorkspaceSelection +} from '../../shared/linear/workspace-types' +import { acquire, release } from './linear-request-concurrency' +import { clearToken } from './linear-token-store' +import { getClients, isAuthError } from './client' +import { + CREATE_PROJECT_MUTATION, + PROJECT_QUERY, + PROJECTS_QUERY, + SEARCH_PROJECTS_QUERY +} from './linear-project-graphql' +import type { + LinearProjectCreateInput, + LinearRawVariables, + ProjectConnectionResponse, + ProjectMutationResponse +} from './linear-project-nodes' +import { + clampLimit, + coalesce, + LINEAR_PROJECT_API_PAGE_SIZE_MAX, + mapProjectDetailForWorkspace, + mapProjectForWorkspace, + normalizeConcreteWorkspaceId +} from './linear-project-models' +import { readCollection } from './linear-project-collection-read' + +export async function listProjects( + query: string | undefined, + limit = 20, + workspaceId?: LinearWorkspaceSelection | null, + force = false +): Promise> { + const first = clampLimit(limit) + const trimmed = query?.trim() + const key = `listProjects:${workspaceId ?? 'default'}:${trimmed ?? ''}:${first}` + return readCollection( + key, + workspaceId, + async (entry) => { + const variables = trimmed ? { term: trimmed, first } : { first, orderBy: 'updatedAt' } + const result = await entry.client.client.rawRequest< + ProjectConnectionResponse, + LinearRawVariables + >(trimmed ? SEARCH_PROJECTS_QUERY : PROJECTS_QUERY, variables) + const connection = trimmed ? result.data?.searchProjects : result.data?.projects + return { + items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), + hasMore: !!connection?.pageInfo?.hasNextPage + } + }, + force + ) +} + +export async function listProjectsByExactName( + name: string, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise { + const projectName = name.trim() + if (!projectName) { + throw new Error('Project name is required') + } + const normalized = projectName.toLowerCase() + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + const key = `listProjectsByExactName:${concreteWorkspaceId}:${normalized}` + return coalesce( + key, + async () => { + const entries = getClients(concreteWorkspaceId) + const entry = entries[0] + if (!entry) { + return [] + } + await acquire() + try { + const matches: LinearProjectSummary[] = [] + let after: string | undefined + while (true) { + const result = await entry.client.client.rawRequest< + ProjectConnectionResponse, + LinearRawVariables + >(SEARCH_PROJECTS_QUERY, { + term: projectName, + first: LINEAR_PROJECT_API_PAGE_SIZE_MAX, + ...(after ? { after } : {}) + }) + const connection = result.data?.searchProjects + for (const project of connection?.nodes ?? []) { + if (project.name.trim().toLowerCase() === normalized) { + matches.push(mapProjectForWorkspace(entry, project)) + } + } + const nextCursor = connection?.pageInfo?.endCursor ?? undefined + if ( + connection?.pageInfo?.hasNextPage !== true || + !nextCursor || + nextCursor === after || + (connection.nodes ?? []).length === 0 + ) { + break + } + after = nextCursor + } + return matches + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } + throw error + } finally { + release() + } + }, + force + ) +} + +export async function getProject( + id: string, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise { + const projectId = id.trim() + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + if (!projectId) { + throw new Error('Project ID is required') + } + const key = `getProject:${concreteWorkspaceId}:${projectId}` + return coalesce( + key, + async () => { + const entries = getClients(concreteWorkspaceId) + const entry = entries[0] + if (!entry) { + return null + } + await acquire() + try { + const result = await entry.client.client.rawRequest< + ProjectConnectionResponse, + LinearRawVariables + >(PROJECT_QUERY, { id: projectId }) + return result.data?.project + ? mapProjectDetailForWorkspace(entry, result.data.project) + : null + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } + throw error + } finally { + release() + } + }, + force + ) +} + +export async function createProject( + input: LinearProjectCreateInput, + workspaceId?: string | null +): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> { + const entry = getClients(workspaceId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Linear' } + } + + await acquire() + try { + const result = await entry.client.client.rawRequest< + ProjectMutationResponse, + LinearRawVariables + >(CREATE_PROJECT_MUTATION, { input }) + const payload = result.data?.projectCreate + const project = payload?.project + if (!payload?.success || !project) { + return { ok: false, error: 'Linear project create failed' } + } + return { ok: true, project: mapProjectDetailForWorkspace(entry, project) } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + throw error + } + const message = error instanceof Error ? error.message : String(error) + return { ok: false, error: message } + } finally { + release() + } +} diff --git a/src/main/linear/linear-project-team-queries.ts b/src/main/linear/linear-project-team-queries.ts new file mode 100644 index 00000000000..569dd130220 --- /dev/null +++ b/src/main/linear/linear-project-team-queries.ts @@ -0,0 +1,77 @@ +import type { LinearProjectSummary } from '../../shared/linear/project-types' +import type { LinearConcreteWorkspaceId } from '../../shared/linear/workspace-types' +import { acquire, release } from './linear-request-concurrency' +import { clearToken } from './linear-token-store' +import { getClients, isAuthError } from './client' +import { PROJECT_TEAMS_QUERY } from './linear-project-graphql' +import type { LinearRawVariables, ProjectTeamsResponse } from './linear-project-nodes' +import { coalesce, normalizeConcreteWorkspaceId } from './linear-project-models' + +export async function listProjectTeams( + projectId: string, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise> { + const id = projectId.trim() + if (!id) { + throw new Error('Project ID is required') + } + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + const key = `listProjectTeams:${concreteWorkspaceId}:${id}` + return coalesce( + key, + async () => { + const entry = getClients(concreteWorkspaceId)[0] + if (!entry) { + return [] + } + const teams: NonNullable = [] + let after: string | undefined + await acquire() + try { + while (true) { + const result = await entry.client.client.rawRequest< + ProjectTeamsResponse, + LinearRawVariables + >(PROJECT_TEAMS_QUERY, { + id, + first: 50, + ...(after ? { after } : {}) + }) + const project = result.data?.project + if (!project) { + throw new Error('Project was not found') + } + const connection = project.teams + const nodes = connection?.nodes ?? [] + teams.push( + ...nodes.map((team) => ({ + id: team.id, + name: team.name ?? '', + key: team.key ?? undefined + })) + ) + const nextCursor = connection?.pageInfo?.endCursor ?? undefined + if ( + !connection?.pageInfo?.hasNextPage || + !nextCursor || + nextCursor === after || + nodes.length === 0 + ) { + break + } + after = nextCursor + } + return teams + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } + throw error + } finally { + release() + } + }, + force + ) +} diff --git a/src/main/linear/projects.test.ts b/src/main/linear/projects.test.ts index c8a5dd6ac09..45655495f64 100644 --- a/src/main/linear/projects.test.ts +++ b/src/main/linear/projects.test.ts @@ -388,6 +388,8 @@ describe('Linear project queries', () => { targetDate: '2026-08-01' } }) + expect(rawRequest.mock.calls[0]?.[0]).toContain('mutation OrcaLinearProjectCreate') + expect(rawRequest.mock.calls[0]?.[0]).toContain('projectCreate(input: $input)') }) it('lets manual custom view list refresh bypass older in-flight reads', async () => { diff --git a/src/main/linear/projects.ts b/src/main/linear/projects.ts index 483b1b032a6..4ea2d13c241 100644 --- a/src/main/linear/projects.ts +++ b/src/main/linear/projects.ts @@ -1,1258 +1,15 @@ -/* eslint-disable max-lines -- Why: Linear project and custom-view reads share - raw GraphQL selection sets, workspace fan-out, and partial-failure mapping. */ -import type { LinearIssue } from '../../shared/linear/issue-types' -import type { - LinearCustomViewModel, - LinearCustomViewSummary, - LinearProjectDetail, - LinearProjectMemberSummary, - LinearProjectSummary -} from '../../shared/linear/project-types' -import type { - LinearCollectionResult, - LinearConcreteWorkspaceId, - LinearWorkspaceError, - LinearWorkspaceSelection -} from '../../shared/linear/workspace-types' -import { - LINEAR_ISSUE_API_PAGE_SIZE_MAX, - clampLinearIssueListLimit -} from '../../shared/linear/issue-read-limits' -import { acquire, release } from './linear-request-concurrency' -import { clearToken } from './linear-token-store' -import { getClients, isAuthError, type LinearClientForWorkspace } from './client' - -type LinearRawVariables = Record - -type PageInfoNode = { - hasNextPage?: boolean | null - endCursor?: string | null -} - -type LinearConnection = { - nodes?: T[] | null - pageInfo?: PageInfoNode | null -} - -type LinearUserNode = { - id: string - displayName?: string | null - avatarUrl?: string | null -} - -type LinearProjectNode = { - id: string - slugId?: string | null - name: string - description?: string | null - content?: string | null - url?: string | null - color?: string | null - icon?: string | null - health?: string | null - priority?: number | null - priorityLabel?: string | null - progress?: number | null - scope?: number | null - issueCountHistory?: number[] | null - completedIssueCountHistory?: number[] | null - startDate?: string | null - targetDate?: string | null - createdAt?: string | null - updatedAt?: string | null - completedAt?: string | null - canceledAt?: string | null - startedAt?: string | null - status?: { - id: string - name?: string | null - type?: string | null - color?: string | null - } | null - lead?: LinearUserNode | null - members?: LinearConnection | null - teams?: LinearConnection<{ id: string; name?: string | null; key?: string | null }> | null - labels?: LinearConnection<{ id: string; name?: string | null; color?: string | null }> | null - projectMilestones?: LinearConnection<{ - id: string - name?: string | null - status?: string | null - targetDate?: string | null - progress?: number | null - }> | null - externalLinks?: LinearConnection<{ - id: string - label?: string | null - url?: string | null - }> | null - lastUpdate?: { - id: string - body?: string | null - health?: string | null - url?: string | null - createdAt?: string | null - updatedAt?: string | null - user?: LinearUserNode | null - } | null -} - -type LinearIssueNode = { - id: string - identifier: string - title: string - description?: string | null - url: string - estimate?: number | null - priority: number - updatedAt: string - labelIds?: string[] | null - state?: { - name?: string | null - type?: string | null - color?: string | null - } | null - team?: { - id?: string | null - name?: string | null - key?: string | null - } | null - assignee?: LinearUserNode | null - labels?: LinearConnection<{ id: string; name: string }> | null -} - -type LinearCustomViewNode = { - id: string - name: string - description?: string | null - modelName?: string | null - color?: string | null - icon?: string | null - shared?: boolean | null - slugId?: string | null - createdAt?: string | null - updatedAt?: string | null - team?: { id: string; name?: string | null; key?: string | null } | null - owner?: LinearUserNode | null - creator?: LinearUserNode | null -} - -type ProjectConnectionResponse = { - projects?: LinearConnection | null - searchProjects?: LinearConnection | null - project?: LinearProjectNode | null -} - -type ProjectIssueConnectionResponse = { - project?: { - issues?: LinearConnection | null - } | null -} - -type ProjectTeamsResponse = { - project?: { - teams?: LinearConnection<{ id: string; name?: string | null; key?: string | null }> | null - } | null -} - -type CustomViewConnectionResponse = { - customViews?: LinearConnection | null - customView?: - | (LinearCustomViewNode & { - issues?: LinearConnection | null - projects?: LinearConnection | null - }) - | null -} - -type ProjectMutationResponse = { - projectCreate?: { - success?: boolean | null - project?: LinearProjectNode | null - } | null -} - -export type LinearProjectCreateInput = { - name: string - description?: string - content?: string - teamIds: string[] - leadId?: string - memberIds?: string[] - labelIds?: string[] - priority?: number - startDate?: string - targetDate?: string -} - -const ORCA_PROJECT_FIELDS = ` - id - slugId - name - description - content - url - color - icon - health - priority - priorityLabel - progress - scope - issueCountHistory - completedIssueCountHistory - startDate - targetDate - createdAt - updatedAt - completedAt - canceledAt - startedAt - status { - id - name - type - color - } - lead { - id - displayName - avatarUrl - } - members(first: 10) { - nodes { - id - displayName - avatarUrl - } - } - teams(first: 10) { - nodes { - id - name - key - } - } - labels(first: 20) { - nodes { - id - name - color - } - } -` - -const ORCA_PROJECT_DETAIL_FIELDS = ` - ${ORCA_PROJECT_FIELDS} - projectMilestones(first: 20) { - nodes { - id - name - status - targetDate - progress - } - } - externalLinks(first: 20) { - nodes { - id - label - url - } - } - lastUpdate { - id - body - health - url - createdAt - updatedAt - user { - id - displayName - avatarUrl - } - } -` - -const ORCA_ISSUE_FIELDS = ` - id - identifier - title - description - url - priority - estimate - updatedAt - labelIds - state { - name - type - color - } - team { - id - name - key - } - assignee { - id - displayName - avatarUrl - } - labels(first: 50) { - nodes { - id - name - } - } -` - -const PROJECTS_QUERY = ` - query OrcaLinearProjects($first: Int, $filter: ProjectFilter, $orderBy: PaginationOrderBy) { - projects(first: $first, filter: $filter, orderBy: $orderBy) { - nodes { - ${ORCA_PROJECT_FIELDS} - } - pageInfo { - hasNextPage - } - } - } -` - -const SEARCH_PROJECTS_QUERY = ` - query OrcaLinearProjectSearch($term: String!, $first: Int, $after: String) { - searchProjects(term: $term, first: $first, after: $after) { - nodes { - ${ORCA_PROJECT_FIELDS} - } - pageInfo { - hasNextPage - endCursor - } - } - } -` - -const PROJECT_QUERY = ` - query OrcaLinearProject($id: String!) { - project(id: $id) { - ${ORCA_PROJECT_DETAIL_FIELDS} - } - } -` - -const CREATE_PROJECT_MUTATION = ` - mutation OrcaLinearProjectCreate($input: ProjectCreateInput!) { - projectCreate(input: $input) { - success - project { - ${ORCA_PROJECT_DETAIL_FIELDS} - } - } - } -` - -const PROJECT_ISSUES_QUERY = ` - query OrcaLinearProjectIssues( - $id: String!, - $first: Int, - $after: String, - $orderBy: PaginationOrderBy - ) { - project(id: $id) { - issues(first: $first, after: $after, orderBy: $orderBy) { - nodes { - ${ORCA_ISSUE_FIELDS} - } - pageInfo { - hasNextPage - endCursor - } - } - } - } -` - -const PROJECT_TEAMS_QUERY = ` - query OrcaLinearProjectTeams($id: String!, $first: Int, $after: String) { - project(id: $id) { - teams(first: $first, after: $after) { - nodes { - id - name - key - } - pageInfo { - hasNextPage - endCursor - } - } - } - } -` - -const CUSTOM_VIEWS_QUERY = ` - query OrcaLinearCustomViews( - $first: Int, - $filter: CustomViewFilter, - $orderBy: PaginationOrderBy - ) { - customViews(first: $first, filter: $filter, orderBy: $orderBy) { - nodes { - id - name - description - modelName - color - icon - shared - slugId - createdAt - updatedAt - team { - id - name - key - } - owner { - id - displayName - avatarUrl - } - creator { - id - displayName - avatarUrl - } - } - pageInfo { - hasNextPage - } - } - } -` - -const CUSTOM_VIEW_QUERY = ` - query OrcaLinearCustomView($id: String!) { - customView(id: $id) { - id - name - description - modelName - color - icon - shared - slugId - createdAt - updatedAt - team { - id - name - key - } - owner { - id - displayName - avatarUrl - } - creator { - id - displayName - avatarUrl - } - } - } -` - -const CUSTOM_VIEW_ISSUES_QUERY = ` - query OrcaLinearCustomViewIssues( - $id: String!, - $first: Int, - $after: String, - $orderBy: PaginationOrderBy - ) { - customView(id: $id) { - id - modelName - issues(first: $first, after: $after, orderBy: $orderBy) { - nodes { - ${ORCA_ISSUE_FIELDS} - } - pageInfo { - hasNextPage - endCursor - } - } - } - } -` - -const CUSTOM_VIEW_PROJECTS_QUERY = ` - query OrcaLinearCustomViewProjects($id: String!, $first: Int, $orderBy: PaginationOrderBy) { - customView(id: $id) { - id - modelName - projects(first: $first, orderBy: $orderBy) { - nodes { - ${ORCA_PROJECT_FIELDS} - } - pageInfo { - hasNextPage - } - } - } - } -` - -const inFlight = new Map>() -const LINEAR_PROJECT_API_PAGE_SIZE_MAX = 50 - -function clampLimit(limit = 20): number { - return Math.min(Math.max(1, Math.floor(limit)), LINEAR_PROJECT_API_PAGE_SIZE_MAX) -} - -function coalesce(key: string, load: () => Promise, force = false): Promise { - const existing = inFlight.get(key) as Promise | undefined - if (existing && !force) { - return existing - } - const promise = load().finally(() => { - if (inFlight.get(key) === promise) { - inFlight.delete(key) - } - }) - inFlight.set(key, promise) - return promise -} - -function normalizeConcreteWorkspaceId(workspaceId: unknown): LinearConcreteWorkspaceId { - if (typeof workspaceId !== 'string' || !workspaceId.trim() || workspaceId === 'all') { - throw new Error('Concrete Linear workspace ID is required') - } - return workspaceId.trim() -} - -function workspaceError(entry: LinearClientForWorkspace, error: unknown): LinearWorkspaceError { - if (isAuthError(error)) { - return { - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - type: 'auth', - message: 'Linear authentication expired for this workspace.' - } - } - - const record = error as { name?: string; message?: string; status?: number; response?: unknown } - const message = record.message || 'Linear request failed.' - const status = - typeof record.status === 'number' - ? record.status - : typeof (record.response as { status?: unknown } | undefined)?.status === 'number' - ? ((record.response as { status: number }).status as number) - : undefined - const name = record.name ?? '' - - if (status === 429 || /rate/i.test(name)) { - return { - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - type: 'rate_limited', - message - } - } - if ((typeof status === 'number' && status >= 500) || /network/i.test(name)) { - return { - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - type: 'network', - message - } - } - - return { - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - type: 'unknown', - message - } -} - -function shouldFailWholeRequest(selection: LinearWorkspaceSelection | null | undefined): boolean { - return selection !== 'all' -} - -function lastNumericValue(values?: number[] | null): number | undefined { - const last = values?.at(-1) - return typeof last === 'number' ? last : undefined -} - -function mapUser(user?: LinearUserNode | null): LinearProjectMemberSummary | undefined { - if (!user?.id) { - return undefined - } - return { - id: user.id, - displayName: user.displayName ?? '', - avatarUrl: user.avatarUrl ?? undefined - } -} - -function mapProjectForWorkspace( - entry: LinearClientForWorkspace, - project: LinearProjectNode -): LinearProjectSummary { - return { - id: project.id, - slugId: project.slugId ?? undefined, - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - name: project.name, - url: project.url ?? undefined, - color: project.color ?? undefined, - icon: project.icon ?? undefined, - description: project.description ?? undefined, - content: project.content ?? undefined, - status: project.status - ? { - id: project.status.id, - name: project.status.name ?? '', - type: project.status.type ?? undefined, - color: project.status.color ?? undefined - } - : undefined, - health: project.health ?? null, - priority: project.priority ?? null, - priorityLabel: project.priorityLabel ?? null, - lead: mapUser(project.lead), - members: project.members?.nodes - ?.map(mapUser) - .filter((user): user is LinearProjectMemberSummary => !!user), - teams: project.teams?.nodes?.map((team) => ({ - id: team.id, - name: team.name ?? '', - key: team.key ?? undefined - })), - labels: project.labels?.nodes?.map((label) => ({ - id: label.id, - name: label.name ?? '', - color: label.color ?? undefined - })), - startDate: project.startDate ?? null, - targetDate: project.targetDate ?? null, - createdAt: project.createdAt ?? undefined, - updatedAt: project.updatedAt ?? undefined, - completedAt: project.completedAt ?? null, - canceledAt: project.canceledAt ?? null, - startedAt: project.startedAt ?? null, - progress: project.progress ?? null, - scope: project.scope ?? null, - issueCount: lastNumericValue(project.issueCountHistory), - completedIssueCount: lastNumericValue(project.completedIssueCountHistory) - } -} - -function mapProjectDetailForWorkspace( - entry: LinearClientForWorkspace, - project: LinearProjectNode -): LinearProjectDetail { - return { - ...mapProjectForWorkspace(entry, project), - milestones: project.projectMilestones?.nodes?.map((milestone) => ({ - id: milestone.id, - name: milestone.name ?? '', - status: milestone.status ?? undefined, - targetDate: milestone.targetDate ?? null, - progress: milestone.progress ?? null - })), - resources: project.externalLinks?.nodes - ?.filter((link) => link.url) - .map((link) => ({ - id: link.id, - title: link.label || link.url || 'Link', - url: link.url!, - type: 'link' - })), - latestUpdate: project.lastUpdate - ? { - id: project.lastUpdate.id, - body: project.lastUpdate.body ?? undefined, - health: project.lastUpdate.health ?? null, - url: project.lastUpdate.url ?? undefined, - createdAt: project.lastUpdate.createdAt ?? undefined, - updatedAt: project.lastUpdate.updatedAt ?? undefined, - user: mapUser(project.lastUpdate.user) - } - : undefined - } -} - -function mapIssueForWorkspace( - entry: LinearClientForWorkspace, - issue: LinearIssueNode -): LinearIssue { - const labelNodes = issue.labels?.nodes ?? [] - return { - id: issue.id, - identifier: issue.identifier, - title: issue.title, - description: issue.description ?? undefined, - url: issue.url, - state: { - name: issue.state?.name ?? '', - type: issue.state?.type ?? '', - color: issue.state?.color ?? '' - }, - team: { - id: issue.team?.id ?? '', - name: issue.team?.name ?? '', - key: issue.team?.key ?? '' - }, - labels: labelNodes.map((label) => label.name), - labelIds: issue.labelIds ?? labelNodes.map((label) => label.id), - assignee: mapUser(issue.assignee), - estimate: issue.estimate ?? null, - priority: issue.priority, - updatedAt: issue.updatedAt, - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName - } -} - -function mapCustomViewModel(modelName?: string | null): LinearCustomViewModel | null { - const normalized = modelName?.toLowerCase() - if (normalized === 'issue') { - return 'issue' - } - if (normalized === 'project') { - return 'project' - } - return null -} - -function mapCustomViewForWorkspace( - entry: LinearClientForWorkspace, - view: LinearCustomViewNode -): LinearCustomViewSummary | null { - const model = mapCustomViewModel(view.modelName) - if (!model) { - return null - } - return { - id: view.id, - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - name: view.name, - description: view.description ?? undefined, - model, - url: view.slugId - ? `https://linear.app/${entry.workspace.organizationUrlKey}/view/${view.slugId}` - : undefined, - color: view.color ?? undefined, - icon: view.icon ?? undefined, - shared: view.shared ?? undefined, - team: view.team - ? { - id: view.team.id, - name: view.team.name ?? undefined, - key: view.team.key ?? undefined - } - : undefined, - owner: mapUser(view.owner), - creator: mapUser(view.creator), - createdAt: view.createdAt ?? undefined, - updatedAt: view.updatedAt ?? undefined - } -} - -async function readIssueConnectionPages( - entry: LinearClientForWorkspace, - limit: number, - loadConnection: (variables: { - first: number - after?: string - }) => Promise | null | undefined> -): Promise> { - const items: LinearIssue[] = [] - let after: string | undefined - let hasMore = false - - while (items.length < limit) { - // Why: Linear returns issue connections in pages of up to 50; expanded - // Orca reads must follow cursors to show more than one backend page. - const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - items.length) - const connection = await loadConnection(after ? { first, after } : { first }) - const nodes = connection?.nodes ?? [] - items.push(...nodes.map((issue) => mapIssueForWorkspace(entry, issue))) - hasMore = Boolean(connection?.pageInfo?.hasNextPage) - - const nextCursor = connection?.pageInfo?.endCursor ?? undefined - if (!hasMore || !nextCursor || nextCursor === after || nodes.length === 0) { - break - } - after = nextCursor - } - - return { items, hasMore } -} - -async function readCollection( - key: string, - workspaceId: LinearWorkspaceSelection | null | undefined, - load: (entry: LinearClientForWorkspace) => Promise>, - force = false -): Promise> { - return coalesce( - key, - async () => { - const entries = getClients(workspaceId) - if (entries.length === 0) { - return { items: [] } - } - - const results = await Promise.all( - entries.map(async (entry) => { - await acquire() - try { - return await load(entry) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } else { - console.warn('[linear] project/view read failed:', error) - } - if (shouldFailWholeRequest(workspaceId)) { - throw error - } - return { items: [], errors: [workspaceError(entry, error)] } - } finally { - release() - } - }) - ) - - return { - items: results.flatMap((result) => result.items), - errors: results.flatMap((result) => result.errors ?? []).length - ? results.flatMap((result) => result.errors ?? []) - : undefined, - hasMore: results.some((result) => result.hasMore) - } - }, - force - ) -} - -async function readConcreteCollection( - key: string, - workspaceId: LinearConcreteWorkspaceId, - load: (entry: LinearClientForWorkspace) => Promise>, - force = false -): Promise> { - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - return readCollection(key, concreteWorkspaceId, load, force) -} - -export async function listProjects( - query: string | undefined, - limit = 20, - workspaceId?: LinearWorkspaceSelection | null, - force = false -): Promise> { - const first = clampLimit(limit) - const trimmed = query?.trim() - const key = `listProjects:${workspaceId ?? 'default'}:${trimmed ?? ''}:${first}` - return readCollection( - key, - workspaceId, - async (entry) => { - const variables = trimmed ? { term: trimmed, first } : { first, orderBy: 'updatedAt' } - const result = await entry.client.client.rawRequest< - ProjectConnectionResponse, - LinearRawVariables - >(trimmed ? SEARCH_PROJECTS_QUERY : PROJECTS_QUERY, variables) - const connection = trimmed ? result.data?.searchProjects : result.data?.projects - return { - items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), - hasMore: !!connection?.pageInfo?.hasNextPage - } - }, - force - ) -} - -export async function listProjectsByExactName( - name: string, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise { - const projectName = name.trim() - if (!projectName) { - throw new Error('Project name is required') - } - const normalized = projectName.toLowerCase() - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - const key = `listProjectsByExactName:${concreteWorkspaceId}:${normalized}` - return coalesce( - key, - async () => { - const entries = getClients(concreteWorkspaceId) - const entry = entries[0] - if (!entry) { - return [] - } - await acquire() - try { - const matches: LinearProjectSummary[] = [] - let after: string | undefined - while (true) { - const result = await entry.client.client.rawRequest< - ProjectConnectionResponse, - LinearRawVariables - >(SEARCH_PROJECTS_QUERY, { - term: projectName, - first: LINEAR_PROJECT_API_PAGE_SIZE_MAX, - ...(after ? { after } : {}) - }) - const connection = result.data?.searchProjects - for (const project of connection?.nodes ?? []) { - if (project.name.trim().toLowerCase() === normalized) { - matches.push(mapProjectForWorkspace(entry, project)) - } - } - const nextCursor = connection?.pageInfo?.endCursor ?? undefined - if ( - connection?.pageInfo?.hasNextPage !== true || - !nextCursor || - nextCursor === after || - (connection.nodes ?? []).length === 0 - ) { - break - } - after = nextCursor - } - return matches - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } - }, - force - ) -} - -export async function getProject( - id: string, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise { - const projectId = id.trim() - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - if (!projectId) { - throw new Error('Project ID is required') - } - const key = `getProject:${concreteWorkspaceId}:${projectId}` - return coalesce( - key, - async () => { - const entries = getClients(concreteWorkspaceId) - const entry = entries[0] - if (!entry) { - return null - } - await acquire() - try { - const result = await entry.client.client.rawRequest< - ProjectConnectionResponse, - LinearRawVariables - >(PROJECT_QUERY, { id: projectId }) - return result.data?.project - ? mapProjectDetailForWorkspace(entry, result.data.project) - : null - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } - }, - force - ) -} - -export async function createProject( - input: LinearProjectCreateInput, - workspaceId?: string | null -): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> { - const entry = getClients(workspaceId)[0] - if (!entry) { - return { ok: false, error: 'Not connected to Linear' } - } - - await acquire() - try { - const result = await entry.client.client.rawRequest< - ProjectMutationResponse, - LinearRawVariables - >(CREATE_PROJECT_MUTATION, { input }) - const payload = result.data?.projectCreate - const project = payload?.project - if (!payload?.success || !project) { - return { ok: false, error: 'Linear project create failed' } - } - return { ok: true, project: mapProjectDetailForWorkspace(entry, project) } - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - throw error - } - const message = error instanceof Error ? error.message : String(error) - return { ok: false, error: message } - } finally { - release() - } -} - -export async function listProjectIssues( - projectId: string, - limit = 20, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise> { - const id = projectId.trim() - if (!id) { - throw new Error('Project ID is required') - } - const first = clampLinearIssueListLimit(limit) - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - return readConcreteCollection( - `listProjectIssues:${concreteWorkspaceId}:${id}:${first}`, - concreteWorkspaceId, - async (entry) => { - return readIssueConnectionPages(entry, first, async (page) => { - const result = await entry.client.client.rawRequest< - ProjectIssueConnectionResponse, - LinearRawVariables - >(PROJECT_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' }) - const project = result.data?.project - if (!project) { - throw new Error('Project was not found') - } - return project.issues - }) - }, - force - ) -} - -export async function listProjectTeams( - projectId: string, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise> { - const id = projectId.trim() - if (!id) { - throw new Error('Project ID is required') - } - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - const key = `listProjectTeams:${concreteWorkspaceId}:${id}` - return coalesce( - key, - async () => { - const entry = getClients(concreteWorkspaceId)[0] - if (!entry) { - return [] - } - const teams: NonNullable = [] - let after: string | undefined - await acquire() - try { - while (true) { - const result = await entry.client.client.rawRequest< - ProjectTeamsResponse, - LinearRawVariables - >(PROJECT_TEAMS_QUERY, { - id, - first: 50, - ...(after ? { after } : {}) - }) - const project = result.data?.project - if (!project) { - throw new Error('Project was not found') - } - const connection = project.teams - const nodes = connection?.nodes ?? [] - teams.push( - ...nodes.map((team) => ({ - id: team.id, - name: team.name ?? '', - key: team.key ?? undefined - })) - ) - const nextCursor = connection?.pageInfo?.endCursor ?? undefined - if ( - !connection?.pageInfo?.hasNextPage || - !nextCursor || - nextCursor === after || - nodes.length === 0 - ) { - break - } - after = nextCursor - } - return teams - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } - }, - force - ) -} - -export async function listCustomViews( - model: LinearCustomViewModel, - limit = 20, - workspaceId?: LinearWorkspaceSelection | null, - force = false -): Promise> { - const first = clampLimit(limit) - const key = `listCustomViews:${workspaceId ?? 'default'}:${model}:${first}` - const filter = { modelName: { eq: model === 'project' ? 'Project' : 'Issue' } } - return readCollection( - key, - workspaceId, - async (entry) => { - const result = await entry.client.client.rawRequest< - CustomViewConnectionResponse, - LinearRawVariables - >(CUSTOM_VIEWS_QUERY, { first, filter, orderBy: 'updatedAt' }) - const connection = result.data?.customViews - return { - items: (connection?.nodes ?? []) - .map((view) => mapCustomViewForWorkspace(entry, view)) - .filter((view): view is LinearCustomViewSummary => !!view && view.model === model), - hasMore: !!connection?.pageInfo?.hasNextPage - } - }, - force - ) -} - -export async function getCustomView( - viewId: string, - model: LinearCustomViewModel, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise { - const id = viewId.trim() - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - if (!id) { - throw new Error('Custom view ID is required') - } - const key = `getCustomView:${concreteWorkspaceId}:${model}:${id}` - return coalesce( - key, - async () => { - const entries = getClients(concreteWorkspaceId) - const entry = entries[0] - if (!entry) { - return null - } - await acquire() - try { - const result = await entry.client.client.rawRequest< - CustomViewConnectionResponse, - LinearRawVariables - >(CUSTOM_VIEW_QUERY, { id }) - const view = result.data?.customView - const mapped = view ? mapCustomViewForWorkspace(entry, view) : null - return mapped?.model === model ? mapped : null - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } - }, - force - ) -} - -export async function listCustomViewIssues( - viewId: string, - limit = 20, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise> { - const id = viewId.trim() - if (!id) { - throw new Error('Custom view ID is required') - } - const first = clampLinearIssueListLimit(limit) - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - return readConcreteCollection( - `listCustomViewIssues:${concreteWorkspaceId}:${id}:${first}`, - concreteWorkspaceId, - async (entry) => { - return readIssueConnectionPages(entry, first, async (page) => { - const result = await entry.client.client.rawRequest< - CustomViewConnectionResponse, - LinearRawVariables - >(CUSTOM_VIEW_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' }) - const view = result.data?.customView - if (mapCustomViewModel(view?.modelName) !== 'issue') { - throw new Error('Custom view does not contain issues') - } - return view?.issues - }) - }, - force - ) -} - -export async function listCustomViewProjects( - viewId: string, - limit = 20, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise> { - const id = viewId.trim() - if (!id) { - throw new Error('Custom view ID is required') - } - const first = clampLimit(limit) - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - return readConcreteCollection( - `listCustomViewProjects:${concreteWorkspaceId}:${id}:${first}`, - concreteWorkspaceId, - async (entry) => { - const result = await entry.client.client.rawRequest< - CustomViewConnectionResponse, - LinearRawVariables - >(CUSTOM_VIEW_PROJECTS_QUERY, { id, first, orderBy: 'updatedAt' }) - const view = result.data?.customView - if (mapCustomViewModel(view?.modelName) !== 'project') { - throw new Error('Custom view does not contain projects') - } - const connection = view?.projects - return { - items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), - hasMore: !!connection?.pageInfo?.hasNextPage - } - }, - force - ) -} +export type { LinearProjectCreateInput } from './linear-project-nodes' +export { + createProject, + getProject, + listProjects, + listProjectsByExactName +} from './linear-project-queries' +export { listProjectIssues } from './linear-project-issue-queries' +export { listProjectTeams } from './linear-project-team-queries' +export { + getCustomView, + listCustomViewIssues, + listCustomViewProjects, + listCustomViews +} from './linear-custom-view-queries' From 8776b6e49aa1177baf7ad6126c8cb2eb39ffdaed Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:52:04 -0700 Subject: [PATCH 26/65] refactor(renderer): split GitHub store slice (#16183) --- config/max-lines-baseline.txt | 1 - .../github-project/ProjectViewWrapper.tsx | 2 +- .../components/right-sidebar/ChecksPanel.tsx | 9 +- .../FolderWorkspacePrChecksPanel.test.tsx | 2 +- ...checks-panel-pr-refresh-breadcrumb.test.ts | 2 +- .../checks-panel-pr-refresh-breadcrumb.ts | 5 +- .../parent-pr-checks-rows.test.ts | 2 +- .../right-sidebar/parent-pr-checks-rows.ts | 2 +- .../use-worktree-card-review-details.ts | 2 +- .../sidebar/use-worktree-issue-link.ts | 2 +- .../status-bar/WorkspaceSpaceManagerPanel.tsx | 2 +- .../task-page-cache-selectors.test.ts | 3 +- .../components/task-page-cache-selectors.ts | 12 +- .../task-page-jira-cache-selectors.ts | 2 +- .../task-page-linear-cache-selectors.ts | 2 +- .../src/lib/worktree-palette-document.ts | 2 +- .../src/store/github/cache-identity.ts | 227 + src/renderer/src/store/github/cache-model.ts | 64 + .../src/store/github/cache-persistence.ts | 17 + src/renderer/src/store/github/cache-policy.ts | 50 + .../src/store/github/check-actions.ts | 231 + .../src/store/github/comment-fetch-actions.ts | 93 + .../store/github/comment-mutation-actions.ts | 216 + .../github/conversation-comment-actions.ts | 94 + .../src/store/github/issue-actions.ts | 94 + .../src/store/github/pr-check-status-cache.ts | 82 + .../src/store/github/pr-comment-cache.ts | 42 + .../src/store/github/pr-refresh-state.ts | 141 + .../src/store/github/pr-result-cache.ts | 252 + .../src/store/github/pr-result-routing.ts | 196 + .../src/store/github/project-actions.ts | 254 + .../src/store/github/project-cache.ts | 129 + .../src/store/github/project-row-actions.ts | 292 + .../src/store/github/pull-request-actions.ts | 155 + .../store/github/pull-request-execution.ts | 286 + .../src/store/github/refresh-event-actions.ts | 278 + .../store/github/refresh-routing-actions.ts | 90 + .../src/store/github/refresh-sweep-actions.ts | 221 + .../src/store/github/repository-routing.ts | 117 + .../src/store/github/request-coordination.ts | 84 + .../src/store/github/review-thread-actions.ts | 92 + src/renderer/src/store/github/slice-types.ts | 276 + .../github/stale-worktree-refresh-actions.ts | 84 + .../github/work-item-aggregate-actions.ts | 263 + .../store/github/work-item-fetch-actions.ts | 235 + .../github/work-item-mutation-actions.ts | 133 + .../src/store/github/work-item-routing.ts | 223 + .../src/store/github/worktree-refresh.ts | 280 + ...github-branch-mismatched-linked-pr.test.ts | 2 +- .../github-cache-eviction-and-bounds.test.ts | 54 +- .../store/slices/github-checks-cache.test.ts | 3 +- .../slices/github-issue-state-machine.test.ts | 83 + ...ithub-pr-branch-coordinator-events.test.ts | 2 +- ...hub-pr-branch-direct-refresh-scope.test.ts | 2 +- .../github-pr-branch-fallback-results.test.ts | 2 +- ...thub-pr-branch-hosted-review-cache.test.ts | 6 +- ...hub-pr-branch-linked-pr-divergence.test.ts | 2 +- .../slices/github-pr-checks-fetch.test.ts | 2 +- .../store/slices/github-pr-comments.test.ts | 29 +- ...ithub-project-request-coordination.test.ts | 133 + .../slices/github-project-view-tables.test.ts | 73 +- ...ithub-provider-request-concurrency.test.ts | 67 + .../github-review-thread-actions.test.ts | 126 + .../store/slices/github-slice-test-harness.ts | 3 + .../github-work-item-cache-identity.test.ts | 2 +- .../github-work-items-runtime-routing.test.ts | 2 +- src/renderer/src/store/slices/github.ts | 4841 +---------------- src/renderer/src/store/slices/jira.ts | 2 +- src/renderer/src/store/slices/linear.ts | 2 +- src/renderer/src/store/slices/repos.test.ts | 2 +- 70 files changed, 5938 insertions(+), 4845 deletions(-) create mode 100644 src/renderer/src/store/github/cache-identity.ts create mode 100644 src/renderer/src/store/github/cache-model.ts create mode 100644 src/renderer/src/store/github/cache-persistence.ts create mode 100644 src/renderer/src/store/github/cache-policy.ts create mode 100644 src/renderer/src/store/github/check-actions.ts create mode 100644 src/renderer/src/store/github/comment-fetch-actions.ts create mode 100644 src/renderer/src/store/github/comment-mutation-actions.ts create mode 100644 src/renderer/src/store/github/conversation-comment-actions.ts create mode 100644 src/renderer/src/store/github/issue-actions.ts create mode 100644 src/renderer/src/store/github/pr-check-status-cache.ts create mode 100644 src/renderer/src/store/github/pr-comment-cache.ts create mode 100644 src/renderer/src/store/github/pr-refresh-state.ts create mode 100644 src/renderer/src/store/github/pr-result-cache.ts create mode 100644 src/renderer/src/store/github/pr-result-routing.ts create mode 100644 src/renderer/src/store/github/project-actions.ts create mode 100644 src/renderer/src/store/github/project-cache.ts create mode 100644 src/renderer/src/store/github/project-row-actions.ts create mode 100644 src/renderer/src/store/github/pull-request-actions.ts create mode 100644 src/renderer/src/store/github/pull-request-execution.ts create mode 100644 src/renderer/src/store/github/refresh-event-actions.ts create mode 100644 src/renderer/src/store/github/refresh-routing-actions.ts create mode 100644 src/renderer/src/store/github/refresh-sweep-actions.ts create mode 100644 src/renderer/src/store/github/repository-routing.ts create mode 100644 src/renderer/src/store/github/request-coordination.ts create mode 100644 src/renderer/src/store/github/review-thread-actions.ts create mode 100644 src/renderer/src/store/github/slice-types.ts create mode 100644 src/renderer/src/store/github/stale-worktree-refresh-actions.ts create mode 100644 src/renderer/src/store/github/work-item-aggregate-actions.ts create mode 100644 src/renderer/src/store/github/work-item-fetch-actions.ts create mode 100644 src/renderer/src/store/github/work-item-mutation-actions.ts create mode 100644 src/renderer/src/store/github/work-item-routing.ts create mode 100644 src/renderer/src/store/github/worktree-refresh.ts create mode 100644 src/renderer/src/store/slices/github-issue-state-machine.test.ts create mode 100644 src/renderer/src/store/slices/github-project-request-coordination.test.ts create mode 100644 src/renderer/src/store/slices/github-provider-request-concurrency.test.ts create mode 100644 src/renderer/src/store/slices/github-review-thread-actions.test.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 4d0d6233471..456bd969189 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -143,7 +143,6 @@ inline src/renderer/src/runtime/web-session-tabs-sync.ts inline src/renderer/src/store/slices/agent-status.ts inline src/renderer/src/store/slices/browser.ts inline src/renderer/src/store/slices/diffComments.ts -inline src/renderer/src/store/slices/github.ts inline src/renderer/src/store/slices/hosted-review.ts inline src/renderer/src/store/slices/jira.ts inline src/renderer/src/store/slices/linear.ts diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index 211a73bd2f6..250f4a43468 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -30,7 +30,7 @@ import { cn } from '@/lib/utils' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' -import { projectViewCacheKey } from '@/store/slices/github' +import { projectViewCacheKey } from '@/store/github/cache-identity' import type { GitHubIssueType, GitHubProjectFieldMutationValue, diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 7c81834319c..6201871ab3a 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -15,11 +15,10 @@ import { import { useAppStore, type AppState } from '@/store' import { buildGitHubPRRefreshStateClearToken, - getGitHubPRRefreshStateExpiryAt, - mergePRCommentIntoList, - prChecksCacheSuffix, - prCommentsCacheSuffix -} from '@/store/slices/github' + getGitHubPRRefreshStateExpiryAt +} from '@/store/github/pr-refresh-state' +import { mergePRCommentIntoList } from '@/store/github/pr-comment-cache' +import { prChecksCacheSuffix, prCommentsCacheSuffix } from '@/store/github/cache-identity' import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' import { useActiveWorktree, useRepoById } from '@/store/selectors' import { useChecksPanelTerminalWorktree } from './use-checks-panel-terminal-worktree' diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx index 4ee957c5001..90c17779112 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx @@ -12,7 +12,7 @@ import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import { getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' -import { prChecksCacheSuffix } from '@/store/slices/github' +import { prChecksCacheSuffix } from '@/store/github/cache-identity' type MockStoreState = { activeWorktreeId: string | null diff --git a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts index 325c7409246..ae20fa2aba0 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { PRRefreshState } from '@/store/slices/github' +import type { PRRefreshState } from '@/store/github/pr-refresh-state' import { buildChecksPanelPRRefreshBreadcrumbData, recordChecksPanelPRRefreshBreadcrumb diff --git a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.ts b/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.ts index d2925dc64c3..c3d4f2b35a8 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.ts @@ -1,5 +1,8 @@ import { recordRendererCrashBreadcrumb } from '@/lib/crash-diagnostics' -import { getGitHubPRRefreshStateExpiryAt, type PRRefreshState } from '@/store/slices/github' +import { + getGitHubPRRefreshStateExpiryAt, + type PRRefreshState +} from '@/store/github/pr-refresh-state' import type { CrashReportBreadcrumbData, CrashReportDetailValue diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts index 4a071f6638e..f44e7dca663 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts @@ -10,7 +10,7 @@ import { getGitHubRepoCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key' -import { prChecksCacheSuffix } from '@/store/slices/github' +import { prChecksCacheSuffix } from '@/store/github/cache-identity' import { buildParentPrChecksProjection, getParentPrChecksRefreshIdentity, diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts index 10d0367f620..d3c2480514e 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts @@ -8,7 +8,7 @@ import { getWorktreeCardPrDisplay } from '@/components/sidebar/worktree-card-pr- import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' import { getHostedReviewCacheKey, linkedReviewHintKey } from '@/store/slices/hosted-review' -import { prChecksCacheSuffix } from '@/store/slices/github' +import { prChecksCacheSuffix } from '@/store/github/cache-identity' import { PARENT_PR_CHECKS_GROUP_LABELS, PARENT_PR_CHECKS_GROUP_ORDER, diff --git a/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts b/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts index 23129542be2..05bee14ee80 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts @@ -1,7 +1,7 @@ import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { useAppStore } from '@/store' import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' -import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' +import { issueCacheKey as getIssueCacheKey } from '@/store/github/cache-identity' import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' import type { HostedReviewInfo } from '../../../../shared/hosted-review' diff --git a/src/renderer/src/components/sidebar/use-worktree-issue-link.ts b/src/renderer/src/components/sidebar/use-worktree-issue-link.ts index bf1e0a1bb31..4a3aba42542 100644 --- a/src/renderer/src/components/sidebar/use-worktree-issue-link.ts +++ b/src/renderer/src/components/sidebar/use-worktree-issue-link.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import { parseGitHubIssueOrPRNumber } from '@/lib/github-links' -import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' +import { issueCacheKey as getIssueCacheKey } from '@/store/github/cache-identity' import { useMountedRef } from '@/hooks/useMountedRef' import { findIndexedWorktreeOwner } from '@/lib/worktree-runtime-owner-index' import { buildLinearIssueUrl, parseLinearIssueInput } from '../../../../shared/linear/links' diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index 890e7c81d7d..c4d8b080843 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -55,7 +55,7 @@ import { getWorktreeOnHostFromState } from '../../store/selectors' import { getHostedReviewCacheKey } from '../../store/slices/hosted-review' -import { issueCacheKey as getIssueCacheKey } from '../../store/slices/github' +import { issueCacheKey as getIssueCacheKey } from '../../store/github/cache-identity' import { findRepoForHost } from '@/store/slices/repo-host-identity' import { getSelectedDeletableWorkspaceRows, diff --git a/src/renderer/src/components/task-page-cache-selectors.test.ts b/src/renderer/src/components/task-page-cache-selectors.test.ts index 968189a5417..44777236862 100644 --- a/src/renderer/src/components/task-page-cache-selectors.test.ts +++ b/src/renderer/src/components/task-page-cache-selectors.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { shallow } from 'zustand/shallow' -import { workItemsCacheKey, type CacheEntry } from '@/store/slices/github' +import { workItemsCacheKey } from '@/store/github/cache-identity' +import type { CacheEntry } from '@/store/github/cache-model' import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { LinearIssue } from '../../../shared/linear/issue-types' import type { LinearCollectionResult } from '../../../shared/linear/workspace-types' diff --git a/src/renderer/src/components/task-page-cache-selectors.ts b/src/renderer/src/components/task-page-cache-selectors.ts index 403a8a97dd1..a563dcae7b1 100644 --- a/src/renderer/src/components/task-page-cache-selectors.ts +++ b/src/renderer/src/components/task-page-cache-selectors.ts @@ -1,9 +1,9 @@ -import { - workItemsCacheKey, - type CacheEntry, - type WorkItemsCacheError, - type WorkItemsCacheSources -} from '@/store/slices/github' +import { workItemsCacheKey } from '@/store/github/cache-identity' +import type { + CacheEntry, + WorkItemsCacheError, + WorkItemsCacheSources +} from '@/store/github/cache-model' import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import { taskPageWorkItemKey, diff --git a/src/renderer/src/components/task-page-jira-cache-selectors.ts b/src/renderer/src/components/task-page-jira-cache-selectors.ts index 969160f13f8..7dfc8075ade 100644 --- a/src/renderer/src/components/task-page-jira-cache-selectors.ts +++ b/src/renderer/src/components/task-page-jira-cache-selectors.ts @@ -1,4 +1,4 @@ -import type { CacheEntry } from '@/store/slices/github' +import type { CacheEntry } from '@/store/github/cache-model' import { getTaskSourceCacheScope, type TaskSourceContext diff --git a/src/renderer/src/components/task-page-linear-cache-selectors.ts b/src/renderer/src/components/task-page-linear-cache-selectors.ts index 31ac2730b27..4114e52c4c8 100644 --- a/src/renderer/src/components/task-page-linear-cache-selectors.ts +++ b/src/renderer/src/components/task-page-linear-cache-selectors.ts @@ -1,4 +1,4 @@ -import type { CacheEntry } from '@/store/slices/github' +import type { CacheEntry } from '@/store/github/cache-model' import type { LinearIssue } from '../../../shared/linear/issue-types' import type { LinearCollectionResult } from '../../../shared/linear/workspace-types' import { sortedStrings } from './task-page-work-item-signatures' diff --git a/src/renderer/src/lib/worktree-palette-document.ts b/src/renderer/src/lib/worktree-palette-document.ts index e99bf0e781a..4d00368c33e 100644 --- a/src/renderer/src/lib/worktree-palette-document.ts +++ b/src/renderer/src/lib/worktree-palette-document.ts @@ -1,6 +1,6 @@ import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' +import { issueCacheKey as getIssueCacheKey } from '@/store/github/cache-identity' import { buildPaletteDocument, type PaletteDocument } from './palette-match/palette-document' import type { PaletteComposedEvidence } from './palette-match/evidence-composer' import { diff --git a/src/renderer/src/store/github/cache-identity.ts b/src/renderer/src/store/github/cache-identity.ts new file mode 100644 index 00000000000..88eb8a4e73e --- /dev/null +++ b/src/renderer/src/store/github/cache-identity.ts @@ -0,0 +1,227 @@ +import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' +import { githubProjectIdentityKey } from '../../../../shared/github/project-identity' +import type { PRCheckDetail } from '../../../../shared/github/check-types' +import type { GitHubOwnerRepo } from '../../../../shared/github/pull-request-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { GetProjectViewTableArgs } from '../../../../shared/github/project-request-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId +} from '../../../../shared/execution-host' +import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from '../slices/github-cache-key' +import type { AppState } from '../types' +import { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import type { CacheEntry } from './cache-model' + +function queryOverrideKeyPart(queryOverride: string | undefined): string { + return queryOverride === undefined ? '' : `:q=${queryOverride}` +} + +export function projectViewCacheKey( + ownerType: GetProjectViewTableArgs['ownerType'], + owner: string, + projectNumber: number, + resolvedViewId: string, + queryOverride?: string, + sourceScope = 'local', + host?: string +): string { + const projectKey = githubProjectIdentityKey({ ownerType, owner, number: projectNumber, host }) + return `github-project:${sourceScope}:${projectKey}:${resolvedViewId}${queryOverrideKeyPart(queryOverride)}` +} + +export function projectViewRequestKey(args: GetProjectViewTableArgs, sourceScope: string): string { + const selector = args.viewId + ? `id:${args.viewId}` + : args.viewNumber !== undefined + ? `num:${args.viewNumber}` + : args.viewName + ? `name:${args.viewName}` + : 'default' + const projectKey = githubProjectIdentityKey({ + ownerType: args.ownerType, + owner: args.owner, + number: args.projectNumber, + host: args.host + }) + return `${sourceScope}:${projectKey}:${selector}${queryOverrideKeyPart(args.queryOverride)}` +} + +export function projectViewSourceScope(settings: AppState['settings']): string { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' +} + +export function settingsForProjectViewCacheKey( + settings: AppState['settings'], + cacheKey: string +): Pick, 'activeRuntimeEnvironmentId'> { + const runtimeMatch = /^github-project:runtime:([^:]+):/.exec(cacheKey) + return runtimeMatch + ? { ...settings, activeRuntimeEnvironmentId: runtimeMatch[1] } + : { ...settings, activeRuntimeEnvironmentId: null } +} + +export function workItemsCacheKey( + repoId: string, + limit: number, + query: string, + executionHostId?: string | null +): string { + const scope = executionHostId?.trim() ?? '' + const hostId = normalizeExecutionHostId(scope) + const owner = `${repoId}::${limit}::${query}` + if (hostId) { + return hostId !== LOCAL_EXECUTION_HOST_ID ? `${hostId}::${owner}` : owner + } + return scope ? `${scope}::${owner}` : owner +} + +export function workItemsInflightRequestKey( + cacheKey: string, + target: { kind: 'environment'; environmentId: string; runtimeRepoId: string } | { kind: 'local' } +): string { + const targetPart = + target.kind === 'environment' ? `env:${target.environmentId}:${target.runtimeRepoId}` : 'local' + return `${cacheKey}::${targetPart}` +} + +export function issueCacheKey( + repoPath: string, + repoId: string | undefined, + issueNumber: number | string, + settings?: Pick | null, + connectionId?: string | null, + executionHostId?: string | null, + hasRepoOwner = false +): string { + return getGitHubRepoCacheKey( + repoPath, + repoId, + String(issueNumber), + settings, + connectionId, + executionHostId, + hasRepoOwner + ) +} + +export function runtimeScopedRepoCacheKey( + repoPath: string, + repoId: string | undefined, + suffix: string, + settings?: AppState['settings'], + connectionId?: string | null, + executionHostId?: string | null, + hasRepoOwner = false +): string { + return getGitHubRepoCacheKey( + repoPath, + repoId, + suffix, + settings, + connectionId, + executionHostId, + hasRepoOwner + ) +} + +export function sourceScopedRepoCacheKey( + repoPath: string, + repoId: string | undefined, + suffix: string, + settings?: AppState['settings'], + connectionId?: string | null, + executionHostId?: string | null, + sourceContext?: TaskSourceContext | null, + hasRepoOwner = false +): string { + if (sourceContext?.provider === 'github') { + return `${getTaskSourceCacheScope(sourceContext)}::${repoId ?? repoPath}::${suffix}` + } + return runtimeScopedRepoCacheKey( + repoPath, + repoId, + suffix, + settings, + connectionId, + executionHostId, + hasRepoOwner + ) +} + +export function prCacheKey( + repoPath: string, + repoId: string | undefined, + branch: string, + settings?: AppState['settings'], + connectionId?: string | null, + executionHostId?: string | null, + hasRepoOwner = false +): string { + return getGitHubPRCacheKey( + repoPath, + repoId, + branch, + settings, + connectionId, + executionHostId, + hasRepoOwner + ) +} + +export function repoCacheKeyPrefixes(repoId: string, repoPath?: string): string[] { + const prefixes = [`${repoId}::`] + if (repoPath && repoPath !== repoId) { + prefixes.push(`${repoPath}::`) + } + return prefixes +} + +export function matchesRepoCacheKey(key: string, prefixes: readonly string[]): boolean { + return prefixes.some((prefix) => key.startsWith(prefix)) +} + +export function evictRepoCacheEntries( + cache: Record>, + prefixes: readonly string[] +): { cache: Record>; evicted: boolean } { + let next: Record> | null = null + for (const key of Object.keys(cache)) { + if (!matchesRepoCacheKey(key, prefixes)) { + continue + } + next ??= { ...cache } + delete next[key] + } + return next ? { cache: next, evicted: true } : { cache, evicted: false } +} + +function normalizedHeadSha(headSha?: string): string | null { + const trimmed = headSha?.trim() + return trimmed ? trimmed.toLowerCase() : null +} + +export function prChecksCacheSuffix( + prNumber: number, + prRepo?: GitHubOwnerRepo | null, + headSha?: string +): string { + const headSuffix = normalizedHeadSha(headSha) + const base = prRepo + ? `pr-checks::${githubRepoIdentityKey(prRepo)}::${prNumber}` + : `pr-checks::${prNumber}` + return headSuffix ? `${base}::head::${headSuffix}` : base +} + +export function prCommentsCacheSuffix(prNumber: number, prRepo?: GitHubOwnerRepo | null): string { + return prRepo + ? `pr-comments::${githubRepoIdentityKey(prRepo)}::${prNumber}` + : `pr-comments::${prNumber}` +} + +export function getPRChecksCacheTtl(entry: CacheEntry | undefined): number { + return entry?.data?.length === 0 ? 10_000 : 60_000 +} diff --git a/src/renderer/src/store/github/cache-model.ts b/src/renderer/src/store/github/cache-model.ts new file mode 100644 index 00000000000..27db1bf8167 --- /dev/null +++ b/src/renderer/src/store/github/cache-model.ts @@ -0,0 +1,64 @@ +import type { ClassifiedError } from '../../../../shared/classified-error' +import type { GitHubOwnerRepo } from '../../../../shared/github/pull-request-types' +import type { GitHubPRRefreshAlias } from '../../../../shared/github/pull-request-refresh-types' +import type { GitHubProjectViewError } from '../../../../shared/github/project-result-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' + +export type WorkItemsCacheSources = { + issues: GitHubOwnerRepo | null + prs: GitHubOwnerRepo | null + /** Raw origin remote, kept distinct from the effective PR source. */ + originCandidate: GitHubOwnerRepo | null + /** Raw upstream remote, kept distinct from the effective issue source. */ + upstreamCandidate: GitHubOwnerRepo | null +} + +export type WorkItemsCacheError = ClassifiedError & { source: GitHubOwnerRepo } + +export type CacheEntry = { + data: T | null + fetchedAt: number + headSha?: string + sources?: WorkItemsCacheSources + error?: WorkItemsCacheError + issueSourceFellBack?: true +} + +export type FetchOptions = { + force?: boolean + noCache?: boolean + requireComplete?: boolean + allowStaleFallback?: boolean + sourceContext?: TaskSourceContext | null +} + +export type RepoScopedFetchOptions = FetchOptions & { repoId?: string } + +export type GitHubPRFallbackSource = NonNullable + +export type ProjectViewCacheEntry = { + data: T | null + fetchedAt: number + error?: GitHubProjectViewError +} + +export type ProjectRowContentUpdate = { + title?: string + body?: string + addLabels?: string[] + removeLabels?: string[] + addAssignees?: string[] + removeAssignees?: string[] +} + +export type ProjectRowContentPatch = { + title?: string + body?: string + state?: 'open' | 'closed' | 'merged' | 'draft' + labels?: string[] + assignees?: string[] +} + +export type GitHubPatchWorkItemOptions = { + sourceContext?: TaskSourceContext | null +} diff --git a/src/renderer/src/store/github/cache-persistence.ts b/src/renderer/src/store/github/cache-persistence.ts new file mode 100644 index 00000000000..06337e94a11 --- /dev/null +++ b/src/renderer/src/store/github/cache-persistence.ts @@ -0,0 +1,17 @@ +import type { AppState } from '../types' + +let saveTimer: ReturnType | null = null + +/** Schedules the renderer's single trailing GitHub cache persistence write. */ +export function debouncedSaveCache(state: AppState): void { + clearTimeout(saveTimer ?? undefined) + saveTimer = setTimeout(() => { + saveTimer = null + window.api.cache.setGitHub({ + cache: { + pr: state.prCache, + issue: state.issueCache + } + }) + }, 1000) +} diff --git a/src/renderer/src/store/github/cache-policy.ts b/src/renderer/src/store/github/cache-policy.ts new file mode 100644 index 00000000000..2296f37f208 --- /dev/null +++ b/src/renderer/src/store/github/cache-policy.ts @@ -0,0 +1,50 @@ +import type { CacheEntry } from './cache-model' + +export const CACHE_TTL = 300_000 +export const WORK_ITEMS_CACHE_TTL = 60_000 +export const GITHUB_SEARCH_RESULT_WINDOW = 1000 +export const ERROR_TOAST_DURATION = 60_000 +export const MAX_CACHE_ENTRIES = 500 + +export function isFresh( + entry: CacheEntry | undefined, + ttl = CACHE_TTL +): entry is CacheEntry { + return entry !== undefined && Date.now() - entry.fetchedAt < ttl +} + +/** Evicts the oldest entries only when over the bound; age alone does not evict. */ +export function evictStaleEntries( + cache: Record, + maxEntries = MAX_CACHE_ENTRIES +): Record { + const entries = Object.entries(cache) + if (entries.length <= maxEntries) { + return cache + } + entries.sort(([, a], [, b]) => b.fetchedAt - a.fetchedAt) + return Object.fromEntries(entries.slice(0, maxEntries)) +} + +export function withBoundedCacheEntry( + cache: Record, + key: string, + entry: T +): Record { + return evictStaleEntries({ ...cache, [key]: entry }) +} + +export function capRecordByInsertionOrder( + record: Record, + maxEntries = MAX_CACHE_ENTRIES +): Record { + const keys = Object.keys(record) + if (keys.length <= maxEntries) { + return record + } + const next = { ...record } + for (let i = 0; i < keys.length - maxEntries; i += 1) { + delete next[keys[i]] + } + return next +} diff --git a/src/renderer/src/store/github/check-actions.ts b/src/renderer/src/store/github/check-actions.ts new file mode 100644 index 00000000000..d29a3720d38 --- /dev/null +++ b/src/renderer/src/store/github/check-actions.ts @@ -0,0 +1,231 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout' +import { syncPRChecksStatus } from '../slices/github-checks' +import { + getPRChecksCacheTtl, + prChecksCacheSuffix, + sourceScopedRepoCacheKey +} from './cache-identity' +import { isFresh, withBoundedCacheEntry } from './cache-policy' +import { debouncedSaveCache } from './cache-persistence' +import { inflightChecksRequests } from './request-coordination' +import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' + +export const createCheckActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + fetchPRChecks: async ( + repoPath, + prNumber, + branch, + headSha, + prRepo, + options + ): Promise => { + const repo = get().repos?.find((candidate) => + options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + prChecksCacheSuffix(prNumber, prRepo, headSha), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + const legacyCacheKey = headSha + ? sourceScopedRepoCacheKey( + repoPath, + repoId, + prChecksCacheSuffix(prNumber, prRepo), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + : cacheKey + const inflightKey = cacheKey + const cached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey] + if ( + !options?.force && + !options?.noCache && + isFresh(cached, getPRChecksCacheTtl(cached)) && + (!headSha || cached.headSha === headSha) + ) { + const cachedChecks = cached.data ?? [] + const prStatusUpdate = syncPRChecksStatus( + get(), + repoPath, + repoId, + branch, + cachedChecks, + cached.headSha, + prRepo, + requestSettings, + repo?.connectionId, + repo?.executionHostId, + repo !== undefined + ) + if (prStatusUpdate) { + set(prStatusUpdate) + debouncedSaveCache(get()) + } + return cachedChecks + } + + const inflightRequest = inflightChecksRequests.get(inflightKey) + if (inflightRequest) { + if ( + (options?.force && !inflightRequest.force) || + (options?.noCache && !inflightRequest.noCache) + ) { + await inflightRequest.promise.catch(() => {}) + } else { + return inflightRequest.promise + } + } + + const request = (async () => { + try { + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + const checks = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.prChecks', + { + repo: requestContext.target.runtimeRepoId, + prNumber, + headSha, + prRepo: prRepo ?? null, + noCache: Boolean(options?.force || options?.noCache) + }, + { timeoutMs: 30_000 } + ) + : ((await window.api.gh.prChecks({ + repoPath, + repoId, + prNumber, + headSha, + prRepo: prRepo ?? null, + noCache: Boolean(options?.force || options?.noCache), + sourceContext: options?.sourceContext + })) as PRCheckDetail[]) + set((s) => { + const nextState: Partial = { + checksCache: withBoundedCacheEntry(s.checksCache, cacheKey, { + data: checks, + fetchedAt: Date.now(), + headSha + }) + } + + const prStatusUpdate = syncPRChecksStatus( + s, + repoPath, + repoId, + branch, + checks, + headSha, + prRepo, + requestSettings, + repo?.connectionId, + repo?.executionHostId, + repo !== undefined + ) + if (prStatusUpdate?.prCache) { + nextState.prCache = prStatusUpdate.prCache + } + + return nextState + }) + debouncedSaveCache(get()) + return checks + } catch (err) { + console.error('Failed to fetch PR checks:', err) + const latestCached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey] + if (latestCached?.data && (!headSha || latestCached.headSha === headSha)) { + return latestCached.data + } + return [] + } finally { + inflightChecksRequests.delete(inflightKey) + } + })() + + inflightChecksRequests.set(inflightKey, { + promise: request, + force: Boolean(options?.force), + noCache: Boolean(options?.force || options?.noCache) + }) + return request + }, + + fetchPRCheckDetails: async (repoPath, args, options): Promise => { + const repo = get().repos?.find((candidate) => + options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + const requestTarget = requestContext.target + return requestTarget.kind === 'environment' + ? await withGitHubCheckDetailsTimeout((signal) => + callRuntimeRpc( + { kind: 'environment', environmentId: requestTarget.environmentId }, + 'github.prCheckDetails', + { + repo: requestTarget.runtimeRepoId, + checkRunId: args.checkRunId, + workflowRunId: args.workflowRunId, + checkName: args.checkName, + url: args.url, + prRepo: args.prRepo ?? null + }, + { timeoutMs: 30_000, signal } + ) + ) + : await withGitHubCheckDetailsTimeout(() => + window.api.gh.prCheckDetails({ + repoPath, + repoId, + checkRunId: args.checkRunId, + workflowRunId: args.workflowRunId, + checkName: args.checkName, + url: args.url, + prRepo: args.prRepo ?? null, + sourceContext: options?.sourceContext + }) + ) + } +}) diff --git a/src/renderer/src/store/github/comment-fetch-actions.ts b/src/renderer/src/store/github/comment-fetch-actions.ts new file mode 100644 index 00000000000..09ba5103213 --- /dev/null +++ b/src/renderer/src/store/github/comment-fetch-actions.ts @@ -0,0 +1,93 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { PRComment } from '../../../../shared/github/comment-types' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from './cache-identity' +import { isFresh, withBoundedCacheEntry } from './cache-policy' +import { inflightCommentsRequests } from './request-coordination' +import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' + +export const createCommentFetchActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + fetchPRComments: async (repoPath, prNumber, options): Promise => { + const repo = get().repos?.find((candidate) => + options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + prCommentsCacheSuffix(prNumber, options?.prRepo), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + const cached = get().commentsCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data ?? [] + } + + const inflightRequest = inflightCommentsRequests.get(cacheKey) + if (inflightRequest) { + return inflightRequest + } + + const request = (async () => { + try { + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + const comments = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.prComments', + { + repo: requestContext.target.runtimeRepoId, + prNumber, + prRepo: options?.prRepo ?? null, + noCache: options?.force + }, + { timeoutMs: 30_000 } + ) + : ((await window.api.gh.prComments({ + repoPath, + repoId, + prNumber, + prRepo: options?.prRepo ?? null, + noCache: options?.force, + sourceContext: options?.sourceContext + })) as PRComment[]) + set((s) => ({ + commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, { + data: comments, + fetchedAt: Date.now() + }) + })) + return comments + } catch (err) { + console.error('Failed to fetch PR comments:', err) + return get().commentsCache[cacheKey]?.data ?? [] + } finally { + inflightCommentsRequests.delete(cacheKey) + } + })() + + inflightCommentsRequests.set(cacheKey, request) + return request + } +}) diff --git a/src/renderer/src/store/github/comment-mutation-actions.ts b/src/renderer/src/store/github/comment-mutation-actions.ts new file mode 100644 index 00000000000..5de7c86f75f --- /dev/null +++ b/src/renderer/src/store/github/comment-mutation-actions.ts @@ -0,0 +1,216 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { GitHubCommentResult, PRComment } from '../../../../shared/github/comment-types' +import { translate } from '@/i18n/i18n' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { restoreReactionOnSubject, setReactionOnSubject } from '@/lib/pr-comment-reactions' +import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from './cache-identity' +import { withBoundedCacheEntry } from './cache-policy' +import { hasUsableCommentPayload, mergePRCommentIntoList } from './pr-comment-cache' +import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' + +export const createCommentMutationActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + addPRReviewCommentReply: async (repoPath, prNumber, commentId, body, options) => { + const repo = get().repos?.find((candidate) => + options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + prCommentsCacheSuffix(prNumber, options?.prRepo), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + let result: GitHubCommentResult + try { + result = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.addPRReviewCommentReply', + { + repo: requestContext.target.runtimeRepoId, + prNumber, + commentId, + body, + threadId: options?.threadId, + path: options?.path, + line: options?.line, + prRepo: options?.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.addPRReviewCommentReply({ + repoPath, + repoId, + prNumber, + commentId, + body, + threadId: options?.threadId, + path: options?.path, + line: options?.line, + prRepo: options?.prRepo ?? null, + sourceContext: options?.sourceContext + }) + } catch (err) { + const error = err instanceof Error ? err.message : 'Failed to post reply.' + return { ok: false, error } + } + if (!hasUsableCommentPayload(result)) { + return result.ok + ? { + ok: false, + error: translate( + 'auto.store.slices.github.f129c42773', + 'GitHub did not return the new comment.' + ) + } + : result + } + const comment: PRComment = { + ...result.comment, + threadId: result.comment.threadId ?? options?.threadId, + path: result.comment.path ?? options?.path, + line: result.comment.line ?? options?.line + } + set((s) => { + const entry = s.commentsCache[cacheKey] + return { + commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, { + data: mergePRCommentIntoList(entry?.data, comment), + fetchedAt: Date.now() + }) + } + }) + return { ok: true, comment } + }, + + setPRCommentReaction: async ( + repoPath, + prNumber, + reactionSubjectId, + content, + reacted, + options + ) => { + const repo = get().repos?.find((candidate) => + options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + prCommentsCacheSuffix(prNumber, options?.prRepo), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + const previousComment = get().commentsCache[cacheKey]?.data?.find( + (comment) => comment.reactionSubjectId === reactionSubjectId + ) + const previousReaction = previousComment?.reactions?.find( + (reaction) => reaction.content === content + ) + set((state) => { + const entry = state.commentsCache[cacheKey] + if (!entry?.data) { + return state + } + return { + commentsCache: { + ...state.commentsCache, + [cacheKey]: { + ...entry, + data: setReactionOnSubject(entry.data, reactionSubjectId, content, reacted) + } + } + } + }) + + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + let ok = false + try { + ok = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.setPRCommentReaction', + { + repo: requestContext.target.runtimeRepoId, + reactionSubjectId, + content, + reacted, + prRepo: options?.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.setPRCommentReaction({ + repoPath, + repoId, + reactionSubjectId, + content, + reacted, + prRepo: options?.prRepo ?? null, + sourceContext: options?.sourceContext + }) + } catch (err) { + console.error('Failed to update PR comment reaction:', err) + } + if (!ok && previousComment) { + set((state) => { + const entry = state.commentsCache[cacheKey] + if (!entry?.data) { + return state + } + return { + commentsCache: { + ...state.commentsCache, + [cacheKey]: { + ...entry, + data: restoreReactionOnSubject( + entry.data, + reactionSubjectId, + content, + previousReaction + ) + } + } + } + }) + } + return ok + } +}) diff --git a/src/renderer/src/store/github/conversation-comment-actions.ts b/src/renderer/src/store/github/conversation-comment-actions.ts new file mode 100644 index 00000000000..d92a51baa39 --- /dev/null +++ b/src/renderer/src/store/github/conversation-comment-actions.ts @@ -0,0 +1,94 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { GitHubCommentResult } from '../../../../shared/github/comment-types' +import { translate } from '@/i18n/i18n' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from './cache-identity' +import { withBoundedCacheEntry } from './cache-policy' +import { hasUsableCommentPayload, mergePRCommentIntoList } from './pr-comment-cache' +import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' + +export const createConversationCommentActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + addPRConversationComment: async (repoPath, prNumber, body, options) => { + const repo = get().repos?.find((candidate) => + options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + prCommentsCacheSuffix(prNumber, options?.prRepo), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + let result: GitHubCommentResult + try { + result = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.addIssueComment', + { + repo: requestContext.target.runtimeRepoId, + number: prNumber, + body, + type: 'pr', + prRepo: options?.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.addIssueComment({ + repoPath, + repoId, + number: prNumber, + body, + type: 'pr', + prRepo: options?.prRepo ?? null, + sourceContext: options?.sourceContext + }) + } catch (err) { + const error = err instanceof Error ? err.message : 'Failed to post comment.' + return { ok: false, error } + } + if (!hasUsableCommentPayload(result)) { + return result.ok + ? { + ok: false, + error: translate( + 'auto.store.slices.github.f129c42773', + 'GitHub did not return the new comment.' + ) + } + : result + } + set((s) => { + const entry = s.commentsCache[cacheKey] + return { + commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, { + data: mergePRCommentIntoList(entry?.data, result.comment), + fetchedAt: Date.now() + }) + } + }) + return result + } +}) diff --git a/src/renderer/src/store/github/issue-actions.ts b/src/renderer/src/store/github/issue-actions.ts new file mode 100644 index 00000000000..d360457a235 --- /dev/null +++ b/src/renderer/src/store/github/issue-actions.ts @@ -0,0 +1,94 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { IssueInfo } from '../../../../shared/github/pull-request-types' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { sourceScopedRepoCacheKey } from './cache-identity' +import { isFresh, withBoundedCacheEntry } from './cache-policy' +import { debouncedSaveCache } from './cache-persistence' +import { inflightIssueRequests } from './request-coordination' +import { findRepoForGitHubOwner } from './repository-routing' +import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' + +export const createIssueActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + fetchIssue: async (repoPath, number, options) => { + const repo = findRepoForGitHubOwner(get(), options?.repoId, repoPath) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + String(number), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + const cached = get().issueCache[cacheKey] + if (isFresh(cached)) { + return cached.data + } + + const inflightRequest = inflightIssueRequests.get(cacheKey) + if (inflightRequest) { + return inflightRequest + } + + const request = (async () => { + try { + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + const issue = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.issue', + { repo: requestContext.target.runtimeRepoId, number }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.issue({ + repoPath, + repoId, + number, + sourceContext: options?.sourceContext + }) + set((s) => ({ + issueCache: withBoundedCacheEntry(s.issueCache, cacheKey, { + data: issue, + fetchedAt: Date.now() + }) + })) + debouncedSaveCache(get()) + return issue + } catch (err) { + console.error('Failed to fetch issue:', err) + set((s) => ({ + issueCache: withBoundedCacheEntry(s.issueCache, cacheKey, { + data: null, + fetchedAt: Date.now() + }) + })) + debouncedSaveCache(get()) + return null + } finally { + inflightIssueRequests.delete(cacheKey) + } + })() + + inflightIssueRequests.set(cacheKey, request) + return request + } +}) diff --git a/src/renderer/src/store/github/pr-check-status-cache.ts b/src/renderer/src/store/github/pr-check-status-cache.ts new file mode 100644 index 00000000000..0f1289aeb01 --- /dev/null +++ b/src/renderer/src/store/github/pr-check-status-cache.ts @@ -0,0 +1,82 @@ +import type { AppState } from '../types' +import type { GitHubPRRefreshAlias } from '../../../../shared/github/pull-request-refresh-types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import { deriveCheckStatusFromChecks } from '../slices/github-checks' +import { + getPRChecksCacheTtl, + prChecksCacheSuffix, + runtimeScopedRepoCacheKey +} from './cache-identity' + +export function applyCachedChecksStatus( + state: AppState, + alias: GitHubPRRefreshAlias, + pr: PRInfo, + fetchedAt: number, + aliasExecutionHostId: string +): PRInfo { + const checksCacheKeys = [ + ...(alias.repoId + ? [ + ...(pr.headSha + ? [ + runtimeScopedRepoCacheKey( + alias.repoPath, + alias.repoId, + prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha), + state.settings, + alias.connectionId, + aliasExecutionHostId, + true + ) + ] + : []), + runtimeScopedRepoCacheKey( + alias.repoPath, + alias.repoId, + prChecksCacheSuffix(pr.number, pr.prRepo), + state.settings, + alias.connectionId, + aliasExecutionHostId, + true + ) + ] + : []), + ...(pr.headSha + ? [ + runtimeScopedRepoCacheKey( + alias.repoPath, + undefined, + prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha), + state.settings, + alias.connectionId, + aliasExecutionHostId, + true + ) + ] + : []), + runtimeScopedRepoCacheKey( + alias.repoPath, + undefined, + prChecksCacheSuffix(pr.number, pr.prRepo), + state.settings, + alias.connectionId, + aliasExecutionHostId, + true + ), + `${alias.repoPath}::pr-checks::${pr.number}` + ] + const checksEntry = checksCacheKeys + .map((key) => state.checksCache[key]) + .find((entry) => entry?.data) + if ( + checksEntry?.data && + checksEntry.headSha && + pr.headSha && + checksEntry.headSha === pr.headSha && + fetchedAt - checksEntry.fetchedAt < getPRChecksCacheTtl(checksEntry) + ) { + return { ...pr, checksStatus: deriveCheckStatusFromChecks(checksEntry.data) } + } + return pr +} diff --git a/src/renderer/src/store/github/pr-comment-cache.ts b/src/renderer/src/store/github/pr-comment-cache.ts new file mode 100644 index 00000000000..569a0f29be8 --- /dev/null +++ b/src/renderer/src/store/github/pr-comment-cache.ts @@ -0,0 +1,42 @@ +import type { GitHubCommentResult, PRComment } from '../../../../shared/github/comment-types' + +function commentTimestamp(comment: PRComment): number { + const timestamp = new Date(comment.createdAt).getTime() + return Number.isFinite(timestamp) ? timestamp : 0 +} + +export function mergePRCommentIntoList( + comments: readonly PRComment[] | null | undefined, + incoming: PRComment +): PRComment[] { + const byId = new Map() + for (const comment of comments ?? []) { + byId.set(comment.id, comment) + } + const previous = byId.get(incoming.id) + byId.set(incoming.id, { + ...previous, + ...incoming, + threadId: incoming.threadId ?? previous?.threadId, + path: incoming.path ?? previous?.path, + line: incoming.line ?? previous?.line, + startLine: incoming.startLine ?? previous?.startLine, + isResolved: incoming.isResolved ?? previous?.isResolved, + isOutdated: incoming.isOutdated ?? previous?.isOutdated + }) + return Array.from(byId.values()).sort((a, b) => commentTimestamp(a) - commentTimestamp(b)) +} + +export function hasUsableCommentPayload(result: GitHubCommentResult): result is { + ok: true + comment: PRComment +} { + return ( + result.ok && + typeof result.comment?.id === 'number' && + Number.isSafeInteger(result.comment.id) && + result.comment.id > 0 && + typeof result.comment.body === 'string' && + typeof result.comment.createdAt === 'string' + ) +} diff --git a/src/renderer/src/store/github/pr-refresh-state.ts b/src/renderer/src/store/github/pr-refresh-state.ts new file mode 100644 index 00000000000..13769332230 --- /dev/null +++ b/src/renderer/src/store/github/pr-refresh-state.ts @@ -0,0 +1,141 @@ +import type { + GitHubPRRefreshReason, + GitHubPRRefreshSkippedReason, + PRRefreshErrorType +} from '../../../../shared/github/pull-request-refresh-types' +import { capRecordByInsertionOrder } from './cache-policy' + +export type PRRefreshState = { + status: 'queued' | 'in-flight' | 'paused' | 'skipped' | 'error' + reason: GitHubPRRefreshReason + updatedAt: number + pausedUntil?: number + message?: string + errorType?: PRRefreshErrorType + skippedReason?: GitHubPRRefreshSkippedReason + nextAutoRetryAt?: number + retryDisabledUntil?: number +} + +export type PRRefreshStateClearToken = { + sequence: number + status: PRRefreshState['status'] + updatedAt: number +} + +const PR_REFRESH_ACTIVE_STALE_MS = 120_000 +const PR_REFRESH_PAUSED_GRACE_MS = 5_000 +const MAX_PR_REFRESH_STATE_ENTRIES = 2000 +const SETTLED_PR_REFRESH_STATUSES = new Set(['error', 'skipped']) +export const ACTIVE_PR_REFRESH_STATUSES = new Set([ + 'queued', + 'in-flight', + 'paused' +]) + +export function bypassesGitHubPRRefreshFreshness(reason: GitHubPRRefreshReason): boolean { + return reason === 'manual' || reason === 'active' || reason === 'post-push' +} + +export function buildGitHubPRRefreshStateClearToken( + state: PRRefreshState | undefined, + sequences: Record, + cacheKey: string +): PRRefreshStateClearToken | null { + if (!state) { + return null + } + return { + sequence: sequences[cacheKey] ?? 0, + status: state.status, + updatedAt: state.updatedAt + } +} + +export function getGitHubPRRefreshStateExpiryAt(state: PRRefreshState | undefined): number | null { + if (!state) { + return null + } + if (state.status === 'queued' || state.status === 'in-flight') { + return Number.isFinite(state.updatedAt) ? state.updatedAt + PR_REFRESH_ACTIVE_STALE_MS : 0 + } + if (state.status === 'paused') { + return Number.isFinite(state.pausedUntil) + ? (state.pausedUntil ?? 0) + PR_REFRESH_PAUSED_GRACE_MS + : 0 + } + return null +} + +export function isExpiredActivePRRefreshState(state: PRRefreshState, now: number): boolean { + const expiryAt = getGitHubPRRefreshStateExpiryAt(state) + return ACTIVE_PR_REFRESH_STATUSES.has(state.status) && expiryAt !== null && now > expiryAt +} + +export function getEffectiveGitHubPRRefreshState( + states: Record, + cacheKey: string, + now = Date.now() +): PRRefreshState | undefined { + const state = states[cacheKey] + return !state || isExpiredActivePRRefreshState(state, now) ? undefined : state +} + +export function pruneExpiredPRRefreshStates( + states: Record, + now = Date.now() +): Record { + let next: Record | null = null + for (const [cacheKey, state] of Object.entries(states)) { + if (!isExpiredActivePRRefreshState(state, now)) { + continue + } + next ??= { ...states } + delete next[cacheKey] + } + return next ?? states +} + +export function capPrRefreshStates( + states: Record, + maxEntries = MAX_PR_REFRESH_STATE_ENTRIES +): Record { + const keys = Object.keys(states) + let toEvict = keys.length - maxEntries + if (toEvict <= 0) { + return states + } + const evicted = new Set() + for (const key of keys) { + if (toEvict === 0) { + break + } + if (SETTLED_PR_REFRESH_STATUSES.has(states[key].status)) { + evicted.add(key) + toEvict -= 1 + } + } + for (const key of keys) { + if (toEvict === 0) { + break + } + if (!evicted.has(key)) { + evicted.add(key) + toEvict -= 1 + } + } + const capped: Record = {} + for (const key of keys) { + if (!evicted.has(key)) { + capped[key] = states[key] + } + } + return capped +} + +export function capPrRefreshSequences( + sequences: Record, + maxEntries?: number +): Record { + return capRecordByInsertionOrder(sequences, maxEntries) +} diff --git a/src/renderer/src/store/github/pr-result-cache.ts b/src/renderer/src/store/github/pr-result-cache.ts new file mode 100644 index 00000000000..7603d32332c --- /dev/null +++ b/src/renderer/src/store/github/pr-result-cache.ts @@ -0,0 +1,252 @@ +import type { AppState } from '../types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import { getHostedReviewCacheKey } from '../slices/hosted-review-cache-identity' +import type { GitHubPRFallbackSource } from './cache-model' +import { withBoundedCacheEntry } from './cache-policy' +import { prRefreshStartedHostedReviewEntries } from './request-coordination' +import { + shouldWritePRCacheForHostedReviewSync, + syncHostedReviewCacheFromGitHubPRResult +} from './pr-result-routing' +import { findWorktreeById } from './worktree-refresh' + +const PR_REFRESH_STARTED_HOSTED_REVIEW_ENTRY_MAX = 128 + +export function applyPRCacheResult( + cache: AppState['prCache'], + cacheKey: string, + pr: PRInfo | null, + fetchedAt: number, + accepted: boolean, + preserveExisting: boolean +): AppState['prCache'] { + if (preserveExisting) { + return cache + } + if (accepted) { + return withBoundedCacheEntry(cache, cacheKey, { data: pr, fetchedAt }) + } + if (!cache[cacheKey]) { + return cache + } + const next = { ...cache } + delete next[cacheKey] + return next +} + +export function prRefreshStartedEntryKey(sequence: number, cacheKey: string): string { + return `${sequence}::${cacheKey}` +} + +export function deletePRRefreshStartedEntry(sequence: number | undefined, cacheKey: string): void { + if (sequence !== undefined && sequence > 0) { + prRefreshStartedHostedReviewEntries.delete(prRefreshStartedEntryKey(sequence, cacheKey)) + } +} + +export function setPRRefreshStartedHostedReviewEntry( + key: string, + entry: AppState['hostedReviewCache'][string] | undefined +): void { + if (entry === undefined) { + prRefreshStartedHostedReviewEntries.delete(key) + return + } + prRefreshStartedHostedReviewEntries.delete(key) + prRefreshStartedHostedReviewEntries.set(key, entry) + while (prRefreshStartedHostedReviewEntries.size > PR_REFRESH_STARTED_HOSTED_REVIEW_ENTRY_MAX) { + const oldest = prRefreshStartedHostedReviewEntries.keys().next() + if (oldest.done) { + return + } + prRefreshStartedHostedReviewEntries.delete(oldest.value) + } +} + +export function setGitHubPRResultCaches( + state: AppState, + args: { + prCacheKey: string + repoPath: string + branch: string + settings: AppState['settings'] + repoId?: string + connectionId?: string | null + executionHostId?: string | null + hasRepoOwner?: boolean + pr: PRInfo | null + fetchedAt: number + worktreeId?: string + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + fallbackPRSource?: GitHubPRFallbackSource | null + requestStartedAt?: number + requestStartedEntry?: AppState['hostedReviewCache'][string] + } +): Partial { + const preserveExistingPRForFallbackMiss = shouldPreserveExistingPRForFallbackMiss({ + currentPR: state.prCache[args.prCacheKey]?.data, + nextPR: args.pr, + state, + worktreeId: args.worktreeId, + linkedPRNumber: args.linkedPRNumber, + fallbackPRNumber: args.fallbackPRNumber, + fallbackPRSource: args.fallbackPRSource + }) + const hostedReviewSync = syncHostedReviewCacheFromGitHubPRResult({ + cache: state.hostedReviewCache, + repoPath: args.repoPath, + branch: args.branch, + settings: args.settings, + repoId: args.repoId, + connectionId: args.connectionId, + executionHostId: args.executionHostId, + hasRepoOwner: args.hasRepoOwner, + pr: args.pr, + fetchedAt: args.fetchedAt, + linkedPRNumber: args.linkedPRNumber, + fallbackPRNumber: args.fallbackPRNumber, + fallbackPRSource: args.fallbackPRSource, + preserveExistingPRForFallbackMiss, + requestStartedAt: args.requestStartedAt, + requestStartedEntry: args.requestStartedEntry + }) + const hostedReviewCacheKey = getHostedReviewCacheKey( + args.repoPath, + args.branch, + args.settings, + args.repoId, + args.connectionId, + args.executionHostId, + args.hasRepoOwner === true + ) + const nextPRCache = applyPRCacheResult( + state.prCache, + args.prCacheKey, + args.pr, + args.fetchedAt, + shouldWritePRCacheForHostedReviewSync({ + hostedReviewSyncAccepted: hostedReviewSync.accepted, + hostedReviewEntry: state.hostedReviewCache[hostedReviewCacheKey], + pr: args.pr, + linkedPRNumber: args.linkedPRNumber, + fallbackPRNumber: args.fallbackPRNumber + }), + preserveExistingPRForFallbackMiss + ) + return { + ...(nextPRCache === state.prCache ? {} : { prCache: nextPRCache }), + ...(hostedReviewSync.cache === state.hostedReviewCache + ? {} + : { hostedReviewCache: hostedReviewSync.cache }) + } +} + +export function applyGitHubPRResultToCaches(args: { + prCache: AppState['prCache'] + hostedReviewCache: AppState['hostedReviewCache'] + prCacheKey: string + repoPath: string + branch: string + settings: AppState['settings'] + repoId?: string + connectionId?: string | null + executionHostId?: string | null + hasRepoOwner?: boolean + pr: PRInfo | null + fetchedAt: number + state: AppState + worktreeId?: string + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + fallbackPRSource?: GitHubPRFallbackSource | null + requestStartedAt?: number + requestStartedEntry?: AppState['hostedReviewCache'][string] +}): { + prCache: AppState['prCache'] + hostedReviewCache: AppState['hostedReviewCache'] +} { + const preserveExistingPRForFallbackMiss = shouldPreserveExistingPRForFallbackMiss({ + currentPR: args.prCache[args.prCacheKey]?.data, + nextPR: args.pr, + state: args.state, + worktreeId: args.worktreeId, + linkedPRNumber: args.linkedPRNumber, + fallbackPRNumber: args.fallbackPRNumber, + fallbackPRSource: args.fallbackPRSource + }) + const hostedReviewSync = syncHostedReviewCacheFromGitHubPRResult({ + cache: args.hostedReviewCache, + repoPath: args.repoPath, + branch: args.branch, + settings: args.settings, + repoId: args.repoId, + connectionId: args.connectionId, + executionHostId: args.executionHostId, + hasRepoOwner: args.hasRepoOwner, + pr: args.pr, + fetchedAt: args.fetchedAt, + linkedPRNumber: args.linkedPRNumber, + fallbackPRNumber: args.fallbackPRNumber, + fallbackPRSource: args.fallbackPRSource, + preserveExistingPRForFallbackMiss, + requestStartedAt: args.requestStartedAt, + requestStartedEntry: args.requestStartedEntry + }) + const hostedReviewCacheKey = getHostedReviewCacheKey( + args.repoPath, + args.branch, + args.settings, + args.repoId, + args.connectionId, + args.executionHostId, + args.hasRepoOwner === true + ) + return { + prCache: applyPRCacheResult( + args.prCache, + args.prCacheKey, + args.pr, + args.fetchedAt, + shouldWritePRCacheForHostedReviewSync({ + hostedReviewSyncAccepted: hostedReviewSync.accepted, + hostedReviewEntry: args.hostedReviewCache[hostedReviewCacheKey], + pr: args.pr, + linkedPRNumber: args.linkedPRNumber, + fallbackPRNumber: args.fallbackPRNumber + }), + preserveExistingPRForFallbackMiss + ), + hostedReviewCache: hostedReviewSync.cache + } +} +export function shouldPreserveExistingPRForFallbackMiss(args: { + currentPR: PRInfo | null | undefined + nextPR: PRInfo | null + state: AppState + worktreeId?: string + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + fallbackPRSource?: GitHubPRFallbackSource | null +}): boolean { + if ( + args.nextPR !== null || + args.linkedPRNumber != null || + args.currentPR?.state !== 'merged' || + typeof args.currentPR.headSha !== 'string' || + args.currentPR.headSha.length === 0 + ) { + return false + } + // Why: gate the global worktree scan so batched refresh aliases don't multiply full scans (common paths don't need it). + const worktree = args.worktreeId ? findWorktreeById(args.state, args.worktreeId) : null + const worktreeHead = worktree?.head + // Why: keep a merged PR only when its cached head matches the worktree head — exactly or a confirmed-contained commit. + const preservesMergedPRForCurrentHead = + typeof worktreeHead === 'string' && + worktreeHead.length > 0 && + (args.currentPR.headSha === worktreeHead || + args.currentPR.confirmedContainedHeadOid === worktreeHead) + + return preservesMergedPRForCurrentHead +} diff --git a/src/renderer/src/store/github/pr-result-routing.ts b/src/renderer/src/store/github/pr-result-routing.ts new file mode 100644 index 00000000000..8efd6e43b54 --- /dev/null +++ b/src/renderer/src/store/github/pr-result-routing.ts @@ -0,0 +1,196 @@ +import type { AppState } from '../types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' +import { + getHostedReviewCacheKey, + linkedReviewHintKey +} from '../slices/hosted-review-cache-identity' +import type { GitHubPRFallbackSource } from './cache-model' + +export function githubHostedReviewFallbackPRNumber( + state: AppState, + repoPath: string, + repoId: string | undefined, + branch: string, + connectionId?: string | null, + executionHostId?: string | null, + hasRepoOwner = false +): number | null { + const hostedReviewCacheKey = getHostedReviewCacheKey( + repoPath, + branch, + state.settings, + repoId, + connectionId, + executionHostId, + hasRepoOwner + ) + const hostedReview = state.hostedReviewCache[hostedReviewCacheKey]?.data + return hostedReview?.provider === 'github' ? hostedReview.number : null +} + +export function shouldClearHostedReviewForNoGitHubPR( + entry: AppState['hostedReviewCache'][string] | undefined +): boolean { + // Why: a GitHub-only miss must not suppress GitLab/other hosted-review discovery via provider-neutral branch misses. + if (!entry) { + return false + } + if (entry.data?.provider === 'github') { + return true + } + return entry.data === null && isGitHubLinkedReviewHintKey(entry.linkedReviewHintKey) +} + +export function isGitHubLinkedReviewHintKey(hintKey: string | undefined): boolean { + return hintKey?.split('|').some((key) => key.startsWith('github:')) ?? false +} + +export function prLookupHintKey( + linkedPRNumber: number | null, + fallbackPRNumber: number | null +): string { + if (linkedPRNumber !== null) { + return `linked:${linkedPRNumber}` + } + return fallbackPRNumber !== null ? `fallback:${fallbackPRNumber}` : '' +} + +export function linkedReviewHintKeyForNoGitHubPR( + entry: AppState['hostedReviewCache'][string] | undefined +): string | undefined { + if (entry?.data?.provider === 'github') { + return isGitHubLinkedReviewHintKey(entry.linkedReviewHintKey) + ? entry.linkedReviewHintKey + : linkedReviewHintKey({ linkedGitHubPR: entry.data.number }) + } + return entry?.linkedReviewHintKey +} + +export function hasNewerHostedReviewCacheEntry( + cache: AppState['hostedReviewCache'], + cacheKey: string, + requestStartedAt: number, + requestStartedEntry: AppState['hostedReviewCache'][string] | undefined +): boolean { + const entry = cache[cacheKey] + return ( + entry !== undefined && + (entry.fetchedAt > requestStartedAt || + (entry.fetchedAt === requestStartedAt && entry !== requestStartedEntry)) + ) +} + +export function syncHostedReviewCacheFromGitHubPRResult(args: { + cache: AppState['hostedReviewCache'] + repoPath: string + branch: string + settings: AppState['settings'] + repoId?: string + connectionId?: string | null + executionHostId?: string | null + hasRepoOwner?: boolean + pr: PRInfo | null + fetchedAt: number + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + fallbackPRSource?: GitHubPRFallbackSource | null + preserveExistingPRForFallbackMiss?: boolean + requestStartedAt?: number + requestStartedEntry?: AppState['hostedReviewCache'][string] +}): { cache: AppState['hostedReviewCache']; accepted: boolean } { + const hostedReviewCacheKey = getHostedReviewCacheKey( + args.repoPath, + args.branch, + args.settings, + args.repoId, + args.connectionId, + args.executionHostId, + args.hasRepoOwner === true + ) + if ( + args.requestStartedAt !== undefined && + hasNewerHostedReviewCacheEntry( + args.cache, + hostedReviewCacheKey, + args.requestStartedAt, + args.requestStartedEntry + ) + ) { + return { cache: args.cache, accepted: false } + } + const hostedReviewEntry = args.cache[hostedReviewCacheKey] + if ( + args.requestStartedAt === undefined && + hostedReviewEntry !== undefined && + hostedReviewEntry.fetchedAt >= args.fetchedAt + ) { + return { cache: args.cache, accepted: false } + } + if (args.pr && hostedReviewEntry?.data && hostedReviewEntry.data.provider !== 'github') { + return { cache: args.cache, accepted: false } + } + // Why: a hosted-review row survives an authoritative miss only when the paired PR cache preserves a terminal, head-current PR. + if ( + !args.pr && + args.linkedPRNumber == null && + args.fallbackPRNumber != null && + args.fallbackPRSource !== 'hosted-review' && + hostedReviewEntry?.data?.provider === 'github' && + hostedReviewEntry.data.number === args.fallbackPRNumber && + args.preserveExistingPRForFallbackMiss === true && + canPreserveReviewForFallbackMiss(hostedReviewEntry.data.state) + ) { + return { cache: args.cache, accepted: false } + } + if (!args.pr && !shouldClearHostedReviewForNoGitHubPR(hostedReviewEntry)) { + return { cache: args.cache, accepted: hostedReviewEntry?.data == null } + } + // Why: hosted-review fallbacks may be stale exact links; inherit branch provenance only when already proven. + const branchLookupGitHubPRNumber = + args.pr && + args.linkedPRNumber == null && + (args.fallbackPRSource !== 'hosted-review' || + args.pr.number !== args.fallbackPRNumber || + hostedReviewEntry?.branchLookupGitHubPRNumber === args.pr.number) + ? args.pr.number + : undefined + return { + cache: { + ...args.cache, + [hostedReviewCacheKey]: { + data: args.pr ? hostedReviewInfoFromGitHubPRInfo(args.pr) : null, + fetchedAt: args.fetchedAt, + linkedReviewHintKey: args.pr + ? linkedReviewHintKey({ linkedGitHubPR: args.pr.number }) + : linkedReviewHintKeyForNoGitHubPR(hostedReviewEntry), + ...(branchLookupGitHubPRNumber !== undefined ? { branchLookupGitHubPRNumber } : {}) + } + }, + accepted: true + } +} + +export function shouldWritePRCacheForHostedReviewSync(args: { + hostedReviewSyncAccepted: boolean + hostedReviewEntry: AppState['hostedReviewCache'][string] | undefined + pr: PRInfo | null + linkedPRNumber?: number | null + fallbackPRNumber?: number | null +}): boolean { + // Why: grouping reads prCache while cards read hostedReviewCache; keep them from drifting when a result is rejected for the card. + if (args.hostedReviewSyncAccepted) { + return true + } + const exactPRNumber = args.linkedPRNumber ?? args.fallbackPRNumber ?? null + return ( + exactPRNumber !== null && + args.pr?.number === exactPRNumber && + args.hostedReviewEntry?.data?.provider === 'github' && + args.hostedReviewEntry.data.number === exactPRNumber + ) +} + +function canPreserveReviewForFallbackMiss(state: PRInfo['state'] | undefined): boolean { + return state === 'closed' || state === 'merged' +} diff --git a/src/renderer/src/store/github/project-actions.ts b/src/renderer/src/store/github/project-actions.ts new file mode 100644 index 00000000000..f4c5fec3100 --- /dev/null +++ b/src/renderer/src/store/github/project-actions.ts @@ -0,0 +1,254 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { GitHubProjectRow } from '../../../../shared/github/project-types' +import { translate } from '@/i18n/i18n' +import type { + GetProjectViewTableResult, + GitHubProjectMutationResult +} from '../../../../shared/github/project-result-types' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { + projectViewCacheKey, + projectViewRequestKey, + projectViewSourceScope, + settingsForProjectViewCacheKey +} from './cache-identity' +import { withBoundedCacheEntry, WORK_ITEMS_CACHE_TTL } from './cache-policy' +import { + acquireProviderRequestSlot as acquireWorkItemSlot, + inflightProjectViewRequests, + releaseProviderRequestSlot as releaseWorkItemSlot +} from './request-coordination' +import { + applyRowPatch, + optimisticFieldValueFromMutation, + rollbackRowIfPresent +} from './project-cache' + +export const createProjectActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick< + GitHubSlice, + 'fetchProjectViewTable' | 'updateProjectFieldValue' | 'clearProjectFieldValue' +> => ({ + fetchProjectViewTable: async (args, options) => { + const target = getActiveRuntimeTarget(get().settings) + const sourceScope = projectViewSourceScope(get().settings) + const requestKey = projectViewRequestKey(args, sourceScope) + + // Fast path: a caller-supplied `viewId` gives the resolved cache key up front, so serve a fresh entry directly. + const maybeKnownKey = args.viewId + ? projectViewCacheKey( + args.ownerType, + args.owner, + args.projectNumber, + args.viewId, + args.queryOverride, + sourceScope, + args.host + ) + : null + if (!options?.force && maybeKnownKey) { + const cached = get().projectViewCache[maybeKnownKey] + if (cached?.data && Date.now() - cached.fetchedAt < WORK_ITEMS_CACHE_TTL) { + return { ok: true, data: cached.data } + } + } + + const existing = inflightProjectViewRequests.get(requestKey) + if (existing) { + // Why: a forcing caller must not dedupe to a non-forcing in-flight request; wait for it to settle, then issue a fresh forced call (mirrors fetchWorkItems). + if (options?.force && !existing.force) { + await existing.promise.catch(() => {}) + } else { + return existing.promise + } + } + + const request = (async (): Promise => { + await acquireWorkItemSlot() + try { + const envelope = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.viewTable', + args, + { timeoutMs: 60_000 } + ) + : await window.api.gh.getProjectViewTable(args) + if (envelope.ok) { + const table = envelope.data + const key = projectViewCacheKey( + table.project.ownerType, + table.project.owner, + table.project.number, + table.selectedView.id, + args.queryOverride, + sourceScope, + table.project.host + ) + set((s) => ({ + projectViewCache: withBoundedCacheEntry(s.projectViewCache, key, { + data: table, + fetchedAt: Date.now() + }) + })) + } else if (maybeKnownKey) { + // Why: only stamp the error when we have a resolved key; without one there's nowhere to write it and the renderer classifies from the envelope. + set((s) => ({ + projectViewCache: withBoundedCacheEntry(s.projectViewCache, maybeKnownKey, { + data: s.projectViewCache[maybeKnownKey]?.data ?? null, + fetchedAt: Date.now(), + error: envelope.error + }) + })) + } + return envelope + } catch (err) { + // Why: the IPC boundary must not throw across the promise — wrap unexpected errors in the classified envelope for a single renderer shape. + console.error('Failed to fetch GitHub project view:', err) + return { + ok: false, + error: { + type: 'unknown', + message: err instanceof Error ? err.message : 'Failed to fetch project view' + } + } + } finally { + releaseWorkItemSlot() + inflightProjectViewRequests.delete(requestKey) + } + })() + + inflightProjectViewRequests.set(requestKey, { + promise: request, + force: Boolean(options?.force) + }) + return request + }, + + updateProjectFieldValue: async (cacheKey, rowId, fieldId, value) => { + const state = get() + const entry = state.projectViewCache[cacheKey] + const table = entry?.data + if (!table) { + return { + ok: false, + error: { + type: 'unknown', + message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded') + } + } + } + const rowIndex = table.rows.findIndex((r) => r.id === rowId) + if (rowIndex === -1) { + return { + ok: false, + error: { + type: 'unknown', + message: translate('auto.store.slices.github.f963485d37', 'Row not found') + } + } + } + const previousRow = table.rows[rowIndex] + // Optimistic patch: build a field value matching the mutation shape. + const nextField = optimisticFieldValueFromMutation(table, fieldId, value) + const optimisticFieldValues = { ...previousRow.fieldValuesByFieldId } + if (nextField) { + optimisticFieldValues[fieldId] = nextField + } + const optimisticRow: GitHubProjectRow = { + ...previousRow, + fieldValuesByFieldId: optimisticFieldValues + } + applyRowPatch(set, cacheKey, rowId, optimisticRow) + + const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey)) + const result = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.updateItemField', + { + projectId: table.project.id, + host: table.project.host, + itemId: rowId, + fieldId, + value + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateProjectItemField({ + projectId: table.project.id, + host: table.project.host, + itemId: rowId, + fieldId, + value + }) + if (!result.ok) { + rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) + } + return result + }, + + clearProjectFieldValue: async (cacheKey, rowId, fieldId) => { + const state = get() + const entry = state.projectViewCache[cacheKey] + const table = entry?.data + if (!table) { + return { + ok: false, + error: { + type: 'unknown', + message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded') + } + } + } + const rowIndex = table.rows.findIndex((r) => r.id === rowId) + if (rowIndex === -1) { + return { + ok: false, + error: { + type: 'unknown', + message: translate('auto.store.slices.github.f963485d37', 'Row not found') + } + } + } + const previousRow = table.rows[rowIndex] + const optimisticFieldValues = { ...previousRow.fieldValuesByFieldId } + delete optimisticFieldValues[fieldId] + const optimisticRow: GitHubProjectRow = { + ...previousRow, + fieldValuesByFieldId: optimisticFieldValues + } + applyRowPatch(set, cacheKey, rowId, optimisticRow) + + const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey)) + const result = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.clearItemField', + { + projectId: table.project.id, + host: table.project.host, + itemId: rowId, + fieldId + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.clearProjectItemField({ + projectId: table.project.id, + host: table.project.host, + itemId: rowId, + fieldId + }) + if (!result.ok) { + rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) + } + return result + } +}) diff --git a/src/renderer/src/store/github/project-cache.ts b/src/renderer/src/store/github/project-cache.ts new file mode 100644 index 00000000000..df8ac361025 --- /dev/null +++ b/src/renderer/src/store/github/project-cache.ts @@ -0,0 +1,129 @@ +import type { AppState } from '../types' +import type { + GitHubProjectFieldMutationValue, + GitHubProjectRow, + GitHubProjectTable +} from '../../../../shared/github/project-types' + +// Why: optimistic field value so the patched row re-renders immediately; best-effort, overwritten by the authoritative payload on next refresh. +export function optimisticFieldValueFromMutation( + table: GitHubProjectTable, + fieldId: string, + value: GitHubProjectFieldMutationValue +): GitHubProjectTable['rows'][number]['fieldValuesByFieldId'][string] | null { + const field = table.selectedView.fields.find((f) => f.id === fieldId) + switch (value.kind) { + case 'single-select': { + if (field?.kind === 'single-select') { + const option = field.options.find((o) => o.id === value.optionId) + if (option) { + return { + kind: 'single-select', + fieldId, + optionId: option.id, + name: option.name, + color: option.color + } + } + } + return { + kind: 'single-select', + fieldId, + optionId: value.optionId, + name: '', + color: '' + } + } + case 'iteration': { + if (field?.kind === 'iteration') { + const iteration = field.iterations.find((i) => i.id === value.iterationId) + if (iteration) { + return { + kind: 'iteration', + fieldId, + iterationId: iteration.id, + title: iteration.title, + startDate: iteration.startDate, + duration: iteration.duration + } + } + } + return { + kind: 'iteration', + fieldId, + iterationId: value.iterationId, + title: '', + startDate: '', + duration: 0 + } + } + case 'text': + return { kind: 'text', fieldId, text: value.text } + case 'number': + return { kind: 'number', fieldId, number: value.number } + case 'date': + return { kind: 'date', fieldId, date: value.date } + } + return null +} + +export function applyRowPatch( + set: (fn: (s: AppState) => Partial) => void, + cacheKey: string, + rowId: string, + nextRow: GitHubProjectRow +): void { + set((s) => { + const entry = s.projectViewCache[cacheKey] + if (!entry?.data) { + return {} + } + const rowIndex = entry.data.rows.findIndex((r) => r.id === rowId) + if (rowIndex === -1) { + return {} + } + const rows = [...entry.data.rows] + rows[rowIndex] = nextRow + return { + projectViewCache: { + ...s.projectViewCache, + [cacheKey]: { + ...entry, + data: { ...entry.data, rows } + } + } + } + }) +} + +export function rollbackRowIfPresent( + set: (fn: (s: AppState) => Partial) => void, + get: () => AppState, + cacheKey: string, + rowId: string, + previousRow: GitHubProjectRow +): void { + // Why: skip rollback when the entry moved (rapid project switch) or the row is gone, else stale data would surface in the newly selected project. + const entry = get().projectViewCache[cacheKey] + if (!entry?.data) { + return + } + const stillPresent = entry.data.rows.some((r) => r.id === rowId) + if (!stillPresent) { + return + } + applyRowPatch(set, cacheKey, rowId, previousRow) +} + +export function parseSlugAndNumber( + row: GitHubProjectRow +): { owner: string; repo: string; number: number } | null { + if (!row.content.repository || row.content.number == null) { + return null + } + const parts = row.content.repository.split('/') + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null + } + return { owner: parts[0], repo: parts[1], number: row.content.number } +} diff --git a/src/renderer/src/store/github/project-row-actions.ts b/src/renderer/src/store/github/project-row-actions.ts new file mode 100644 index 00000000000..63fd0d18ee3 --- /dev/null +++ b/src/renderer/src/store/github/project-row-actions.ts @@ -0,0 +1,292 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { GitHubProjectRow } from '../../../../shared/github/project-types' +import type { GitHubProjectMutationResult } from '../../../../shared/github/project-result-types' +import { translate } from '@/i18n/i18n' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { settingsForProjectRowOwner } from '../slices/github-project-row-owner' +import { settingsForProjectViewCacheKey } from './cache-identity' +import { applyRowPatch, parseSlugAndNumber, rollbackRowIfPresent } from './project-cache' + +export const createProjectRowActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick< + GitHubSlice, + 'patchProjectIssueOrPr' | 'patchProjectRowIssueType' | 'patchProjectRowContent' +> => ({ + patchProjectIssueOrPr: async (cacheKey, rowId, updates) => { + const state = get() + const entry = state.projectViewCache[cacheKey] + const table = entry?.data + if (!table) { + return { + ok: false, + error: { + type: 'unknown', + message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded') + } + } + } + const rowIndex = table.rows.findIndex((r) => r.id === rowId) + if (rowIndex === -1) { + return { + ok: false, + error: { + type: 'unknown', + message: translate('auto.store.slices.github.f963485d37', 'Row not found') + } + } + } + const previousRow = table.rows[rowIndex] + const { owner, repo, number } = parseSlugAndNumber(previousRow) ?? {} + if (!owner || !repo || !number) { + return { + ok: false, + error: { + type: 'validation_error', + message: translate( + 'auto.store.slices.github.87020f6605', + 'Row has no owner/repo/number — cannot patch underlying item' + ) + } + } + } + // Optimistic content patch. + const nextContent = { ...previousRow.content } + if (updates.title !== undefined) { + nextContent.title = updates.title + } + if (updates.body !== undefined) { + nextContent.body = updates.body + } + if (updates.addLabels || updates.removeLabels) { + const next = new Map(nextContent.labels.map((l) => [l.name, l])) + for (const name of updates.addLabels ?? []) { + if (!next.has(name)) { + next.set(name, { name, color: '808080' }) + } + } + for (const name of updates.removeLabels ?? []) { + next.delete(name) + } + nextContent.labels = Array.from(next.values()) + } + if (updates.addAssignees || updates.removeAssignees) { + const next = new Map(nextContent.assignees.map((u) => [u.login, u])) + for (const login of updates.addAssignees ?? []) { + if (!next.has(login)) { + next.set(login, { login, name: null, avatarUrl: null }) + } + } + for (const login of updates.removeAssignees ?? []) { + next.delete(login) + } + nextContent.assignees = Array.from(next.values()) + } + const optimisticRow: GitHubProjectRow = { ...previousRow, content: nextContent } + applyRowPatch(set, cacheKey, rowId, optimisticRow) + + // Why: labels/assignees go through the issue endpoint for both (GitHub PRs are issues for those); title/body split PR→updatePullRequestBySlug vs issue→updateIssueBySlug. + let envelope: GitHubProjectMutationResult = { ok: true } + // Why: slug-only Project rows have no registered Orca repo, so fall back to the view source in the cache key, not the focused host. + const target = getActiveRuntimeTarget( + settingsForProjectRowOwner( + get(), + owner, + repo, + table.project.host, + settingsForProjectViewCacheKey(get().settings, cacheKey) + ) + ) + if ( + previousRow.itemType === 'PULL_REQUEST' && + (updates.title !== undefined || updates.body !== undefined) + ) { + const args = { + owner, + repo, + host: table.project.host, + number, + updates: { + ...(updates.title !== undefined ? { title: updates.title } : {}), + ...(updates.body !== undefined ? { body: updates.body } : {}) + } + } + const prRes = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.updatePullRequestBySlug', + args, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updatePullRequestBySlug(args) + if (!prRes.ok) { + envelope = prRes + } + } + if ( + envelope.ok && + (updates.addLabels?.length || + updates.removeLabels?.length || + updates.addAssignees?.length || + updates.removeAssignees?.length || + (previousRow.itemType === 'ISSUE' && + (updates.title !== undefined || updates.body !== undefined))) + ) { + const args = { + owner, + repo, + host: table.project.host, + number, + updates: { + ...(updates.title !== undefined ? { title: updates.title } : {}), + ...(updates.body !== undefined ? { body: updates.body } : {}), + ...(updates.addLabels ? { addLabels: updates.addLabels } : {}), + ...(updates.removeLabels ? { removeLabels: updates.removeLabels } : {}), + ...(updates.addAssignees ? { addAssignees: updates.addAssignees } : {}), + ...(updates.removeAssignees ? { removeAssignees: updates.removeAssignees } : {}) + } + } + const issueRes = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.updateIssueBySlug', + args, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateIssueBySlug(args) + if (!issueRes.ok) { + envelope = issueRes + } + } + if (!envelope.ok) { + rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) + } + return envelope + }, + + patchProjectRowIssueType: async (cacheKey, rowId, issueType) => { + const state = get() + const entry = state.projectViewCache[cacheKey] + const table = entry?.data + if (!table) { + return { + ok: false, + error: { + type: 'unknown', + message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded') + } + } + } + const row = table.rows.find((r) => r.id === rowId) + if (!row) { + return { + ok: false, + error: { + type: 'unknown', + message: translate('auto.store.slices.github.f963485d37', 'Row not found') + } + } + } + if (row.itemType !== 'ISSUE') { + return { + ok: false, + error: { + type: 'validation_error', + message: translate( + 'auto.store.slices.github.83f9b126ad', + 'Issue Type can only be set on Issues.' + ) + } + } + } + const { owner, repo, number } = parseSlugAndNumber(row) ?? {} + if (!owner || !repo || !number) { + return { + ok: false, + error: { + type: 'validation_error', + message: translate('auto.store.slices.github.683a21264b', 'Row has no owner/repo/number.') + } + } + } + const previousRow = row + const optimistic: GitHubProjectRow = { + ...previousRow, + content: { ...previousRow.content, issueType } + } + applyRowPatch(set, cacheKey, rowId, optimistic) + // Why: slug-only Project rows belong to the host that loaded the view, which may differ from the now-focused host. + const target = getActiveRuntimeTarget( + settingsForProjectRowOwner( + get(), + owner, + repo, + table.project.host, + settingsForProjectViewCacheKey(get().settings, cacheKey) + ) + ) + const args = { + owner, + repo, + host: table.project.host, + number, + issueTypeId: issueType?.id ?? null + } + const res = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'github.project.updateIssueTypeBySlug', + args, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateIssueTypeBySlug(args) + if (!res.ok) { + rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) + } + return res + }, + + patchProjectRowContent: (cacheKey, rowId, patch) => { + const state = get() + const entry = state.projectViewCache[cacheKey] + const table = entry?.data + if (!table) { + return + } + const previousRow = table.rows.find((r) => r.id === rowId) + if (!previousRow) { + return + } + const nextContent = { ...previousRow.content } + if (patch.title !== undefined) { + nextContent.title = patch.title + } + if (patch.body !== undefined) { + nextContent.body = patch.body + } + if (patch.state !== undefined) { + // Why: ProjectV2 row.state is GitHub's UPPERCASE enum ('OPEN'|'CLOSED'|'MERGED') but the dialog tracks lowercase; upper-case so the patch matches the canonical row shape. + nextContent.state = patch.state.toUpperCase() + } + if (patch.labels !== undefined) { + const existingByName = new Map(previousRow.content.labels.map((l) => [l.name, l])) + nextContent.labels = patch.labels.map( + (name) => existingByName.get(name) ?? { name, color: '808080' } + ) + } + if (patch.assignees !== undefined) { + const existingByLogin = new Map(previousRow.content.assignees.map((u) => [u.login, u])) + nextContent.assignees = patch.assignees.map( + (login) => existingByLogin.get(login) ?? { login, name: null, avatarUrl: null } + ) + } + const nextRow: GitHubProjectRow = { ...previousRow, content: nextContent } + applyRowPatch(set, cacheKey, rowId, nextRow) + } +}) diff --git a/src/renderer/src/store/github/pull-request-actions.ts b/src/renderer/src/store/github/pull-request-actions.ts new file mode 100644 index 00000000000..c9a43e075ac --- /dev/null +++ b/src/renderer/src/store/github/pull-request-actions.ts @@ -0,0 +1,155 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { GitHubPRFallbackSource } from './cache-model' +import { getHostedReviewCacheKey } from '../slices/hosted-review-cache-identity' +import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index' +import { prCacheKey } from './cache-identity' +import { isFresh } from './cache-policy' +import { buildGitHubPRRefreshStateClearToken } from './pr-refresh-state' +import { githubHostedReviewFallbackPRNumber, prLookupHintKey } from './pr-result-routing' +import { inflightPRRequests, prRequestGenerations } from './request-coordination' +import { settingsForGitHubRepoOwner } from './work-item-routing' +import { + findWorktreeById, + shouldApplyDivergedLinkedPRClear, + shouldClearDivergedLinkedMergedPR +} from './worktree-refresh' +import { startPullRequestLookup } from './pull-request-execution' + +export const createPullRequestActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + fetchPRForBranch: async (repoPath, branch, options): Promise => { + const repoLookup = getGitHubRepoLookupIndex(get().repos) + const repo = options?.repoId + ? repoLookup.findById(options.repoId) + : repoLookup.findByPath(repoPath) + const repoId = options?.repoId ?? repo?.id + const requestSettings = settingsForGitHubRepoOwner(get().settings, repo) + const cacheKey = prCacheKey( + repoPath, + repoId, + branch, + requestSettings, + repo?.connectionId, + repo?.executionHostId, + repo !== undefined + ) + const cached = get().prCache[cacheKey] + const hostedReviewCacheKey = getHostedReviewCacheKey( + repoPath, + branch, + requestSettings, + repoId, + repo?.connectionId, + repo?.executionHostId, + repo !== undefined + ) + // Why: a prior linkedPR-less caller may have cached null for this branch; refetch so the cached miss can now resolve via the linkedPR path. + const linkedPRNumber = options?.linkedPRNumber ?? null + const explicitFallbackPRNumber = options?.fallbackPRNumber ?? null + const hostedReviewFallbackPRNumber = githubHostedReviewFallbackPRNumber( + get(), + repoPath, + repoId, + branch, + repo?.connectionId, + repo?.executionHostId, + repo !== undefined + ) + const fallbackPRNumber = + linkedPRNumber == null ? (explicitFallbackPRNumber ?? hostedReviewFallbackPRNumber) : null + const fallbackPRSource: GitHubPRFallbackSource | null = + linkedPRNumber != null || fallbackPRNumber == null + ? null + : (options?.fallbackPRSource ?? + (explicitFallbackPRNumber != null ? 'explicit' : 'hosted-review')) + const lookupHintKey = prLookupHintKey(linkedPRNumber, fallbackPRNumber) + const linkedRefetch = + cached?.data === null && (linkedPRNumber !== null || fallbackPRNumber !== null) + if (!options?.force && !linkedRefetch && isFresh(cached)) { + // Why: even a fresh cache hit carries the head-scoped divergence signal; if a prior clear was declined for a mid-request head move and we're back on that head, clear the durable link. + if ( + options?.worktreeId && + linkedPRNumber != null && + cached?.data?.headDivergedFromMergedPRAtOid != null + ) { + const currentHeadOid = findWorktreeById(get(), options.worktreeId)?.head ?? null + if ( + shouldClearDivergedLinkedMergedPR({ + pr: cached.data, + linkedPRNumber, + requestHeadOid: currentHeadOid + }) + ) { + void get().updateWorktreeMeta( + options.worktreeId, + { linkedPR: null }, + { + shouldApply: (worktree) => + shouldApplyDivergedLinkedPRClear({ + worktree, + linkedPRNumber, + branch, + requestHeadOid: currentHeadOid + }) + } + ) + } + } + return cached.data + } + + const inflightRequest = inflightPRRequests.get(cacheKey) + if ( + inflightRequest && + (!options?.force || inflightRequest.force) && + inflightRequest.lookupHintKey === lookupHintKey && + !linkedRefetch + ) { + return inflightRequest.promise + } + + const generation = (prRequestGenerations.get(cacheKey) ?? 0) + 1 + const requestStartedAt = Date.now() + const requestStartedHostedReviewEntry = get().hostedReviewCache[hostedReviewCacheKey] + const requestStartedPRRefreshState = get().prRefreshStates[cacheKey] + const requestStartedPRRefreshToken = buildGitHubPRRefreshStateClearToken( + requestStartedPRRefreshState, + get().prRefreshSequences, + cacheKey + ) + prRequestGenerations.set(cacheKey, generation) + + const request = startPullRequestLookup({ + set, + get, + repoPath, + branch, + options, + repo, + repoId, + requestSettings, + cacheKey, + cached, + linkedPRNumber, + fallbackPRNumber, + fallbackPRSource, + generation, + requestStartedAt, + requestStartedHostedReviewEntry, + requestStartedPRRefreshToken + }) + + inflightPRRequests.set(cacheKey, { + promise: request, + force: Boolean(options?.force), + generation, + lookupHintKey + }) + return request + } +}) diff --git a/src/renderer/src/store/github/pull-request-execution.ts b/src/renderer/src/store/github/pull-request-execution.ts new file mode 100644 index 00000000000..3233fc2f40b --- /dev/null +++ b/src/renderer/src/store/github/pull-request-execution.ts @@ -0,0 +1,286 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { Repo } from '../../../../shared/repo-types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { CacheEntry, GitHubPRFallbackSource } from './cache-model' +import type { PRRefreshStateClearToken } from './pr-refresh-state' + +import type { + GitHubPRRefreshCandidate, + PRRefreshOutcome +} from '../../../../shared/github/pull-request-refresh-types' +import { normalizeGitHubPRForBranchOutcome } from '../../../../shared/github/pull-request-for-branch-outcome' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { debouncedSaveCache } from './cache-persistence' +import { inflightPRRequests, prRequestGenerations } from './request-coordination' +import { getRuntimeRepoTarget } from './repository-routing' +import { setGitHubPRResultCaches, shouldPreserveExistingPRForFallbackMiss } from './pr-result-cache' +import { + findUniqueWorktreeById, + findWorktreeById, + isStaleExactLinkedPRLookup, + shouldApplyBranchMismatchedLinkedPRClear, + shouldApplyDivergedLinkedPRClear, + shouldClearBranchMismatchedLinkedOpenPR, + shouldClearDivergedLinkedMergedPR +} from './worktree-refresh' +export function startPullRequestLookup(args: { + set: Parameters>[0] + get: Parameters>[1] + repoPath: string + branch: string + options: Parameters[2] + repo: Repo | undefined + repoId: string | undefined + requestSettings: AppState['settings'] + cacheKey: string + cached: CacheEntry | undefined + linkedPRNumber: number | null + fallbackPRNumber: number | null + fallbackPRSource: GitHubPRFallbackSource | null + generation: number + requestStartedAt: number + requestStartedHostedReviewEntry: AppState['hostedReviewCache'][string] | undefined + requestStartedPRRefreshToken: PRRefreshStateClearToken | null +}): Promise { + const { + set, + get, + repoPath, + branch, + options, + repo, + repoId, + requestSettings, + cacheKey, + cached, + linkedPRNumber, + fallbackPRNumber, + fallbackPRSource, + generation, + requestStartedAt, + requestStartedHostedReviewEntry, + requestStartedPRRefreshToken + } = args + const request = (async () => { + try { + const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) + const candidateWorktree = options?.worktreeId + ? findWorktreeById(get(), options.worktreeId) + : null + const requestHeadOid = candidateWorktree?.head ?? null + const outcome = runtimeRepo + ? await callRuntimeRpc( + runtimeRepo.target, + 'github.prForBranch', + { + repo: runtimeRepo.repo.id, + branch, + linkedPRNumber, + currentHeadOid: requestHeadOid, + ...(fallbackPRNumber !== null + ? { fallbackPRNumber, acceptMergedFallbackPR: fallbackPRSource !== null } + : {}) + }, + { timeoutMs: 30_000 } + ).then((result) => normalizeGitHubPRForBranchOutcome(result)) + : await (async () => { + const candidate: GitHubPRRefreshCandidate = { + repoId: repoId ?? '', + repoPath, + repoKind: repo?.kind ?? 'git', + branch, + cacheKey, + worktreeId: options?.worktreeId, + currentHeadOid: requestHeadOid, + linkedPRNumber, + fallbackPRNumber, + fallbackPRSource, + connectionId: repo?.connectionId ?? null, + executionHostId: repo?.executionHostId ?? null, + cachedFetchedAt: cached?.fetchedAt ?? null, + cachedHasPR: cached?.data ? true : cached ? false : null, + cachedPRState: cached?.data?.state ?? null, + cachedChecksStatus: cached?.data?.checksStatus ?? null, + cachedMergeable: cached?.data?.mergeable ?? null, + cachedMergeStateStatus: cached?.data?.mergeStateStatus ?? null + } + const response = window.api.gh.refreshPRNow + ? await window.api.gh.refreshPRNow({ candidate }) + : await window.api.gh.prForBranch({ + repoPath, + repoId, + branch, + linkedPRNumber, + fallbackPRNumber, + acceptMergedFallbackPR: fallbackPRNumber !== null && fallbackPRSource !== null, + currentHeadOid: requestHeadOid + }) + return normalizeGitHubPRForBranchOutcome(response) + })() + const pr: PRInfo | null = + outcome.kind === 'found' ? outcome.pr : outcome.kind === 'no-pr' ? null : null + if (outcome.kind === 'upstream-error') { + // Why: the runtime RPC path skips the coordinator broadcast that fills prRefreshStates on native, so record the classified error here for Checks parity with native (design criterion 2). + if (runtimeRepo && prRequestGenerations.get(cacheKey) === generation) { + set((s) => { + const nextStates = { ...s.prRefreshStates } + delete nextStates[cacheKey] + nextStates[cacheKey] = { + status: 'error', + reason: 'swr', + updatedAt: Date.now(), + message: outcome.message, + errorType: outcome.errorType, + nextAutoRetryAt: outcome.nextAutoRetryAt, + retryDisabledUntil: outcome.retryDisabledUntil + } + return { prRefreshStates: nextStates } + }) + } + return cached?.data ?? null + } + if (prRequestGenerations.get(cacheKey) === generation) { + let skippedStaleLinkedPRLookup = false + let didUpdatePRCache = false + set((s) => { + // Why: unlinking a PR mid exact-linked-PR-lookup must stop the older result from restoring the manual link UI. + if (isStaleExactLinkedPRLookup(s, options?.worktreeId, linkedPRNumber)) { + skippedStaleLinkedPRLookup = true + return {} + } + const updates = setGitHubPRResultCaches(s, { + prCacheKey: cacheKey, + repoPath, + branch, + settings: requestSettings, + repoId, + connectionId: repo?.connectionId, + executionHostId: repo?.executionHostId, + hasRepoOwner: repo !== undefined, + pr, + fetchedAt: outcome.fetchedAt, + worktreeId: options?.worktreeId, + linkedPRNumber, + fallbackPRNumber, + fallbackPRSource, + requestStartedAt, + requestStartedEntry: requestStartedHostedReviewEntry + }) + didUpdatePRCache = updates.prCache !== undefined + return updates + }) + if (skippedStaleLinkedPRLookup) { + return null + } + if (didUpdatePRCache) { + debouncedSaveCache(get()) + } + const linkedPRWorktree = + options?.worktreeId && linkedPRNumber != null + ? findUniqueWorktreeById( + get(), + options.worktreeId, + repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID + ) + : null + if ( + options?.worktreeId && + linkedPRWorktree && + linkedPRNumber != null && + shouldClearDivergedLinkedMergedPR({ pr, linkedPRNumber, requestHeadOid }) + ) { + // Why: only clear the durable link that produced this exact probe; drift means the stale result no longer owns the worktree. + void get().updateWorktreeMeta( + options.worktreeId, + { linkedPR: null }, + { + shouldApply: () => + shouldApplyDivergedLinkedPRClear({ + worktree: + findUniqueWorktreeById( + get(), + options.worktreeId!, + repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID + ) ?? undefined, + linkedPRNumber, + branch, + requestHeadOid + }) + } + ) + } + if ( + options?.worktreeId && + linkedPRWorktree && + linkedPRNumber != null && + shouldClearBranchMismatchedLinkedOpenPR({ + pr, + linkedPRNumber, + branch, + requestHeadOid, + pushTargetBranch: linkedPRWorktree.pushTarget?.branchName ?? null + }) + ) { + void get().updateWorktreeMeta( + options.worktreeId, + { linkedPR: null }, + { + // Why: the branch-scoped PR refetch below updates both caches; the generic metadata refresh would duplicate provider work. + suppressHostedReviewRefresh: true, + shouldApply: () => + shouldApplyBranchMismatchedLinkedPRClear({ + worktree: + findUniqueWorktreeById( + get(), + options.worktreeId!, + repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID + ) ?? undefined, + linkedPRNumber, + branch, + requestHeadOid + }) + } + ) + // Re-resolve by branch now so Checks recover this refresh instead of serving the stale linked PR. + void get().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId: options.worktreeId + }) + } + } + if ( + shouldPreserveExistingPRForFallbackMiss({ + currentPR: get().prCache[cacheKey]?.data, + nextPR: pr, + state: get(), + worktreeId: options?.worktreeId, + linkedPRNumber, + fallbackPRNumber, + fallbackPRSource + }) + ) { + return get().prCache[cacheKey]?.data ?? null + } + return pr ?? null + } catch (err) { + console.error('Failed to fetch PR:', err) + return null + } finally { + const activeRequest = inflightPRRequests.get(cacheKey) + if (activeRequest?.generation === generation) { + inflightPRRequests.delete(cacheKey) + if (prRequestGenerations.get(cacheKey) === generation) { + prRequestGenerations.delete(cacheKey) + } + } + if (requestStartedPRRefreshToken) { + get().expireGitHubPRRefreshState(cacheKey, requestStartedPRRefreshToken) + } + } + })() + return request +} diff --git a/src/renderer/src/store/github/refresh-event-actions.ts b/src/renderer/src/store/github/refresh-event-actions.ts new file mode 100644 index 00000000000..1e4bccbebd6 --- /dev/null +++ b/src/renderer/src/store/github/refresh-event-actions.ts @@ -0,0 +1,278 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import { getHostedReviewCacheKey } from '../slices/hosted-review-cache-identity' +import { applyCachedChecksStatus } from './pr-check-status-cache' +import { debouncedSaveCache } from './cache-persistence' +import { + capPrRefreshSequences, + capPrRefreshStates, + pruneExpiredPRRefreshStates +} from './pr-refresh-state' +import { + applyGitHubPRResultToCaches, + deletePRRefreshStartedEntry, + prRefreshStartedEntryKey, + setPRRefreshStartedHostedReviewEntry +} from './pr-result-cache' +import { prRefreshStartedHostedReviewEntries } from './request-coordination' +import { getRefreshAliasExecutionHostId } from './repository-routing' +import { + buildWorktreeLookupIndex, + findUniqueWorktreeById, + isStaleExactLinkedPRLookup, + shouldApplyBranchMismatchedLinkedPRClear, + shouldApplyDivergedLinkedPRClear, + shouldClearBranchMismatchedLinkedOpenPR, + shouldClearDivergedLinkedMergedPR +} from './worktree-refresh' +import type { WorktreeLookupIndex } from './worktree-refresh' + +export const createRefreshEventActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + applyGitHubPRRefreshEvent: (event) => { + // Why: local-repo sidebar refresh routes through the main PR coordinator, so run the same guarded diverged-merged-PR clear. + const divergedLinkedPRClears: { + worktreeId: string + linkedPRNumber: number + branch: string + requestHeadOid: string | null + executionHostId: string + }[] = [] + const branchMismatchedLinkedPRClears: { + worktreeId: string + linkedPRNumber: number + branch: string + requestHeadOid: string | null + executionHostId: string + }[] = [] + let didUpdatePRCache = false + set((s) => { + let linkedWorktreeLookupIndex: WorktreeLookupIndex | undefined + const nextSequences = { ...s.prRefreshSequences } + const prunedStates = pruneExpiredPRRefreshStates(s.prRefreshStates) + const nextStates = { ...prunedStates } + let nextPRCache = s.prCache + let nextHostedReviewCache = s.hostedReviewCache ?? {} + let changed = prunedStates !== s.prRefreshStates + + for (const alias of event.aliases) { + const aliasExecutionHostId = getRefreshAliasExecutionHostId(alias) + const previousSequence = nextSequences[alias.cacheKey] ?? 0 + if ( + event.outcome ? event.sequence < previousSequence : event.sequence <= previousSequence + ) { + if (event.outcome || event.status !== 'in-flight') { + deletePRRefreshStartedEntry(event.sequence, alias.cacheKey) + } + continue + } + // Why: delete-then-set re-orders this key last so capPrRefreshSequences evicts idle, not active, keys. + delete nextSequences[alias.cacheKey] + nextSequences[alias.cacheKey] = event.sequence + changed = true + + if (event.outcome) { + const startedEntryKey = prRefreshStartedEntryKey(event.sequence, alias.cacheKey) + const requestStartedEntry = prRefreshStartedHostedReviewEntries.get(startedEntryKey) + prRefreshStartedHostedReviewEntries.delete(startedEntryKey) + if (previousSequence !== event.sequence) { + deletePRRefreshStartedEntry(previousSequence, alias.cacheKey) + } + delete nextStates[alias.cacheKey] + if (event.outcome.kind === 'upstream-error') { + nextStates[alias.cacheKey] = { + status: 'error', + reason: event.reason, + updatedAt: Date.now(), + message: event.outcome.message, + errorType: event.outcome.errorType, + nextAutoRetryAt: event.outcome.nextAutoRetryAt, + retryDisabledUntil: event.outcome.retryDisabledUntil + } + continue + } + const data = + event.outcome.kind === 'found' + ? applyCachedChecksStatus( + s, + alias, + event.outcome.pr, + event.outcome.fetchedAt, + aliasExecutionHostId + ) + : null + const linkedPRNumber = alias.linkedPRNumber ?? null + // Why: one outcome fans out to many aliases; build one lazy index instead of rescanning worktrees per alias. + const worktreeLookupIndex = + alias.worktreeId && linkedPRNumber != null + ? (linkedWorktreeLookupIndex ??= buildWorktreeLookupIndex(s)) + : undefined + // Why: a queued refresh finishing after the user unlinks an exact PR must not restore the manual-link UI. + if ( + isStaleExactLinkedPRLookup(s, alias.worktreeId, linkedPRNumber, worktreeLookupIndex) + ) { + continue + } + if (event.outcome.kind === 'found' && alias.worktreeId) { + const requestHeadOid = alias.currentHeadOid ?? null + const worktree = + linkedPRNumber != null + ? findUniqueWorktreeById( + s, + alias.worktreeId, + aliasExecutionHostId, + worktreeLookupIndex + ) + : null + // Why: only the sequence-gate winner owns metadata side effects; late outcomes must not unlink a newer PR. + if ( + worktree && + linkedPRNumber != null && + shouldClearDivergedLinkedMergedPR({ + pr: event.outcome.pr, + linkedPRNumber, + requestHeadOid + }) + ) { + divergedLinkedPRClears.push({ + worktreeId: alias.worktreeId, + linkedPRNumber, + branch: alias.branch, + requestHeadOid, + executionHostId: aliasExecutionHostId + }) + } else if ( + worktree && + linkedPRNumber != null && + shouldClearBranchMismatchedLinkedOpenPR({ + pr: event.outcome.pr, + linkedPRNumber, + branch: alias.branch, + requestHeadOid, + pushTargetBranch: worktree.pushTarget?.branchName ?? null + }) + ) { + branchMismatchedLinkedPRClears.push({ + worktreeId: alias.worktreeId, + linkedPRNumber, + branch: alias.branch, + requestHeadOid, + executionHostId: aliasExecutionHostId + }) + } + } + const nextCaches = applyGitHubPRResultToCaches({ + prCache: nextPRCache, + hostedReviewCache: nextHostedReviewCache, + prCacheKey: alias.cacheKey, + repoPath: alias.repoPath, + branch: alias.branch, + settings: s.settings, + repoId: alias.repoId, + connectionId: alias.connectionId, + executionHostId: aliasExecutionHostId, + hasRepoOwner: true, + pr: data, + fetchedAt: event.outcome.fetchedAt, + state: s, + worktreeId: alias.worktreeId, + linkedPRNumber: alias.linkedPRNumber, + fallbackPRNumber: alias.fallbackPRNumber, + fallbackPRSource: alias.fallbackPRSource, + requestStartedAt: event.requestStartedAt, + requestStartedEntry + }) + didUpdatePRCache = didUpdatePRCache || nextCaches.prCache !== nextPRCache + nextPRCache = nextCaches.prCache + nextHostedReviewCache = nextCaches.hostedReviewCache + continue + } + + if (event.status) { + if (previousSequence !== event.sequence) { + deletePRRefreshStartedEntry(previousSequence, alias.cacheKey) + } + if (event.status === 'in-flight' && event.requestStartedAt !== undefined) { + const hostedReviewCacheKey = getHostedReviewCacheKey( + alias.repoPath, + alias.branch, + s.settings, + alias.repoId, + alias.connectionId, + aliasExecutionHostId, + true + ) + setPRRefreshStartedHostedReviewEntry( + prRefreshStartedEntryKey(event.sequence, alias.cacheKey), + s.hostedReviewCache[hostedReviewCacheKey] + ) + } else { + // Why: pause/skip can follow an in-flight broadcast with no outcome; drop the stale request-start snapshot. + deletePRRefreshStartedEntry(event.sequence, alias.cacheKey) + } + // Why: delete-then-set re-orders this key last so capRecordByInsertionOrder evicts idle, not active, keys. + delete nextStates[alias.cacheKey] + const isPaused = event.status === 'paused' + nextStates[alias.cacheKey] = { + status: event.status, + reason: event.reason, + updatedAt: Date.now(), + pausedUntil: event.pausedUntil, + skippedReason: event.skippedReason, + // Why: paused = rate-limit gate; map pausedUntil into the schedule to show auto-retry and disable manual Retry. + nextAutoRetryAt: isPaused ? event.pausedUntil : undefined, + retryDisabledUntil: isPaused ? event.pausedUntil : undefined + } + } + } + + return changed + ? { + prRefreshSequences: capPrRefreshSequences(nextSequences), + // Why: bound prRefreshStates with status-aware eviction so visible in-progress pills survive. + prRefreshStates: capPrRefreshStates(nextStates), + prCache: nextPRCache, + hostedReviewCache: nextHostedReviewCache + } + : {} + }) + if (didUpdatePRCache && event.outcome && event.outcome.kind !== 'upstream-error') { + debouncedSaveCache(get()) + } + for (const clear of divergedLinkedPRClears) { + void get().updateWorktreeMeta( + clear.worktreeId, + { linkedPR: null }, + { + shouldApply: () => + shouldApplyDivergedLinkedPRClear({ + worktree: + findUniqueWorktreeById(get(), clear.worktreeId, clear.executionHostId) ?? undefined, + linkedPRNumber: clear.linkedPRNumber, + branch: clear.branch, + requestHeadOid: clear.requestHeadOid + }) + } + ) + } + for (const clear of branchMismatchedLinkedPRClears) { + void get().updateWorktreeMeta( + clear.worktreeId, + { linkedPR: null }, + { + shouldApply: () => + shouldApplyBranchMismatchedLinkedPRClear({ + worktree: + findUniqueWorktreeById(get(), clear.worktreeId, clear.executionHostId) ?? undefined, + linkedPRNumber: clear.linkedPRNumber, + branch: clear.branch, + requestHeadOid: clear.requestHeadOid + }) + } + ) + } + } +}) diff --git a/src/renderer/src/store/github/refresh-routing-actions.ts b/src/renderer/src/store/github/refresh-routing-actions.ts new file mode 100644 index 00000000000..bca0e2020d2 --- /dev/null +++ b/src/renderer/src/store/github/refresh-routing-actions.ts @@ -0,0 +1,90 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-request-refresh-types' +import { bypassesGitHubPRRefreshFreshness } from './pr-refresh-state' +import { + enqueueLocalGitHubPRRefresh, + getPRRefreshRuntimeRepoTarget, + shouldEnqueueLocalPRRefresh +} from './repository-routing' +import { buildPRRefreshCandidate, findWorktreeById } from './worktree-refresh' + +export const createRefreshRoutingActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick< + GitHubSlice, + | 'enqueueGitHubPRRefresh' + | 'reportVisibleGitHubPRRefreshCandidates' + | 'bumpGitHubPRVisibleRefreshGeneration' +> => ({ + enqueueGitHubPRRefresh: (worktreeId, reason, priority = 0) => { + const state = get() + const worktree = findWorktreeById(state, worktreeId) + const candidate = worktree ? buildPRRefreshCandidate(state, worktree) : null + if (!candidate) { + return + } + if (getPRRefreshRuntimeRepoTarget(state, candidate)) { + void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { + force: bypassesGitHubPRRefreshFreshness(reason), + repoId: candidate.repoId, + worktreeId: candidate.worktreeId, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: candidate.fallbackPRNumber ?? null, + fallbackPRSource: candidate.fallbackPRSource ?? null + }) + return + } + if (!shouldEnqueueLocalPRRefresh(candidate)) { + return + } + enqueueLocalGitHubPRRefresh({ candidate, reason, priority }, async () => { + await get().fetchPRForBranch(candidate.repoPath, candidate.branch, { + force: bypassesGitHubPRRefreshFreshness(reason), + repoId: candidate.repoId, + worktreeId: candidate.worktreeId, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: candidate.fallbackPRNumber ?? null, + fallbackPRSource: candidate.fallbackPRSource ?? null + }) + }) + }, + + reportVisibleGitHubPRRefreshCandidates: (worktreeIds, generation) => { + const state = get() + const candidates = worktreeIds + .map((id) => { + const worktree = findWorktreeById(state, id) + return worktree ? buildPRRefreshCandidate(state, worktree) : null + }) + .filter((candidate): candidate is GitHubPRRefreshCandidate => candidate !== null) + const localCandidates: GitHubPRRefreshCandidate[] = [] + for (const candidate of candidates) { + if (getPRRefreshRuntimeRepoTarget(state, candidate)) { + void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { + repoId: candidate.repoId, + worktreeId: candidate.worktreeId, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: candidate.fallbackPRNumber ?? null, + fallbackPRSource: candidate.fallbackPRSource ?? null + }) + continue + } + if (shouldEnqueueLocalPRRefresh(candidate)) { + localCandidates.push(candidate) + } + } + const reportVisible = window.api.gh.reportVisiblePRRefreshCandidates + if (reportVisible) { + void reportVisible({ candidates: localCandidates, generation }).catch((err) => { + console.warn('Failed to report visible PR refresh candidates:', err) + }) + } + }, + + bumpGitHubPRVisibleRefreshGeneration: () => { + set((s) => ({ prVisibleRefreshGeneration: s.prVisibleRefreshGeneration + 1 })) + } +}) diff --git a/src/renderer/src/store/github/refresh-sweep-actions.ts b/src/renderer/src/store/github/refresh-sweep-actions.ts new file mode 100644 index 00000000000..ab28629539d --- /dev/null +++ b/src/renderer/src/store/github/refresh-sweep-actions.ts @@ -0,0 +1,221 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-request-refresh-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' +import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index' +import { issueCacheKey, prCacheKey } from './cache-identity' +import { CACHE_TTL, evictStaleEntries } from './cache-policy' +import { pruneExpiredPRRefreshStates } from './pr-refresh-state' +import { + enqueueLocalGitHubPRRefresh, + getPRRefreshRuntimeRepoTarget, + getRuntimeRepoTarget, + shouldEnqueueLocalPRRefresh +} from './repository-routing' +import { settingsForGitHubRepoOwner } from './work-item-routing' +import { buildPRRefreshCandidate } from './worktree-refresh' + +export const createRefreshSweepActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + refreshAllGitHub: () => { + // Clear comments cache; evict stale entries to bound long-session growth across repos/branches. + set((s) => { + const next = { + commentsCache: Object.keys(s.commentsCache).length === 0 ? s.commentsCache : {}, + prCache: evictStaleEntries(s.prCache), + issueCache: evictStaleEntries(s.issueCache), + checksCache: evictStaleEntries(s.checksCache), + workItemsCache: evictStaleEntries(s.workItemsCache), + projectViewCache: evictStaleEntries(s.projectViewCache), + prRefreshStates: pruneExpiredPRRefreshStates(s.prRefreshStates) + } + // Why: each eviction helper returns its input untouched when nothing changed, so an + // unchanged sweep can return `s` and avoid waking every subscriber on window resume. + return next.commentsCache === s.commentsCache && + next.prCache === s.prCache && + next.issueCache === s.issueCache && + next.checksCache === s.checksCache && + next.workItemsCache === s.workItemsCache && + next.projectViewCache === s.projectViewCache && + next.prRefreshStates === s.prRefreshStates + ? s + : next + }) + + // Why: don't prune prRequestGenerations here — deleting a live generation makes its response look stale. + + // Only re-fetch PR/issue entries that are already stale — skip fresh ones + const state = get() + const cardProps = state.worktreeCardProperties ?? [] + const rawCardProps = cardProps as readonly string[] + const shouldRefreshIssues = (state.worktreeCardProperties ?? []).includes('issue') + const isPRStatusGrouping = state.groupBy === 'pr-status' + const rightSidebarShowsPR = rightSidebarShowsPullRequestData(state) + const shouldRefreshPRs = + isPRStatusGrouping || + rightSidebarShowsPR || + (state.settings?.experimentalNewWorktreeCardStyle === true + ? cardProps.includes('status') + : cardProps.includes('pr') || rawCardProps.includes('ci')) + if (!shouldRefreshPRs && !shouldRefreshIssues) { + return + } + + const now = Date.now() + const stalePRCandidates: { candidate: GitHubPRRefreshCandidate; score: number }[] = [] + const repoLookup = getGitHubRepoLookupIndex(state.repos) + + for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const wt of worktrees) { + const repo = repoLookup.findById(wt.repoId) + if (!repo) { + continue + } + + const branch = wt.branch.replace(/^refs\/heads\//, '') + if (shouldRefreshPRs && !wt.isBare && branch) { + const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) + const prKey = prCacheKey( + repo.path, + repo.id, + branch, + ownerSettings, + repo.connectionId, + repo.executionHostId + ) + const prEntry = state.prCache[prKey] + if (!prEntry || now - prEntry.fetchedAt >= CACHE_TTL) { + const candidate = buildPRRefreshCandidate(state, wt, undefined, repo) + if (candidate) { + stalePRCandidates.push({ + candidate, + score: + (state.activeWorktreeId === wt.id ? Number.MAX_SAFE_INTEGER : 0) + + wt.lastActivityAt + }) + } + } + } + if (shouldRefreshIssues && wt.linkedIssue) { + const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) + const issueKey = issueCacheKey( + repo.path, + repo.id, + wt.linkedIssue, + ownerSettings, + repo.connectionId, + repo.executionHostId, + true + ) + const issueEntry = state.issueCache[issueKey] + if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) { + void get().fetchIssue(repo.path, wt.linkedIssue, { repoId: repo.id }) + } + } + } + } + const candidatesToRefresh = stalePRCandidates + .sort((a, b) => b.score - a.score) + .slice(0, isPRStatusGrouping ? stalePRCandidates.length : 5) + for (const { candidate } of candidatesToRefresh) { + const candidateSettings = settingsForGitHubRepoOwner( + state.settings, + candidate as Pick + ) + if (getRuntimeRepoTarget(state, candidate.repoPath, candidateSettings)) { + void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { + repoId: candidate.repoId, + worktreeId: candidate.worktreeId, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: candidate.fallbackPRNumber ?? null, + fallbackPRSource: candidate.fallbackPRSource ?? null + }) + } else if (shouldEnqueueLocalPRRefresh(candidate)) { + enqueueLocalGitHubPRRefresh({ candidate, reason: 'swr', priority: 10 }) + } + } + }, + + refreshGitHubForWorktree: (worktreeId) => { + const state = get() + let worktree: Worktree | undefined + for (const worktrees of Object.values(state.worktreesByRepo)) { + worktree = worktrees.find((w) => w.id === worktreeId) + if (worktree) { + break + } + } + if (!worktree) { + return + } + + const repo = state.repos.find((r) => r.id === worktree.repoId) + if (!repo) { + return + } + + // Invalidate this worktree's cache entries + const branch = worktree.branch.replace(/^refs\/heads\//, '') + const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) + const prKey = prCacheKey( + repo.path, + repo.id, + branch, + ownerSettings, + repo.connectionId, + repo.executionHostId + ) + const issueKey = worktree.linkedIssue + ? issueCacheKey( + repo.path, + repo.id, + worktree.linkedIssue, + ownerSettings, + repo.connectionId, + repo.executionHostId, + true + ) + : '' + + set((s) => { + const updates: Partial = {} + if (s.prCache[prKey]) { + updates.prCache = { ...s.prCache, [prKey]: { ...s.prCache[prKey], fetchedAt: 0 } } + } + if (issueKey && s.issueCache[issueKey]) { + updates.issueCache = { + ...s.issueCache, + [issueKey]: { ...s.issueCache[issueKey], fetchedAt: 0 } + } + } + return updates + }) + + // Re-fetch (skip when branch is empty — detached HEAD during rebase) + if (!worktree.isBare && branch) { + const candidate = buildPRRefreshCandidate(get(), worktree) + if (candidate) { + if (getPRRefreshRuntimeRepoTarget(get(), candidate)) { + void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { + force: true, + repoId: candidate.repoId, + worktreeId: candidate.worktreeId, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: candidate.fallbackPRNumber ?? null, + fallbackPRSource: candidate.fallbackPRSource ?? null + }) + } else if (shouldEnqueueLocalPRRefresh(candidate)) { + enqueueLocalGitHubPRRefresh({ candidate, reason: 'post-push', priority: 100 }) + } + } + } + if ((state.worktreeCardProperties ?? []).includes('issue') && worktree.linkedIssue) { + void get().fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id }) + } + } +}) diff --git a/src/renderer/src/store/github/repository-routing.ts b/src/renderer/src/store/github/repository-routing.ts new file mode 100644 index 00000000000..95967b07979 --- /dev/null +++ b/src/renderer/src/store/github/repository-routing.ts @@ -0,0 +1,117 @@ +import type { AppState } from '../types' +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + GitHubPRRefreshReason +} from '../../../../shared/github/pull-request-refresh-types' +import type { Repo } from '../../../../shared/repo-types' +import { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index' +import { + LOCAL_EXECUTION_HOST_ID, + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + normalizeExecutionHostId, + parseExecutionHostId +} from '../../../../shared/execution-host' + +export function getRuntimeRepoTarget( + state: AppState, + repoPath: string, + settings: AppState['settings'] = state.settings +): { target: { kind: 'environment'; environmentId: string }; repo: Repo } | null { + const target = getActiveRuntimeTarget(settings) + if (target.kind !== 'environment') { + return null + } + const repo = getGitHubRepoLookupIndex(state.repos).findByPath(repoPath) + return repo ? { target, repo } : null +} + +export function getPRRefreshOwnerRuntimeEnvironmentId( + candidate: Pick +): string | null { + const parsed = parseExecutionHostId(candidate.executionHostId) + if (parsed?.kind === 'runtime') { + return parsed.environmentId + } + const cacheScope = candidate.cacheKey.split('::', 1)[0] + const cacheScopeHost = parseExecutionHostId(cacheScope) + return cacheScopeHost?.kind === 'runtime' ? cacheScopeHost.environmentId : null +} + +export function getPRRefreshRuntimeRepoTarget( + state: AppState, + candidate: GitHubPRRefreshCandidate +): { target: { kind: 'environment'; environmentId: string }; repo: Repo } | null { + const ownerRuntimeEnvironmentId = getPRRefreshOwnerRuntimeEnvironmentId(candidate) + if (!ownerRuntimeEnvironmentId) { + return null + } + // Why: PR refreshes must follow the repo owner host, not the Active Server dropdown (a runtime-owned worktree can show while Local is focused). + return getRuntimeRepoTarget( + state, + candidate.repoPath, + state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId } + : ({ activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId } as AppState['settings']) + ) +} + +export function shouldEnqueueLocalPRRefresh(candidate: GitHubPRRefreshCandidate): boolean { + // Why: the local coordinator owns local git + SSH-bridge refreshes; runtime-owned and disconnected-SSH repos must not hit the IPC crash path. + if (getPRRefreshOwnerRuntimeEnvironmentId(candidate) !== null) { + return false + } + return !candidate.connectionId || candidate.connectionState === 'connected' +} + +export function enqueueLocalGitHubPRRefresh( + args: { + candidate: GitHubPRRefreshCandidate + reason: GitHubPRRefreshReason + priority: number + }, + onNotQueued?: () => void | Promise +): void { + const enqueue = window.api.gh.enqueuePRRefresh + if (!enqueue) { + return + } + // Why: renderer refresh triggers are best-effort — main may reject stale paths, and this must not become an unhandled-rejection crash. + void enqueue(args) + .then((queued) => + queued === false || queued?.kind === 'fallback' ? onNotQueued?.() : undefined + ) + .catch((err) => { + console.warn('Failed to enqueue PR refresh:', err) + }) +} +export function getRefreshAliasExecutionHostId(alias: GitHubPRRefreshAlias): string { + const explicitHostId = normalizeExecutionHostId(alias.executionHostId) + if (explicitHostId) { + return explicitHostId + } + const scope = alias.cacheKey.split('::', 1)[0] + return normalizeExecutionHostId(scope) ?? LOCAL_EXECUTION_HOST_ID +} + +export function findRepoForGitHubOwner( + state: Partial>, + repoId: string | undefined, + repoPath: string +): Repo | undefined { + return state.repos + ? getGitHubRepoLookupIndex(state.repos).findByIdOrPath(repoId, repoPath) + : undefined +} + +export function getGitHubFocusedRepoOwnerHostId( + settings: AppState['settings'], + repo: Pick | undefined +): string { + if (repo?.executionHostId || repo?.connectionId) { + return getRepoExecutionHostId(repo) + } + return getSettingsFocusedExecutionHostId(settings) +} diff --git a/src/renderer/src/store/github/request-coordination.ts b/src/renderer/src/store/github/request-coordination.ts new file mode 100644 index 00000000000..fc6f3cb0b7c --- /dev/null +++ b/src/renderer/src/store/github/request-coordination.ts @@ -0,0 +1,84 @@ +import type { PRCheckDetail } from '../../../../shared/github/check-types' +import type { PRComment } from '../../../../shared/github/comment-types' +import type { IssueInfo, PRInfo } from '../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import type { GetProjectViewTableResult } from '../../../../shared/github/project-result-types' +import type { AppState } from '../types' + +export type InflightPR = { + promise: Promise + force: boolean + generation: number + lookupHintKey: string +} +export type InflightChecks = { + promise: Promise + force: boolean + noCache: boolean +} +export type InflightWorkItems = { + promise: Promise + force: boolean + noCache: boolean + requireComplete: boolean +} + +export const inflightPRRequests = new Map() +export const inflightIssueRequests = new Map>() +export const inflightChecksRequests = new Map() +export const inflightCommentsRequests = new Map>() +export const inflightWorkItemsRequests = new Map() +export const inflightProjectViewRequests = new Map< + string, + { promise: Promise; force: boolean } +>() +export const prRequestGenerations = new Map() +export const prRefreshStartedHostedReviewEntries = new Map< + string, + AppState['hostedReviewCache'][string] | undefined +>() + +export function _getGitHubPRRequestGenerationCountForTest(): number { + return prRequestGenerations.size +} + +export function _getGitHubPRRefreshStartedEntryCountForTest(): number { + return prRefreshStartedHostedReviewEntries.size +} + +export function _clearGitHubPRRefreshStartedEntriesForTest(): void { + prRefreshStartedHostedReviewEntries.clear() +} + +const PROVIDER_REQUEST_CONCURRENCY = 8 +let providerRequestsInFlight = 0 +const providerRequestWaiters: (() => void)[] = [] + +export async function acquireProviderRequestSlot(): Promise { + if (providerRequestsInFlight < PROVIDER_REQUEST_CONCURRENCY) { + providerRequestsInFlight += 1 + return + } + await new Promise((resolve) => providerRequestWaiters.push(resolve)) +} + +export function releaseProviderRequestSlot(): void { + const next = providerRequestWaiters.shift() + if (next) { + next() + return + } + providerRequestsInFlight -= 1 +} + +export function clearInflightWorkItemsForRepo(repoId: string, repoPath?: string): void { + const prefixes = [`${repoId}::`] + if (repoPath && repoPath !== repoId) { + prefixes.push(`${repoPath}::`) + } + for (const key of Array.from(inflightWorkItemsRequests.keys())) { + if (prefixes.some((prefix) => key.startsWith(prefix))) { + inflightWorkItemsRequests.delete(key) + } + } +} diff --git a/src/renderer/src/store/github/review-thread-actions.ts b/src/renderer/src/store/github/review-thread-actions.ts new file mode 100644 index 00000000000..727465919e2 --- /dev/null +++ b/src/renderer/src/store/github/review-thread-actions.ts @@ -0,0 +1,92 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from './cache-identity' +import { getGitHubRepoSourceSettings, getGitHubWorkItemRequestContext } from './work-item-routing' + +export const createReviewThreadActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + resolveReviewThread: async (repoPath, prNumber, threadId, resolve, options) => { + const repo = get().repos?.find((candidate) => + options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + prCommentsCacheSuffix(prNumber, options?.prRepo), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + + // Optimistic toggle of isResolved for this thread; reverts if the API call fails. + const prev = get().commentsCache[cacheKey]?.data + if (prev) { + set((s) => ({ + commentsCache: { + ...s.commentsCache, + [cacheKey]: { + ...s.commentsCache[cacheKey], + data: prev.map((c) => (c.threadId === threadId ? { ...c, isResolved: resolve } : c)) + } + } + })) + } + + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + let ok = false + try { + ok = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.resolveReviewThread', + { + repo: requestContext.target.runtimeRepoId, + threadId, + resolve, + prRepo: options?.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.resolveReviewThread({ + repoPath, + repoId, + threadId, + resolve, + prRepo: options?.prRepo ?? null, + sourceContext: options?.sourceContext + }) + } catch (err) { + console.error('Failed to update review thread:', err) + ok = false + } + if (!ok && prev) { + // Revert optimistic update on failure + set((s) => ({ + commentsCache: { + ...s.commentsCache, + [cacheKey]: { ...s.commentsCache[cacheKey], data: prev } + } + })) + } + return ok + } +}) diff --git a/src/renderer/src/store/github/slice-types.ts b/src/renderer/src/store/github/slice-types.ts new file mode 100644 index 00000000000..0d1f41c5da6 --- /dev/null +++ b/src/renderer/src/store/github/slice-types.ts @@ -0,0 +1,276 @@ +import type { ClassifiedError } from '../../../../shared/classified-error' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' +import type { + GitHubCommentResult, + GitHubReactionContent, + PRComment +} from '../../../../shared/github/comment-types' +import type { + GitHubPRRefreshEvent, + GitHubPRRefreshReason +} from '../../../../shared/github/pull-request-refresh-types' +import type { + GitHubOwnerRepo, + IssueInfo, + PRInfo +} from '../../../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import type { IssueSourcePreference } from '../../../../shared/repo-types' +import type { + GitHubProjectFieldMutationValue, + GitHubProjectTable +} from '../../../../shared/github/project-types' +import type { + GetProjectViewTableResult, + GitHubProjectMutationResult +} from '../../../../shared/github/project-result-types' +import type { GetProjectViewTableArgs } from '../../../../shared/github/project-request-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { + CacheEntry, + FetchOptions, + GitHubPatchWorkItemOptions, + GitHubPRFallbackSource, + ProjectRowContentPatch, + ProjectRowContentUpdate, + ProjectViewCacheEntry, + RepoScopedFetchOptions, + WorkItemsCacheError, + WorkItemsCacheSources +} from './cache-model' +import type { PRRefreshState, PRRefreshStateClearToken } from './pr-refresh-state' + +export type GitHubSlice = { + prCache: Record> + issueCache: Record> + checksCache: Record> + commentsCache: Record> + prRefreshSequences: Record + prRefreshStates: Record + prVisibleRefreshGeneration: number + // Why: keyed by repoId + limit + query so same-path repos on different SSH targets don't share results. + workItemsCache: Record> + fetchPRForBranch: ( + repoPath: string, + branch: string, + options?: RepoScopedFetchOptions & { + worktreeId?: string + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + fallbackPRSource?: GitHubPRFallbackSource | null + } + ) => Promise + fetchIssue: ( + repoPath: string, + number: number, + options?: RepoScopedFetchOptions + ) => Promise + fetchPRChecks: ( + repoPath: string, + prNumber: number, + branch?: string, + headSha?: string, + prRepo?: GitHubOwnerRepo | null, + options?: RepoScopedFetchOptions + ) => Promise + fetchPRCheckDetails: ( + repoPath: string, + args: { + checkRunId?: number + workflowRunId?: number + checkName?: string + url?: string | null + prRepo?: GitHubOwnerRepo | null + }, + options?: RepoScopedFetchOptions + ) => Promise + fetchPRComments: ( + repoPath: string, + prNumber: number, + options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } + ) => Promise + addPRConversationComment: ( + repoPath: string, + prNumber: number, + body: string, + options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } + ) => Promise + addPRReviewCommentReply: ( + repoPath: string, + prNumber: number, + commentId: number, + body: string, + options?: RepoScopedFetchOptions & { + prRepo?: GitHubOwnerRepo | null + threadId?: string + path?: string + line?: number + } + ) => Promise + setPRCommentReaction: ( + repoPath: string, + prNumber: number, + reactionSubjectId: string, + content: GitHubReactionContent, + reacted: boolean, + options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } + ) => Promise + resolveReviewThread: ( + repoPath: string, + prNumber: number, + threadId: string, + resolve: boolean, + options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } + ) => Promise + initGitHubCache: () => Promise + refreshAllGitHub: () => void + refreshGitHubForWorktree: (worktreeId: string) => void + refreshGitHubForWorktreeIfStale: (worktreeId: string) => void + enqueueGitHubPRRefresh: ( + worktreeId: string, + reason: GitHubPRRefreshReason, + priority?: number + ) => void + reportVisibleGitHubPRRefreshCandidates: (worktreeIds: string[], generation: number) => void + bumpGitHubPRVisibleRefreshGeneration: () => void + applyGitHubPRRefreshEvent: (event: GitHubPRRefreshEvent) => void + getEffectiveGitHubPRRefreshState: (cacheKey: string, now?: number) => PRRefreshState | undefined + expireGitHubPRRefreshState: ( + cacheKey: string, + token: PRRefreshStateClearToken, + now?: number + ) => void + /** SWR: returns cached work items immediately (null if none) and fires a background refresh when stale. */ + getCachedWorkItems: ( + repoId: string, + limit: number, + query: string, + repoPath?: string, + sourceContext?: TaskSourceContext | null + ) => readonly GitHubWorkItem[] | null + /** Returns a thin view (sources + error, never items) so it stays a cheap selector without dragging the whole work-item array through the equality check. */ + getWorkItemsSourcesAndError: ( + repoId: string, + limit: number, + query: string, + repoPath?: string + ) => { sources: WorkItemsCacheSources | null; error: WorkItemsCacheError | null } + /** + * Falls back to any `${repoPath}::` cache entry with resolved sources when the primary entry isn't populated yet — sources are repo-level (query-independent), so any sibling is safe to reuse. + * Returns a single stable reference so the dialog can subscribe to just this selector; entries are replaced (not mutated) on write, preserving reference equality between unchanged entries. + */ + getWorkItemsAnySourcesForRepo: ( + repoId: string, + limit: number, + repoPath?: string + ) => WorkItemsCacheSources | null + fetchWorkItems: ( + repoId: string, + repoPath: string, + limit: number, + query: string, + options?: FetchOptions + ) => Promise + /** + * Fan out one work-item query across repos; partial failures don't reject — a repo with no cached fallback increments `failedCount`, but one served stale cache on rejection isn't counted. + * `githubUnavailable`: every selected GitHub source refresh failed because GitHub was unreachable (5xx/network/rate-limit), even if stale cache remains — lets the caller attribute the stale/empty list. + */ + fetchWorkItemsAcrossRepos: ( + repos: { + repoId: string + path: string + executionHostId?: string | null + sourceContext?: TaskSourceContext | null + }[], + perRepoLimit: number, + displayLimit: number, + query: string, + options?: FetchOptions + ) => Promise<{ + items: GitHubWorkItem[] + failedCount: number + githubUnavailable: boolean + requestFailureCount?: number + }> + /** Fetch one numbered provider page. Pagination pages remain renderer-local. */ + fetchWorkItemsNextPage: ( + repos: { + repoId: string + path: string + executionHostId?: string | null + sourceContext?: TaskSourceContext | null + }[], + perRepoLimit: number, + displayLimit: number, + query: string, + page: number, + options?: Pick + ) => Promise<{ + items: GitHubWorkItem[] + failedCount: number + errorTypes: ClassifiedError['type'][] + }> + /** Count items and derive pages from the largest per-repo result set. */ + countWorkItemsAcrossRepos: ( + repos: { + repoId: string + path: string + executionHostId?: string | null + sourceContext?: TaskSourceContext | null + }[], + query: string, + perRepoLimit: number + ) => Promise<{ totalCount: number; totalPages: number }> + /** Fire-and-forget prefetch to warm the cache before the page mounts (hover/focus of the "new workspace" buttons). */ + prefetchWorkItems: ( + repoId: string, + repoPath: string, + limit?: number, + query?: string, + options?: { sourceContext?: TaskSourceContext | null } + ) => void + patchWorkItem: ( + itemId: string, + patch: Partial, + repoId?: string | null, + options?: GitHubPatchWorkItemOptions + ) => void + /** Monotonic counter bumped on issue-source preference flips; subscribers include it in their deps to force a re-fetch, since cache eviction alone won't trip effects keyed on selectedRepos/search/nonce. */ + workItemsInvalidationNonce: number + /** Persist the preference, update the local Repo record, and invalidate all `${repoId}::*` cache keys — not just the primary — so alternate-query lines don't serve stale results after the source flips. */ + setIssueSourcePreference: ( + repoId: string, + repoPath: string, + preference: IssueSourcePreference + ) => Promise + evictGitHubRepoCaches: (repoId: string, repoPath?: string) => void + // ── ProjectV2 view cache ───────────────────────────────────────────── + projectViewCache: Record> + fetchProjectViewTable: ( + args: GetProjectViewTableArgs, + options?: FetchOptions + ) => Promise + updateProjectFieldValue: ( + cacheKey: string, + rowId: string, + fieldId: string, + value: GitHubProjectFieldMutationValue + ) => Promise + clearProjectFieldValue: ( + cacheKey: string, + rowId: string, + fieldId: string + ) => Promise + patchProjectIssueOrPr: ( + cacheKey: string, + rowId: string, + updates: ProjectRowContentUpdate + ) => Promise + patchProjectRowIssueType: ( + cacheKey: string, + rowId: string, + issueType: { id: string; name: string; color: string | null; description: string | null } | null + ) => Promise + /** Optimistic, IPC-free patcher for a single `projectViewCache` row's `content`; `patchWorkItem` only walks `workItemsCache` and would leave the Project view stale until the next refresh. */ + patchProjectRowContent: (cacheKey: string, rowId: string, patch: ProjectRowContentPatch) => void +} diff --git a/src/renderer/src/store/github/stale-worktree-refresh-actions.ts b/src/renderer/src/store/github/stale-worktree-refresh-actions.ts new file mode 100644 index 00000000000..1ead3a5d360 --- /dev/null +++ b/src/renderer/src/store/github/stale-worktree-refresh-actions.ts @@ -0,0 +1,84 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' +import { issueCacheKey } from './cache-identity' +import { CACHE_TTL } from './cache-policy' +import { + enqueueLocalGitHubPRRefresh, + getPRRefreshRuntimeRepoTarget, + shouldEnqueueLocalPRRefresh +} from './repository-routing' +import { settingsForGitHubRepoOwner } from './work-item-routing' +import { buildPRRefreshCandidate } from './worktree-refresh' + +export const createStaleWorktreeRefreshActions = ( + get: Parameters>[1] +): Pick => ({ + // Why: activation is the strongest freshness signal; route through the coordinator to keep coalescing/rate-limit guards. + refreshGitHubForWorktreeIfStale: (worktreeId) => { + const state = get() + let worktree: Worktree | undefined + for (const worktrees of Object.values(state.worktreesByRepo)) { + worktree = worktrees.find((w) => w.id === worktreeId) + if (worktree) { + break + } + } + if (!worktree) { + return + } + + const repo = state.repos.find((r) => r.id === worktree.repoId) + if (!repo) { + return + } + + const now = Date.now() + const branch = worktree.branch.replace(/^refs\/heads\//, '') + const cardProps = state.worktreeCardProperties ?? [] + const rawCardProps = cardProps as readonly string[] + const shouldRefreshPR = + state.groupBy === 'pr-status' || + (state.settings?.experimentalNewWorktreeCardStyle === true + ? cardProps.includes('status') + : cardProps.includes('pr') || rawCardProps.includes('ci')) || + rightSidebarShowsPullRequestData(state) + + if (shouldRefreshPR && !worktree.isBare && branch) { + const candidate = buildPRRefreshCandidate(state, worktree) + if (candidate) { + if (getPRRefreshRuntimeRepoTarget(state, candidate)) { + void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { + force: true, + repoId: candidate.repoId, + worktreeId: candidate.worktreeId, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: candidate.fallbackPRNumber ?? null, + fallbackPRSource: candidate.fallbackPRSource ?? null + }) + } else if (shouldEnqueueLocalPRRefresh(candidate)) { + enqueueLocalGitHubPRRefresh({ candidate, reason: 'active', priority: 80 }) + } + } + } + + if ((state.worktreeCardProperties ?? []).includes('issue') && worktree.linkedIssue) { + const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) + const issueKey = issueCacheKey( + repo.path, + repo.id, + worktree.linkedIssue, + ownerSettings, + repo.connectionId, + repo.executionHostId, + true + ) + const issueEntry = state.issueCache[issueKey] + if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) { + void get().fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id }) + } + } + } +}) diff --git a/src/renderer/src/store/github/work-item-aggregate-actions.ts b/src/renderer/src/store/github/work-item-aggregate-actions.ts new file mode 100644 index 00000000000..3bb9d20e872 --- /dev/null +++ b/src/renderer/src/store/github/work-item-aggregate-actions.ts @@ -0,0 +1,263 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { ClassifiedError } from '../../../../shared/classified-error' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import { + isGitHubWorkItemsSshRemoteRequiredError, + sortWorkItemsByNumber, + PER_REPO_FETCH_LIMIT +} from '../../../../shared/work-items' +import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' +import { + GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN, + isGitHubWorkItemsQueryTooLarge +} from '../slices/github-work-items-query-bounds' +import { workItemsCacheKey, workItemsInflightRequestKey } from './cache-identity' +import { GITHUB_SEARCH_RESULT_WINDOW, isFresh, WORK_ITEMS_CACHE_TTL } from './cache-policy' +import { + acquireProviderRequestSlot as acquireWorkItemSlot, + inflightWorkItemsRequests, + releaseProviderRequestSlot as releaseWorkItemSlot +} from './request-coordination' +import { findRepoForGitHubOwner } from './repository-routing' +import { + countGitHubWorkItemsForRepo, + getGitHubWorkItemRequestContext, + getGitHubWorkItemSourceSettings, + getWorkItemsCacheKeyForOwner, + isGitHubUnavailableWorkItemsError, + listGitHubWorkItemsForRepo +} from './work-item-routing' + +export const createWorkItemAggregateActions = ( + get: Parameters>[1] +): Pick< + GitHubSlice, + | 'fetchWorkItemsAcrossRepos' + | 'fetchWorkItemsNextPage' + | 'countWorkItemsAcrossRepos' + | 'prefetchWorkItems' +> => ({ + fetchWorkItemsAcrossRepos: async (repos, perRepoLimit, displayLimit, query, options) => { + if (isGitHubWorkItemsQueryTooLarge(query)) { + return { items: [], failedCount: 0, githubUnavailable: false } + } + const state = get() + let failedCount = 0 + let requestFailureCount = 0 + let unavailableFailureCount = 0 + let skippedSourceCount = 0 + const perProjectResults = await Promise.all( + repos.map(async (r) => { + try { + return await state.fetchWorkItems(r.repoId, r.path, perRepoLimit, query, { + ...options, + sourceContext: r.sourceContext ?? options?.sourceContext + }) + } catch (err) { + // Why: fall back to any cache entry (stale or not) before declaring this repo failed; only count as failed when it has nothing to contribute. + // Why: use perRepoLimit (not displayLimit) so the cache key matches what fetchWorkItems wrote. + if (isGitHubWorkItemsSshRemoteRequiredError(err)) { + if (options?.requireComplete) { + requestFailureCount += 1 + failedCount += 1 + } + skippedSourceCount += 1 + return [] as GitHubWorkItem[] + } + requestFailureCount += 1 + if (isGitHubUnavailableWorkItemsError(err)) { + unavailableFailureCount += 1 + } + const key = + r.sourceContext?.provider === 'github' + ? workItemsCacheKey( + r.repoId, + perRepoLimit, + query, + getTaskSourceCacheScope(r.sourceContext) + ) + : getWorkItemsCacheKeyForOwner(get(), r.repoId, perRepoLimit, query, r.path) + const cached = get().workItemsCache[key]?.data + if (cached && options?.allowStaleFallback !== false) { + console.warn(`[workItems] ${r.repoId} failed, serving cached:`, err) + return cached + } + console.warn(`[workItems] ${r.repoId} failed:`, err) + failedCount += 1 + return [] as GitHubWorkItem[] + } + }) + ) + const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit) + // Why: only claim global unavailability when every eligible source failed for a reachability reason; skipped SSH repos aren't GitHub sources here. + const githubUnavailable = + requestFailureCount > 0 && + requestFailureCount === repos.length - skippedSourceCount && + unavailableFailureCount === requestFailureCount + return { + items: merged, + failedCount, + githubUnavailable, + ...(requestFailureCount > 0 ? { requestFailureCount } : {}) + } + }, + + fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, page, options) => { + if (isGitHubWorkItemsQueryTooLarge(query)) { + return { items: [], failedCount: 0, errorTypes: [] } + } + let failedCount = 0 + const errorTypes: ClassifiedError['type'][] = [] + const perProjectResults = await Promise.all( + repos.map(async (r) => { + const requestState = get() + const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + r.sourceContext + ) + const requestContext = getGitHubWorkItemRequestContext( + requestState, + requestSettings, + r.repoId, + r.path, + r.sourceContext + ) + await acquireWorkItemSlot() + try { + const envelope = await listGitHubWorkItemsForRepo(requestContext, { + limit: perRepoLimit, + query: query || undefined, + page, + ...(options?.noCache ? { noCache: true } : {}) + }) + // Why: page-N failures aren't in the per-repo banner (keyed on the initial fetch); log them so pagination failures are observable instead of silently truncating (richer surface deferred, design doc §6). + if (envelope.errors?.issues) { + const { type, message } = envelope.errors.issues + // Why: only the 1000-result-window 422 may drive the unreachable + // clamp; demote other validation errors so they read as failures. + errorTypes.push( + type === 'validation_error' && + !GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN.test(message) + ? 'unknown' + : type + ) + console.warn( + `[workItems] next page ${r.repoId} issues-side partial failure:`, + envelope.errors.issues + ) + } + if (envelope.errors?.prs) { + // Why: the window 422 is issue-side only — a PR-side validation + // error must never join the unreachable signal. + const { type } = envelope.errors.prs + errorTypes.push(type === 'validation_error' ? 'unknown' : type) + console.warn( + `[workItems] next page ${r.repoId} prs-side partial failure:`, + envelope.errors.prs + ) + } + if (options?.requireComplete && (envelope.errors?.issues || envelope.errors?.prs)) { + failedCount += 1 + return [] as GitHubWorkItem[] + } + return envelope.items.map((item): GitHubWorkItem => ({ ...item, repoId: r.repoId })) + } catch (err) { + if (isGitHubWorkItemsSshRemoteRequiredError(err)) { + if (options?.requireComplete) { + failedCount += 1 + } + return [] as GitHubWorkItem[] + } + console.warn(`[workItems] next page ${r.repoId} failed:`, err) + failedCount += 1 + return [] as GitHubWorkItem[] + } finally { + releaseWorkItemSlot() + } + }) + ) + const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit) + return { items: merged, failedCount, errorTypes } + }, + + countWorkItemsAcrossRepos: async (repos, query, perRepoLimit) => { + if (isGitHubWorkItemsQueryTooLarge(query)) { + return { totalCount: 0, totalPages: 0 } + } + const normalizedLimit = Math.max(1, Math.floor(perRepoLimit)) + // Why: GitHub 422s pages that start past its 1000-result search window. + const maxReachablePages = Math.max(1, Math.ceil(GITHUB_SEARCH_RESULT_WINDOW / normalizedLimit)) + const counts = await Promise.all( + repos.map(async (r) => { + // Why: same stampede cap as item-fetch — without a slot a 90-repo selection fires 90 concurrent count IPCs before the main-side rate-limit guard sees the first 403. + await acquireWorkItemSlot() + try { + const requestState = get() + const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + r.sourceContext + ) + const requestContext = getGitHubWorkItemRequestContext( + requestState, + requestSettings, + r.repoId, + r.path, + r.sourceContext + ) + return await countGitHubWorkItemsForRepo(requestContext, { query: query || undefined }) + } catch { + return 0 + } finally { + releaseWorkItemSlot() + } + }) + ) + return { + totalCount: counts.reduce((sum, count) => sum + count, 0), + // Why: repos advance independently by page, so take the max across repos — a sum/page-width undercounts when one repo owns most results. + totalPages: counts.reduce( + (maxPages, count) => + Math.max(maxPages, Math.min(Math.ceil(count / normalizedLimit), maxReachablePages)), + 0 + ) + } + }, + + prefetchWorkItems: (repoId, repoPath, limit = PER_REPO_FETCH_LIMIT, query = '', options) => { + if (isGitHubWorkItemsQueryTooLarge(query)) { + return + } + const requestState = get() + const repo = findRepoForGitHubOwner(requestState, repoId, repoPath) + const key = + options?.sourceContext?.provider === 'github' + ? workItemsCacheKey(repoId, limit, query, getTaskSourceCacheScope(options.sourceContext)) + : getWorkItemsCacheKeyForOwner(requestState, repoId, limit, query, repoPath) + const cached = get().workItemsCache[key] + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + options?.sourceContext + ) + const requestContext = getGitHubWorkItemRequestContext( + requestState, + requestSettings, + repoId, + repoPath, + options?.sourceContext + ) + const inflightKey = workItemsInflightRequestKey(key, requestContext.target) + if (isFresh(cached, WORK_ITEMS_CACHE_TTL) || inflightWorkItemsRequests.has(inflightKey)) { + return + } + void get() + .fetchWorkItems(repoId, repoPath, limit, query, { sourceContext: options?.sourceContext }) + .catch(() => {}) + } +}) diff --git a/src/renderer/src/store/github/work-item-fetch-actions.ts b/src/renderer/src/store/github/work-item-fetch-actions.ts new file mode 100644 index 00000000000..722556ee0ff --- /dev/null +++ b/src/renderer/src/store/github/work-item-fetch-actions.ts @@ -0,0 +1,235 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import type { WorkItemsCacheError } from './cache-model' +import { isGitHubWorkItemsSshRemoteRequiredError } from '../../../../shared/work-items' +import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' +import { isGitHubWorkItemsQueryTooLarge } from '../slices/github-work-items-query-bounds' +import { reconcileCatalogRows } from '../slices/repo-identity-reconcile' +import { structuralValuesEqual } from '../../../../shared/structural-value-equality' +import { workItemsCacheKey, workItemsInflightRequestKey } from './cache-identity' +import { isFresh, withBoundedCacheEntry, WORK_ITEMS_CACHE_TTL } from './cache-policy' +import { + acquireProviderRequestSlot as acquireWorkItemSlot, + inflightWorkItemsRequests, + releaseProviderRequestSlot as releaseWorkItemSlot +} from './request-coordination' +import { findRepoForGitHubOwner } from './repository-routing' +import { + getGitHubWorkItemRequestContext, + getGitHubWorkItemSourceCacheScope, + getGitHubWorkItemSourceHostId, + getGitHubWorkItemSourceSettings, + getWorkItemsCacheKeyForOwner, + listGitHubWorkItemsForRepo +} from './work-item-routing' + +export const createWorkItemFetchActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick< + GitHubSlice, + | 'getCachedWorkItems' + | 'getWorkItemsSourcesAndError' + | 'getWorkItemsAnySourcesForRepo' + | 'fetchWorkItems' +> => ({ + getCachedWorkItems: (repoId, limit, query, repoPath, sourceContext) => { + if (isGitHubWorkItemsQueryTooLarge(query)) { + return null + } + const state = get() + const key = + sourceContext?.provider === 'github' + ? workItemsCacheKey(repoId, limit, query, getTaskSourceCacheScope(sourceContext)) + : getWorkItemsCacheKeyForOwner(state, repoId, limit, query, repoPath) + return get().workItemsCache[key]?.data ?? null + }, + + getWorkItemsSourcesAndError: (repoId, limit, query, repoPath) => { + if (isGitHubWorkItemsQueryTooLarge(query)) { + return { sources: null, error: null } + } + const key = getWorkItemsCacheKeyForOwner(get(), repoId, limit, query, repoPath) + const entry = get().workItemsCache[key] + return { + sources: entry?.sources ?? null, + error: entry?.error ?? null + } + }, + + getWorkItemsAnySourcesForRepo: (repoId, limit, repoPath) => { + const cache = get().workItemsCache + const primaryKey = getWorkItemsCacheKeyForOwner(get(), repoId, limit, '', repoPath) + const primary = cache[primaryKey]?.sources + if (primary) { + return primary + } + const prefix = primaryKey + for (const [key, entry] of Object.entries(cache)) { + if (key.startsWith(prefix) && entry.sources) { + return entry.sources + } + } + return null + }, + + fetchWorkItems: async ( + repoId, + repoPath, + limit, + query, + options + ): Promise => { + if (isGitHubWorkItemsQueryTooLarge(query)) { + return [] + } + const requestState = get() + const repo = findRepoForGitHubOwner(requestState, repoId, repoPath) + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + options?.sourceContext + ) + const ownerHostId = getGitHubWorkItemSourceHostId(requestState, repo, options?.sourceContext) + const cacheScope = getGitHubWorkItemSourceCacheScope(requestState, repo, options?.sourceContext) + const key = workItemsCacheKey(repoId, limit, query, cacheScope) + const cached = get().workItemsCache[key] + if (!options?.force && isFresh(cached, WORK_ITEMS_CACHE_TTL)) { + return cached.data ?? [] + } + + const requestInvalidationNonce = requestState.workItemsInvalidationNonce + const requestContext = getGitHubWorkItemRequestContext( + requestState, + requestSettings, + repoId, + repoPath, + options?.sourceContext + ) + const inflightKey = workItemsInflightRequestKey(key, requestContext.target) + const existing = inflightWorkItemsRequests.get(inflightKey) + if (existing) { + // Why: a forcing/noCache caller must not dedupe to a weaker in-flight fetch (noCache is stricter — it must bypass gh api's cache too). + if ( + (options?.force && !existing.force) || + (options?.noCache && !existing.noCache) || + (options?.requireComplete && !existing.requireComplete) + ) { + await existing.promise.catch(() => {}) + } else { + return existing.promise + } + } + + const request = (async () => { + await acquireWorkItemSlot() + try { + const envelope = await listGitHubWorkItemsForRepo(requestContext, { + limit, + query: query || undefined, + ...(options?.noCache ? { noCache: true } : {}) + }) + // Why: stamp repoId at the fetch boundary so downstream consumers can rely on it — main doesn't know Orca's Repo.id. + const items: GitHubWorkItem[] = envelope.items.map((item) => ({ ...item, repoId })) + if (options?.requireComplete && (envelope.errors?.issues || envelope.errors?.prs)) { + throw new Error('GitHub work-item fetch returned a partial result.') + } + // Why: only surface issues-side errors here; PR-side failures predate the issue-source split (#1076) and are out of scope for this banner (design doc §2). + const issuesError = envelope.errors?.issues + // Why: errors.issues without sources.issues has no slug for the banner, so it's dropped from the cache; log it so this rare case is visible in devtools. + if (issuesError && !envelope.sources.issues) { + console.warn( + '[workItems] dropping issues-side error with no resolved source:', + issuesError + ) + } + const errorForCache: WorkItemsCacheError | undefined = + issuesError && envelope.sources.issues + ? { ...issuesError, source: envelope.sources.issues } + : undefined + const currentRepo = findRepoForGitHubOwner(get(), repoId, repoPath) + const currentHostId = getGitHubWorkItemSourceHostId( + get(), + currentRepo, + options?.sourceContext + ) + // Why: repo ownership changed, so this response belongs to an older execution-host bucket (host focus changes alone are fine). + if ((currentHostId ?? null) !== (ownerHostId ?? null)) { + return items + } + // Why: the old promise can still settle after the in-flight clear; don't let pre-flip source data repopulate the cache once the invalidation nonce changed. + if (get().workItemsInvalidationNonce !== requestInvalidationNonce) { + return items + } + // Why: TaskPage useShallow-selects cache entry refs. A new { ...entry, fetchedAt } + // still remaps every visible row. IPC structuredClone rebuilds nested records, so + // data === previous.data never holds — reconcile structurally, then either mutate + // fetchedAt in place or write one entry that keeps unchanged row/meta refs. + set((s) => { + const previousEntry = s.workItemsCache[key] + const previousData = previousEntry?.data ?? [] + const reconciled = reconcileCatalogRows( + previousData, + items, + (row) => `${row.repoId}\0${row.id}` + ) + const nextFellBack = envelope.issueSourceFellBack ? true : undefined + const sourcesUnchanged = structuralValuesEqual(previousEntry?.sources, envelope.sources) + const errorUnchanged = structuralValuesEqual(previousEntry?.error, errorForCache) + const fellBackUnchanged = previousEntry?.issueSourceFellBack === nextFellBack + if ( + previousEntry && + reconciled === previousData && + sourcesUnchanged && + errorUnchanged && + fellBackUnchanged + ) { + previousEntry.fetchedAt = Date.now() + return {} + } + const previousSources = previousEntry?.sources + const previousError = previousEntry?.error + return { + workItemsCache: withBoundedCacheEntry(s.workItemsCache, key, { + // Why: `reconciled` already is `previousEntry.data` when nothing changed — + // reconcileCatalogRows returns the previous array on a structural match. + data: reconciled, + fetchedAt: Date.now(), + sources: + sourcesUnchanged && previousSources !== undefined + ? previousSources + : envelope.sources, + ...(errorForCache + ? { + error: + errorUnchanged && previousError !== undefined ? previousError : errorForCache + } + : {}), + ...(nextFellBack ? { issueSourceFellBack: true } : {}) + }) + } + }) + return items + } catch (err) { + // Why: rethrow but keep the stale cache entry so the UI still renders while the user retries. + if (!isGitHubWorkItemsSshRemoteRequiredError(err)) { + console.error('Failed to fetch GitHub work items:', err) + } + throw err + } finally { + releaseWorkItemSlot() + inflightWorkItemsRequests.delete(inflightKey) + } + })() + + inflightWorkItemsRequests.set(inflightKey, { + promise: request, + force: Boolean(options?.force), + noCache: Boolean(options?.noCache), + requireComplete: Boolean(options?.requireComplete) + }) + return request + } +}) diff --git a/src/renderer/src/store/github/work-item-mutation-actions.ts b/src/renderer/src/store/github/work-item-mutation-actions.ts new file mode 100644 index 00000000000..e1d02a09d3b --- /dev/null +++ b/src/renderer/src/store/github/work-item-mutation-actions.ts @@ -0,0 +1,133 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GitHubSlice } from './slice-types' +import { toast } from 'sonner' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' +import { translate } from '@/i18n/i18n' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import type { CacheEntry } from './cache-model' +import { evictRepoCacheEntries, repoCacheKeyPrefixes } from './cache-identity' +import { ERROR_TOAST_DURATION } from './cache-policy' +import { clearInflightWorkItemsForRepo } from './request-coordination' + +export const createWorkItemMutationActions = ( + set: Parameters>[0], + get: Parameters>[1] +): Pick => ({ + patchWorkItem: (itemId, patch, repoId, options) => { + set((s) => { + const nextCache = { ...s.workItemsCache } + let changed = false + const sourceScope = + options?.sourceContext?.provider === 'github' + ? getTaskSourceCacheScope(options.sourceContext) + : null + for (const key of Object.keys(nextCache)) { + // Why: don't patch another host/account's visually identical issue/PR cache entry. + if (sourceScope && key !== sourceScope && !key.startsWith(`${sourceScope}::`)) { + continue + } + const entry = nextCache[key] + if (!entry?.data) { + continue + } + // Why: issue/PR ids are only unique within a repo; cross-repo views can share `pr:42`. + const idx = entry.data.findIndex( + (item) => item.id === itemId && (!repoId || item.repoId === repoId) + ) + if (idx === -1) { + continue + } + const updatedItems = [...entry.data] + updatedItems[idx] = { ...updatedItems[idx], ...patch } + nextCache[key] = { ...entry, data: updatedItems } + changed = true + } + return changed ? { workItemsCache: nextCache } : {} + }) + }, + + setIssueSourcePreference: async (repoId, repoPath, preference) => { + // Why: optimistically patch the local Repo so the segmented control updates this frame; resync via fetchRepos on IPC failure. + set((s) => ({ + repos: s.repos.map((r) => + r.id === repoId + ? { + ...r, + issueSourcePreference: preference === 'auto' ? undefined : preference + } + : r + ) + })) + try { + // Why: use the generic `repos:update` channel so a single write → single `repos:changed` broadcast re-fetches other windows. + // Why: map 'auto' to undefined so persistence drops the key entirely (see main/persistence.ts#updateRepo). + const updates = { issueSourcePreference: preference === 'auto' ? undefined : preference } + // Why: route to the repo's owner host (like updateRepo) so the write lands where the repo lives, not the focused runtime. + const target = getActiveRuntimeTarget(getSettingsForRepoRuntimeOwner(get(), repoId)) + await (target.kind === 'local' + ? window.api.repos.update({ repoId, updates }) + : callRuntimeRpc(target, 'repo.update', { repo: repoId, updates }, { timeoutMs: 15_000 })) + } catch (err) { + console.error('Failed to persist issue-source preference:', err) + // Why: without this toast the pill silently snaps back (optimistic patch + resync) and the user wouldn't know the write failed. + toast.error( + translate('auto.store.slices.github.d49ef4b944', 'Failed to save issue-source preference'), + { + duration: ERROR_TOAST_DURATION + } + ) + // Why: the optimistic patch may now disagree with disk; resync rather than leave a lie on screen. + void get().fetchRepos() + } + // Why: clear inflight dedupe BEFORE bumping the nonce so the re-triggered fetch can't collapse onto a pre-flip in-flight entry. + clearInflightWorkItemsForRepo(repoId, repoPath) + // Why: evict AFTER the await so an overlapping fetch can't repopulate with pre-flip data; also drops legacy path-scoped keys. + set((s) => { + const prefix = `${repoId}::` + const legacyPrefix = `${repoPath}::` + const next: Record> = {} + for (const [key, entry] of Object.entries(s.workItemsCache)) { + if (!key.startsWith(prefix) && !key.startsWith(legacyPrefix)) { + next[key] = entry + } + } + // Why: the Tasks fetch effect keys on the nonce, not the cache, so bump it to re-run and re-populate the evicted entries. + return { workItemsCache: next, workItemsInvalidationNonce: s.workItemsInvalidationNonce + 1 } + }) + }, + + evictGitHubRepoCaches: (repoId, repoPath) => { + clearInflightWorkItemsForRepo(repoId, repoPath) + set((s) => { + const prefixes = repoCacheKeyPrefixes(repoId, repoPath) + const workItems = evictRepoCacheEntries(s.workItemsCache, prefixes) + const prs = evictRepoCacheEntries(s.prCache, prefixes) + const issues = evictRepoCacheEntries(s.issueCache, prefixes) + const checks = evictRepoCacheEntries(s.checksCache, prefixes) + const comments = evictRepoCacheEntries(s.commentsCache, prefixes) + const updates: Partial = {} + + if (workItems.evicted) { + updates.workItemsCache = workItems.cache + updates.workItemsInvalidationNonce = s.workItemsInvalidationNonce + 1 + } + if (prs.evicted) { + updates.prCache = prs.cache + } + if (issues.evicted) { + updates.issueCache = issues.cache + } + if (checks.evicted) { + updates.checksCache = checks.cache + } + if (comments.evicted) { + updates.commentsCache = comments.cache + } + + return updates + }) + } +}) diff --git a/src/renderer/src/store/github/work-item-routing.ts b/src/renderer/src/store/github/work-item-routing.ts new file mode 100644 index 00000000000..b47ee0501d8 --- /dev/null +++ b/src/renderer/src/store/github/work-item-routing.ts @@ -0,0 +1,223 @@ +import type { AppState } from '../types' +import type { Repo } from '../../../../shared/repo-types' +import type { GitHubWorkItem, ListWorkItemsResult } from '../../../../shared/github/work-item-types' +import { + getTaskSourceCacheScope, + getTaskSourceRuntimeSettings, + type TaskSourceContext +} from '../../../../shared/task-source-context' +import { + getRepoExecutionHostId, + normalizeExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { classifyGitHubUnavailable } from '../../../../shared/github/api-availability' +import { callRuntimeRpc, RuntimeRpcCallError } from '../../runtime/runtime-rpc-client' +import { workItemsCacheKey } from './cache-identity' +import { + findRepoForGitHubOwner, + getGitHubFocusedRepoOwnerHostId, + getRuntimeRepoTarget +} from './repository-routing' + +export type GitHubWorkItemRequestContext = { + repoId: string + repoPath: string + target: GitHubWorkItemRequestTarget +} + +export type GitHubWorkItemRequestTarget = + | { kind: 'environment'; environmentId: string; runtimeRepoId: string } + | { kind: 'local' } + +export type GitHubWorkItemsListArgs = { + limit: number + query?: string + page?: number + noCache?: true +} + +export function settingsForGitHubRepoOwner( + settings: AppState['settings'], + repo: Pick | undefined +): AppState['settings'] { + if (!repo) { + return settings + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + return settings + ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } + : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) + } + // Why: local and SSH-owned GitHub lookups run on the desktop client; host focus must not redirect them to the selected runtime. + return settings + ? { ...settings, activeRuntimeEnvironmentId: null } + : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) +} + +export function settingsForGitHubFocusedRepoOwner( + settings: AppState['settings'], + repo: Pick | undefined +): AppState['settings'] { + if (!repo?.executionHostId && !repo?.connectionId) { + return settings + } + return settingsForGitHubRepoOwner(settings, repo) +} +export function getWorkItemsCacheKeyForOwner( + state: Partial>, + repoId: string, + limit: number, + query: string, + repoPath?: string +): string { + const repo = findRepoForGitHubOwner(state, repoId, repoPath ?? '') + return workItemsCacheKey( + repoId, + limit, + query, + repo ? getGitHubFocusedRepoOwnerHostId(state.settings ?? null, repo) : undefined + ) +} + +export function getGitHubWorkItemSourceHostId( + state: AppState, + repo: Pick | undefined, + sourceContext?: TaskSourceContext | null +): ExecutionHostId | undefined { + if (sourceContext?.provider === 'github') { + return sourceContext.hostId + } + return repo + ? (normalizeExecutionHostId(getGitHubFocusedRepoOwnerHostId(state.settings, repo)) ?? undefined) + : undefined +} + +export function getGitHubWorkItemSourceCacheScope( + state: AppState, + repo: Pick | undefined, + sourceContext?: TaskSourceContext | null +): string | undefined { + if (sourceContext?.provider === 'github') { + return getTaskSourceCacheScope(sourceContext) + } + return getGitHubWorkItemSourceHostId(state, repo, sourceContext) +} + +export function getGitHubWorkItemSourceSettings( + settings: AppState['settings'], + repo: Pick | undefined, + sourceContext?: TaskSourceContext | null +): AppState['settings'] { + if (sourceContext?.provider === 'github') { + return { + ...settings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as AppState['settings'] + } + return settingsForGitHubFocusedRepoOwner(settings, repo) +} + +export function getGitHubRepoSourceSettings( + settings: AppState['settings'], + repo: Pick | undefined, + sourceContext?: TaskSourceContext | null +): AppState['settings'] { + if (sourceContext?.provider === 'github') { + return { + ...settings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as AppState['settings'] + } + return settingsForGitHubRepoOwner(settings, repo) +} + +export function getGitHubWorkItemRequestContext( + state: AppState, + settings: AppState['settings'], + repoId: string, + repoPath: string, + sourceContext?: TaskSourceContext | null +): GitHubWorkItemRequestContext { + if (sourceContext?.provider === 'github') { + const parsedHost = parseExecutionHostId(sourceContext.hostId) + if (parsedHost?.kind === 'runtime') { + return { + repoId, + repoPath, + target: { + kind: 'environment', + environmentId: parsedHost.environmentId, + runtimeRepoId: sourceContext.repoId ?? repoId + } + } + } + } + const runtimeRepo = getRuntimeRepoTarget(state, repoPath, settings) + return { + repoId, + repoPath, + target: runtimeRepo + ? { + kind: 'environment', + environmentId: runtimeRepo.target.environmentId, + runtimeRepoId: runtimeRepo.repo.id + } + : { kind: 'local' } + } +} + +export function listGitHubWorkItemsForRepo( + context: GitHubWorkItemRequestContext, + args: GitHubWorkItemsListArgs +): Promise>> { + if (context.target.kind === 'environment') { + return callRuntimeRpc>>( + { kind: 'environment', environmentId: context.target.environmentId }, + 'github.listWorkItems', + { + repo: context.target.runtimeRepoId, + ...args + }, + { timeoutMs: 30_000 } + ) + } + return window.api.gh.listWorkItems({ + repoPath: context.repoPath, + repoId: context.repoId, + ...args + }) +} + +export function countGitHubWorkItemsForRepo( + context: GitHubWorkItemRequestContext, + args: { query?: string } +): Promise { + if (context.target.kind === 'environment') { + return callRuntimeRpc( + { kind: 'environment', environmentId: context.target.environmentId }, + 'github.countWorkItems', + { + repo: context.target.runtimeRepoId, + ...args + }, + { timeoutMs: 30_000 } + ) + } + return window.api.gh.countWorkItems({ + repoPath: context.repoPath, + repoId: context.repoId, + ...args + }) +} + +export function isGitHubUnavailableWorkItemsError(error: unknown): boolean { + // Why: only `runtime_error` came from the GitHub method; other RPC transport failures ("timed out"/"unavailable") must not be blamed on GitHub. + if (error instanceof RuntimeRpcCallError && error.code !== 'runtime_error') { + return false + } + const message = error instanceof Error ? error.message : String(error) + return classifyGitHubUnavailable(message) !== null +} diff --git a/src/renderer/src/store/github/worktree-refresh.ts b/src/renderer/src/store/github/worktree-refresh.ts new file mode 100644 index 00000000000..3865c4834b1 --- /dev/null +++ b/src/renderer/src/store/github/worktree-refresh.ts @@ -0,0 +1,280 @@ +import type { AppState } from '../types' +import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-request-refresh-types' +import type { PRInfo } from '../../../../shared/github/pull-request-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId +} from '../../../../shared/execution-host' +import { isMacAppDataPath } from '@/lib/passive-macos-app-data-access' +import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index' +import { prCacheKey } from './cache-identity' +import { settingsForGitHubRepoOwner } from './work-item-routing' +import { githubHostedReviewFallbackPRNumber } from './pr-result-routing' +import type { GitHubPRFallbackSource } from './cache-model' + +export function findWorktreeById(state: AppState, worktreeId: string): Worktree | null { + return getWorktreeLookupIndex(state).byId.get(worktreeId)?.first ?? null +} + +type WorktreeLookupEntry = { + first: Worktree + unique: Worktree | null +} + +export type WorktreeLookupIndex = { + byId: Map + repoHostIdsByRepoId: Map> +} + +const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {} +const EMPTY_WORKTREE_REPOS: AppState['repos'] = [] + +export function buildWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { + const byId = new Map() + for (const worktrees of Object.values(state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO)) { + for (const worktree of worktrees) { + const worktreeId = worktree.id + const existing = byId.get(worktreeId) + if (existing) { + existing.unique = null + } else { + byId.set(worktreeId, { first: worktree, unique: worktree }) + } + } + } + + const repoHostIdsByRepoId = new Map>() + for (const repo of state.repos ?? []) { + let hostIds = repoHostIdsByRepoId.get(repo.id) + if (!hostIds) { + hostIds = new Set() + repoHostIdsByRepoId.set(repo.id, hostIds) + } + hostIds.add(getRepoExecutionHostId(repo)) + } + return { byId, repoHostIdsByRepoId } +} + +// Why: worktree/owner updates replace these snapshots, while weak ownership avoids retaining superseded state. +const worktreeLookupIndexes = new WeakMap< + AppState['worktreesByRepo'], + { repos: AppState['repos']; index: WorktreeLookupIndex } +>() + +export function getWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { + const worktreesByRepo = state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO + const repos = state.repos ?? EMPTY_WORKTREE_REPOS + const cached = worktreeLookupIndexes.get(worktreesByRepo) + if (cached && cached.repos === repos) { + return cached.index + } + const index = buildWorktreeLookupIndex(state) + worktreeLookupIndexes.set(worktreesByRepo, { repos, index }) + return index +} + +export function findUniqueWorktreeById( + state: AppState, + worktreeId: string, + executionHostId?: string, + lookupIndex = getWorktreeLookupIndex(state) +): Worktree | null { + const match = lookupIndex.byId.get(worktreeId)?.unique ?? null + // Why: metadata persistence is keyed only by worktree id; an id owned by two hosts is non-unique so destructive clears fail closed. + if (!match || executionHostId === undefined) { + return match + } + const expectedHostId = normalizeExecutionHostId(executionHostId) ?? LOCAL_EXECUTION_HOST_ID + const explicitWorktreeHostId = normalizeExecutionHostId(match.hostId) + if (explicitWorktreeHostId) { + return explicitWorktreeHostId === expectedHostId ? match : null + } + const repoHostIds = lookupIndex.repoHostIdsByRepoId.get(match.repoId) + // Pre-host persisted rows are safe only when their repo has one unambiguous owner. + if (!repoHostIds || repoHostIds.size !== 1 || !repoHostIds.has(expectedHostId)) { + return null + } + return match +} + +export function isStaleExactLinkedPRLookup( + state: AppState, + worktreeId: string | undefined, + linkedPRNumber: number | null | undefined, + lookupIndex?: WorktreeLookupIndex +): boolean { + if (!worktreeId || linkedPRNumber == null) { + return false + } + const worktree = lookupIndex + ? (lookupIndex.byId.get(worktreeId)?.first ?? null) + : findWorktreeById(state, worktreeId) + return worktree?.linkedPR !== linkedPRNumber +} + +export function shouldClearDivergedLinkedMergedPR(args: { + pr: PRInfo | null + linkedPRNumber: number | null + requestHeadOid: string | null +}): boolean { + const { pr, linkedPRNumber, requestHeadOid } = args + return ( + linkedPRNumber != null && + requestHeadOid !== null && + pr?.number === linkedPRNumber && + pr.state === 'merged' && + // Head-scoped: clear only the worktree whose head diverged, so a PR-number-coalesced broadcast can't clear a sibling still on the PR's line of work. + pr.headDivergedFromMergedPRAtOid === requestHeadOid && + pr.headSha !== requestHeadOid && + pr.confirmedContainedHeadOid !== requestHeadOid + ) +} + +export function shouldApplyDivergedLinkedPRClear(args: { + worktree: Pick | undefined + linkedPRNumber: number + branch: string + requestHeadOid: string | null +}): boolean { + const { worktree, linkedPRNumber, branch, requestHeadOid } = args + return ( + Boolean(worktree) && + requestHeadOid !== null && + worktree?.linkedPR === linkedPRNumber && + worktree.branch.replace(/^refs\/heads\//, '') === branch && + worktree.head === requestHeadOid && + worktree.isBare !== true && + worktree.isArchived !== true + ) +} + +// Why: a linked PR is branch-scoped; it's stale once the worktree switched branches with neither push target nor HEAD at the PR head, else Checks stays pinned to the old branch's PR. +export function shouldClearBranchMismatchedLinkedOpenPR(args: { + pr: PRInfo | null + linkedPRNumber: number | null + branch: string + requestHeadOid: string | null + pushTargetBranch: string | null +}): boolean { + const { pr, linkedPRNumber, branch, requestHeadOid, pushTargetBranch } = args + const headRefName = pr?.headRefName?.trim() ?? '' + const currentBranch = branch.replace(/^refs\/heads\//, '').trim() + return ( + linkedPRNumber != null && + pr?.number === linkedPRNumber && + // Draft reviews are open PRs too; don't let their distinct renderer state leave a stale durable link wedged after a branch switch. + (pr.state === 'open' || pr.state === 'draft') && + requestHeadOid !== null && + headRefName !== '' && + currentBranch !== '' && + headRefName !== currentBranch && + (pushTargetBranch === null || pushTargetBranch !== headRefName) && + // A worktree parked on the PR's head commit is the same line of work (e.g. renamed local branch); keep the link. + !(pr.headSha != null && pr.headSha === requestHeadOid) + ) +} + +export function shouldApplyBranchMismatchedLinkedPRClear(args: { + worktree: Pick | undefined + linkedPRNumber: number + branch: string + requestHeadOid: string | null +}): boolean { + const { worktree, linkedPRNumber, branch, requestHeadOid } = args + return ( + Boolean(worktree) && + requestHeadOid !== null && + worktree?.linkedPR === linkedPRNumber && + // Branch-scoped: clear only while still on the branch the mismatch was computed against; a newer switch re-validates. + worktree.branch.replace(/^refs\/heads\//, '') === branch.replace(/^refs\/heads\//, '') && + worktree.head === requestHeadOid && + worktree.isBare !== true && + worktree.isArchived !== true + ) +} + +export function buildPRRefreshCandidate( + state: AppState, + worktree: Worktree, + repoPath?: string, + repoOverride?: Repo +): GitHubPRRefreshCandidate | null { + const repo = repoOverride ?? getGitHubRepoLookupIndex(state.repos).findById(worktree.repoId) + if (!repo) { + return null + } + if (isMacAppDataPath(repoPath ?? repo.path)) { + return null + } + const branch = worktree.branch.replace(/^refs\/heads\//, '') + const cacheKey = prCacheKey( + repoPath ?? repo.path, + repo.id, + branch, + settingsForGitHubRepoOwner(state.settings, repo), + repo.connectionId, + repo.executionHostId, + true + ) + const cachedPR = state.prCache[cacheKey]?.data ?? null + const hostedReviewFallbackPRNumber = githubHostedReviewFallbackPRNumber( + state, + repoPath ?? repo.path, + repo.id, + branch, + repo.connectionId, + repo.executionHostId, + true + ) + const cachedFallbackPRNumber = cachedPR?.number ?? null + // Why: a merged PR is a valid fallback only while the worktree sits on its head or a confirmed-contained commit — else the branch moved on. + const cachedMergedPRMovedPastHead = + worktree.linkedPR == null && + cachedPR?.state === 'merged' && + cachedPR.headSha !== worktree.head && + cachedPR.confirmedContainedHeadOid !== worktree.head + const fallbackPRNumber = + worktree.linkedPR == null && !cachedMergedPRMovedPastHead + ? (cachedFallbackPRNumber ?? hostedReviewFallbackPRNumber) + : null + const fallbackPRSource: GitHubPRFallbackSource | null = + worktree.linkedPR != null || fallbackPRNumber == null + ? null + : cachedFallbackPRNumber != null + ? 'pr-cache' + : 'hosted-review' + const sshStatus = repo.connectionId + ? state.sshConnectionStates.get(repo.connectionId)?.status + : null + return { + repoId: repo.id, + repoPath: repoPath ?? repo.path, + repoKind: repo.kind ?? 'git', + branch, + cacheKey, + worktreeId: worktree.id, + currentHeadOid: worktree.head ?? null, + // Why: persisted linked PR metadata is exact; PR cache numbers are only fallback hints after branch-lookup misses. + linkedPRNumber: worktree.linkedPR ?? null, + fallbackPRNumber, + fallbackPRSource, + isBare: worktree.isBare, + isArchived: worktree.isArchived, + connectionId: repo.connectionId ?? null, + executionHostId: repo.executionHostId ?? null, + connectionState: repo.connectionId + ? sshStatus === 'connected' + ? 'connected' + : 'disconnected' + : 'unknown', + cachedFetchedAt: state.prCache[cacheKey]?.fetchedAt ?? null, + cachedHasPR: cachedPR ? true : state.prCache[cacheKey] ? false : null, + cachedPRState: cachedPR?.state ?? null, + cachedChecksStatus: cachedPR?.checksStatus ?? null, + cachedMergeable: cachedPR?.mergeable ?? null, + cachedMergeStateStatus: cachedPR?.mergeStateStatus ?? null + } +} diff --git a/src/renderer/src/store/slices/github-branch-mismatched-linked-pr.test.ts b/src/renderer/src/store/slices/github-branch-mismatched-linked-pr.test.ts index 60a17379232..cbb722049d3 100644 --- a/src/renderer/src/store/slices/github-branch-mismatched-linked-pr.test.ts +++ b/src/renderer/src/store/slices/github-branch-mismatched-linked-pr.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { shouldClearBranchMismatchedLinkedOpenPR } from './github' +import { shouldClearBranchMismatchedLinkedOpenPR } from '../github/worktree-refresh' import type { PRInfo } from '../../../../shared/github/pull-request-types' import './github-slice-test-harness' diff --git a/src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts b/src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts index 9030ac4d404..8176bf64f6e 100644 --- a/src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts +++ b/src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { issueCacheKey, workItemsCacheKey } from './github' +import { issueCacheKey, workItemsCacheKey } from '../github/cache-identity' import { createTestStore, githubSourceContext, @@ -62,6 +62,58 @@ describe('createGitHubSlice.evictGitHubRepoCaches', () => { expect(state.workItemsInvalidationNonce).toBe(5) }) + it('preserves runtime and explicit-source-prefixed entries during repo eviction', () => { + const store = createTestStore() + const sourceContext = githubSourceContext('runtime:source-env', 'source-repo') + const runtimeKey = workItemsCacheKey('repo-1', 20, '', 'runtime:owner-env') + const sourceKey = workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(sourceContext)) + store.setState({ + workItemsInvalidationNonce: 4, + workItemsCache: { + [runtimeKey]: { data: [], fetchedAt: 1 }, + [sourceKey]: { data: [], fetchedAt: 1 } + } + }) + + store.getState().evictGitHubRepoCaches('repo-1', '/repo/one') + + expect(store.getState().workItemsCache).toEqual({ + [runtimeKey]: { data: [], fetchedAt: 1 }, + [sourceKey]: { data: [], fetchedAt: 1 } + }) + expect(store.getState().workItemsInvalidationNonce).toBe(4) + }) + + it('preference invalidation removes local keys but preserves prefixed host/source keys', async () => { + const store = createTestStore() + const sourceContext = githubSourceContext('runtime:source-env', 'source-repo') + const localKey = workItemsCacheKey('repo-1', 20, '') + const runtimeKey = workItemsCacheKey('repo-1', 20, '', 'runtime:owner-env') + const sourceKey = workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(sourceContext)) + store.setState({ + repos: [{ id: 'repo-1', path: '/repo/one', name: 'one', kind: 'git' }], + workItemsInvalidationNonce: 4, + workItemsCache: { + [localKey]: { data: [], fetchedAt: 1 }, + [runtimeKey]: { data: [], fetchedAt: 1 }, + [sourceKey]: { data: [], fetchedAt: 1 } + } + } as unknown as Partial) + mockApi.repos.update.mockResolvedValueOnce(undefined) + + await store.getState().setIssueSourcePreference('repo-1', '/repo/one', 'origin') + + expect(store.getState().workItemsCache).toEqual({ + [runtimeKey]: { data: [], fetchedAt: 1 }, + [sourceKey]: { data: [], fetchedAt: 1 } + }) + expect(store.getState().workItemsInvalidationNonce).toBe(5) + expect(mockApi.repos.update).toHaveBeenCalledWith({ + repoId: 'repo-1', + updates: { issueSourcePreference: 'origin' } + }) + }) + it('does not bump the work-item invalidation nonce when no work-item entries are evicted', () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/store/slices/github-checks-cache.test.ts b/src/renderer/src/store/slices/github-checks-cache.test.ts index 0808acba490..2b6c4c7f4dd 100644 --- a/src/renderer/src/store/slices/github-checks-cache.test.ts +++ b/src/renderer/src/store/slices/github-checks-cache.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' -import { createGitHubSlice, prChecksCacheSuffix } from './github' +import { createGitHubSlice } from './github' +import { prChecksCacheSuffix } from '../github/cache-identity' import { createHostedReviewSlice } from './hosted-review' import { getHostedReviewCacheKey } from './hosted-review-cache-identity' import type { AppState } from '../types' diff --git a/src/renderer/src/store/slices/github-issue-state-machine.test.ts b/src/renderer/src/store/slices/github-issue-state-machine.test.ts new file mode 100644 index 00000000000..4df798c799e --- /dev/null +++ b/src/renderer/src/store/slices/github-issue-state-machine.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { IssueInfo } from '../../../../shared/github/pull-request-types' +import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' +import { issueCacheKey } from '../github/cache-identity' +import { + createTestStore, + githubSourceContext, + mockApi, + resetRemoteRuntimeMocks +} from './github-slice-test-harness' + +describe('createGitHubSlice.fetchIssue state machine', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + resetRemoteRuntimeMocks() + mockApi.gh.issue.mockResolvedValue(null) + }) + + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) + + it('treats a fresh null as a valid hit even when force is requested', async () => { + const store = createTestStore() + const cacheKey = issueCacheKey('/repo/one', 'repo-1', 42) + store.setState({ + issueCache: { + [cacheKey]: { data: null, fetchedAt: Date.now() } + } + }) + + await expect( + store.getState().fetchIssue('/repo/one', 42, { repoId: 'repo-1', force: true }) + ).resolves.toBeNull() + expect(mockApi.gh.issue).not.toHaveBeenCalled() + }) + + it('deduplicates a forced caller onto the same in-flight request', async () => { + const store = createTestStore() + const pendingIssue = Promise.withResolvers() + const issue = { number: 42, title: 'Issue' } as IssueInfo + mockApi.gh.issue.mockReturnValueOnce(pendingIssue.promise) + + const first = store.getState().fetchIssue('/repo/one', 42, { repoId: 'repo-1' }) + const forced = store.getState().fetchIssue('/repo/one', 42, { repoId: 'repo-1', force: true }) + pendingIssue.resolve(issue) + + await expect(Promise.all([first, forced])).resolves.toEqual([issue, issue]) + await vi.advanceTimersByTimeAsync(1_000) + expect(mockApi.gh.issue).toHaveBeenCalledTimes(1) + }) + + it('persists a source-scoped five-minute null entry after a thrown fetch', async () => { + const store = createTestStore() + const sourceContext = githubSourceContext('local', 'source-repo') + const cacheKey = `${getTaskSourceCacheScope(sourceContext)}::repo-1::42` + mockApi.gh.issue.mockRejectedValueOnce(new Error('offline')) + + await expect( + store.getState().fetchIssue('/repo/one', 42, { repoId: 'repo-1', sourceContext }) + ).resolves.toBeNull() + + const failedEntry = store.getState().issueCache[cacheKey] + expect(failedEntry?.data).toBeNull() + expect(Date.now() - (failedEntry?.fetchedAt ?? 0)).toBeLessThan(300_000) + expect(mockApi.cache.setGitHub).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1_000) + expect(mockApi.cache.setGitHub).toHaveBeenCalledWith({ + cache: { + pr: store.getState().prCache, + issue: store.getState().issueCache + } + }) + + await expect( + store.getState().fetchIssue('/repo/one', 42, { repoId: 'repo-1', sourceContext, force: true }) + ).resolves.toBeNull() + expect(mockApi.gh.issue).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts b/src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts index 6024d2b9d39..63ca5ab4192 100644 --- a/src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { _clearGitHubPRRefreshStartedEntriesForTest } from './github' +import { _clearGitHubPRRefreshStartedEntriesForTest } from '../github/request-coordination' import { createTestStore, installLinkedPRClearStub, diff --git a/src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts b/src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts index a4871d2c20d..8bbde6cec19 100644 --- a/src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { _clearGitHubPRRefreshStartedEntriesForTest, _getGitHubPRRequestGenerationCountForTest -} from './github' +} from '../github/request-coordination' import { createTestStore, makePR, diff --git a/src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts b/src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts index f6df1a86778..ea32952a89e 100644 --- a/src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { _clearGitHubPRRefreshStartedEntriesForTest } from './github' +import { _clearGitHubPRRefreshStartedEntriesForTest } from '../github/request-coordination' import { createTestStore, makePR, diff --git a/src/renderer/src/store/slices/github-pr-branch-hosted-review-cache.test.ts b/src/renderer/src/store/slices/github-pr-branch-hosted-review-cache.test.ts index 2fb6a8b74e6..50850ac19dc 100644 --- a/src/renderer/src/store/slices/github-pr-branch-hosted-review-cache.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-hosted-review-cache.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { prChecksCacheSuffix } from '../github/cache-identity' import { _clearGitHubPRRefreshStartedEntriesForTest, - _getGitHubPRRefreshStartedEntryCountForTest, - prChecksCacheSuffix -} from './github' + _getGitHubPRRefreshStartedEntryCountForTest +} from '../github/request-coordination' import { createTestStore, makePR, diff --git a/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts b/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts index 2f0559d6409..418b6648fa0 100644 --- a/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { _clearGitHubPRRefreshStartedEntriesForTest } from './github' +import { _clearGitHubPRRefreshStartedEntriesForTest } from '../github/request-coordination' import { createTestStore, installLinkedPRClearStub, diff --git a/src/renderer/src/store/slices/github-pr-checks-fetch.test.ts b/src/renderer/src/store/slices/github-pr-checks-fetch.test.ts index a2b1147e48c..e1c40623901 100644 --- a/src/renderer/src/store/slices/github-pr-checks-fetch.test.ts +++ b/src/renderer/src/store/slices/github-pr-checks-fetch.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { prChecksCacheSuffix, prCommentsCacheSuffix } from './github' +import { prChecksCacheSuffix, prCommentsCacheSuffix } from '../github/cache-identity' import { createTestStore, githubSourceContext, diff --git a/src/renderer/src/store/slices/github-pr-comments.test.ts b/src/renderer/src/store/slices/github-pr-comments.test.ts index 87e74aeef10..8432f944fd4 100644 --- a/src/renderer/src/store/slices/github-pr-comments.test.ts +++ b/src/renderer/src/store/slices/github-pr-comments.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { mergePRCommentIntoList, prCommentsCacheSuffix } from './github' +import { prCommentsCacheSuffix } from '../github/cache-identity' +import { mergePRCommentIntoList } from '../github/pr-comment-cache' +import type { PRComment } from '../../../../shared/github/comment-types' import { createTestStore, githubSourceContext, @@ -184,6 +186,31 @@ describe('createGitHubSlice.fetchPRComments', () => { expect(mockApi.gh.prComments).not.toHaveBeenCalled() }) + it('deduplicates a forced comment fetch onto an existing non-forced request', async () => { + const store = createTestStore() + const pendingComments = Promise.withResolvers() + const comments: PRComment[] = [ + { + id: 1, + author: 'octocat', + authorAvatarUrl: '', + body: 'pending', + createdAt: '2026-08-23T00:00:00Z', + url: '' + } + ] + mockApi.gh.prComments.mockReturnValueOnce(pendingComments.promise) + + const first = store.getState().fetchPRComments('/repo/one', 12, { repoId: 'repo-1' }) + const forced = store + .getState() + .fetchPRComments('/repo/one', 12, { repoId: 'repo-1', force: true }) + + expect(mockApi.gh.prComments).toHaveBeenCalledTimes(1) + pendingComments.resolve(comments) + await expect(Promise.all([first, forced])).resolves.toEqual([comments, comments]) + }) + it('bounds PR comment cache entries across many repos', async () => { vi.useFakeTimers() diff --git a/src/renderer/src/store/slices/github-project-request-coordination.test.ts b/src/renderer/src/store/slices/github-project-request-coordination.test.ts new file mode 100644 index 00000000000..96e4632e909 --- /dev/null +++ b/src/renderer/src/store/slices/github-project-request-coordination.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitHubProjectTable } from '../../../../shared/github/project-types' +import type { GetProjectViewTableResult } from '../../../../shared/github/project-result-types' +import { projectViewCacheKey } from '../github/cache-identity' +import { createTestStore, mockApi, resetRemoteRuntimeMocks } from './github-slice-test-harness' + +function makeTable(title = 'Roadmap'): GitHubProjectTable { + return { + project: { + id: 'project-1', + owner: 'acme', + ownerType: 'organization', + number: 1, + title, + url: 'https://github.com/orgs/acme/projects/1' + }, + selectedView: { + id: 'view-1', + number: 1, + name: 'Table', + layout: 'TABLE_LAYOUT', + filter: '', + fields: [], + groupByFields: [], + sortByFields: [] + }, + rows: [], + totalCount: 0, + parentFieldDropped: false + } +} + +const request = { + owner: 'acme', + ownerType: 'organization' as const, + projectNumber: 1, + viewId: 'view-1' +} + +const cacheKey = projectViewCacheKey('organization', 'acme', 1, 'view-1') + +describe('createGitHubSlice.fetchProjectViewTable coordination', () => { + beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeMocks() + }) + + it('returns a fresh known-view cache entry without starting a request', async () => { + const store = createTestStore() + const table = makeTable() + store.setState({ projectViewCache: { [cacheKey]: { data: table, fetchedAt: Date.now() } } }) + + await expect(store.getState().fetchProjectViewTable(request)).resolves.toEqual({ + ok: true, + data: table + }) + expect(mockApi.gh.getProjectViewTable).not.toHaveBeenCalled() + }) + + it('deduplicates identical non-forced in-flight requests', async () => { + const store = createTestStore() + const pending = Promise.withResolvers() + mockApi.gh.getProjectViewTable.mockReturnValueOnce(pending.promise) + + const first = store.getState().fetchProjectViewTable(request) + const duplicate = store.getState().fetchProjectViewTable(request) + pending.resolve({ ok: true, data: makeTable() }) + + await expect(Promise.all([first, duplicate])).resolves.toEqual([ + { ok: true, data: makeTable() }, + { ok: true, data: makeTable() } + ]) + expect(mockApi.gh.getProjectViewTable).toHaveBeenCalledTimes(1) + }) + + it('lets each forced waiter start after a weaker request settles', async () => { + const store = createTestStore() + const weak = Promise.withResolvers() + mockApi.gh.getProjectViewTable + .mockReturnValueOnce(weak.promise) + .mockResolvedValue({ ok: true, data: makeTable('forced') }) + + const first = store.getState().fetchProjectViewTable(request) + const forcedOne = store.getState().fetchProjectViewTable(request, { force: true }) + const forcedTwo = store.getState().fetchProjectViewTable(request, { force: true }) + weak.resolve({ ok: true, data: makeTable('weak') }) + + await expect(Promise.all([first, forcedOne, forcedTwo])).resolves.toHaveLength(3) + expect(mockApi.gh.getProjectViewTable).toHaveBeenCalledTimes(3) + }) + + it('stamps a classified failure onto stale data only when the view key is known', async () => { + const store = createTestStore() + const stale = makeTable('stale') + const error = { type: 'network_error' as const, message: 'offline' } + store.setState({ projectViewCache: { [cacheKey]: { data: stale, fetchedAt: 1 } } }) + mockApi.gh.getProjectViewTable.mockResolvedValueOnce({ ok: false, error }) + + await expect(store.getState().fetchProjectViewTable(request, { force: true })).resolves.toEqual( + { + ok: false, + error + } + ) + expect(store.getState().projectViewCache[cacheKey]).toMatchObject({ data: stale, error }) + + mockApi.gh.getProjectViewTable.mockResolvedValueOnce({ ok: false, error }) + await expect( + store.getState().fetchProjectViewTable({ + owner: 'acme', + ownerType: 'organization', + projectNumber: 1 + }) + ).resolves.toEqual({ ok: false, error }) + expect(Object.keys(store.getState().projectViewCache)).toEqual([cacheKey]) + }) + it('wraps an unexpected transport throw without stamping an unknown selector', async () => { + const store = createTestStore() + mockApi.gh.getProjectViewTable.mockRejectedValueOnce(new Error('transport offline')) + + await expect( + store.getState().fetchProjectViewTable({ + owner: 'acme', + ownerType: 'organization', + projectNumber: 1 + }) + ).resolves.toEqual({ + ok: false, + error: { type: 'unknown', message: 'transport offline' } + }) + expect(store.getState().projectViewCache).toEqual({}) + }) +}) diff --git a/src/renderer/src/store/slices/github-project-view-tables.test.ts b/src/renderer/src/store/slices/github-project-view-tables.test.ts index 64bd72e91a0..faa589824c3 100644 --- a/src/renderer/src/store/slices/github-project-view-tables.test.ts +++ b/src/renderer/src/store/slices/github-project-view-tables.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { projectViewCacheKey } from './github' +import { projectViewCacheKey } from '../github/cache-identity' import { createTestStore, mockApi, @@ -406,6 +406,77 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }) }) + it('optimistically patches a project field and rolls back an ok:false result', async () => { + const store = createTestStore() + const cacheKey = projectViewCacheKey('organization', 'acme', 1, 'view-1') + store.setState({ + settings: { activeRuntimeEnvironmentId: null }, + projectViewCache: { + [cacheKey]: { + fetchedAt: 1, + data: { + project: { + id: 'project-1', + owner: 'acme', + ownerType: 'organization', + number: 1, + title: 'Roadmap', + url: 'https://github.com/orgs/acme/projects/1' + }, + selectedView: { + id: 'view-1', + number: 1, + name: 'Table', + layout: 'TABLE_LAYOUT', + filter: '', + fields: [{ id: 'field-1', name: 'Notes', dataType: 'TEXT', kind: 'text' }], + groupByFields: [], + sortByFields: [] + }, + rows: [ + { + id: 'row-1', + itemType: 'ISSUE', + content: { + repository: 'acme/repo', + number: 12, + title: 'Issue', + body: '', + url: 'https://github.com/acme/repo/issues/12', + state: 'OPEN', + labels: [], + assignees: [], + issueType: null, + parentIssue: null + }, + fieldValuesByFieldId: { + 'field-1': { kind: 'text', fieldId: 'field-1', text: 'before' } + } + } + ], + totalCount: 1, + parentFieldDropped: false + } + } + } + } as unknown as Partial) + const mutationResult = Promise.withResolvers<{ ok: false; error: string }>() + mockApi.gh.updateProjectItemField.mockReturnValueOnce(mutationResult.promise) + + const mutation = store + .getState() + .updateProjectFieldValue(cacheKey, 'row-1', 'field-1', { kind: 'text', text: 'after' }) + expect( + store.getState().projectViewCache[cacheKey]?.data?.rows[0]?.fieldValuesByFieldId['field-1'] + ).toEqual({ kind: 'text', fieldId: 'field-1', text: 'after' }) + + mutationResult.resolve({ ok: false, error: 'rejected' }) + await expect(mutation).resolves.toEqual({ ok: false, error: 'rejected' }) + expect( + store.getState().projectViewCache[cacheKey]?.data?.rows[0]?.fieldValuesByFieldId['field-1'] + ).toEqual({ kind: 'text', fieldId: 'field-1', text: 'before' }) + }) + it('bounds project view table cache entries across many projects', async () => { vi.useFakeTimers() diff --git a/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts b/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts new file mode 100644 index 00000000000..30717fca0d9 --- /dev/null +++ b/src/renderer/src/store/slices/github-provider-request-concurrency.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GetProjectViewTableResult } from '../../../../shared/github/project-result-types' +import type { ListWorkItemsResult } from '../../../../shared/github/work-item-types' +import { createTestStore, mockApi, resetRemoteRuntimeMocks } from './github-slice-test-harness' + +const emptyWorkItems: ListWorkItemsResult = { + items: [], + sources: { issues: null, prs: null, originCandidate: null, upstreamCandidate: null } +} + +describe('GitHub provider request concurrency', () => { + beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeMocks() + }) + + it('shares eight FIFO slots across work items and Projects and hands off after rejection', async () => { + const store = createTestStore() + const workRequests = Array.from({ length: 9 }, () => + Promise.withResolvers>() + ) + let workCall = 0 + mockApi.gh.listWorkItems.mockImplementation(() => workRequests[workCall++].promise) + const projectRequest = Promise.withResolvers() + mockApi.gh.getProjectViewTable.mockReturnValueOnce(projectRequest.promise) + + const activeWork = Array.from({ length: 8 }, (_, index) => + store + .getState() + .fetchWorkItems(`repo-${index}`, `/repo/${index}`, 20, '') + .catch(() => []) + ) + const queuedProject = store.getState().fetchProjectViewTable({ + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + viewId: 'view-1' + }) + const queuedWork = store + .getState() + .fetchWorkItems('repo-queued', '/repo/queued', 20, '') + .catch(() => []) + await Promise.resolve() + + expect(mockApi.gh.listWorkItems).toHaveBeenCalledTimes(8) + expect(mockApi.gh.getProjectViewTable).not.toHaveBeenCalled() + + workRequests[0].reject(new Error('first request failed')) + await activeWork[0] + await Promise.resolve() + expect(mockApi.gh.getProjectViewTable).toHaveBeenCalledTimes(1) + expect(mockApi.gh.listWorkItems).toHaveBeenCalledTimes(8) + + projectRequest.resolve({ + ok: false, + error: { type: 'network_error', message: 'project failed' } + }) + await queuedProject + await Promise.resolve() + expect(mockApi.gh.listWorkItems).toHaveBeenCalledTimes(9) + + for (const request of workRequests.slice(1)) { + request.resolve(emptyWorkItems) + } + await Promise.all([...activeWork.slice(1), queuedWork]) + }) +}) diff --git a/src/renderer/src/store/slices/github-review-thread-actions.test.ts b/src/renderer/src/store/slices/github-review-thread-actions.test.ts new file mode 100644 index 00000000000..41c12410c10 --- /dev/null +++ b/src/renderer/src/store/slices/github-review-thread-actions.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PRComment } from '../../../../shared/github/comment-types' +import type { AppState } from '../types' +import { prCommentsCacheSuffix, sourceScopedRepoCacheKey } from '../github/cache-identity' +import { + createTestStore, + mockApi, + resetRemoteRuntimeMocks, + runtimeEnvironmentCall +} from './github-slice-test-harness' + +const repoPath = '/repo' +const repoId = 'repo-id' +const threadId = 'thread-1' + +function makeComments(isResolved = false): PRComment[] { + return [ + { + id: 1, + author: 'octocat', + authorAvatarUrl: '', + body: 'review', + createdAt: '2026-08-23T00:00:00Z', + url: '', + threadId, + isResolved + } + ] +} + +function installLocalComments(store: ReturnType): string { + const cacheKey = sourceScopedRepoCacheKey(repoPath, repoId, prCommentsCacheSuffix(12)) + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + commentsCache: { [cacheKey]: { data: makeComments(), fetchedAt: 1 } } + } as unknown as Partial) + return cacheKey +} + +describe('createGitHubSlice.resolveReviewThread', () => { + beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeMocks() + }) + + it('optimistically resolves and routes through the repository runtime', async () => { + const store = createTestStore() + const settings = { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'] + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + prCommentsCacheSuffix(12), + settings, + null, + 'runtime:env-1', + undefined, + true + ) + store.setState({ + settings, + repos: [ + { + id: repoId, + path: repoPath, + name: 'repo', + kind: 'git', + executionHostId: 'runtime:env-1' + } + ], + commentsCache: { [cacheKey]: { data: makeComments(), fetchedAt: 1 } } + } as unknown as Partial) + const runtimeResult = Promise.withResolvers<{ + id: string + ok: true + result: boolean + _meta: { runtimeId: string } + }>() + runtimeEnvironmentCall.mockReturnValueOnce(runtimeResult.promise) + + const pending = store.getState().resolveReviewThread(repoPath, 12, threadId, true, { + repoId + }) + expect(store.getState().commentsCache[cacheKey]?.data?.[0].isResolved).toBe(true) + runtimeResult.resolve({ + id: 'rpc-thread', + ok: true, + result: true, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect(pending).resolves.toBe(true) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.resolveReviewThread', + params: { repo: repoId, threadId, resolve: true, prRepo: null }, + timeoutMs: 30_000 + }) + expect(mockApi.gh.resolveReviewThread).not.toHaveBeenCalled() + expect(store.getState().commentsCache[cacheKey]?.data?.[0].isResolved).toBe(true) + }) + + it('rolls back the captured comment array when GitHub returns false', async () => { + const store = createTestStore() + const cacheKey = installLocalComments(store) + const result = Promise.withResolvers() + mockApi.gh.resolveReviewThread.mockReturnValueOnce(result.promise) + + const pending = store.getState().resolveReviewThread(repoPath, 12, threadId, true, { repoId }) + expect(store.getState().commentsCache[cacheKey]?.data?.[0].isResolved).toBe(true) + result.resolve(false) + + await expect(pending).resolves.toBe(false) + expect(store.getState().commentsCache[cacheKey]?.data).toEqual(makeComments()) + }) + + it('swallows a thrown transport error and rolls back the captured comment array', async () => { + const store = createTestStore() + const cacheKey = installLocalComments(store) + mockApi.gh.resolveReviewThread.mockRejectedValueOnce(new Error('offline')) + + await expect( + store.getState().resolveReviewThread(repoPath, 12, threadId, true, { repoId }) + ).resolves.toBe(false) + expect(store.getState().commentsCache[cacheKey]?.data).toEqual(makeComments()) + }) +}) diff --git a/src/renderer/src/store/slices/github-slice-test-harness.ts b/src/renderer/src/store/slices/github-slice-test-harness.ts index f274d58d54c..ef7aede802e 100644 --- a/src/renderer/src/store/slices/github-slice-test-harness.ts +++ b/src/renderer/src/store/slices/github-slice-test-harness.ts @@ -63,6 +63,9 @@ export const mockApi = { updatePullRequestBySlug: stubMock(), updateIssueTypeBySlug: stubMock() }, + repos: { + update: stubMock().mockResolvedValue(undefined) + }, hostedReview: { forBranch: stubMock().mockResolvedValue(null), getCreationEligibility: stubMock(), diff --git a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts index cf8b529e9f5..ef22da072d1 100644 --- a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts +++ b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { workItemsCacheKey } from './github' +import { workItemsCacheKey } from '../github/cache-identity' import { createTestStore, githubSourceContext, diff --git a/src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts b/src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts index 6450112a8f1..5053883c11b 100644 --- a/src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts +++ b/src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { workItemsCacheKey } from './github' +import { workItemsCacheKey } from '../github/cache-identity' import { createTestStore, mockApi, diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 541b88db919..126c75710af 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -1,2087 +1,29 @@ -/* eslint-disable max-lines -- Why: the GitHub slice co-locates cache + fetch logic for PR/issue/checks/comments so dedup and invalidation patterns stay consistent. */ import type { StateCreator } from 'zustand' -import { toast } from 'sonner' import type { AppState } from '../types' -import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' -import { githubProjectIdentityKey } from '../../../../shared/github/project-identity' -import type { ClassifiedError } from '../../../../shared/classified-error' -import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' -import type { - GitHubCommentResult, - GitHubReactionContent, - PRComment -} from '../../../../shared/github/comment-types' -import type { - GitHubPRRefreshAlias, - GitHubPRRefreshCandidate, - GitHubPRRefreshEvent, - GitHubPRRefreshReason, - GitHubPRRefreshSkippedReason, - PRRefreshErrorType, - PRRefreshOutcome -} from '../../../../shared/github/pull-request-refresh-types' -import type { - GitHubOwnerRepo, - IssueInfo, - PRInfo -} from '../../../../shared/github/pull-request-types' -import type { GitHubWorkItem, ListWorkItemsResult } from '../../../../shared/github/work-item-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import type { IssueSourcePreference, Repo } from '../../../../shared/repo-types' -import type { Worktree } from '../../../../shared/worktree/types' -import type { - GitHubProjectFieldMutationValue, - GitHubProjectRow, - GitHubProjectTable -} from '../../../../shared/github/project-types' -import type { - GetProjectViewTableResult, - GitHubProjectMutationResult, - GitHubProjectViewError -} from '../../../../shared/github/project-result-types' -import type { GetProjectViewTableArgs } from '../../../../shared/github/project-request-types' +import type { GitHubSlice as GitHubSliceContract } from '../github/slice-types' import { - isGitHubWorkItemsSshRemoteRequiredError, - sortWorkItemsByNumber, - PER_REPO_FETCH_LIMIT -} from '../../../../shared/work-items' -import { deriveCheckStatusFromChecks, syncPRChecksStatus } from './github-checks' -import { - callRuntimeRpc, - getActiveRuntimeTarget, - RuntimeRpcCallError -} from '../../runtime/runtime-rpc-client' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' -import { settingsForProjectRowOwner } from './github-project-row-owner' -import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' -import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' -import { getHostedReviewCacheKey, linkedReviewHintKey } from './hosted-review-cache-identity' -import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from './github-cache-key' -import { - GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN, - isGitHubWorkItemsQueryTooLarge -} from './github-work-items-query-bounds' -import { classifyGitHubUnavailable } from '../../../../shared/github/api-availability' -import { isMacAppDataPath } from '@/lib/passive-macos-app-data-access' -import { translate } from '@/i18n/i18n' -import { - LOCAL_EXECUTION_HOST_ID, - getRepoExecutionHostId, - getSettingsFocusedExecutionHostId, - normalizeExecutionHostId, - parseExecutionHostId, - type ExecutionHostId -} from '../../../../shared/execution-host' -import { - getTaskSourceCacheScope, - getTaskSourceRuntimeSettings, - type TaskSourceContext -} from '../../../../shared/task-source-context' -import { normalizeGitHubPRForBranchOutcome } from '../../../../shared/github/pull-request-for-branch-outcome' -import { restoreReactionOnSubject, setReactionOnSubject } from '@/lib/pr-comment-reactions' -import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout' -import { getGitHubRepoLookupIndex } from './github-repo-lookup-index' -import { reconcileCatalogRows } from './repo-identity-reconcile' -import { structuralValuesEqual } from '../../../../shared/structural-value-equality' - -// ─── ProjectV2 cache types ──────────────────────────────────────────── -// Why: separate from CacheEntry — project-view has a single GraphQL source (no issue/PR fallback) and a distinct error union. -export type ProjectViewCacheEntry = { - data: T | null - fetchedAt: number - error?: GitHubProjectViewError -} - -export type ProjectRowContentUpdate = { - title?: string - body?: string - addLabels?: string[] - removeLabels?: string[] - addAssignees?: string[] - removeAssignees?: string[] -} - -export type GitHubPatchWorkItemOptions = { - sourceContext?: TaskSourceContext | null -} - -/** Optimistic, IPC-free patch shape for `projectViewCache` rows; uses full `labels`/`assignees` arrays (not add/remove deltas) to match the dialog's local state and avoid set-merge at the call site. */ -export type ProjectRowContentPatch = { - title?: string - body?: string - /** Lowercase renderer state vocab, translated to GitHub's UPPERCASE `row.content.state` on apply; merged/draft pass through though the dialog only flips open↔closed today. */ - state?: 'open' | 'closed' | 'merged' | 'draft' - labels?: string[] - assignees?: string[] -} - -// Why: queryOverride is part of the cache key; `undefined` = the view's stored filter, `''` = a distinct "no filter" override that gets its own entry. -function queryOverrideKeyPart(queryOverride: string | undefined): string { - if (queryOverride === undefined) { - return '' - } - return `:q=${queryOverride}` -} - -function getRuntimeRepoTarget( - state: AppState, - repoPath: string, - settings: AppState['settings'] = state.settings -): { target: { kind: 'environment'; environmentId: string }; repo: Repo } | null { - const target = getActiveRuntimeTarget(settings) - if (target.kind !== 'environment') { - return null - } - const repo = getGitHubRepoLookupIndex(state.repos).findByPath(repoPath) - return repo ? { target, repo } : null -} - -function getPRRefreshOwnerRuntimeEnvironmentId( - candidate: Pick -): string | null { - const parsed = parseExecutionHostId(candidate.executionHostId) - if (parsed?.kind === 'runtime') { - return parsed.environmentId - } - const cacheScope = candidate.cacheKey.split('::', 1)[0] - const cacheScopeHost = parseExecutionHostId(cacheScope) - return cacheScopeHost?.kind === 'runtime' ? cacheScopeHost.environmentId : null -} - -function getPRRefreshRuntimeRepoTarget( - state: AppState, - candidate: GitHubPRRefreshCandidate -): { target: { kind: 'environment'; environmentId: string }; repo: Repo } | null { - const ownerRuntimeEnvironmentId = getPRRefreshOwnerRuntimeEnvironmentId(candidate) - if (!ownerRuntimeEnvironmentId) { - return null - } - // Why: PR refreshes must follow the repo owner host, not the Active Server dropdown (a runtime-owned worktree can show while Local is focused). - return getRuntimeRepoTarget( - state, - candidate.repoPath, - state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId } - : ({ activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId } as AppState['settings']) - ) -} - -function shouldEnqueueLocalPRRefresh(candidate: GitHubPRRefreshCandidate): boolean { - // Why: the local coordinator owns local git + SSH-bridge refreshes; runtime-owned and disconnected-SSH repos must not hit the IPC crash path. - if (getPRRefreshOwnerRuntimeEnvironmentId(candidate) !== null) { - return false - } - return !candidate.connectionId || candidate.connectionState === 'connected' -} - -function enqueueLocalGitHubPRRefresh( - args: { - candidate: GitHubPRRefreshCandidate - reason: GitHubPRRefreshReason - priority: number - }, - onNotQueued?: () => void | Promise -): void { - const enqueue = window.api.gh.enqueuePRRefresh - if (!enqueue) { - return - } - // Why: renderer refresh triggers are best-effort — main may reject stale paths, and this must not become an unhandled-rejection crash. - void enqueue(args) - .then((queued) => - queued === false || queued?.kind === 'fallback' ? onNotQueued?.() : undefined - ) - .catch((err) => { - console.warn('Failed to enqueue PR refresh:', err) - }) -} - -type GitHubWorkItemRequestContext = { - repoId: string - repoPath: string - target: GitHubWorkItemRequestTarget -} - -type GitHubWorkItemRequestTarget = - | { kind: 'environment'; environmentId: string; runtimeRepoId: string } - | { kind: 'local' } - -type GitHubWorkItemsListArgs = { - limit: number - query?: string - page?: number - noCache?: true -} - -function settingsForGitHubRepoOwner( - settings: AppState['settings'], - repo: Pick | undefined -): AppState['settings'] { - if (!repo) { - return settings - } - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - if (parsed?.kind === 'runtime') { - return settings - ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } - : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) - } - // Why: local and SSH-owned GitHub lookups run on the desktop client; host focus must not redirect them to the selected runtime. - return settings - ? { ...settings, activeRuntimeEnvironmentId: null } - : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) -} - -function settingsForGitHubFocusedRepoOwner( - settings: AppState['settings'], - repo: Pick | undefined -): AppState['settings'] { - if (!repo?.executionHostId && !repo?.connectionId) { - return settings - } - return settingsForGitHubRepoOwner(settings, repo) -} - -function getRefreshAliasExecutionHostId(alias: GitHubPRRefreshAlias): string { - const explicitHostId = normalizeExecutionHostId(alias.executionHostId) - if (explicitHostId) { - return explicitHostId - } - const scope = alias.cacheKey.split('::', 1)[0] - return normalizeExecutionHostId(scope) ?? LOCAL_EXECUTION_HOST_ID -} - -function findRepoForGitHubOwner( - state: Partial>, - repoId: string | undefined, - repoPath: string -): Repo | undefined { - return state.repos - ? getGitHubRepoLookupIndex(state.repos).findByIdOrPath(repoId, repoPath) - : undefined -} - -function getGitHubFocusedRepoOwnerHostId( - settings: AppState['settings'], - repo: Pick | undefined -): string { - if (repo?.executionHostId || repo?.connectionId) { - return getRepoExecutionHostId(repo) - } - return getSettingsFocusedExecutionHostId(settings) -} - -function getWorkItemsCacheKeyForOwner( - state: Partial>, - repoId: string, - limit: number, - query: string, - repoPath?: string -): string { - const repo = findRepoForGitHubOwner(state, repoId, repoPath ?? '') - return workItemsCacheKey( - repoId, - limit, - query, - repo ? getGitHubFocusedRepoOwnerHostId(state.settings ?? null, repo) : undefined - ) -} - -function getGitHubWorkItemSourceHostId( - state: AppState, - repo: Pick | undefined, - sourceContext?: TaskSourceContext | null -): ExecutionHostId | undefined { - if (sourceContext?.provider === 'github') { - return sourceContext.hostId - } - return repo - ? (normalizeExecutionHostId(getGitHubFocusedRepoOwnerHostId(state.settings, repo)) ?? undefined) - : undefined -} - -function getGitHubWorkItemSourceCacheScope( - state: AppState, - repo: Pick | undefined, - sourceContext?: TaskSourceContext | null -): string | undefined { - if (sourceContext?.provider === 'github') { - return getTaskSourceCacheScope(sourceContext) - } - return getGitHubWorkItemSourceHostId(state, repo, sourceContext) -} - -function getGitHubWorkItemSourceSettings( - settings: AppState['settings'], - repo: Pick | undefined, - sourceContext?: TaskSourceContext | null -): AppState['settings'] { - if (sourceContext?.provider === 'github') { - return { - ...settings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as AppState['settings'] - } - return settingsForGitHubFocusedRepoOwner(settings, repo) -} - -function getGitHubRepoSourceSettings( - settings: AppState['settings'], - repo: Pick | undefined, - sourceContext?: TaskSourceContext | null -): AppState['settings'] { - if (sourceContext?.provider === 'github') { - return { - ...settings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as AppState['settings'] - } - return settingsForGitHubRepoOwner(settings, repo) -} - -function getGitHubWorkItemRequestContext( - state: AppState, - settings: AppState['settings'], - repoId: string, - repoPath: string, - sourceContext?: TaskSourceContext | null -): GitHubWorkItemRequestContext { - if (sourceContext?.provider === 'github') { - const parsedHost = parseExecutionHostId(sourceContext.hostId) - if (parsedHost?.kind === 'runtime') { - return { - repoId, - repoPath, - target: { - kind: 'environment', - environmentId: parsedHost.environmentId, - runtimeRepoId: sourceContext.repoId ?? repoId - } - } - } - } - const runtimeRepo = getRuntimeRepoTarget(state, repoPath, settings) - return { - repoId, - repoPath, - target: runtimeRepo - ? { - kind: 'environment', - environmentId: runtimeRepo.target.environmentId, - runtimeRepoId: runtimeRepo.repo.id - } - : { kind: 'local' } - } -} - -function listGitHubWorkItemsForRepo( - context: GitHubWorkItemRequestContext, - args: GitHubWorkItemsListArgs -): Promise>> { - if (context.target.kind === 'environment') { - return callRuntimeRpc>>( - { kind: 'environment', environmentId: context.target.environmentId }, - 'github.listWorkItems', - { - repo: context.target.runtimeRepoId, - ...args - }, - { timeoutMs: 30_000 } - ) - } - return window.api.gh.listWorkItems({ - repoPath: context.repoPath, - repoId: context.repoId, - ...args - }) -} - -function countGitHubWorkItemsForRepo( - context: GitHubWorkItemRequestContext, - args: { query?: string } -): Promise { - if (context.target.kind === 'environment') { - return callRuntimeRpc( - { kind: 'environment', environmentId: context.target.environmentId }, - 'github.countWorkItems', - { - repo: context.target.runtimeRepoId, - ...args - }, - { timeoutMs: 30_000 } - ) - } - return window.api.gh.countWorkItems({ - repoPath: context.repoPath, - repoId: context.repoId, - ...args - }) -} - -function isGitHubUnavailableWorkItemsError(error: unknown): boolean { - // Why: only `runtime_error` came from the GitHub method; other RPC transport failures ("timed out"/"unavailable") must not be blamed on GitHub. - if (error instanceof RuntimeRpcCallError && error.code !== 'runtime_error') { - return false - } - const message = error instanceof Error ? error.message : String(error) - return classifyGitHubUnavailable(message) !== null -} - -export function projectViewCacheKey( - ownerType: GetProjectViewTableArgs['ownerType'], - owner: string, - projectNumber: number, - resolvedViewId: string, - queryOverride?: string, - sourceScope = 'local', - host?: string -): string { - const projectKey = githubProjectIdentityKey({ ownerType, owner, number: projectNumber, host }) - return `github-project:${sourceScope}:${projectKey}:${resolvedViewId}${queryOverrideKeyPart(queryOverride)}` -} - -function projectViewRequestKey(args: GetProjectViewTableArgs, sourceScope: string): string { - // Why: without `viewId` the resolved cache key isn't known until the IPC returns, so dedup on the input-arg signature instead. - const selector = args.viewId - ? `id:${args.viewId}` - : args.viewNumber !== undefined - ? `num:${args.viewNumber}` - : args.viewName - ? `name:${args.viewName}` - : 'default' - const projectKey = githubProjectIdentityKey({ - ownerType: args.ownerType, - owner: args.owner, - number: args.projectNumber, - host: args.host - }) - return `${sourceScope}:${projectKey}:${selector}${queryOverrideKeyPart(args.queryOverride)}` -} - -function projectViewSourceScope(settings: AppState['settings']): string { - const target = getActiveRuntimeTarget(settings) - return target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local' -} - -function settingsForProjectViewCacheKey( - settings: AppState['settings'], - cacheKey: string -): Pick, 'activeRuntimeEnvironmentId'> { - const runtimeMatch = /^github-project:runtime:([^:]+):/.exec(cacheKey) - if (runtimeMatch) { - return { ...settings, activeRuntimeEnvironmentId: runtimeMatch[1] } - } - return { ...settings, activeRuntimeEnvironmentId: null } -} - -// Why: reuses the work-item concurrency gate since project-view and work-item fetches share the same gh subprocess budget; separate gates would blow the cap. -const inflightProjectViewRequests = new Map< - string, - { promise: Promise; force: boolean } ->() - -// Why: optimistic field value so the patched row re-renders immediately; best-effort, overwritten by the authoritative payload on next refresh. -function optimisticFieldValueFromMutation( - table: GitHubProjectTable, - fieldId: string, - value: GitHubProjectFieldMutationValue -): GitHubProjectTable['rows'][number]['fieldValuesByFieldId'][string] | null { - const field = table.selectedView.fields.find((f) => f.id === fieldId) - switch (value.kind) { - case 'single-select': { - if (field?.kind === 'single-select') { - const option = field.options.find((o) => o.id === value.optionId) - if (option) { - return { - kind: 'single-select', - fieldId, - optionId: option.id, - name: option.name, - color: option.color - } - } - } - return { - kind: 'single-select', - fieldId, - optionId: value.optionId, - name: '', - color: '' - } - } - case 'iteration': { - if (field?.kind === 'iteration') { - const iteration = field.iterations.find((i) => i.id === value.iterationId) - if (iteration) { - return { - kind: 'iteration', - fieldId, - iterationId: iteration.id, - title: iteration.title, - startDate: iteration.startDate, - duration: iteration.duration - } - } - } - return { - kind: 'iteration', - fieldId, - iterationId: value.iterationId, - title: '', - startDate: '', - duration: 0 - } - } - case 'text': - return { kind: 'text', fieldId, text: value.text } - case 'number': - return { kind: 'number', fieldId, number: value.number } - case 'date': - return { kind: 'date', fieldId, date: value.date } - } - return null -} - -function applyRowPatch( - set: (fn: (s: AppState) => Partial) => void, - cacheKey: string, - rowId: string, - nextRow: GitHubProjectRow -): void { - set((s) => { - const entry = s.projectViewCache[cacheKey] - if (!entry?.data) { - return {} - } - const rowIndex = entry.data.rows.findIndex((r) => r.id === rowId) - if (rowIndex === -1) { - return {} - } - const rows = [...entry.data.rows] - rows[rowIndex] = nextRow - return { - projectViewCache: { - ...s.projectViewCache, - [cacheKey]: { - ...entry, - data: { ...entry.data, rows } - } - } - } - }) -} - -function rollbackRowIfPresent( - set: (fn: (s: AppState) => Partial) => void, - get: () => AppState, - cacheKey: string, - rowId: string, - previousRow: GitHubProjectRow -): void { - // Why: skip rollback when the entry moved (rapid project switch) or the row is gone, else stale data would surface in the newly selected project. - const entry = get().projectViewCache[cacheKey] - if (!entry?.data) { - return - } - const stillPresent = entry.data.rows.some((r) => r.id === rowId) - if (!stillPresent) { - return - } - applyRowPatch(set, cacheKey, rowId, previousRow) -} - -function parseSlugAndNumber( - row: GitHubProjectRow -): { owner: string; repo: string; number: number } | null { - if (!row.content.repository || row.content.number == null) { - return null - } - const parts = row.content.repository.split('/') - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null - } - return { owner: parts[0], repo: parts[1], number: row.content.number } -} - -export type WorkItemsCacheSources = { - issues: GitHubOwnerRepo | null - prs: GitHubOwnerRepo | null - /** Raw origin remote (if any); required-nullable so the selector can distinguish it from the effective PR source. */ - originCandidate: GitHubOwnerRepo | null - /** Raw upstream remote (if any); required-nullable (like `issues`/`prs`) so consumers branch on null-vs-value, not a three-state. */ - upstreamCandidate: GitHubOwnerRepo | null -} - -// Why: stamp the slug on the error so banner copy stays correct even when the error outlives the entry's `sources` field. -export type WorkItemsCacheError = ClassifiedError & { source: GitHubOwnerRepo } - -export type CacheEntry = { - data: T | null - fetchedAt: number - headSha?: string - /** Resolved issue/PR owner/repo slugs; set only on `fetchWorkItems` entries (single-item PR/issue caches don't carry sources). */ - sources?: WorkItemsCacheSources - /** Per-side classified error; on partial success `data` keeps the good side and the failing side is recorded here so banner + list render together. */ - error?: WorkItemsCacheError - /** True when the resolver fell back to origin because the preferred `'upstream'` remote is gone; typed `?: true` (never `false`) to encode "present iff fell-back". */ - issueSourceFellBack?: true -} - -type FetchOptions = { - force?: boolean - noCache?: boolean - requireComplete?: boolean - allowStaleFallback?: boolean - sourceContext?: TaskSourceContext | null -} - -type RepoScopedFetchOptions = FetchOptions & { - repoId?: string -} - -export type PRRefreshState = { - status: 'queued' | 'in-flight' | 'paused' | 'skipped' | 'error' - reason: GitHubPRRefreshReason - updatedAt: number - pausedUntil?: number - message?: string - // Why: classified errors drive stable copy without exposing raw upstream messages. - errorType?: PRRefreshErrorType - skippedReason?: GitHubPRRefreshSkippedReason - nextAutoRetryAt?: number - retryDisabledUntil?: number -} - -export type PRRefreshStateClearToken = { - sequence: number - status: PRRefreshState['status'] - updatedAt: number -} - -const PR_REFRESH_ACTIVE_STALE_MS = 120_000 -const PR_REFRESH_PAUSED_GRACE_MS = 5_000 - -function bypassesGitHubPRRefreshFreshness(reason: GitHubPRRefreshReason): boolean { - return reason === 'manual' || reason === 'active' || reason === 'post-push' -} - -const CACHE_TTL = 300_000 // 5 minutes (stale data shown instantly, then refreshed) -const CHECKS_CACHE_TTL = 60_000 // 1 minute — checks change more frequently -const EMPTY_CHECKS_CACHE_TTL = 10_000 -// Why: the work-item list is a browse surface, not a source of truth, so 60s staleness is fine (SWR keeps it current). -const WORK_ITEMS_CACHE_TTL = 60_000 -// GitHub's Search API serves the page that starts within its first 1000 results; -// the next page 422s even when the final reachable page crosses the boundary. -const GITHUB_SEARCH_RESULT_WINDOW = 1000 -// Why: long-lived (matches repos.ts) so the user has time to read + act on persist failures before the toast vanishes. -const ERROR_TOAST_DURATION = 60_000 - -const inflightPRRequests = new Map< - string, - { promise: Promise; force: boolean; generation: number; lookupHintKey: string } ->() -const inflightIssueRequests = new Map>() -type InflightChecks = { - promise: Promise - force: boolean - noCache: boolean -} -const inflightChecksRequests = new Map() -const inflightCommentsRequests = new Map>() -type InflightWorkItems = { - promise: Promise - force: boolean - noCache: boolean - requireComplete: boolean -} -const inflightWorkItemsRequests = new Map() -const prRequestGenerations = new Map() -const prRefreshStartedHostedReviewEntries = new Map< - string, - AppState['hostedReviewCache'][string] | undefined ->() -const PR_REFRESH_STARTED_HOSTED_REVIEW_ENTRY_MAX = 128 - -/** @internal - exposed for leak-regression tests only */ -export function _getGitHubPRRequestGenerationCountForTest(): number { - return prRequestGenerations.size -} - -/** @internal - exposed for leak-regression tests only */ -export function _getGitHubPRRefreshStartedEntryCountForTest(): number { - return prRefreshStartedHostedReviewEntries.size -} - -/** @internal - exposed for leak-regression tests only */ -export function _clearGitHubPRRefreshStartedEntriesForTest(): void { - prRefreshStartedHostedReviewEntries.clear() -} - -// Why: cap fan-out at the renderer boundary (main-side gate is behind IPC, can't stop a stampede in time); 8 balances responsiveness vs gh rate limits. -const WORK_ITEM_FETCH_CONCURRENCY = 8 -let workItemFetchInFlight = 0 -const workItemFetchWaiters: (() => void)[] = [] - -async function acquireWorkItemSlot(): Promise { - if (workItemFetchInFlight < WORK_ITEM_FETCH_CONCURRENCY) { - workItemFetchInFlight += 1 - return - } - await new Promise((resolve) => workItemFetchWaiters.push(resolve)) - // Why: the resolver already claimed the slot on our behalf, so don't re-increment here. -} - -function releaseWorkItemSlot(): void { - const next = workItemFetchWaiters.shift() - if (next) { - // Hand the slot off directly (net count unchanged) so a third caller can't race into the cap between decrement and resolve. - next() - return - } - workItemFetchInFlight -= 1 -} - -export function workItemsCacheKey( - repoId: string, - limit: number, - query: string, - executionHostId?: string | null -): string { - const scope = executionHostId?.trim() ?? '' - const hostId = normalizeExecutionHostId(scope) - const owner = `${repoId}::${limit}::${query}` - if (hostId) { - return hostId !== LOCAL_EXECUTION_HOST_ID ? `${hostId}::${owner}` : owner - } - return scope ? `${scope}::${owner}` : owner -} - -function workItemsInflightRequestKey( - cacheKey: string, - target: GitHubWorkItemRequestTarget -): string { - const targetPart = - target.kind === 'environment' ? `env:${target.environmentId}:${target.runtimeRepoId}` : 'local' - return `${cacheKey}::${targetPart}` -} - -export function issueCacheKey( - repoPath: string, - repoId: string | undefined, - issueNumber: number | string, - settings?: Pick | null, - connectionId?: string | null, - executionHostId?: string | null, - hasRepoOwner = false -): string { - return getGitHubRepoCacheKey( - repoPath, - repoId, - String(issueNumber), - settings, - connectionId, - executionHostId, - hasRepoOwner - ) -} - -function runtimeScopedRepoCacheKey( - repoPath: string, - repoId: string | undefined, - suffix: string, - settings?: AppState['settings'], - connectionId?: string | null, - executionHostId?: string | null, - hasRepoOwner = false -): string { - return getGitHubRepoCacheKey( - repoPath, - repoId, - suffix, - settings, - connectionId, - executionHostId, - hasRepoOwner - ) -} - -function sourceScopedRepoCacheKey( - repoPath: string, - repoId: string | undefined, - suffix: string, - settings?: AppState['settings'], - connectionId?: string | null, - executionHostId?: string | null, - sourceContext?: TaskSourceContext | null, - hasRepoOwner = false -): string { - if (sourceContext?.provider === 'github') { - return `${getTaskSourceCacheScope(sourceContext)}::${repoId ?? repoPath}::${suffix}` - } - return runtimeScopedRepoCacheKey( - repoPath, - repoId, - suffix, - settings, - connectionId, - executionHostId, - hasRepoOwner - ) -} - -function prCacheKey( - repoPath: string, - repoId: string | undefined, - branch: string, - settings?: AppState['settings'], - connectionId?: string | null, - executionHostId?: string | null, - hasRepoOwner = false -): string { - return getGitHubPRCacheKey( - repoPath, - repoId, - branch, - settings, - connectionId, - executionHostId, - hasRepoOwner - ) -} - -function repoCacheKeyPrefixes(repoId: string, repoPath?: string): string[] { - const prefixes = [`${repoId}::`] - if (repoPath && repoPath !== repoId) { - prefixes.push(`${repoPath}::`) - } - return prefixes -} - -function matchesRepoCacheKey(key: string, prefixes: readonly string[]): boolean { - return prefixes.some((prefix) => key.startsWith(prefix)) -} - -function clearInflightWorkItemsForRepo(repoId: string, repoPath?: string): void { - const prefixes = repoCacheKeyPrefixes(repoId, repoPath) - for (const key of Array.from(inflightWorkItemsRequests.keys())) { - if (matchesRepoCacheKey(key, prefixes)) { - inflightWorkItemsRequests.delete(key) - } - } -} - -function evictRepoCacheEntries( - cache: Record>, - prefixes: readonly string[] -): { cache: Record>; evicted: boolean } { - let next: Record> | null = null - for (const key of Object.keys(cache)) { - if (!matchesRepoCacheKey(key, prefixes)) { - continue - } - if (!next) { - next = { ...cache } - } - delete next[key] - } - return next ? { cache: next, evicted: true } : { cache, evicted: false } -} - -function normalizedRepoIdentity(repo: GitHubOwnerRepo): string { - return githubRepoIdentityKey(repo) -} - -function normalizedHeadSha(headSha?: string): string | null { - const trimmed = headSha?.trim() - return trimmed ? trimmed.toLowerCase() : null -} - -export function prChecksCacheSuffix( - prNumber: number, - prRepo?: GitHubOwnerRepo | null, - headSha?: string -): string { - const headSuffix = normalizedHeadSha(headSha) - const base = prRepo - ? `pr-checks::${normalizedRepoIdentity(prRepo)}::${prNumber}` - : `pr-checks::${prNumber}` - return headSuffix ? `${base}::head::${headSuffix}` : base -} - -export function prCommentsCacheSuffix(prNumber: number, prRepo?: GitHubOwnerRepo | null): string { - if (!prRepo) { - return `pr-comments::${prNumber}` - } - return `pr-comments::${normalizedRepoIdentity(prRepo)}::${prNumber}` -} - -function commentTimestamp(comment: PRComment): number { - const timestamp = new Date(comment.createdAt).getTime() - return Number.isFinite(timestamp) ? timestamp : 0 -} - -export function mergePRCommentIntoList( - comments: readonly PRComment[] | null | undefined, - incoming: PRComment -): PRComment[] { - const byId = new Map() - for (const comment of comments ?? []) { - byId.set(comment.id, comment) - } - const previous = byId.get(incoming.id) - byId.set(incoming.id, { - ...previous, - ...incoming, - threadId: incoming.threadId ?? previous?.threadId, - path: incoming.path ?? previous?.path, - line: incoming.line ?? previous?.line, - startLine: incoming.startLine ?? previous?.startLine, - isResolved: incoming.isResolved ?? previous?.isResolved, - isOutdated: incoming.isOutdated ?? previous?.isOutdated - }) - return Array.from(byId.values()).sort((a, b) => commentTimestamp(a) - commentTimestamp(b)) -} - -function hasUsableCommentPayload(result: GitHubCommentResult): result is { - ok: true - comment: PRComment -} { - return ( - result.ok && - typeof result.comment?.id === 'number' && - Number.isSafeInteger(result.comment.id) && - result.comment.id > 0 && - typeof result.comment.body === 'string' && - typeof result.comment.createdAt === 'string' - ) -} - -// Why: bound cache growth across many repos/branches over a long session; 500 is above realistic use. -const MAX_CACHE_ENTRIES = 500 -type GitHubPRFallbackSource = NonNullable - -function isFresh(entry: CacheEntry | undefined, ttl = CACHE_TTL): entry is CacheEntry { - return entry !== undefined && Date.now() - entry.fetchedAt < ttl -} - -function getPRChecksCacheTtl(entry: CacheEntry | undefined): number { - return entry?.data?.length === 0 ? EMPTY_CHECKS_CACHE_TTL : CHECKS_CACHE_TTL -} - -function findWorktreeById(state: AppState, worktreeId: string): Worktree | null { - return getWorktreeLookupIndex(state).byId.get(worktreeId)?.first ?? null -} - -type WorktreeLookupEntry = { - first: Worktree - unique: Worktree | null -} - -type WorktreeLookupIndex = { - byId: Map - repoHostIdsByRepoId: Map> -} - -const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {} -const EMPTY_WORKTREE_REPOS: AppState['repos'] = [] - -function buildWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { - const byId = new Map() - for (const worktrees of Object.values(state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO)) { - for (const worktree of worktrees) { - const worktreeId = worktree.id - const existing = byId.get(worktreeId) - if (existing) { - existing.unique = null - } else { - byId.set(worktreeId, { first: worktree, unique: worktree }) - } - } - } - - const repoHostIdsByRepoId = new Map>() - for (const repo of state.repos ?? []) { - let hostIds = repoHostIdsByRepoId.get(repo.id) - if (!hostIds) { - hostIds = new Set() - repoHostIdsByRepoId.set(repo.id, hostIds) - } - hostIds.add(getRepoExecutionHostId(repo)) - } - return { byId, repoHostIdsByRepoId } -} - -// Why: worktree/owner updates replace these snapshots, while weak ownership avoids retaining superseded state. -const worktreeLookupIndexes = new WeakMap< - AppState['worktreesByRepo'], - { repos: AppState['repos']; index: WorktreeLookupIndex } ->() - -function getWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { - const worktreesByRepo = state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO - const repos = state.repos ?? EMPTY_WORKTREE_REPOS - const cached = worktreeLookupIndexes.get(worktreesByRepo) - if (cached && cached.repos === repos) { - return cached.index - } - const index = buildWorktreeLookupIndex(state) - worktreeLookupIndexes.set(worktreesByRepo, { repos, index }) - return index -} - -function findUniqueWorktreeById( - state: AppState, - worktreeId: string, - executionHostId?: string, - lookupIndex = getWorktreeLookupIndex(state) -): Worktree | null { - const match = lookupIndex.byId.get(worktreeId)?.unique ?? null - // Why: metadata persistence is keyed only by worktree id; an id owned by two hosts is non-unique so destructive clears fail closed. - if (!match || executionHostId === undefined) { - return match - } - const expectedHostId = normalizeExecutionHostId(executionHostId) ?? LOCAL_EXECUTION_HOST_ID - const explicitWorktreeHostId = normalizeExecutionHostId(match.hostId) - if (explicitWorktreeHostId) { - return explicitWorktreeHostId === expectedHostId ? match : null - } - const repoHostIds = lookupIndex.repoHostIdsByRepoId.get(match.repoId) - // Pre-host persisted rows are safe only when their repo has one unambiguous owner. - if (!repoHostIds || repoHostIds.size !== 1 || !repoHostIds.has(expectedHostId)) { - return null - } - return match -} - -function isStaleExactLinkedPRLookup( - state: AppState, - worktreeId: string | undefined, - linkedPRNumber: number | null | undefined, - lookupIndex?: WorktreeLookupIndex -): boolean { - if (!worktreeId || linkedPRNumber == null) { - return false - } - const worktree = lookupIndex - ? (lookupIndex.byId.get(worktreeId)?.first ?? null) - : findWorktreeById(state, worktreeId) - return worktree?.linkedPR !== linkedPRNumber -} - -function shouldClearDivergedLinkedMergedPR(args: { - pr: PRInfo | null - linkedPRNumber: number | null - requestHeadOid: string | null -}): boolean { - const { pr, linkedPRNumber, requestHeadOid } = args - return ( - linkedPRNumber != null && - requestHeadOid !== null && - pr?.number === linkedPRNumber && - pr.state === 'merged' && - // Head-scoped: clear only the worktree whose head diverged, so a PR-number-coalesced broadcast can't clear a sibling still on the PR's line of work. - pr.headDivergedFromMergedPRAtOid === requestHeadOid && - pr.headSha !== requestHeadOid && - pr.confirmedContainedHeadOid !== requestHeadOid - ) -} - -function shouldApplyDivergedLinkedPRClear(args: { - worktree: Pick | undefined - linkedPRNumber: number - branch: string - requestHeadOid: string | null -}): boolean { - const { worktree, linkedPRNumber, branch, requestHeadOid } = args - return ( - Boolean(worktree) && - requestHeadOid !== null && - worktree?.linkedPR === linkedPRNumber && - worktree.branch.replace(/^refs\/heads\//, '') === branch && - worktree.head === requestHeadOid && - worktree.isBare !== true && - worktree.isArchived !== true - ) -} - -// Why: a linked PR is branch-scoped; it's stale once the worktree switched branches with neither push target nor HEAD at the PR head, else Checks stays pinned to the old branch's PR. -export function shouldClearBranchMismatchedLinkedOpenPR(args: { - pr: PRInfo | null - linkedPRNumber: number | null - branch: string - requestHeadOid: string | null - pushTargetBranch: string | null -}): boolean { - const { pr, linkedPRNumber, branch, requestHeadOid, pushTargetBranch } = args - const headRefName = pr?.headRefName?.trim() ?? '' - const currentBranch = branch.replace(/^refs\/heads\//, '').trim() - return ( - linkedPRNumber != null && - pr?.number === linkedPRNumber && - // Draft reviews are open PRs too; don't let their distinct renderer state leave a stale durable link wedged after a branch switch. - (pr.state === 'open' || pr.state === 'draft') && - requestHeadOid !== null && - headRefName !== '' && - currentBranch !== '' && - headRefName !== currentBranch && - (pushTargetBranch === null || pushTargetBranch !== headRefName) && - // A worktree parked on the PR's head commit is the same line of work (e.g. renamed local branch); keep the link. - !(pr.headSha != null && pr.headSha === requestHeadOid) - ) -} - -function shouldApplyBranchMismatchedLinkedPRClear(args: { - worktree: Pick | undefined - linkedPRNumber: number - branch: string - requestHeadOid: string | null -}): boolean { - const { worktree, linkedPRNumber, branch, requestHeadOid } = args - return ( - Boolean(worktree) && - requestHeadOid !== null && - worktree?.linkedPR === linkedPRNumber && - // Branch-scoped: clear only while still on the branch the mismatch was computed against; a newer switch re-validates. - worktree.branch.replace(/^refs\/heads\//, '') === branch.replace(/^refs\/heads\//, '') && - worktree.head === requestHeadOid && - worktree.isBare !== true && - worktree.isArchived !== true - ) -} - -function buildPRRefreshCandidate( - state: AppState, - worktree: Worktree, - repoPath?: string, - repoOverride?: Repo -): GitHubPRRefreshCandidate | null { - const repo = repoOverride ?? getGitHubRepoLookupIndex(state.repos).findById(worktree.repoId) - if (!repo) { - return null - } - if (isMacAppDataPath(repoPath ?? repo.path)) { - return null - } - const branch = worktree.branch.replace(/^refs\/heads\//, '') - const cacheKey = prCacheKey( - repoPath ?? repo.path, - repo.id, - branch, - settingsForGitHubRepoOwner(state.settings, repo), - repo.connectionId, - repo.executionHostId, - true - ) - const cachedPR = state.prCache[cacheKey]?.data ?? null - const hostedReviewFallbackPRNumber = githubHostedReviewFallbackPRNumber( - state, - repoPath ?? repo.path, - repo.id, - branch, - repo.connectionId, - repo.executionHostId, - true - ) - const cachedFallbackPRNumber = cachedPR?.number ?? null - // Why: a merged PR is a valid fallback only while the worktree sits on its head or a confirmed-contained commit — else the branch moved on. - const cachedMergedPRMovedPastHead = - worktree.linkedPR == null && - cachedPR?.state === 'merged' && - cachedPR.headSha !== worktree.head && - cachedPR.confirmedContainedHeadOid !== worktree.head - const fallbackPRNumber = - worktree.linkedPR == null && !cachedMergedPRMovedPastHead - ? (cachedFallbackPRNumber ?? hostedReviewFallbackPRNumber) - : null - const fallbackPRSource: GitHubPRFallbackSource | null = - worktree.linkedPR != null || fallbackPRNumber == null - ? null - : cachedFallbackPRNumber != null - ? 'pr-cache' - : 'hosted-review' - const sshStatus = repo.connectionId - ? state.sshConnectionStates.get(repo.connectionId)?.status - : null - return { - repoId: repo.id, - repoPath: repoPath ?? repo.path, - repoKind: repo.kind ?? 'git', - branch, - cacheKey, - worktreeId: worktree.id, - currentHeadOid: worktree.head ?? null, - // Why: persisted linked PR metadata is exact; PR cache numbers are only fallback hints after branch-lookup misses. - linkedPRNumber: worktree.linkedPR ?? null, - fallbackPRNumber, - fallbackPRSource, - isBare: worktree.isBare, - isArchived: worktree.isArchived, - connectionId: repo.connectionId ?? null, - executionHostId: repo.executionHostId ?? null, - connectionState: repo.connectionId - ? sshStatus === 'connected' - ? 'connected' - : 'disconnected' - : 'unknown', - cachedFetchedAt: state.prCache[cacheKey]?.fetchedAt ?? null, - cachedHasPR: cachedPR ? true : state.prCache[cacheKey] ? false : null, - cachedPRState: cachedPR?.state ?? null, - cachedChecksStatus: cachedPR?.checksStatus ?? null, - cachedMergeable: cachedPR?.mergeable ?? null, - cachedMergeStateStatus: cachedPR?.mergeStateStatus ?? null - } -} - -function githubHostedReviewFallbackPRNumber( - state: AppState, - repoPath: string, - repoId: string | undefined, - branch: string, - connectionId?: string | null, - executionHostId?: string | null, - hasRepoOwner = false -): number | null { - const hostedReviewCacheKey = getHostedReviewCacheKey( - repoPath, - branch, - state.settings, - repoId, - connectionId, - executionHostId, - hasRepoOwner - ) - const hostedReview = state.hostedReviewCache[hostedReviewCacheKey]?.data - return hostedReview?.provider === 'github' ? hostedReview.number : null -} - -function shouldClearHostedReviewForNoGitHubPR( - entry: AppState['hostedReviewCache'][string] | undefined -): boolean { - // Why: a GitHub-only miss must not suppress GitLab/other hosted-review discovery via provider-neutral branch misses. - if (!entry) { - return false - } - if (entry.data?.provider === 'github') { - return true - } - return entry.data === null && isGitHubLinkedReviewHintKey(entry.linkedReviewHintKey) -} - -function isGitHubLinkedReviewHintKey(hintKey: string | undefined): boolean { - return hintKey?.split('|').some((key) => key.startsWith('github:')) ?? false -} - -function prLookupHintKey(linkedPRNumber: number | null, fallbackPRNumber: number | null): string { - if (linkedPRNumber !== null) { - return `linked:${linkedPRNumber}` - } - return fallbackPRNumber !== null ? `fallback:${fallbackPRNumber}` : '' -} - -function linkedReviewHintKeyForNoGitHubPR( - entry: AppState['hostedReviewCache'][string] | undefined -): string | undefined { - if (entry?.data?.provider === 'github') { - return isGitHubLinkedReviewHintKey(entry.linkedReviewHintKey) - ? entry.linkedReviewHintKey - : linkedReviewHintKey({ linkedGitHubPR: entry.data.number }) - } - return entry?.linkedReviewHintKey -} - -function hasNewerHostedReviewCacheEntry( - cache: AppState['hostedReviewCache'], - cacheKey: string, - requestStartedAt: number, - requestStartedEntry: AppState['hostedReviewCache'][string] | undefined -): boolean { - const entry = cache[cacheKey] - return ( - entry !== undefined && - (entry.fetchedAt > requestStartedAt || - (entry.fetchedAt === requestStartedAt && entry !== requestStartedEntry)) - ) -} - -function syncHostedReviewCacheFromGitHubPRResult(args: { - cache: AppState['hostedReviewCache'] - repoPath: string - branch: string - settings: AppState['settings'] - repoId?: string - connectionId?: string | null - executionHostId?: string | null - hasRepoOwner?: boolean - pr: PRInfo | null - fetchedAt: number - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - fallbackPRSource?: GitHubPRFallbackSource | null - preserveExistingPRForFallbackMiss?: boolean - requestStartedAt?: number - requestStartedEntry?: AppState['hostedReviewCache'][string] -}): { cache: AppState['hostedReviewCache']; accepted: boolean } { - const hostedReviewCacheKey = getHostedReviewCacheKey( - args.repoPath, - args.branch, - args.settings, - args.repoId, - args.connectionId, - args.executionHostId, - args.hasRepoOwner === true - ) - if ( - args.requestStartedAt !== undefined && - hasNewerHostedReviewCacheEntry( - args.cache, - hostedReviewCacheKey, - args.requestStartedAt, - args.requestStartedEntry - ) - ) { - return { cache: args.cache, accepted: false } - } - const hostedReviewEntry = args.cache[hostedReviewCacheKey] - if ( - args.requestStartedAt === undefined && - hostedReviewEntry !== undefined && - hostedReviewEntry.fetchedAt >= args.fetchedAt - ) { - return { cache: args.cache, accepted: false } - } - if (args.pr && hostedReviewEntry?.data && hostedReviewEntry.data.provider !== 'github') { - return { cache: args.cache, accepted: false } - } - // Why: a hosted-review row survives an authoritative miss only when the paired PR cache preserves a terminal, head-current PR. - if ( - !args.pr && - args.linkedPRNumber == null && - args.fallbackPRNumber != null && - args.fallbackPRSource !== 'hosted-review' && - hostedReviewEntry?.data?.provider === 'github' && - hostedReviewEntry.data.number === args.fallbackPRNumber && - args.preserveExistingPRForFallbackMiss === true && - canPreserveReviewForFallbackMiss(hostedReviewEntry.data.state) - ) { - return { cache: args.cache, accepted: false } - } - if (!args.pr && !shouldClearHostedReviewForNoGitHubPR(hostedReviewEntry)) { - return { cache: args.cache, accepted: hostedReviewEntry?.data == null } - } - // Why: hosted-review fallbacks may be stale exact links; inherit branch provenance only when already proven. - const branchLookupGitHubPRNumber = - args.pr && - args.linkedPRNumber == null && - (args.fallbackPRSource !== 'hosted-review' || - args.pr.number !== args.fallbackPRNumber || - hostedReviewEntry?.branchLookupGitHubPRNumber === args.pr.number) - ? args.pr.number - : undefined - return { - cache: { - ...args.cache, - [hostedReviewCacheKey]: { - data: args.pr ? hostedReviewInfoFromGitHubPRInfo(args.pr) : null, - fetchedAt: args.fetchedAt, - linkedReviewHintKey: args.pr - ? linkedReviewHintKey({ linkedGitHubPR: args.pr.number }) - : linkedReviewHintKeyForNoGitHubPR(hostedReviewEntry), - ...(branchLookupGitHubPRNumber !== undefined ? { branchLookupGitHubPRNumber } : {}) - } - }, - accepted: true - } -} - -function shouldWritePRCacheForHostedReviewSync(args: { - hostedReviewSyncAccepted: boolean - hostedReviewEntry: AppState['hostedReviewCache'][string] | undefined - pr: PRInfo | null - linkedPRNumber?: number | null - fallbackPRNumber?: number | null -}): boolean { - // Why: grouping reads prCache while cards read hostedReviewCache; keep them from drifting when a result is rejected for the card. - if (args.hostedReviewSyncAccepted) { - return true - } - const exactPRNumber = args.linkedPRNumber ?? args.fallbackPRNumber ?? null - return ( - exactPRNumber !== null && - args.pr?.number === exactPRNumber && - args.hostedReviewEntry?.data?.provider === 'github' && - args.hostedReviewEntry.data.number === exactPRNumber - ) -} - -function canPreserveReviewForFallbackMiss(state: PRInfo['state'] | undefined): boolean { - return state === 'closed' || state === 'merged' -} - -function shouldPreserveExistingPRForFallbackMiss(args: { - currentPR: PRInfo | null | undefined - nextPR: PRInfo | null - state: AppState - worktreeId?: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - fallbackPRSource?: GitHubPRFallbackSource | null -}): boolean { - if ( - args.nextPR !== null || - args.linkedPRNumber != null || - args.currentPR?.state !== 'merged' || - typeof args.currentPR.headSha !== 'string' || - args.currentPR.headSha.length === 0 - ) { - return false - } - // Why: gate the global worktree scan so batched refresh aliases don't multiply full scans (common paths don't need it). - const worktree = args.worktreeId ? findWorktreeById(args.state, args.worktreeId) : null - const worktreeHead = worktree?.head - // Why: keep a merged PR only when its cached head matches the worktree head — exactly or a confirmed-contained commit. - const preservesMergedPRForCurrentHead = - typeof worktreeHead === 'string' && - worktreeHead.length > 0 && - (args.currentPR.headSha === worktreeHead || - args.currentPR.confirmedContainedHeadOid === worktreeHead) - - return preservesMergedPRForCurrentHead -} - -function applyPRCacheResult( - cache: AppState['prCache'], - cacheKey: string, - pr: PRInfo | null, - fetchedAt: number, - accepted: boolean, - preserveExisting: boolean -): AppState['prCache'] { - if (preserveExisting) { - return cache - } - if (accepted) { - return withBoundedCacheEntry(cache, cacheKey, { data: pr, fetchedAt }) - } - if (!cache[cacheKey]) { - return cache - } - const next = { ...cache } - delete next[cacheKey] - return next -} - -function prRefreshStartedEntryKey(sequence: number, cacheKey: string): string { - return `${sequence}::${cacheKey}` -} - -function deletePRRefreshStartedEntry(sequence: number | undefined, cacheKey: string): void { - if (sequence !== undefined && sequence > 0) { - prRefreshStartedHostedReviewEntries.delete(prRefreshStartedEntryKey(sequence, cacheKey)) - } -} - -function setPRRefreshStartedHostedReviewEntry( - key: string, - entry: AppState['hostedReviewCache'][string] | undefined -): void { - if (entry === undefined) { - prRefreshStartedHostedReviewEntries.delete(key) - return - } - prRefreshStartedHostedReviewEntries.delete(key) - prRefreshStartedHostedReviewEntries.set(key, entry) - while (prRefreshStartedHostedReviewEntries.size > PR_REFRESH_STARTED_HOSTED_REVIEW_ENTRY_MAX) { - const oldest = prRefreshStartedHostedReviewEntries.keys().next() - if (oldest.done) { - return - } - prRefreshStartedHostedReviewEntries.delete(oldest.value) - } -} - -function setGitHubPRResultCaches( - state: AppState, - args: { - prCacheKey: string - repoPath: string - branch: string - settings: AppState['settings'] - repoId?: string - connectionId?: string | null - executionHostId?: string | null - hasRepoOwner?: boolean - pr: PRInfo | null - fetchedAt: number - worktreeId?: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - fallbackPRSource?: GitHubPRFallbackSource | null - requestStartedAt?: number - requestStartedEntry?: AppState['hostedReviewCache'][string] - } -): Partial { - const preserveExistingPRForFallbackMiss = shouldPreserveExistingPRForFallbackMiss({ - currentPR: state.prCache[args.prCacheKey]?.data, - nextPR: args.pr, - state, - worktreeId: args.worktreeId, - linkedPRNumber: args.linkedPRNumber, - fallbackPRNumber: args.fallbackPRNumber, - fallbackPRSource: args.fallbackPRSource - }) - const hostedReviewSync = syncHostedReviewCacheFromGitHubPRResult({ - cache: state.hostedReviewCache, - repoPath: args.repoPath, - branch: args.branch, - settings: args.settings, - repoId: args.repoId, - connectionId: args.connectionId, - executionHostId: args.executionHostId, - hasRepoOwner: args.hasRepoOwner, - pr: args.pr, - fetchedAt: args.fetchedAt, - linkedPRNumber: args.linkedPRNumber, - fallbackPRNumber: args.fallbackPRNumber, - fallbackPRSource: args.fallbackPRSource, - preserveExistingPRForFallbackMiss, - requestStartedAt: args.requestStartedAt, - requestStartedEntry: args.requestStartedEntry - }) - const hostedReviewCacheKey = getHostedReviewCacheKey( - args.repoPath, - args.branch, - args.settings, - args.repoId, - args.connectionId, - args.executionHostId, - args.hasRepoOwner === true - ) - const nextPRCache = applyPRCacheResult( - state.prCache, - args.prCacheKey, - args.pr, - args.fetchedAt, - shouldWritePRCacheForHostedReviewSync({ - hostedReviewSyncAccepted: hostedReviewSync.accepted, - hostedReviewEntry: state.hostedReviewCache[hostedReviewCacheKey], - pr: args.pr, - linkedPRNumber: args.linkedPRNumber, - fallbackPRNumber: args.fallbackPRNumber - }), - preserveExistingPRForFallbackMiss - ) - return { - ...(nextPRCache === state.prCache ? {} : { prCache: nextPRCache }), - ...(hostedReviewSync.cache === state.hostedReviewCache - ? {} - : { hostedReviewCache: hostedReviewSync.cache }) - } -} - -function applyGitHubPRResultToCaches(args: { - prCache: AppState['prCache'] - hostedReviewCache: AppState['hostedReviewCache'] - prCacheKey: string - repoPath: string - branch: string - settings: AppState['settings'] - repoId?: string - connectionId?: string | null - executionHostId?: string | null - hasRepoOwner?: boolean - pr: PRInfo | null - fetchedAt: number - state: AppState - worktreeId?: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - fallbackPRSource?: GitHubPRFallbackSource | null - requestStartedAt?: number - requestStartedEntry?: AppState['hostedReviewCache'][string] -}): { - prCache: AppState['prCache'] - hostedReviewCache: AppState['hostedReviewCache'] -} { - const preserveExistingPRForFallbackMiss = shouldPreserveExistingPRForFallbackMiss({ - currentPR: args.prCache[args.prCacheKey]?.data, - nextPR: args.pr, - state: args.state, - worktreeId: args.worktreeId, - linkedPRNumber: args.linkedPRNumber, - fallbackPRNumber: args.fallbackPRNumber, - fallbackPRSource: args.fallbackPRSource - }) - const hostedReviewSync = syncHostedReviewCacheFromGitHubPRResult({ - cache: args.hostedReviewCache, - repoPath: args.repoPath, - branch: args.branch, - settings: args.settings, - repoId: args.repoId, - connectionId: args.connectionId, - executionHostId: args.executionHostId, - hasRepoOwner: args.hasRepoOwner, - pr: args.pr, - fetchedAt: args.fetchedAt, - linkedPRNumber: args.linkedPRNumber, - fallbackPRNumber: args.fallbackPRNumber, - fallbackPRSource: args.fallbackPRSource, - preserveExistingPRForFallbackMiss, - requestStartedAt: args.requestStartedAt, - requestStartedEntry: args.requestStartedEntry - }) - const hostedReviewCacheKey = getHostedReviewCacheKey( - args.repoPath, - args.branch, - args.settings, - args.repoId, - args.connectionId, - args.executionHostId, - args.hasRepoOwner === true - ) - return { - prCache: applyPRCacheResult( - args.prCache, - args.prCacheKey, - args.pr, - args.fetchedAt, - shouldWritePRCacheForHostedReviewSync({ - hostedReviewSyncAccepted: hostedReviewSync.accepted, - hostedReviewEntry: args.hostedReviewCache[hostedReviewCacheKey], - pr: args.pr, - linkedPRNumber: args.linkedPRNumber, - fallbackPRNumber: args.fallbackPRNumber - }), - preserveExistingPRForFallbackMiss - ), - hostedReviewCache: hostedReviewSync.cache - } -} - -/** Evicts the oldest entries when over max size; returns a pruned copy, or the original reference if nothing was evicted. */ -function evictStaleEntries( - cache: Record, - maxEntries = MAX_CACHE_ENTRIES -): Record { - const keys = Object.keys(cache) - if (keys.length <= maxEntries) { - return cache - } - const sorted = keys - .map((k) => ({ key: k, fetchedAt: cache[k].fetchedAt })) - .sort((a, b) => b.fetchedAt - a.fetchedAt) - const keep = new Set(sorted.slice(0, maxEntries).map((e) => e.key)) - const pruned: Record = {} - for (const k of keep) { - pruned[k] = cache[k] - } - return pruned -} - -function withBoundedCacheEntry( - cache: Record, - key: string, - entry: T -): Record { - return evictStaleEntries({ ...cache, [key]: entry }) -} - -// Why: prRefresh* maps have no `fetchedAt` to sort by, so bound them by insertion order (oldest-touched evicted first; an evicted long-idle branch restarts clean). -function capRecordByInsertionOrder( - record: Record, - maxEntries = MAX_CACHE_ENTRIES -): Record { - const keys = Object.keys(record) - if (keys.length <= maxEntries) { - return record - } - const capped: Record = {} - for (const key of keys.slice(keys.length - maxEntries)) { - capped[key] = record[key] - } - return capped -} - -function capPrRefreshSequences( - sequences: Record, - maxEntries = MAX_CACHE_ENTRIES -): Record { - return capRecordByInsertionOrder(sequences, maxEntries) -} - -// Why: backs visible status pills, so evict *settled* (error/skipped) before active entries and never drop an in-progress indicator; evicted entries self-heal on next refresh. -const MAX_PR_REFRESH_STATE_ENTRIES = 2000 -const SETTLED_PR_REFRESH_STATUSES = new Set(['error', 'skipped']) -const ACTIVE_PR_REFRESH_STATUSES = new Set([ - 'queued', - 'in-flight', - 'paused' -]) - -function isPRRefreshStateExpired(state: PRRefreshState, now: number): boolean { - const expiryAt = getGitHubPRRefreshStateExpiryAt(state) - return expiryAt !== null && now > expiryAt -} - -/** Captures the exact refresh snapshot a later timeout or request is allowed to clear. */ -export function buildGitHubPRRefreshStateClearToken( - state: PRRefreshState | undefined, - sequences: Record, - cacheKey: string -): PRRefreshStateClearToken | null { - if (!state) { - return null - } - return { - sequence: sequences[cacheKey] ?? 0, - status: state.status, - updatedAt: state.updatedAt - } -} - -/** Returns the wall-clock expiry for transient refresh states; settled states persist. */ -export function getGitHubPRRefreshStateExpiryAt(state: PRRefreshState | undefined): number | null { - if (!state) { - return null - } - if (state.status === 'queued' || state.status === 'in-flight') { - return Number.isFinite(state.updatedAt) ? state.updatedAt + PR_REFRESH_ACTIVE_STALE_MS : 0 - } - if (state.status === 'paused') { - return Number.isFinite(state.pausedUntil) - ? (state.pausedUntil ?? 0) + PR_REFRESH_PAUSED_GRACE_MS - : 0 - } - return null -} - -function isExpiredActivePRRefreshState(state: PRRefreshState, now: number): boolean { - return ACTIVE_PR_REFRESH_STATUSES.has(state.status) && isPRRefreshStateExpired(state, now) -} - -/** Reads refresh state for UI selectors while hiding stale active entries from view. */ -export function getEffectiveGitHubPRRefreshState( - states: Record, - cacheKey: string, - now = Date.now() -): PRRefreshState | undefined { - const state = states[cacheKey] - if (!state || isExpiredActivePRRefreshState(state, now)) { - return undefined - } - return state -} - -function pruneExpiredPRRefreshStates( - states: Record, - now = Date.now() -): Record { - let next: Record | null = null - for (const [cacheKey, state] of Object.entries(states)) { - if (!isExpiredActivePRRefreshState(state, now)) { - continue - } - if (!next) { - next = { ...states } - } - delete next[cacheKey] - } - return next ?? states -} - -function capPrRefreshStates( - states: Record, - maxEntries = MAX_PR_REFRESH_STATE_ENTRIES -): Record { - const keys = Object.keys(states) - let toEvict = keys.length - maxEntries - if (toEvict <= 0) { - return states - } - const evicted = new Set() - // First pass: evict oldest settled (error/skipped) entries. - for (const key of keys) { - if (toEvict === 0) { - break - } - if (SETTLED_PR_REFRESH_STATUSES.has(states[key].status)) { - evicted.add(key) - toEvict-- - } - } - // Last resort: evict oldest remaining keys to enforce the hard bound. - for (const key of keys) { - if (toEvict === 0) { - break - } - if (!evicted.has(key)) { - evicted.add(key) - toEvict-- - } - } - const capped: Record = {} - for (const key of keys) { - if (!evicted.has(key)) { - capped[key] = states[key] - } - } - return capped -} - -function shouldRefreshIssueDecorations(state: AppState): boolean { - return (state.worktreeCardProperties ?? []).includes('issue') -} - -let saveTimer: ReturnType | null = null - -function debouncedSaveCache(state: AppState): void { - if (saveTimer) { - clearTimeout(saveTimer) - } - saveTimer = setTimeout(() => { - saveTimer = null - window.api.cache.setGitHub({ - cache: { - pr: state.prCache, - issue: state.issueCache - } - }) - }, 1000) // Save at most once per second -} - -export type GitHubSlice = { - prCache: Record> - issueCache: Record> - checksCache: Record> - commentsCache: Record> - prRefreshSequences: Record - prRefreshStates: Record - prVisibleRefreshGeneration: number - // Why: keyed by repoId + limit + query so same-path repos on different SSH targets don't share results. - workItemsCache: Record> - fetchPRForBranch: ( - repoPath: string, - branch: string, - options?: RepoScopedFetchOptions & { - worktreeId?: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - fallbackPRSource?: GitHubPRFallbackSource | null - } - ) => Promise - fetchIssue: ( - repoPath: string, - number: number, - options?: RepoScopedFetchOptions - ) => Promise - fetchPRChecks: ( - repoPath: string, - prNumber: number, - branch?: string, - headSha?: string, - prRepo?: GitHubOwnerRepo | null, - options?: RepoScopedFetchOptions - ) => Promise - fetchPRCheckDetails: ( - repoPath: string, - args: { - checkRunId?: number - workflowRunId?: number - checkName?: string - url?: string | null - prRepo?: GitHubOwnerRepo | null - }, - options?: RepoScopedFetchOptions - ) => Promise - fetchPRComments: ( - repoPath: string, - prNumber: number, - options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } - ) => Promise - addPRConversationComment: ( - repoPath: string, - prNumber: number, - body: string, - options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } - ) => Promise - addPRReviewCommentReply: ( - repoPath: string, - prNumber: number, - commentId: number, - body: string, - options?: RepoScopedFetchOptions & { - prRepo?: GitHubOwnerRepo | null - threadId?: string - path?: string - line?: number - } - ) => Promise - setPRCommentReaction: ( - repoPath: string, - prNumber: number, - reactionSubjectId: string, - content: GitHubReactionContent, - reacted: boolean, - options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } - ) => Promise - resolveReviewThread: ( - repoPath: string, - prNumber: number, - threadId: string, - resolve: boolean, - options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } - ) => Promise - initGitHubCache: () => Promise - refreshAllGitHub: () => void - refreshGitHubForWorktree: (worktreeId: string) => void - refreshGitHubForWorktreeIfStale: (worktreeId: string) => void - enqueueGitHubPRRefresh: ( - worktreeId: string, - reason: GitHubPRRefreshReason, - priority?: number - ) => void - reportVisibleGitHubPRRefreshCandidates: (worktreeIds: string[], generation: number) => void - bumpGitHubPRVisibleRefreshGeneration: () => void - applyGitHubPRRefreshEvent: (event: GitHubPRRefreshEvent) => void - getEffectiveGitHubPRRefreshState: (cacheKey: string, now?: number) => PRRefreshState | undefined - expireGitHubPRRefreshState: ( - cacheKey: string, - token: PRRefreshStateClearToken, - now?: number - ) => void - /** SWR: returns cached work items immediately (null if none) and fires a background refresh when stale. */ - getCachedWorkItems: ( - repoId: string, - limit: number, - query: string, - repoPath?: string, - sourceContext?: TaskSourceContext | null - ) => readonly GitHubWorkItem[] | null - /** Returns a thin view (sources + error, never items) so it stays a cheap selector without dragging the whole work-item array through the equality check. */ - getWorkItemsSourcesAndError: ( - repoId: string, - limit: number, - query: string, - repoPath?: string - ) => { sources: WorkItemsCacheSources | null; error: WorkItemsCacheError | null } - /** - * Falls back to any `${repoPath}::` cache entry with resolved sources when the primary entry isn't populated yet — sources are repo-level (query-independent), so any sibling is safe to reuse. - * Returns a single stable reference so the dialog can subscribe to just this selector; entries are replaced (not mutated) on write, preserving reference equality between unchanged entries. - */ - getWorkItemsAnySourcesForRepo: ( - repoId: string, - limit: number, - repoPath?: string - ) => WorkItemsCacheSources | null - fetchWorkItems: ( - repoId: string, - repoPath: string, - limit: number, - query: string, - options?: FetchOptions - ) => Promise - /** - * Fan out one work-item query across repos; partial failures don't reject — a repo with no cached fallback increments `failedCount`, but one served stale cache on rejection isn't counted. - * `githubUnavailable`: every selected GitHub source refresh failed because GitHub was unreachable (5xx/network/rate-limit), even if stale cache remains — lets the caller attribute the stale/empty list. - */ - fetchWorkItemsAcrossRepos: ( - repos: { - repoId: string - path: string - executionHostId?: string | null - sourceContext?: TaskSourceContext | null - }[], - perRepoLimit: number, - displayLimit: number, - query: string, - options?: FetchOptions - ) => Promise<{ - items: GitHubWorkItem[] - failedCount: number - githubUnavailable: boolean - requestFailureCount?: number - }> - /** Fetch one numbered provider page. Pagination pages remain renderer-local. */ - fetchWorkItemsNextPage: ( - repos: { - repoId: string - path: string - executionHostId?: string | null - sourceContext?: TaskSourceContext | null - }[], - perRepoLimit: number, - displayLimit: number, - query: string, - page: number, - options?: Pick - ) => Promise<{ - items: GitHubWorkItem[] - failedCount: number - errorTypes: ClassifiedError['type'][] - }> - /** Count items and derive pages from the largest per-repo result set. */ - countWorkItemsAcrossRepos: ( - repos: { - repoId: string - path: string - executionHostId?: string | null - sourceContext?: TaskSourceContext | null - }[], - query: string, - perRepoLimit: number - ) => Promise<{ totalCount: number; totalPages: number }> - /** Fire-and-forget prefetch to warm the cache before the page mounts (hover/focus of the "new workspace" buttons). */ - prefetchWorkItems: ( - repoId: string, - repoPath: string, - limit?: number, - query?: string, - options?: { sourceContext?: TaskSourceContext | null } - ) => void - patchWorkItem: ( - itemId: string, - patch: Partial, - repoId?: string | null, - options?: GitHubPatchWorkItemOptions - ) => void - /** Monotonic counter bumped on issue-source preference flips; subscribers include it in their deps to force a re-fetch, since cache eviction alone won't trip effects keyed on selectedRepos/search/nonce. */ - workItemsInvalidationNonce: number - /** Persist the preference, update the local Repo record, and invalidate all `${repoId}::*` cache keys — not just the primary — so alternate-query lines don't serve stale results after the source flips. */ - setIssueSourcePreference: ( - repoId: string, - repoPath: string, - preference: IssueSourcePreference - ) => Promise - evictGitHubRepoCaches: (repoId: string, repoPath?: string) => void - // ── ProjectV2 view cache ───────────────────────────────────────────── - projectViewCache: Record> - fetchProjectViewTable: ( - args: GetProjectViewTableArgs, - options?: FetchOptions - ) => Promise - updateProjectFieldValue: ( - cacheKey: string, - rowId: string, - fieldId: string, - value: GitHubProjectFieldMutationValue - ) => Promise - clearProjectFieldValue: ( - cacheKey: string, - rowId: string, - fieldId: string - ) => Promise - patchProjectIssueOrPr: ( - cacheKey: string, - rowId: string, - updates: ProjectRowContentUpdate - ) => Promise - patchProjectRowIssueType: ( - cacheKey: string, - rowId: string, - issueType: { id: string; name: string; color: string | null; description: string | null } | null - ) => Promise - /** Optimistic, IPC-free patcher for a single `projectViewCache` row's `content`; `patchWorkItem` only walks `workItemsCache` and would leave the Project view stale until the next refresh. */ - patchProjectRowContent: (cacheKey: string, rowId: string, patch: ProjectRowContentPatch) => void -} + ACTIVE_PR_REFRESH_STATUSES, + getEffectiveGitHubPRRefreshState, + isExpiredActivePRRefreshState +} from '../github/pr-refresh-state' +import { evictStaleEntries } from '../github/cache-policy' +import { createProjectActions } from '../github/project-actions' +import { createProjectRowActions } from '../github/project-row-actions' +import { createWorkItemFetchActions } from '../github/work-item-fetch-actions' +import { createWorkItemAggregateActions } from '../github/work-item-aggregate-actions' +import { createWorkItemMutationActions } from '../github/work-item-mutation-actions' +import { createPullRequestActions } from '../github/pull-request-actions' +import { createIssueActions } from '../github/issue-actions' +import { createCheckActions } from '../github/check-actions' +import { createCommentFetchActions } from '../github/comment-fetch-actions' +import { createCommentMutationActions } from '../github/comment-mutation-actions' +import { createConversationCommentActions } from '../github/conversation-comment-actions' +import { createReviewThreadActions } from '../github/review-thread-actions' +import { createStaleWorktreeRefreshActions } from '../github/stale-worktree-refresh-actions' +import { createRefreshRoutingActions } from '../github/refresh-routing-actions' +import { createRefreshEventActions } from '../github/refresh-event-actions' +import { createRefreshSweepActions } from '../github/refresh-sweep-actions' +export type GitHubSlice = GitHubSliceContract export const createGitHubSlice: StateCreator = (set, get) => ({ prCache: {}, @@ -2129,918 +71,22 @@ export const createGitHubSlice: StateCreator = (s }) }, - fetchProjectViewTable: async (args, options) => { - const target = getActiveRuntimeTarget(get().settings) - const sourceScope = projectViewSourceScope(get().settings) - const requestKey = projectViewRequestKey(args, sourceScope) - - // Fast path: a caller-supplied `viewId` gives the resolved cache key up front, so serve a fresh entry directly. - const maybeKnownKey = args.viewId - ? projectViewCacheKey( - args.ownerType, - args.owner, - args.projectNumber, - args.viewId, - args.queryOverride, - sourceScope, - args.host - ) - : null - if (!options?.force && maybeKnownKey) { - const cached = get().projectViewCache[maybeKnownKey] - if (cached?.data && Date.now() - cached.fetchedAt < WORK_ITEMS_CACHE_TTL) { - return { ok: true, data: cached.data } - } - } - - const existing = inflightProjectViewRequests.get(requestKey) - if (existing) { - // Why: a forcing caller must not dedupe to a non-forcing in-flight request; wait for it to settle, then issue a fresh forced call (mirrors fetchWorkItems). - if (options?.force && !existing.force) { - await existing.promise.catch(() => {}) - } else { - return existing.promise - } - } - - const request = (async (): Promise => { - await acquireWorkItemSlot() - try { - const envelope = - target.kind === 'environment' - ? await callRuntimeRpc( - target, - 'github.project.viewTable', - args, - { timeoutMs: 60_000 } - ) - : await window.api.gh.getProjectViewTable(args) - if (envelope.ok) { - const table = envelope.data - const key = projectViewCacheKey( - table.project.ownerType, - table.project.owner, - table.project.number, - table.selectedView.id, - args.queryOverride, - sourceScope, - table.project.host - ) - set((s) => ({ - projectViewCache: withBoundedCacheEntry(s.projectViewCache, key, { - data: table, - fetchedAt: Date.now() - }) - })) - } else if (maybeKnownKey) { - // Why: only stamp the error when we have a resolved key; without one there's nowhere to write it and the renderer classifies from the envelope. - set((s) => ({ - projectViewCache: withBoundedCacheEntry(s.projectViewCache, maybeKnownKey, { - data: s.projectViewCache[maybeKnownKey]?.data ?? null, - fetchedAt: Date.now(), - error: envelope.error - }) - })) - } - return envelope - } catch (err) { - // Why: the IPC boundary must not throw across the promise — wrap unexpected errors in the classified envelope for a single renderer shape. - console.error('Failed to fetch GitHub project view:', err) - return { - ok: false, - error: { - type: 'unknown', - message: err instanceof Error ? err.message : 'Failed to fetch project view' - } - } - } finally { - releaseWorkItemSlot() - inflightProjectViewRequests.delete(requestKey) - } - })() - - inflightProjectViewRequests.set(requestKey, { - promise: request, - force: Boolean(options?.force) - }) - return request - }, - - updateProjectFieldValue: async (cacheKey, rowId, fieldId, value) => { - const state = get() - const entry = state.projectViewCache[cacheKey] - const table = entry?.data - if (!table) { - return { - ok: false, - error: { - type: 'unknown', - message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded') - } - } - } - const rowIndex = table.rows.findIndex((r) => r.id === rowId) - if (rowIndex === -1) { - return { - ok: false, - error: { - type: 'unknown', - message: translate('auto.store.slices.github.f963485d37', 'Row not found') - } - } - } - const previousRow = table.rows[rowIndex] - // Optimistic patch: build a field value matching the mutation shape. - const nextField = optimisticFieldValueFromMutation(table, fieldId, value) - const optimisticFieldValues = { ...previousRow.fieldValuesByFieldId } - if (nextField) { - optimisticFieldValues[fieldId] = nextField - } - const optimisticRow: GitHubProjectRow = { - ...previousRow, - fieldValuesByFieldId: optimisticFieldValues - } - applyRowPatch(set, cacheKey, rowId, optimisticRow) - - const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey)) - const result = - target.kind === 'environment' - ? await callRuntimeRpc( - target, - 'github.project.updateItemField', - { - projectId: table.project.id, - host: table.project.host, - itemId: rowId, - fieldId, - value - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.updateProjectItemField({ - projectId: table.project.id, - host: table.project.host, - itemId: rowId, - fieldId, - value - }) - if (!result.ok) { - rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) - } - return result - }, - - clearProjectFieldValue: async (cacheKey, rowId, fieldId) => { - const state = get() - const entry = state.projectViewCache[cacheKey] - const table = entry?.data - if (!table) { - return { - ok: false, - error: { - type: 'unknown', - message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded') - } - } - } - const rowIndex = table.rows.findIndex((r) => r.id === rowId) - if (rowIndex === -1) { - return { - ok: false, - error: { - type: 'unknown', - message: translate('auto.store.slices.github.f963485d37', 'Row not found') - } - } - } - const previousRow = table.rows[rowIndex] - const optimisticFieldValues = { ...previousRow.fieldValuesByFieldId } - delete optimisticFieldValues[fieldId] - const optimisticRow: GitHubProjectRow = { - ...previousRow, - fieldValuesByFieldId: optimisticFieldValues - } - applyRowPatch(set, cacheKey, rowId, optimisticRow) - - const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey)) - const result = - target.kind === 'environment' - ? await callRuntimeRpc( - target, - 'github.project.clearItemField', - { - projectId: table.project.id, - host: table.project.host, - itemId: rowId, - fieldId - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.clearProjectItemField({ - projectId: table.project.id, - host: table.project.host, - itemId: rowId, - fieldId - }) - if (!result.ok) { - rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) - } - return result - }, - - patchProjectIssueOrPr: async (cacheKey, rowId, updates) => { - const state = get() - const entry = state.projectViewCache[cacheKey] - const table = entry?.data - if (!table) { - return { - ok: false, - error: { - type: 'unknown', - message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded') - } - } - } - const rowIndex = table.rows.findIndex((r) => r.id === rowId) - if (rowIndex === -1) { - return { - ok: false, - error: { - type: 'unknown', - message: translate('auto.store.slices.github.f963485d37', 'Row not found') - } - } - } - const previousRow = table.rows[rowIndex] - const { owner, repo, number } = parseSlugAndNumber(previousRow) ?? {} - if (!owner || !repo || !number) { - return { - ok: false, - error: { - type: 'validation_error', - message: translate( - 'auto.store.slices.github.87020f6605', - 'Row has no owner/repo/number — cannot patch underlying item' - ) - } - } - } - // Optimistic content patch. - const nextContent = { ...previousRow.content } - if (updates.title !== undefined) { - nextContent.title = updates.title - } - if (updates.body !== undefined) { - nextContent.body = updates.body - } - if (updates.addLabels || updates.removeLabels) { - const next = new Map(nextContent.labels.map((l) => [l.name, l])) - for (const name of updates.addLabels ?? []) { - if (!next.has(name)) { - next.set(name, { name, color: '808080' }) - } - } - for (const name of updates.removeLabels ?? []) { - next.delete(name) - } - nextContent.labels = Array.from(next.values()) - } - if (updates.addAssignees || updates.removeAssignees) { - const next = new Map(nextContent.assignees.map((u) => [u.login, u])) - for (const login of updates.addAssignees ?? []) { - if (!next.has(login)) { - next.set(login, { login, name: null, avatarUrl: null }) - } - } - for (const login of updates.removeAssignees ?? []) { - next.delete(login) - } - nextContent.assignees = Array.from(next.values()) - } - const optimisticRow: GitHubProjectRow = { ...previousRow, content: nextContent } - applyRowPatch(set, cacheKey, rowId, optimisticRow) - - // Why: labels/assignees go through the issue endpoint for both (GitHub PRs are issues for those); title/body split PR→updatePullRequestBySlug vs issue→updateIssueBySlug. - let envelope: GitHubProjectMutationResult = { ok: true } - // Why: slug-only Project rows have no registered Orca repo, so fall back to the view source in the cache key, not the focused host. - const target = getActiveRuntimeTarget( - settingsForProjectRowOwner( - get(), - owner, - repo, - table.project.host, - settingsForProjectViewCacheKey(get().settings, cacheKey) - ) - ) - if ( - previousRow.itemType === 'PULL_REQUEST' && - (updates.title !== undefined || updates.body !== undefined) - ) { - const args = { - owner, - repo, - host: table.project.host, - number, - updates: { - ...(updates.title !== undefined ? { title: updates.title } : {}), - ...(updates.body !== undefined ? { body: updates.body } : {}) - } - } - const prRes = - target.kind === 'environment' - ? await callRuntimeRpc( - target, - 'github.project.updatePullRequestBySlug', - args, - { timeoutMs: 30_000 } - ) - : await window.api.gh.updatePullRequestBySlug(args) - if (!prRes.ok) { - envelope = prRes - } - } - if ( - envelope.ok && - (updates.addLabels?.length || - updates.removeLabels?.length || - updates.addAssignees?.length || - updates.removeAssignees?.length || - (previousRow.itemType === 'ISSUE' && - (updates.title !== undefined || updates.body !== undefined))) - ) { - const args = { - owner, - repo, - host: table.project.host, - number, - updates: { - ...(updates.title !== undefined ? { title: updates.title } : {}), - ...(updates.body !== undefined ? { body: updates.body } : {}), - ...(updates.addLabels ? { addLabels: updates.addLabels } : {}), - ...(updates.removeLabels ? { removeLabels: updates.removeLabels } : {}), - ...(updates.addAssignees ? { addAssignees: updates.addAssignees } : {}), - ...(updates.removeAssignees ? { removeAssignees: updates.removeAssignees } : {}) - } - } - const issueRes = - target.kind === 'environment' - ? await callRuntimeRpc( - target, - 'github.project.updateIssueBySlug', - args, - { timeoutMs: 30_000 } - ) - : await window.api.gh.updateIssueBySlug(args) - if (!issueRes.ok) { - envelope = issueRes - } - } - if (!envelope.ok) { - rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) - } - return envelope - }, - - patchProjectRowIssueType: async (cacheKey, rowId, issueType) => { - const state = get() - const entry = state.projectViewCache[cacheKey] - const table = entry?.data - if (!table) { - return { - ok: false, - error: { - type: 'unknown', - message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded') - } - } - } - const row = table.rows.find((r) => r.id === rowId) - if (!row) { - return { - ok: false, - error: { - type: 'unknown', - message: translate('auto.store.slices.github.f963485d37', 'Row not found') - } - } - } - if (row.itemType !== 'ISSUE') { - return { - ok: false, - error: { - type: 'validation_error', - message: translate( - 'auto.store.slices.github.83f9b126ad', - 'Issue Type can only be set on Issues.' - ) - } - } - } - const { owner, repo, number } = parseSlugAndNumber(row) ?? {} - if (!owner || !repo || !number) { - return { - ok: false, - error: { - type: 'validation_error', - message: translate('auto.store.slices.github.683a21264b', 'Row has no owner/repo/number.') - } - } - } - const previousRow = row - const optimistic: GitHubProjectRow = { - ...previousRow, - content: { ...previousRow.content, issueType } - } - applyRowPatch(set, cacheKey, rowId, optimistic) - // Why: slug-only Project rows belong to the host that loaded the view, which may differ from the now-focused host. - const target = getActiveRuntimeTarget( - settingsForProjectRowOwner( - get(), - owner, - repo, - table.project.host, - settingsForProjectViewCacheKey(get().settings, cacheKey) - ) - ) - const args = { - owner, - repo, - host: table.project.host, - number, - issueTypeId: issueType?.id ?? null - } - const res = - target.kind === 'environment' - ? await callRuntimeRpc( - target, - 'github.project.updateIssueTypeBySlug', - args, - { timeoutMs: 30_000 } - ) - : await window.api.gh.updateIssueTypeBySlug(args) - if (!res.ok) { - rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow) - } - return res - }, - - patchProjectRowContent: (cacheKey, rowId, patch) => { - const state = get() - const entry = state.projectViewCache[cacheKey] - const table = entry?.data - if (!table) { - return - } - const previousRow = table.rows.find((r) => r.id === rowId) - if (!previousRow) { - return - } - const nextContent = { ...previousRow.content } - if (patch.title !== undefined) { - nextContent.title = patch.title - } - if (patch.body !== undefined) { - nextContent.body = patch.body - } - if (patch.state !== undefined) { - // Why: ProjectV2 row.state is GitHub's UPPERCASE enum ('OPEN'|'CLOSED'|'MERGED') but the dialog tracks lowercase; upper-case so the patch matches the canonical row shape. - nextContent.state = patch.state.toUpperCase() - } - if (patch.labels !== undefined) { - const existingByName = new Map(previousRow.content.labels.map((l) => [l.name, l])) - nextContent.labels = patch.labels.map( - (name) => existingByName.get(name) ?? { name, color: '808080' } - ) - } - if (patch.assignees !== undefined) { - const existingByLogin = new Map(previousRow.content.assignees.map((u) => [u.login, u])) - nextContent.assignees = patch.assignees.map( - (login) => existingByLogin.get(login) ?? { login, name: null, avatarUrl: null } - ) - } - const nextRow: GitHubProjectRow = { ...previousRow, content: nextContent } - applyRowPatch(set, cacheKey, rowId, nextRow) - }, - - getCachedWorkItems: (repoId, limit, query, repoPath, sourceContext) => { - if (isGitHubWorkItemsQueryTooLarge(query)) { - return null - } - const state = get() - const key = - sourceContext?.provider === 'github' - ? workItemsCacheKey(repoId, limit, query, getTaskSourceCacheScope(sourceContext)) - : getWorkItemsCacheKeyForOwner(state, repoId, limit, query, repoPath) - return get().workItemsCache[key]?.data ?? null - }, - - getWorkItemsSourcesAndError: (repoId, limit, query, repoPath) => { - if (isGitHubWorkItemsQueryTooLarge(query)) { - return { sources: null, error: null } - } - const key = getWorkItemsCacheKeyForOwner(get(), repoId, limit, query, repoPath) - const entry = get().workItemsCache[key] - return { - sources: entry?.sources ?? null, - error: entry?.error ?? null - } - }, - - getWorkItemsAnySourcesForRepo: (repoId, limit, repoPath) => { - const cache = get().workItemsCache - const primaryKey = getWorkItemsCacheKeyForOwner(get(), repoId, limit, '', repoPath) - const primary = cache[primaryKey]?.sources - if (primary) { - return primary - } - const prefix = primaryKey - for (const [key, entry] of Object.entries(cache)) { - if (key.startsWith(prefix) && entry.sources) { - return entry.sources - } - } - return null - }, - - fetchWorkItems: async ( - repoId, - repoPath, - limit, - query, - options - ): Promise => { - if (isGitHubWorkItemsQueryTooLarge(query)) { - return [] - } - const requestState = get() - const repo = findRepoForGitHubOwner(requestState, repoId, repoPath) - const requestSettings = getGitHubWorkItemSourceSettings( - requestState.settings, - repo, - options?.sourceContext - ) - const ownerHostId = getGitHubWorkItemSourceHostId(requestState, repo, options?.sourceContext) - const cacheScope = getGitHubWorkItemSourceCacheScope(requestState, repo, options?.sourceContext) - const key = workItemsCacheKey(repoId, limit, query, cacheScope) - const cached = get().workItemsCache[key] - if (!options?.force && isFresh(cached, WORK_ITEMS_CACHE_TTL)) { - return cached.data ?? [] - } - - const requestInvalidationNonce = requestState.workItemsInvalidationNonce - const requestContext = getGitHubWorkItemRequestContext( - requestState, - requestSettings, - repoId, - repoPath, - options?.sourceContext - ) - const inflightKey = workItemsInflightRequestKey(key, requestContext.target) - const existing = inflightWorkItemsRequests.get(inflightKey) - if (existing) { - // Why: a forcing/noCache caller must not dedupe to a weaker in-flight fetch (noCache is stricter — it must bypass gh api's cache too). - if ( - (options?.force && !existing.force) || - (options?.noCache && !existing.noCache) || - (options?.requireComplete && !existing.requireComplete) - ) { - await existing.promise.catch(() => {}) - } else { - return existing.promise - } - } - - const request = (async () => { - await acquireWorkItemSlot() - try { - const envelope = await listGitHubWorkItemsForRepo(requestContext, { - limit, - query: query || undefined, - ...(options?.noCache ? { noCache: true } : {}) - }) - // Why: stamp repoId at the fetch boundary so downstream consumers can rely on it — main doesn't know Orca's Repo.id. - const items: GitHubWorkItem[] = envelope.items.map((item) => ({ ...item, repoId })) - if (options?.requireComplete && (envelope.errors?.issues || envelope.errors?.prs)) { - throw new Error('GitHub work-item fetch returned a partial result.') - } - // Why: only surface issues-side errors here; PR-side failures predate the issue-source split (#1076) and are out of scope for this banner (design doc §2). - const issuesError = envelope.errors?.issues - // Why: errors.issues without sources.issues has no slug for the banner, so it's dropped from the cache; log it so this rare case is visible in devtools. - if (issuesError && !envelope.sources.issues) { - console.warn( - '[workItems] dropping issues-side error with no resolved source:', - issuesError - ) - } - const errorForCache: WorkItemsCacheError | undefined = - issuesError && envelope.sources.issues - ? { ...issuesError, source: envelope.sources.issues } - : undefined - const currentRepo = findRepoForGitHubOwner(get(), repoId, repoPath) - const currentHostId = getGitHubWorkItemSourceHostId( - get(), - currentRepo, - options?.sourceContext - ) - // Why: repo ownership changed, so this response belongs to an older execution-host bucket (host focus changes alone are fine). - if ((currentHostId ?? null) !== (ownerHostId ?? null)) { - return items - } - // Why: the old promise can still settle after the in-flight clear; don't let pre-flip source data repopulate the cache once the invalidation nonce changed. - if (get().workItemsInvalidationNonce !== requestInvalidationNonce) { - return items - } - // Why: TaskPage useShallow-selects cache entry refs. A new { ...entry, fetchedAt } - // still remaps every visible row. IPC structuredClone rebuilds nested records, so - // data === previous.data never holds — reconcile structurally, then either mutate - // fetchedAt in place or write one entry that keeps unchanged row/meta refs. - set((s) => { - const previousEntry = s.workItemsCache[key] - const previousData = previousEntry?.data ?? [] - const reconciled = reconcileCatalogRows( - previousData, - items, - (row) => `${row.repoId}\0${row.id}` - ) - const nextFellBack = envelope.issueSourceFellBack ? true : undefined - const sourcesUnchanged = structuralValuesEqual(previousEntry?.sources, envelope.sources) - const errorUnchanged = structuralValuesEqual(previousEntry?.error, errorForCache) - const fellBackUnchanged = previousEntry?.issueSourceFellBack === nextFellBack - if ( - previousEntry && - reconciled === previousData && - sourcesUnchanged && - errorUnchanged && - fellBackUnchanged - ) { - previousEntry.fetchedAt = Date.now() - return {} - } - const previousSources = previousEntry?.sources - const previousError = previousEntry?.error - return { - workItemsCache: withBoundedCacheEntry(s.workItemsCache, key, { - // Why: `reconciled` already is `previousEntry.data` when nothing changed — - // reconcileCatalogRows returns the previous array on a structural match. - data: reconciled, - fetchedAt: Date.now(), - sources: - sourcesUnchanged && previousSources !== undefined - ? previousSources - : envelope.sources, - ...(errorForCache - ? { - error: - errorUnchanged && previousError !== undefined ? previousError : errorForCache - } - : {}), - ...(nextFellBack ? { issueSourceFellBack: true } : {}) - }) - } - }) - return items - } catch (err) { - // Why: rethrow but keep the stale cache entry so the UI still renders while the user retries. - if (!isGitHubWorkItemsSshRemoteRequiredError(err)) { - console.error('Failed to fetch GitHub work items:', err) - } - throw err - } finally { - releaseWorkItemSlot() - inflightWorkItemsRequests.delete(inflightKey) - } - })() - - inflightWorkItemsRequests.set(inflightKey, { - promise: request, - force: Boolean(options?.force), - noCache: Boolean(options?.noCache), - requireComplete: Boolean(options?.requireComplete) - }) - return request - }, - - fetchWorkItemsAcrossRepos: async (repos, perRepoLimit, displayLimit, query, options) => { - if (isGitHubWorkItemsQueryTooLarge(query)) { - return { items: [], failedCount: 0, githubUnavailable: false } - } - const state = get() - let failedCount = 0 - let requestFailureCount = 0 - let unavailableFailureCount = 0 - let skippedSourceCount = 0 - const perProjectResults = await Promise.all( - repos.map(async (r) => { - try { - return await state.fetchWorkItems(r.repoId, r.path, perRepoLimit, query, { - ...options, - sourceContext: r.sourceContext ?? options?.sourceContext - }) - } catch (err) { - // Why: fall back to any cache entry (stale or not) before declaring this repo failed; only count as failed when it has nothing to contribute. - // Why: use perRepoLimit (not displayLimit) so the cache key matches what fetchWorkItems wrote. - if (isGitHubWorkItemsSshRemoteRequiredError(err)) { - if (options?.requireComplete) { - requestFailureCount += 1 - failedCount += 1 - } - skippedSourceCount += 1 - return [] as GitHubWorkItem[] - } - requestFailureCount += 1 - if (isGitHubUnavailableWorkItemsError(err)) { - unavailableFailureCount += 1 - } - const key = - r.sourceContext?.provider === 'github' - ? workItemsCacheKey( - r.repoId, - perRepoLimit, - query, - getTaskSourceCacheScope(r.sourceContext) - ) - : getWorkItemsCacheKeyForOwner(get(), r.repoId, perRepoLimit, query, r.path) - const cached = get().workItemsCache[key]?.data - if (cached && options?.allowStaleFallback !== false) { - console.warn(`[workItems] ${r.repoId} failed, serving cached:`, err) - return cached - } - console.warn(`[workItems] ${r.repoId} failed:`, err) - failedCount += 1 - return [] as GitHubWorkItem[] - } - }) - ) - const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit) - // Why: only claim global unavailability when every eligible source failed for a reachability reason; skipped SSH repos aren't GitHub sources here. - const githubUnavailable = - requestFailureCount > 0 && - requestFailureCount === repos.length - skippedSourceCount && - unavailableFailureCount === requestFailureCount - return { - items: merged, - failedCount, - githubUnavailable, - ...(requestFailureCount > 0 ? { requestFailureCount } : {}) - } - }, - - fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, page, options) => { - if (isGitHubWorkItemsQueryTooLarge(query)) { - return { items: [], failedCount: 0, errorTypes: [] } - } - let failedCount = 0 - const errorTypes: ClassifiedError['type'][] = [] - const perProjectResults = await Promise.all( - repos.map(async (r) => { - const requestState = get() - const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) - const requestSettings = getGitHubWorkItemSourceSettings( - requestState.settings, - repo, - r.sourceContext - ) - const requestContext = getGitHubWorkItemRequestContext( - requestState, - requestSettings, - r.repoId, - r.path, - r.sourceContext - ) - await acquireWorkItemSlot() - try { - const envelope = await listGitHubWorkItemsForRepo(requestContext, { - limit: perRepoLimit, - query: query || undefined, - page, - ...(options?.noCache ? { noCache: true } : {}) - }) - // Why: page-N failures aren't in the per-repo banner (keyed on the initial fetch); log them so pagination failures are observable instead of silently truncating (richer surface deferred, design doc §6). - if (envelope.errors?.issues) { - const { type, message } = envelope.errors.issues - // Why: only the 1000-result-window 422 may drive the unreachable - // clamp; demote other validation errors so they read as failures. - errorTypes.push( - type === 'validation_error' && - !GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN.test(message) - ? 'unknown' - : type - ) - console.warn( - `[workItems] next page ${r.repoId} issues-side partial failure:`, - envelope.errors.issues - ) - } - if (envelope.errors?.prs) { - // Why: the window 422 is issue-side only — a PR-side validation - // error must never join the unreachable signal. - const { type } = envelope.errors.prs - errorTypes.push(type === 'validation_error' ? 'unknown' : type) - console.warn( - `[workItems] next page ${r.repoId} prs-side partial failure:`, - envelope.errors.prs - ) - } - if (options?.requireComplete && (envelope.errors?.issues || envelope.errors?.prs)) { - failedCount += 1 - return [] as GitHubWorkItem[] - } - return envelope.items.map((item): GitHubWorkItem => ({ ...item, repoId: r.repoId })) - } catch (err) { - if (isGitHubWorkItemsSshRemoteRequiredError(err)) { - if (options?.requireComplete) { - failedCount += 1 - } - return [] as GitHubWorkItem[] - } - console.warn(`[workItems] next page ${r.repoId} failed:`, err) - failedCount += 1 - return [] as GitHubWorkItem[] - } finally { - releaseWorkItemSlot() - } - }) - ) - const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit) - return { items: merged, failedCount, errorTypes } - }, - - countWorkItemsAcrossRepos: async (repos, query, perRepoLimit) => { - if (isGitHubWorkItemsQueryTooLarge(query)) { - return { totalCount: 0, totalPages: 0 } - } - const normalizedLimit = Math.max(1, Math.floor(perRepoLimit)) - // Why: GitHub 422s pages that start past its 1000-result search window. - const maxReachablePages = Math.max(1, Math.ceil(GITHUB_SEARCH_RESULT_WINDOW / normalizedLimit)) - const counts = await Promise.all( - repos.map(async (r) => { - // Why: same stampede cap as item-fetch — without a slot a 90-repo selection fires 90 concurrent count IPCs before the main-side rate-limit guard sees the first 403. - await acquireWorkItemSlot() - try { - const requestState = get() - const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) - const requestSettings = getGitHubWorkItemSourceSettings( - requestState.settings, - repo, - r.sourceContext - ) - const requestContext = getGitHubWorkItemRequestContext( - requestState, - requestSettings, - r.repoId, - r.path, - r.sourceContext - ) - return await countGitHubWorkItemsForRepo(requestContext, { query: query || undefined }) - } catch { - return 0 - } finally { - releaseWorkItemSlot() - } - }) - ) - return { - totalCount: counts.reduce((sum, count) => sum + count, 0), - // Why: repos advance independently by page, so take the max across repos — a sum/page-width undercounts when one repo owns most results. - totalPages: counts.reduce( - (maxPages, count) => - Math.max(maxPages, Math.min(Math.ceil(count / normalizedLimit), maxReachablePages)), - 0 - ) - } - }, - - prefetchWorkItems: (repoId, repoPath, limit = PER_REPO_FETCH_LIMIT, query = '', options) => { - if (isGitHubWorkItemsQueryTooLarge(query)) { - return - } - const requestState = get() - const repo = findRepoForGitHubOwner(requestState, repoId, repoPath) - const key = - options?.sourceContext?.provider === 'github' - ? workItemsCacheKey(repoId, limit, query, getTaskSourceCacheScope(options.sourceContext)) - : getWorkItemsCacheKeyForOwner(requestState, repoId, limit, query, repoPath) - const cached = get().workItemsCache[key] - const requestSettings = getGitHubWorkItemSourceSettings( - requestState.settings, - repo, - options?.sourceContext - ) - const requestContext = getGitHubWorkItemRequestContext( - requestState, - requestSettings, - repoId, - repoPath, - options?.sourceContext - ) - const inflightKey = workItemsInflightRequestKey(key, requestContext.target) - if (isFresh(cached, WORK_ITEMS_CACHE_TTL) || inflightWorkItemsRequests.has(inflightKey)) { - return - } - void get() - .fetchWorkItems(repoId, repoPath, limit, query, { sourceContext: options?.sourceContext }) - .catch(() => {}) - }, + ...createProjectActions(set, get), + ...createProjectRowActions(set, get), + ...createWorkItemFetchActions(set, get), + ...createWorkItemAggregateActions(get), + ...createPullRequestActions(set, get), + ...createIssueActions(set, get), + ...createCheckActions(set, get), + ...createCommentFetchActions(set, get), + ...createCommentMutationActions(set, get), + ...createConversationCommentActions(set, get), + ...createReviewThreadActions(set, get), + ...createStaleWorktreeRefreshActions(get), + ...createRefreshRoutingActions(set, get), + ...createRefreshEventActions(set, get), + ...createRefreshSweepActions(set, get), + ...createWorkItemMutationActions(set, get), initGitHubCache: async () => { try { @@ -3054,1814 +100,5 @@ export const createGitHubSlice: StateCreator = (s } catch (err) { console.error('Failed to load GitHub cache from disk:', err) } - }, - - fetchPRForBranch: async (repoPath, branch, options): Promise => { - const repoLookup = getGitHubRepoLookupIndex(get().repos) - const repo = options?.repoId - ? repoLookup.findById(options.repoId) - : repoLookup.findByPath(repoPath) - const repoId = options?.repoId ?? repo?.id - const requestSettings = settingsForGitHubRepoOwner(get().settings, repo) - const cacheKey = prCacheKey( - repoPath, - repoId, - branch, - requestSettings, - repo?.connectionId, - repo?.executionHostId, - repo !== undefined - ) - const cached = get().prCache[cacheKey] - const hostedReviewCacheKey = getHostedReviewCacheKey( - repoPath, - branch, - requestSettings, - repoId, - repo?.connectionId, - repo?.executionHostId, - repo !== undefined - ) - // Why: a prior linkedPR-less caller may have cached null for this branch; refetch so the cached miss can now resolve via the linkedPR path. - const linkedPRNumber = options?.linkedPRNumber ?? null - const explicitFallbackPRNumber = options?.fallbackPRNumber ?? null - const hostedReviewFallbackPRNumber = githubHostedReviewFallbackPRNumber( - get(), - repoPath, - repoId, - branch, - repo?.connectionId, - repo?.executionHostId, - repo !== undefined - ) - const fallbackPRNumber = - linkedPRNumber == null ? (explicitFallbackPRNumber ?? hostedReviewFallbackPRNumber) : null - const fallbackPRSource: GitHubPRFallbackSource | null = - linkedPRNumber != null || fallbackPRNumber == null - ? null - : (options?.fallbackPRSource ?? - (explicitFallbackPRNumber != null ? 'explicit' : 'hosted-review')) - const lookupHintKey = prLookupHintKey(linkedPRNumber, fallbackPRNumber) - const linkedRefetch = - cached?.data === null && (linkedPRNumber !== null || fallbackPRNumber !== null) - if (!options?.force && !linkedRefetch && isFresh(cached)) { - // Why: even a fresh cache hit carries the head-scoped divergence signal; if a prior clear was declined for a mid-request head move and we're back on that head, clear the durable link. - if ( - options?.worktreeId && - linkedPRNumber != null && - cached?.data?.headDivergedFromMergedPRAtOid != null - ) { - const currentHeadOid = findWorktreeById(get(), options.worktreeId)?.head ?? null - if ( - shouldClearDivergedLinkedMergedPR({ - pr: cached.data, - linkedPRNumber, - requestHeadOid: currentHeadOid - }) - ) { - void get().updateWorktreeMeta( - options.worktreeId, - { linkedPR: null }, - { - shouldApply: (worktree) => - shouldApplyDivergedLinkedPRClear({ - worktree, - linkedPRNumber, - branch, - requestHeadOid: currentHeadOid - }) - } - ) - } - } - return cached.data - } - - const inflightRequest = inflightPRRequests.get(cacheKey) - if ( - inflightRequest && - (!options?.force || inflightRequest.force) && - inflightRequest.lookupHintKey === lookupHintKey && - !linkedRefetch - ) { - return inflightRequest.promise - } - - const generation = (prRequestGenerations.get(cacheKey) ?? 0) + 1 - const requestStartedAt = Date.now() - const requestStartedHostedReviewEntry = get().hostedReviewCache[hostedReviewCacheKey] - const requestStartedPRRefreshState = get().prRefreshStates[cacheKey] - const requestStartedPRRefreshToken = buildGitHubPRRefreshStateClearToken( - requestStartedPRRefreshState, - get().prRefreshSequences, - cacheKey - ) - prRequestGenerations.set(cacheKey, generation) - - const request = (async () => { - try { - const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) - const candidateWorktree = options?.worktreeId - ? findWorktreeById(get(), options.worktreeId) - : null - const requestHeadOid = candidateWorktree?.head ?? null - const outcome = runtimeRepo - ? await callRuntimeRpc( - runtimeRepo.target, - 'github.prForBranch', - { - repo: runtimeRepo.repo.id, - branch, - linkedPRNumber, - currentHeadOid: requestHeadOid, - ...(fallbackPRNumber !== null - ? { fallbackPRNumber, acceptMergedFallbackPR: fallbackPRSource !== null } - : {}) - }, - { timeoutMs: 30_000 } - ).then((result) => normalizeGitHubPRForBranchOutcome(result)) - : await (async () => { - const candidate: GitHubPRRefreshCandidate = { - repoId: repoId ?? '', - repoPath, - repoKind: repo?.kind ?? 'git', - branch, - cacheKey, - worktreeId: options?.worktreeId, - currentHeadOid: requestHeadOid, - linkedPRNumber, - fallbackPRNumber, - fallbackPRSource, - connectionId: repo?.connectionId ?? null, - executionHostId: repo?.executionHostId ?? null, - cachedFetchedAt: cached?.fetchedAt ?? null, - cachedHasPR: cached?.data ? true : cached ? false : null, - cachedPRState: cached?.data?.state ?? null, - cachedChecksStatus: cached?.data?.checksStatus ?? null, - cachedMergeable: cached?.data?.mergeable ?? null, - cachedMergeStateStatus: cached?.data?.mergeStateStatus ?? null - } - const response = window.api.gh.refreshPRNow - ? await window.api.gh.refreshPRNow({ candidate }) - : await window.api.gh.prForBranch({ - repoPath, - repoId, - branch, - linkedPRNumber, - fallbackPRNumber, - acceptMergedFallbackPR: fallbackPRNumber !== null && fallbackPRSource !== null, - currentHeadOid: requestHeadOid - }) - return normalizeGitHubPRForBranchOutcome(response) - })() - const pr: PRInfo | null = - outcome.kind === 'found' ? outcome.pr : outcome.kind === 'no-pr' ? null : null - if (outcome.kind === 'upstream-error') { - // Why: the runtime RPC path skips the coordinator broadcast that fills prRefreshStates on native, so record the classified error here for Checks parity with native (design criterion 2). - if (runtimeRepo && prRequestGenerations.get(cacheKey) === generation) { - set((s) => { - const nextStates = { ...s.prRefreshStates } - delete nextStates[cacheKey] - nextStates[cacheKey] = { - status: 'error', - reason: 'swr', - updatedAt: Date.now(), - message: outcome.message, - errorType: outcome.errorType, - nextAutoRetryAt: outcome.nextAutoRetryAt, - retryDisabledUntil: outcome.retryDisabledUntil - } - return { prRefreshStates: nextStates } - }) - } - return cached?.data ?? null - } - if (prRequestGenerations.get(cacheKey) === generation) { - let skippedStaleLinkedPRLookup = false - let didUpdatePRCache = false - set((s) => { - // Why: unlinking a PR mid exact-linked-PR-lookup must stop the older result from restoring the manual link UI. - if (isStaleExactLinkedPRLookup(s, options?.worktreeId, linkedPRNumber)) { - skippedStaleLinkedPRLookup = true - return {} - } - const updates = setGitHubPRResultCaches(s, { - prCacheKey: cacheKey, - repoPath, - branch, - settings: requestSettings, - repoId, - connectionId: repo?.connectionId, - executionHostId: repo?.executionHostId, - hasRepoOwner: repo !== undefined, - pr, - fetchedAt: outcome.fetchedAt, - worktreeId: options?.worktreeId, - linkedPRNumber, - fallbackPRNumber, - fallbackPRSource, - requestStartedAt, - requestStartedEntry: requestStartedHostedReviewEntry - }) - didUpdatePRCache = updates.prCache !== undefined - return updates - }) - if (skippedStaleLinkedPRLookup) { - return null - } - if (didUpdatePRCache) { - debouncedSaveCache(get()) - } - const linkedPRWorktree = - options?.worktreeId && linkedPRNumber != null - ? findUniqueWorktreeById( - get(), - options.worktreeId, - repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID - ) - : null - if ( - options?.worktreeId && - linkedPRWorktree && - linkedPRNumber != null && - shouldClearDivergedLinkedMergedPR({ pr, linkedPRNumber, requestHeadOid }) - ) { - // Why: only clear the durable link that produced this exact probe; drift means the stale result no longer owns the worktree. - void get().updateWorktreeMeta( - options.worktreeId, - { linkedPR: null }, - { - shouldApply: () => - shouldApplyDivergedLinkedPRClear({ - worktree: - findUniqueWorktreeById( - get(), - options.worktreeId!, - repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID - ) ?? undefined, - linkedPRNumber, - branch, - requestHeadOid - }) - } - ) - } - if ( - options?.worktreeId && - linkedPRWorktree && - linkedPRNumber != null && - shouldClearBranchMismatchedLinkedOpenPR({ - pr, - linkedPRNumber, - branch, - requestHeadOid, - pushTargetBranch: linkedPRWorktree.pushTarget?.branchName ?? null - }) - ) { - void get().updateWorktreeMeta( - options.worktreeId, - { linkedPR: null }, - { - // Why: the branch-scoped PR refetch below updates both caches; the generic metadata refresh would duplicate provider work. - suppressHostedReviewRefresh: true, - shouldApply: () => - shouldApplyBranchMismatchedLinkedPRClear({ - worktree: - findUniqueWorktreeById( - get(), - options.worktreeId!, - repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID - ) ?? undefined, - linkedPRNumber, - branch, - requestHeadOid - }) - } - ) - // Re-resolve by branch now so Checks recover this refresh instead of serving the stale linked PR. - void get().fetchPRForBranch(repoPath, branch, { - force: true, - repoId, - worktreeId: options.worktreeId - }) - } - } - if ( - shouldPreserveExistingPRForFallbackMiss({ - currentPR: get().prCache[cacheKey]?.data, - nextPR: pr, - state: get(), - worktreeId: options?.worktreeId, - linkedPRNumber, - fallbackPRNumber, - fallbackPRSource - }) - ) { - return get().prCache[cacheKey]?.data ?? null - } - return pr ?? null - } catch (err) { - console.error('Failed to fetch PR:', err) - return null - } finally { - const activeRequest = inflightPRRequests.get(cacheKey) - if (activeRequest?.generation === generation) { - inflightPRRequests.delete(cacheKey) - if (prRequestGenerations.get(cacheKey) === generation) { - prRequestGenerations.delete(cacheKey) - } - } - if (requestStartedPRRefreshToken) { - get().expireGitHubPRRefreshState(cacheKey, requestStartedPRRefreshToken) - } - } - })() - - inflightPRRequests.set(cacheKey, { - promise: request, - force: Boolean(options?.force), - generation, - lookupHintKey - }) - return request - }, - - fetchIssue: async (repoPath, number, options) => { - const repo = findRepoForGitHubOwner(get(), options?.repoId, repoPath) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) - const cacheKey = sourceScopedRepoCacheKey( - repoPath, - repoId, - String(number), - requestSettings, - repo?.connectionId, - repo?.executionHostId, - options?.sourceContext, - repo !== undefined - ) - const cached = get().issueCache[cacheKey] - if (isFresh(cached)) { - return cached.data - } - - const inflightRequest = inflightIssueRequests.get(cacheKey) - if (inflightRequest) { - return inflightRequest - } - - const request = (async () => { - try { - const requestContext = getGitHubWorkItemRequestContext( - get(), - requestSettings, - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - const issue = - requestContext.target.kind === 'environment' - ? await callRuntimeRpc( - { kind: 'environment', environmentId: requestContext.target.environmentId }, - 'github.issue', - { repo: requestContext.target.runtimeRepoId, number }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.issue({ - repoPath, - repoId, - number, - sourceContext: options?.sourceContext - }) - set((s) => ({ - issueCache: withBoundedCacheEntry(s.issueCache, cacheKey, { - data: issue, - fetchedAt: Date.now() - }) - })) - debouncedSaveCache(get()) - return issue - } catch (err) { - console.error('Failed to fetch issue:', err) - set((s) => ({ - issueCache: withBoundedCacheEntry(s.issueCache, cacheKey, { - data: null, - fetchedAt: Date.now() - }) - })) - debouncedSaveCache(get()) - return null - } finally { - inflightIssueRequests.delete(cacheKey) - } - })() - - inflightIssueRequests.set(cacheKey, request) - return request - }, - - fetchPRChecks: async ( - repoPath, - prNumber, - branch, - headSha, - prRepo, - options - ): Promise => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) - const cacheKey = sourceScopedRepoCacheKey( - repoPath, - repoId, - prChecksCacheSuffix(prNumber, prRepo, headSha), - requestSettings, - repo?.connectionId, - repo?.executionHostId, - options?.sourceContext, - repo !== undefined - ) - const legacyCacheKey = headSha - ? sourceScopedRepoCacheKey( - repoPath, - repoId, - prChecksCacheSuffix(prNumber, prRepo), - requestSettings, - repo?.connectionId, - repo?.executionHostId, - options?.sourceContext, - repo !== undefined - ) - : cacheKey - const inflightKey = cacheKey - const cached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey] - if ( - !options?.force && - !options?.noCache && - isFresh(cached, getPRChecksCacheTtl(cached)) && - (!headSha || cached.headSha === headSha) - ) { - const cachedChecks = cached.data ?? [] - const prStatusUpdate = syncPRChecksStatus( - get(), - repoPath, - repoId, - branch, - cachedChecks, - cached.headSha, - prRepo, - requestSettings, - repo?.connectionId, - repo?.executionHostId, - repo !== undefined - ) - if (prStatusUpdate) { - set(prStatusUpdate) - debouncedSaveCache(get()) - } - return cachedChecks - } - - const inflightRequest = inflightChecksRequests.get(inflightKey) - if (inflightRequest) { - if ( - (options?.force && !inflightRequest.force) || - (options?.noCache && !inflightRequest.noCache) - ) { - await inflightRequest.promise.catch(() => {}) - } else { - return inflightRequest.promise - } - } - - const request = (async () => { - try { - const requestContext = getGitHubWorkItemRequestContext( - get(), - requestSettings, - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - const checks = - requestContext.target.kind === 'environment' - ? await callRuntimeRpc( - { kind: 'environment', environmentId: requestContext.target.environmentId }, - 'github.prChecks', - { - repo: requestContext.target.runtimeRepoId, - prNumber, - headSha, - prRepo: prRepo ?? null, - noCache: Boolean(options?.force || options?.noCache) - }, - { timeoutMs: 30_000 } - ) - : ((await window.api.gh.prChecks({ - repoPath, - repoId, - prNumber, - headSha, - prRepo: prRepo ?? null, - noCache: Boolean(options?.force || options?.noCache), - sourceContext: options?.sourceContext - })) as PRCheckDetail[]) - set((s) => { - const nextState: Partial = { - checksCache: withBoundedCacheEntry(s.checksCache, cacheKey, { - data: checks, - fetchedAt: Date.now(), - headSha - }) - } - - const prStatusUpdate = syncPRChecksStatus( - s, - repoPath, - repoId, - branch, - checks, - headSha, - prRepo, - requestSettings, - repo?.connectionId, - repo?.executionHostId, - repo !== undefined - ) - if (prStatusUpdate?.prCache) { - nextState.prCache = prStatusUpdate.prCache - } - - return nextState - }) - debouncedSaveCache(get()) - return checks - } catch (err) { - console.error('Failed to fetch PR checks:', err) - const latestCached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey] - if (latestCached?.data && (!headSha || latestCached.headSha === headSha)) { - return latestCached.data - } - return [] - } finally { - inflightChecksRequests.delete(inflightKey) - } - })() - - inflightChecksRequests.set(inflightKey, { - promise: request, - force: Boolean(options?.force), - noCache: Boolean(options?.force || options?.noCache) - }) - return request - }, - - fetchPRCheckDetails: async (repoPath, args, options): Promise => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) - const requestContext = getGitHubWorkItemRequestContext( - get(), - requestSettings, - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - const requestTarget = requestContext.target - return requestTarget.kind === 'environment' - ? await withGitHubCheckDetailsTimeout((signal) => - callRuntimeRpc( - { kind: 'environment', environmentId: requestTarget.environmentId }, - 'github.prCheckDetails', - { - repo: requestTarget.runtimeRepoId, - checkRunId: args.checkRunId, - workflowRunId: args.workflowRunId, - checkName: args.checkName, - url: args.url, - prRepo: args.prRepo ?? null - }, - { timeoutMs: 30_000, signal } - ) - ) - : await withGitHubCheckDetailsTimeout(() => - window.api.gh.prCheckDetails({ - repoPath, - repoId, - checkRunId: args.checkRunId, - workflowRunId: args.workflowRunId, - checkName: args.checkName, - url: args.url, - prRepo: args.prRepo ?? null, - sourceContext: options?.sourceContext - }) - ) - }, - - fetchPRComments: async (repoPath, prNumber, options): Promise => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) - const cacheKey = sourceScopedRepoCacheKey( - repoPath, - repoId, - prCommentsCacheSuffix(prNumber, options?.prRepo), - requestSettings, - repo?.connectionId, - repo?.executionHostId, - options?.sourceContext, - repo !== undefined - ) - const cached = get().commentsCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data ?? [] - } - - const inflightRequest = inflightCommentsRequests.get(cacheKey) - if (inflightRequest) { - return inflightRequest - } - - const request = (async () => { - try { - const requestContext = getGitHubWorkItemRequestContext( - get(), - requestSettings, - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - const comments = - requestContext.target.kind === 'environment' - ? await callRuntimeRpc( - { kind: 'environment', environmentId: requestContext.target.environmentId }, - 'github.prComments', - { - repo: requestContext.target.runtimeRepoId, - prNumber, - prRepo: options?.prRepo ?? null, - noCache: options?.force - }, - { timeoutMs: 30_000 } - ) - : ((await window.api.gh.prComments({ - repoPath, - repoId, - prNumber, - prRepo: options?.prRepo ?? null, - noCache: options?.force, - sourceContext: options?.sourceContext - })) as PRComment[]) - set((s) => ({ - commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, { - data: comments, - fetchedAt: Date.now() - }) - })) - return comments - } catch (err) { - console.error('Failed to fetch PR comments:', err) - return get().commentsCache[cacheKey]?.data ?? [] - } finally { - inflightCommentsRequests.delete(cacheKey) - } - })() - - inflightCommentsRequests.set(cacheKey, request) - return request - }, - - addPRConversationComment: async (repoPath, prNumber, body, options) => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) - const cacheKey = sourceScopedRepoCacheKey( - repoPath, - repoId, - prCommentsCacheSuffix(prNumber, options?.prRepo), - requestSettings, - repo?.connectionId, - repo?.executionHostId, - options?.sourceContext, - repo !== undefined - ) - const requestContext = getGitHubWorkItemRequestContext( - get(), - requestSettings, - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - let result: GitHubCommentResult - try { - result = - requestContext.target.kind === 'environment' - ? await callRuntimeRpc( - { kind: 'environment', environmentId: requestContext.target.environmentId }, - 'github.addIssueComment', - { - repo: requestContext.target.runtimeRepoId, - number: prNumber, - body, - type: 'pr', - prRepo: options?.prRepo ?? null - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.addIssueComment({ - repoPath, - repoId, - number: prNumber, - body, - type: 'pr', - prRepo: options?.prRepo ?? null, - sourceContext: options?.sourceContext - }) - } catch (err) { - const error = err instanceof Error ? err.message : 'Failed to post comment.' - return { ok: false, error } - } - if (!hasUsableCommentPayload(result)) { - return result.ok - ? { - ok: false, - error: translate( - 'auto.store.slices.github.f129c42773', - 'GitHub did not return the new comment.' - ) - } - : result - } - set((s) => { - const entry = s.commentsCache[cacheKey] - return { - commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, { - data: mergePRCommentIntoList(entry?.data, result.comment), - fetchedAt: Date.now() - }) - } - }) - return result - }, - - addPRReviewCommentReply: async (repoPath, prNumber, commentId, body, options) => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) - const cacheKey = sourceScopedRepoCacheKey( - repoPath, - repoId, - prCommentsCacheSuffix(prNumber, options?.prRepo), - requestSettings, - repo?.connectionId, - repo?.executionHostId, - options?.sourceContext, - repo !== undefined - ) - const requestContext = getGitHubWorkItemRequestContext( - get(), - requestSettings, - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - let result: GitHubCommentResult - try { - result = - requestContext.target.kind === 'environment' - ? await callRuntimeRpc( - { kind: 'environment', environmentId: requestContext.target.environmentId }, - 'github.addPRReviewCommentReply', - { - repo: requestContext.target.runtimeRepoId, - prNumber, - commentId, - body, - threadId: options?.threadId, - path: options?.path, - line: options?.line, - prRepo: options?.prRepo ?? null - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.addPRReviewCommentReply({ - repoPath, - repoId, - prNumber, - commentId, - body, - threadId: options?.threadId, - path: options?.path, - line: options?.line, - prRepo: options?.prRepo ?? null, - sourceContext: options?.sourceContext - }) - } catch (err) { - const error = err instanceof Error ? err.message : 'Failed to post reply.' - return { ok: false, error } - } - if (!hasUsableCommentPayload(result)) { - return result.ok - ? { - ok: false, - error: translate( - 'auto.store.slices.github.f129c42773', - 'GitHub did not return the new comment.' - ) - } - : result - } - const comment: PRComment = { - ...result.comment, - threadId: result.comment.threadId ?? options?.threadId, - path: result.comment.path ?? options?.path, - line: result.comment.line ?? options?.line - } - set((s) => { - const entry = s.commentsCache[cacheKey] - return { - commentsCache: withBoundedCacheEntry(s.commentsCache, cacheKey, { - data: mergePRCommentIntoList(entry?.data, comment), - fetchedAt: Date.now() - }) - } - }) - return { ok: true, comment } - }, - - setPRCommentReaction: async ( - repoPath, - prNumber, - reactionSubjectId, - content, - reacted, - options - ) => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) - const cacheKey = sourceScopedRepoCacheKey( - repoPath, - repoId, - prCommentsCacheSuffix(prNumber, options?.prRepo), - requestSettings, - repo?.connectionId, - repo?.executionHostId, - options?.sourceContext, - repo !== undefined - ) - const previousComment = get().commentsCache[cacheKey]?.data?.find( - (comment) => comment.reactionSubjectId === reactionSubjectId - ) - const previousReaction = previousComment?.reactions?.find( - (reaction) => reaction.content === content - ) - set((state) => { - const entry = state.commentsCache[cacheKey] - if (!entry?.data) { - return state - } - return { - commentsCache: { - ...state.commentsCache, - [cacheKey]: { - ...entry, - data: setReactionOnSubject(entry.data, reactionSubjectId, content, reacted) - } - } - } - }) - - const requestContext = getGitHubWorkItemRequestContext( - get(), - requestSettings, - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - let ok = false - try { - ok = - requestContext.target.kind === 'environment' - ? await callRuntimeRpc( - { kind: 'environment', environmentId: requestContext.target.environmentId }, - 'github.setPRCommentReaction', - { - repo: requestContext.target.runtimeRepoId, - reactionSubjectId, - content, - reacted, - prRepo: options?.prRepo ?? null - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.setPRCommentReaction({ - repoPath, - repoId, - reactionSubjectId, - content, - reacted, - prRepo: options?.prRepo ?? null, - sourceContext: options?.sourceContext - }) - } catch (err) { - console.error('Failed to update PR comment reaction:', err) - } - if (!ok && previousComment) { - set((state) => { - const entry = state.commentsCache[cacheKey] - if (!entry?.data) { - return state - } - return { - commentsCache: { - ...state.commentsCache, - [cacheKey]: { - ...entry, - data: restoreReactionOnSubject( - entry.data, - reactionSubjectId, - content, - previousReaction - ) - } - } - } - }) - } - return ok - }, - - resolveReviewThread: async (repoPath, prNumber, threadId, resolve, options) => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const repoId = options?.repoId ?? repo?.id - const requestSettings = getGitHubRepoSourceSettings( - get().settings, - repo, - options?.sourceContext - ) - const cacheKey = sourceScopedRepoCacheKey( - repoPath, - repoId, - prCommentsCacheSuffix(prNumber, options?.prRepo), - requestSettings, - repo?.connectionId, - repo?.executionHostId, - options?.sourceContext, - repo !== undefined - ) - - // Optimistic toggle of isResolved for this thread; reverts if the API call fails. - const prev = get().commentsCache[cacheKey]?.data - if (prev) { - set((s) => ({ - commentsCache: { - ...s.commentsCache, - [cacheKey]: { - ...s.commentsCache[cacheKey], - data: prev.map((c) => (c.threadId === threadId ? { ...c, isResolved: resolve } : c)) - } - } - })) - } - - const requestContext = getGitHubWorkItemRequestContext( - get(), - requestSettings, - repoId ?? repoPath, - repoPath, - options?.sourceContext - ) - let ok = false - try { - ok = - requestContext.target.kind === 'environment' - ? await callRuntimeRpc( - { kind: 'environment', environmentId: requestContext.target.environmentId }, - 'github.resolveReviewThread', - { - repo: requestContext.target.runtimeRepoId, - threadId, - resolve, - prRepo: options?.prRepo ?? null - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.resolveReviewThread({ - repoPath, - repoId, - threadId, - resolve, - prRepo: options?.prRepo ?? null, - sourceContext: options?.sourceContext - }) - } catch (err) { - console.error('Failed to update review thread:', err) - ok = false - } - if (!ok && prev) { - // Revert optimistic update on failure - set((s) => ({ - commentsCache: { - ...s.commentsCache, - [cacheKey]: { ...s.commentsCache[cacheKey], data: prev } - } - })) - } - return ok - }, - - enqueueGitHubPRRefresh: (worktreeId, reason, priority = 0) => { - const state = get() - const worktree = findWorktreeById(state, worktreeId) - const candidate = worktree ? buildPRRefreshCandidate(state, worktree) : null - if (!candidate) { - return - } - if (getPRRefreshRuntimeRepoTarget(state, candidate)) { - void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { - force: bypassesGitHubPRRefreshFreshness(reason), - repoId: candidate.repoId, - worktreeId: candidate.worktreeId, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null - }) - return - } - if (!shouldEnqueueLocalPRRefresh(candidate)) { - return - } - enqueueLocalGitHubPRRefresh({ candidate, reason, priority }, async () => { - await get().fetchPRForBranch(candidate.repoPath, candidate.branch, { - force: bypassesGitHubPRRefreshFreshness(reason), - repoId: candidate.repoId, - worktreeId: candidate.worktreeId, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null - }) - }) - }, - - reportVisibleGitHubPRRefreshCandidates: (worktreeIds, generation) => { - const state = get() - const candidates = worktreeIds - .map((id) => { - const worktree = findWorktreeById(state, id) - return worktree ? buildPRRefreshCandidate(state, worktree) : null - }) - .filter((candidate): candidate is GitHubPRRefreshCandidate => candidate !== null) - const localCandidates: GitHubPRRefreshCandidate[] = [] - for (const candidate of candidates) { - if (getPRRefreshRuntimeRepoTarget(state, candidate)) { - void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { - repoId: candidate.repoId, - worktreeId: candidate.worktreeId, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null - }) - continue - } - if (shouldEnqueueLocalPRRefresh(candidate)) { - localCandidates.push(candidate) - } - } - const reportVisible = window.api.gh.reportVisiblePRRefreshCandidates - if (reportVisible) { - void reportVisible({ candidates: localCandidates, generation }).catch((err) => { - console.warn('Failed to report visible PR refresh candidates:', err) - }) - } - }, - - bumpGitHubPRVisibleRefreshGeneration: () => { - set((s) => ({ prVisibleRefreshGeneration: s.prVisibleRefreshGeneration + 1 })) - }, - - applyGitHubPRRefreshEvent: (event) => { - // Why: local-repo sidebar refresh routes through the main PR coordinator, so run the same guarded diverged-merged-PR clear. - const divergedLinkedPRClears: { - worktreeId: string - linkedPRNumber: number - branch: string - requestHeadOid: string | null - executionHostId: string - }[] = [] - const branchMismatchedLinkedPRClears: { - worktreeId: string - linkedPRNumber: number - branch: string - requestHeadOid: string | null - executionHostId: string - }[] = [] - let didUpdatePRCache = false - set((s) => { - let linkedWorktreeLookupIndex: WorktreeLookupIndex | undefined - const nextSequences = { ...s.prRefreshSequences } - const prunedStates = pruneExpiredPRRefreshStates(s.prRefreshStates) - const nextStates = { ...prunedStates } - let nextPRCache = s.prCache - let nextHostedReviewCache = s.hostedReviewCache ?? {} - let changed = prunedStates !== s.prRefreshStates - - for (const alias of event.aliases) { - const aliasExecutionHostId = getRefreshAliasExecutionHostId(alias) - const previousSequence = nextSequences[alias.cacheKey] ?? 0 - if ( - event.outcome ? event.sequence < previousSequence : event.sequence <= previousSequence - ) { - if (event.outcome || event.status !== 'in-flight') { - deletePRRefreshStartedEntry(event.sequence, alias.cacheKey) - } - continue - } - // Why: delete-then-set re-orders this key last so capPrRefreshSequences evicts idle, not active, keys. - delete nextSequences[alias.cacheKey] - nextSequences[alias.cacheKey] = event.sequence - changed = true - - if (event.outcome) { - const startedEntryKey = prRefreshStartedEntryKey(event.sequence, alias.cacheKey) - const requestStartedEntry = prRefreshStartedHostedReviewEntries.get(startedEntryKey) - prRefreshStartedHostedReviewEntries.delete(startedEntryKey) - if (previousSequence !== event.sequence) { - deletePRRefreshStartedEntry(previousSequence, alias.cacheKey) - } - delete nextStates[alias.cacheKey] - if (event.outcome.kind === 'upstream-error') { - nextStates[alias.cacheKey] = { - status: 'error', - reason: event.reason, - updatedAt: Date.now(), - message: event.outcome.message, - errorType: event.outcome.errorType, - nextAutoRetryAt: event.outcome.nextAutoRetryAt, - retryDisabledUntil: event.outcome.retryDisabledUntil - } - continue - } - const data = - event.outcome.kind === 'found' - ? (() => { - const pr = event.outcome.pr - const checksCacheKeys = [ - ...(alias.repoId - ? [ - ...(pr.headSha - ? [ - runtimeScopedRepoCacheKey( - alias.repoPath, - alias.repoId, - prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha), - s.settings, - alias.connectionId, - aliasExecutionHostId, - true - ) - ] - : []), - runtimeScopedRepoCacheKey( - alias.repoPath, - alias.repoId, - prChecksCacheSuffix(pr.number, pr.prRepo), - s.settings, - alias.connectionId, - aliasExecutionHostId, - true - ) - ] - : []), - ...(pr.headSha - ? [ - runtimeScopedRepoCacheKey( - alias.repoPath, - undefined, - prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha), - s.settings, - alias.connectionId, - aliasExecutionHostId, - true - ) - ] - : []), - runtimeScopedRepoCacheKey( - alias.repoPath, - undefined, - prChecksCacheSuffix(pr.number, pr.prRepo), - s.settings, - alias.connectionId, - aliasExecutionHostId, - true - ), - `${alias.repoPath}::pr-checks::${pr.number}` - ] - const checksEntry = checksCacheKeys - .map((key) => s.checksCache[key]) - .find((entry) => entry?.data) - if ( - checksEntry?.data && - checksEntry.headSha && - pr.headSha && - checksEntry.headSha === pr.headSha && - event.outcome.fetchedAt - checksEntry.fetchedAt < - getPRChecksCacheTtl(checksEntry) - ) { - return { ...pr, checksStatus: deriveCheckStatusFromChecks(checksEntry.data) } - } - return pr - })() - : null - const linkedPRNumber = alias.linkedPRNumber ?? null - // Why: one outcome fans out to many aliases; build one lazy index instead of rescanning worktrees per alias. - const worktreeLookupIndex = - alias.worktreeId && linkedPRNumber != null - ? (linkedWorktreeLookupIndex ??= buildWorktreeLookupIndex(s)) - : undefined - // Why: a queued refresh finishing after the user unlinks an exact PR must not restore the manual-link UI. - if ( - isStaleExactLinkedPRLookup(s, alias.worktreeId, linkedPRNumber, worktreeLookupIndex) - ) { - continue - } - if (event.outcome.kind === 'found' && alias.worktreeId) { - const requestHeadOid = alias.currentHeadOid ?? null - const worktree = - linkedPRNumber != null - ? findUniqueWorktreeById( - s, - alias.worktreeId, - aliasExecutionHostId, - worktreeLookupIndex - ) - : null - // Why: only the sequence-gate winner owns metadata side effects; late outcomes must not unlink a newer PR. - if ( - worktree && - linkedPRNumber != null && - shouldClearDivergedLinkedMergedPR({ - pr: event.outcome.pr, - linkedPRNumber, - requestHeadOid - }) - ) { - divergedLinkedPRClears.push({ - worktreeId: alias.worktreeId, - linkedPRNumber, - branch: alias.branch, - requestHeadOid, - executionHostId: aliasExecutionHostId - }) - } else if ( - worktree && - linkedPRNumber != null && - shouldClearBranchMismatchedLinkedOpenPR({ - pr: event.outcome.pr, - linkedPRNumber, - branch: alias.branch, - requestHeadOid, - pushTargetBranch: worktree.pushTarget?.branchName ?? null - }) - ) { - branchMismatchedLinkedPRClears.push({ - worktreeId: alias.worktreeId, - linkedPRNumber, - branch: alias.branch, - requestHeadOid, - executionHostId: aliasExecutionHostId - }) - } - } - const nextCaches = applyGitHubPRResultToCaches({ - prCache: nextPRCache, - hostedReviewCache: nextHostedReviewCache, - prCacheKey: alias.cacheKey, - repoPath: alias.repoPath, - branch: alias.branch, - settings: s.settings, - repoId: alias.repoId, - connectionId: alias.connectionId, - executionHostId: aliasExecutionHostId, - hasRepoOwner: true, - pr: data, - fetchedAt: event.outcome.fetchedAt, - state: s, - worktreeId: alias.worktreeId, - linkedPRNumber: alias.linkedPRNumber, - fallbackPRNumber: alias.fallbackPRNumber, - fallbackPRSource: alias.fallbackPRSource, - requestStartedAt: event.requestStartedAt, - requestStartedEntry - }) - didUpdatePRCache = didUpdatePRCache || nextCaches.prCache !== nextPRCache - nextPRCache = nextCaches.prCache - nextHostedReviewCache = nextCaches.hostedReviewCache - continue - } - - if (event.status) { - if (previousSequence !== event.sequence) { - deletePRRefreshStartedEntry(previousSequence, alias.cacheKey) - } - if (event.status === 'in-flight' && event.requestStartedAt !== undefined) { - const hostedReviewCacheKey = getHostedReviewCacheKey( - alias.repoPath, - alias.branch, - s.settings, - alias.repoId, - alias.connectionId, - aliasExecutionHostId, - true - ) - setPRRefreshStartedHostedReviewEntry( - prRefreshStartedEntryKey(event.sequence, alias.cacheKey), - s.hostedReviewCache[hostedReviewCacheKey] - ) - } else { - // Why: pause/skip can follow an in-flight broadcast with no outcome; drop the stale request-start snapshot. - deletePRRefreshStartedEntry(event.sequence, alias.cacheKey) - } - // Why: delete-then-set re-orders this key last so capRecordByInsertionOrder evicts idle, not active, keys. - delete nextStates[alias.cacheKey] - const isPaused = event.status === 'paused' - nextStates[alias.cacheKey] = { - status: event.status, - reason: event.reason, - updatedAt: Date.now(), - pausedUntil: event.pausedUntil, - skippedReason: event.skippedReason, - // Why: paused = rate-limit gate; map pausedUntil into the schedule to show auto-retry and disable manual Retry. - nextAutoRetryAt: isPaused ? event.pausedUntil : undefined, - retryDisabledUntil: isPaused ? event.pausedUntil : undefined - } - } - } - - return changed - ? { - prRefreshSequences: capPrRefreshSequences(nextSequences), - // Why: bound prRefreshStates with status-aware eviction so visible in-progress pills survive. - prRefreshStates: capPrRefreshStates(nextStates), - prCache: nextPRCache, - hostedReviewCache: nextHostedReviewCache - } - : {} - }) - if (didUpdatePRCache && event.outcome && event.outcome.kind !== 'upstream-error') { - debouncedSaveCache(get()) - } - for (const clear of divergedLinkedPRClears) { - void get().updateWorktreeMeta( - clear.worktreeId, - { linkedPR: null }, - { - shouldApply: () => - shouldApplyDivergedLinkedPRClear({ - worktree: - findUniqueWorktreeById(get(), clear.worktreeId, clear.executionHostId) ?? undefined, - linkedPRNumber: clear.linkedPRNumber, - branch: clear.branch, - requestHeadOid: clear.requestHeadOid - }) - } - ) - } - for (const clear of branchMismatchedLinkedPRClears) { - void get().updateWorktreeMeta( - clear.worktreeId, - { linkedPR: null }, - { - shouldApply: () => - shouldApplyBranchMismatchedLinkedPRClear({ - worktree: - findUniqueWorktreeById(get(), clear.worktreeId, clear.executionHostId) ?? undefined, - linkedPRNumber: clear.linkedPRNumber, - branch: clear.branch, - requestHeadOid: clear.requestHeadOid - }) - } - ) - } - }, - - refreshAllGitHub: () => { - // Clear comments cache; evict stale entries to bound long-session growth across repos/branches. - set((s) => { - const next = { - commentsCache: Object.keys(s.commentsCache).length === 0 ? s.commentsCache : {}, - prCache: evictStaleEntries(s.prCache), - issueCache: evictStaleEntries(s.issueCache), - checksCache: evictStaleEntries(s.checksCache), - workItemsCache: evictStaleEntries(s.workItemsCache), - projectViewCache: evictStaleEntries(s.projectViewCache), - prRefreshStates: pruneExpiredPRRefreshStates(s.prRefreshStates) - } - // Why: each eviction helper returns its input untouched when nothing changed, so an - // unchanged sweep can return `s` and avoid waking every subscriber on window resume. - return next.commentsCache === s.commentsCache && - next.prCache === s.prCache && - next.issueCache === s.issueCache && - next.checksCache === s.checksCache && - next.workItemsCache === s.workItemsCache && - next.projectViewCache === s.projectViewCache && - next.prRefreshStates === s.prRefreshStates - ? s - : next - }) - - // Why: don't prune prRequestGenerations here — deleting a live generation makes its response look stale. - - // Only re-fetch PR/issue entries that are already stale — skip fresh ones - const state = get() - const cardProps = state.worktreeCardProperties ?? [] - const rawCardProps = cardProps as readonly string[] - const shouldRefreshIssues = shouldRefreshIssueDecorations(state) - const isPRStatusGrouping = state.groupBy === 'pr-status' - const rightSidebarShowsPR = rightSidebarShowsPullRequestData(state) - const shouldRefreshPRs = - isPRStatusGrouping || - rightSidebarShowsPR || - (state.settings?.experimentalNewWorktreeCardStyle === true - ? cardProps.includes('status') - : cardProps.includes('pr') || rawCardProps.includes('ci')) - if (!shouldRefreshPRs && !shouldRefreshIssues) { - return - } - - const now = Date.now() - const stalePRCandidates: { candidate: GitHubPRRefreshCandidate; score: number }[] = [] - const repoLookup = getGitHubRepoLookupIndex(state.repos) - - for (const worktrees of Object.values(state.worktreesByRepo)) { - for (const wt of worktrees) { - const repo = repoLookup.findById(wt.repoId) - if (!repo) { - continue - } - - const branch = wt.branch.replace(/^refs\/heads\//, '') - if (shouldRefreshPRs && !wt.isBare && branch) { - const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) - const prKey = prCacheKey( - repo.path, - repo.id, - branch, - ownerSettings, - repo.connectionId, - repo.executionHostId - ) - const prEntry = state.prCache[prKey] - if (!prEntry || now - prEntry.fetchedAt >= CACHE_TTL) { - const candidate = buildPRRefreshCandidate(state, wt, undefined, repo) - if (candidate) { - stalePRCandidates.push({ - candidate, - score: - (state.activeWorktreeId === wt.id ? Number.MAX_SAFE_INTEGER : 0) + - wt.lastActivityAt - }) - } - } - } - if (shouldRefreshIssues && wt.linkedIssue) { - const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) - const issueKey = issueCacheKey( - repo.path, - repo.id, - wt.linkedIssue, - ownerSettings, - repo.connectionId, - repo.executionHostId, - true - ) - const issueEntry = state.issueCache[issueKey] - if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) { - void get().fetchIssue(repo.path, wt.linkedIssue, { repoId: repo.id }) - } - } - } - } - const candidatesToRefresh = stalePRCandidates - .sort((a, b) => b.score - a.score) - .slice(0, isPRStatusGrouping ? stalePRCandidates.length : 5) - for (const { candidate } of candidatesToRefresh) { - const candidateSettings = settingsForGitHubRepoOwner( - state.settings, - candidate as Pick - ) - if (getRuntimeRepoTarget(state, candidate.repoPath, candidateSettings)) { - void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { - repoId: candidate.repoId, - worktreeId: candidate.worktreeId, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null - }) - } else if (shouldEnqueueLocalPRRefresh(candidate)) { - enqueueLocalGitHubPRRefresh({ candidate, reason: 'swr', priority: 10 }) - } - } - }, - - refreshGitHubForWorktree: (worktreeId) => { - const state = get() - let worktree: Worktree | undefined - for (const worktrees of Object.values(state.worktreesByRepo)) { - worktree = worktrees.find((w) => w.id === worktreeId) - if (worktree) { - break - } - } - if (!worktree) { - return - } - - const repo = state.repos.find((r) => r.id === worktree.repoId) - if (!repo) { - return - } - - // Invalidate this worktree's cache entries - const branch = worktree.branch.replace(/^refs\/heads\//, '') - const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) - const prKey = prCacheKey( - repo.path, - repo.id, - branch, - ownerSettings, - repo.connectionId, - repo.executionHostId - ) - const issueKey = worktree.linkedIssue - ? issueCacheKey( - repo.path, - repo.id, - worktree.linkedIssue, - ownerSettings, - repo.connectionId, - repo.executionHostId, - true - ) - : '' - - set((s) => { - const updates: Partial = {} - if (s.prCache[prKey]) { - updates.prCache = { ...s.prCache, [prKey]: { ...s.prCache[prKey], fetchedAt: 0 } } - } - if (issueKey && s.issueCache[issueKey]) { - updates.issueCache = { - ...s.issueCache, - [issueKey]: { ...s.issueCache[issueKey], fetchedAt: 0 } - } - } - return updates - }) - - // Re-fetch (skip when branch is empty — detached HEAD during rebase) - if (!worktree.isBare && branch) { - const candidate = buildPRRefreshCandidate(get(), worktree) - if (candidate) { - if (getPRRefreshRuntimeRepoTarget(get(), candidate)) { - void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { - force: true, - repoId: candidate.repoId, - worktreeId: candidate.worktreeId, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null - }) - } else if (shouldEnqueueLocalPRRefresh(candidate)) { - enqueueLocalGitHubPRRefresh({ candidate, reason: 'post-push', priority: 100 }) - } - } - } - if (shouldRefreshIssueDecorations(state) && worktree.linkedIssue) { - void get().fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id }) - } - }, - - patchWorkItem: (itemId, patch, repoId, options) => { - set((s) => { - const nextCache = { ...s.workItemsCache } - let changed = false - const sourceScope = - options?.sourceContext?.provider === 'github' - ? getTaskSourceCacheScope(options.sourceContext) - : null - for (const key of Object.keys(nextCache)) { - // Why: don't patch another host/account's visually identical issue/PR cache entry. - if (sourceScope && key !== sourceScope && !key.startsWith(`${sourceScope}::`)) { - continue - } - const entry = nextCache[key] - if (!entry?.data) { - continue - } - // Why: issue/PR ids are only unique within a repo; cross-repo views can share `pr:42`. - const idx = entry.data.findIndex( - (item) => item.id === itemId && (!repoId || item.repoId === repoId) - ) - if (idx === -1) { - continue - } - const updatedItems = [...entry.data] - updatedItems[idx] = { ...updatedItems[idx], ...patch } - nextCache[key] = { ...entry, data: updatedItems } - changed = true - } - return changed ? { workItemsCache: nextCache } : {} - }) - }, - - setIssueSourcePreference: async (repoId, repoPath, preference) => { - // Why: optimistically patch the local Repo so the segmented control updates this frame; resync via fetchRepos on IPC failure. - set((s) => ({ - repos: s.repos.map((r) => - r.id === repoId - ? { - ...r, - issueSourcePreference: preference === 'auto' ? undefined : preference - } - : r - ) - })) - try { - // Why: use the generic `repos:update` channel so a single write → single `repos:changed` broadcast re-fetches other windows. - // Why: map 'auto' to undefined so persistence drops the key entirely (see main/persistence.ts#updateRepo). - const updates = { issueSourcePreference: preference === 'auto' ? undefined : preference } - // Why: route to the repo's owner host (like updateRepo) so the write lands where the repo lives, not the focused runtime. - const target = getActiveRuntimeTarget(getSettingsForRepoRuntimeOwner(get(), repoId)) - await (target.kind === 'local' - ? window.api.repos.update({ repoId, updates }) - : callRuntimeRpc(target, 'repo.update', { repo: repoId, updates }, { timeoutMs: 15_000 })) - } catch (err) { - console.error('Failed to persist issue-source preference:', err) - // Why: without this toast the pill silently snaps back (optimistic patch + resync) and the user wouldn't know the write failed. - toast.error( - translate('auto.store.slices.github.d49ef4b944', 'Failed to save issue-source preference'), - { - duration: ERROR_TOAST_DURATION - } - ) - // Why: the optimistic patch may now disagree with disk; resync rather than leave a lie on screen. - void get().fetchRepos() - } - // Why: clear inflight dedupe BEFORE bumping the nonce so the re-triggered fetch can't collapse onto a pre-flip in-flight entry. - clearInflightWorkItemsForRepo(repoId, repoPath) - // Why: evict AFTER the await so an overlapping fetch can't repopulate with pre-flip data; also drops legacy path-scoped keys. - set((s) => { - const prefix = `${repoId}::` - const legacyPrefix = `${repoPath}::` - const next: Record> = {} - for (const [key, entry] of Object.entries(s.workItemsCache)) { - if (!key.startsWith(prefix) && !key.startsWith(legacyPrefix)) { - next[key] = entry - } - } - // Why: the Tasks fetch effect keys on the nonce, not the cache, so bump it to re-run and re-populate the evicted entries. - return { workItemsCache: next, workItemsInvalidationNonce: s.workItemsInvalidationNonce + 1 } - }) - }, - - evictGitHubRepoCaches: (repoId, repoPath) => { - clearInflightWorkItemsForRepo(repoId, repoPath) - set((s) => { - const prefixes = repoCacheKeyPrefixes(repoId, repoPath) - const workItems = evictRepoCacheEntries(s.workItemsCache, prefixes) - const prs = evictRepoCacheEntries(s.prCache, prefixes) - const issues = evictRepoCacheEntries(s.issueCache, prefixes) - const checks = evictRepoCacheEntries(s.checksCache, prefixes) - const comments = evictRepoCacheEntries(s.commentsCache, prefixes) - const updates: Partial = {} - - if (workItems.evicted) { - updates.workItemsCache = workItems.cache - updates.workItemsInvalidationNonce = s.workItemsInvalidationNonce + 1 - } - if (prs.evicted) { - updates.prCache = prs.cache - } - if (issues.evicted) { - updates.issueCache = issues.cache - } - if (checks.evicted) { - updates.checksCache = checks.cache - } - if (comments.evicted) { - updates.commentsCache = comments.cache - } - - return updates - }) - }, - - // Why: activation is the strongest freshness signal; route through the coordinator to keep coalescing/rate-limit guards. - refreshGitHubForWorktreeIfStale: (worktreeId) => { - const state = get() - let worktree: Worktree | undefined - for (const worktrees of Object.values(state.worktreesByRepo)) { - worktree = worktrees.find((w) => w.id === worktreeId) - if (worktree) { - break - } - } - if (!worktree) { - return - } - - const repo = state.repos.find((r) => r.id === worktree.repoId) - if (!repo) { - return - } - - const now = Date.now() - const branch = worktree.branch.replace(/^refs\/heads\//, '') - const cardProps = state.worktreeCardProperties ?? [] - const rawCardProps = cardProps as readonly string[] - const shouldRefreshPR = - state.groupBy === 'pr-status' || - (state.settings?.experimentalNewWorktreeCardStyle === true - ? cardProps.includes('status') - : cardProps.includes('pr') || rawCardProps.includes('ci')) || - rightSidebarShowsPullRequestData(state) - - if (shouldRefreshPR && !worktree.isBare && branch) { - const candidate = buildPRRefreshCandidate(state, worktree) - if (candidate) { - if (getPRRefreshRuntimeRepoTarget(state, candidate)) { - void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { - force: true, - repoId: candidate.repoId, - worktreeId: candidate.worktreeId, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null - }) - } else if (shouldEnqueueLocalPRRefresh(candidate)) { - enqueueLocalGitHubPRRefresh({ candidate, reason: 'active', priority: 80 }) - } - } - } - - if (shouldRefreshIssueDecorations(state) && worktree.linkedIssue) { - const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo) - const issueKey = issueCacheKey( - repo.path, - repo.id, - worktree.linkedIssue, - ownerSettings, - repo.connectionId, - repo.executionHostId, - true - ) - const issueEntry = state.issueCache[issueKey] - if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) { - void get().fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id }) - } - } } }) diff --git a/src/renderer/src/store/slices/jira.ts b/src/renderer/src/store/slices/jira.ts index 418e4a7ad25..4b0e5237987 100644 --- a/src/renderer/src/store/slices/jira.ts +++ b/src/renderer/src/store/slices/jira.ts @@ -11,7 +11,7 @@ import type { JiraSiteSelection, JiraViewer } from '../../../../shared/jira-types' -import type { CacheEntry } from './github' +import type { CacheEntry } from '../github/cache-model' import { isIntegrationCredentialDecryptionError } from '../../../../shared/integration-credential-errors' import { jiraConnect, diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index eddbbcf3086..588c6845f3a 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -19,7 +19,7 @@ import type { LinearWorkspaceError, LinearWorkspaceSelection } from '../../../../shared/linear/workspace-types' -import type { CacheEntry } from './github' +import type { CacheEntry } from '../github/cache-model' import { clampLinearIssueListLimit } from '../../../../shared/linear/issue-read-limits' import { isIntegrationCredentialDecryptionError } from '../../../../shared/integration-credential-errors' import { clearLinearMetadataCache } from '../../hooks/useIssueMetadata' diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 83a3564cf5b..d5a246b5da5 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { createTestStore, makeWorktree } from './store-test-helpers' -import { workItemsCacheKey } from './github' +import { workItemsCacheKey } from '../github/cache-identity' import type { Project, ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import { toast } from 'sonner' From 1c436a8084e2d71f0201fde93dde8cb6cb977bc9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:52:12 -0700 Subject: [PATCH 27/65] refactor(rpc): split terminal methods into cohesive modules (#16188) --- config/max-lines-baseline.txt | 1 - ...-legacy-stream-id-characterization.test.ts | 91 + ...terminal-manifest-characterization.test.ts | 207 + ...stream-extraction-characterization.test.ts | 130 + src/main/runtime/rpc/methods/terminal.ts | 3840 +---------------- .../rpc/methods/terminal/stream-schemas.ts | 78 + .../terminal/terminal-input-delivery.ts | 188 + .../terminal-legacy-binary-control-frames.ts | 141 + .../terminal-legacy-simple-subscriptions.ts | 184 + .../terminal-legacy-subscribe-binary.ts | 286 ++ .../terminal-legacy-subscribe-live.ts | 158 + .../terminal-legacy-subscribe-snapshot.ts | 265 ++ .../terminal-legacy-subscription-types.ts | 58 + .../terminal/terminal-lifecycle-methods.ts | 178 + .../terminal/terminal-multiplex-cleanup.ts | 100 + .../terminal/terminal-multiplex-connection.ts | 106 + .../terminal-multiplex-flow-control.ts | 264 ++ .../terminal-multiplex-frame-delivery.ts | 153 + .../terminal-multiplex-initial-snapshot.ts | 137 + .../terminal-multiplex-live-stream.ts | 139 + .../terminal/terminal-multiplex-method.ts | 72 + .../terminal-multiplex-slot-frames.ts | 297 ++ ...erminal-multiplex-stream-initialization.ts | 151 + .../terminal-multiplex-subscribe-frame.ts | 73 + ...terminal-multiplex-subscribe-resolution.ts | 96 + .../terminal/terminal-output-batcher.ts | 120 + .../terminal/terminal-query-methods.ts | 101 + .../methods/terminal/terminal-send-method.ts | 227 + .../terminal/terminal-snapshot-publication.ts | 209 + .../terminal/terminal-stream-replay.ts | 203 + .../methods/terminal/terminal-stream-types.ts | 100 + .../terminal/terminal-subscribe-method.ts | 95 + .../terminal-subscription-stream-id.ts | 5 + .../terminal/terminal-viewport-methods.ts | 120 + .../terminal/terminal-viewport-update.ts | 38 + .../rpc/methods/terminal/unary-schemas.ts | 203 + .../rpc/methods/terminal/viewport-schemas.ts | 51 + 37 files changed, 5042 insertions(+), 3823 deletions(-) create mode 100644 src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts create mode 100644 src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts create mode 100644 src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts create mode 100644 src/main/runtime/rpc/methods/terminal/stream-schemas.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-cleanup.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-live-stream.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-send-method.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-stream-replay.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-subscription-stream-id.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-viewport-update.ts create mode 100644 src/main/runtime/rpc/methods/terminal/unary-schemas.ts create mode 100644 src/main/runtime/rpc/methods/terminal/viewport-schemas.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 456bd969189..88b327ecbc8 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -60,7 +60,6 @@ inline src/main/runtime/orca-runtime-git.ts inline src/main/runtime/orca-runtime.test.ts inline src/main/runtime/orca-runtime.ts inline src/main/runtime/rpc/methods/orchestration.ts -inline src/main/runtime/rpc/methods/terminal.ts inline src/main/runtime/runtime-rpc.ts inline src/main/source-control/hosted-review-creation.ts inline src/main/speech/model-manager.ts diff --git a/src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts new file mode 100644 index 00000000000..3f3630b0ffa --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RuntimeTerminalWait } from '../../../../shared/runtime-types' +import { createSubscriptionRegistryDouble } from '../subscription-registry-test-double' +import { TERMINAL_METHODS } from './terminal' + +function request(params: unknown): RpcRequest { + return { + id: 'request', + authToken: 'token', + method: 'terminal.subscribe', + params + } +} + +describe('legacy terminal subscription stream IDs', () => { + it('allocates binary and JSON subscriptions from one monotonically increasing owner', async () => { + const registry = createSubscriptionRegistryDouble() + const runtime = { + getRuntimeId: () => 'runtime', + resolveLeafForHandle: vi.fn(() => ({ ptyId: 'pty' })), + readTerminal: vi.fn(async () => ({ tail: [], truncated: false })), + serializeTerminalBuffer: vi.fn(async () => ({ data: '', cols: 80, rows: 24 })), + getTerminalSize: vi.fn(() => ({ cols: 80, rows: 24 })), + getMobileDisplayMode: vi.fn(() => 'auto'), + getLayout: vi.fn(() => ({ seq: 1 })), + subscribeToTerminalData: vi.fn(() => () => {}), + registerRemoteTerminalViewSubscriber: vi.fn(() => () => {}), + subscribeToTerminalResize: vi.fn(() => () => {}), + subscribeToFitOverrideChanges: vi.fn(() => () => {}), + subscribeToPtyExit: vi.fn(() => () => {}), + registerSubscriptionCleanup: vi.fn(registry.registerSubscriptionCleanup), + registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup), + cleanupSubscription: vi.fn(registry.cleanupSubscription), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as Partial + const dispatcher = new RpcDispatcher({ + runtime: runtime as OrcaRuntimeService, + methods: TERMINAL_METHODS + }) + + const subscribeBinary = async (terminal: string, clientId: string) => { + const messages: string[] = [] + const pending = dispatcher.dispatchStreaming( + request({ + terminal, + client: { id: clientId, type: 'desktop' }, + capabilities: { terminalBinaryStream: 1 } + }), + (message) => messages.push(message), + { connectionId: `connection-${clientId}`, sendBinary: () => {} } + ) + await vi.waitFor(() => + expect(messages.some((message) => JSON.parse(message).result?.type === 'subscribed')).toBe( + true + ) + ) + const subscribed = messages + .map((message) => JSON.parse(message)) + .find((message) => message.result?.type === 'subscribed') + const streamId = subscribed?.result?.streamId + if (typeof streamId !== 'number') { + throw new Error('Missing legacy binary stream ID') + } + runtime.cleanupSubscription?.(`${terminal}:${clientId}`) + await pending + return streamId + } + + const first = await subscribeBinary('terminal-a', 'client-a') + + const jsonMessages: string[] = [] + const jsonPending = dispatcher.dispatchStreaming( + request({ terminal: 'terminal-json', client: { id: 'client-json', type: 'desktop' } }), + (message) => jsonMessages.push(message), + { connectionId: 'connection-json' } + ) + await vi.waitFor(() => + expect( + jsonMessages.some((message) => JSON.parse(message).result?.type === 'scrollback') + ).toBe(true) + ) + runtime.cleanupSubscription?.('terminal-json:client-json') + await jsonPending + + const second = await subscribeBinary('terminal-b', 'client-b') + expect(second).toBe(first + 2) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts new file mode 100644 index 00000000000..daa25165d3d --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { TERMINAL_METHODS } from './terminal' +import { + TerminalMultiplexLegacyAckFrame, + TerminalMultiplexSourceRangeAckFrame, + TerminalMultiplexSubscribeFrame +} from './terminal/stream-schemas' + +const METHOD_CASES: readonly (readonly [string, unknown, boolean])[] = [ + ['terminal.list', {}, false], + ['terminal.resolveActive', {}, false], + ['terminal.resolvePane', { paneKey: 'pane' }, false], + ['terminal.recoverPane', { paneKey: 'pane', worktreeId: 'worktree' }, false], + ['terminal.show', { terminal: 'term' }, false], + ['terminal.read', { terminal: 'term' }, false], + ['terminal.inspectProcess', { terminal: 'term' }, false], + ['terminal.isRunningAgent', { terminal: 'term' }, false], + ['terminal.agentStatus', { terminal: 'term' }, false], + ['terminal.rename', { terminal: 'term', title: null }, false], + ['terminal.clearBuffer', { terminal: 'term' }, false], + ['terminal.send', { terminal: 'term', text: 'x' }, false], + ['terminal.wait', { terminal: 'term', for: 'exit' }, false], + ['terminal.create', {}, false], + ['terminal.split', { terminal: 'term' }, false], + ['terminal.stop', { worktree: 'worktree' }, false], + ['terminal.sleep', { worktree: 'worktree' }, false], + ['terminal.stopExact', { worktree: 'worktree', expectedPtyIds: ['pty'] }, false], + ['terminal.resizeForClient', { terminal: 'term', mode: 'restore', clientId: 'client' }, false], + ['terminal.focus', { terminal: 'term' }, false], + ['terminal.close', { terminal: 'term' }, false], + ['terminal.closeTab', { terminal: 'term' }, false], + ['agentTeams.tmuxCompat', { teamId: 'team', token: 'token', envPane: 'pane', argv: [] }, false], + ['agentTeams.prepareLaunch', { paneKey: 'pane' }, false], + ['terminal.setDisplayMode', { terminal: 'term', mode: 'auto' }, false], + ['terminal.restoreFit', { terminal: 'term' }, false], + ['terminal.getDisplayMode', { terminal: 'term' }, false], + [ + 'terminal.updateViewport', + { terminal: 'term', client: { id: 'client' }, viewport: { cols: 80, rows: 24 } }, + false + ], + ['terminal.multiplex', {}, true], + ['terminal.subscribe', { terminal: 'term' }, true], + ['terminal.unsubscribe', { subscriptionId: 'term' }, false], + ['terminal.getAutoRestoreFit', {}, false], + ['terminal.setAutoRestoreFit', { ms: null }, false] +] + +function schemaFor(name: string) { + const method = TERMINAL_METHODS.find((candidate) => candidate.name === name) + if (!method?.params) { + throw new Error(`Missing terminal schema: ${name}`) + } + return method.params +} +async function invoke(name: string, params: unknown, runtime: Partial) { + const method = TERMINAL_METHODS.find((candidate) => candidate.name === name) + if (!method?.params || 'stream' in method) { + throw new Error(`Missing unary terminal method: ${name}`) + } + return method.handler(method.params.parse(params), { runtime: runtime as OrcaRuntimeService }) +} + +describe('terminal RPC manifest characterization', () => { + it('preserves all method names, order, streaming flags, and parseable minimum inputs', () => { + expect(TERMINAL_METHODS).toHaveLength(33) + expect(TERMINAL_METHODS.map((method) => [method.name, 'stream' in method])).toEqual( + METHOD_CASES.map(([name, _params, stream]) => [name, stream]) + ) + expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(33) + for (const [name, params] of METHOD_CASES) { + expect(() => schemaFor(name).parse(params), name).not.toThrow() + } + }) + + it('keeps legacy coercion and viewport-boundary differences', () => { + expect(schemaFor('terminal.list').parse({ limit: 'invalid' })).toEqual({}) + expect(schemaFor('terminal.split').parse({ terminal: 'term', direction: 'diagonal' })).toEqual({ + terminal: 'term' + }) + expect(() => schemaFor('terminal.rename').parse({ terminal: 'term' })).toThrow() + expect(schemaFor('terminal.rename').parse({ terminal: 'term', title: '' })).toEqual({ + terminal: 'term', + title: '' + }) + expect(() => + schemaFor('terminal.updateViewport').parse({ + terminal: 'term', + client: { id: 'client' }, + viewport: { cols: 241, rows: 120 } + }) + ).toThrow() + expect(() => + schemaFor('terminal.subscribe').parse({ + terminal: 'term', + viewport: { cols: 1000, rows: 500 } + }) + ).not.toThrow() + expect(() => + schemaFor('terminal.setDisplayMode').parse({ + terminal: 'term', + mode: 'auto', + viewport: { cols: 1001, rows: 501 } + }) + ).not.toThrow() + }) + + it('keeps multiplex control objects strict while subscribe remains skew-tolerant', () => { + expect(TerminalMultiplexLegacyAckFrame.safeParse({ bytes: 1, future: true }).success).toBe( + false + ) + expect( + TerminalMultiplexSourceRangeAckFrame.safeParse({ + streamGeneration: 'generation', + ackedEndByte: 1, + future: true + }).success + ).toBe(false) + expect( + TerminalMultiplexSubscribeFrame.parse({ + streamId: 1, + terminal: 'term', + futureCapability: 1 + }) + ).toEqual({ streamId: 1, terminal: 'term' }) + }) + + it('returns execution-host authority from lifecycle handlers without reinterpretation', async () => { + const pane = { + handle: 'term-pane', + executionHostId: 'ssh-host', + hostPlatform: 'win32' + } + const recovered = { + handle: 'term-recovered', + executionHostId: 'folder-host', + hostPlatform: 'linux' + } + const shown = { + handle: 'term-shown', + executionHostId: 'ssh-host', + hostPlatform: 'win32' + } + const split = { + handle: 'term-split', + executionHostId: 'folder-host', + hostPlatform: 'linux' + } + const runtime = { + resolveTerminalPane: vi.fn(() => pane), + recoverTerminalPane: vi.fn(async () => recovered), + showTerminal: vi.fn(async () => shown), + splitTerminal: vi.fn(async () => split) + } as unknown as Partial + + await expect(invoke('terminal.resolvePane', { paneKey: 'pane' }, runtime)).resolves.toEqual({ + terminal: pane + }) + await expect( + invoke('terminal.recoverPane', { paneKey: 'pane', worktreeId: 'folder-worktree' }, runtime) + ).resolves.toEqual({ terminal: recovered }) + await expect(invoke('terminal.show', { terminal: 'term' }, runtime)).resolves.toEqual({ + terminal: shown + }) + await expect(invoke('terminal.split', { terminal: 'term' }, runtime)).resolves.toEqual({ + split + }) + }) + + it('keeps remote and folder selectors opaque through terminal.create', async () => { + const created = { + handle: 'term-created', + executionHostId: 'ssh-host', + hostPlatform: 'win32' + } + const createTerminal = vi.fn(async () => created) + const runtime = { + dedupeTerminalCreate: vi.fn( + async ( + _owner: string, + selector: string | undefined, + _mutationId: string | undefined, + _reconcile: boolean, + create: (selector: string | undefined, preAllocatedHandle?: string) => Promise + ) => create(selector, 'term-preallocated') + ), + createTerminal + } as unknown as Partial + const selector = 'ssh://windows-host/C:/Users/dev/repo' + + await expect( + invoke( + 'terminal.create', + { worktree: selector, clientMutationId: 'mutation', command: 'provider resume-token' }, + runtime + ) + ).resolves.toEqual({ terminal: created }) + expect(createTerminal).toHaveBeenCalledWith( + selector, + expect.objectContaining({ + command: 'provider resume-token', + preAllocatedHandle: 'term-preallocated' + }) + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts new file mode 100644 index 00000000000..db05c6b3fe5 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamJson, + encodeTerminalStreamFrame +} from '../../../../shared/terminal-stream-protocol' +import { sendSnapshotFrames } from './terminal/terminal-snapshot-publication' +import { TerminalSourceRangeRegistry } from '../terminal-source-range-registry' +import { initializeMultiplexStream } from './terminal/terminal-multiplex-stream-initialization' +import type { TerminalMultiplexConnection } from './terminal/terminal-multiplex-connection' +import type { OrcaRuntimeService } from '../../orca-runtime' + +// These assertions protect the permanent binary publication seam while its state machine +// lives in concrete terminal domain modules. +describe('terminal stream extraction characterization', () => { + it('publishes an empty snapshot as Start then End without a synthetic chunk', () => { + const frames: { + opcode: TerminalStreamOpcode + payload?: Uint8Array + }[] = [] + const stats = sendSnapshotFrames( + (opcode, payload) => { + frames.push({ opcode, payload }) + }, + { + kind: 'scrollback', + cols: 80, + rows: 24, + data: '', + seq: 41, + truncated: false, + truncatedByByteBudget: false + } + ) + + expect(frames.map((frame) => frame.opcode)).toEqual([ + TerminalStreamOpcode.SnapshotStart, + TerminalStreamOpcode.SnapshotEnd + ]) + expect(decodeTerminalStreamJson(frames[0]!.payload ?? new Uint8Array())).toEqual({ + kind: 'scrollback', + cols: 80, + rows: 24, + seq: 41, + truncated: false, + truncatedByByteBudget: false + }) + expect(stats).toEqual({ bytes: 0, chunks: 0, published: true }) + }) + + it('stops publication immediately when SnapshotStart is rejected', () => { + const opcodes: TerminalStreamOpcode[] = [] + const stats = sendSnapshotFrames( + (opcode) => { + opcodes.push(opcode) + return false + }, + { + kind: 'scrollback', + cols: 80, + rows: 24, + data: 'unpublished' + } + ) + + expect(opcodes).toEqual([TerminalStreamOpcode.SnapshotStart]) + expect(stats).toEqual({ bytes: 0, chunks: 0, published: false }) + }) + + it('keeps the permanent v1 header bytes and opcode assignments', () => { + const seq = 2 ** 32 + 0x89abcdef + const bytes = encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.WriteUnavailable, + streamId: 0x78563412, + seq, + payload: new Uint8Array([0xaa]) + }) + const header = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + + expect(Array.from(bytes.slice(0, 4))).toEqual([0x74, 1, 17, 0]) + expect(header.getUint32(4, true)).toBe(0x78563412) + expect(header.getUint32(8, true)).toBe(1) + expect(header.getUint32(12, true)).toBe(0x89abcdef) + expect(bytes[16]).toBe(0xaa) + expect([ + TerminalStreamOpcode.Output, + TerminalStreamOpcode.SnapshotStart, + TerminalStreamOpcode.SnapshotChunk, + TerminalStreamOpcode.SnapshotEnd, + TerminalStreamOpcode.Resized, + TerminalStreamOpcode.Error, + TerminalStreamOpcode.Input, + TerminalStreamOpcode.Resize, + TerminalStreamOpcode.Subscribe, + TerminalStreamOpcode.Unsubscribe, + TerminalStreamOpcode.SnapshotRequest, + TerminalStreamOpcode.Metadata, + TerminalStreamOpcode.Ack, + TerminalStreamOpcode.ClaimViewport, + TerminalStreamOpcode.OutputSpan, + TerminalStreamOpcode.SetOutputPaused, + TerminalStreamOpcode.WriteUnavailable + ]).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]) + }) + + it('lets a slot-handler registration throw escape before per-stream catch ownership begins', async () => { + const streams = new Map() + const runtime = { + attachRemoteTerminalSourceRangeConsumer: vi.fn(() => false) + } as unknown as OrcaRuntimeService + const state = { + runtime, + connectionId: 'connection', + streams, + sourceRangeRegistry: new TerminalSourceRangeRegistry(), + registerBinaryStreamHandler: vi.fn(() => { + throw new Error('registrar-failed') + }), + sendFrame: vi.fn(), + queueOrSendOutput: vi.fn() + } as unknown as TerminalMultiplexConnection + const installed = vi.fn() + + await expect( + initializeMultiplexStream(state, { streamId: 7, terminal: 'terminal' }, 'pty', installed) + ).rejects.toThrow('registrar-failed') + expect(installed).not.toHaveBeenCalled() + expect(streams.has(7)).toBe(true) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 5dee92a7676..e80d07773dc 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1,3826 +1,22 @@ -/* oxlint-disable max-lines -- Why: terminal RPC methods are co-located for discoverability; splitting would scatter related handlers across files. */ -import { z } from 'zod' -import { randomUUID } from 'node:crypto' +import type { RpcAnyMethod } from '../core' +import { TERMINAL_LIFECYCLE_METHODS } from './terminal/terminal-lifecycle-methods' +import { TERMINAL_MULTIPLEX_METHODS } from './terminal/terminal-multiplex-method' +import { TERMINAL_QUERY_METHODS } from './terminal/terminal-query-methods' +import { TERMINAL_SEND_METHODS } from './terminal/terminal-send-method' +import { TERMINAL_SUBSCRIBE_METHODS } from './terminal/terminal-subscribe-method' import { - InvalidArgumentError, - defineMethod, - defineStreamingMethod, - type RpcAnyMethod -} from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' -import type { DriverState, OrcaRuntimeService, SubscriptionRegistration } from '../../orca-runtime' -import { - TerminalStreamOpcode, - decodeTerminalStreamJson, - decodeTerminalStreamText, - encodeTerminalStreamFrame, - encodeTerminalStreamJson, - encodeTerminalStreamText, - type TerminalStreamFrame -} from '../../../../shared/terminal-stream-protocol' -import { - iterateTerminalOutputFrameChunks, - sliceTerminalOutputSourceRanges, - type TerminalOutputFrameChunk, - type TerminalOutputMeta -} from '../terminal-output-frame-chunks' -import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../shared/feature-education-telemetry' -import type { TerminalOscLinkRange } from '../../../../shared/terminal-osc-link-ranges' -import { - TERMINAL_INPUT_MAX_BYTES, - TERMINAL_INPUT_TOO_LARGE_ERROR, - isTerminalInputTooLargeWithYield -} from '../../../../shared/terminal-input' -import { - measureTerminalStreamByteLength, - terminalStreamByteLength, - terminalStreamByteLengthExceeds -} from '../terminal-stream-byte-length' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' -import { - EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE, - scanTerminalReplyQuerySequences, - type TerminalReplyQuerySequence, - type TerminalReplyQueryScanState -} from '../../../../shared/terminal-reply-query-scan' -import { - MOBILE_SNAPSHOT_BYTE_BUDGET, - MOBILE_SUBSCRIBE_SCROLLBACK_ROWS -} from '../../scrollback-limits' -import { assertTerminalAgentSendable } from '../terminal-agent-send-guard' -import { - navigationTargetsHost, - resolveRuntimeNavigationTarget -} from '../../../../shared/runtime-navigation' -import { - TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES, - TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES, - TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES, - TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES, - TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION, - TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION, - TERMINAL_MULTIPLEX_PENDING_MAX_BYTES, - TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR, - TERMINAL_OUTPUT_BATCH_MAX_BYTES -} from '../../../../shared/terminal-multiplex-flow-control' -import { drainTerminalMultiplexRoundRobin } from '../terminal-multiplex-round-robin' -import type { TerminalSourceRangeLedger } from '../terminal-source-range-ledger' -import { TerminalSourceRangeRegistry } from '../terminal-source-range-registry' -import { - sameTerminalOutputSourceIdentity, - type TerminalOutputSourceRange -} from '../../../../shared/terminal-output-source-range' -import type { TerminalSnapshotUnavailableReason } from '../../../../shared/terminal-snapshot-unavailability' -import type { RemoteTerminalSourceRangeReplacementReservation } from '../../remote-terminal-source-range-consumer' -import { withTerminalCloseAttribution } from '../terminal-close-attribution' - -const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 -const TERMINAL_OUTPUT_FLUSH_MS = 5 -const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 -// Why: bound initial subscribe latency; readiness after this deadline triggers an in-stream recovery snapshot. -const MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS = 3_000 -let nextTerminalStreamId = 1 - -type SnapshotFrameOptions = { - kind: 'scrollback' | 'resized' - cols: number - rows: number - data: string - requestId?: number - displayMode?: string - reason?: string - seq?: number - cwd?: string | null - truncated?: boolean - truncatedByByteBudget?: boolean - // Why: distinguishes "I could not answer right now" from a genuinely empty buffer; omitted on success. - unavailable?: TerminalSnapshotUnavailableReason - source?: 'headless' | 'renderer' - oscLinks?: TerminalOscLinkRange[] - pendingEscapeTailAnsi?: string - /** Effective kitty flags proven at this frame's own `seq`. */ - kittyKeyboardFlags?: number -} - -type SerializedSnapshot = { - data: string - scrollbackAnsi?: string - cols: number - rows: number - seq?: number - cwd?: string | null - source?: 'headless' | 'renderer' - oscLinks?: TerminalOscLinkRange[] - scrollbackRows: number - truncatedByByteBudget: boolean - pendingEscapeTailAnsi?: string - kittyKeyboardFlags?: number -} | null - -type TerminalViewportClient = { - id: string - type?: 'mobile' | 'desktop' -} - -type TerminalMultiplexStream = { - streamId: number - terminal: string - ptyId: string - client: TerminalViewportClient | undefined - isMobile: boolean - ackOutput: boolean - ackOutputSourceRanges: boolean - streamGeneration: string - sourceRangeLedger: TerminalSourceRangeLedger | null - sourceRangeConsumerAttached: boolean - sourceRangeReplacement: RemoteTerminalSourceRangeReplacementReservation | null - ackInFlightBytes: number - ackWindowBytes: number - supportsOutputPause: boolean - supportsWriteUnavailable: boolean - outputPaused: boolean - supportsDesktopViewportClaims: boolean - desktopClaimTail: Promise - // Whether THIS stream registered the width driver, so detach won't release a peer stream's floor. - registeredRemoteDesktopDriver: boolean - remoteDesktopSubscriptionKey: string - pendingRemoteDesktopViewport: { cols: number; rows: number } | null - buffering: boolean - ackPendingOutput: TerminalOutputFrameChunk[] - ackPendingOutputBytes: number - ackPendingOutputOverflowed: boolean - ackRecoverySnapshotInFlight: boolean - pendingOutput: TerminalOutputChunk[] - pendingOutputBytes: number - pendingOutputOverflowed: boolean - // Cols the mobile client last rewrapped to; re-stream full scrollback only when width actually changes. - lastResizeCols: number | undefined - resizeGeneration: number - outputBatcher: ReturnType - unsubscribeData: () => void - unsubscribeResize: () => void - unsubscribeFit: () => void - unsubscribeDriver: () => void - unregisterBinaryHandler: () => void - // Why: the runtime drops the exit-waiter only on real PTY exit; abort on detach so a never-exiting agent terminal doesn't leak the waiter. - exitWaiterAbort: AbortController -} - -type TerminalOutputChunk = { - data: string - bytes: number - meta?: TerminalOutputMeta -} - -function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutputMeta) => void): { - push: (data: string, meta?: TerminalOutputMeta) => void - flush: () => void - dispose: () => void -} { - let chunks: string[] = [] - let bytes = 0 - let lastSeq: number | undefined - let pendingCwd: string | undefined - let pendingRawLength = 0 - let pendingSourceRanges: TerminalOutputSourceRange[] = [] - let timer: ReturnType | null = null - - const clearTimer = (): void => { - if (!timer) { - return - } - clearTimeout(timer) - timer = null - } - - const flush = (): void => { - clearTimer() - if (chunks.length === 0 && pendingRawLength === 0) { - return - } - const data = chunks.length === 1 ? chunks[0]! : chunks.join('') - const meta = - typeof lastSeq === 'number' || pendingCwd !== undefined || pendingSourceRanges.length > 0 - ? { - ...(typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: pendingRawLength } : {}), - ...(pendingCwd !== undefined ? { cwd: pendingCwd } : {}), - ...(pendingSourceRanges.length > 0 - ? { sourceRanges: Object.freeze(pendingSourceRanges.slice()) } - : {}) - } - : undefined - chunks = [] - bytes = 0 - lastSeq = undefined - pendingCwd = undefined - pendingRawLength = 0 - pendingSourceRanges = [] - onFlush(data, meta) - } - - return { - push(data: string, meta?: TerminalOutputMeta): void { - const rawLength = meta?.rawLength ?? data.length - if (!data && rawLength === 0) { - return - } - if (meta?.transformed || rawLength !== data.length) { - flush() - onFlush(data, { ...meta, rawLength, transformed: true }) - return - } - const nextSourceRanges = meta?.sourceRanges ?? [] - const lastSourceRange = pendingSourceRanges.at(-1) - const firstNextSourceRange = nextSourceRanges[0] - if ( - chunks.length > 0 && - (pendingSourceRanges.length > 0 !== nextSourceRanges.length > 0 || - (lastSourceRange && - firstNextSourceRange && - (!sameTerminalOutputSourceIdentity(lastSourceRange, firstNextSourceRange) || - lastSourceRange.displayEnd !== firstNextSourceRange.displayStart))) - ) { - flush() - } - if (meta?.cwd !== undefined) { - flush() - pendingCwd = meta.cwd - } - chunks.push(data) - pendingRawLength += rawLength - pendingSourceRanges.push(...nextSourceRanges) - const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes) - const measurement = measureTerminalStreamByteLength(data, { - stopAfterBytes: remainingBudget - }) - bytes += measurement.byteLength - if (typeof meta?.seq === 'number') { - lastSeq = meta.seq - } - if (measurement.exceededLimit || bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) { - flush() - return - } - if (!timer) { - // Why: coalesce stream output before it crosses the network; desktop subscribers share the same burst boundary. - timer = setTimeout(flush, TERMINAL_OUTPUT_FLUSH_MS) - if (typeof timer.unref === 'function') { - timer.unref() - } - } - }, - flush, - dispose(): void { - clearTimer() - chunks = [] - bytes = 0 - pendingRawLength = 0 - pendingSourceRanges = [] - } - } -} - -function isTerminalInputLockedForClient( - runtime: OrcaRuntimeService, - ptyId: string, - client: TerminalViewportClient | undefined -): boolean { - if (client?.type === 'mobile') { - return false - } - // Why: pre-refactor mobile builds sent no client metadata, so treat a missing client as legacy mobile (unlocked). - if (!client) { - return false - } - return runtime.getDriver(ptyId).kind === 'mobile' -} - -async function assertTerminalSendTextWithinLimit(text: string | undefined): Promise { - if (!text) { - return - } - // Why: sends can be paste-sized; validate outside Zod so large input yields before runtime dispatch. - if (await isTerminalInputTooLargeWithYield(text, TERMINAL_INPUT_MAX_BYTES)) { - throw new InvalidArgumentError(TERMINAL_INPUT_TOO_LARGE_ERROR) - } -} - -function resolveMobileFloorClientId( - driver: DriverState | null, - client: TerminalViewportClient | undefined -): string | null { - if (client?.type === 'mobile') { - return client.id - } - if (!client && driver?.kind === 'mobile') { - return driver.clientId - } - return null -} - -type TerminalStreamInputOutcome = 'delivered' | 'rejected' | 'failed' - -function watchSubscriptionLifetime( - runtime: OrcaRuntimeService, - ptyId: string, - signal: AbortSignal | undefined, - registration: SubscriptionRegistration -): () => void { - let unsubscribeExit: (() => void) | null = null - let removeAbort: (() => void) | null = null - let stopped = false - const stop = (): void => { - stopped = true - unsubscribeExit?.() - removeAbort?.() - } - const release = (): void => { - registration.releaseIfCurrent() - stop() - } - unsubscribeExit = runtime.subscribeToPtyExit(ptyId, release) - if (stopped) { - unsubscribeExit() - return stop - } - if (!signal) { - return stop - } - if (signal.aborted) { - release() - return stop - } - const onAbort = (): void => release() - removeAbort = () => signal.removeEventListener('abort', onAbort) - signal.addEventListener('abort', onAbort, { once: true }) - if (stopped) { - removeAbort() - } - return stop -} - -function isTerminalStreamInputRejection(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return message.includes('terminal_not_writable') || message.includes('terminal_handle_stale') -} - -async function sendTerminalStreamInput( - runtime: OrcaRuntimeService, - args: { - terminal: string - text: string - client: TerminalViewportClient | undefined - isMobile: boolean - } -): Promise { - const action = { text: args.text, enter: false, interrupt: false } - const clientId = args.isMobile ? args.client?.id : undefined - const floorClaim: MobileInputFloorClaimHolder = { current: null } - try { - if (!clientId) { - const result = await runtime.sendTerminal(args.terminal, action) - return result.accepted ? 'delivered' : 'rejected' - } - const result = await runtime.sendTerminal(args.terminal, action, { - reserveWrite: (writePtyId) => { - const claim = runtime.beginMobileInputFloor(writePtyId, clientId) - if (!claim) { - throw new Error('mobile_input_floor_unavailable') - } - floorClaim.current = claim - }, - afterWrite: () => commitMobileInputFloorClaim(floorClaim) - }) - if (!result.accepted) { - floorClaim.current?.rollback() - return 'rejected' - } - return 'delivered' - } catch (error) { - floorClaim.current?.rollback() - return isTerminalStreamInputRejection(error) ? 'rejected' : 'failed' - } -} - -type MobileInputFloorClaimHolder = { - current: ReturnType -} - -async function commitMobileInputFloorClaim(claim: MobileInputFloorClaimHolder): Promise { - const current = claim.current - if (!current) { - return - } - try { - await current.commit() - } finally { - // Why: the runtime may yield before the next write, which then needs a fresh reservation if desktop reclaimed the floor. - if (claim.current === current) { - claim.current = null - } - } -} - -function getTerminalSendGuardRefusedReason(error: unknown): 'no-agent' | 'permission' | undefined { - const message = error instanceof Error ? error.message : String(error) - if (message.includes('terminal_guard_permission')) { - return 'permission' - } - if (message.includes('terminal_guard_no_agent')) { - return 'no-agent' - } - return undefined -} - -function isTerminalSendGuardNotWritable(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return message.includes('terminal_guard_not_writable') -} - -function assertTerminalSendExactPtyBinding( - runtime: OrcaRuntimeService, - handle: string, - expectedPtyId: string | undefined -): void { - try { - if (expectedPtyId && runtime.resolveLiveLeafForHandle(handle)?.ptyId === expectedPtyId) { - return - } - } catch { - // Fall through to the stable guarded-send result below. - } - throw new Error('terminal_guard_not_writable') -} - -function appendPendingMultiplexOutput( - stream: TerminalMultiplexStream, - data: string, - meta?: TerminalOutputMeta -): void { - const remainingBudget = Math.max( - 1, - TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - stream.pendingOutputBytes - ) - const measurement = measureTerminalStreamByteLength(data, { - stopAfterBytes: remainingBudget - }) - stream.pendingOutput.push({ data, bytes: measurement.byteLength, meta }) - stream.pendingOutputBytes += measurement.byteLength - const trimmed = trimPendingOutputToBudget(stream.pendingOutput, stream.pendingOutputBytes) - stream.pendingOutputBytes = trimmed.bytes - stream.pendingOutputOverflowed ||= trimmed.overflowed -} - -function getOutputAfterSnapshotSeq( - chunk: TerminalOutputChunk, - snapshotSeq: number | undefined -): TerminalOutputChunk | null { - if ( - typeof snapshotSeq !== 'number' || - typeof chunk.meta?.seq !== 'number' || - typeof chunk.meta.rawLength !== 'number' - ) { - return chunk - } - if (chunk.meta.seq <= snapshotSeq) { - return null - } - const chunkStartSeq = chunk.meta.seq - chunk.meta.rawLength - if (chunkStartSeq >= snapshotSeq) { - return chunk - } - if (chunk.meta.transformed) { - return null - } - const offset = snapshotSeq - chunkStartSeq - return { - data: chunk.data.slice(offset), - bytes: chunk.bytes, - meta: { - ...chunk.meta, - rawLength: chunk.meta.rawLength - offset, - sourceRanges: sliceTerminalOutputSourceRanges( - chunk.meta.sourceRanges, - offset, - chunk.data.length - ) - } - } -} - -function stripSnapshotBoundaryQuerySuffixes( - data: string, - dataStartSeq: number, - snapshotSeq: number, - queries: TerminalReplyQuerySequence[] -): string { - let output = '' - let offset = 0 - for (const query of queries) { - if (query.startSeq >= snapshotSeq || query.endSeq <= snapshotSeq) { - continue - } - const removeStart = Math.max(0, query.startSeq - dataStartSeq) - const removeEnd = Math.min(data.length, query.endSeq - dataStartSeq) - if (removeEnd <= offset || removeStart >= data.length) { - continue - } - output += data.slice(offset, removeStart) - offset = removeEnd - } - return output + data.slice(offset) -} - -function appendAckPendingOutput( - stream: TerminalMultiplexStream, - chunk: TerminalOutputFrameChunk -): void { - stream.ackPendingOutput.push(chunk) - stream.ackPendingOutputBytes += chunk.bytes.byteLength - let omittedChunkCount = 0 - while ( - stream.ackPendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && - omittedChunkCount < stream.ackPendingOutput.length - ) { - stream.ackPendingOutputBytes -= stream.ackPendingOutput[omittedChunkCount]!.bytes.byteLength - omittedChunkCount += 1 - } - if (omittedChunkCount > 0) { - stream.ackPendingOutput.splice(0, omittedChunkCount) - stream.ackPendingOutputOverflowed = true - } -} - -function trimPendingOutputToBudget( - pendingOutput: TerminalOutputChunk[], - pendingOutputBytes: number -): { bytes: number; overflowed: boolean } { - let omittedChunkCount = 0 - while ( - pendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && - omittedChunkCount < pendingOutput.length - ) { - const chunk = pendingOutput[omittedChunkCount] - pendingOutputBytes -= chunk.bytes - omittedChunkCount += 1 - } - if (omittedChunkCount > 0) { - pendingOutput.splice(0, omittedChunkCount) - } - return { bytes: pendingOutputBytes, overflowed: omittedChunkCount > 0 } -} - -function trimPendingOutputCoveredBySnapshot( - pendingOutput: TerminalOutputChunk[], - snapshotSeq: number | undefined -): { chunks: TerminalOutputChunk[]; bytes: number } { - if (typeof snapshotSeq !== 'number') { - return { - chunks: pendingOutput, - bytes: pendingOutput.reduce((sum, chunk) => sum + chunk.bytes, 0) - } - } - const chunks: TerminalOutputChunk[] = [] - let bytes = 0 - for (const chunk of pendingOutput) { - const chunkSeq = chunk.meta?.seq - const rawLength = chunk.meta?.rawLength ?? chunk.data.length - if (typeof chunkSeq !== 'number' || rawLength !== chunk.data.length) { - chunks.push(chunk) - bytes += chunk.bytes - continue - } - const startSeq = chunkSeq - rawLength - if (snapshotSeq >= chunkSeq) { - continue - } - if (snapshotSeq <= startSeq) { - chunks.push(chunk) - bytes += chunk.bytes - continue - } - const data = chunk.data.slice(snapshotSeq - startSeq) - const slicedBytes = terminalStreamByteLength(data) - chunks.push({ data, bytes: slicedBytes, meta: undefined }) - bytes += slicedBytes - } - return { chunks, bytes } -} - -function* iterateTerminalStreamTextPayloads(data: string): Generator> { - if (!data) { - return - } - for (const chunk of iterateTerminalOutputFrameChunks(data)) { - yield chunk.bytes - } -} - -function isTerminalReadPayloadIncomplete(read: { truncated: boolean; limited?: boolean }): boolean { - // Why: a limited preview is an incomplete payload even when the retained buffer wasn't truncated. - return read.truncated || read.limited === true -} - -function normalizeMultiplexSnapshotScrollbackRows(value: number | undefined): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return undefined - } - return Math.max(0, Math.min(50_000, Math.floor(value))) -} - -function requestedSnapshotScrollbackCandidates(requestedRows: number | undefined): number[] { - const candidates = [requestedRows ?? 0, 1000, 500, 250, 100, 25, 0] - .filter((rows): rows is number => typeof rows === 'number') - .map((rows) => Math.max(0, Math.min(50_000, Math.floor(rows)))) - return [...new Set(candidates)] -} - -async function serializeBudgetedRequestedSnapshot( - runtime: OrcaRuntimeService, - ptyId: string, - scrollbackRows: number | undefined -): Promise { - const requestedRows = scrollbackRows ?? 0 - for (const rows of requestedSnapshotScrollbackCandidates(scrollbackRows)) { - const serialized = await runtime.serializeAuthoritativeTerminalBuffer(ptyId, { - scrollbackRows: rows - }) - if (!serialized) { - return null - } - const scrollbackAnsi = - 'scrollbackAnsi' in serialized && typeof serialized.scrollbackAnsi === 'string' - ? serialized.scrollbackAnsi - : '' - const data = scrollbackAnsi + serialized.data - const overByteBudget = terminalStreamByteLengthExceeds(data, REQUESTED_SNAPSHOT_BYTE_BUDGET) - if (!overByteBudget || rows === 0) { - return { - ...serialized, - data, - scrollbackRows: rows, - truncatedByByteBudget: rows < requestedRows || overByteBudget - } - } - } - return null -} - -function sendSnapshotFrames( - sendFrame: ( - opcode: TerminalStreamOpcode, - payload?: Uint8Array - ) => boolean | void, - options: SnapshotFrameOptions -): { bytes: number; chunks: number; published: boolean } { - if ( - sendFrame( - TerminalStreamOpcode.SnapshotStart, - encodeTerminalStreamJson({ - kind: options.kind, - cols: options.cols, - rows: options.rows, - requestId: options.requestId, - displayMode: options.displayMode, - reason: options.reason, - unavailable: options.unavailable, - seq: options.seq, - cwd: options.cwd, - source: options.source, - oscLinks: options.oscLinks, - pendingEscapeTailAnsi: options.pendingEscapeTailAnsi, - // Why conditional and additive: old clients ignore the unknown field, - // and a new client must read absence as unknown rather than zero, so - // no opcode or capability negotiation is involved (Rule 1 of - // docs/reference/remote-wire-compatibility.md). - // Why `seq` is required: the flags are only proven at this frame's own - // seq, so without a replay boundary the client cannot order them. - ...(typeof options.seq === 'number' && options.kittyKeyboardFlags !== undefined - ? { kittyKeyboardFlags: options.kittyKeyboardFlags } - : {}), - truncated: options.truncated === true, - truncatedByByteBudget: options.truncatedByByteBudget === true - }) - ) === false - ) { - return { bytes: 0, chunks: 0, published: false } - } - let chunks = 0 - let bytes = 0 - for (const chunk of iterateTerminalStreamTextPayloads(options.data)) { - if (sendFrame(TerminalStreamOpcode.SnapshotChunk, chunk) === false) { - return { bytes, chunks, published: false } - } - chunks++ - bytes += chunk.byteLength - } - const published = sendFrame(TerminalStreamOpcode.SnapshotEnd) !== false - return { bytes, chunks, published } -} - -async function serializeBudgetedMobileSnapshot( - runtime: OrcaRuntimeService, - ptyId: string, - isMobile: boolean -): Promise { - if (!isMobile) { - const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: 0 }) - return serialized - ? { - ...serialized, - data: (serialized.scrollbackAnsi ?? '') + serialized.data, - scrollbackRows: 0, - truncatedByByteBudget: false - } - : null - } - const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] - for (const rows of candidates) { - const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: rows }) - if (!serialized) { - return null - } - const data = (serialized.scrollbackAnsi ?? '') + serialized.data - const overByteBudget = terminalStreamByteLengthExceeds(data, MOBILE_SNAPSHOT_BYTE_BUDGET) - if (!overByteBudget || rows === 0) { - return { - ...serialized, - data, - scrollbackRows: rows, - truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget - } - } - } - return null -} - -async function serializeStableMobileRendererSnapshot( - runtime: OrcaRuntimeService, - ptyId: string -): Promise { - const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] - let candidateIndex = 0 - for (let attempt = 0; attempt < candidates.length; attempt += 1) { - // Why: advance toward zero scrollback each retry so the final attempt always has a bounded payload. - candidateIndex = Math.max(candidateIndex, attempt) - const rows = candidates[candidateIndex] - const outputSequenceBefore = runtime.getPtyOutputSequence(ptyId) - const serialized = await runtime.serializeRendererTerminalBuffer(ptyId, { - scrollbackRows: rows - }) - const outputSequenceAfter = runtime.getPtyOutputSequence(ptyId) - if (outputSequenceBefore !== outputSequenceAfter) { - continue - } - if (!serialized) { - return null - } - const overByteBudget = terminalStreamByteLengthExceeds( - serialized.data, - MOBILE_SNAPSHOT_BYTE_BUDGET - ) - if (!overByteBudget || rows === 0) { - return { - ...serialized, - scrollbackRows: rows, - truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget - } - } - candidateIndex += 1 - } - return null -} - -// Why: mobile xterm can't rewrap the HARD newlines baked into a restored snapshot, so a real reflow re-serializes and replays the FULL buffer at the new cols. -async function sendMobileResizeRestream( - runtime: OrcaRuntimeService, - ptyId: string, - sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array) => void, - event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number }, - shouldSend?: () => boolean -): Promise { - // Why: only a true geometry reflow rewraps scrollback; a dimensionless mode-change would re-send the whole buffer for nothing. - if (event.reason !== 'apply-layout' || runtime.isTerminalAlternateScreen(ptyId)) { - return false - } - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true) - if (!serialized) { - return false - } - if (shouldSend && !shouldSend()) { - return true - } - sendSnapshotFrames(sendFrame, { - kind: 'resized', - cols: serialized.cols, - rows: serialized.rows, - displayMode: event.displayMode, - reason: event.reason, - seq: event.seq ?? serialized.seq, - source: serialized.source, - cwd: serialized.cwd, - oscLinks: serialized.oscLinks, - truncated: false, - truncatedByByteBudget: serialized.truncatedByByteBudget, - data: serialized.data - }) - return true -} - -async function updateViewportForClient( - runtime: OrcaRuntimeService, - ptyId: string, - subscriptionKey: string, - client: TerminalViewportClient, - viewport: { cols: number; rows: number }, - defaultType: 'mobile' | 'desktop', - // Why: the one-shot RPC has no disconnect hook, so 'refresh' only updates a stream-owned floor; stream paths that own cleanup 'register'. - registration: 'register' | 'refresh' = 'register', - claim = false -): Promise<{ updated: boolean; applied: boolean }> { - const type = client.type ?? defaultType - if (type === 'mobile') { - return runtime.updateMobileViewport(ptyId, client.id, viewport) - } - // Why: stream attachment observes geometry without taking control; a later claim frame makes it authoritative. - const updated = - registration === 'refresh' - ? await runtime.refreshRemoteDesktopViewer( - ptyId, - client.id, - viewport.cols, - viewport.rows, - claim - ) - : await runtime.updateRemoteDesktopViewer( - ptyId, - subscriptionKey, - client.id, - viewport.cols, - viewport.rows, - claim - ) - return { updated, applied: updated } -} - -const TerminalHandle = z.object({ - terminal: requiredString('Missing terminal handle') -}) - -const TerminalFocus = TerminalHandle.extend({ - navigation: z.enum(['caller', 'host']).optional() -}) - -const TerminalListParams = z.object({ - worktree: OptionalString, - limit: OptionalFiniteNumber, - handles: z - .array(requiredString('Missing terminal handle').pipe(z.string().max(256))) - .max(64) - .optional(), - requireFreshPtyLiveness: z.boolean().optional(), - // Why: layouts are ~31% of a large listing and only the human CLI formatter - // reads them. Absent means "include" so pre-flag clients keep rendering them. - includeVisualLayouts: z.boolean().optional() -}) - -const TerminalResolveActive = z.object({ - worktree: OptionalString -}) - -const TerminalResolvePane = z.object({ - paneKey: requiredString('Missing pane key'), - worktreeId: OptionalString -}) - -const TerminalRecoverPane = z.object({ - paneKey: requiredString('Missing pane key'), - worktreeId: requiredString('Missing worktree ID'), - expectedTerminal: requiredString('Missing expected terminal handle').optional() -}) - -const TerminalRead = TerminalHandle.extend({ - cursor: z - .unknown() - .transform((value) => { - if (value === undefined) { - return undefined - } - if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { - return Number.NaN - } - return value - }) - .pipe( - z - .number() - .optional() - .refine((v) => v === undefined || Number.isFinite(v), { - message: 'Cursor must be a non-negative integer' - }) - ) - .optional(), - limit: OptionalFiniteNumber, - // Why: optional so an older host that does not understand it simply drops the key and answers - // with its usual stream read; the response's `source` is what tells the caller which it got. - screen: z.literal(true).optional() -}).refine((params) => !(params.screen === true && params.cursor !== undefined), { - // Why: a cursor pages through accumulated output; a screen is the current frame with nothing - // behind it. Honoring both would answer with rendered lines carrying the stream's pagination - // metadata — two frames of reference in one payload, which is the confusion `source` exists to - // remove. The CLI already refuses the pair, but the RPC is reachable without it. - message: 'Cursor cannot be combined with a screen read' -}) - -// Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct. -const TerminalRename = TerminalHandle.extend({ - title: z.custom((value) => value === null || typeof value === 'string', { - message: 'Missing --title (pass empty string or null to reset)' - }) -}) - -const TerminalSend = TerminalHandle.extend({ - text: OptionalString, - enter: z.unknown().optional(), - interrupt: z.unknown().optional(), - // Why: older hosts strip this optional intent and retain their direct-send behavior. - agentPrompt: z.literal(true).optional(), - resolvedLaunchDraft: z - .object({ - text: z.string(), - createdAt: z.number().finite() - }) - .optional(), - requireAgentStatus: z.enum(['sendable']).optional(), - // Why: terminal-generated replies are valid input but must not transfer the shared terminal floor. - inputKind: z.enum(['query-reply']).optional(), - // Why: identifies the caller for the driver state machine; when absent (older clients) the server falls back to the most recent mobile actor (docs/mobile-presence-lock.md). - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop').optional() - }) - .optional(), - viewport: z - .object({ - cols: z.number().int().min(1).max(1000), - rows: z.number().int().min(1).max(500) - }) - .optional(), - claimViewport: z.literal(true).optional() -}) - -const TerminalViewport = z.object({ - cols: z.number().int().min(1).max(1000), - rows: z.number().int().min(1).max(500) -}) - -const TerminalWait = TerminalHandle.extend({ - for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', { - message: 'Invalid --for value. Supported: exit, tui-idle' - }), - timeoutMs: OptionalFiniteNumber -}) - -const TerminalCreateParams = z.object({ - worktree: OptionalString, - clientMutationId: z.string().min(1).max(128).optional(), - reconcileExisting: z.boolean().optional(), - command: OptionalString, - startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), - env: z.record(z.string(), z.string()).optional(), - envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), - launchConfig: z - .object({ - agentCommand: z.string().optional(), - agentArgs: z.string(), - agentEnv: z.record(z.string(), z.string()), - ompResumeFilePath: z - .string() - .min(1) - .max(32 * 1024) - .optional() - }) - .optional(), - resumeProviderSession: z - .object({ - key: z.enum(['session_id', 'conversation_id']), - id: z.string().min(1).max(512), - transcriptPath: z.string().min(1).max(32_768).optional() - }) - .optional(), - launchToken: OptionalString, - launchAgent: z.string().refine(isTuiAgent).optional(), - terminalColorQueryReplies: z - .object({ - foreground: z.string().max(128).optional(), - background: z.string().max(128).optional() - }) - .optional(), - title: OptionalString, - focus: z.unknown().optional(), - rendererBacked: z.unknown().optional(), - activate: z.unknown().optional(), - presentation: z.enum(['background', 'focused']).optional(), - tabId: OptionalString, - leafId: OptionalString -}) - -const TerminalSplit = TerminalHandle.extend({ - direction: z - .unknown() - .transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined)) - .pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()])) - .optional(), - command: OptionalString, - env: z.record(z.string(), z.string()).optional(), - telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional() -}) - -const TerminalStop = z.object({ - worktree: requiredString('Missing worktree selector') -}) - -const TerminalSleep = TerminalStop - -const TerminalStopExact = TerminalStop.extend({ - expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1), - keepHistory: z.boolean().optional(), - targetOnly: z.boolean().optional() -}) - -const AgentTeamsTmuxCompat = z.object({ - teamId: requiredString('Missing agent team ID'), - token: requiredString('Missing agent team token'), - envPane: requiredString('Missing tmux pane identity'), - cwd: OptionalString, - argv: z.array(z.string()) -}) - -const AgentTeamsPrepareLaunch = z.object({ - paneKey: requiredString('Missing pane key'), - env: z.record(z.string(), z.string()).optional() -}) - -const TerminalResizeForClient = z.discriminatedUnion('mode', [ - z.object({ - terminal: requiredString('Missing terminal handle'), - mode: z.literal('mobile-fit'), - cols: z.number().finite().positive(), - rows: z.number().finite().positive(), - clientId: requiredString('Missing client ID') - }), - z.object({ - terminal: requiredString('Missing terminal handle'), - mode: z.literal('restore'), - clientId: requiredString('Missing client ID') - }) -]) - -const TerminalSubscribe = TerminalHandle.extend({ - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop') - }) - .optional(), - viewport: TerminalViewport.optional(), - capabilities: z - .object({ - terminalBinaryStream: z.literal(1).optional(), - desktopViewportClaims: z.literal(1).optional(), - mobileInputLeaseOnly: z.literal(1).optional(), - writeUnavailable: z.literal(1).optional() - }) - .optional() -}) - -const TerminalMultiplex = z.object({}) - -const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ - streamId: z.number().int().min(1), - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop') - }) - .optional(), - viewport: TerminalViewport.optional(), - capabilities: z - .object({ - ackOutput: z.literal(1).optional(), - ackOutputSourceRanges: z.literal(1).optional(), - desktopViewportClaims: z.literal(1).optional(), - outputPause: z.literal(1).optional(), - writeUnavailable: z.literal(1).optional() - }) - .optional() -}) - -const TerminalMultiplexLegacyAckFrame = z - .object({ - bytes: z.number().int().nonnegative() - }) - .strict() - -const TerminalMultiplexSourceRangeAckFrame = z - .object({ - streamGeneration: z.string().min(1), - ackedEndByte: z.number().int().nonnegative() - }) - .strict() - -const TerminalMultiplexSnapshotRequestFrame = z.object({ - requestId: z.number().int().positive().optional(), - scrollbackRows: z.number().finite().optional() -}) - -const TerminalSetDisplayMode = TerminalHandle.extend({ - // Why: 'auto' = mobile drives dims while subscribed (desktop restores on last-leave); 'desktop' = no resize, mobile scales to fit. - mode: z.enum(['auto', 'desktop']), - // Why: identifies the caller for the driver state machine; optional for older mobile clients. - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop').optional() - }) - .optional(), - // Why: carries the measured viewport so an 'auto' toggle on a viewport-less record can phone-fit instead of no-op'ing. - viewport: z - .object({ - cols: z.number().int().positive(), - rows: z.number().int().positive() - }) - .optional() -}) - -const TerminalUnsubscribe = z.object({ - subscriptionId: requiredString('Missing subscription ID'), - // Why: lets the server rebuild the composite `${terminal}:${clientId}` cleanup key when older clients pass a bare subscriptionId (docs/mobile-presence-lock.md). - client: z - .object({ - id: requiredString('Missing client ID') - }) - .optional() -}) - -// Why: in-place update avoids an unsubscribe→resubscribe that flashed the lock banner and stranded the PTY at phone dims (docs/mobile-presence-lock.md). -const TerminalUpdateViewport = TerminalHandle.extend({ - client: z.object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('mobile').optional() - }), - viewport: z.object({ - cols: z.number().int().min(20).max(240), - rows: z.number().int().min(8).max(120) - }), - claim: z.boolean().optional() -}) - -// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md); `null` = Indefinite, finite ms clamped to [5_000, 60min] server-side. -const TerminalSetAutoRestoreFit = z.object({ - ms: z.number().nullable() -}) + TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS, + TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS +} from './terminal/terminal-viewport-methods' +// The manifest order is part of the released RPC contract. Keep composition here so the +// public entry point owns registration rather than forwarding an aggregated child export. export const TERMINAL_METHODS: RpcAnyMethod[] = [ - defineMethod({ - name: 'terminal.list', - params: TerminalListParams, - handler: async (params, { runtime }) => - runtime.listTerminals(params.worktree, params.limit, { - handles: params.handles, - requireFreshPtyLiveness: params.requireFreshPtyLiveness, - includeVisualLayouts: params.includeVisualLayouts - }) - }), - defineMethod({ - name: 'terminal.resolveActive', - params: TerminalResolveActive, - handler: async (params, { runtime }) => ({ - handle: await runtime.resolveActiveTerminal(params.worktree) - }) - }), - defineMethod({ - name: 'terminal.resolvePane', - params: TerminalResolvePane, - handler: async (params, { runtime }) => ({ - terminal: runtime.resolveTerminalPane(params.paneKey, params.worktreeId) - }) - }), - defineMethod({ - name: 'terminal.recoverPane', - params: TerminalRecoverPane, - handler: async (params, { runtime }) => ({ - terminal: await runtime.recoverTerminalPane( - params.paneKey, - params.worktreeId, - params.expectedTerminal - ) - }) - }), - defineMethod({ - name: 'terminal.show', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - terminal: await runtime.showTerminal(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.read', - params: TerminalRead, - handler: async (params, { runtime }) => ({ - terminal: await runtime.readTerminal(params.terminal, { - cursor: params.cursor, - limit: params.limit, - screen: params.screen - }) - }) - }), - defineMethod({ - name: 'terminal.inspectProcess', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - process: await runtime.inspectTerminalProcess(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.isRunningAgent', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - isRunningAgent: await runtime.isTerminalRunningAgent(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.agentStatus', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - agentStatus: await runtime.getTerminalAgentStatus(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.rename', - params: TerminalRename, - handler: async (params, { runtime }) => ({ - rename: await runtime.renameTerminal(params.terminal, params.title || null) - }) - }), - defineMethod({ - name: 'terminal.clearBuffer', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - clear: await runtime.clearTerminalBuffer(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.send', - params: TerminalSend, - handler: async (params, { runtime, clientId, signal }) => { - await assertTerminalSendTextWithinLimit(params.text) - await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text) - const queryReplyClientId = clientId ?? params.client?.id - if ( - params.inputKind === 'query-reply' && - (!params.text || - !isTerminalQueryReply(params.text) || - params.enter === true || - params.interrupt === true || - params.agentPrompt === true || - params.requireAgentStatus !== undefined || - params.client?.type !== 'mobile' || - !queryReplyClientId || - (clientId !== undefined && params.client.id !== clientId)) - ) { - throw new InvalidArgumentError('Invalid terminal query reply') - } - // Why: a stale handle must fail with terminal_handle_stale, not evaluate driver/lock state against the wrong PTY (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null - if ( - params.inputKind === 'query-reply' && - leaf?.ptyId && - !runtime.isMobileTerminalQueryReplyAuthority(leaf.ptyId, queryReplyClientId!) - ) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - if ( - leaf?.ptyId && - params.client?.type === 'desktop' && - params.claimViewport === true && - params.viewport - ) { - const claim = await updateViewportForClient( - runtime, - leaf.ptyId, - `send:${params.client.id}`, - params.client, - params.viewport, - 'desktop', - 'refresh', - true - ) - // Why: a stream-less request can't safely create ownership, so never write at stale geometry. - if (!claim.updated || isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - } - const hasText = typeof params.text === 'string' && params.text.length > 0 - const hasSuffix = params.enter === true || params.interrupt === true - if (params.requireAgentStatus === 'sendable' && hasText && hasSuffix) { - // Why: guarded sends are two-phase; reject combined payload + submit so a guard flip can't cause partial delivery. - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - // Why: recheck permission/no-agent state immediately before accepting the PTY write. - const assertSendPreconditions = - params.requireAgentStatus === 'sendable' - ? async (ptyId?: string): Promise => { - await assertTerminalAgentSendable({ - runtime, - handle: params.terminal, - assertWritable: () => { - assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) - if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - throw new Error('terminal_guard_not_writable') - } - } - }) - } - : undefined - if (params.requireAgentStatus === 'sendable') { - try { - await assertSendPreconditions?.(leaf?.ptyId ?? undefined) - } catch (error) { - if (isTerminalSendGuardNotWritable(error)) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - const refusedReason = getTerminalSendGuardRefusedReason(error) - if (!refusedReason) { - throw error - } - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0, - refusedReason - } - } - } - } - const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) - const mobileFloorClaim: MobileInputFloorClaimHolder = { current: null } - const beforeWrite = assertSendPreconditions - const useSettledAgentPrompt = - params.agentPrompt === true && - hasText && - params.enter === true && - params.interrupt !== true && - params.client?.type === 'desktop' && - (await runtime.isTerminalRunningSettledPromptAgent(params.terminal)) - const reserveWrite = - params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId - ? (ptyId: string): void => { - const claim = runtime.beginMobileInputFloor(ptyId, mobileFloorClientId) - if (!claim) { - throw new Error('mobile_input_floor_unavailable') - } - mobileFloorClaim.current = claim - } - : undefined - let result - try { - result = useSettledAgentPrompt - ? await runtime.sendTerminalAgentPrompt(params.terminal, params.text!, { - beforeWrite, - signal - }) - : await runtime.sendTerminal( - params.terminal, - { - text: params.text, - enter: params.enter === true, - interrupt: params.interrupt === true - }, - { - beforeWrite, - ...(reserveWrite ? { reserveWrite } : {}), - ...(params.inputKind !== 'query-reply' && mobileFloorClientId - ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } - : {}) - } - ) - } catch (error) { - mobileFloorClaim.current?.rollback() - const refusedReason = getTerminalSendGuardRefusedReason(error) - if (refusedReason) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0, - refusedReason - } - } - } - if (isTerminalSendGuardNotWritable(error)) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - throw error - } - if (result.accepted !== true) { - mobileFloorClaim.current?.rollback() - } - if ( - result.accepted === true && - params.enter === true && - params.client?.type === 'mobile' && - params.resolvedLaunchDraft - ) { - runtime.notifyNativeChatLaunchDraftResolved(params.terminal, params.resolvedLaunchDraft) - } - // Why: deliberate mobile input takes the floor (drives `* → mobile{clientId}`); clientless sends fall back to the current mobile driver. - return { send: result } - } - }), - defineMethod({ - name: 'terminal.wait', - params: TerminalWait, - handler: async (params, { runtime, signal }) => ({ - wait: await runtime.waitForTerminal(params.terminal, { - condition: params.for, - timeoutMs: params.timeoutMs, - signal - }) - }) - }), - defineMethod({ - name: 'terminal.create', - params: TerminalCreateParams, - handler: async (params, { runtime, pairedDeviceId, clientId }) => ({ - terminal: await runtime.dedupeTerminalCreate( - pairedDeviceId ?? clientId ?? 'local', - params.worktree, - params.clientMutationId, - params.reconcileExisting === true, - (canonicalWorktreeSelector, preAllocatedHandle) => - runtime.createTerminal(canonicalWorktreeSelector, { - command: params.command, - startupCommandDelivery: params.startupCommandDelivery, - env: params.env, - envToDelete: params.envToDelete, - ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), - ...(params.resumeProviderSession - ? { resumeProviderSession: params.resumeProviderSession } - : {}), - ...(params.launchToken ? { launchToken: params.launchToken } : {}), - ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), - ...(params.terminalColorQueryReplies - ? { terminalColorQueryReplies: params.terminalColorQueryReplies } - : {}), - title: params.title, - focus: params.focus === true, - rendererBacked: params.rendererBacked === true, - activate: params.activate === true, - presentation: params.presentation, - tabId: params.tabId, - leafId: params.leafId, - ...(preAllocatedHandle ? { preAllocatedHandle } : {}) - }) - ) - }) - }), - defineMethod({ - name: 'terminal.split', - params: TerminalSplit, - handler: async (params, { runtime }) => ({ - split: await runtime.splitTerminal(params.terminal, { - direction: params.direction, - command: params.command, - env: params.env, - telemetrySource: params.telemetrySource - }) - }) - }), - defineMethod({ - name: 'terminal.stop', - params: TerminalStop, - handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree) - }), - defineMethod({ - name: 'terminal.sleep', - params: TerminalSleep, - handler: async (params, { runtime }) => runtime.sleepTerminalsForWorktree(params.worktree) - }), - defineMethod({ - name: 'terminal.stopExact', - params: TerminalStopExact, - handler: async (params, { runtime }) => - runtime.stopExactTerminalsForWorktree(params.worktree, params.expectedPtyIds, { - keepHistory: params.keepHistory, - targetOnly: params.targetOnly - }) - }), - defineMethod({ - name: 'terminal.resizeForClient', - params: TerminalResizeForClient, - handler: async (params, { runtime }) => { - // Why: a stale handle must fail with terminal_handle_stale, not resize the wrong PTY (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - if (!leaf?.ptyId) { - throw new Error('no_connected_pty') - } - const result = await runtime.resizeForClient( - leaf.ptyId, - params.mode, - params.clientId, - params.mode === 'mobile-fit' ? params.cols : undefined, - params.mode === 'mobile-fit' ? params.rows : undefined - ) - return { - terminal: { - handle: params.terminal, - ...result - } - } - } - }), - defineMethod({ - name: 'terminal.focus', - params: TerminalFocus, - handler: async (params, { runtime, clientKind }) => ({ - focus: await runtime.focusTerminal(params.terminal, { - navigateHost: navigationTargetsHost( - resolveRuntimeNavigationTarget({ navigation: params.navigation, clientKind }) - ) - }) - }) - }), - defineMethod({ - name: 'terminal.close', - params: TerminalHandle, - handler: async (params, context) => ({ - close: await withTerminalCloseAttribution( - 'terminal.close', - context, - 'terminal', - params.terminal, - () => context.runtime.closeTerminal(params.terminal) - ) - }) - }), - defineMethod({ - name: 'terminal.closeTab', - params: TerminalHandle, - handler: async (params, context) => ({ - close: await withTerminalCloseAttribution( - 'terminal.closeTab', - context, - 'terminal-tab', - params.terminal, - () => context.runtime.closeTerminalTab(params.terminal) - ) - }) - }), - defineMethod({ - name: 'agentTeams.tmuxCompat', - params: AgentTeamsTmuxCompat, - handler: async (params, { runtime }) => ({ - tmux: await runtime.handleAgentTeamsTmuxCompat(params) - }) - }), - defineMethod({ - name: 'agentTeams.prepareLaunch', - params: AgentTeamsPrepareLaunch, - handler: async (params, { runtime }) => ({ - launch: await runtime.prepareClaudeAgentTeamsLeader({ - paneKey: params.paneKey, - baseEnv: params.env - }) - }) - }), - defineMethod({ - name: 'terminal.setDisplayMode', - params: TerminalSetDisplayMode, - handler: async (params, { runtime }) => { - // Why: a stale handle must fail with terminal_handle_stale, not mutate the wrong PTY's display mode/viewport (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - if (!leaf?.ptyId) { - throw new Error('no_connected_pty') - } - // Why: late-bind viewport for desktop-subscribed callers; otherwise an 'auto' toggle skips phone-fit and nothing resizes. - if (params.viewport && params.client?.id) { - runtime.updateMobileSubscriberViewport(leaf.ptyId, params.client.id, params.viewport) - } - if (params.client && params.client.type === 'mobile' && params.mode !== 'desktop') { - runtime.markMobileActor(leaf.ptyId, params.client.id) - } - runtime.setMobileDisplayMode(leaf.ptyId, params.mode) - await runtime.applyMobileDisplayMode(leaf.ptyId) - return { mode: params.mode, seq: runtime.getLayout(leaf.ptyId)?.seq } - } - }), - defineMethod({ - name: 'terminal.restoreFit', - params: TerminalHandle, - handler: async (params, { runtime }) => { - // Why: a stale handle must fail with terminal_handle_stale, not reclaim the wrong PTY to desktop dims (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - if (!leaf?.ptyId) { - throw new Error('no_connected_pty') - } - return { restored: await runtime.reclaimTerminalForDesktop(leaf.ptyId) } - } - }), - defineMethod({ - name: 'terminal.getDisplayMode', - params: TerminalHandle, - handler: async (params, { runtime }) => { - const leaf = runtime.resolveLeafForHandle(params.terminal) - const mode = leaf?.ptyId ? runtime.getMobileDisplayMode(leaf.ptyId) : 'auto' - const isPhoneFitted = leaf?.ptyId ? runtime.isMobileSubscriberActive(leaf.ptyId) : false - return { mode, isPhoneFitted } - } - }), - defineMethod({ - name: 'terminal.updateViewport', - params: TerminalUpdateViewport, - handler: async (params, { runtime }) => { - // Why: a stale handle must fail with terminal_handle_stale, not write viewport state to the wrong PTY (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - if (!leaf?.ptyId) { - throw new Error('no_connected_pty') - } - const viewportUpdate = await updateViewportForClient( - runtime, - leaf.ptyId, - `viewport:${params.client.id}`, - params.client, - params.viewport, - 'mobile', - // Why: one-shot RPC with no disconnect hook — refresh the existing stream-owned floor, never create a leak-prone one. - 'refresh', - params.claim === true - ) - return { ...viewportUpdate, seq: runtime.getLayout(leaf.ptyId)?.seq } - } - }), - // Why: one streaming RPC owns the binary socket and routes many panes by streamId; legacy subscribe stays as fallback. - defineStreamingMethod({ - name: 'terminal.multiplex', - params: TerminalMultiplex, - handler: async ( - _params, - { runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal }, - emit - ) => { - if (!sendBinary || !registerBinaryStreamHandler || !connectionId) { - throw new Error('binary_terminal_stream_required') - } - - let closed = false - let cursor = 0 - const streams = new Map() - const sourceRangeRegistry = new TerminalSourceRangeRegistry() - const pendingPtyWaitControllers = new Map>() - let ackTotalInFlightBytes = 0 - let ackTotalWindowBytes = TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES - let ackFlushCursorStreamId: number | null = null - let resolveMultiplex = (): void => {} - const multiplexClosed = new Promise((resolve) => { - resolveMultiplex = resolve - }) - const sendFrame = ( - streamId: number, - opcode: TerminalStreamOpcode, - payload: Uint8Array = new Uint8Array(), - seq?: number, - onRejected?: () => void - ): boolean => { - if (closed) { - onRejected?.() - return false - } - // Why: a seq-less Output chunk must carry sentinel 0, not the control-frame cursor, or it poisons the client's frame-drop tracker. - const resolvedSeq = - typeof seq === 'number' ? seq : opcode === TerminalStreamOpcode.Output ? 0 : cursor++ - let sent: boolean | void - try { - sent = sendBinary( - encodeTerminalStreamFrame({ opcode, streamId, seq: resolvedSeq, payload }) - ) - } catch { - onRejected?.() - closeMultiplex() - return false - } - if (sent === false) { - onRejected?.() - // Why: false means the transport discarded this frame; reconnect is the only available retry boundary with an authoritative snapshot. - closeMultiplex() - return false - } - return true - } - const sendStreamError = (streamId: number, message: string): void => { - sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message)) - emit({ type: 'error', streamId, message }) - } - const notifyStreamWriteUnavailable = ( - stream: TerminalMultiplexStream, - outcome: TerminalStreamInputOutcome - ): void => { - if ( - closed || - streams.get(stream.streamId) !== stream || - outcome !== 'rejected' || - !stream.supportsWriteUnavailable - ) { - return - } - sendFrame(stream.streamId, TerminalStreamOpcode.WriteUnavailable) - } - const sendResizedFrame = ( - stream: TerminalMultiplexStream, - event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } - ): void => { - stream.lastResizeCols = event.cols - sendFrame( - stream.streamId, - TerminalStreamOpcode.Resized, - encodeTerminalStreamJson({ - cols: event.cols, - rows: event.rows, - displayMode: event.displayMode, - reason: event.reason, - seq: event.seq - }) - ) - } - const canSendAckGatedOutput = (stream: TerminalMultiplexStream, bytes: number): boolean => { - if (!stream.ackOutput) { - return true - } - return ( - stream.ackInFlightBytes + bytes <= stream.ackWindowBytes && - ackTotalInFlightBytes + bytes <= ackTotalWindowBytes && - (!stream.ackOutputSourceRanges || stream.sourceRangeLedger?.canAccept(bytes) === true) - ) - } - const sendAckGatedOutput = ( - stream: TerminalMultiplexStream, - chunk: TerminalOutputFrameChunk - ): boolean => { - const prepared = stream.ackOutputSourceRanges - ? stream.sourceRangeLedger?.prepareAccept( - chunk.bytes.byteLength, - chunk.displayLength, - chunk.sourceRanges ?? [], - chunk.seq - ) - : undefined - if (stream.ackOutputSourceRanges && prepared?.status !== 'ready') { - if (prepared?.status !== 'capacity') { - detachStream(stream.streamId, true) - } - return false - } - const admission = prepared?.status === 'ready' ? prepared.admission : undefined - const sent = sendFrame( - stream.streamId, - chunk.opcode ?? TerminalStreamOpcode.Output, - chunk.bytes, - chunk.seq, - admission?.rollback - ) - if (!sent) { - return false - } - if (admission && !admission.commit()) { - detachStream(stream.streamId, true) - return false - } - if (stream.ackOutput) { - stream.ackInFlightBytes += chunk.bytes.byteLength - ackTotalInFlightBytes += chunk.bytes.byteLength - } - return true - } - const queueOrSendOutput = ( - stream: TerminalMultiplexStream, - chunk: TerminalOutputFrameChunk - ): void => { - if (closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { - return - } - if ( - stream.ackPendingOutputOverflowed || - stream.ackPendingOutput.length > 0 || - !canSendAckGatedOutput(stream, chunk.bytes.byteLength) - ) { - appendAckPendingOutput(stream, chunk) - return - } - sendAckGatedOutput(stream, chunk) - } - const sendAckRecoverySnapshot = async (stream: TerminalMultiplexStream): Promise => { - if ( - closed || - streams.get(stream.streamId) !== stream || - stream.outputPaused || - stream.ackRecoverySnapshotInFlight - ) { - return - } - stream.ackRecoverySnapshotInFlight = true - let replacement: RemoteTerminalSourceRangeReplacementReservation | null = null - try { - const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0) - if (closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { - return - } - if (!serialized) { - throw new Error('Remote terminal recovery snapshot unavailable.') - } - if ( - stream.ackOutputSourceRanges && - (serialized.source === undefined || typeof serialized.seq !== 'number') - ) { - throw new Error('Remote terminal recovery snapshot source identity unavailable.') - } - if ( - stream.ackOutputSourceRanges && - serialized.source !== undefined && - typeof serialized.seq === 'number' - ) { - replacement = runtime.reserveRemoteTerminalSourceRangeReplacement( - { - ptyId: stream.ptyId, - consumerId: stream.remoteDesktopSubscriptionKey, - streamGeneration: stream.streamGeneration - }, - serialized.seq, - 'ack-pending-overflow' - ) - stream.sourceRangeReplacement = replacement - } - const displayMode = runtime.getMobileDisplayMode(stream.ptyId) - const publication = sendSnapshotFrames( - (opcode, payload) => - !closed && - streams.get(stream.streamId) === stream && - sendFrame(stream.streamId, opcode, payload), - { - kind: 'scrollback', - cols: serialized.cols, - rows: serialized.rows, - displayMode, - reason: 'ack-pending-overflow', - seq: serialized.seq, - source: serialized.source, - kittyKeyboardFlags: serialized.kittyKeyboardFlags, - truncatedByByteBudget: serialized.truncatedByByteBudget, - data: serialized.data - } - ) - if (!publication.published) { - throw new Error('Remote terminal recovery snapshot was not published.') - } - if (closed || streams.get(stream.streamId) !== stream) { - throw new Error('Remote terminal recovery snapshot stream detached.') - } - const localReplacement = replacement - ? typeof serialized.seq === 'number' - ? stream.sourceRangeLedger?.planSourceRangeReplacement(serialized.seq) - : null - : null - if (replacement && !localReplacement) { - throw new Error('Remote terminal recovery source ledger replacement unavailable.') - } - if ( - replacement && - (!serialized.source || - typeof serialized.seq !== 'number' || - !runtime.commitRemoteTerminalSourceRangeReplacement(replacement, { - source: serialized.source, - seq: serialized.seq - })) - ) { - throw new Error('Remote terminal recovery snapshot replacement was not accepted.') - } - localReplacement?.commit() - stream.sourceRangeReplacement = null - replacement = null - if (typeof serialized.seq === 'number') { - const snapshotSeq = serialized.seq - const retained = stream.ackPendingOutput.filter( - (chunk) => !(typeof chunk.seq === 'number' && chunk.seq <= snapshotSeq) - ) - stream.ackPendingOutput = retained - stream.ackPendingOutputBytes = retained.reduce( - (total, chunk) => total + chunk.bytes.byteLength, - 0 - ) - } - stream.ackPendingOutputOverflowed = false - } catch (error) { - if (replacement) { - if (stream.sourceRangeReplacement === replacement) { - stream.sourceRangeReplacement = null - runtime.rollbackRemoteTerminalSourceRangeReplacement( - replacement, - 'ack-pending-overflow-unpublished' - ) - } - replacement = null - } - if (closed || streams.get(stream.streamId) !== stream) { - return - } - sendStreamError( - stream.streamId, - error instanceof Error ? error.message : 'Remote terminal recovery snapshot failed.' - ) - detachStream(stream.streamId, true) - } finally { - if (streams.get(stream.streamId) === stream) { - stream.ackRecoverySnapshotInFlight = false - flushAllAckPendingOutput() - } - } - } - const flushAckPendingOutput = ( - stream: TerminalMultiplexStream, - maxChunks = Number.POSITIVE_INFINITY - ): number => { - if (stream.outputPaused) { - return 0 - } - if (stream.ackPendingOutputOverflowed) { - void sendAckRecoverySnapshot(stream) - return 0 - } - let flushed = 0 - while ( - flushed < stream.ackPendingOutput.length && - flushed < maxChunks && - canSendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!.bytes.byteLength) - ) { - if (!sendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!)) { - return flushed - } - flushed += 1 - } - if (flushed > 0) { - stream.ackPendingOutput.splice(0, flushed) - stream.ackPendingOutputBytes = stream.ackPendingOutput.reduce( - (total, pending) => total + pending.bytes.byteLength, - 0 - ) - } - return flushed - } - const flushAllAckPendingOutput = (): void => { - const ordered = Array.from(streams.values()) - ackFlushCursorStreamId = drainTerminalMultiplexRoundRobin({ - streams: ordered, - cursorStreamId: ackFlushCursorStreamId, - canContinue: () => !closed, - drainOne: (stream) => { - if (streams.get(stream.streamId) !== stream) { - return false - } - if (flushAckPendingOutput(stream, 1) > 0) { - return true - } - return false - } - }) - } - const acknowledgeOutput = (stream: TerminalMultiplexStream, bytes: number): void => { - if (!stream.ackOutput || bytes <= 0) { - return - } - const acknowledged = Math.min(stream.ackInFlightBytes, bytes) - stream.ackWindowBytes = Math.min( - TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES, - stream.ackWindowBytes + acknowledged - ) - ackTotalWindowBytes = Math.min( - TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES, - ackTotalWindowBytes + acknowledged - ) - stream.ackInFlightBytes -= acknowledged - ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - acknowledged) - flushAllAckPendingOutput() - } - const acknowledgeSourceRanges = ( - stream: TerminalMultiplexStream, - streamGeneration: string, - ackedEndByte: number - ): void => { - if (!stream.ackOutputSourceRanges) { - return - } - const result = stream.sourceRangeLedger?.acknowledge(streamGeneration, ackedEndByte) - if (!result) { - return - } - if (result.status !== 'accepted') { - return - } - if (result.settled.length > 0) { - runtime.settleRemoteTerminalSourceRanges( - { - ptyId: stream.ptyId, - consumerId: stream.remoteDesktopSubscriptionKey, - streamGeneration: stream.streamGeneration - }, - result.settled - ) - } - acknowledgeOutput(stream, result.acknowledgedBytes) - } - const detachSourceRangeConsumer = (stream: TerminalMultiplexStream, reason: string): void => { - if (!stream.sourceRangeConsumerAttached) { - return - } - stream.sourceRangeConsumerAttached = false - const ledger = stream.sourceRangeLedger - stream.sourceRangeLedger = null - if (!ledger) { - return - } - const identity = { - ptyId: stream.ptyId, - consumerId: stream.remoteDesktopSubscriptionKey, - streamGeneration: stream.streamGeneration - } - const transfer = ledger.beginTransfer() - const ranges = transfer.frames.flatMap((frame) => frame.sourceRanges) - try { - runtime.cancelRemoteTerminalSourceRanges(identity, ranges, reason) - } finally { - transfer.commit() - } - } - const detachStream = ( - streamId: number, - emitEnd: boolean, - releaseRemoteDesktopDriver = true - ): void => { - const stream = streams.get(streamId) - if (!stream) { - return - } - const replacement = stream.sourceRangeReplacement - stream.sourceRangeReplacement = null - if (replacement) { - runtime.rollbackRemoteTerminalSourceRangeReplacement( - replacement, - 'stream-detached-replacement-aborted' - ) - } - stream.outputBatcher.flush() - stream.outputBatcher.dispose() - detachSourceRangeConsumer(stream, 'stream-detached') - ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - stream.ackInFlightBytes) - stream.ackInFlightBytes = 0 - stream.ackPendingOutput = [] - stream.ackPendingOutputBytes = 0 - stream.ackPendingOutputOverflowed = false - stream.ackRecoverySnapshotInFlight = false - stream.unsubscribeData() - stream.unsubscribeResize() - stream.unsubscribeFit() - stream.unsubscribeDriver() - stream.unregisterBinaryHandler() - streams.delete(streamId) - flushAllAckPendingOutput() - // Why: release the runtime exit-waiter for this slot (see the field's note); delete before abort so its .catch no-ops instead of re-detaching. - stream.exitWaiterAbort.abort() - if (stream.isMobile && stream.client?.id) { - runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id) - } else if ( - releaseRemoteDesktopDriver && - stream.registeredRemoteDesktopDriver && - stream.client?.id - ) { - // Why: release the width floor only if THIS stream took it, so a passive stream can't release a peer's floor. - runtime.unregisterRemoteDesktopViewer(stream.ptyId, stream.remoteDesktopSubscriptionKey) - } - if (emitEnd) { - emit({ type: 'end', streamId }) - } - } - const cancelPendingPtyWaits = (streamId: number): void => { - const controllers = pendingPtyWaitControllers.get(streamId) - if (!controllers) { - return - } - pendingPtyWaitControllers.delete(streamId) - for (const controller of controllers) { - controller.abort() - } - } - const cancelAllPendingPtyWaits = (): void => { - for (const streamId of Array.from(pendingPtyWaitControllers.keys())) { - cancelPendingPtyWaits(streamId) - } - } - const closeMultiplex = (): void => { - if (closed) { - return - } - closed = true - signal?.removeEventListener('abort', cancelAllPendingPtyWaits) - cancelAllPendingPtyWaits() - const remoteDesktopKeysByPty = new Map() - for (const streamId of Array.from(streams.keys())) { - const stream = streams.get(streamId) - if (stream?.registeredRemoteDesktopDriver && !stream.isMobile && stream.client?.id) { - const keys = remoteDesktopKeysByPty.get(stream.ptyId) ?? [] - keys.push(stream.remoteDesktopSubscriptionKey) - remoteDesktopKeysByPty.set(stream.ptyId, keys) - } - detachStream(streamId, false, false) - } - // Why: one connection can own many panes on the same PTY; remove floors together so close scans each registry once. - for (const [ptyId, subscriptionKeys] of remoteDesktopKeysByPty) { - void runtime.unregisterRemoteDesktopViewers(ptyId, subscriptionKeys) - } - unregisterControlHandler() - resolveMultiplex() - } - const handleSlotFrame = ( - stream: TerminalMultiplexStream, - frame: TerminalStreamFrame - ): void => { - if (closed || streams.get(stream.streamId) !== stream) { - return - } - if (frame.opcode === TerminalStreamOpcode.Unsubscribe) { - cancelPendingPtyWaits(stream.streamId) - detachStream(stream.streamId, false) - return - } - if (frame.opcode === TerminalStreamOpcode.Ack) { - const payload = decodeTerminalStreamJson(frame.payload) ?? {} - if (stream.ackOutputSourceRanges) { - const parsed = TerminalMultiplexSourceRangeAckFrame.safeParse(payload) - if (parsed.success) { - acknowledgeSourceRanges( - stream, - parsed.data.streamGeneration, - parsed.data.ackedEndByte - ) - } - } else { - const parsed = TerminalMultiplexLegacyAckFrame.safeParse(payload) - if (parsed.success) { - acknowledgeOutput(stream, parsed.data.bytes) - } - } - return - } - if (frame.opcode === TerminalStreamOpcode.Input) { - const text = decodeTerminalStreamText(frame.payload) - if (!text) { - return - } - if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { - return - } - // Mobile already has the higher-priority floor, so a rejected desktop claim must not suppress later phone input. - const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail - void inputClaimTail.then(async (claimed) => { - if (!claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { - return - } - const outcome = await sendTerminalStreamInput(runtime, { - terminal: stream.terminal, - text, - client: stream.client, - isMobile: stream.isMobile - }) - notifyStreamWriteUnavailable(stream, outcome) - }) - return - } - if (frame.opcode === TerminalStreamOpcode.SetOutputPaused && stream.supportsOutputPause) { - const payload = decodeTerminalStreamJson<{ paused?: unknown }>(frame.payload) - if (typeof payload?.paused !== 'boolean' || stream.outputPaused === payload.paused) { - return - } - stream.outputPaused = payload.paused - if (stream.outputPaused) { - stream.outputBatcher.flush() - stream.ackPendingOutput = [] - stream.ackPendingOutputBytes = 0 - stream.ackPendingOutputOverflowed = false - } - return - } - if (frame.opcode === TerminalStreamOpcode.Resize && stream.client) { - const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( - frame.payload - ) - if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { - return - } - const cols = viewport.cols - const rows = viewport.rows - // Why: resize registers stream-scoped geometry so detach can release it; older clients lack explicit claims. - if (!stream.isMobile && stream.client?.id) { - stream.registeredRemoteDesktopDriver = true - if (stream.buffering) { - stream.pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows } - return - } - } - stream.desktopClaimTail = stream.desktopClaimTail - .then(async (priorClaimed) => { - const result = await updateViewportForClient( - runtime, - stream.ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client!, - { cols, rows }, - stream.isMobile ? 'mobile' : 'desktop', - 'register', - !stream.supportsDesktopViewportClaims - ) - return stream.supportsDesktopViewportClaims - ? priorClaimed && result.applied - : result.applied - }) - .catch(() => false) - return - } - if ( - frame.opcode === TerminalStreamOpcode.ClaimViewport && - stream.client && - !stream.isMobile - ) { - const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( - frame.payload - ) - if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { - return - } - const cols = viewport.cols - const rows = viewport.rows - stream.registeredRemoteDesktopDriver = true - stream.desktopClaimTail = stream.desktopClaimTail - .then( - () => - runtime.updateRemoteDesktopViewer( - stream.ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client!.id, - cols, - rows, - true - ), - () => - runtime.updateRemoteDesktopViewer( - stream.ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client!.id, - cols, - rows, - true - ) - ) - .catch(() => false) - return - } - if (frame.opcode === TerminalStreamOpcode.SnapshotRequest) { - const payload = TerminalMultiplexSnapshotRequestFrame.safeParse( - decodeTerminalStreamJson(frame.payload) ?? {} - ) - void sendRequestedSnapshot(stream, payload.success ? payload.data : {}) - } - } - const sendRequestedSnapshot = async ( - stream: TerminalMultiplexStream, - request: z.infer - ): Promise => { - if (closed || streams.get(stream.streamId) !== stream) { - return - } - stream.outputBatcher.flush() - stream.pendingOutputOverflowed = false - stream.buffering = true - const requestId = request.requestId - let sentSnapshotOutputSeq: number | undefined - try { - const scrollbackRows = normalizeMultiplexSnapshotScrollbackRows(request.scrollbackRows) - let serialized = await serializeBudgetedRequestedSnapshot( - runtime, - stream.ptyId, - scrollbackRows - ) - if (closed || streams.get(stream.streamId) !== stream) { - return - } - let size = runtime.getTerminalSize(stream.ptyId) - let displayMode = runtime.getMobileDisplayMode(stream.ptyId) - if (stream.pendingOutputOverflowed) { - // Why: the overflowed tail is newer than the first snapshot, so retry for a current image instead of null. - stream.pendingOutput.splice(0) - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - serialized = await serializeBudgetedRequestedSnapshot( - runtime, - stream.ptyId, - scrollbackRows - ) - if (closed || streams.get(stream.streamId) !== stream) { - return - } - size = runtime.getTerminalSize(stream.ptyId) - displayMode = runtime.getMobileDisplayMode(stream.ptyId) - if (stream.pendingOutputOverflowed) { - sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), { - kind: 'scrollback', - cols: size?.cols ?? 80, - rows: size?.rows ?? 24, - requestId, - displayMode, - truncated: true, - truncatedByByteBudget: false, - unavailable: 'pending-output-overflowed', - data: '' - }) - return - } - } - sentSnapshotOutputSeq = serialized?.seq - sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), { - kind: 'scrollback', - cols: serialized?.cols ?? size?.cols ?? 80, - rows: serialized?.rows ?? size?.rows ?? 24, - requestId, - displayMode, - seq: serialized?.seq, - cwd: serialized?.cwd, - source: serialized?.source, - kittyKeyboardFlags: serialized?.kittyKeyboardFlags, - oscLinks: serialized?.oscLinks, - pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, - truncated: false, - truncatedByByteBudget: serialized?.truncatedByByteBudget, - // Why: no serializer answered, which is not proof the pane is empty — say so instead of passing off '' as the buffer. - unavailable: serialized ? undefined : 'no-serializable-buffer', - data: serialized?.data ?? '' - }) - } catch (error) { - sendStreamError( - stream.streamId, - error instanceof Error ? error.message : 'Remote terminal snapshot failed.' - ) - } finally { - if (streams.get(stream.streamId) === stream) { - const shouldFlushPendingOutput = !stream.pendingOutputOverflowed - stream.buffering = false - const pendingOutput = stream.pendingOutput.splice(0) - if (shouldFlushPendingOutput) { - for (const chunk of pendingOutput) { - // Why: an untagged reply resets the client to the snapshot's - // high-water, so covered bytes would render twice; tagged - // snapshots feed a side consumer and the live view still - // needs every buffered chunk. - const uncovered = - typeof requestId === 'number' - ? chunk - : getOutputAfterSnapshotSeq(chunk, sentSnapshotOutputSeq) - if (uncovered) { - stream.outputBatcher.push(uncovered.data, uncovered.meta) - } - } - } - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - stream.outputBatcher.flush() - // Why: a resize parked during snapshot buffering must be applied now, or it is dropped until the viewer's next resize. - if ( - !stream.isMobile && - stream.client?.id && - stream.registeredRemoteDesktopDriver && - stream.pendingRemoteDesktopViewport - ) { - const viewport = stream.pendingRemoteDesktopViewport - stream.pendingRemoteDesktopViewport = null - void updateViewportForClient( - runtime, - stream.ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client, - viewport, - 'desktop', - 'register', - !stream.supportsDesktopViewportClaims - ).catch(() => {}) - } - } - } - } - const handleSubscribeFrame = async (payload: Uint8Array): Promise => { - const raw = decodeTerminalStreamJson(payload) - const parsed = TerminalMultiplexSubscribeFrame.safeParse(raw) - if (!parsed.success) { - return - } - const request = parsed.data - detachStream(request.streamId, false) - cancelPendingPtyWaits(request.streamId) - - const isMobile = request.client?.type === 'mobile' - let leaf: { ptyId: string | null } | null - try { - // Why: binding the stream to whatever PTY now occupies a stale handle's pane would mirror the wrong terminal (#7718). - leaf = runtime.resolveLiveLeafForHandle(request.terminal) - } catch { - sendStreamError(request.streamId, 'terminal_handle_stale') - emit({ type: 'end', streamId: request.streamId }) - return - } - if (!leaf?.ptyId && request.client) { - if ( - pendingPtyWaitControllers.size >= - TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION - ) { - sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) - emit({ type: 'end', streamId: request.streamId }) - return - } - // Why: a never-mounted tab has no graph leaf to await; mounting the exact tab attaches its PTY without activating the worktree. - runtime.requestRendererTerminalTabMount(request.terminal) - const waitController = new AbortController() - const pendingControllers = pendingPtyWaitControllers.get(request.streamId) ?? new Set() - pendingControllers.add(waitController) - pendingPtyWaitControllers.set(request.streamId, pendingControllers) - if (signal?.aborted) { - waitController.abort() - } - // Why: the live slot handler does not exist until the PTY attaches; retain cancellation ownership while the pane is still pending. - const unregisterPendingHandler = registerBinaryStreamHandler( - request.streamId, - (frame) => { - if (frame.opcode === TerminalStreamOpcode.Unsubscribe) { - cancelPendingPtyWaits(request.streamId) - detachStream(request.streamId, false) - } - } - ) - try { - const ptyId = await runtime.waitForLeafPtyId( - request.terminal, - 10_000, - waitController.signal - ) - leaf = { ptyId } - } catch { - if (closed || signal?.aborted || waitController.signal.aborted) { - return - } - // Fall through to the explicit no_connected_pty error below. - } finally { - const currentControllers = pendingPtyWaitControllers.get(request.streamId) - currentControllers?.delete(waitController) - if (currentControllers?.size === 0) { - pendingPtyWaitControllers.delete(request.streamId) - } - unregisterPendingHandler() - } - } - if (!leaf?.ptyId) { - sendStreamError(request.streamId, 'no_connected_pty') - emit({ type: 'end', streamId: request.streamId }) - return - } - if (closed) { - return - } - // Why: a competing subscribe may own this streamId after the PTY await; detach it so an orphaned view subscriber can't silence the model responder (terminal-query-authority.md). - detachStream(request.streamId, false) - if (streams.size >= TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION) { - sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) - emit({ type: 'end', streamId: request.streamId }) - return - } - - const ptyId = leaf.ptyId - const remoteDesktopSubscriptionKey = `multiplex:${connectionId}:${request.streamId}` - const streamGeneration = randomUUID() - const requestedSourceRangeConsumer = - request.capabilities?.ackOutput === 1 && request.capabilities?.ackOutputSourceRanges === 1 - const sourceRangeLedger = requestedSourceRangeConsumer - ? sourceRangeRegistry.open(streamGeneration) - : null - const sourceRangeConsumerAttached = - sourceRangeLedger !== null && - runtime.attachRemoteTerminalSourceRangeConsumer({ - ptyId, - consumerId: remoteDesktopSubscriptionKey, - streamGeneration - }) - if (!sourceRangeConsumerAttached) { - sourceRangeLedger?.close() - } - const stream: TerminalMultiplexStream = { - streamId: request.streamId, - terminal: request.terminal, - ptyId, - client: request.client, - isMobile, - ackOutput: request.capabilities?.ackOutput === 1, - ackOutputSourceRanges: sourceRangeConsumerAttached, - streamGeneration, - sourceRangeLedger: sourceRangeConsumerAttached ? sourceRangeLedger : null, - sourceRangeConsumerAttached, - sourceRangeReplacement: null, - ackInFlightBytes: 0, - ackWindowBytes: TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES, - supportsOutputPause: request.capabilities?.outputPause === 1, - supportsWriteUnavailable: request.capabilities?.writeUnavailable === 1, - outputPaused: false, - supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1, - desktopClaimTail: Promise.resolve(true), - registeredRemoteDesktopDriver: false, - // Why: streamId is client-local, so key the width floor by connectionId or two connections sharing stream 1 for one PTY clobber each other's floor. - remoteDesktopSubscriptionKey, - pendingRemoteDesktopViewport: null, - buffering: true, - ackPendingOutput: [], - ackPendingOutputBytes: 0, - ackPendingOutputOverflowed: false, - ackRecoverySnapshotInFlight: false, - pendingOutput: [], - pendingOutputBytes: 0, - pendingOutputOverflowed: false, - lastResizeCols: undefined, - resizeGeneration: 0, - outputBatcher: createTerminalOutputBatcher((data, meta) => { - if (meta?.cwd !== undefined) { - sendFrame( - request.streamId, - TerminalStreamOpcode.Metadata, - encodeTerminalStreamJson({ cwd: meta.cwd }), - meta.seq - ) - } - for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { - queueOrSendOutput(stream, chunk) - } - }), - unsubscribeData: () => {}, - unsubscribeResize: () => {}, - unsubscribeFit: () => {}, - unsubscribeDriver: () => {}, - unregisterBinaryHandler: () => {}, - exitWaiterAbort: new AbortController() - } - streams.set(request.streamId, stream) - stream.unregisterBinaryHandler = registerBinaryStreamHandler(request.streamId, (frame) => - handleSlotFrame(stream, frame) - ) - - try { - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { - if (closed || streams.get(request.streamId) !== stream) { - return - } - if (stream.outputPaused) { - return - } - if (stream.buffering) { - appendPendingMultiplexOutput(stream, data, meta) - return - } - stream.outputBatcher.push(data, meta) - }) - // Why: a multiplexed stream feeds a remote xterm view with query authority, so the main model responder yields while attached (terminal-query-authority.md). - const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - stream.unsubscribeData = () => { - releaseViewSubscriber() - unsubscribeStreamData() - } - - if (isMobile && request.client?.id) { - await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport) - } else if (request.client?.id && request.viewport) { - // Why: subscribe records this stream's geometry and cleanup key but doesn't claim ownership; activity frames claim later. - stream.registeredRemoteDesktopDriver = true - stream.pendingRemoteDesktopViewport = request.viewport - } - if ( - !isMobile && - request.client?.id && - stream.registeredRemoteDesktopDriver && - stream.pendingRemoteDesktopViewport - ) { - const viewport = stream.pendingRemoteDesktopViewport - stream.pendingRemoteDesktopViewport = null - await updateViewportForClient( - runtime, - ptyId, - stream.remoteDesktopSubscriptionKey, - request.client, - viewport, - 'desktop', - 'register', - !stream.supportsDesktopViewportClaims - ) - } - if (closed || streams.get(request.streamId) !== stream) { - return - } - - let read = await runtime.readTerminal(request.terminal) - let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed || streams.get(request.streamId) !== stream) { - return - } - let initialOutputOverflowed = false - if (stream.pendingOutputOverflowed) { - stream.pendingOutput.splice(0) - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - read = await runtime.readTerminal(request.terminal) - serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed || streams.get(request.streamId) !== stream) { - return - } - if (stream.pendingOutputOverflowed) { - initialOutputOverflowed = true - stream.pendingOutput.splice(0) - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - } - } - const size = runtime.getTerminalSize(ptyId) - const displayMode = runtime.getMobileDisplayMode(ptyId) - const layoutSeq = runtime.getLayout(ptyId)?.seq - // Why: layout versions and output offsets are different sequence domains. - const snapshotOutputSeq = serialized?.seq - emit({ - type: 'subscribed', - streamId: request.streamId, - terminal: request.terminal, - cols: serialized?.cols ?? size?.cols, - rows: serialized?.rows ?? size?.rows, - displayMode, - seq: layoutSeq, - ...((stream.ackOutputSourceRanges || stream.supportsOutputPause) && { - capabilities: { - ...(stream.ackOutputSourceRanges ? { ackOutputSourceRanges: 1 as const } : {}), - ...(stream.supportsOutputPause ? { outputPause: 1 as const } : {}) - } - }), - ...(stream.ackOutputSourceRanges ? { streamGeneration: stream.streamGeneration } : {}), - // Why: retained-tail truncation loses history, not the authoritative latest-screen fallback. - truncated: initialOutputOverflowed - }) - stream.sourceRangeReplacement = - stream.ackOutputSourceRanges && - serialized?.source !== undefined && - typeof serialized.seq === 'number' - ? runtime.reserveRemoteTerminalSourceRangeReplacement( - { - ptyId, - consumerId: stream.remoteDesktopSubscriptionKey, - streamGeneration: stream.streamGeneration - }, - serialized.seq, - 'initial-snapshot' - ) - : null - const snapshotPublication = sendSnapshotFrames( - (opcode, payload) => sendFrame(request.streamId, opcode, payload), - { - kind: 'scrollback', - cols: serialized?.cols ?? size?.cols ?? 80, - rows: serialized?.rows ?? size?.rows ?? 24, - displayMode, - seq: snapshotOutputSeq, - cwd: serialized?.cwd, - truncated: initialOutputOverflowed, - truncatedByByteBudget: serialized?.truncatedByByteBudget, - source: serialized?.source, - kittyKeyboardFlags: serialized?.kittyKeyboardFlags, - oscLinks: serialized?.oscLinks, - pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, - data: - serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '') - } - ) - const replacement = stream.sourceRangeReplacement - stream.sourceRangeReplacement = null - if (replacement) { - const committed = - snapshotPublication.published && - serialized?.source !== undefined && - typeof serialized.seq === 'number' && - runtime.commitRemoteTerminalSourceRangeReplacement(replacement, { - source: serialized.source, - seq: serialized.seq - }) - if (!committed) { - runtime.rollbackRemoteTerminalSourceRangeReplacement( - replacement, - 'initial-snapshot-unpublished' - ) - } - } - // Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay. - stream.lastResizeCols = serialized?.cols ?? size?.cols - stream.buffering = false - const pendingOutput = stream.pendingOutput.splice(0) - if (!initialOutputOverflowed) { - for (const chunk of pendingOutput) { - const uncovered = getOutputAfterSnapshotSeq(chunk, snapshotOutputSeq) - if (uncovered) { - stream.outputBatcher.push(uncovered.data, uncovered.meta) - } - } - } - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - stream.outputBatcher.flush() - if (!isMobile) { - stream.unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { - const mode = - event.mode === 'mobile-fit' - ? event.mode - : (runtime.getRemoteDesktopFitHold?.(ptyId, stream.remoteDesktopSubscriptionKey) - .mode ?? 'desktop-fit') - emit({ - type: 'fit-override-changed', - streamId: request.streamId, - mode, - cols: event.cols, - rows: event.rows - }) - }) - stream.unsubscribeDriver = runtime.subscribeToDriverChanges(ptyId, (driver) => { - emit({ - type: 'driver-changed', - streamId: request.streamId, - driver - }) - }) - const fitOverride = runtime.getTerminalFitOverride(ptyId) - const desktopHold = runtime.getRemoteDesktopFitHold?.( - ptyId, - stream.remoteDesktopSubscriptionKey - ) ?? { mode: 'desktop-fit' as const, cols: size?.cols ?? 0, rows: size?.rows ?? 0 } - emit({ - type: 'fit-override-changed', - streamId: request.streamId, - mode: fitOverride?.mode ?? desktopHold.mode, - cols: fitOverride?.cols ?? desktopHold.cols, - rows: fitOverride?.rows ?? desktopHold.rows - }) - emit({ - type: 'driver-changed', - streamId: request.streamId, - driver: runtime.getDriver(ptyId) - }) - } - stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { - stream.outputBatcher.flush() - const resizeGeneration = stream.resizeGeneration + 1 - stream.resizeGeneration = resizeGeneration - const widthChanged = stream.isMobile && event.cols !== stream.lastResizeCols - if (widthChanged) { - stream.lastResizeCols = event.cols - // Why: re-serialize+replay the full scrollback at the new cols so restored hard-wrapped lines rewrap; live output resumes after the snapshot lands. - void sendMobileResizeRestream( - runtime, - ptyId, - (opcode, payload) => sendFrame(request.streamId, opcode, payload), - event, - () => - !closed && - streams.get(request.streamId) === stream && - stream.resizeGeneration === resizeGeneration - ) - .then((restreamed) => { - if ( - closed || - streams.get(request.streamId) !== stream || - stream.resizeGeneration !== resizeGeneration - ) { - return - } - if (!restreamed) { - sendResizedFrame(stream, event) - } - }) - // Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize. - .catch(() => { - if ( - closed || - streams.get(request.streamId) !== stream || - stream.resizeGeneration !== resizeGeneration - ) { - return - } - sendResizedFrame(stream, event) - }) - return - } - sendResizedFrame(stream, event) - }) - // Install the resize listener before draining the parked viewport, since applyLayout emits synchronously. - if ( - !stream.isMobile && - stream.client?.id && - stream.registeredRemoteDesktopDriver && - stream.pendingRemoteDesktopViewport - ) { - const viewport = stream.pendingRemoteDesktopViewport - stream.pendingRemoteDesktopViewport = null - void updateViewportForClient( - runtime, - ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client, - viewport, - 'desktop', - 'register', - !stream.supportsDesktopViewportClaims - ).catch(() => {}) - } - void runtime - .waitForTerminal(request.terminal, { - condition: 'exit', - signal: stream.exitWaiterAbort.signal - }) - .then(() => { - if (streams.get(request.streamId) === stream) { - detachStream(request.streamId, true) - } - }) - .catch(() => { - if (streams.get(request.streamId) === stream) { - detachStream(request.streamId, true) - } - }) - } catch (error) { - // Why the ownership check: a newer subscribe may already own this streamId; tearing down the slot here would kill the successor's live registrations. - if (streams.get(request.streamId) !== stream) { - return - } - detachStream(request.streamId, false) - sendStreamError(request.streamId, error instanceof Error ? error.message : String(error)) - emit({ type: 'end', streamId: request.streamId }) - } - } - const unregisterControlHandler = registerBinaryStreamHandler(0, (frame) => { - if (frame.opcode === TerminalStreamOpcode.Subscribe) { - void handleSubscribeFrame(frame.payload) - } - }) - - signal?.addEventListener('abort', cancelAllPendingPtyWaits, { once: true }) - - runtime.registerSubscriptionCleanup( - `terminal-multiplex:${connectionId}`, - closeMultiplex, - connectionId - ) - emit({ type: 'ready' }) - await multiplexClosed - } - }), - // terminal.subscribe: streams live terminal output over WebSocket; mobile clients pass client+viewport for server-side auto-fit. - defineStreamingMethod({ - name: 'terminal.subscribe', - params: TerminalSubscribe, - handler: async ( - params, - { runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal }, - emit - ) => { - let leaf = runtime.resolveLeafForHandle(params.terminal) - const isMobile = params.client?.type === 'mobile' - const serializerGenerationBeforeAnyMount = isMobile - ? (runtime.getRendererTerminalSerializerGenerationForHandle?.(params.terminal) ?? 0) - : 0 - let rendererMountRequestedBeforePty = false - const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary) - // Why: a closed stream must not allocate listeners, mobile-fit state, or a hidden renderer surface no client will consume. - if (signal?.aborted) { - return - } - - // Why: the PTY spawns asynchronously after tab creation; wait for it so an early subscribe gets a live stream instead of a bare scrollback+end. - if (!leaf?.ptyId && params.client) { - // Why: a never-mounted tab has no graph leaf to await; mounting the exact tab attaches its PTY without activating the worktree. - rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal) - try { - const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) - leaf = { ptyId } - } catch { - if (signal?.aborted) { - return - } - // PTY wait timed out — fall through to scrollback-only path below - } - } - - if (!leaf?.ptyId) { - const read = await runtime.readTerminal(params.terminal) - emit({ - type: 'subscribed', - streamId: null, - lines: read.tail, - truncated: isTerminalReadPayloadIncomplete(read) - }) - emit({ type: 'end' }) - return - } - - if (isMobile && (!useBinaryStream || !sendBinary)) { - throw new Error('binary_terminal_stream_required') - } - - const ptyId = leaf.ptyId - const clientId = params.client?.id - const mobileInputLeaseOnly = - isMobile && params.capabilities?.mobileInputLeaseOnly === 1 && Boolean(clientId) - // Why: mount/PTY wait and phone-fit can each emit a redraw creating suffix-only state, so capture the pre-mount absence signal first. - const missingHeadlessStateBeforeMobileFit = - isMobile && - (rendererMountRequestedBeforePty || runtime.hasHeadlessTerminalState?.(ptyId) === false) - const serializerGenerationBeforeMobileFit = missingHeadlessStateBeforeMobileFit - ? rendererMountRequestedBeforePty - ? serializerGenerationBeforeAnyMount - : runtime.getRendererTerminalSerializerGeneration(ptyId) - : 0 - const supportsDesktopViewportClaims = params.capabilities?.desktopViewportClaims === 1 - const supportsWriteUnavailable = params.capabilities?.writeUnavailable === 1 - if (mobileInputLeaseOnly && clientId) { - let closed = false - let stopWatchingLifetime = (): void => {} - let resolveStream = (): void => {} - const streamClosed = new Promise((resolve) => { - resolveStream = resolve - }) - const subscriptionId = `${params.terminal}:${clientId}` - // Why: chat needs the input-floor ack without registering a view subscriber or transporting duplicate PTY output. - const registration = runtime.registerOwnedSubscriptionCleanup( - subscriptionId, - () => { - stopWatchingLifetime() - closed = true - runtime.handleMobileUnsubscribe(ptyId, clientId) - emit({ type: 'end' }) - resolveStream() - }, - connectionId - ) - stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) - if (closed) { - // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. - return - } - try { - // Why: a lease-only subscriber has no terminal view, so its cached viewport must never phone-fit the PTY. - await runtime.handleMobileSubscribe(ptyId, clientId, undefined) - if (closed || signal?.aborted) { - // Why: a disconnect can win the awaited subscribe and resurrect mobile presence after cleanup already released it. - // Unguarded on purpose: this must still fire when our own cleanup already ran. - // Safe only because lease-only passes no viewport and both !viewport paths in - // handleMobileSubscribeInternal return with no await, so no rebind can land - // first. Adding an await there — or passing a viewport here — makes a - // superseded handler delete the replacement's (ptyId, clientId) presence. - runtime.handleMobileUnsubscribe(ptyId, clientId) - if (!closed) { - registration.releaseIfCurrent() - } - return - } - emit({ type: 'subscribed', streamId: null, lines: [], truncated: false }) - await streamClosed - } catch (error) { - registration.releaseIfCurrent() - throw error - } - return - } - // Why: only unregister the width floor this subscription took (see the multiplex stream's registeredRemoteDesktopDriver note). - let registeredRemoteDesktopDriver = false - if (!useBinaryStream) { - // Why: a hidden watcher and a visible pane can subscribe to one terminal, so key by client so neither stream evicts the other. - const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal - const remoteDesktopSubscriptionKey = `json:${nextTerminalStreamId++}` - let closed = false - let outputBatcher: ReturnType | null = null - let unsubscribeData = (): void => {} - let unsubscribeFit = (): void => {} - let stopWatchingLifetime = (): void => {} - let resolveStream = (): void => {} - const streamClosed = new Promise((resolve) => { - resolveStream = resolve - }) - // Why: register before viewport/snapshot awaits so a socket close can't orphan the stream listeners or its remote-desktop width floor. - const registration = runtime.registerOwnedSubscriptionCleanup( - subscriptionId, - () => { - stopWatchingLifetime() - closed = true - outputBatcher?.flush() - outputBatcher?.dispose() - unsubscribeData() - unsubscribeFit() - if (registeredRemoteDesktopDriver && clientId) { - runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) - } - emit({ type: 'end' }) - resolveStream() - }, - connectionId - ) - stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) - if (closed) { - // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. - return - } - try { - if (clientId && params.client && params.viewport) { - registeredRemoteDesktopDriver = true - await updateViewportForClient( - runtime, - ptyId, - remoteDesktopSubscriptionKey, - params.client, - params.viewport, - 'desktop', - 'register', - !supportsDesktopViewportClaims - ) - } - if (closed || signal?.aborted) { - registration.releaseIfCurrent() - return - } - const read = await runtime.readTerminal(params.terminal) - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) - if (closed || signal?.aborted) { - registration.releaseIfCurrent() - return - } - const size = runtime.getTerminalSize(ptyId) - const displayMode = runtime.getMobileDisplayMode(ptyId) - const seq = runtime.getLayout(ptyId)?.seq - emit({ - type: 'scrollback', - lines: read.tail, - truncated: isTerminalReadPayloadIncomplete(read), - serialized: serialized?.data, - oscLinks: serialized?.oscLinks, - cwd: serialized?.cwd, - // Why: an empty snapshot with no PTY size must still report the dims the fit - // will produce — dimless frames re-armed the mobile fit loop (STA-3337). - cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols, - rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows, - displayMode, - seq - }) - outputBatcher = createTerminalOutputBatcher((chunk) => { - emit({ type: 'data', chunk }) - }) - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => { - outputBatcher?.push(data) - }) - // Why: the legacy JSON stream can feed a live xterm view, so register as a view subscriber; worst case is a withheld model reply, safer than a double reply. - const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - unsubscribeData = () => { - releaseViewSubscriber() - unsubscribeStreamData() - } - unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { - outputBatcher?.flush() - const mode = - event.mode === 'mobile-fit' - ? event.mode - : (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ?? - 'desktop-fit') - emit({ - type: 'fit-override-changed', - mode, - cols: event.cols, - rows: event.rows - }) - }) - await streamClosed - } catch (error) { - registration.releaseIfCurrent() - throw error - } - return - } - - const streamId = nextTerminalStreamId++ - const remoteDesktopSubscriptionKey = `stream:${streamId}` - let cursor = 0 - let closed = false - let buffering = true - let pendingRemoteDesktopViewport: { cols: number; rows: number } | null = null - // Why: cols the mobile client last rewrapped to; gates the resize re-stream to fire only on an actual width change. - let lastResizeCols: number | undefined - let resizeGeneration = 0 - let pendingOutput: TerminalOutputChunk[] = [] - let desktopClaimTail: Promise = Promise.resolve(true) - let pendingOutputBytes = 0 - let pendingOutputOverflowed = false - let pendingQueryScanState: TerminalReplyQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE - const pendingQuerySequences: TerminalReplyQuerySequence[] = [] - let pendingQueryChars = 0 - let pendingQueryOverflowed = false - let unsubscribeData = (): void => {} - let unsubscribeResize = (): void => {} - let unsubscribeFit = (): void => {} - let unregisterBinaryHandler = (): void => {} - let abortRendererMountWait = (): void => {} - let stopWatchingLifetime = (): void => {} - let lateRendererReadyPromise: Promise | null = null - let outputBatcher: ReturnType | null = null - let resolveStream = (): void => {} - const streamClosed = new Promise((resolve) => { - resolveStream = resolve - }) - // Why: register cleanup before any await so a mid-subscribe disconnect still removes mobile presence; client-scoped ids also allow parallel desktop subscribers. - const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal - const registration = runtime.registerOwnedSubscriptionCleanup( - subscriptionId, - () => { - stopWatchingLifetime() - outputBatcher?.flush() - outputBatcher?.dispose() - closed = true - unsubscribeData() - unsubscribeResize() - unsubscribeFit() - unregisterBinaryHandler() - abortRendererMountWait() - if (isMobile && clientId) { - runtime.handleMobileUnsubscribe(ptyId, clientId) - } else if (registeredRemoteDesktopDriver && clientId) { - runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) - } - emit({ type: 'end' }) - resolveStream() - }, - connectionId - ) - stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) - if (closed) { - // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. - return - } - const sendFrame = ( - opcode: TerminalStreamOpcode, - payload: Uint8Array = new Uint8Array(), - frameSeq = cursor++ - ): void => { - if (closed || !sendBinary) { - return - } - sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: frameSeq, payload })) - } - outputBatcher = createTerminalOutputBatcher((data, meta) => { - if (meta?.cwd !== undefined) { - sendFrame( - TerminalStreamOpcode.Metadata, - encodeTerminalStreamJson({ cwd: meta.cwd }), - meta.seq - ) - } - for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { - sendFrame(chunk.opcode ?? TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) - } - }) - unregisterBinaryHandler = - registerBinaryStreamHandler?.(streamId, (frame) => { - if (closed) { - return - } - if (frame.opcode === TerminalStreamOpcode.Input) { - const text = decodeTerminalStreamText(frame.payload) - if (!text) { - return - } - if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - return - } - void desktopClaimTail.then(async (claimed) => { - if (!claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - return - } - const outcome = await sendTerminalStreamInput(runtime, { - terminal: params.terminal, - text, - client: params.client, - isMobile - }) - if (!closed && outcome === 'rejected' && supportsWriteUnavailable) { - sendFrame(TerminalStreamOpcode.WriteUnavailable) - } - }) - return - } - if (frame.opcode === TerminalStreamOpcode.Resize && params.client) { - const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( - frame.payload - ) - if ( - !viewport || - typeof viewport.cols !== 'number' || - typeof viewport.rows !== 'number' - ) { - return - } - const cols = viewport.cols - const rows = viewport.rows - if (clientId) { - registeredRemoteDesktopDriver = true - if (buffering) { - pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows } - return - } - } - desktopClaimTail = desktopClaimTail - .then(async (priorClaimed) => { - const result = await updateViewportForClient( - runtime, - ptyId, - remoteDesktopSubscriptionKey, - params.client!, - { cols, rows }, - 'desktop', - 'register', - !supportsDesktopViewportClaims - ) - return supportsDesktopViewportClaims - ? priorClaimed && result.applied - : result.applied - }) - .catch(() => false) - return - } - if ( - frame.opcode === TerminalStreamOpcode.ClaimViewport && - params.client && - clientId && - !isMobile - ) { - const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( - frame.payload - ) - if ( - !viewport || - typeof viewport.cols !== 'number' || - typeof viewport.rows !== 'number' - ) { - return - } - const cols = viewport.cols - const rows = viewport.rows - registeredRemoteDesktopDriver = true - desktopClaimTail = desktopClaimTail - .then( - () => - runtime.updateRemoteDesktopViewer( - ptyId, - remoteDesktopSubscriptionKey, - clientId, - cols, - rows, - true - ), - () => - runtime.updateRemoteDesktopViewer( - ptyId, - remoteDesktopSubscriptionKey, - clientId, - cols, - rows, - true - ) - ) - .catch(() => false) - } - }) ?? (() => {}) - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { - if (closed) { - return - } - if (buffering) { - const rawLength = meta?.rawLength - if ( - typeof meta?.seq === 'number' && - typeof rawLength === 'number' && - rawLength === data.length - ) { - const scan = scanTerminalReplyQuerySequences( - data, - meta.seq - rawLength, - pendingQueryScanState - ) - pendingQueryScanState = scan.state - for (const query of scan.queries) { - if (pendingQueryChars + query.data.length > TERMINAL_QUERY_REPLAY_MAX_CHARS) { - pendingQueryOverflowed = true - break - } - pendingQuerySequences.push(query) - pendingQueryChars += query.data.length - } - } else { - pendingQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE - } - const remainingBudget = Math.max( - 1, - TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes - ) - const measurement = measureTerminalStreamByteLength(data, { - stopAfterBytes: remainingBudget - }) - pendingOutput.push({ data, bytes: measurement.byteLength, meta }) - pendingOutputBytes += measurement.byteLength - const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) - pendingOutputBytes = trimmed.bytes - pendingOutputOverflowed ||= trimmed.overflowed - return - } - outputBatcher?.push(data, meta) - }) - // Why: capture live bytes before mobile-fit awaits; registering presence first would suppress main while no view held the query. - const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - unsubscribeData = () => { - releaseViewSubscriber() - unsubscribeStreamData() - } - // Server-side auto-fit: resize PTY to phone dims before serializing scrollback - try { - if (isMobile && clientId) { - await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) - } else if (clientId && params.viewport) { - // Why: legacy subscribe records geometry without taking ownership; only an explicit activity/claim frame may suppress the host. - registeredRemoteDesktopDriver = true - pendingRemoteDesktopViewport = params.viewport - } - if (closed) { - return - } - - let read = await runtime.readTerminal(params.terminal) - let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed) { - return - } - // Why: missing model state (not blank snapshot text) signals a never-attached PTY; a renderer-sourced snapshot already proves attachment, so skip the remount. - const mountRequested = - missingHeadlessStateBeforeMobileFit && - serialized?.source !== 'renderer' && - (rendererMountRequestedBeforePty || - runtime.requestRendererTerminalTabMount(params.terminal)) - if (missingHeadlessStateBeforeMobileFit && mountRequested) { - // Why: an idle legacy PTY emits no later byte, so wait for a settle proving this remount completed before replaying its screen. - const mountWaitController = new AbortController() - const abortMountWait = (): void => mountWaitController.abort() - abortRendererMountWait = abortMountWait - if (signal?.aborted) { - abortMountWait() - } else { - signal?.addEventListener('abort', abortMountWait, { once: true }) - } - const rendererReadyPromise = runtime - .waitForRendererTerminalSerializer( - ptyId, - serializerGenerationBeforeMobileFit, - undefined, - mountWaitController.signal - ) - .catch(() => false) - const finishMountWait = (): void => { - signal?.removeEventListener('abort', abortMountWait) - if (abortRendererMountWait === abortMountWait) { - abortRendererMountWait = () => {} - } - } - void rendererReadyPromise.then(finishMountWait, finishMountWait) - let deadlineTimer: ReturnType | null = null - const initialDeadline = new Promise((resolve) => { - deadlineTimer = setTimeout(() => resolve(false), MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS) - if (typeof deadlineTimer.unref === 'function') { - deadlineTimer.unref() - } - }) - const rendererReady = await Promise.race([rendererReadyPromise, initialDeadline]) - if (deadlineTimer) { - clearTimeout(deadlineTimer) - } - if (closed || signal?.aborted) { - return - } - if (rendererReady) { - read = await runtime.readTerminal(params.terminal) - const stableRendererSnapshot = await serializeStableMobileRendererSnapshot( - runtime, - ptyId - ) - if (closed) { - return - } - if (stableRendererSnapshot?.data.length) { - serialized = stableRendererSnapshot - const trailingOutput = pendingOutput.flatMap((item) => { - const output = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq) - const seq = item.meta?.seq - return output && typeof seq === 'number' ? [{ data: output.data, seq }] : [] - }) - runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery( - ptyId, - stableRendererSnapshot, - trailingOutput - ) - } - } else { - // Why: a renderer can settle after the bounded initial response; keep observing so an idle PTY self-heals without bytes. - lateRendererReadyPromise = rendererReadyPromise - } - } - let initialOutputOverflowed = false - if (pendingOutputOverflowed) { - pendingOutput.splice(0) - pendingOutputBytes = 0 - pendingOutputOverflowed = false - read = await runtime.readTerminal(params.terminal) - serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed) { - return - } - if (pendingOutputOverflowed) { - initialOutputOverflowed = true - pendingOutput.splice(0) - pendingOutputBytes = 0 - pendingOutputOverflowed = false - } - } - const size = runtime.getTerminalSize(ptyId) - const displayMode = runtime.getMobileDisplayMode(ptyId) - // Why: layout seq is the mobile stale-event filter's high-water mark (undefined pre-transition is fail-open). See docs/mobile-terminal-layout-state-machine.md. - const layoutSeq = runtime.getLayout(ptyId)?.seq - // Why: only an output offset can cover buffered chunks; layout versions are a separate sequence domain. - let snapshotOutputSeq = serialized?.seq - emit({ - type: 'subscribed', - streamId, - lines: read.tail, - truncated: - initialOutputOverflowed || (!sendBinary && isTerminalReadPayloadIncomplete(read)), - cols: serialized?.cols ?? size?.cols, - rows: serialized?.rows ?? size?.rows, - displayMode, - seq: layoutSeq - }) - const snapshotStats = sendSnapshotFrames(sendFrame, { - kind: 'scrollback', - // Why: prefer the subscriber's viewport over the 80x24 stopgap when the PTY has - // no size yet — the mismatch made mobile burn its resubscribe budget (STA-3337). - cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols ?? 80, - rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows ?? 24, - displayMode, - seq: snapshotOutputSeq, - cwd: serialized?.cwd, - truncated: initialOutputOverflowed, - truncatedByByteBudget: serialized?.truncatedByByteBudget, - oscLinks: serialized?.oscLinks, - data: serialized?.data ?? '' - }) - console.log('[mobile-terminal-stream] snapshot', { - terminal: params.terminal, - streamId, - kind: 'scrollback', - bytes: snapshotStats.bytes, - chunks: snapshotStats.chunks, - scrollbackRows: serialized?.scrollbackRows, - truncatedByByteBudget: serialized?.truncatedByByteBudget === true - }) - // Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay. - lastResizeCols = serialized?.cols ?? size?.cols - let recoveryAttempts = 0 - // Why: if the bounded pre-subscribe tail overflowed, only a fresh model snapshot covers the dropped middle without replay gaps. - while (pendingOutputOverflowed && recoveryAttempts < 2) { - pendingOutputOverflowed = false - recoveryAttempts += 1 - const recovery = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed) { - return - } - if (!recovery) { - break - } - // Why: without an output seq (renderer fallback) covered chunks can't be trimmed exactly, so keep the bounded replay over an unverifiable snapshot. - if (typeof recovery.seq !== 'number') { - break - } - // Why: clients drop a repeat scrollback snapshot but apply 'resized' inline; omit seq so output-byte seqs don't pollute the layout-seq filter. - const recoveryStats = sendSnapshotFrames(sendFrame, { - kind: 'resized', - cols: recovery.cols, - rows: recovery.rows, - displayMode, - reason: 'pending-output-overflow', - source: recovery.source, - truncated: false, - truncatedByByteBudget: recovery.truncatedByByteBudget, - data: recovery.data - }) - console.log('[mobile-terminal-stream] recovery snapshot', { - terminal: params.terminal, - streamId, - reason: 'pending-output-overflow', - bytes: recoveryStats.bytes, - chunks: recoveryStats.chunks, - scrollbackRows: recovery.scrollbackRows, - truncatedByByteBudget: recovery.truncatedByByteBudget === true - }) - const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq) - pendingOutput = trimmed.chunks - pendingOutputBytes = trimmed.bytes - snapshotOutputSeq = recovery.seq - } - buffering = false - const bufferedOutput = pendingOutput.splice(0) - const queryReplayData = pendingQueryOverflowed - ? '' - : pendingQuerySequences - .filter( - (query) => - initialOutputOverflowed || - (typeof snapshotOutputSeq === 'number' && query.startSeq < snapshotOutputSeq) - ) - .map((query) => query.data) - .join('') - if (queryReplayData) { - // Why: snapshots omit control queries but their seq trims the live chunk; replay the post-snapshot query so the mobile xterm answers once. - outputBatcher.push(queryReplayData) - } - if (!initialOutputOverflowed) { - for (const item of bufferedOutput) { - const uncovered = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) - let uncoveredData = uncovered?.data ?? null - let uncoveredMeta = uncovered?.meta - if ( - uncoveredData && - uncoveredData !== item.data && - typeof snapshotOutputSeq === 'number' && - typeof item.meta?.seq === 'number' && - typeof item.meta.rawLength === 'number' - ) { - if (item.meta.rawLength === item.data.length) { - uncoveredMeta = { ...item.meta, rawLength: uncoveredData.length } - } - uncoveredData = stripSnapshotBoundaryQuerySuffixes( - uncoveredData, - snapshotOutputSeq, - snapshotOutputSeq, - pendingQuerySequences - ) - } - if (uncoveredData) { - outputBatcher.push(uncoveredData, uncoveredMeta) - } - } - } - pendingOutputBytes = 0 - outputBatcher.flush() - const lateRendererReady = lateRendererReadyPromise - lateRendererReadyPromise = null - if (lateRendererReady) { - void lateRendererReady - .then(async (rendererReady) => { - if (!rendererReady || closed) { - return - } - outputBatcher?.flush() - const recovery = await serializeStableMobileRendererSnapshot(runtime, ptyId) - if (closed) { - return - } - if (!recovery?.data.length) { - return - } - // Why: late recovery has no buffered-output gate, so only an exact renderer high-water may reset mobile without erasing live bytes. - if (recovery.seq !== runtime.getPtyOutputSequence(ptyId)) { - return - } - runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, recovery) - // Why: shipped mobile clients apply resized snapshots in place, so a blank xterm recovers without resubscribe. - const recoveryStats = sendSnapshotFrames(sendFrame, { - kind: 'resized', - cols: recovery.cols, - rows: recovery.rows, - displayMode, - reason: 'renderer-mount-ready', - source: recovery.source, - truncated: false, - truncatedByByteBudget: recovery.truncatedByByteBudget, - data: recovery.data - }) - lastResizeCols = recovery.cols - console.log('[mobile-terminal-stream] recovery snapshot', { - terminal: params.terminal, - streamId, - reason: 'renderer-mount-ready', - bytes: recoveryStats.bytes, - chunks: recoveryStats.chunks, - scrollbackRows: recovery.scrollbackRows, - truncatedByByteBudget: recovery.truncatedByByteBudget === true - }) - }) - .catch(() => {}) - } - const sendResizedFrame = (event: { - cols: number - rows: number - displayMode: string - reason: string - seq?: number - }): void => { - lastResizeCols = event.cols - sendFrame( - TerminalStreamOpcode.Resized, - encodeTerminalStreamJson({ - cols: event.cols, - rows: event.rows, - displayMode: event.displayMode, - reason: event.reason, - seq: event.seq - }) - ) - } - unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { - outputBatcher?.flush() - const eventGeneration = resizeGeneration + 1 - resizeGeneration = eventGeneration - // Why: xterm only re-wraps soft-wrapped lines, so a width change needs a full re-serialize+replay to rewrap restored hard-wrapped scrollback. - const widthChanged = isMobile && event.cols !== lastResizeCols - if (widthChanged) { - lastResizeCols = event.cols - void sendMobileResizeRestream( - runtime, - ptyId, - sendFrame, - event, - () => !closed && resizeGeneration === eventGeneration - ) - .then((restreamed) => { - if (closed || resizeGeneration !== eventGeneration) { - return - } - if (!restreamed) { - sendResizedFrame(event) - } - }) - // Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize. - .catch(() => { - if (closed || resizeGeneration !== eventGeneration) { - return - } - sendResizedFrame(event) - }) - return - } - sendResizedFrame(event) - }) - - // Install the resize listener before draining the parked viewport, since applyLayout emits synchronously. - if ( - clientId && - params.client && - registeredRemoteDesktopDriver && - pendingRemoteDesktopViewport - ) { - const viewport = pendingRemoteDesktopViewport - pendingRemoteDesktopViewport = null - void updateViewportForClient( - runtime, - ptyId, - remoteDesktopSubscriptionKey, - params.client, - viewport, - 'desktop', - 'register', - !supportsDesktopViewportClaims - ).catch(() => {}) - } - - // Legacy fit-override-changed for non-mobile (desktop) subscribers - unsubscribeFit = !isMobile - ? runtime.subscribeToFitOverrideChanges(ptyId, (event) => { - const mode = - event.mode === 'mobile-fit' - ? event.mode - : (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ?? - 'desktop-fit') - emit({ - type: 'fit-override-changed', - mode, - cols: event.cols, - rows: event.rows - }) - }) - : () => {} - } catch (error) { - registration.releaseIfCurrent() - throw error - } - - await streamClosed - } - }), - defineMethod({ - name: 'terminal.unsubscribe', - params: TerminalUnsubscribe, - handler: async (params, { runtime, connectionId }) => { - // Why: only the connection that owns the subscription may retire it — a stale - // unsubscribe from the pre-reconnect socket names an id the replacement now owns. - let unsubscribed = runtime.cleanupSubscriptionIfOwnedByConnection( - params.subscriptionId, - connectionId - ) - // Why: older builds send a bare-handle subscriptionId, so also try the reconstructed `${terminal}:${clientId}` composite key. - // Why AND over the calls that ran: a clientless stream registers under the bare id - // and a client-scoped one under the composite, so either call can be the real - // teardown. Reporting false needs one genuine refusal, not merely a missing id. - if (params.client && !params.subscriptionId.includes(':')) { - unsubscribed = - runtime.cleanupSubscriptionIfOwnedByConnection( - `${params.subscriptionId}:${params.client.id}`, - connectionId - ) && unsubscribed - } - return { unsubscribed } - } - }), - defineMethod({ - name: 'terminal.getAutoRestoreFit', - params: z.object({}), - handler: async (_params, { runtime }) => ({ - ms: runtime.getMobileAutoRestoreFitMs() - }) - }), - defineMethod({ - name: 'terminal.setAutoRestoreFit', - params: TerminalSetAutoRestoreFit, - handler: async (params, { runtime }) => ({ - ms: runtime.setMobileAutoRestoreFitMs(params.ms) - }) - }) + ...TERMINAL_QUERY_METHODS, + ...TERMINAL_SEND_METHODS, + ...TERMINAL_LIFECYCLE_METHODS, + ...TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS, + ...TERMINAL_MULTIPLEX_METHODS, + ...TERMINAL_SUBSCRIBE_METHODS, + ...TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS ] diff --git a/src/main/runtime/rpc/methods/terminal/stream-schemas.ts b/src/main/runtime/rpc/methods/terminal/stream-schemas.ts new file mode 100644 index 00000000000..04a6990fe51 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/stream-schemas.ts @@ -0,0 +1,78 @@ +import { z } from 'zod' +import { requiredString } from '../../schemas' +import { TerminalViewport } from './unary-schemas' + +const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) + +export const TerminalResizeForClient = z.discriminatedUnion('mode', [ + z.object({ + terminal: requiredString('Missing terminal handle'), + mode: z.literal('mobile-fit'), + cols: z.number().finite().positive(), + rows: z.number().finite().positive(), + clientId: requiredString('Missing client ID') + }), + z.object({ + terminal: requiredString('Missing terminal handle'), + mode: z.literal('restore'), + clientId: requiredString('Missing client ID') + }) +]) + +export const TerminalSubscribe = TerminalHandle.extend({ + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop') + }) + .optional(), + viewport: TerminalViewport.optional(), + capabilities: z + .object({ + terminalBinaryStream: z.literal(1).optional(), + desktopViewportClaims: z.literal(1).optional(), + mobileInputLeaseOnly: z.literal(1).optional(), + writeUnavailable: z.literal(1).optional() + }) + .optional() +}) + +export const TerminalMultiplex = z.object({}) + +export const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ + streamId: z.number().int().min(1), + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop') + }) + .optional(), + viewport: TerminalViewport.optional(), + capabilities: z + .object({ + ackOutput: z.literal(1).optional(), + ackOutputSourceRanges: z.literal(1).optional(), + desktopViewportClaims: z.literal(1).optional(), + outputPause: z.literal(1).optional(), + writeUnavailable: z.literal(1).optional() + }) + .optional() +}) + +export const TerminalMultiplexLegacyAckFrame = z + .object({ + bytes: z.number().int().nonnegative() + }) + .strict() + +export const TerminalMultiplexSourceRangeAckFrame = z + .object({ + streamGeneration: z.string().min(1), + ackedEndByte: z.number().int().nonnegative() + }) + .strict() + +export const TerminalMultiplexSnapshotRequestFrame = z.object({ + requestId: z.number().int().positive().optional(), + scrollbackRows: z.number().finite().optional() +}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts new file mode 100644 index 00000000000..7313eb82b94 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts @@ -0,0 +1,188 @@ +import { InvalidArgumentError } from '../../core' +import type { + DriverState, + OrcaRuntimeService, + SubscriptionRegistration +} from '../../../orca-runtime' +import { + TERMINAL_INPUT_MAX_BYTES, + TERMINAL_INPUT_TOO_LARGE_ERROR, + isTerminalInputTooLargeWithYield +} from '../../../../../shared/terminal-input' +import type { TerminalViewportClient } from './terminal-stream-types' + +export function isTerminalInputLockedForClient( + runtime: OrcaRuntimeService, + ptyId: string, + client: TerminalViewportClient | undefined +): boolean { + if (client?.type === 'mobile') { + return false + } + // Why: pre-refactor mobile builds sent no client metadata, so treat a missing client as legacy mobile (unlocked). + if (!client) { + return false + } + return runtime.getDriver(ptyId).kind === 'mobile' +} + +export async function assertTerminalSendTextWithinLimit(text: string | undefined): Promise { + if (!text) { + return + } + // Why: sends can be paste-sized; validate outside Zod so large input yields before runtime dispatch. + if (await isTerminalInputTooLargeWithYield(text, TERMINAL_INPUT_MAX_BYTES)) { + throw new InvalidArgumentError(TERMINAL_INPUT_TOO_LARGE_ERROR) + } +} + +export function resolveMobileFloorClientId( + driver: DriverState | null, + client: TerminalViewportClient | undefined +): string | null { + if (client?.type === 'mobile') { + return client.id + } + if (!client && driver?.kind === 'mobile') { + return driver.clientId + } + return null +} + +export type TerminalStreamInputOutcome = 'delivered' | 'rejected' | 'failed' + +export function watchSubscriptionLifetime( + runtime: OrcaRuntimeService, + ptyId: string, + signal: AbortSignal | undefined, + registration: SubscriptionRegistration +): () => void { + let unsubscribeExit: (() => void) | null = null + let removeAbort: (() => void) | null = null + let stopped = false + const stop = (): void => { + stopped = true + unsubscribeExit?.() + removeAbort?.() + } + const release = (): void => { + registration.releaseIfCurrent() + stop() + } + unsubscribeExit = runtime.subscribeToPtyExit(ptyId, release) + if (stopped) { + unsubscribeExit() + return stop + } + if (!signal) { + return stop + } + if (signal.aborted) { + release() + return stop + } + const onAbort = (): void => release() + removeAbort = () => signal.removeEventListener('abort', onAbort) + signal.addEventListener('abort', onAbort, { once: true }) + if (stopped) { + removeAbort() + } + return stop +} + +export function isTerminalStreamInputRejection(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return message.includes('terminal_not_writable') || message.includes('terminal_handle_stale') +} + +export async function sendTerminalStreamInput( + runtime: OrcaRuntimeService, + args: { + terminal: string + text: string + client: TerminalViewportClient | undefined + isMobile: boolean + } +): Promise { + const action = { text: args.text, enter: false, interrupt: false } + const clientId = args.isMobile ? args.client?.id : undefined + const floorClaim: MobileInputFloorClaimHolder = { current: null } + try { + if (!clientId) { + const result = await runtime.sendTerminal(args.terminal, action) + return result.accepted ? 'delivered' : 'rejected' + } + const result = await runtime.sendTerminal(args.terminal, action, { + reserveWrite: (writePtyId) => { + const claim = runtime.beginMobileInputFloor(writePtyId, clientId) + if (!claim) { + throw new Error('mobile_input_floor_unavailable') + } + floorClaim.current = claim + }, + afterWrite: () => commitMobileInputFloorClaim(floorClaim) + }) + if (!result.accepted) { + floorClaim.current?.rollback() + return 'rejected' + } + return 'delivered' + } catch (error) { + floorClaim.current?.rollback() + return isTerminalStreamInputRejection(error) ? 'rejected' : 'failed' + } +} + +export type MobileInputFloorClaimHolder = { + current: ReturnType +} + +export async function commitMobileInputFloorClaim( + claim: MobileInputFloorClaimHolder +): Promise { + const current = claim.current + if (!current) { + return + } + try { + await current.commit() + } finally { + // Why: the runtime may yield before the next write, which then needs a fresh reservation if desktop reclaimed the floor. + if (claim.current === current) { + claim.current = null + } + } +} + +export function getTerminalSendGuardRefusedReason( + error: unknown +): 'no-agent' | 'permission' | undefined { + const message = error instanceof Error ? error.message : String(error) + if (message.includes('terminal_guard_permission')) { + return 'permission' + } + if (message.includes('terminal_guard_no_agent')) { + return 'no-agent' + } + return undefined +} + +export function isTerminalSendGuardNotWritable(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return message.includes('terminal_guard_not_writable') +} + +export function assertTerminalSendExactPtyBinding( + runtime: OrcaRuntimeService, + handle: string, + expectedPtyId: string | undefined +): void { + try { + if (expectedPtyId && runtime.resolveLiveLeafForHandle(handle)?.ptyId === expectedPtyId) { + return + } + } catch { + // Fall through to the stable guarded-send result below. + } + throw new Error('terminal_guard_not_writable') +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts new file mode 100644 index 00000000000..1033464071c --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts @@ -0,0 +1,141 @@ +import { + TerminalStreamOpcode, + decodeTerminalStreamJson, + decodeTerminalStreamText +} from '../../../../../shared/terminal-stream-protocol' +import { isTerminalInputLockedForClient, sendTerminalStreamInput } from './terminal-input-delivery' +import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types' +import { updateViewportForClient } from './terminal-viewport-update' + +type LegacyBinaryControlState = { + isClosed: () => boolean + isBuffering: () => boolean + setRegisteredRemoteDesktopDriver: () => void + setPendingRemoteDesktopViewport: (viewport: { cols: number; rows: number }) => void + getDesktopClaimTail: () => Promise + setDesktopClaimTail: (tail: Promise) => void + sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array) => void +} + +export function registerLegacyBinaryControlFrames( + args: TerminalSubscriptionArgs, + streamId: number, + remoteDesktopSubscriptionKey: string, + controls: LegacyBinaryControlState +): () => void { + const { + params, + runtime, + registerBinaryStreamHandler, + ptyId, + clientId, + isMobile, + supportsDesktopViewportClaims, + supportsWriteUnavailable + } = args + if (!registerBinaryStreamHandler) { + return () => {} + } + return registerBinaryStreamHandler(streamId, (frame) => { + if (controls.isClosed()) { + return + } + if (frame.opcode === TerminalStreamOpcode.Input) { + const text = decodeTerminalStreamText(frame.payload) + if (!text) { + return + } + if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + return + } + void controls.getDesktopClaimTail().then(async (claimed) => { + if (!claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + return + } + const outcome = await sendTerminalStreamInput(runtime, { + terminal: params.terminal, + text, + client: params.client, + isMobile + }) + if (!controls.isClosed() && outcome === 'rejected' && supportsWriteUnavailable) { + controls.sendFrame(TerminalStreamOpcode.WriteUnavailable) + } + }) + return + } + if (frame.opcode === TerminalStreamOpcode.Resize && params.client) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(frame.payload) + if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { + return + } + const cols = viewport.cols + const rows = viewport.rows + if (clientId) { + controls.setRegisteredRemoteDesktopDriver() + if (controls.isBuffering()) { + controls.setPendingRemoteDesktopViewport({ cols: viewport.cols, rows: viewport.rows }) + return + } + } + controls.setDesktopClaimTail( + controls + .getDesktopClaimTail() + .then(async (priorClaimed) => { + const result = await updateViewportForClient( + runtime, + ptyId, + remoteDesktopSubscriptionKey, + params.client!, + { cols, rows }, + 'desktop', + 'register', + !supportsDesktopViewportClaims + ) + return supportsDesktopViewportClaims ? priorClaimed && result.applied : result.applied + }) + .catch(() => false) + ) + return + } + if ( + frame.opcode === TerminalStreamOpcode.ClaimViewport && + params.client && + clientId && + !isMobile + ) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(frame.payload) + if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { + return + } + const cols = viewport.cols + const rows = viewport.rows + controls.setRegisteredRemoteDesktopDriver() + controls.setDesktopClaimTail( + controls + .getDesktopClaimTail() + .then( + () => + runtime.updateRemoteDesktopViewer( + ptyId, + remoteDesktopSubscriptionKey, + clientId, + cols, + rows, + true + ), + () => + runtime.updateRemoteDesktopViewer( + ptyId, + remoteDesktopSubscriptionKey, + clientId, + cols, + rows, + true + ) + ) + .catch(() => false) + ) + } + }) +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts new file mode 100644 index 00000000000..ad4c7dd05da --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts @@ -0,0 +1,184 @@ +import { createTerminalOutputBatcher } from './terminal-output-batcher' +import type { TerminalOutputBatcher } from './terminal-output-batcher' +import { isTerminalReadPayloadIncomplete } from './terminal-stream-replay' +import { serializeBudgetedMobileSnapshot } from './terminal-snapshot-publication' +import { updateViewportForClient } from './terminal-viewport-update' +import { watchSubscriptionLifetime } from './terminal-input-delivery' +import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types' +import { allocateTerminalSubscriptionStreamId } from './terminal-subscription-stream-id' + +export async function runTerminalLeaseSubscription(args: TerminalSubscriptionArgs): Promise { + const { params, runtime, connectionId, signal, emit, ptyId, clientId } = args + if (!clientId) { + return + } + let closed = false + let stopWatchingLifetime = (): void => {} + let resolveStream = (): void => {} + const streamClosed = new Promise((resolve) => { + resolveStream = resolve + }) + const subscriptionId = `${params.terminal}:${clientId}` + // Why: chat needs the input-floor ack without registering a view subscriber or transporting duplicate PTY output. + const registration = runtime.registerOwnedSubscriptionCleanup( + subscriptionId, + () => { + stopWatchingLifetime() + closed = true + runtime.handleMobileUnsubscribe(ptyId, clientId) + emit({ type: 'end' }) + resolveStream() + }, + connectionId + ) + stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) + if (closed) { + // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. + return + } + try { + // Why: a lease-only subscriber has no terminal view, so its cached viewport must never phone-fit the PTY. + await runtime.handleMobileSubscribe(ptyId, clientId, undefined) + if (closed || signal?.aborted) { + // Why: a disconnect can win the awaited subscribe and resurrect mobile presence after cleanup already released it. + // Unguarded on purpose: this must still fire when our own cleanup already ran. + // Safe only because lease-only passes no viewport and both !viewport paths in + // handleMobileSubscribeInternal return with no await, so no rebind can land + // first. Adding an await there — or passing a viewport here — makes a + // superseded handler delete the replacement's (ptyId, clientId) presence. + runtime.handleMobileUnsubscribe(ptyId, clientId) + if (!closed) { + registration.releaseIfCurrent() + } + return + } + emit({ type: 'subscribed', streamId: null, lines: [], truncated: false }) + await streamClosed + } catch (error) { + registration.releaseIfCurrent() + throw error + } +} + +export async function runTerminalJsonSubscription(args: TerminalSubscriptionArgs): Promise { + const { + params, + runtime, + connectionId, + signal, + emit, + ptyId, + clientId, + supportsDesktopViewportClaims + } = args + // Why: only unregister the width floor this subscription took (see the multiplex stream's registeredRemoteDesktopDriver note). + let registeredRemoteDesktopDriver = false + + // Why: a hidden watcher and a visible pane can subscribe to one terminal, so key by client so neither stream evicts the other. + const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal + const remoteDesktopSubscriptionKey = `json:${allocateTerminalSubscriptionStreamId()}` + let closed = false + let outputBatcher: TerminalOutputBatcher | null = null + let unsubscribeData = (): void => {} + let unsubscribeFit = (): void => {} + let stopWatchingLifetime = (): void => {} + let resolveStream = (): void => {} + const streamClosed = new Promise((resolve) => { + resolveStream = resolve + }) + // Why: register before viewport/snapshot awaits so a socket close can't orphan the stream listeners or its remote-desktop width floor. + const registration = runtime.registerOwnedSubscriptionCleanup( + subscriptionId, + () => { + stopWatchingLifetime() + closed = true + outputBatcher?.flush() + outputBatcher?.dispose() + unsubscribeData() + unsubscribeFit() + if (registeredRemoteDesktopDriver && clientId) { + runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) + } + emit({ type: 'end' }) + resolveStream() + }, + connectionId + ) + stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) + if (closed) { + // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. + return + } + try { + if (clientId && params.client && params.viewport) { + registeredRemoteDesktopDriver = true + await updateViewportForClient( + runtime, + ptyId, + remoteDesktopSubscriptionKey, + params.client, + params.viewport, + 'desktop', + 'register', + !supportsDesktopViewportClaims + ) + } + if (closed || signal?.aborted) { + registration.releaseIfCurrent() + return + } + const read = await runtime.readTerminal(params.terminal) + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) + if (closed || signal?.aborted) { + registration.releaseIfCurrent() + return + } + const size = runtime.getTerminalSize(ptyId) + const displayMode = runtime.getMobileDisplayMode(ptyId) + const seq = runtime.getLayout(ptyId)?.seq + emit({ + type: 'scrollback', + lines: read.tail, + truncated: isTerminalReadPayloadIncomplete(read), + serialized: serialized?.data, + oscLinks: serialized?.oscLinks, + cwd: serialized?.cwd, + // Why: an empty snapshot with no PTY size must still report the dims the fit + // will produce — dimless frames re-armed the mobile fit loop (STA-3337). + cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols, + rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows, + displayMode, + seq + }) + outputBatcher = createTerminalOutputBatcher((chunk) => { + emit({ type: 'data', chunk }) + }) + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => { + outputBatcher?.push(data) + }) + // Why: the legacy JSON stream can feed a live xterm view, so register as a view subscriber; worst case is a withheld model reply, safer than a double reply. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } + unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + outputBatcher?.flush() + const mode = + event.mode === 'mobile-fit' + ? event.mode + : (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ?? + 'desktop-fit') + emit({ + type: 'fit-override-changed', + mode, + cols: event.cols, + rows: event.rows + }) + }) + await streamClosed + } catch (error) { + registration.releaseIfCurrent() + throw error + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts new file mode 100644 index 00000000000..08639f16f48 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts @@ -0,0 +1,286 @@ +import { + TerminalStreamOpcode, + encodeTerminalStreamFrame, + encodeTerminalStreamJson +} from '../../../../../shared/terminal-stream-protocol' +import { iterateTerminalOutputFrameChunks } from '../../terminal-output-frame-chunks' +import { + EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE, + scanTerminalReplyQuerySequences, + type TerminalReplyQueryScanState, + type TerminalReplyQuerySequence +} from '../../../../../shared/terminal-reply-query-scan' +import { TERMINAL_MULTIPLEX_PENDING_MAX_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' +import { measureTerminalStreamByteLength } from '../../terminal-stream-byte-length' +import { createTerminalOutputBatcher, type TerminalOutputBatcher } from './terminal-output-batcher' +import { watchSubscriptionLifetime } from './terminal-input-delivery' +import { trimPendingOutputToBudget } from './terminal-stream-replay' +import { allocateTerminalSubscriptionStreamId } from './terminal-subscription-stream-id' +import type { + LegacyBinarySubscriptionState, + TerminalSubscriptionArgs +} from './terminal-legacy-subscription-types' +import type { TerminalOutputChunk } from './terminal-stream-types' +import { publishLegacyBinaryInitialSnapshot } from './terminal-legacy-subscribe-snapshot' +import { activateLegacyBinarySubscription } from './terminal-legacy-subscribe-live' +import { registerLegacyBinaryControlFrames } from './terminal-legacy-binary-control-frames' +const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 +export async function runTerminalBinarySubscription(args: TerminalSubscriptionArgs): Promise { + const { params, runtime, connectionId, sendBinary, signal, emit, ptyId, clientId, isMobile } = + args + if (!sendBinary) { + throw new Error('binary_terminal_stream_required') + } + let registeredRemoteDesktopDriver = false + const streamId = allocateTerminalSubscriptionStreamId() + const remoteDesktopSubscriptionKey = `stream:${streamId}` + let cursor = 0 + let closed = false + let buffering = true + let pendingRemoteDesktopViewport: { cols: number; rows: number } | null = null + let lastResizeCols: number | undefined + let resizeGeneration = 0 + let pendingOutput: TerminalOutputChunk[] = [] + let desktopClaimTail: Promise = Promise.resolve(true) + let pendingOutputBytes = 0 + let pendingOutputOverflowed = false + let pendingQueryScanState: TerminalReplyQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + const pendingQuerySequences: TerminalReplyQuerySequence[] = [] + let pendingQueryChars = 0 + let pendingQueryOverflowed = false + let unsubscribeData = (): void => {} + let unsubscribeResize = (): void => {} + let unsubscribeFit = (): void => {} + let unregisterBinaryHandler = (): void => {} + let abortRendererMountWait = (): void => {} + let stopWatchingLifetime = (): void => {} + let lateRendererReadyPromise: Promise | null = null + let outputBatcher: TerminalOutputBatcher | null = null + let resolveStream = (): void => {} + const streamClosed = new Promise((resolve) => { + resolveStream = resolve + }) + // Why: register cleanup before any await so a mid-subscribe disconnect still removes mobile presence; client-scoped ids also allow parallel desktop subscribers. + const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal + const registration = runtime.registerOwnedSubscriptionCleanup( + subscriptionId, + () => { + stopWatchingLifetime() + outputBatcher?.flush() + outputBatcher?.dispose() + closed = true + unsubscribeData() + unsubscribeResize() + unsubscribeFit() + unregisterBinaryHandler() + abortRendererMountWait() + if (isMobile && clientId) { + runtime.handleMobileUnsubscribe(ptyId, clientId) + } else if (registeredRemoteDesktopDriver && clientId) { + runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) + } + emit({ type: 'end' }) + resolveStream() + }, + connectionId + ) + stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) + if (closed) { + // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. + return + } + const sendFrame = ( + opcode: TerminalStreamOpcode, + payload: Uint8Array = new Uint8Array(), + frameSeq = cursor++ + ): void => { + if (closed || !sendBinary) { + return + } + sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: frameSeq, payload })) + } + outputBatcher = createTerminalOutputBatcher((data, meta) => { + if (meta?.cwd !== undefined) { + sendFrame( + TerminalStreamOpcode.Metadata, + encodeTerminalStreamJson({ cwd: meta.cwd }), + meta.seq + ) + } + for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { + sendFrame(chunk.opcode ?? TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) + } + }) + unregisterBinaryHandler = registerLegacyBinaryControlFrames( + args, + streamId, + remoteDesktopSubscriptionKey, + { + isClosed: () => closed, + isBuffering: () => buffering, + setRegisteredRemoteDesktopDriver: () => { + registeredRemoteDesktopDriver = true + }, + setPendingRemoteDesktopViewport: (viewport) => { + pendingRemoteDesktopViewport = viewport + }, + getDesktopClaimTail: () => desktopClaimTail, + setDesktopClaimTail: (tail) => { + desktopClaimTail = tail + }, + sendFrame + } + ) + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + if (closed) { + return + } + if (buffering) { + const rawLength = meta?.rawLength + if ( + typeof meta?.seq === 'number' && + typeof rawLength === 'number' && + rawLength === data.length + ) { + const scan = scanTerminalReplyQuerySequences( + data, + meta.seq - rawLength, + pendingQueryScanState + ) + pendingQueryScanState = scan.state + for (const query of scan.queries) { + if (pendingQueryChars + query.data.length > TERMINAL_QUERY_REPLAY_MAX_CHARS) { + pendingQueryOverflowed = true + break + } + pendingQuerySequences.push(query) + pendingQueryChars += query.data.length + } + } else { + pendingQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + } + const remainingBudget = Math.max(1, TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes) + const measurement = measureTerminalStreamByteLength(data, { + stopAfterBytes: remainingBudget + }) + pendingOutput.push({ data, bytes: measurement.byteLength, meta }) + pendingOutputBytes += measurement.byteLength + const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) + pendingOutputBytes = trimmed.bytes + pendingOutputOverflowed ||= trimmed.overflowed + return + } + outputBatcher?.push(data, meta) + }) + // Why: capture live bytes before mobile-fit awaits; registering presence first would suppress main while no view held the query. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } + const state: LegacyBinarySubscriptionState = { + streamId, + remoteDesktopSubscriptionKey, + get closed() { + return closed + }, + set closed(value) { + closed = value + }, + get buffering() { + return buffering + }, + set buffering(value) { + buffering = value + }, + get pendingRemoteDesktopViewport() { + return pendingRemoteDesktopViewport + }, + set pendingRemoteDesktopViewport(value) { + pendingRemoteDesktopViewport = value + }, + get lastResizeCols() { + return lastResizeCols + }, + set lastResizeCols(value) { + lastResizeCols = value + }, + get resizeGeneration() { + return resizeGeneration + }, + set resizeGeneration(value) { + resizeGeneration = value + }, + get pendingOutput() { + return pendingOutput + }, + set pendingOutput(value) { + pendingOutput = value + }, + get pendingOutputBytes() { + return pendingOutputBytes + }, + set pendingOutputBytes(value) { + pendingOutputBytes = value + }, + get pendingOutputOverflowed() { + return pendingOutputOverflowed + }, + set pendingOutputOverflowed(value) { + pendingOutputOverflowed = value + }, + pendingQuerySequences, + get pendingQueryOverflowed() { + return pendingQueryOverflowed + }, + set pendingQueryOverflowed(value) { + pendingQueryOverflowed = value + }, + get lateRendererReadyPromise() { + return lateRendererReadyPromise + }, + set lateRendererReadyPromise(value) { + lateRendererReadyPromise = value + }, + get abortRendererMountWait() { + return abortRendererMountWait + }, + set abortRendererMountWait(value) { + abortRendererMountWait = value + }, + outputBatcher: outputBatcher!, + get unsubscribeResize() { + return unsubscribeResize + }, + set unsubscribeResize(value) { + unsubscribeResize = value + }, + get unsubscribeFit() { + return unsubscribeFit + }, + set unsubscribeFit(value) { + unsubscribeFit = value + }, + get registeredRemoteDesktopDriver() { + return registeredRemoteDesktopDriver + }, + set registeredRemoteDesktopDriver(value) { + registeredRemoteDesktopDriver = value + }, + displayMode: 'auto', + registration, + streamClosed, + sendFrame + } + try { + await publishLegacyBinaryInitialSnapshot(args, state) + if (state.closed || signal?.aborted) { + return + } + activateLegacyBinarySubscription(args, state) + } catch (error) { + registration.releaseIfCurrent() + throw error + } + await streamClosed +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts new file mode 100644 index 00000000000..73a359d73af --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts @@ -0,0 +1,158 @@ +import { + TerminalStreamOpcode, + encodeTerminalStreamJson +} from '../../../../../shared/terminal-stream-protocol' +import { + sendMobileResizeRestream, + sendSnapshotFrames, + serializeStableMobileRendererSnapshot +} from './terminal-snapshot-publication' +import { updateViewportForClient } from './terminal-viewport-update' +import type { + LegacyBinarySubscriptionState, + TerminalSubscriptionArgs +} from './terminal-legacy-subscription-types' + +export function activateLegacyBinarySubscription( + args: TerminalSubscriptionArgs, + state: LegacyBinarySubscriptionState +): void { + const { params, runtime, emit, ptyId, clientId, isMobile, supportsDesktopViewportClaims } = args + const lateRendererReady = state.lateRendererReadyPromise + state.lateRendererReadyPromise = null + if (lateRendererReady) { + void lateRendererReady + .then(async (rendererReady) => { + if (!rendererReady || state.closed) { + return + } + state.outputBatcher?.flush() + const recovery = await serializeStableMobileRendererSnapshot(runtime, ptyId) + if (state.closed) { + return + } + if (!recovery?.data.length) { + return + } + // Why: late recovery has no buffered-output gate, so only an exact renderer high-water may reset mobile without erasing live bytes. + if (recovery.seq !== runtime.getPtyOutputSequence(ptyId)) { + return + } + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, recovery) + // Why: shipped mobile clients apply resized snapshots in place, so a blank xterm recovers without resubscribe. + const recoveryStats = sendSnapshotFrames(state.sendFrame, { + kind: 'resized', + cols: recovery.cols, + rows: recovery.rows, + displayMode: state.displayMode, + reason: 'renderer-mount-ready', + source: recovery.source, + truncated: false, + truncatedByByteBudget: recovery.truncatedByByteBudget, + data: recovery.data + }) + state.lastResizeCols = recovery.cols + console.log('[mobile-terminal-stream] recovery snapshot', { + terminal: params.terminal, + streamId: state.streamId, + reason: 'renderer-mount-ready', + bytes: recoveryStats.bytes, + chunks: recoveryStats.chunks, + scrollbackRows: recovery.scrollbackRows, + truncatedByByteBudget: recovery.truncatedByByteBudget === true + }) + }) + .catch(() => {}) + } + const sendResizedFrame = (event: { + cols: number + rows: number + displayMode: string + reason: string + seq?: number + }): void => { + state.lastResizeCols = event.cols + state.sendFrame( + TerminalStreamOpcode.Resized, + encodeTerminalStreamJson({ + cols: event.cols, + rows: event.rows, + displayMode: event.displayMode, + reason: event.reason, + seq: event.seq + }) + ) + } + state.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { + state.outputBatcher?.flush() + const eventGeneration = state.resizeGeneration + 1 + state.resizeGeneration = eventGeneration + // Why: xterm only re-wraps soft-wrapped lines, so a width change needs a full re-serialize+replay to rewrap restored hard-wrapped scrollback. + const widthChanged = isMobile && event.cols !== state.lastResizeCols + if (widthChanged) { + state.lastResizeCols = event.cols + void sendMobileResizeRestream( + runtime, + ptyId, + state.sendFrame, + event, + () => !state.closed && state.resizeGeneration === eventGeneration + ) + .then((restreamed) => { + if (state.closed || state.resizeGeneration !== eventGeneration) { + return + } + if (!restreamed) { + sendResizedFrame(event) + } + }) + // Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize. + .catch(() => { + if (state.closed || state.resizeGeneration !== eventGeneration) { + return + } + sendResizedFrame(event) + }) + return + } + sendResizedFrame(event) + }) + + // Install the resize listener before draining the parked viewport, since applyLayout emits synchronously. + if ( + clientId && + params.client && + state.registeredRemoteDesktopDriver && + state.pendingRemoteDesktopViewport + ) { + const viewport = state.pendingRemoteDesktopViewport + state.pendingRemoteDesktopViewport = null + void updateViewportForClient( + runtime, + ptyId, + state.remoteDesktopSubscriptionKey, + params.client, + viewport, + 'desktop', + 'register', + !supportsDesktopViewportClaims + ).catch(() => {}) + } + + // Legacy fit-override-changed for non-mobile (desktop) subscribers + state.unsubscribeFit = !isMobile + ? runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + const mode = + event.mode === 'mobile-fit' + ? event.mode + : (runtime.getRemoteDesktopFitHold?.(ptyId, state.remoteDesktopSubscriptionKey).mode ?? + 'desktop-fit') + emit({ + type: 'fit-override-changed', + mode, + cols: event.cols, + rows: event.rows + }) + }) + : () => {} +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts new file mode 100644 index 00000000000..fcae3d02f6f --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts @@ -0,0 +1,265 @@ +import { + sendSnapshotFrames, + serializeBudgetedMobileSnapshot, + serializeStableMobileRendererSnapshot +} from './terminal-snapshot-publication' +import { + getOutputAfterSnapshotSeq, + isTerminalReadPayloadIncomplete, + stripSnapshotBoundaryQuerySuffixes, + trimPendingOutputCoveredBySnapshot +} from './terminal-stream-replay' +import type { + LegacyBinarySubscriptionState, + TerminalSubscriptionArgs +} from './terminal-legacy-subscription-types' + +const MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS = 3_000 + +export async function publishLegacyBinaryInitialSnapshot( + args: TerminalSubscriptionArgs, + state: LegacyBinarySubscriptionState +): Promise { + const { + params, + runtime, + signal, + sendBinary, + emit, + ptyId, + clientId, + isMobile, + missingHeadlessStateBeforeMobileFit, + rendererMountRequestedBeforePty, + serializerGenerationBeforeMobileFit + } = args + if (isMobile && clientId) { + await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) + } else if (clientId && params.viewport) { + // Why: legacy subscribe records geometry without taking ownership; only an explicit activity/claim frame may suppress the host. + state.registeredRemoteDesktopDriver = true + state.pendingRemoteDesktopViewport = params.viewport + } + if (state.closed) { + return + } + + let read = await runtime.readTerminal(params.terminal) + let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (state.closed) { + return + } + // Why: missing model state (not blank snapshot text) signals a never-attached PTY; a renderer-sourced snapshot already proves attachment, so skip the remount. + const mountRequested = + missingHeadlessStateBeforeMobileFit && + serialized?.source !== 'renderer' && + (rendererMountRequestedBeforePty || runtime.requestRendererTerminalTabMount(params.terminal)) + if (missingHeadlessStateBeforeMobileFit && mountRequested) { + // Why: an idle legacy PTY emits no later byte, so wait for a settle proving this remount completed before replaying its screen. + const mountWaitController = new AbortController() + const abortMountWait = (): void => mountWaitController.abort() + state.abortRendererMountWait = abortMountWait + if (signal?.aborted) { + abortMountWait() + } else { + signal?.addEventListener('abort', abortMountWait, { once: true }) + } + const rendererReadyPromise = runtime + .waitForRendererTerminalSerializer( + ptyId, + serializerGenerationBeforeMobileFit, + undefined, + mountWaitController.signal + ) + .catch(() => false) + const finishMountWait = (): void => { + signal?.removeEventListener('abort', abortMountWait) + if (state.abortRendererMountWait === abortMountWait) { + state.abortRendererMountWait = () => {} + } + } + void rendererReadyPromise.then(finishMountWait, finishMountWait) + let deadlineTimer: ReturnType | null = null + const initialDeadline = new Promise((resolve) => { + deadlineTimer = setTimeout(() => resolve(false), MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS) + if (typeof deadlineTimer.unref === 'function') { + deadlineTimer.unref() + } + }) + const rendererReady = await Promise.race([rendererReadyPromise, initialDeadline]) + if (deadlineTimer) { + clearTimeout(deadlineTimer) + } + if (state.closed || signal?.aborted) { + return + } + if (rendererReady) { + read = await runtime.readTerminal(params.terminal) + const stableRendererSnapshot = await serializeStableMobileRendererSnapshot(runtime, ptyId) + if (state.closed) { + return + } + if (stableRendererSnapshot?.data.length) { + serialized = stableRendererSnapshot + const trailingOutput = state.pendingOutput.flatMap((item) => { + const output = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq) + const seq = item.meta?.seq + return output && typeof seq === 'number' ? [{ data: output.data, seq }] : [] + }) + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery( + ptyId, + stableRendererSnapshot, + trailingOutput + ) + } + } else { + // Why: a renderer can settle after the bounded initial response; keep observing so an idle PTY self-heals without bytes. + state.lateRendererReadyPromise = rendererReadyPromise + } + } + let initialOutputOverflowed = false + if (state.pendingOutputOverflowed) { + state.pendingOutput.splice(0) + state.pendingOutputBytes = 0 + state.pendingOutputOverflowed = false + read = await runtime.readTerminal(params.terminal) + serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (state.closed) { + return + } + if (state.pendingOutputOverflowed) { + initialOutputOverflowed = true + state.pendingOutput.splice(0) + state.pendingOutputBytes = 0 + state.pendingOutputOverflowed = false + } + } + const size = runtime.getTerminalSize(ptyId) + state.displayMode = runtime.getMobileDisplayMode(ptyId) + // Why: layout seq is the mobile stale-event filter's high-water mark (undefined pre-transition is fail-open). See docs/mobile-terminal-layout-state-machine.md. + const layoutSeq = runtime.getLayout(ptyId)?.seq + // Why: only an output offset can cover buffered chunks; layout versions are a separate sequence domain. + let snapshotOutputSeq = serialized?.seq + emit({ + type: 'subscribed', + streamId: state.streamId, + lines: read.tail, + truncated: initialOutputOverflowed || (!sendBinary && isTerminalReadPayloadIncomplete(read)), + cols: serialized?.cols ?? size?.cols, + rows: serialized?.rows ?? size?.rows, + displayMode: state.displayMode, + seq: layoutSeq + }) + const snapshotStats = sendSnapshotFrames(state.sendFrame, { + kind: 'scrollback', + // Why: prefer the subscriber's viewport over the 80x24 stopgap when the PTY has + // no size yet — the mismatch made mobile burn its resubscribe budget (STA-3337). + cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows ?? 24, + displayMode: state.displayMode, + seq: snapshotOutputSeq, + cwd: serialized?.cwd, + truncated: initialOutputOverflowed, + truncatedByByteBudget: serialized?.truncatedByByteBudget, + oscLinks: serialized?.oscLinks, + data: serialized?.data ?? '' + }) + console.log('[mobile-terminal-stream] snapshot', { + terminal: params.terminal, + streamId: state.streamId, + kind: 'scrollback', + bytes: snapshotStats.bytes, + chunks: snapshotStats.chunks, + scrollbackRows: serialized?.scrollbackRows, + truncatedByByteBudget: serialized?.truncatedByByteBudget === true + }) + // Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay. + state.lastResizeCols = serialized?.cols ?? size?.cols + let recoveryAttempts = 0 + // Why: if the bounded pre-subscribe tail overflowed, only a fresh model snapshot covers the dropped middle without replay gaps. + while (state.pendingOutputOverflowed && recoveryAttempts < 2) { + state.pendingOutputOverflowed = false + recoveryAttempts += 1 + const recovery = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (state.closed) { + return + } + if (!recovery) { + break + } + // Why: without an output seq (renderer fallback) covered chunks can't be trimmed exactly, so keep the bounded replay over an unverifiable snapshot. + if (typeof recovery.seq !== 'number') { + break + } + // Why: clients drop a repeat scrollback snapshot but apply 'resized' inline; omit seq so output-byte seqs don't pollute the layout-seq filter. + const recoveryStats = sendSnapshotFrames(state.sendFrame, { + kind: 'resized', + cols: recovery.cols, + rows: recovery.rows, + displayMode: state.displayMode, + reason: 'pending-output-overflow', + source: recovery.source, + truncated: false, + truncatedByByteBudget: recovery.truncatedByByteBudget, + data: recovery.data + }) + console.log('[mobile-terminal-stream] recovery snapshot', { + terminal: params.terminal, + streamId: state.streamId, + reason: 'pending-output-overflow', + bytes: recoveryStats.bytes, + chunks: recoveryStats.chunks, + scrollbackRows: recovery.scrollbackRows, + truncatedByByteBudget: recovery.truncatedByByteBudget === true + }) + const trimmed = trimPendingOutputCoveredBySnapshot(state.pendingOutput, recovery.seq) + state.pendingOutput = trimmed.chunks + state.pendingOutputBytes = trimmed.bytes + snapshotOutputSeq = recovery.seq + } + state.buffering = false + const bufferedOutput = state.pendingOutput.splice(0) + const queryReplayData = state.pendingQueryOverflowed + ? '' + : state.pendingQuerySequences + .filter( + (query) => + initialOutputOverflowed || + (typeof snapshotOutputSeq === 'number' && query.startSeq < snapshotOutputSeq) + ) + .map((query) => query.data) + .join('') + if (queryReplayData) { + // Why: snapshots omit control queries but their seq trims the live chunk; replay the post-snapshot query so the mobile xterm answers once. + state.outputBatcher.push(queryReplayData) + } + if (!initialOutputOverflowed) { + for (const item of bufferedOutput) { + const uncovered = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) + let uncoveredData = uncovered?.data ?? null + let uncoveredMeta = uncovered?.meta + if ( + uncoveredData && + uncoveredData !== item.data && + typeof snapshotOutputSeq === 'number' && + typeof item.meta?.seq === 'number' && + typeof item.meta.rawLength === 'number' + ) { + if (item.meta.rawLength === item.data.length) { + uncoveredMeta = { ...item.meta, rawLength: uncoveredData.length } + } + uncoveredData = stripSnapshotBoundaryQuerySuffixes( + uncoveredData, + snapshotOutputSeq, + snapshotOutputSeq, + state.pendingQuerySequences + ) + } + if (uncoveredData) { + state.outputBatcher.push(uncoveredData, uncoveredMeta) + } + } + } + state.pendingOutputBytes = 0 + state.outputBatcher.flush() +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts new file mode 100644 index 00000000000..d9eb624f0e1 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts @@ -0,0 +1,58 @@ +import type { z } from 'zod' +import type { RpcContext } from '../../core' +import type { SubscriptionRegistration } from '../../../orca-runtime' +import type { TerminalReplyQuerySequence } from '../../../../../shared/terminal-reply-query-scan' +import type { TerminalOutputChunk } from './terminal-stream-types' +import type { TerminalSubscribe } from './stream-schemas' +import type { TerminalOutputBatcher } from './terminal-output-batcher' +import type { TerminalStreamOpcode } from '../../../../../shared/terminal-stream-protocol' + +export type TerminalSubscribeParams = z.infer +export type TerminalSubscriptionEmit = (result: unknown) => void + +export type TerminalSubscriptionArgs = { + params: TerminalSubscribeParams + runtime: RpcContext['runtime'] + connectionId: RpcContext['connectionId'] + sendBinary: RpcContext['sendBinary'] + registerBinaryStreamHandler: RpcContext['registerBinaryStreamHandler'] + signal: RpcContext['signal'] + emit: TerminalSubscriptionEmit + ptyId: string + clientId: string | undefined + isMobile: boolean + supportsDesktopViewportClaims: boolean + supportsWriteUnavailable: boolean + missingHeadlessStateBeforeMobileFit: boolean + rendererMountRequestedBeforePty: boolean + serializerGenerationBeforeMobileFit: number +} + +export type LegacyBinarySubscriptionState = { + readonly streamId: number + readonly remoteDesktopSubscriptionKey: string + closed: boolean + buffering: boolean + pendingRemoteDesktopViewport: { cols: number; rows: number } | null + lastResizeCols: number | undefined + resizeGeneration: number + pendingOutput: TerminalOutputChunk[] + pendingOutputBytes: number + pendingOutputOverflowed: boolean + readonly pendingQuerySequences: TerminalReplyQuerySequence[] + pendingQueryOverflowed: boolean + lateRendererReadyPromise: Promise | null + abortRendererMountWait: () => void + outputBatcher: TerminalOutputBatcher + unsubscribeResize: () => void + unsubscribeFit: () => void + registeredRemoteDesktopDriver: boolean + displayMode: string + readonly registration: SubscriptionRegistration + readonly streamClosed: Promise + readonly sendFrame: ( + opcode: TerminalStreamOpcode, + payload?: Uint8Array, + frameSeq?: number + ) => void +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts new file mode 100644 index 00000000000..37c4cda51b4 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -0,0 +1,178 @@ +import { defineMethod, type RpcAnyMethod } from '../../core' +import { + navigationTargetsHost, + resolveRuntimeNavigationTarget +} from '../../../../../shared/runtime-navigation' +import { withTerminalCloseAttribution } from '../../terminal-close-attribution' +import { + AgentTeamsPrepareLaunch, + AgentTeamsTmuxCompat, + TerminalCreateParams, + TerminalFocus, + TerminalHandle, + TerminalSleep, + TerminalSplit, + TerminalStop, + TerminalStopExact, + TerminalWait +} from './unary-schemas' +import { TerminalResizeForClient } from './stream-schemas' + +export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'terminal.wait', + params: TerminalWait, + handler: async (params, { runtime, signal }) => ({ + wait: await runtime.waitForTerminal(params.terminal, { + condition: params.for, + timeoutMs: params.timeoutMs, + signal + }) + }) + }), + defineMethod({ + name: 'terminal.create', + params: TerminalCreateParams, + handler: async (params, { runtime, pairedDeviceId, clientId }) => ({ + terminal: await runtime.dedupeTerminalCreate( + pairedDeviceId ?? clientId ?? 'local', + params.worktree, + params.clientMutationId, + params.reconcileExisting === true, + (canonicalWorktreeSelector, preAllocatedHandle) => + runtime.createTerminal(canonicalWorktreeSelector, { + command: params.command, + startupCommandDelivery: params.startupCommandDelivery, + env: params.env, + envToDelete: params.envToDelete, + ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), + ...(params.resumeProviderSession + ? { resumeProviderSession: params.resumeProviderSession } + : {}), + ...(params.launchToken ? { launchToken: params.launchToken } : {}), + ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.terminalColorQueryReplies + ? { terminalColorQueryReplies: params.terminalColorQueryReplies } + : {}), + title: params.title, + focus: params.focus === true, + rendererBacked: params.rendererBacked === true, + activate: params.activate === true, + presentation: params.presentation, + tabId: params.tabId, + leafId: params.leafId, + ...(preAllocatedHandle ? { preAllocatedHandle } : {}) + }) + ) + }) + }), + defineMethod({ + name: 'terminal.split', + params: TerminalSplit, + handler: async (params, { runtime }) => ({ + split: await runtime.splitTerminal(params.terminal, { + direction: params.direction, + command: params.command, + env: params.env, + telemetrySource: params.telemetrySource + }) + }) + }), + defineMethod({ + name: 'terminal.stop', + params: TerminalStop, + handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree) + }), + defineMethod({ + name: 'terminal.sleep', + params: TerminalSleep, + handler: async (params, { runtime }) => runtime.sleepTerminalsForWorktree(params.worktree) + }), + defineMethod({ + name: 'terminal.stopExact', + params: TerminalStopExact, + handler: async (params, { runtime }) => + runtime.stopExactTerminalsForWorktree(params.worktree, params.expectedPtyIds, { + keepHistory: params.keepHistory, + targetOnly: params.targetOnly + }) + }), + defineMethod({ + name: 'terminal.resizeForClient', + params: TerminalResizeForClient, + handler: async (params, { runtime }) => { + // Why: a stale handle must fail with terminal_handle_stale, not resize the wrong PTY (#7718). + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + const result = await runtime.resizeForClient( + leaf.ptyId, + params.mode, + params.clientId, + params.mode === 'mobile-fit' ? params.cols : undefined, + params.mode === 'mobile-fit' ? params.rows : undefined + ) + return { + terminal: { + handle: params.terminal, + ...result + } + } + } + }), + defineMethod({ + name: 'terminal.focus', + params: TerminalFocus, + handler: async (params, { runtime, clientKind }) => ({ + focus: await runtime.focusTerminal(params.terminal, { + navigateHost: navigationTargetsHost( + resolveRuntimeNavigationTarget({ navigation: params.navigation, clientKind }) + ) + }) + }) + }), + defineMethod({ + name: 'terminal.close', + params: TerminalHandle, + handler: async (params, context) => ({ + close: await withTerminalCloseAttribution( + 'terminal.close', + context, + 'terminal', + params.terminal, + () => context.runtime.closeTerminal(params.terminal) + ) + }) + }), + defineMethod({ + name: 'terminal.closeTab', + params: TerminalHandle, + handler: async (params, context) => ({ + close: await withTerminalCloseAttribution( + 'terminal.closeTab', + context, + 'terminal-tab', + params.terminal, + () => context.runtime.closeTerminalTab(params.terminal) + ) + }) + }), + defineMethod({ + name: 'agentTeams.tmuxCompat', + params: AgentTeamsTmuxCompat, + handler: async (params, { runtime }) => ({ + tmux: await runtime.handleAgentTeamsTmuxCompat(params) + }) + }), + defineMethod({ + name: 'agentTeams.prepareLaunch', + params: AgentTeamsPrepareLaunch, + handler: async (params, { runtime }) => ({ + launch: await runtime.prepareClaudeAgentTeamsLeader({ + paneKey: params.paneKey, + baseEnv: params.env + }) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-cleanup.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-cleanup.ts new file mode 100644 index 00000000000..924975d34b6 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-cleanup.ts @@ -0,0 +1,100 @@ +import type { + TerminalMultiplexCleanupStage, + TerminalMultiplexConnection, + TerminalMultiplexFlowControlStage +} from './terminal-multiplex-connection' + +export function installMultiplexCleanup( + build: TerminalMultiplexFlowControlStage +): asserts build is TerminalMultiplexCleanupStage { + const state = build as TerminalMultiplexConnection + const { runtime, streams, pendingPtyWaitControllers, emit, signal } = state + state.detachStream = ( + streamId: number, + emitEnd: boolean, + releaseRemoteDesktopDriver = true + ): void => { + const stream = streams.get(streamId) + if (!stream) { + return + } + const replacement = stream.sourceRangeReplacement + stream.sourceRangeReplacement = null + if (replacement) { + runtime.rollbackRemoteTerminalSourceRangeReplacement( + replacement, + 'stream-detached-replacement-aborted' + ) + } + stream.outputBatcher.flush() + stream.outputBatcher.dispose() + state.detachSourceRangeConsumer(stream, 'stream-detached') + state.ackTotalInFlightBytes = Math.max(0, state.ackTotalInFlightBytes - stream.ackInFlightBytes) + stream.ackInFlightBytes = 0 + stream.ackPendingOutput = [] + stream.ackPendingOutputBytes = 0 + stream.ackPendingOutputOverflowed = false + stream.ackRecoverySnapshotInFlight = false + stream.unsubscribeData() + stream.unsubscribeResize() + stream.unsubscribeFit() + stream.unsubscribeDriver() + stream.unregisterBinaryHandler() + streams.delete(streamId) + state.flushAllAckPendingOutput() + // Why: release the runtime exit-waiter for this slot (see the field's note); delete before abort so its .catch no-ops instead of re-detaching. + stream.exitWaiterAbort.abort() + if (stream.isMobile && stream.client?.id) { + runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id) + } else if ( + releaseRemoteDesktopDriver && + stream.registeredRemoteDesktopDriver && + stream.client?.id + ) { + // Why: release the width floor only if THIS stream took it, so a passive stream can't release a peer's floor. + runtime.unregisterRemoteDesktopViewer(stream.ptyId, stream.remoteDesktopSubscriptionKey) + } + if (emitEnd) { + emit({ type: 'end', streamId }) + } + } + state.cancelPendingPtyWaits = (streamId: number): void => { + const controllers = pendingPtyWaitControllers.get(streamId) + if (!controllers) { + return + } + pendingPtyWaitControllers.delete(streamId) + for (const controller of controllers) { + controller.abort() + } + } + state.cancelAllPendingPtyWaits = (): void => { + for (const streamId of Array.from(pendingPtyWaitControllers.keys())) { + state.cancelPendingPtyWaits(streamId) + } + } + state.closeMultiplex = (): void => { + if (state.closed) { + return + } + state.closed = true + signal?.removeEventListener('abort', state.cancelAllPendingPtyWaits) + state.cancelAllPendingPtyWaits() + const remoteDesktopKeysByPty = new Map() + for (const streamId of Array.from(streams.keys())) { + const stream = streams.get(streamId) + if (stream?.registeredRemoteDesktopDriver && !stream.isMobile && stream.client?.id) { + const keys = remoteDesktopKeysByPty.get(stream.ptyId) ?? [] + keys.push(stream.remoteDesktopSubscriptionKey) + remoteDesktopKeysByPty.set(stream.ptyId, keys) + } + state.detachStream(streamId, false, false) + } + // Why: one connection can own many panes on the same PTY; remove floors together so close scans each registry once. + for (const [ptyId, subscriptionKeys] of remoteDesktopKeysByPty) { + void runtime.unregisterRemoteDesktopViewers(ptyId, subscriptionKeys) + } + state.unregisterControlHandler() + state.resolveMultiplex() + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts new file mode 100644 index 00000000000..67d68602b62 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts @@ -0,0 +1,106 @@ +import type { z } from 'zod' +import type { RpcContext } from '../../core' +import type { + TerminalStreamFrame, + TerminalStreamOpcode +} from '../../../../../shared/terminal-stream-protocol' +import type { TerminalOutputFrameChunk } from '../../terminal-output-frame-chunks' +import type { + TerminalMultiplexSnapshotRequestFrame, + TerminalMultiplexSubscribeFrame +} from './stream-schemas' +import type { TerminalMultiplexStream } from './terminal-stream-types' +import type { TerminalSourceRangeRegistry } from '../../terminal-source-range-registry' + +export type MultiplexSubscribeRequest = z.infer +export type MultiplexSnapshotRequest = z.infer +export type MultiplexEmit = (result: unknown) => void + +export type TerminalMultiplexConnectionBase = { + runtime: RpcContext['runtime'] + connectionId: string + sendBinary: NonNullable + registerBinaryStreamHandler: NonNullable + signal: RpcContext['signal'] + emit: MultiplexEmit + closed: boolean + cursor: number + streams: Map + sourceRangeRegistry: TerminalSourceRangeRegistry + pendingPtyWaitControllers: Map> + ackTotalInFlightBytes: number + ackTotalWindowBytes: number + ackFlushCursorStreamId: number | null + resolveMultiplex: () => void + multiplexClosed: Promise + unregisterControlHandler: () => void +} + +export type TerminalMultiplexFrameDelivery = { + sendFrame: ( + streamId: number, + opcode: TerminalStreamOpcode, + payload?: Uint8Array, + frameSeq?: number, + onRejected?: () => void + ) => boolean + sendStreamError: (streamId: number, message: string) => void + notifyStreamWriteUnavailable: ( + stream: TerminalMultiplexStream, + outcome: 'delivered' | 'rejected' | 'failed' + ) => void + sendResizedFrame: ( + stream: TerminalMultiplexStream, + event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } + ) => void + canSendAckGatedOutput: (stream: TerminalMultiplexStream, bytes: number) => boolean + sendAckGatedOutput: (stream: TerminalMultiplexStream, chunk: TerminalOutputFrameChunk) => boolean + queueOrSendOutput: (stream: TerminalMultiplexStream, chunk: TerminalOutputFrameChunk) => void +} + +export type TerminalMultiplexFlowControl = { + sendAckRecoverySnapshot: (stream: TerminalMultiplexStream) => Promise + flushAckPendingOutput: (stream: TerminalMultiplexStream, maxChunks?: number) => number + flushAllAckPendingOutput: () => void + acknowledgeOutput: (stream: TerminalMultiplexStream, bytes: number) => void + acknowledgeSourceRanges: ( + stream: TerminalMultiplexStream, + streamGeneration: string, + ackedEndByte: number + ) => void + detachSourceRangeConsumer: (stream: TerminalMultiplexStream, reason: string) => void +} + +export type TerminalMultiplexCleanup = { + detachStream: (streamId: number, emitEnd: boolean, releaseRemoteDesktopDriver?: boolean) => void + cancelPendingPtyWaits: (streamId: number) => void + cancelAllPendingPtyWaits: () => void + closeMultiplex: () => void +} + +export type TerminalMultiplexSlotFrames = { + handleSlotFrame: (stream: TerminalMultiplexStream, frame: TerminalStreamFrame) => void + sendRequestedSnapshot: ( + stream: TerminalMultiplexStream, + request: MultiplexSnapshotRequest + ) => Promise +} + +export type TerminalMultiplexSubscribeFrame = { + handleSubscribeFrame: (payload: Uint8Array) => Promise +} + +export type TerminalMultiplexFrameDeliveryStage = TerminalMultiplexConnectionBase & + TerminalMultiplexFrameDelivery + +export type TerminalMultiplexFlowControlStage = TerminalMultiplexFrameDeliveryStage & + TerminalMultiplexFlowControl + +export type TerminalMultiplexCleanupStage = TerminalMultiplexFlowControlStage & + TerminalMultiplexCleanup + +export type TerminalMultiplexSlotFramesStage = TerminalMultiplexCleanupStage & + TerminalMultiplexSlotFrames + +export type TerminalMultiplexConnection = TerminalMultiplexSlotFramesStage & + TerminalMultiplexSubscribeFrame diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts new file mode 100644 index 00000000000..a469b2d7a00 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts @@ -0,0 +1,264 @@ +import { + TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES, + TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES +} from '../../../../../shared/terminal-multiplex-flow-control' +import { drainTerminalMultiplexRoundRobin } from '../../terminal-multiplex-round-robin' +import { + sendSnapshotFrames, + serializeBudgetedRequestedSnapshot +} from './terminal-snapshot-publication' +import type { + TerminalMultiplexConnection, + TerminalMultiplexFlowControlStage, + TerminalMultiplexFrameDeliveryStage +} from './terminal-multiplex-connection' +import type { TerminalMultiplexStream } from './terminal-stream-types' +import type { RemoteTerminalSourceRangeReplacementReservation } from '../../../remote-terminal-source-range-consumer' + +export function installMultiplexFlowControl( + build: TerminalMultiplexFrameDeliveryStage +): asserts build is TerminalMultiplexFlowControlStage { + const state = build as TerminalMultiplexConnection + const { runtime, streams } = state + state.sendAckRecoverySnapshot = async (stream: TerminalMultiplexStream): Promise => { + if ( + state.closed || + streams.get(stream.streamId) !== stream || + stream.outputPaused || + stream.ackRecoverySnapshotInFlight + ) { + return + } + stream.ackRecoverySnapshotInFlight = true + let replacement: RemoteTerminalSourceRangeReplacementReservation | null = null + try { + const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0) + if (state.closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { + return + } + if (!serialized) { + throw new Error('Remote terminal recovery snapshot unavailable.') + } + if ( + stream.ackOutputSourceRanges && + (serialized.source === undefined || typeof serialized.seq !== 'number') + ) { + throw new Error('Remote terminal recovery snapshot source identity unavailable.') + } + if ( + stream.ackOutputSourceRanges && + serialized.source !== undefined && + typeof serialized.seq === 'number' + ) { + replacement = runtime.reserveRemoteTerminalSourceRangeReplacement( + { + ptyId: stream.ptyId, + consumerId: stream.remoteDesktopSubscriptionKey, + streamGeneration: stream.streamGeneration + }, + serialized.seq, + 'ack-pending-overflow' + ) + stream.sourceRangeReplacement = replacement + } + const displayMode = runtime.getMobileDisplayMode(stream.ptyId) + const publication = sendSnapshotFrames( + (opcode, payload) => + !state.closed && + streams.get(stream.streamId) === stream && + state.sendFrame(stream.streamId, opcode, payload), + { + kind: 'scrollback', + cols: serialized.cols, + rows: serialized.rows, + displayMode, + reason: 'ack-pending-overflow', + seq: serialized.seq, + source: serialized.source, + kittyKeyboardFlags: serialized.kittyKeyboardFlags, + truncatedByByteBudget: serialized.truncatedByByteBudget, + data: serialized.data + } + ) + if (!publication.published) { + throw new Error('Remote terminal recovery snapshot was not published.') + } + if (state.closed || streams.get(stream.streamId) !== stream) { + throw new Error('Remote terminal recovery snapshot stream detached.') + } + const localReplacement = replacement + ? typeof serialized.seq === 'number' + ? stream.sourceRangeLedger?.planSourceRangeReplacement(serialized.seq) + : null + : null + if (replacement && !localReplacement) { + throw new Error('Remote terminal recovery source ledger replacement unavailable.') + } + if ( + replacement && + (!serialized.source || + typeof serialized.seq !== 'number' || + !runtime.commitRemoteTerminalSourceRangeReplacement(replacement, { + source: serialized.source, + seq: serialized.seq + })) + ) { + throw new Error('Remote terminal recovery snapshot replacement was not accepted.') + } + localReplacement?.commit() + stream.sourceRangeReplacement = null + replacement = null + if (typeof serialized.seq === 'number') { + const snapshotSeq = serialized.seq + const retained = stream.ackPendingOutput.filter( + (chunk) => !(typeof chunk.seq === 'number' && chunk.seq <= snapshotSeq) + ) + stream.ackPendingOutput = retained + stream.ackPendingOutputBytes = retained.reduce( + (total, chunk) => total + chunk.bytes.byteLength, + 0 + ) + } + stream.ackPendingOutputOverflowed = false + } catch (error) { + if (replacement) { + if (stream.sourceRangeReplacement === replacement) { + stream.sourceRangeReplacement = null + runtime.rollbackRemoteTerminalSourceRangeReplacement( + replacement, + 'ack-pending-overflow-unpublished' + ) + } + replacement = null + } + if (state.closed || streams.get(stream.streamId) !== stream) { + return + } + state.sendStreamError( + stream.streamId, + error instanceof Error ? error.message : 'Remote terminal recovery snapshot failed.' + ) + state.detachStream(stream.streamId, true) + } finally { + if (streams.get(stream.streamId) === stream) { + stream.ackRecoverySnapshotInFlight = false + state.flushAllAckPendingOutput() + } + } + } + state.flushAckPendingOutput = ( + stream: TerminalMultiplexStream, + maxChunks = Number.POSITIVE_INFINITY + ): number => { + if (stream.outputPaused) { + return 0 + } + if (stream.ackPendingOutputOverflowed) { + void state.sendAckRecoverySnapshot(stream) + return 0 + } + let flushed = 0 + while ( + flushed < stream.ackPendingOutput.length && + flushed < maxChunks && + state.canSendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!.bytes.byteLength) + ) { + if (!state.sendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!)) { + return flushed + } + flushed += 1 + } + if (flushed > 0) { + stream.ackPendingOutput.splice(0, flushed) + stream.ackPendingOutputBytes = stream.ackPendingOutput.reduce( + (total, pending) => total + pending.bytes.byteLength, + 0 + ) + } + return flushed + } + state.flushAllAckPendingOutput = (): void => { + const ordered = Array.from(streams.values()) + state.ackFlushCursorStreamId = drainTerminalMultiplexRoundRobin({ + streams: ordered, + cursorStreamId: state.ackFlushCursorStreamId, + canContinue: () => !state.closed, + drainOne: (stream) => { + if (streams.get(stream.streamId) !== stream) { + return false + } + if (state.flushAckPendingOutput(stream, 1) > 0) { + return true + } + return false + } + }) + } + state.acknowledgeOutput = (stream: TerminalMultiplexStream, bytes: number): void => { + if (!stream.ackOutput || bytes <= 0) { + return + } + const acknowledged = Math.min(stream.ackInFlightBytes, bytes) + stream.ackWindowBytes = Math.min( + TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES, + stream.ackWindowBytes + acknowledged + ) + state.ackTotalWindowBytes = Math.min( + TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES, + state.ackTotalWindowBytes + acknowledged + ) + stream.ackInFlightBytes -= acknowledged + state.ackTotalInFlightBytes = Math.max(0, state.ackTotalInFlightBytes - acknowledged) + state.flushAllAckPendingOutput() + } + state.acknowledgeSourceRanges = ( + stream: TerminalMultiplexStream, + streamGeneration: string, + ackedEndByte: number + ): void => { + if (!stream.ackOutputSourceRanges) { + return + } + const result = stream.sourceRangeLedger?.acknowledge(streamGeneration, ackedEndByte) + if (!result) { + return + } + if (result.status !== 'accepted') { + return + } + if (result.settled.length > 0) { + runtime.settleRemoteTerminalSourceRanges( + { + ptyId: stream.ptyId, + consumerId: stream.remoteDesktopSubscriptionKey, + streamGeneration: stream.streamGeneration + }, + result.settled + ) + } + state.acknowledgeOutput(stream, result.acknowledgedBytes) + } + state.detachSourceRangeConsumer = (stream: TerminalMultiplexStream, reason: string): void => { + if (!stream.sourceRangeConsumerAttached) { + return + } + stream.sourceRangeConsumerAttached = false + const ledger = stream.sourceRangeLedger + stream.sourceRangeLedger = null + if (!ledger) { + return + } + const identity = { + ptyId: stream.ptyId, + consumerId: stream.remoteDesktopSubscriptionKey, + streamGeneration: stream.streamGeneration + } + const transfer = ledger.beginTransfer() + const ranges = transfer.frames.flatMap((frame) => frame.sourceRanges) + try { + runtime.cancelRemoteTerminalSourceRanges(identity, ranges, reason) + } finally { + transfer.commit() + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts new file mode 100644 index 00000000000..6e9c0e38231 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts @@ -0,0 +1,153 @@ +import { + TerminalStreamOpcode, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../../../shared/terminal-stream-protocol' +import { appendAckPendingOutput } from './terminal-stream-replay' +import type { + TerminalMultiplexConnection, + TerminalMultiplexConnectionBase, + TerminalMultiplexFrameDeliveryStage +} from './terminal-multiplex-connection' +import type { TerminalOutputFrameChunk } from '../../terminal-output-frame-chunks' +import type { TerminalStreamInputOutcome } from './terminal-input-delivery' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export function installMultiplexFrameDelivery( + build: TerminalMultiplexConnectionBase +): asserts build is TerminalMultiplexFrameDeliveryStage { + const state = build as TerminalMultiplexConnection + const { sendBinary, emit, streams } = state + state.sendFrame = ( + streamId: number, + opcode: TerminalStreamOpcode, + payload: Uint8Array = new Uint8Array(), + seq?: number, + onRejected?: () => void + ): boolean => { + if (state.closed) { + onRejected?.() + return false + } + // Why: a seq-less Output chunk must carry sentinel 0, not the control-frame state.cursor, or it poisons the client's frame-drop tracker. + const resolvedSeq = + typeof seq === 'number' ? seq : opcode === TerminalStreamOpcode.Output ? 0 : state.cursor++ + let sent: boolean | void + try { + sent = sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: resolvedSeq, payload })) + } catch { + onRejected?.() + state.closeMultiplex() + return false + } + if (sent === false) { + onRejected?.() + // Why: false means the transport discarded this frame; reconnect is the only available retry boundary with an authoritative snapshot. + state.closeMultiplex() + return false + } + return true + } + state.sendStreamError = (streamId: number, message: string): void => { + state.sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message)) + emit({ type: 'error', streamId, message }) + } + state.notifyStreamWriteUnavailable = ( + stream: TerminalMultiplexStream, + outcome: TerminalStreamInputOutcome + ): void => { + if ( + state.closed || + streams.get(stream.streamId) !== stream || + outcome !== 'rejected' || + !stream.supportsWriteUnavailable + ) { + return + } + state.sendFrame(stream.streamId, TerminalStreamOpcode.WriteUnavailable) + } + state.sendResizedFrame = ( + stream: TerminalMultiplexStream, + event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } + ): void => { + stream.lastResizeCols = event.cols + state.sendFrame( + stream.streamId, + TerminalStreamOpcode.Resized, + encodeTerminalStreamJson({ + cols: event.cols, + rows: event.rows, + displayMode: event.displayMode, + reason: event.reason, + seq: event.seq + }) + ) + } + state.canSendAckGatedOutput = (stream: TerminalMultiplexStream, bytes: number): boolean => { + if (!stream.ackOutput) { + return true + } + return ( + stream.ackInFlightBytes + bytes <= stream.ackWindowBytes && + state.ackTotalInFlightBytes + bytes <= state.ackTotalWindowBytes && + (!stream.ackOutputSourceRanges || stream.sourceRangeLedger?.canAccept(bytes) === true) + ) + } + state.sendAckGatedOutput = ( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk + ): boolean => { + const prepared = stream.ackOutputSourceRanges + ? stream.sourceRangeLedger?.prepareAccept( + chunk.bytes.byteLength, + chunk.displayLength, + chunk.sourceRanges ?? [], + chunk.seq + ) + : undefined + if (stream.ackOutputSourceRanges && prepared?.status !== 'ready') { + if (prepared?.status !== 'capacity') { + state.detachStream(stream.streamId, true) + } + return false + } + const admission = prepared?.status === 'ready' ? prepared.admission : undefined + const sent = state.sendFrame( + stream.streamId, + chunk.opcode ?? TerminalStreamOpcode.Output, + chunk.bytes, + chunk.seq, + admission?.rollback + ) + if (!sent) { + return false + } + if (admission && !admission.commit()) { + state.detachStream(stream.streamId, true) + return false + } + if (stream.ackOutput) { + stream.ackInFlightBytes += chunk.bytes.byteLength + state.ackTotalInFlightBytes += chunk.bytes.byteLength + } + return true + } + state.queueOrSendOutput = ( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk + ): void => { + if (state.closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { + return + } + if ( + stream.ackPendingOutputOverflowed || + stream.ackPendingOutput.length > 0 || + !state.canSendAckGatedOutput(stream, chunk.bytes.byteLength) + ) { + appendAckPendingOutput(stream, chunk) + return + } + state.sendAckGatedOutput(stream, chunk) + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts new file mode 100644 index 00000000000..724e14f2cc5 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts @@ -0,0 +1,137 @@ +import { + sendSnapshotFrames, + serializeBudgetedMobileSnapshot +} from './terminal-snapshot-publication' +import { getOutputAfterSnapshotSeq } from './terminal-stream-replay' +import type { + MultiplexSubscribeRequest, + TerminalMultiplexConnection +} from './terminal-multiplex-connection' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export type MultiplexPublishedInitialState = { + isMobile: boolean + size: { cols: number; rows: number } | null + displayMode: string +} + +export async function publishMultiplexInitialSnapshot( + state: TerminalMultiplexConnection, + request: MultiplexSubscribeRequest, + stream: TerminalMultiplexStream +): Promise { + const { runtime, streams, emit } = state + const { ptyId } = stream + const isMobile = stream.isMobile + let read = await runtime.readTerminal(request.terminal) + let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (state.closed || streams.get(request.streamId) !== stream) { + return null + } + let initialOutputOverflowed = false + if (stream.pendingOutputOverflowed) { + stream.pendingOutput.splice(0) + stream.pendingOutputBytes = 0 + stream.pendingOutputOverflowed = false + read = await runtime.readTerminal(request.terminal) + serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (state.closed || streams.get(request.streamId) !== stream) { + return null + } + if (stream.pendingOutputOverflowed) { + initialOutputOverflowed = true + stream.pendingOutput.splice(0) + stream.pendingOutputBytes = 0 + stream.pendingOutputOverflowed = false + } + } + const size = runtime.getTerminalSize(ptyId) + const displayMode = runtime.getMobileDisplayMode(ptyId) + const layoutSeq = runtime.getLayout(ptyId)?.seq + // Why: layout versions and output offsets are different sequence domains. + const snapshotOutputSeq = serialized?.seq + emit({ + type: 'subscribed', + streamId: request.streamId, + terminal: request.terminal, + cols: serialized?.cols ?? size?.cols, + rows: serialized?.rows ?? size?.rows, + displayMode, + seq: layoutSeq, + ...((stream.ackOutputSourceRanges || stream.supportsOutputPause) && { + capabilities: { + ...(stream.ackOutputSourceRanges ? { ackOutputSourceRanges: 1 as const } : {}), + ...(stream.supportsOutputPause ? { outputPause: 1 as const } : {}) + } + }), + ...(stream.ackOutputSourceRanges ? { streamGeneration: stream.streamGeneration } : {}), + // Why: retained-tail truncation loses history, not the authoritative latest-screen fallback. + truncated: initialOutputOverflowed + }) + stream.sourceRangeReplacement = + stream.ackOutputSourceRanges && + serialized?.source !== undefined && + typeof serialized.seq === 'number' + ? runtime.reserveRemoteTerminalSourceRangeReplacement( + { + ptyId, + consumerId: stream.remoteDesktopSubscriptionKey, + streamGeneration: stream.streamGeneration + }, + serialized.seq, + 'initial-snapshot' + ) + : null + const snapshotPublication = sendSnapshotFrames( + (opcode, payload) => state.sendFrame(request.streamId, opcode, payload), + { + kind: 'scrollback', + cols: serialized?.cols ?? size?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? 24, + displayMode, + seq: snapshotOutputSeq, + cwd: serialized?.cwd, + truncated: initialOutputOverflowed, + truncatedByByteBudget: serialized?.truncatedByByteBudget, + source: serialized?.source, + kittyKeyboardFlags: serialized?.kittyKeyboardFlags, + oscLinks: serialized?.oscLinks, + pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, + data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '') + } + ) + const replacement = stream.sourceRangeReplacement + stream.sourceRangeReplacement = null + if (replacement) { + const committed = + snapshotPublication.published && + serialized?.source !== undefined && + typeof serialized.seq === 'number' && + runtime.commitRemoteTerminalSourceRangeReplacement(replacement, { + source: serialized.source, + seq: serialized.seq + }) + if (!committed) { + runtime.rollbackRemoteTerminalSourceRangeReplacement( + replacement, + 'initial-snapshot-unpublished' + ) + } + } + // Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay. + stream.lastResizeCols = serialized?.cols ?? size?.cols + stream.buffering = false + const pendingOutput = stream.pendingOutput.splice(0) + if (!initialOutputOverflowed) { + for (const chunk of pendingOutput) { + const uncovered = getOutputAfterSnapshotSeq(chunk, snapshotOutputSeq) + if (uncovered) { + stream.outputBatcher.push(uncovered.data, uncovered.meta) + } + } + } + stream.pendingOutputBytes = 0 + stream.pendingOutputOverflowed = false + stream.outputBatcher.flush() + return { isMobile, size, displayMode } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-live-stream.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-live-stream.ts new file mode 100644 index 00000000000..45604dc4a01 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-live-stream.ts @@ -0,0 +1,139 @@ +import { sendMobileResizeRestream } from './terminal-snapshot-publication' +import { updateViewportForClient } from './terminal-viewport-update' +import type { + MultiplexSubscribeRequest, + TerminalMultiplexConnection +} from './terminal-multiplex-connection' +import type { MultiplexPublishedInitialState } from './terminal-multiplex-initial-snapshot' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export function activateMultiplexStream( + state: TerminalMultiplexConnection, + request: MultiplexSubscribeRequest, + stream: TerminalMultiplexStream, + published: MultiplexPublishedInitialState +): void { + const { runtime, streams, emit } = state + const { ptyId } = stream + const { isMobile, size } = published + if (!isMobile) { + stream.unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + const mode = + event.mode === 'mobile-fit' + ? event.mode + : (runtime.getRemoteDesktopFitHold?.(ptyId, stream.remoteDesktopSubscriptionKey).mode ?? + 'desktop-fit') + emit({ + type: 'fit-override-changed', + streamId: request.streamId, + mode, + cols: event.cols, + rows: event.rows + }) + }) + stream.unsubscribeDriver = runtime.subscribeToDriverChanges(ptyId, (driver) => { + emit({ + type: 'driver-changed', + streamId: request.streamId, + driver + }) + }) + const fitOverride = runtime.getTerminalFitOverride(ptyId) + const desktopHold = runtime.getRemoteDesktopFitHold?.( + ptyId, + stream.remoteDesktopSubscriptionKey + ) ?? { mode: 'desktop-fit' as const, cols: size?.cols ?? 0, rows: size?.rows ?? 0 } + emit({ + type: 'fit-override-changed', + streamId: request.streamId, + mode: fitOverride?.mode ?? desktopHold.mode, + cols: fitOverride?.cols ?? desktopHold.cols, + rows: fitOverride?.rows ?? desktopHold.rows + }) + emit({ + type: 'driver-changed', + streamId: request.streamId, + driver: runtime.getDriver(ptyId) + }) + } + stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { + stream.outputBatcher.flush() + const resizeGeneration = stream.resizeGeneration + 1 + stream.resizeGeneration = resizeGeneration + const widthChanged = stream.isMobile && event.cols !== stream.lastResizeCols + if (widthChanged) { + stream.lastResizeCols = event.cols + // Why: re-serialize+replay the full scrollback at the new cols so restored hard-wrapped lines rewrap; live output resumes after the snapshot lands. + void sendMobileResizeRestream( + runtime, + ptyId, + (opcode, payload) => state.sendFrame(request.streamId, opcode, payload), + event, + () => + !state.closed && + streams.get(request.streamId) === stream && + stream.resizeGeneration === resizeGeneration + ) + .then((restreamed) => { + if ( + state.closed || + streams.get(request.streamId) !== stream || + stream.resizeGeneration !== resizeGeneration + ) { + return + } + if (!restreamed) { + state.sendResizedFrame(stream, event) + } + }) + // Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize. + .catch(() => { + if ( + state.closed || + streams.get(request.streamId) !== stream || + stream.resizeGeneration !== resizeGeneration + ) { + return + } + state.sendResizedFrame(stream, event) + }) + return + } + state.sendResizedFrame(stream, event) + }) + // Install the resize listener before draining the parked viewport, since applyLayout emits synchronously. + if ( + !stream.isMobile && + stream.client?.id && + stream.registeredRemoteDesktopDriver && + stream.pendingRemoteDesktopViewport + ) { + const viewport = stream.pendingRemoteDesktopViewport + stream.pendingRemoteDesktopViewport = null + void updateViewportForClient( + runtime, + ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client, + viewport, + 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ).catch(() => {}) + } + void runtime + .waitForTerminal(request.terminal, { + condition: 'exit', + signal: stream.exitWaiterAbort.signal + }) + .then(() => { + if (streams.get(request.streamId) === stream) { + state.detachStream(request.streamId, true) + } + }) + .catch(() => { + if (streams.get(request.streamId) === stream) { + state.detachStream(request.streamId, true) + } + }) +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts new file mode 100644 index 00000000000..11fd0c4d039 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts @@ -0,0 +1,72 @@ +import { defineStreamingMethod, type RpcAnyMethod } from '../../core' +import { TerminalStreamOpcode } from '../../../../../shared/terminal-stream-protocol' +import { TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' +import { TerminalSourceRangeRegistry } from '../../terminal-source-range-registry' +import { TerminalMultiplex } from './stream-schemas' +import type { TerminalMultiplexConnectionBase } from './terminal-multiplex-connection' +import type { TerminalMultiplexStream } from './terminal-stream-types' +import { installMultiplexFrameDelivery } from './terminal-multiplex-frame-delivery' +import { installMultiplexFlowControl } from './terminal-multiplex-flow-control' +import { installMultiplexCleanup } from './terminal-multiplex-cleanup' +import { installMultiplexSlotFrames } from './terminal-multiplex-slot-frames' +import { installMultiplexSubscribeFrame } from './terminal-multiplex-subscribe-frame' + +export const TERMINAL_MULTIPLEX_METHODS: RpcAnyMethod[] = [ + defineStreamingMethod({ + name: 'terminal.multiplex', + params: TerminalMultiplex, + handler: async ( + _params, + { runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal }, + emit + ) => { + if (!sendBinary || !registerBinaryStreamHandler || !connectionId) { + throw new Error('binary_terminal_stream_required') + } + let resolveMultiplex = (): void => {} + const multiplexClosed = new Promise((resolve) => { + resolveMultiplex = resolve + }) + // Installers only close over this per-connection state; none of it is module-global. + const state: TerminalMultiplexConnectionBase = { + runtime, + connectionId, + sendBinary, + registerBinaryStreamHandler, + signal, + emit, + closed: false, + cursor: 0, + streams: new Map(), + sourceRangeRegistry: new TerminalSourceRangeRegistry(), + pendingPtyWaitControllers: new Map>(), + ackTotalInFlightBytes: 0, + ackTotalWindowBytes: TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES, + ackFlushCursorStreamId: null, + resolveMultiplex, + multiplexClosed, + unregisterControlHandler: () => {} + } + + installMultiplexFrameDelivery(state) + installMultiplexFlowControl(state) + installMultiplexCleanup(state) + installMultiplexSlotFrames(state) + installMultiplexSubscribeFrame(state) + + state.unregisterControlHandler = registerBinaryStreamHandler(0, (frame) => { + if (frame.opcode === TerminalStreamOpcode.Subscribe) { + void state.handleSubscribeFrame(frame.payload) + } + }) + signal?.addEventListener('abort', state.cancelAllPendingPtyWaits, { once: true }) + runtime.registerSubscriptionCleanup( + `terminal-multiplex:${connectionId}`, + state.closeMultiplex, + connectionId + ) + emit({ type: 'ready' }) + await multiplexClosed + } + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts new file mode 100644 index 00000000000..7a4ae50e701 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts @@ -0,0 +1,297 @@ +import { + TerminalStreamOpcode, + decodeTerminalStreamJson, + decodeTerminalStreamText, + type TerminalStreamFrame +} from '../../../../../shared/terminal-stream-protocol' +import { + TerminalMultiplexLegacyAckFrame, + TerminalMultiplexSnapshotRequestFrame, + TerminalMultiplexSourceRangeAckFrame +} from './stream-schemas' +import { isTerminalInputLockedForClient, sendTerminalStreamInput } from './terminal-input-delivery' +import { + getOutputAfterSnapshotSeq, + normalizeMultiplexSnapshotScrollbackRows +} from './terminal-stream-replay' +import { + sendSnapshotFrames, + serializeBudgetedRequestedSnapshot +} from './terminal-snapshot-publication' +import { updateViewportForClient } from './terminal-viewport-update' +import type { + MultiplexSnapshotRequest, + TerminalMultiplexCleanupStage, + TerminalMultiplexConnection, + TerminalMultiplexSlotFramesStage +} from './terminal-multiplex-connection' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export function installMultiplexSlotFrames( + build: TerminalMultiplexCleanupStage +): asserts build is TerminalMultiplexSlotFramesStage { + const state = build as TerminalMultiplexConnection + const { runtime, streams } = state + state.handleSlotFrame = (stream: TerminalMultiplexStream, frame: TerminalStreamFrame): void => { + if (state.closed || streams.get(stream.streamId) !== stream) { + return + } + if (frame.opcode === TerminalStreamOpcode.Unsubscribe) { + state.cancelPendingPtyWaits(stream.streamId) + state.detachStream(stream.streamId, false) + return + } + if (frame.opcode === TerminalStreamOpcode.Ack) { + const payload = decodeTerminalStreamJson(frame.payload) ?? {} + if (stream.ackOutputSourceRanges) { + const parsed = TerminalMultiplexSourceRangeAckFrame.safeParse(payload) + if (parsed.success) { + state.acknowledgeSourceRanges( + stream, + parsed.data.streamGeneration, + parsed.data.ackedEndByte + ) + } + } else { + const parsed = TerminalMultiplexLegacyAckFrame.safeParse(payload) + if (parsed.success) { + state.acknowledgeOutput(stream, parsed.data.bytes) + } + } + return + } + if (frame.opcode === TerminalStreamOpcode.Input) { + const text = decodeTerminalStreamText(frame.payload) + if (!text) { + return + } + if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { + return + } + // Mobile already has the higher-priority floor, so a rejected desktop claim must not suppress later phone input. + const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail + void inputClaimTail.then(async (claimed) => { + if (!claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { + return + } + const outcome = await sendTerminalStreamInput(runtime, { + terminal: stream.terminal, + text, + client: stream.client, + isMobile: stream.isMobile + }) + state.notifyStreamWriteUnavailable(stream, outcome) + }) + return + } + if (frame.opcode === TerminalStreamOpcode.SetOutputPaused && stream.supportsOutputPause) { + const payload = decodeTerminalStreamJson<{ paused?: unknown }>(frame.payload) + if (typeof payload?.paused !== 'boolean' || stream.outputPaused === payload.paused) { + return + } + stream.outputPaused = payload.paused + if (stream.outputPaused) { + stream.outputBatcher.flush() + stream.ackPendingOutput = [] + stream.ackPendingOutputBytes = 0 + stream.ackPendingOutputOverflowed = false + } + return + } + if (frame.opcode === TerminalStreamOpcode.Resize && stream.client) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(frame.payload) + if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { + return + } + const cols = viewport.cols + const rows = viewport.rows + // Why: resize registers stream-scoped geometry so detach can release it; older clients lack explicit claims. + if (!stream.isMobile && stream.client?.id) { + stream.registeredRemoteDesktopDriver = true + if (stream.buffering) { + stream.pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows } + return + } + } + stream.desktopClaimTail = stream.desktopClaimTail + .then(async (priorClaimed) => { + const result = await updateViewportForClient( + runtime, + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client!, + { cols, rows }, + stream.isMobile ? 'mobile' : 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ) + return stream.supportsDesktopViewportClaims + ? priorClaimed && result.applied + : result.applied + }) + .catch(() => false) + return + } + if (frame.opcode === TerminalStreamOpcode.ClaimViewport && stream.client && !stream.isMobile) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(frame.payload) + if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { + return + } + const cols = viewport.cols + const rows = viewport.rows + stream.registeredRemoteDesktopDriver = true + stream.desktopClaimTail = stream.desktopClaimTail + .then( + () => + runtime.updateRemoteDesktopViewer( + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client!.id, + cols, + rows, + true + ), + () => + runtime.updateRemoteDesktopViewer( + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client!.id, + cols, + rows, + true + ) + ) + .catch(() => false) + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotRequest) { + const payload = TerminalMultiplexSnapshotRequestFrame.safeParse( + decodeTerminalStreamJson(frame.payload) ?? {} + ) + void state.sendRequestedSnapshot(stream, payload.success ? payload.data : {}) + } + } + state.sendRequestedSnapshot = async ( + stream: TerminalMultiplexStream, + request: MultiplexSnapshotRequest + ): Promise => { + if (state.closed || streams.get(stream.streamId) !== stream) { + return + } + stream.outputBatcher.flush() + stream.pendingOutputOverflowed = false + stream.buffering = true + const requestId = request.requestId + let sentSnapshotOutputSeq: number | undefined + try { + const scrollbackRows = normalizeMultiplexSnapshotScrollbackRows(request.scrollbackRows) + let serialized = await serializeBudgetedRequestedSnapshot( + runtime, + stream.ptyId, + scrollbackRows + ) + if (state.closed || streams.get(stream.streamId) !== stream) { + return + } + let size = runtime.getTerminalSize(stream.ptyId) + let displayMode = runtime.getMobileDisplayMode(stream.ptyId) + if (stream.pendingOutputOverflowed) { + // Why: the overflowed tail is newer than the first snapshot, so retry for a current image instead of null. + stream.pendingOutput.splice(0) + stream.pendingOutputBytes = 0 + stream.pendingOutputOverflowed = false + serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, scrollbackRows) + if (state.closed || streams.get(stream.streamId) !== stream) { + return + } + size = runtime.getTerminalSize(stream.ptyId) + displayMode = runtime.getMobileDisplayMode(stream.ptyId) + if (stream.pendingOutputOverflowed) { + sendSnapshotFrames( + (opcode, payload) => state.sendFrame(stream.streamId, opcode, payload), + { + kind: 'scrollback', + cols: size?.cols ?? 80, + rows: size?.rows ?? 24, + requestId, + displayMode, + truncated: true, + truncatedByByteBudget: false, + unavailable: 'pending-output-overflowed', + data: '' + } + ) + return + } + } + sentSnapshotOutputSeq = serialized?.seq + sendSnapshotFrames((opcode, payload) => state.sendFrame(stream.streamId, opcode, payload), { + kind: 'scrollback', + cols: serialized?.cols ?? size?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? 24, + requestId, + displayMode, + seq: serialized?.seq, + cwd: serialized?.cwd, + source: serialized?.source, + kittyKeyboardFlags: serialized?.kittyKeyboardFlags, + oscLinks: serialized?.oscLinks, + pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, + truncated: false, + truncatedByByteBudget: serialized?.truncatedByByteBudget, + // Why: no serializer answered, which is not proof the pane is empty — say so instead of passing off '' as the buffer. + unavailable: serialized ? undefined : 'no-serializable-buffer', + data: serialized?.data ?? '' + }) + } catch (error) { + state.sendStreamError( + stream.streamId, + error instanceof Error ? error.message : 'Remote terminal snapshot failed.' + ) + } finally { + if (streams.get(stream.streamId) === stream) { + const shouldFlushPendingOutput = !stream.pendingOutputOverflowed + stream.buffering = false + const pendingOutput = stream.pendingOutput.splice(0) + if (shouldFlushPendingOutput) { + for (const chunk of pendingOutput) { + // Why: an untagged reply resets the client to the snapshot's + // high-water, so covered bytes would render twice; tagged + // snapshots feed a side consumer and the live view still + // needs every buffered chunk. + const uncovered = + typeof requestId === 'number' + ? chunk + : getOutputAfterSnapshotSeq(chunk, sentSnapshotOutputSeq) + if (uncovered) { + stream.outputBatcher.push(uncovered.data, uncovered.meta) + } + } + } + stream.pendingOutputBytes = 0 + stream.pendingOutputOverflowed = false + stream.outputBatcher.flush() + // Why: a resize parked during snapshot buffering must be applied now, or it is dropped until the viewer's next resize. + if ( + !stream.isMobile && + stream.client?.id && + stream.registeredRemoteDesktopDriver && + stream.pendingRemoteDesktopViewport + ) { + const viewport = stream.pendingRemoteDesktopViewport + stream.pendingRemoteDesktopViewport = null + void updateViewportForClient( + runtime, + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client, + viewport, + 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ).catch(() => {}) + } + } + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts new file mode 100644 index 00000000000..cc3ff90b2d4 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts @@ -0,0 +1,151 @@ +import { randomUUID } from 'node:crypto' +import { + TerminalStreamOpcode, + encodeTerminalStreamJson +} from '../../../../../shared/terminal-stream-protocol' +import { iterateTerminalOutputFrameChunks } from '../../terminal-output-frame-chunks' +import { TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' +import { createTerminalOutputBatcher } from './terminal-output-batcher' +import { appendPendingMultiplexOutput } from './terminal-stream-replay' +import { updateViewportForClient } from './terminal-viewport-update' +import type { + MultiplexSubscribeRequest, + TerminalMultiplexConnection +} from './terminal-multiplex-connection' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export async function initializeMultiplexStream( + state: TerminalMultiplexConnection, + request: MultiplexSubscribeRequest, + ptyId: string, + onInstalled: (stream: TerminalMultiplexStream) => void +): Promise { + const { runtime, connectionId, streams, sourceRangeRegistry, registerBinaryStreamHandler } = state + const isMobile = request.client?.type === 'mobile' + const remoteDesktopSubscriptionKey = `multiplex:${connectionId}:${request.streamId}` + const streamGeneration = randomUUID() + const requestedSourceRangeConsumer = + request.capabilities?.ackOutput === 1 && request.capabilities?.ackOutputSourceRanges === 1 + const sourceRangeLedger = requestedSourceRangeConsumer + ? sourceRangeRegistry.open(streamGeneration) + : null + const sourceRangeConsumerAttached = + sourceRangeLedger !== null && + runtime.attachRemoteTerminalSourceRangeConsumer({ + ptyId, + consumerId: remoteDesktopSubscriptionKey, + streamGeneration + }) + if (!sourceRangeConsumerAttached) { + sourceRangeLedger?.close() + } + const stream: TerminalMultiplexStream = { + streamId: request.streamId, + terminal: request.terminal, + ptyId, + client: request.client, + isMobile, + ackOutput: request.capabilities?.ackOutput === 1, + ackOutputSourceRanges: sourceRangeConsumerAttached, + streamGeneration, + sourceRangeLedger: sourceRangeConsumerAttached ? sourceRangeLedger : null, + sourceRangeConsumerAttached, + sourceRangeReplacement: null, + ackInFlightBytes: 0, + ackWindowBytes: TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES, + supportsOutputPause: request.capabilities?.outputPause === 1, + supportsWriteUnavailable: request.capabilities?.writeUnavailable === 1, + outputPaused: false, + supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1, + desktopClaimTail: Promise.resolve(true), + registeredRemoteDesktopDriver: false, + // Why: streamId is client-local, so key the width floor by connectionId or two connections sharing stream 1 for one PTY clobber each other's floor. + remoteDesktopSubscriptionKey, + pendingRemoteDesktopViewport: null, + buffering: true, + ackPendingOutput: [], + ackPendingOutputBytes: 0, + ackPendingOutputOverflowed: false, + ackRecoverySnapshotInFlight: false, + pendingOutput: [], + pendingOutputBytes: 0, + pendingOutputOverflowed: false, + lastResizeCols: undefined, + resizeGeneration: 0, + outputBatcher: createTerminalOutputBatcher((data, meta) => { + if (meta?.cwd !== undefined) { + state.sendFrame( + request.streamId, + TerminalStreamOpcode.Metadata, + encodeTerminalStreamJson({ cwd: meta.cwd }), + meta.seq + ) + } + for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { + state.queueOrSendOutput(stream, chunk) + } + }), + unsubscribeData: () => {}, + unsubscribeResize: () => {}, + unsubscribeFit: () => {}, + unsubscribeDriver: () => {}, + unregisterBinaryHandler: () => {}, + exitWaiterAbort: new AbortController() + } + streams.set(request.streamId, stream) + stream.unregisterBinaryHandler = registerBinaryStreamHandler(request.streamId, (frame) => + state.handleSlotFrame(stream, frame) + ) + onInstalled(stream) + + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + if (state.closed || streams.get(request.streamId) !== stream) { + return + } + if (stream.outputPaused) { + return + } + if (stream.buffering) { + appendPendingMultiplexOutput(stream, data, meta) + return + } + stream.outputBatcher.push(data, meta) + }) + // Why: a multiplexed stream feeds a remote xterm view with query authority, so the main model responder yields while attached (terminal-query-authority.md). + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + stream.unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } + + if (isMobile && request.client?.id) { + await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport) + } else if (request.client?.id && request.viewport) { + // Why: subscribe records this stream's geometry and cleanup key but doesn't claim ownership; activity frames claim later. + stream.registeredRemoteDesktopDriver = true + stream.pendingRemoteDesktopViewport = request.viewport + } + if ( + !isMobile && + request.client?.id && + stream.registeredRemoteDesktopDriver && + stream.pendingRemoteDesktopViewport + ) { + const viewport = stream.pendingRemoteDesktopViewport + stream.pendingRemoteDesktopViewport = null + await updateViewportForClient( + runtime, + ptyId, + stream.remoteDesktopSubscriptionKey, + request.client, + viewport, + 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ) + } + if (state.closed || streams.get(request.streamId) !== stream) { + return null + } + return stream +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts new file mode 100644 index 00000000000..dcb7b64ece4 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts @@ -0,0 +1,73 @@ +import { decodeTerminalStreamJson } from '../../../../../shared/terminal-stream-protocol' +import { TerminalMultiplexSubscribeFrame } from './stream-schemas' +import type { + TerminalMultiplexConnection, + TerminalMultiplexSlotFramesStage +} from './terminal-multiplex-connection' +import { resolveMultiplexSubscribePty } from './terminal-multiplex-subscribe-resolution' +import { initializeMultiplexStream } from './terminal-multiplex-stream-initialization' +import { publishMultiplexInitialSnapshot } from './terminal-multiplex-initial-snapshot' +import { activateMultiplexStream } from './terminal-multiplex-live-stream' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export function installMultiplexSubscribeFrame( + build: TerminalMultiplexSlotFramesStage +): asserts build is TerminalMultiplexConnection { + const state = build as TerminalMultiplexConnection + state.handleSubscribeFrame = async (payload) => { + const raw = decodeTerminalStreamJson(payload) + const parsed = TerminalMultiplexSubscribeFrame.safeParse(raw) + if (!parsed.success) { + return + } + const request = parsed.data + const resolution = resolveMultiplexSubscribePty(state, request) + const ptyId = + typeof resolution === 'string' || resolution === null ? resolution : await resolution + if (!ptyId) { + return + } + let stream: TerminalMultiplexStream | null = null + let installedStream: TerminalMultiplexStream | null = null + try { + stream = await initializeMultiplexStream(state, request, ptyId, (installed) => { + installedStream = installed + }) + } catch (error) { + if (!installedStream) { + throw error + } + if (state.streams.get(request.streamId) !== installedStream) { + return + } + state.detachStream(request.streamId, false) + state.sendStreamError( + request.streamId, + error instanceof Error ? error.message : String(error) + ) + state.emit({ type: 'end', streamId: request.streamId }) + return + } + if (!stream) { + return + } + try { + const published = await publishMultiplexInitialSnapshot(state, request, stream) + if (!published) { + return + } + activateMultiplexStream(state, request, stream, published) + } catch (error) { + // A successor may already own the reused stream ID; never tear it down here. + if (state.streams.get(request.streamId) !== stream) { + return + } + state.detachStream(request.streamId, false) + state.sendStreamError( + request.streamId, + error instanceof Error ? error.message : String(error) + ) + state.emit({ type: 'end', streamId: request.streamId }) + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts new file mode 100644 index 00000000000..6197850a47a --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts @@ -0,0 +1,96 @@ +import { TerminalStreamOpcode } from '../../../../../shared/terminal-stream-protocol' +import { + TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION, + TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION, + TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR +} from '../../../../../shared/terminal-multiplex-flow-control' +import type { + MultiplexSubscribeRequest, + TerminalMultiplexConnection +} from './terminal-multiplex-connection' + +function finalizeResolvedMultiplexPty( + state: TerminalMultiplexConnection, + request: MultiplexSubscribeRequest, + leaf: { ptyId: string | null } | null +): string | null { + if (!leaf?.ptyId) { + state.sendStreamError(request.streamId, 'no_connected_pty') + state.emit({ type: 'end', streamId: request.streamId }) + return null + } + if (state.closed) { + return null + } + // Why: a competing subscribe may own this streamId after the PTY await; detach it so an orphaned view subscriber can't silence the model responder (terminal-query-authority.md). + state.detachStream(request.streamId, false) + if (state.streams.size >= TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION) { + state.sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) + state.emit({ type: 'end', streamId: request.streamId }) + return null + } + return leaf.ptyId +} + +export function resolveMultiplexSubscribePty( + state: TerminalMultiplexConnection, + request: MultiplexSubscribeRequest +): string | null | Promise { + const { runtime, pendingPtyWaitControllers, registerBinaryStreamHandler, signal, emit } = state + state.detachStream(request.streamId, false) + state.cancelPendingPtyWaits(request.streamId) + + let leaf: { ptyId: string | null } | null + try { + // Why: binding the stream to whatever PTY now occupies a stale handle's pane would mirror the wrong terminal (#7718). + leaf = runtime.resolveLiveLeafForHandle(request.terminal) + } catch { + state.sendStreamError(request.streamId, 'terminal_handle_stale') + emit({ type: 'end', streamId: request.streamId }) + return null + } + if (leaf?.ptyId || !request.client) { + return finalizeResolvedMultiplexPty(state, request, leaf) + } + if (pendingPtyWaitControllers.size >= TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION) { + state.sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) + emit({ type: 'end', streamId: request.streamId }) + return null + } + + // Why: a never-mounted tab has no graph leaf to await; mounting the exact tab attaches its PTY without activating the worktree. + runtime.requestRendererTerminalTabMount(request.terminal) + const waitController = new AbortController() + const pendingControllers = pendingPtyWaitControllers.get(request.streamId) ?? new Set() + pendingControllers.add(waitController) + pendingPtyWaitControllers.set(request.streamId, pendingControllers) + if (signal?.aborted) { + waitController.abort() + } + // Why: the live slot handler does not exist until the PTY attaches; retain cancellation ownership while the pane is still pending. + const unregisterPendingHandler = registerBinaryStreamHandler(request.streamId, (frame) => { + if (frame.opcode === TerminalStreamOpcode.Unsubscribe) { + state.cancelPendingPtyWaits(request.streamId) + state.detachStream(request.streamId, false) + } + }) + return (async () => { + try { + const ptyId = await runtime.waitForLeafPtyId(request.terminal, 10_000, waitController.signal) + leaf = { ptyId } + } catch { + if (state.closed || signal?.aborted || waitController.signal.aborted) { + return null + } + // Fall through to the explicit no_connected_pty error below. + } finally { + const currentControllers = pendingPtyWaitControllers.get(request.streamId) + currentControllers?.delete(waitController) + if (currentControllers?.size === 0) { + pendingPtyWaitControllers.delete(request.streamId) + } + unregisterPendingHandler() + } + return finalizeResolvedMultiplexPty(state, request, leaf) + })() +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts b/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts new file mode 100644 index 00000000000..4a5056946d6 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts @@ -0,0 +1,120 @@ +import type { TerminalOutputMeta } from '../../terminal-output-frame-chunks' +import { TERMINAL_OUTPUT_BATCH_MAX_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' +import { measureTerminalStreamByteLength } from '../../terminal-stream-byte-length' +import { + sameTerminalOutputSourceIdentity, + type TerminalOutputSourceRange +} from '../../../../../shared/terminal-output-source-range' + +const TERMINAL_OUTPUT_FLUSH_MS = 5 +export type TerminalOutputBatcher = { + push: (data: string, meta?: TerminalOutputMeta) => void + flush: () => void + dispose: () => void +} + +export function createTerminalOutputBatcher( + onFlush: (data: string, meta?: TerminalOutputMeta) => void +): TerminalOutputBatcher { + let chunks: string[] = [] + let bytes = 0 + let lastSeq: number | undefined + let pendingCwd: string | undefined + let pendingRawLength = 0 + let pendingSourceRanges: TerminalOutputSourceRange[] = [] + let timer: ReturnType | null = null + + const clearTimer = (): void => { + if (!timer) { + return + } + clearTimeout(timer) + timer = null + } + + const flush = (): void => { + clearTimer() + if (chunks.length === 0 && pendingRawLength === 0) { + return + } + const data = chunks.length === 1 ? chunks[0]! : chunks.join('') + const meta = + typeof lastSeq === 'number' || pendingCwd !== undefined || pendingSourceRanges.length > 0 + ? { + ...(typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: pendingRawLength } : {}), + ...(pendingCwd !== undefined ? { cwd: pendingCwd } : {}), + ...(pendingSourceRanges.length > 0 + ? { sourceRanges: Object.freeze(pendingSourceRanges.slice()) } + : {}) + } + : undefined + chunks = [] + bytes = 0 + lastSeq = undefined + pendingCwd = undefined + pendingRawLength = 0 + pendingSourceRanges = [] + onFlush(data, meta) + } + + return { + push(data: string, meta?: TerminalOutputMeta): void { + const rawLength = meta?.rawLength ?? data.length + if (!data && rawLength === 0) { + return + } + if (meta?.transformed || rawLength !== data.length) { + flush() + onFlush(data, { ...meta, rawLength, transformed: true }) + return + } + const nextSourceRanges = meta?.sourceRanges ?? [] + const lastSourceRange = pendingSourceRanges.at(-1) + const firstNextSourceRange = nextSourceRanges[0] + if ( + chunks.length > 0 && + (pendingSourceRanges.length > 0 !== nextSourceRanges.length > 0 || + (lastSourceRange && + firstNextSourceRange && + (!sameTerminalOutputSourceIdentity(lastSourceRange, firstNextSourceRange) || + lastSourceRange.displayEnd !== firstNextSourceRange.displayStart))) + ) { + flush() + } + if (meta?.cwd !== undefined) { + flush() + pendingCwd = meta.cwd + } + chunks.push(data) + pendingRawLength += rawLength + pendingSourceRanges.push(...nextSourceRanges) + const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes) + const measurement = measureTerminalStreamByteLength(data, { + stopAfterBytes: remainingBudget + }) + bytes += measurement.byteLength + if (typeof meta?.seq === 'number') { + lastSeq = meta.seq + } + if (measurement.exceededLimit || bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) { + flush() + return + } + if (!timer) { + // Why: coalesce stream output before it crosses the network; desktop subscribers share the same burst boundary. + timer = setTimeout(flush, TERMINAL_OUTPUT_FLUSH_MS) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + }, + flush, + dispose(): void { + clearTimer() + chunks = [] + bytes = 0 + pendingRawLength = 0 + pendingSourceRanges = [] + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts new file mode 100644 index 00000000000..91ce5498506 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts @@ -0,0 +1,101 @@ +import { defineMethod, type RpcAnyMethod } from '../../core' +import { + TerminalHandle, + TerminalListParams, + TerminalRead, + TerminalRecoverPane, + TerminalRename, + TerminalResolveActive, + TerminalResolvePane +} from './unary-schemas' + +export const TERMINAL_QUERY_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'terminal.list', + params: TerminalListParams, + handler: async (params, { runtime }) => + runtime.listTerminals(params.worktree, params.limit, { + handles: params.handles, + requireFreshPtyLiveness: params.requireFreshPtyLiveness, + includeVisualLayouts: params.includeVisualLayouts + }) + }), + defineMethod({ + name: 'terminal.resolveActive', + params: TerminalResolveActive, + handler: async (params, { runtime }) => ({ + handle: await runtime.resolveActiveTerminal(params.worktree) + }) + }), + defineMethod({ + name: 'terminal.resolvePane', + params: TerminalResolvePane, + handler: async (params, { runtime }) => ({ + terminal: runtime.resolveTerminalPane(params.paneKey, params.worktreeId) + }) + }), + defineMethod({ + name: 'terminal.recoverPane', + params: TerminalRecoverPane, + handler: async (params, { runtime }) => ({ + terminal: await runtime.recoverTerminalPane( + params.paneKey, + params.worktreeId, + params.expectedTerminal + ) + }) + }), + defineMethod({ + name: 'terminal.show', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + terminal: await runtime.showTerminal(params.terminal) + }) + }), + defineMethod({ + name: 'terminal.read', + params: TerminalRead, + handler: async (params, { runtime }) => ({ + terminal: await runtime.readTerminal(params.terminal, { + cursor: params.cursor, + limit: params.limit, + screen: params.screen + }) + }) + }), + defineMethod({ + name: 'terminal.inspectProcess', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + process: await runtime.inspectTerminalProcess(params.terminal) + }) + }), + defineMethod({ + name: 'terminal.isRunningAgent', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + isRunningAgent: await runtime.isTerminalRunningAgent(params.terminal) + }) + }), + defineMethod({ + name: 'terminal.agentStatus', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + agentStatus: await runtime.getTerminalAgentStatus(params.terminal) + }) + }), + defineMethod({ + name: 'terminal.rename', + params: TerminalRename, + handler: async (params, { runtime }) => ({ + rename: await runtime.renameTerminal(params.terminal, params.title || null) + }) + }), + defineMethod({ + name: 'terminal.clearBuffer', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + clear: await runtime.clearTerminalBuffer(params.terminal) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts new file mode 100644 index 00000000000..a4c58fbab68 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -0,0 +1,227 @@ +import { InvalidArgumentError, defineMethod, type RpcAnyMethod } from '../../core' +import { isTerminalQueryReply } from '../../../../../shared/terminal-query-reply' +import { assertTerminalAgentSendable } from '../../terminal-agent-send-guard' +import { TerminalSend } from './unary-schemas' +import { + assertTerminalSendExactPtyBinding, + assertTerminalSendTextWithinLimit, + commitMobileInputFloorClaim, + getTerminalSendGuardRefusedReason, + isTerminalInputLockedForClient, + isTerminalSendGuardNotWritable, + resolveMobileFloorClientId, + type MobileInputFloorClaimHolder +} from './terminal-input-delivery' +import { updateViewportForClient } from './terminal-viewport-update' + +export const TERMINAL_SEND_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'terminal.send', + params: TerminalSend, + handler: async (params, { runtime, clientId, signal }) => { + await assertTerminalSendTextWithinLimit(params.text) + await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text) + const queryReplyClientId = clientId ?? params.client?.id + if ( + params.inputKind === 'query-reply' && + (!params.text || + !isTerminalQueryReply(params.text) || + params.enter === true || + params.interrupt === true || + params.agentPrompt === true || + params.requireAgentStatus !== undefined || + params.client?.type !== 'mobile' || + !queryReplyClientId || + (clientId !== undefined && params.client.id !== clientId)) + ) { + throw new InvalidArgumentError('Invalid terminal query reply') + } + // Why: a stale handle must fail with terminal_handle_stale, not evaluate driver/lock state against the wrong PTY (#7718). + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) + const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null + if ( + params.inputKind === 'query-reply' && + leaf?.ptyId && + !runtime.isMobileTerminalQueryReplyAuthority(leaf.ptyId, queryReplyClientId!) + ) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + if ( + leaf?.ptyId && + params.client?.type === 'desktop' && + params.claimViewport === true && + params.viewport + ) { + const claim = await updateViewportForClient( + runtime, + leaf.ptyId, + `send:${params.client.id}`, + params.client, + params.viewport, + 'desktop', + 'refresh', + true + ) + // Why: a stream-less request can't safely create ownership, so never write at stale geometry. + if (!claim.updated || isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + } + const hasText = typeof params.text === 'string' && params.text.length > 0 + const hasSuffix = params.enter === true || params.interrupt === true + if (params.requireAgentStatus === 'sendable' && hasText && hasSuffix) { + // Why: guarded sends are two-phase; reject combined payload + submit so a guard flip can't cause partial delivery. + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + // Why: recheck permission/no-agent state immediately before accepting the PTY write. + const assertSendPreconditions = + params.requireAgentStatus === 'sendable' + ? async (ptyId?: string): Promise => { + await assertTerminalAgentSendable({ + runtime, + handle: params.terminal, + assertWritable: () => { + assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) + if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + throw new Error('terminal_guard_not_writable') + } + } + }) + } + : undefined + if (params.requireAgentStatus === 'sendable') { + try { + await assertSendPreconditions?.(leaf?.ptyId ?? undefined) + } catch (error) { + if (isTerminalSendGuardNotWritable(error)) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + const refusedReason = getTerminalSendGuardRefusedReason(error) + if (!refusedReason) { + throw error + } + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0, + refusedReason + } + } + } + } + const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) + const mobileFloorClaim: MobileInputFloorClaimHolder = { current: null } + const beforeWrite = assertSendPreconditions + const useSettledAgentPrompt = + params.agentPrompt === true && + hasText && + params.enter === true && + params.interrupt !== true && + params.client?.type === 'desktop' && + (await runtime.isTerminalRunningSettledPromptAgent(params.terminal)) + const reserveWrite = + params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId + ? (ptyId: string): void => { + const claim = runtime.beginMobileInputFloor(ptyId, mobileFloorClientId) + if (!claim) { + throw new Error('mobile_input_floor_unavailable') + } + mobileFloorClaim.current = claim + } + : undefined + let result + try { + result = useSettledAgentPrompt + ? await runtime.sendTerminalAgentPrompt(params.terminal, params.text!, { + beforeWrite, + signal + }) + : await runtime.sendTerminal( + params.terminal, + { + text: params.text, + enter: params.enter === true, + interrupt: params.interrupt === true + }, + { + beforeWrite, + ...(reserveWrite ? { reserveWrite } : {}), + ...(params.inputKind !== 'query-reply' && mobileFloorClientId + ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } + : {}) + } + ) + } catch (error) { + mobileFloorClaim.current?.rollback() + const refusedReason = getTerminalSendGuardRefusedReason(error) + if (refusedReason) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0, + refusedReason + } + } + } + if (isTerminalSendGuardNotWritable(error)) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + throw error + } + if (result.accepted !== true) { + mobileFloorClaim.current?.rollback() + } + if ( + result.accepted === true && + params.enter === true && + params.client?.type === 'mobile' && + params.resolvedLaunchDraft + ) { + runtime.notifyNativeChatLaunchDraftResolved(params.terminal, params.resolvedLaunchDraft) + } + // Why: deliberate mobile input takes the floor (drives `* → mobile{clientId}`); clientless sends fall back to the current mobile driver. + return { send: result } + } + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts new file mode 100644 index 00000000000..33b09a0ead9 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts @@ -0,0 +1,209 @@ +import type { OrcaRuntimeService } from '../../../orca-runtime' +import { + TerminalStreamOpcode, + encodeTerminalStreamJson +} from '../../../../../shared/terminal-stream-protocol' +import { + MOBILE_SNAPSHOT_BYTE_BUDGET, + MOBILE_SUBSCRIBE_SCROLLBACK_ROWS +} from '../../../scrollback-limits' +import { terminalStreamByteLengthExceeds } from '../../terminal-stream-byte-length' +import { + iterateTerminalStreamTextPayloads, + requestedSnapshotScrollbackCandidates +} from './terminal-stream-replay' +import type { SerializedSnapshot, SnapshotFrameOptions } from './terminal-stream-types' + +const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 + +export async function serializeBudgetedRequestedSnapshot( + runtime: OrcaRuntimeService, + ptyId: string, + scrollbackRows: number | undefined +): Promise { + const requestedRows = scrollbackRows ?? 0 + for (const rows of requestedSnapshotScrollbackCandidates(scrollbackRows)) { + const serialized = await runtime.serializeAuthoritativeTerminalBuffer(ptyId, { + scrollbackRows: rows + }) + if (!serialized) { + return null + } + const scrollbackAnsi = + 'scrollbackAnsi' in serialized && typeof serialized.scrollbackAnsi === 'string' + ? serialized.scrollbackAnsi + : '' + const data = scrollbackAnsi + serialized.data + const overByteBudget = terminalStreamByteLengthExceeds(data, REQUESTED_SNAPSHOT_BYTE_BUDGET) + if (!overByteBudget || rows === 0) { + return { + ...serialized, + data, + scrollbackRows: rows, + truncatedByByteBudget: rows < requestedRows || overByteBudget + } + } + } + return null +} + +export function sendSnapshotFrames( + sendFrame: ( + opcode: TerminalStreamOpcode, + payload?: Uint8Array + ) => boolean | void, + options: SnapshotFrameOptions +): { bytes: number; chunks: number; published: boolean } { + if ( + sendFrame( + TerminalStreamOpcode.SnapshotStart, + encodeTerminalStreamJson({ + kind: options.kind, + cols: options.cols, + rows: options.rows, + requestId: options.requestId, + displayMode: options.displayMode, + reason: options.reason, + unavailable: options.unavailable, + seq: options.seq, + cwd: options.cwd, + source: options.source, + oscLinks: options.oscLinks, + pendingEscapeTailAnsi: options.pendingEscapeTailAnsi, + // Why conditional and additive: old clients ignore the unknown field, + // and a new client must read absence as unknown rather than zero, so + // no opcode or capability negotiation is involved (Rule 1 of + // docs/reference/remote-wire-compatibility.md). + // Why `seq` is required: the flags are only proven at this frame's own + // seq, so without a replay boundary the client cannot order them. + ...(typeof options.seq === 'number' && options.kittyKeyboardFlags !== undefined + ? { kittyKeyboardFlags: options.kittyKeyboardFlags } + : {}), + truncated: options.truncated === true, + truncatedByByteBudget: options.truncatedByByteBudget === true + }) + ) === false + ) { + return { bytes: 0, chunks: 0, published: false } + } + let chunks = 0 + let bytes = 0 + for (const chunk of iterateTerminalStreamTextPayloads(options.data)) { + if (sendFrame(TerminalStreamOpcode.SnapshotChunk, chunk) === false) { + return { bytes, chunks, published: false } + } + chunks++ + bytes += chunk.byteLength + } + const published = sendFrame(TerminalStreamOpcode.SnapshotEnd) !== false + return { bytes, chunks, published } +} + +export async function serializeBudgetedMobileSnapshot( + runtime: OrcaRuntimeService, + ptyId: string, + isMobile: boolean +): Promise { + if (!isMobile) { + const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: 0 }) + return serialized + ? { + ...serialized, + data: (serialized.scrollbackAnsi ?? '') + serialized.data, + scrollbackRows: 0, + truncatedByByteBudget: false + } + : null + } + const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] + for (const rows of candidates) { + const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: rows }) + if (!serialized) { + return null + } + const data = (serialized.scrollbackAnsi ?? '') + serialized.data + const overByteBudget = terminalStreamByteLengthExceeds(data, MOBILE_SNAPSHOT_BYTE_BUDGET) + if (!overByteBudget || rows === 0) { + return { + ...serialized, + data, + scrollbackRows: rows, + truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget + } + } + } + return null +} + +export async function serializeStableMobileRendererSnapshot( + runtime: OrcaRuntimeService, + ptyId: string +): Promise { + const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] + let candidateIndex = 0 + for (let attempt = 0; attempt < candidates.length; attempt += 1) { + // Why: advance toward zero scrollback each retry so the final attempt always has a bounded payload. + candidateIndex = Math.max(candidateIndex, attempt) + const rows = candidates[candidateIndex] + const outputSequenceBefore = runtime.getPtyOutputSequence(ptyId) + const serialized = await runtime.serializeRendererTerminalBuffer(ptyId, { + scrollbackRows: rows + }) + const outputSequenceAfter = runtime.getPtyOutputSequence(ptyId) + if (outputSequenceBefore !== outputSequenceAfter) { + continue + } + if (!serialized) { + return null + } + const overByteBudget = terminalStreamByteLengthExceeds( + serialized.data, + MOBILE_SNAPSHOT_BYTE_BUDGET + ) + if (!overByteBudget || rows === 0) { + return { + ...serialized, + scrollbackRows: rows, + truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget + } + } + candidateIndex += 1 + } + return null +} + +// Why: mobile xterm can't rewrap the HARD newlines baked into a restored snapshot, so a real reflow re-serializes and replays the FULL buffer at the new cols. +export async function sendMobileResizeRestream( + runtime: OrcaRuntimeService, + ptyId: string, + sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array) => void, + event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number }, + shouldSend?: () => boolean +): Promise { + // Why: only a true geometry reflow rewraps scrollback; a dimensionless mode-change would re-send the whole buffer for nothing. + if (event.reason !== 'apply-layout' || runtime.isTerminalAlternateScreen(ptyId)) { + return false + } + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true) + if (!serialized) { + return false + } + if (shouldSend && !shouldSend()) { + return true + } + sendSnapshotFrames(sendFrame, { + kind: 'resized', + cols: serialized.cols, + rows: serialized.rows, + displayMode: event.displayMode, + reason: event.reason, + seq: event.seq ?? serialized.seq, + source: serialized.source, + cwd: serialized.cwd, + oscLinks: serialized.oscLinks, + truncated: false, + truncatedByByteBudget: serialized.truncatedByByteBudget, + data: serialized.data + }) + return true +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-stream-replay.ts b/src/main/runtime/rpc/methods/terminal/terminal-stream-replay.ts new file mode 100644 index 00000000000..5ffd1079a3c --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-stream-replay.ts @@ -0,0 +1,203 @@ +import type { TerminalReplyQuerySequence } from '../../../../../shared/terminal-reply-query-scan' +import { + iterateTerminalOutputFrameChunks, + sliceTerminalOutputSourceRanges, + type TerminalOutputFrameChunk, + type TerminalOutputMeta +} from '../../terminal-output-frame-chunks' +import { + measureTerminalStreamByteLength, + terminalStreamByteLength +} from '../../terminal-stream-byte-length' +import { TERMINAL_MULTIPLEX_PENDING_MAX_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' +import type { TerminalMultiplexStream, TerminalOutputChunk } from './terminal-stream-types' + +export function appendPendingMultiplexOutput( + stream: TerminalMultiplexStream, + data: string, + meta?: TerminalOutputMeta +): void { + const remainingBudget = Math.max( + 1, + TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - stream.pendingOutputBytes + ) + const measurement = measureTerminalStreamByteLength(data, { + stopAfterBytes: remainingBudget + }) + stream.pendingOutput.push({ data, bytes: measurement.byteLength, meta }) + stream.pendingOutputBytes += measurement.byteLength + const trimmed = trimPendingOutputToBudget(stream.pendingOutput, stream.pendingOutputBytes) + stream.pendingOutputBytes = trimmed.bytes + stream.pendingOutputOverflowed ||= trimmed.overflowed +} + +export function getOutputAfterSnapshotSeq( + chunk: TerminalOutputChunk, + snapshotSeq: number | undefined +): TerminalOutputChunk | null { + if ( + typeof snapshotSeq !== 'number' || + typeof chunk.meta?.seq !== 'number' || + typeof chunk.meta.rawLength !== 'number' + ) { + return chunk + } + if (chunk.meta.seq <= snapshotSeq) { + return null + } + const chunkStartSeq = chunk.meta.seq - chunk.meta.rawLength + if (chunkStartSeq >= snapshotSeq) { + return chunk + } + if (chunk.meta.transformed) { + return null + } + const offset = snapshotSeq - chunkStartSeq + return { + data: chunk.data.slice(offset), + bytes: chunk.bytes, + meta: { + ...chunk.meta, + rawLength: chunk.meta.rawLength - offset, + sourceRanges: sliceTerminalOutputSourceRanges( + chunk.meta.sourceRanges, + offset, + chunk.data.length + ) + } + } +} + +export function stripSnapshotBoundaryQuerySuffixes( + data: string, + dataStartSeq: number, + snapshotSeq: number, + queries: TerminalReplyQuerySequence[] +): string { + let output = '' + let offset = 0 + for (const query of queries) { + if (query.startSeq >= snapshotSeq || query.endSeq <= snapshotSeq) { + continue + } + const removeStart = Math.max(0, query.startSeq - dataStartSeq) + const removeEnd = Math.min(data.length, query.endSeq - dataStartSeq) + if (removeEnd <= offset || removeStart >= data.length) { + continue + } + output += data.slice(offset, removeStart) + offset = removeEnd + } + return output + data.slice(offset) +} + +export function appendAckPendingOutput( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk +): void { + stream.ackPendingOutput.push(chunk) + stream.ackPendingOutputBytes += chunk.bytes.byteLength + let omittedChunkCount = 0 + while ( + stream.ackPendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && + omittedChunkCount < stream.ackPendingOutput.length + ) { + stream.ackPendingOutputBytes -= stream.ackPendingOutput[omittedChunkCount]!.bytes.byteLength + omittedChunkCount += 1 + } + if (omittedChunkCount > 0) { + stream.ackPendingOutput.splice(0, omittedChunkCount) + stream.ackPendingOutputOverflowed = true + } +} + +export function trimPendingOutputToBudget( + pendingOutput: TerminalOutputChunk[], + pendingOutputBytes: number +): { bytes: number; overflowed: boolean } { + let omittedChunkCount = 0 + while ( + pendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && + omittedChunkCount < pendingOutput.length + ) { + const chunk = pendingOutput[omittedChunkCount] + pendingOutputBytes -= chunk.bytes + omittedChunkCount += 1 + } + if (omittedChunkCount > 0) { + pendingOutput.splice(0, omittedChunkCount) + } + return { bytes: pendingOutputBytes, overflowed: omittedChunkCount > 0 } +} + +export function trimPendingOutputCoveredBySnapshot( + pendingOutput: TerminalOutputChunk[], + snapshotSeq: number | undefined +): { chunks: TerminalOutputChunk[]; bytes: number } { + if (typeof snapshotSeq !== 'number') { + return { + chunks: pendingOutput, + bytes: pendingOutput.reduce((sum, chunk) => sum + chunk.bytes, 0) + } + } + const chunks: TerminalOutputChunk[] = [] + let bytes = 0 + for (const chunk of pendingOutput) { + const chunkSeq = chunk.meta?.seq + const rawLength = chunk.meta?.rawLength ?? chunk.data.length + if (typeof chunkSeq !== 'number' || rawLength !== chunk.data.length) { + chunks.push(chunk) + bytes += chunk.bytes + continue + } + const startSeq = chunkSeq - rawLength + if (snapshotSeq >= chunkSeq) { + continue + } + if (snapshotSeq <= startSeq) { + chunks.push(chunk) + bytes += chunk.bytes + continue + } + const data = chunk.data.slice(snapshotSeq - startSeq) + const slicedBytes = terminalStreamByteLength(data) + chunks.push({ data, bytes: slicedBytes, meta: undefined }) + bytes += slicedBytes + } + return { chunks, bytes } +} + +export function* iterateTerminalStreamTextPayloads( + data: string +): Generator> { + if (!data) { + return + } + for (const chunk of iterateTerminalOutputFrameChunks(data)) { + yield chunk.bytes + } +} + +export function isTerminalReadPayloadIncomplete(read: { + truncated: boolean + limited?: boolean +}): boolean { + // Why: a limited preview is an incomplete payload even when the retained buffer wasn't truncated. + return read.truncated || read.limited === true +} + +export function normalizeMultiplexSnapshotScrollbackRows( + value: number | undefined +): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined + } + return Math.max(0, Math.min(50_000, Math.floor(value))) +} + +export function requestedSnapshotScrollbackCandidates(requestedRows: number | undefined): number[] { + const candidates = [requestedRows ?? 0, 1000, 500, 250, 100, 25, 0] + .filter((rows): rows is number => typeof rows === 'number') + .map((rows) => Math.max(0, Math.min(50_000, Math.floor(rows)))) + return [...new Set(candidates)] +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts new file mode 100644 index 00000000000..484a9f0f933 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts @@ -0,0 +1,100 @@ +import type { TerminalOscLinkRange } from '../../../../../shared/terminal-osc-link-ranges' +import type { TerminalSnapshotUnavailableReason } from '../../../../../shared/terminal-snapshot-unavailability' +import type { TerminalSourceRangeLedger } from '../../terminal-source-range-ledger' +import type { RemoteTerminalSourceRangeReplacementReservation } from '../../../remote-terminal-source-range-consumer' +import type { + TerminalOutputFrameChunk, + TerminalOutputMeta +} from '../../terminal-output-frame-chunks' +import type { TerminalOutputBatcher } from './terminal-output-batcher' + +export type SnapshotFrameOptions = { + kind: 'scrollback' | 'resized' + cols: number + rows: number + data: string + requestId?: number + displayMode?: string + reason?: string + seq?: number + cwd?: string | null + truncated?: boolean + truncatedByByteBudget?: boolean + // Why: distinguishes "I could not answer right now" from a genuinely empty buffer; omitted on success. + unavailable?: TerminalSnapshotUnavailableReason + source?: 'headless' | 'renderer' + oscLinks?: TerminalOscLinkRange[] + pendingEscapeTailAnsi?: string + /** Effective kitty flags proven at this frame's own `seq`. */ + kittyKeyboardFlags?: number +} + +export type SerializedSnapshot = { + data: string + scrollbackAnsi?: string + cols: number + rows: number + seq?: number + cwd?: string | null + source?: 'headless' | 'renderer' + oscLinks?: TerminalOscLinkRange[] + scrollbackRows: number + truncatedByByteBudget: boolean + pendingEscapeTailAnsi?: string + kittyKeyboardFlags?: number +} | null + +export type TerminalViewportClient = { + id: string + type?: 'mobile' | 'desktop' +} + +export type TerminalMultiplexStream = { + streamId: number + terminal: string + ptyId: string + client: TerminalViewportClient | undefined + isMobile: boolean + ackOutput: boolean + ackOutputSourceRanges: boolean + streamGeneration: string + sourceRangeLedger: TerminalSourceRangeLedger | null + sourceRangeConsumerAttached: boolean + sourceRangeReplacement: RemoteTerminalSourceRangeReplacementReservation | null + ackInFlightBytes: number + ackWindowBytes: number + supportsOutputPause: boolean + supportsWriteUnavailable: boolean + outputPaused: boolean + supportsDesktopViewportClaims: boolean + desktopClaimTail: Promise + // Whether THIS stream registered the width driver, so detach won't release a peer stream's floor. + registeredRemoteDesktopDriver: boolean + remoteDesktopSubscriptionKey: string + pendingRemoteDesktopViewport: { cols: number; rows: number } | null + buffering: boolean + ackPendingOutput: TerminalOutputFrameChunk[] + ackPendingOutputBytes: number + ackPendingOutputOverflowed: boolean + ackRecoverySnapshotInFlight: boolean + pendingOutput: TerminalOutputChunk[] + pendingOutputBytes: number + pendingOutputOverflowed: boolean + // Cols the mobile client last rewrapped to; re-stream full scrollback only when width actually changes. + lastResizeCols: number | undefined + resizeGeneration: number + outputBatcher: TerminalOutputBatcher + unsubscribeData: () => void + unsubscribeResize: () => void + unsubscribeFit: () => void + unsubscribeDriver: () => void + unregisterBinaryHandler: () => void + // Why: the runtime drops the exit-waiter only on real PTY exit; abort on detach so a never-exiting agent terminal doesn't leak the waiter. + exitWaiterAbort: AbortController +} + +export type TerminalOutputChunk = { + data: string + bytes: number + meta?: TerminalOutputMeta +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts new file mode 100644 index 00000000000..bd16382d747 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts @@ -0,0 +1,95 @@ +import { defineStreamingMethod, type RpcAnyMethod } from '../../core' +import { TerminalSubscribe } from './stream-schemas' +import { isTerminalReadPayloadIncomplete } from './terminal-stream-replay' +import { runTerminalBinarySubscription } from './terminal-legacy-subscribe-binary' +import { + runTerminalJsonSubscription, + runTerminalLeaseSubscription +} from './terminal-legacy-simple-subscriptions' +import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types' + +export const TERMINAL_SUBSCRIBE_METHODS: RpcAnyMethod[] = [ + // Streams live terminal output over WebSocket; mobile clients pass client+viewport for server-side auto-fit. + defineStreamingMethod({ + name: 'terminal.subscribe', + params: TerminalSubscribe, + handler: async ( + params, + { runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal }, + emit + ) => { + let leaf = runtime.resolveLeafForHandle(params.terminal) + const isMobile = params.client?.type === 'mobile' + const serializerGenerationBeforeAnyMount = isMobile + ? (runtime.getRendererTerminalSerializerGenerationForHandle?.(params.terminal) ?? 0) + : 0 + let rendererMountRequestedBeforePty = false + const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary) + if (signal?.aborted) { + return + } + + if (!leaf?.ptyId && params.client) { + rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal) + try { + const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) + leaf = { ptyId } + } catch { + if (signal?.aborted) { + return + } + } + } + if (!leaf?.ptyId) { + const read = await runtime.readTerminal(params.terminal) + emit({ + type: 'subscribed', + streamId: null, + lines: read.tail, + truncated: isTerminalReadPayloadIncomplete(read) + }) + emit({ type: 'end' }) + return + } + if (isMobile && (!useBinaryStream || !sendBinary)) { + throw new Error('binary_terminal_stream_required') + } + + const ptyId = leaf.ptyId + const clientId = params.client?.id + const missingHeadlessStateBeforeMobileFit = + isMobile && + (rendererMountRequestedBeforePty || runtime.hasHeadlessTerminalState?.(ptyId) === false) + const args: TerminalSubscriptionArgs = { + params, + runtime, + connectionId, + sendBinary, + registerBinaryStreamHandler, + signal, + emit, + ptyId, + clientId, + isMobile, + supportsDesktopViewportClaims: params.capabilities?.desktopViewportClaims === 1, + supportsWriteUnavailable: params.capabilities?.writeUnavailable === 1, + rendererMountRequestedBeforePty, + missingHeadlessStateBeforeMobileFit, + serializerGenerationBeforeMobileFit: missingHeadlessStateBeforeMobileFit + ? rendererMountRequestedBeforePty + ? serializerGenerationBeforeAnyMount + : runtime.getRendererTerminalSerializerGeneration(ptyId) + : 0 + } + if (isMobile && params.capabilities?.mobileInputLeaseOnly === 1 && Boolean(clientId)) { + await runTerminalLeaseSubscription(args) + return + } + if (!useBinaryStream) { + await runTerminalJsonSubscription(args) + return + } + await runTerminalBinarySubscription(args) + } + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscription-stream-id.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscription-stream-id.ts new file mode 100644 index 00000000000..66c50b3f8c3 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscription-stream-id.ts @@ -0,0 +1,5 @@ +let nextTerminalStreamId = 1 + +export function allocateTerminalSubscriptionStreamId(): number { + return nextTerminalStreamId++ +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts new file mode 100644 index 00000000000..71feee4f24d --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts @@ -0,0 +1,120 @@ +import { z } from 'zod' +import { defineMethod, type RpcAnyMethod } from '../../core' +import { TerminalHandle } from './unary-schemas' +import { + TerminalSetAutoRestoreFit, + TerminalSetDisplayMode, + TerminalUnsubscribe, + TerminalUpdateViewport +} from './viewport-schemas' +import { updateViewportForClient } from './terminal-viewport-update' + +export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS: RpcAnyMethod[] = [ + defineMethod({ + name: 'terminal.setDisplayMode', + params: TerminalSetDisplayMode, + handler: async (params, { runtime }) => { + // Why: a stale handle must fail with terminal_handle_stale, not mutate the wrong PTY's display mode/viewport (#7718). + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + // Why: late-bind viewport for desktop-subscribed callers; otherwise an 'auto' toggle skips phone-fit and nothing resizes. + if (params.viewport && params.client?.id) { + runtime.updateMobileSubscriberViewport(leaf.ptyId, params.client.id, params.viewport) + } + if (params.client && params.client.type === 'mobile' && params.mode !== 'desktop') { + runtime.markMobileActor(leaf.ptyId, params.client.id) + } + runtime.setMobileDisplayMode(leaf.ptyId, params.mode) + await runtime.applyMobileDisplayMode(leaf.ptyId) + return { mode: params.mode, seq: runtime.getLayout(leaf.ptyId)?.seq } + } + }), + defineMethod({ + name: 'terminal.restoreFit', + params: TerminalHandle, + handler: async (params, { runtime }) => { + // Why: a stale handle must fail with terminal_handle_stale, not reclaim the wrong PTY to desktop dims (#7718). + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + return { restored: await runtime.reclaimTerminalForDesktop(leaf.ptyId) } + } + }), + defineMethod({ + name: 'terminal.getDisplayMode', + params: TerminalHandle, + handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + const mode = leaf?.ptyId ? runtime.getMobileDisplayMode(leaf.ptyId) : 'auto' + const isPhoneFitted = leaf?.ptyId ? runtime.isMobileSubscriberActive(leaf.ptyId) : false + return { mode, isPhoneFitted } + } + }), + defineMethod({ + name: 'terminal.updateViewport', + params: TerminalUpdateViewport, + handler: async (params, { runtime }) => { + // Why: a stale handle must fail with terminal_handle_stale, not write viewport state to the wrong PTY (#7718). + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + const viewportUpdate = await updateViewportForClient( + runtime, + leaf.ptyId, + `viewport:${params.client.id}`, + params.client, + params.viewport, + 'mobile', + // Why: one-shot RPC with no disconnect hook — refresh the existing stream-owned floor, never create a leak-prone one. + 'refresh', + params.claim === true + ) + return { ...viewportUpdate, seq: runtime.getLayout(leaf.ptyId)?.seq } + } + }) +] + +export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS: RpcAnyMethod[] = [ + defineMethod({ + name: 'terminal.unsubscribe', + params: TerminalUnsubscribe, + handler: async (params, { runtime, connectionId }) => { + // Why: only the connection that owns the subscription may retire it — a stale + // unsubscribe from the pre-reconnect socket names an id the replacement now owns. + let unsubscribed = runtime.cleanupSubscriptionIfOwnedByConnection( + params.subscriptionId, + connectionId + ) + // Why: older builds send a bare-handle subscriptionId, so also try the reconstructed `${terminal}:${clientId}` composite key. + // Why AND over the calls that ran: a clientless stream registers under the bare id + // and a client-scoped one under the composite, so either call can be the real + // teardown. Reporting false needs one genuine refusal, not merely a missing id. + if (params.client && !params.subscriptionId.includes(':')) { + unsubscribed = + runtime.cleanupSubscriptionIfOwnedByConnection( + `${params.subscriptionId}:${params.client.id}`, + connectionId + ) && unsubscribed + } + return { unsubscribed } + } + }), + defineMethod({ + name: 'terminal.getAutoRestoreFit', + params: z.object({}), + handler: async (_params, { runtime }) => ({ + ms: runtime.getMobileAutoRestoreFitMs() + }) + }), + defineMethod({ + name: 'terminal.setAutoRestoreFit', + params: TerminalSetAutoRestoreFit, + handler: async (params, { runtime }) => ({ + ms: runtime.setMobileAutoRestoreFitMs(params.ms) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewport-update.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewport-update.ts new file mode 100644 index 00000000000..cfcaa3a0896 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewport-update.ts @@ -0,0 +1,38 @@ +import type { OrcaRuntimeService } from '../../../orca-runtime' +import type { TerminalViewportClient } from './terminal-stream-types' + +export async function updateViewportForClient( + runtime: OrcaRuntimeService, + ptyId: string, + subscriptionKey: string, + client: TerminalViewportClient, + viewport: { cols: number; rows: number }, + defaultType: 'mobile' | 'desktop', + // Why: the one-shot RPC has no disconnect hook, so 'refresh' only updates a stream-owned floor; stream paths that own cleanup 'register'. + registration: 'register' | 'refresh' = 'register', + claim = false +): Promise<{ updated: boolean; applied: boolean }> { + const type = client.type ?? defaultType + if (type === 'mobile') { + return runtime.updateMobileViewport(ptyId, client.id, viewport) + } + // Why: stream attachment observes geometry without taking control; a later claim frame makes it authoritative. + const updated = + registration === 'refresh' + ? await runtime.refreshRemoteDesktopViewer( + ptyId, + client.id, + viewport.cols, + viewport.rows, + claim + ) + : await runtime.updateRemoteDesktopViewer( + ptyId, + subscriptionKey, + client.id, + viewport.cols, + viewport.rows, + claim + ) + return { updated, applied: updated } +} diff --git a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts new file mode 100644 index 00000000000..f60b418ca05 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts @@ -0,0 +1,203 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from '../../schemas' +import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../../shared/feature-education-telemetry' +import { isTuiAgent } from '../../../../../shared/tui-agent-config' + +export const TerminalHandle = z.object({ + terminal: requiredString('Missing terminal handle') +}) + +export const TerminalFocus = TerminalHandle.extend({ + navigation: z.enum(['caller', 'host']).optional() +}) + +export const TerminalListParams = z.object({ + worktree: OptionalString, + limit: OptionalFiniteNumber, + handles: z + .array(requiredString('Missing terminal handle').pipe(z.string().max(256))) + .max(64) + .optional(), + requireFreshPtyLiveness: z.boolean().optional(), + // Why: layouts are ~31% of a large listing and only the human CLI formatter + // reads them. Absent means "include" so pre-flag clients keep rendering them. + includeVisualLayouts: z.boolean().optional() +}) + +export const TerminalResolveActive = z.object({ + worktree: OptionalString +}) + +export const TerminalResolvePane = z.object({ + paneKey: requiredString('Missing pane key'), + worktreeId: OptionalString +}) + +export const TerminalRecoverPane = z.object({ + paneKey: requiredString('Missing pane key'), + worktreeId: requiredString('Missing worktree ID'), + expectedTerminal: requiredString('Missing expected terminal handle').optional() +}) + +export const TerminalRead = TerminalHandle.extend({ + cursor: z + .unknown() + .transform((value) => { + if (value === undefined) { + return undefined + } + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + return Number.NaN + } + return value + }) + .pipe( + z + .number() + .optional() + .refine((v) => v === undefined || Number.isFinite(v), { + message: 'Cursor must be a non-negative integer' + }) + ) + .optional(), + limit: OptionalFiniteNumber, + // Why: optional so an older host that does not understand it simply drops the key and answers + // with its usual stream read; the response's `source` is what tells the caller which it got. + screen: z.literal(true).optional() +}).refine((params) => !(params.screen === true && params.cursor !== undefined), { + // Why: a cursor pages through accumulated output; a screen is the current frame with nothing + // behind it. Honoring both would answer with rendered lines carrying the stream's pagination + // metadata — two frames of reference in one payload, which is the confusion `source` exists to + // remove. The CLI already refuses the pair, but the RPC is reachable without it. + message: 'Cursor cannot be combined with a screen read' +}) + +// Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct. +export const TerminalRename = TerminalHandle.extend({ + title: z.custom((value) => value === null || typeof value === 'string', { + message: 'Missing --title (pass empty string or null to reset)' + }) +}) + +export const TerminalSend = TerminalHandle.extend({ + text: OptionalString, + enter: z.unknown().optional(), + interrupt: z.unknown().optional(), + // Why: older hosts strip this optional intent and retain their direct-send behavior. + agentPrompt: z.literal(true).optional(), + resolvedLaunchDraft: z + .object({ + text: z.string(), + createdAt: z.number().finite() + }) + .optional(), + requireAgentStatus: z.enum(['sendable']).optional(), + // Why: terminal-generated replies are valid input but must not transfer the shared terminal floor. + inputKind: z.enum(['query-reply']).optional(), + // Why: identifies the caller for the driver state machine; when absent (older clients) the server falls back to the most recent mobile actor (docs/mobile-presence-lock.md). + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop').optional() + }) + .optional(), + viewport: z + .object({ + cols: z.number().int().min(1).max(1000), + rows: z.number().int().min(1).max(500) + }) + .optional(), + claimViewport: z.literal(true).optional() +}) + +export const TerminalViewport = z.object({ + cols: z.number().int().min(1).max(1000), + rows: z.number().int().min(1).max(500) +}) + +export const TerminalWait = TerminalHandle.extend({ + for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', { + message: 'Invalid --for value. Supported: exit, tui-idle' + }), + timeoutMs: OptionalFiniteNumber +}) + +export const TerminalCreateParams = z.object({ + worktree: OptionalString, + clientMutationId: z.string().min(1).max(128).optional(), + reconcileExisting: z.boolean().optional(), + command: OptionalString, + startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), + env: z.record(z.string(), z.string()).optional(), + envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), + launchConfig: z + .object({ + agentCommand: z.string().optional(), + agentArgs: z.string(), + agentEnv: z.record(z.string(), z.string()), + ompResumeFilePath: z + .string() + .min(1) + .max(32 * 1024) + .optional() + }) + .optional(), + resumeProviderSession: z + .object({ + key: z.enum(['session_id', 'conversation_id']), + id: z.string().min(1).max(512), + transcriptPath: z.string().min(1).max(32_768).optional() + }) + .optional(), + launchToken: OptionalString, + launchAgent: z.string().refine(isTuiAgent).optional(), + terminalColorQueryReplies: z + .object({ + foreground: z.string().max(128).optional(), + background: z.string().max(128).optional() + }) + .optional(), + title: OptionalString, + focus: z.unknown().optional(), + rendererBacked: z.unknown().optional(), + activate: z.unknown().optional(), + presentation: z.enum(['background', 'focused']).optional(), + tabId: OptionalString, + leafId: OptionalString +}) + +export const TerminalSplit = TerminalHandle.extend({ + direction: z + .unknown() + .transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined)) + .pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()])) + .optional(), + command: OptionalString, + env: z.record(z.string(), z.string()).optional(), + telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional() +}) + +export const TerminalStop = z.object({ + worktree: requiredString('Missing worktree selector') +}) + +export const TerminalSleep = TerminalStop + +export const TerminalStopExact = TerminalStop.extend({ + expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1), + keepHistory: z.boolean().optional(), + targetOnly: z.boolean().optional() +}) + +export const AgentTeamsTmuxCompat = z.object({ + teamId: requiredString('Missing agent team ID'), + token: requiredString('Missing agent team token'), + envPane: requiredString('Missing tmux pane identity'), + cwd: OptionalString, + argv: z.array(z.string()) +}) + +export const AgentTeamsPrepareLaunch = z.object({ + paneKey: requiredString('Missing pane key'), + env: z.record(z.string(), z.string()).optional() +}) diff --git a/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts b/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts new file mode 100644 index 00000000000..d9b76d66ea1 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts @@ -0,0 +1,51 @@ +import { z } from 'zod' +import { requiredString } from '../../schemas' + +const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) + +export const TerminalSetDisplayMode = TerminalHandle.extend({ + // Why: 'auto' = mobile drives dims while subscribed (desktop restores on last-leave); 'desktop' = no resize, mobile scales to fit. + mode: z.enum(['auto', 'desktop']), + // Why: identifies the caller for the driver state machine; optional for older mobile clients. + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop').optional() + }) + .optional(), + // Why: carries the measured viewport so an 'auto' toggle on a viewport-less record can phone-fit instead of no-op'ing. + viewport: z + .object({ + cols: z.number().int().positive(), + rows: z.number().int().positive() + }) + .optional() +}) + +export const TerminalUnsubscribe = z.object({ + subscriptionId: requiredString('Missing subscription ID'), + // Why: lets the server rebuild the composite `${terminal}:${clientId}` cleanup key when older clients pass a bare subscriptionId (docs/mobile-presence-lock.md). + client: z + .object({ + id: requiredString('Missing client ID') + }) + .optional() +}) + +// Why: in-place update avoids an unsubscribe→resubscribe that flashed the lock banner and stranded the PTY at phone dims (docs/mobile-presence-lock.md). +export const TerminalUpdateViewport = TerminalHandle.extend({ + client: z.object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('mobile').optional() + }), + viewport: z.object({ + cols: z.number().int().min(20).max(240), + rows: z.number().int().min(8).max(120) + }), + claim: z.boolean().optional() +}) + +// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md); `null` = Indefinite, finite ms clamped to [5_000, 60min] server-side. +export const TerminalSetAutoRestoreFit = z.object({ + ms: z.number().nullable() +}) From 2960fe91937d276424b68746eb9545abb20e4990 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:55:57 -0700 Subject: [PATCH 28/65] Split relay entrypoints into focused modules (#16151) --- config/max-lines-baseline.txt | 2 - .../external-automation-command-executor.ts | 148 ++ ...ternal-automation-provider-catalog.test.ts | 87 + .../external-automation-provider-catalog.ts | 93 ++ src/relay/external-automation-provider.ts | 13 + .../external-automation-storage-paths.ts | 9 + ...ernal-automations-handler-log-path.test.ts | 12 +- .../external-automations-handler.test.ts | 301 ++-- src/relay/external-automations-handler.ts | 982 +----------- src/relay/hermes-output-run-files.ts | 192 +++ src/relay/hermes-run-correlation.test.ts | 59 + src/relay/hermes-run-correlation.ts | 197 +++ src/relay/hermes-run-history.test.ts | 138 ++ src/relay/hermes-run-history.ts | 169 ++ src/relay/hermes-session-run-database.ts | 205 +++ src/relay/relay-agent-hook-runtime.ts | 160 ++ src/relay/relay-connect-channel.ts | 104 ++ src/relay/relay-daemon.ts | 154 ++ src/relay/relay-grace-lifecycle.ts | 160 ++ src/relay/relay-launch-options.test.ts | 82 + src/relay/relay-launch-options.ts | 102 ++ src/relay/relay-orca-cli-channel.ts | 241 +++ src/relay/relay-primary-channel.ts | 126 ++ src/relay/relay-reconnect-listener.ts | 131 ++ src/relay/relay-runtime-services.ts | 143 ++ src/relay/relay-socket-ownership.ts | 219 +++ src/relay/relay.ts | 1400 +---------------- 27 files changed, 3101 insertions(+), 2528 deletions(-) create mode 100644 src/relay/external-automation-command-executor.ts create mode 100644 src/relay/external-automation-provider-catalog.test.ts create mode 100644 src/relay/external-automation-provider-catalog.ts create mode 100644 src/relay/external-automation-provider.ts create mode 100644 src/relay/external-automation-storage-paths.ts create mode 100644 src/relay/hermes-output-run-files.ts create mode 100644 src/relay/hermes-run-correlation.test.ts create mode 100644 src/relay/hermes-run-correlation.ts create mode 100644 src/relay/hermes-run-history.test.ts create mode 100644 src/relay/hermes-run-history.ts create mode 100644 src/relay/hermes-session-run-database.ts create mode 100644 src/relay/relay-agent-hook-runtime.ts create mode 100644 src/relay/relay-connect-channel.ts create mode 100644 src/relay/relay-daemon.ts create mode 100644 src/relay/relay-grace-lifecycle.ts create mode 100644 src/relay/relay-launch-options.test.ts create mode 100644 src/relay/relay-launch-options.ts create mode 100644 src/relay/relay-orca-cli-channel.ts create mode 100644 src/relay/relay-primary-channel.ts create mode 100644 src/relay/relay-reconnect-listener.ts create mode 100644 src/relay/relay-runtime-services.ts create mode 100644 src/relay/relay-socket-ownership.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 88b327ecbc8..fb97a469747 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -75,10 +75,8 @@ inline src/main/window/createMainWindow.ts inline src/main/workspace-space-analysis.ts inline src/preload/index.ts inline src/relay/dispatcher.ts -inline src/relay/external-automations-handler.ts inline src/relay/git-handler.ts inline src/relay/pty-handler.ts -inline src/relay/relay.ts inline src/relay/workspace-space-scan.ts inline src/renderer/src/components/GitLabItemDialog.tsx inline src/renderer/src/components/JiraIssueWorkspace.tsx diff --git a/src/relay/external-automation-command-executor.ts b/src/relay/external-automation-command-executor.ts new file mode 100644 index 00000000000..83f899241e9 --- /dev/null +++ b/src/relay/external-automation-command-executor.ts @@ -0,0 +1,148 @@ +import { + EXTERNAL_AUTOMATION_JOB_ID_PATTERN, + externalAutomationProvider, + isExternalAutomationAction, + type ExternalAutomationAction +} from './external-automation-provider' + +export type ExternalAutomationCommandRunner = ( + command: string, + args: string[], + options: { encoding: 'utf-8'; timeout: number } +) => Promise + +type HermesJobMutation = { + name: string + prompt: string + schedule: string + workdir: string +} + +function hermesCommand(action: ExternalAutomationAction): string { + switch (action) { + case 'pause': + return 'pause' + case 'resume': + return 'resume' + case 'run': + return 'run' + case 'delete': + return 'remove' + } +} + +function openClawCommand(action: ExternalAutomationAction): string { + switch (action) { + case 'pause': + return 'disable' + case 'resume': + return 'enable' + case 'run': + return 'run' + case 'delete': + return 'rm' + } +} + +function normalizeHermesJobMutation(params: Record): HermesJobMutation { + const provider = externalAutomationProvider(params.provider) + if (provider !== 'hermes') { + throw new Error('Only Hermes cron creation and editing are supported.') + } + const name = typeof params.name === 'string' ? params.name.trim() : '' + const prompt = typeof params.prompt === 'string' ? params.prompt.trim() : '' + const schedule = typeof params.schedule === 'string' ? params.schedule.trim() : '' + const workdir = typeof params.workdir === 'string' ? params.workdir.trim() : '' + if (!prompt) { + throw new Error('Hermes cron requires a prompt.') + } + if (!schedule) { + throw new Error('Hermes cron requires a schedule.') + } + return { + name: name || prompt.slice(0, 50).trim(), + prompt, + schedule, + workdir + } +} + +export class ExternalAutomationCommandExecutor { + constructor( + private readonly runCommand: ExternalAutomationCommandRunner, + private readonly clearHermesRunCount: (jobId?: string) => void + ) {} + + async createJob(params: Record = {}): Promise<{ ok: true }> { + const input = normalizeHermesJobMutation(params) + const args = [ + 'cron', + 'create', + input.schedule, + input.prompt, + '--name', + input.name, + '--deliver', + 'local' + ] + if (input.workdir) { + args.push('--workdir', input.workdir) + } + await this.runHermesCronCommand(args) + this.clearHermesRunCount() + return { ok: true } + } + + async updateJob(params: Record = {}): Promise<{ ok: true }> { + const input = normalizeHermesJobMutation(params) + const jobId = params.jobId + if (typeof jobId !== 'string' || !EXTERNAL_AUTOMATION_JOB_ID_PATTERN.test(jobId)) { + throw new Error('Invalid external automation job ID.') + } + const args = [ + 'cron', + 'edit', + jobId, + '--schedule', + input.schedule, + '--prompt', + input.prompt, + '--name', + input.name + ] + if (input.workdir) { + args.push('--workdir', input.workdir) + } + await this.runHermesCronCommand(args) + this.clearHermesRunCount(jobId) + return { ok: true } + } + + async runAction(params: Record = {}): Promise<{ ok: true }> { + const provider = externalAutomationProvider(params.provider) + const action = params.action + const jobId = params.jobId + if (!isExternalAutomationAction(action)) { + throw new Error('Unsupported external automation action.') + } + if (typeof jobId !== 'string' || !EXTERNAL_AUTOMATION_JOB_ID_PATTERN.test(jobId)) { + throw new Error('Invalid external automation job ID.') + } + const command = provider === 'hermes' ? hermesCommand(action) : openClawCommand(action) + await this.runCommand(provider, ['cron', command, jobId], { + encoding: 'utf-8', + timeout: 30_000 + }) + if (provider === 'hermes') { + this.clearHermesRunCount(jobId) + } + return { ok: true } + } + + private async runHermesCronCommand(args: string[]): Promise { + await this.runCommand('hermes', args, { + encoding: 'utf-8', + timeout: 30_000 + }) + } +} diff --git a/src/relay/external-automation-provider-catalog.test.ts b/src/relay/external-automation-provider-catalog.test.ts new file mode 100644 index 00000000000..d2a600b073e --- /dev/null +++ b/src/relay/external-automation-provider-catalog.test.ts @@ -0,0 +1,87 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('ExternalAutomationProviderCatalog', () => { + let hermesHome: string + let previousHermesHome: string | undefined + + beforeEach(async () => { + previousHermesHome = process.env.HERMES_HOME + hermesHome = await mkdtemp(join(tmpdir(), 'relay-hermes-catalog-')) + process.env.HERMES_HOME = hermesHome + await mkdir(join(hermesHome, 'cron'), { recursive: true }) + vi.resetModules() + }) + + afterEach(async () => { + if (previousHermesHome === undefined) { + delete process.env.HERMES_HOME + } else { + process.env.HERMES_HOME = previousHermesHome + } + await rm(hermesHome, { recursive: true, force: true }) + vi.resetModules() + }) + + it('returns readable jobs when the remote CLI is unavailable', async () => { + await writeFile( + join(hermesHome, 'cron', 'jobs.json'), + JSON.stringify({ jobs: [{ id: 'job-1', name: 'Monitor' }] }), + 'utf-8' + ) + const { ExternalAutomationProviderCatalog } = + await import('./external-automation-provider-catalog') + const catalog = new ExternalAutomationProviderCatalog( + vi.fn().mockRejectedValue(new Error('not found')), + vi.fn().mockResolvedValue({ total: 3, runs: [] }) + ) + + await expect(catalog.listJobs({ provider: 'hermes' })).resolves.toEqual({ + jobs: [{ id: 'job-1', name: 'Monitor', run_count: 3, runs: [] }], + hermesAvailable: false, + openclawAvailable: false, + error: null + }) + }) + + it('projects jobs-file parse failures without hiding command availability', async () => { + await writeFile(join(hermesHome, 'cron', 'jobs.json'), '{not-json', 'utf-8') + const { ExternalAutomationProviderCatalog } = + await import('./external-automation-provider-catalog') + const catalog = new ExternalAutomationProviderCatalog( + vi.fn().mockResolvedValue(undefined), + vi.fn() + ) + + const result = await catalog.listJobs({ provider: 'hermes' }) + + expect(result.jobs).toEqual([]) + expect(result.hermesAvailable).toBe(true) + expect(result.openclawAvailable).toBe(false) + expect(result.error).toMatch(/^SyntaxError:/) + }) + + it('preserves all-or-nothing Hermes count enrichment failures', async () => { + await writeFile( + join(hermesHome, 'cron', 'jobs.json'), + JSON.stringify([{ id: 'job-1' }, { id: '--invalid' }]), + 'utf-8' + ) + const { ExternalAutomationProviderCatalog } = + await import('./external-automation-provider-catalog') + const catalog = new ExternalAutomationProviderCatalog( + vi.fn().mockResolvedValue(undefined), + vi + .fn() + .mockResolvedValueOnce({ total: 1, runs: [] }) + .mockRejectedValueOnce(new Error('Invalid external automation job ID.')) + ) + + const result = await catalog.listJobs({ provider: 'hermes' }) + + expect(result.jobs).toEqual([]) + expect(result.error).toBe('Error: Invalid external automation job ID.') + }) +}) diff --git a/src/relay/external-automation-provider-catalog.ts b/src/relay/external-automation-provider-catalog.ts new file mode 100644 index 00000000000..06408160e98 --- /dev/null +++ b/src/relay/external-automation-provider-catalog.ts @@ -0,0 +1,93 @@ +import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { HERMES_JOBS_FILE, OPENCLAW_JOBS_FILE } from './external-automation-storage-paths' +import { + externalAutomationProvider, + type ExternalAutomationProvider +} from './external-automation-provider' +import type { ExternalAutomationCommandRunner } from './external-automation-command-executor' + +type HermesRunLister = (params: Record) => Promise<{ + total: number + runs: unknown[] +}> + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export class ExternalAutomationProviderCatalog { + constructor( + private readonly runCommand: ExternalAutomationCommandRunner, + private readonly listRuns: HermesRunLister + ) {} + + async listJobs(params?: Record): Promise<{ + jobs: unknown[] + hermesAvailable: boolean + openclawAvailable: boolean + error: string | null + }> { + const provider = externalAutomationProvider(params?.provider) + const [commandAvailable, jobsResult] = await Promise.allSettled([ + this.isCommandAvailable(provider), + this.readJobs(provider) + ]) + const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : [] + const available = commandAvailable.status === 'fulfilled' && commandAvailable.value + return { + jobs, + hermesAvailable: provider === 'hermes' && available, + openclawAvailable: provider === 'openclaw' && available, + error: jobsResult.status === 'rejected' ? String(jobsResult.reason) : null + } + } + + private async isCommandAvailable(command: string): Promise { + const finder = process.platform === 'win32' ? 'where' : 'which' + try { + await this.runCommand(finder, [command], { + encoding: 'utf-8', + timeout: 5000 + }) + return true + } catch { + return false + } + } + + private async readJobs(provider: ExternalAutomationProvider): Promise { + const jobsFile = provider === 'hermes' ? HERMES_JOBS_FILE : OPENCLAW_JOBS_FILE + if (!existsSync(jobsFile)) { + return [] + } + const content = await readFile(jobsFile, 'utf-8') + const parsed = JSON.parse(content) as unknown + const jobs = Array.isArray(parsed) + ? parsed + : isRecord(parsed) && Array.isArray(parsed.jobs) + ? parsed.jobs + : [] + if (provider !== 'hermes') { + return jobs + } + return Promise.all( + jobs.map(async (job) => { + if (!isRecord(job) || typeof job.id !== 'string') { + return job + } + const runsPage = await this.listRuns({ + provider: 'hermes', + jobId: job.id, + page: 1, + pageSize: 0 + }) + return { + ...job, + run_count: runsPage.total, + runs: runsPage.runs + } + }) + ) + } +} diff --git a/src/relay/external-automation-provider.ts b/src/relay/external-automation-provider.ts new file mode 100644 index 00000000000..08028ab5439 --- /dev/null +++ b/src/relay/external-automation-provider.ts @@ -0,0 +1,13 @@ +export type ExternalAutomationProvider = 'hermes' | 'openclaw' + +export type ExternalAutomationAction = 'pause' | 'resume' | 'run' | 'delete' + +export const EXTERNAL_AUTOMATION_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ + +export function externalAutomationProvider(value: unknown): ExternalAutomationProvider { + return value === 'openclaw' ? 'openclaw' : 'hermes' +} + +export function isExternalAutomationAction(value: unknown): value is ExternalAutomationAction { + return value === 'pause' || value === 'resume' || value === 'run' || value === 'delete' +} diff --git a/src/relay/external-automation-storage-paths.ts b/src/relay/external-automation-storage-paths.ts new file mode 100644 index 00000000000..5079c8bf960 --- /dev/null +++ b/src/relay/external-automation-storage-paths.ts @@ -0,0 +1,9 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' + +export const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes') +export const HERMES_CRON_DIR = join(HERMES_HOME, 'cron') +export const HERMES_JOBS_FILE = join(HERMES_CRON_DIR, 'jobs.json') +export const HERMES_OUTPUT_DIR = join(HERMES_CRON_DIR, 'output') +export const HERMES_STATE_DB = join(HERMES_HOME, 'state.db') +export const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json') diff --git a/src/relay/external-automations-handler-log-path.test.ts b/src/relay/external-automations-handler-log-path.test.ts index fb48f21397c..ca865c86540 100644 --- a/src/relay/external-automations-handler-log-path.test.ts +++ b/src/relay/external-automations-handler-log-path.test.ts @@ -2,11 +2,6 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { RelayDispatcher } from './dispatcher' - -type ReferencedLogReader = { - readReferencedLogFile(content: string): Promise<{ content: string } | null> -} describe('ExternalAutomationsHandler referenced log paths', () => { let hermesHome: string @@ -34,12 +29,9 @@ describe('ExternalAutomationsHandler referenced log paths', () => { await mkdir(dirname(logPath), { recursive: true }) await writeFile(logPath, 'remote dot-dot-prefixed log line\n', 'utf-8') - const { ExternalAutomationsHandler } = await import('./external-automations-handler') - const handler = new ExternalAutomationsHandler({ - onRequest: () => {} - } as unknown as RelayDispatcher) as unknown as ReferencedLogReader + const { readHermesReferencedLogFile } = await import('./hermes-output-run-files') - const result = await handler.readReferencedLogFile(`Latest log path: ${logPath} + const result = await readHermesReferencedLogFile(`Latest log path: ${logPath} Run summary: monitor automation completed successfully.`) expect(result?.content).toBe('remote dot-dot-prefixed log line\n') diff --git a/src/relay/external-automations-handler.test.ts b/src/relay/external-automations-handler.test.ts index d2a19d79c2d..66209b3e121 100644 --- a/src/relay/external-automations-handler.test.ts +++ b/src/relay/external-automations-handler.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ExternalAutomationCommandExecutor } from './external-automation-command-executor' import { ExternalAutomationsHandler } from './external-automations-handler' import type { RelayDispatcher } from './dispatcher' @@ -16,18 +17,15 @@ vi.mock('child_process', () => ({ execFile: execFileMock })) type CapturedHandler = (params?: Record) => Promise -function createHandlerHarness(): { - handler: ExternalAutomationsHandler - requestHandlers: Map -} { +function createHandlerHarness(): Map { const requestHandlers = new Map() const dispatcher = { onRequest(method: string, handler: CapturedHandler): void { requestHandlers.set(method, handler) } } - const handler = new ExternalAutomationsHandler(dispatcher as unknown as RelayDispatcher) - return { handler, requestHandlers } + new ExternalAutomationsHandler(dispatcher as unknown as RelayDispatcher) + return requestHandlers } beforeEach(() => { @@ -35,8 +33,18 @@ beforeEach(() => { }) describe('ExternalAutomationsHandler', () => { - it('runs external lifecycle actions without shell wrapping', async () => { - const { requestHandlers } = createHandlerHarness() + it('preserves the five external automation routes', () => { + expect([...createHandlerHarness().keys()]).toEqual([ + 'externalAutomations.list', + 'externalAutomations.runs', + 'externalAutomations.create', + 'externalAutomations.update', + 'externalAutomations.act' + ]) + }) + + it('runs lifecycle actions without shell wrapping', async () => { + const requestHandlers = createHandlerHarness() await requestHandlers.get('externalAutomations.act')?.({ provider: 'hermes', @@ -52,189 +60,128 @@ describe('ExternalAutomationsHandler', () => { ) }) - it('paginates remote Hermes run history after ref lookup', async () => { - const { handler, requestHandlers } = createHandlerHarness() - const handlerInternals = handler as unknown as { - readHermesRunRefs: (jobId: string) => Promise<{ id: string; run_at: string }[]> - hydrateHermesRunRef: ( - jobId: string, - ref: { id: string; run_at: string } - ) => Promise<{ id: string; run_at: string }> - } - handlerInternals.readHermesRunRefs = vi.fn().mockResolvedValue([ - { - id: 'cron_job-1_20260516_090000', - run_at: '2026-05-16T09:00:00' - }, - { - id: 'job-1:2026-05-15_09-00-00.md', - run_at: '2026-05-15T09:00:00' - }, - { - id: 'job-1:2026-05-14_09-00-00.md', - run_at: '2026-05-14T09:00:00' - } - ]) - handlerInternals.hydrateHermesRunRef = vi.fn(async (_jobId, ref) => ref) - - const result = (await requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-1', - page: 1, - pageSize: 2 - })) as { total: number; runs: { id: string }[] } - - expect(result.total).toBe(3) - expect(result.runs.map((run) => run.id)).toEqual([ - 'cron_job-1_20260516_090000', - 'job-1:2026-05-15_09-00-00.md' - ]) - }) - - it('uses a count-only path for remote Hermes manager listing run counts', async () => { - const { handler, requestHandlers } = createHandlerHarness() - const handlerInternals = handler as unknown as { - readHermesRunCount: (jobId: string) => Promise - } - handlerInternals.readHermesRunCount = vi.fn().mockResolvedValue(42) - - const result = (await requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-1', - page: 1, - pageSize: 0 - })) as { total: number; runs: unknown[] } - - expect(result).toEqual({ total: 42, runs: [] }) - }) - - it('deduplicates concurrent remote Hermes count reads', async () => { - const { handler, requestHandlers } = createHandlerHarness() - let resolveRefs: (refs: { id: string; run_at: string }[]) => void = () => {} - const readHermesRunRefs = vi.fn( - () => - new Promise<{ id: string; run_at: string }[]>((resolve) => { - resolveRefs = resolve - }) - ) - const handlerInternals = handler as unknown as { - readHermesRunRefs: typeof readHermesRunRefs - } - handlerInternals.readHermesRunRefs = readHermesRunRefs - - const first = requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-1', - page: 1, - pageSize: 0 + it('propagates command failures through the registered route', async () => { + const commandError = new Error('remote Hermes failed') + execFileMock.mockImplementationOnce((...args: unknown[]) => { + const callback = args.at(-1) as (error: Error) => void + callback(commandError) }) - const second = requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-1', - page: 1, - pageSize: 0 - }) - - expect(readHermesRunRefs).toHaveBeenCalledTimes(1) - resolveRefs([ - { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }, - { id: 'job-1:2026-05-16_09-00-00.md', run_at: '2026-05-16T09:00:00' } - ]) - - await expect(Promise.all([first, second])).resolves.toEqual([ - { total: 2, runs: [] }, - { total: 2, runs: [] } - ]) - }) - - it('clears the remote Hermes count cache after lifecycle actions', async () => { - const { handler, requestHandlers } = createHandlerHarness() - const readHermesRunRefs = vi - .fn() - .mockResolvedValueOnce([ - { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' } - ]) - .mockResolvedValueOnce([ - { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }, - { id: 'job-1:2026-05-16_09-00-00.md', run_at: '2026-05-16T09:00:00' } - ]) - const handlerInternals = handler as unknown as { - readHermesRunRefs: typeof readHermesRunRefs - } - handlerInternals.readHermesRunRefs = readHermesRunRefs + const requestHandlers = createHandlerHarness() await expect( - requestHandlers.get('externalAutomations.runs')?.({ + requestHandlers.get('externalAutomations.act')?.({ provider: 'hermes', - jobId: 'job-1', - page: 1, - pageSize: 0 + action: 'run', + jobId: 'job-1' }) - ).resolves.toEqual({ total: 1, runs: [] }) + ).rejects.toBe(commandError) + }) +}) + +describe('ExternalAutomationCommandExecutor', () => { + it.each([ + ['hermes', 'pause', 'pause'], + ['hermes', 'resume', 'resume'], + ['hermes', 'run', 'run'], + ['hermes', 'delete', 'remove'], + ['openclaw', 'pause', 'disable'], + ['openclaw', 'resume', 'enable'], + ['openclaw', 'run', 'run'], + ['openclaw', 'delete', 'rm'] + ])('maps %s %s to its provider command', async (provider, action, command) => { + const runCommand = vi.fn().mockResolvedValue(undefined) + const executor = new ExternalAutomationCommandExecutor(runCommand, vi.fn()) + + await executor.runAction({ provider, action, jobId: 'job-1' }) + + expect(runCommand).toHaveBeenCalledWith(provider, ['cron', command, 'job-1'], { + encoding: 'utf-8', + timeout: 30_000 + }) + }) + + it('does not clear count state when a command fails', async () => { + const commandError = new Error('command failed') + const clearRunCount = vi.fn() + const executor = new ExternalAutomationCommandExecutor( + vi.fn().mockRejectedValue(commandError), + clearRunCount + ) + await expect( - requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-1', - page: 1, - pageSize: 0 - }) - ).resolves.toEqual({ total: 1, runs: [] }) + executor.runAction({ provider: 'hermes', action: 'run', jobId: 'job-1' }) + ).rejects.toBe(commandError) + expect(clearRunCount).not.toHaveBeenCalled() + }) + + it('preserves Hermes create arguments and clears all cached counts after success', async () => { + const runCommand = vi.fn().mockResolvedValue(undefined) + const clearRunCount = vi.fn() + const executor = new ExternalAutomationCommandExecutor(runCommand, clearRunCount) - await requestHandlers.get('externalAutomations.act')?.({ + await executor.createJob({ provider: 'hermes', - action: 'run', - jobId: 'job-1' + name: 'Nightly review', + prompt: 'Review the workspace', + schedule: '0 2 * * *', + workdir: '/remote/workspace' }) - await expect( - requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-1', - page: 1, - pageSize: 0 - }) - ).resolves.toEqual({ total: 2, runs: [] }) - expect(readHermesRunRefs).toHaveBeenCalledTimes(2) + + expect(runCommand).toHaveBeenCalledWith( + 'hermes', + [ + 'cron', + 'create', + '0 2 * * *', + 'Review the workspace', + '--name', + 'Nightly review', + '--deliver', + 'local', + '--workdir', + '/remote/workspace' + ], + { encoding: 'utf-8', timeout: 30_000 } + ) + expect(clearRunCount).toHaveBeenCalledWith() }) - it('evicts oldest remote Hermes count cache entries when many job ids are observed', async () => { - const { handler, requestHandlers } = createHandlerHarness() - const readHermesRunRefs = vi.fn(async (jobId: string) => - jobId === 'job-0' - ? [{ id: 'job-0:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }] - : [] + it('preserves Hermes update arguments and clears only the edited job count', async () => { + const runCommand = vi.fn().mockResolvedValue(undefined) + const clearRunCount = vi.fn() + const executor = new ExternalAutomationCommandExecutor(runCommand, clearRunCount) + + await executor.updateJob({ + provider: 'hermes', + jobId: 'job-1', + name: 'Updated review', + prompt: 'Review changes', + schedule: '15 3 * * *' + }) + + expect(runCommand).toHaveBeenCalledWith( + 'hermes', + [ + 'cron', + 'edit', + 'job-1', + '--schedule', + '15 3 * * *', + '--prompt', + 'Review changes', + '--name', + 'Updated review' + ], + { encoding: 'utf-8', timeout: 30_000 } ) - const handlerInternals = handler as unknown as { - readHermesRunRefs: typeof readHermesRunRefs - } - handlerInternals.readHermesRunRefs = readHermesRunRefs + expect(clearRunCount).toHaveBeenCalledWith('job-1') + }) - await expect( - requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-0', - page: 1, - pageSize: 0 - }) - ).resolves.toEqual({ total: 1, runs: [] }) + it('preserves update validation order', async () => { + const executor = new ExternalAutomationCommandExecutor(vi.fn(), vi.fn()) - for (let i = 1; i <= 200; i += 1) { - await requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: `job-${i}`, - page: 1, - pageSize: 0 - }) - } - await expect( - requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-0', - page: 1, - pageSize: 0 - }) - ).resolves.toEqual({ total: 1, runs: [] }) - - expect(readHermesRunRefs).toHaveBeenCalledTimes(202) + executor.updateJob({ provider: 'hermes', jobId: '--help', prompt: '', schedule: '' }) + ).rejects.toThrow('Hermes cron requires a prompt.') }) }) diff --git a/src/relay/external-automations-handler.ts b/src/relay/external-automations-handler.ts index 03bb0b9beba..6c5d8af98ae 100644 --- a/src/relay/external-automations-handler.ts +++ b/src/relay/external-automations-handler.ts @@ -1,967 +1,39 @@ -/* eslint-disable max-lines -- Why: relay external automation listing, paginated - * run history, and actions must stay co-located behind one relay request handler. */ import { execFile } from 'node:child_process' -import { existsSync } from 'node:fs' -import { open, readdir, readFile, realpath, stat } from 'node:fs/promises' -import { createRequire } from 'node:module' -import { homedir } from 'node:os' -import { isAbsolute, join, relative, resolve, sep } from 'node:path' import { promisify } from 'node:util' import type { RelayDispatcher } from './dispatcher' +import { + ExternalAutomationCommandExecutor, + type ExternalAutomationCommandRunner +} from './external-automation-command-executor' +import { ExternalAutomationProviderCatalog } from './external-automation-provider-catalog' +import { HermesRunHistory } from './hermes-run-history' const execFileAsync = promisify(execFile) -const requireOptional = createRequire(__filename) -const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes') -const HERMES_CRON_DIR = join(HERMES_HOME, 'cron') -const HERMES_JOBS_FILE = join(HERMES_CRON_DIR, 'jobs.json') -const HERMES_OUTPUT_DIR = join(HERMES_CRON_DIR, 'output') -const HERMES_STATE_DB = join(HERMES_HOME, 'state.db') -const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json') -const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ -const HERMES_OUTPUT_FILE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.md$/ -const HERMES_RUN_KEY_PATTERN = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/ -const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000 -const MAX_REFERENCED_LOG_BYTES = 5 * 1024 * 1024 -const HERMES_RUN_COUNT_CACHE_MAX_ENTRIES = 200 -const FULL_SESSION_LOG_HEADING = '## Full session log' -const REFERENCED_LOG_HEADING = '## Latest log file' -const LATEST_LOG_PATH_PATTERN = - /\bLatest log path:\s*(?(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?)(?=\s+Run summary:|\r?\n|$)/i -type SqliteStatement = { - get: (...args: unknown[]) => Record | undefined - all: (...args: unknown[]) => Record[] -} -type SqliteDatabase = { - prepare: (sql: string) => SqliteStatement - close: () => void -} -type DatabaseConstructor = new ( - path: string, - options?: { readonly?: boolean; fileMustExist?: boolean; timeout?: number } -) => SqliteDatabase -type NodeSqliteDatabaseSync = new ( - path: string, - options?: { readOnly?: boolean; timeout?: number } -) => SqliteDatabase -let databaseConstructor: DatabaseConstructor | null | undefined -type ExternalProvider = 'hermes' | 'openclaw' -type HermesAction = 'pause' | 'resume' | 'run' | 'delete' -type HermesOutputRunRef = { - kind: 'output' - id: string - job_id: string - run_at: string | null - run_key: string | null - output_path: string +const runExternalAutomationCommand: ExternalAutomationCommandRunner = async ( + command, + args, + options +) => { + await execFileAsync(command, args, options) } -type HermesSessionRunRef = { - kind: 'session' - id: string - job_id: string - run_at: string | null - run_key: string | null -} -type HermesMergedRunRef = { - id: string - job_id: string - run_at: string | null - run_key: string | null - output: HermesOutputRunRef | null - session: HermesSessionRunRef | null -} -type HermesRunCountCacheEntry = { - promise: Promise - expiresAt: number -} - -const HERMES_RUN_COUNT_CACHE_TTL_MS = 2000 export class ExternalAutomationsHandler { - private readonly hermesRunCountCache = new Map() + private readonly runHistory = new HermesRunHistory() + private readonly commands = new ExternalAutomationCommandExecutor( + runExternalAutomationCommand, + (jobId) => this.runHistory.clearRunCount(jobId) + ) + private readonly providers = new ExternalAutomationProviderCatalog( + runExternalAutomationCommand, + (params) => this.runHistory.listRuns(params) + ) - constructor(private readonly dispatcher: RelayDispatcher) { - this.dispatcher.onRequest('externalAutomations.list', (params) => this.listJobs(params)) - this.dispatcher.onRequest('externalAutomations.runs', (params) => this.listRuns(params)) - this.dispatcher.onRequest('externalAutomations.create', (params) => this.createJob(params)) - this.dispatcher.onRequest('externalAutomations.update', (params) => this.updateJob(params)) - this.dispatcher.onRequest('externalAutomations.act', (params) => this.runAction(params)) - } - - private async isCommandAvailable(command: string): Promise { - const finder = process.platform === 'win32' ? 'where' : 'which' - try { - await execFileAsync(finder, [command], { - encoding: 'utf-8', - timeout: 5000 - }) - return true - } catch { - return false - } - } - - private async readJobs(provider: ExternalProvider): Promise { - const jobsFile = provider === 'hermes' ? HERMES_JOBS_FILE : OPENCLAW_JOBS_FILE - if (!existsSync(jobsFile)) { - return [] - } - const content = await readFile(jobsFile, 'utf-8') - const parsed = JSON.parse(content) as unknown - const jobs = Array.isArray(parsed) - ? parsed - : typeof parsed === 'object' && - parsed !== null && - !Array.isArray(parsed) && - Array.isArray((parsed as { jobs?: unknown }).jobs) - ? (parsed as { jobs: unknown[] }).jobs - : [] - if (provider !== 'hermes') { - return jobs - } - return Promise.all( - jobs.map(async (job) => { - if (!this.isRecord(job) || typeof job.id !== 'string') { - return job - } - const runsPage = await this.listRuns({ - provider: 'hermes', - jobId: job.id, - page: 1, - pageSize: 0 - }) - return { - ...job, - run_count: runsPage.total, - runs: runsPage.runs - } - }) - ) - } - - private isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) - } - - private runAtFromHermesOutputFile(filename: string): string | null { - const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename) - if (!match) { - return null - } - const [, year, month, day, hour, minute, second] = match - return `${year}-${month}-${day}T${hour}:${minute}:${second}` - } - - private runKeyFromHermesOutputFile(filename: string): string | null { - const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename) - if (!match) { - return null - } - const [, year, month, day, hour, minute, second] = match - return `${year}${month}${day}_${hour}${minute}${second}` - } - - private runAtFromUnixSeconds(value: unknown): string | null { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return null - } - const date = new Date(value * 1000) - return Number.isNaN(date.getTime()) ? null : date.toISOString() - } - - private sortableTimeFromRunKey(runKey: string | null): number { - if (!runKey) { - return Number.NaN - } - const match = HERMES_RUN_KEY_PATTERN.exec(runKey) - if (!match) { - return Number.NaN - } - const [, year, month, day, hour, minute, second] = match - return Date.UTC( - Number(year), - Number(month) - 1, - Number(day), - Number(hour), - Number(minute), - Number(second) - ) - } - - private escapeSqlLike(value: string): string { - return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_') - } - - private cleanRunPreview(value: string): string | null { - const normalized = value - .replace(/```[\s\S]*?```/g, ' ') - .replace(/[#*_`>()]/g, ' ') - .replaceAll('[', ' ') - .replaceAll(']', ' ') - .replace(/\s+/g, ' ') - .trim() - if (!normalized) { - return null - } - return normalized.length > 180 ? `${normalized.slice(0, 177)}...` : normalized - } - - private parseHermesOutput(content: string): { - status: 'completed' | 'failed' | 'unknown' - outputPreview: string | null - outputContent: string - error: string | null - } { - const failed = /^#\s+Cron Job:.*\(FAILED\)/m.test(content) || /^##\s+Error\b/m.test(content) - const errorMatch = /##\s+Error\s+```([\s\S]*?)```/m.exec(content) - const responseMatch = /##\s+Response\s+([\s\S]*)$/m.exec(content) - const error = errorMatch ? this.cleanRunPreview(errorMatch[1]) : null - return { - status: failed ? 'failed' : responseMatch ? 'completed' : 'unknown', - outputPreview: this.cleanRunPreview(responseMatch?.[1] ?? errorMatch?.[1] ?? content), - outputContent: content, - error - } - } - - private extractLatestLogPath(content: string): string | null { - const rawPath = LATEST_LOG_PATH_PATTERN.exec(content)?.groups?.path?.trim() - if (!rawPath) { - return null - } - return rawPath.replace(/^`|`$/g, '').trim() - } - - private async readReferencedLogFile(content: string): Promise<{ - path: string - content: string - truncated: boolean - } | null> { - const logPath = this.extractLatestLogPath(content) - if (!logPath || !isAbsolute(logPath)) { - return null - } - try { - const homeRealPath = await realpath(HERMES_HOME) - const logRealPath = await realpath(logPath) - const relativeToHermesHome = relative(resolve(homeRealPath), resolve(logRealPath)) - // Why: the output body can contain agent-authored text, so only hydrate - // referenced files that resolve inside Hermes' own data directory. - if ( - relativeToHermesHome === '..' || - relativeToHermesHome.startsWith(`..${sep}`) || - isAbsolute(relativeToHermesHome) - ) { - return null - } - const logStat = await stat(logPath) - if (!logStat.isFile()) { - return null - } - if (logStat.size <= MAX_REFERENCED_LOG_BYTES) { - return { - path: logPath, - content: await readFile(logPath, 'utf-8'), - truncated: false - } - } - const file = await open(logPath, 'r') - try { - const buffer = Buffer.alloc(MAX_REFERENCED_LOG_BYTES) - await file.read( - buffer, - 0, - MAX_REFERENCED_LOG_BYTES, - logStat.size - MAX_REFERENCED_LOG_BYTES - ) - return { - path: logPath, - content: buffer.toString('utf-8'), - truncated: true - } - } finally { - await file.close() - } - } catch { - return null - } - } - - private async appendReferencedLogFile(content: string): Promise { - if (content.includes(REFERENCED_LOG_HEADING)) { - return content - } - const logFile = await this.readReferencedLogFile(content) - if (!logFile) { - return content - } - const note = logFile.truncated - ? `Showing the last ${MAX_REFERENCED_LOG_BYTES} bytes because the log file is larger.` - : null - return [ - content, - '---', - REFERENCED_LOG_HEADING, - '', - `Path: ${logFile.path}`, - note, - '```text', - logFile.content.trimEnd(), - '```' - ] - .filter((part) => part !== null) - .join('\n\n') - } - - private formatSessionMessages(messages: Record[]): string | null { - if (messages.length === 0) { - return null - } - return messages - .map((message) => { - const role = typeof message.role === 'string' ? message.role : 'message' - const content = typeof message.content === 'string' ? message.content.trim() : '' - const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : '' - const reasoning = - typeof message.reasoning_content === 'string' - ? message.reasoning_content.trim() - : typeof message.reasoning === 'string' - ? message.reasoning.trim() - : '' - const parts = [ - `## ${role}${toolName ? ` / ${toolName}` : ''}`, - reasoning ? `### Reasoning\n\n${reasoning}` : null, - content || '(empty)' - ].filter(Boolean) - return parts.join('\n\n') - }) - .join('\n\n---\n\n') - } - - private getRunKey(run: unknown): string | null { - return this.isRecord(run) && typeof run.run_key === 'string' && run.run_key.trim() - ? run.run_key - : null - } - - private getRunOutputContent(run: unknown): string | null { - return this.isRecord(run) && typeof run.output_content === 'string' && run.output_content.trim() - ? run.output_content - : null - } - - private getRunOutputPreview(run: unknown): string | null { - return this.isRecord(run) && typeof run.output_preview === 'string' && run.output_preview.trim() - ? run.output_preview - : null - } - - private mergeOutputAndSessionContent( - outputContent: string | null, - sessionContent: string | null - ): string | null { - if (!sessionContent) { - return outputContent - } - if (!outputContent) { - return `${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` - } - if (outputContent.includes(FULL_SESSION_LOG_HEADING)) { - return outputContent - } - return `${outputContent}\n\n---\n\n${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` - } - - private findMatchingSessionRunIndex( - outputRun: unknown, - sessionRuns: unknown[], - usedSessionRunIndexes: Set - ): number | null { - const outputRunKey = this.getRunKey(outputRun) - const exactMatchIndex = sessionRuns.findIndex( - (sessionRun, index) => - !usedSessionRunIndexes.has(index) && this.getRunKey(sessionRun) === outputRunKey - ) - if (exactMatchIndex !== -1) { - return exactMatchIndex - } - - const outputTime = this.sortableTimeFromRunKey(outputRunKey) - if (!Number.isFinite(outputTime)) { - return null - } - - let bestIndex: number | null = null - let bestGap = Number.POSITIVE_INFINITY - for (let index = 0; index < sessionRuns.length; index += 1) { - if (usedSessionRunIndexes.has(index)) { - continue - } - const sessionTime = this.sortableTimeFromRunKey(this.getRunKey(sessionRuns[index])) - if (!Number.isFinite(sessionTime)) { - continue - } - const gap = outputTime - sessionTime - if (gap < 0 || gap > MAX_SESSION_OUTPUT_GAP_MS || gap >= bestGap) { - continue - } - bestIndex = index - bestGap = gap - } - return bestIndex - } - - private mergeHermesOutputAndSessionRuns( - outputRuns: unknown[], - sessionRuns: unknown[] - ): unknown[] { - const usedSessionRunIndexes = new Set() - const mergedOutputRuns = outputRuns.map((outputRun) => { - if (!this.isRecord(outputRun)) { - return outputRun - } - const sessionRunIndex = this.findMatchingSessionRunIndex( - outputRun, - sessionRuns, - usedSessionRunIndexes - ) - if (sessionRunIndex === null) { - return outputRun - } - const sessionRun = sessionRuns[sessionRunIndex] - if (!this.isRecord(sessionRun)) { - return outputRun - } - usedSessionRunIndexes.add(sessionRunIndex) - // Hermes writes the markdown output at completion, while state.db keeps - // the actual turn-by-turn transcript under the cron session start time. - return { - ...outputRun, - output_preview: this.getRunOutputPreview(outputRun) ?? this.getRunOutputPreview(sessionRun), - output_content: this.mergeOutputAndSessionContent( - this.getRunOutputContent(outputRun), - this.getRunOutputContent(sessionRun) - ) - } - }) - return [ - ...mergedOutputRuns, - ...sessionRuns.filter((_, index) => !usedSessionRunIndexes.has(index)) - ] - } - - private mergeHermesOutputAndSessionRunRefs( - outputRefs: HermesOutputRunRef[], - sessionRefs: HermesSessionRunRef[] - ): HermesMergedRunRef[] { - const usedSessionRunIndexes = new Set() - const mergedOutputRefs = outputRefs.map((outputRef) => { - const sessionRunIndex = this.findMatchingSessionRunIndex( - outputRef, - sessionRefs, - usedSessionRunIndexes - ) - const sessionRef = sessionRunIndex === null ? null : sessionRefs[sessionRunIndex] - if (sessionRunIndex !== null) { - usedSessionRunIndexes.add(sessionRunIndex) - } - return { - id: outputRef.id, - job_id: outputRef.job_id, - run_at: outputRef.run_at, - run_key: outputRef.run_key, - output: outputRef, - session: sessionRef - } - }) - return [ - ...mergedOutputRefs, - ...sessionRefs - .filter((_, index) => !usedSessionRunIndexes.has(index)) - .map((sessionRef) => ({ - id: sessionRef.id, - job_id: sessionRef.job_id, - run_at: sessionRef.run_at, - run_key: sessionRef.run_key, - output: null, - session: sessionRef - })) - ] - } - - private getDatabaseConstructor(): DatabaseConstructor | null { - if (databaseConstructor !== undefined) { - return databaseConstructor - } - try { - // Why: the remote relay still targets Node 18. Hosts without node:sqlite - // should keep listing file-backed runs and simply omit DB transcripts. - const loaded = requireOptional('node:sqlite') as { - DatabaseSync?: NodeSqliteDatabaseSync - } - const DatabaseSync = loaded.DatabaseSync - if (typeof DatabaseSync !== 'function') { - databaseConstructor = null - return databaseConstructor - } - const SqliteDatabaseSync = DatabaseSync - databaseConstructor = class RelaySqliteDatabase { - private readonly db: SqliteDatabase - - constructor( - path: string, - options: { readonly?: boolean; fileMustExist?: boolean; timeout?: number } = {} - ) { - if (options.fileMustExist && !existsSync(path)) { - throw new Error(`SQLite database does not exist: ${path}`) - } - this.db = new SqliteDatabaseSync(path, { - readOnly: options.readonly, - timeout: options.timeout - }) - } - - prepare(sql: string): SqliteStatement { - return this.db.prepare(sql) - } - - close(): void { - this.db.close() - } - } - } catch { - databaseConstructor = null - } - return databaseConstructor - } - - private async readHermesRunRefs(jobId: string): Promise { - const outputRuns = await this.readHermesOutputFileRunRefs(jobId) - return this.mergeHermesOutputAndSessionRunRefs( - outputRuns, - this.readHermesSessionDbRunRefs(jobId) - ).sort((a, b) => { - const aTime = this.getRawRunTime(a) - const bTime = this.getRawRunTime(b) - if (Number.isFinite(aTime) && Number.isFinite(bTime)) { - return bTime - aTime - } - return this.getRawRunId(b).localeCompare(this.getRawRunId(a)) - }) - } - - private async hydrateHermesRunRef(jobId: string, ref: HermesMergedRunRef): Promise { - const outputRun = ref.output ? await this.readHermesOutputFileRun(ref.output) : null - const sessionRun = ref.session ? this.readHermesSessionDbRunById(jobId, ref.session.id) : null - return ( - this.mergeHermesOutputAndSessionRuns( - outputRun ? [outputRun] : [], - sessionRun ? [sessionRun] : [] - )[0] ?? - outputRun ?? - sessionRun ?? - ref - ) - } - - private async readHermesRunCount(jobId: string): Promise { - if (!EXTERNAL_JOB_ID_PATTERN.test(jobId)) { - return 0 - } - const now = Date.now() - const cached = this.hermesRunCountCache.get(jobId) - if (cached && cached.expiresAt > now) { - return cached.promise - } - if (cached) { - this.hermesRunCountCache.delete(jobId) - } - // Why: remote Hermes jobs can churn independently of Orca; relay - // processes are long-lived, so stale job ids need both TTL and a hard cap. - this.pruneHermesRunCountCache(now) - const entry: HermesRunCountCacheEntry = { - promise: this.readHermesRunRefs(jobId).then((refs) => refs.length), - expiresAt: Number.POSITIVE_INFINITY - } - this.hermesRunCountCache.set(jobId, entry) - try { - const count = await entry.promise - entry.expiresAt = Date.now() + HERMES_RUN_COUNT_CACHE_TTL_MS - return count - } catch (error) { - if (this.hermesRunCountCache.get(jobId) === entry) { - this.hermesRunCountCache.delete(jobId) - } - throw error - } - } - - private pruneHermesRunCountCache(now: number): void { - for (const [jobId, entry] of this.hermesRunCountCache) { - if (entry.expiresAt <= now) { - this.hermesRunCountCache.delete(jobId) - } - } - while (this.hermesRunCountCache.size >= HERMES_RUN_COUNT_CACHE_MAX_ENTRIES) { - const oldestJobId = this.hermesRunCountCache.keys().next().value - if (oldestJobId === undefined) { - return - } - this.hermesRunCountCache.delete(oldestJobId) - } - } - - private clearHermesRunCountCache(jobId?: string): void { - if (jobId) { - this.hermesRunCountCache.delete(jobId) - return - } - this.hermesRunCountCache.clear() - } - - private async listRuns(params: Record = {}): Promise<{ - total: number - runs: unknown[] - }> { - const provider = params.provider === 'openclaw' ? 'openclaw' : 'hermes' - const jobId = params.jobId - const page = - typeof params.page === 'number' && Number.isFinite(params.page) - ? Math.max(1, Math.floor(params.page)) - : 1 - const pageSize = - typeof params.pageSize === 'number' && Number.isFinite(params.pageSize) - ? Math.min(100, Math.max(0, Math.floor(params.pageSize))) - : 25 - if (provider !== 'hermes') { - return { total: 0, runs: [] } - } - if (typeof jobId !== 'string' || !EXTERNAL_JOB_ID_PATTERN.test(jobId)) { - throw new Error('Invalid external automation job ID.') - } - if (pageSize === 0) { - // Why: manager listing only needs a badge count; hydrating markdown logs - // and full session transcripts can make opening Automations very slow. - return { total: await this.readHermesRunCount(jobId), runs: [] } - } - const runRefs = await this.readHermesRunRefs(jobId) - const start = (page - 1) * pageSize - return { - total: runRefs.length, - runs: await Promise.all( - runRefs.slice(start, start + pageSize).map((ref) => this.hydrateHermesRunRef(jobId, ref)) - ) - } - } - - private getRawRunId(run: unknown): string { - if (this.isRecord(run) && 'id' in run) { - return String(run.id) - } - return '' - } - - private getRawRunTime(run: unknown): number { - if (!this.isRecord(run) || !('run_at' in run)) { - return Number.NaN - } - return typeof run.run_at === 'string' ? Date.parse(run.run_at) : Number.NaN - } - - private async readHermesOutputFileRunRefs(jobId: string): Promise { - const outputDir = join(HERMES_OUTPUT_DIR, jobId) - if (!existsSync(outputDir)) { - return [] - } - const entries = await readdir(outputDir, { withFileTypes: true }) - return entries - .filter((entry) => entry.isFile() && HERMES_OUTPUT_FILE_PATTERN.test(entry.name)) - .map((entry) => ({ - kind: 'output' as const, - id: `${jobId}:${entry.name}`, - job_id: jobId, - run_at: this.runAtFromHermesOutputFile(entry.name), - run_key: this.runKeyFromHermesOutputFile(entry.name), - output_path: join(outputDir, entry.name) - })) - } - - private async readHermesOutputFileRun(ref: HermesOutputRunRef): Promise { - try { - const content = await readFile(ref.output_path, 'utf-8') - const parsed = this.parseHermesOutput(content) - const outputContent = await this.appendReferencedLogFile(parsed.outputContent) - return { - id: ref.id, - job_id: ref.job_id, - run_at: ref.run_at, - run_key: ref.run_key, - status: parsed.status, - output_preview: parsed.outputPreview, - output_content: outputContent, - error: parsed.error, - output_path: ref.output_path - } - } catch (error) { - return { - id: ref.id, - job_id: ref.job_id, - run_at: ref.run_at, - run_key: ref.run_key, - status: 'unknown', - output_preview: null, - output_content: null, - error: error instanceof Error ? error.message : String(error), - output_path: ref.output_path - } - } - } - - private readHermesSessionDbRunRefs(jobId: string): HermesSessionRunRef[] { - if (!existsSync(HERMES_STATE_DB)) { - return [] - } - const Database = this.getDatabaseConstructor() - if (!Database) { - return [] - } - try { - const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) - try { - const pattern = `cron\\_${this.escapeSqlLike(jobId)}\\_%` - const rows = db - .prepare( - `SELECT id, started_at - FROM sessions - WHERE id LIKE ? ESCAPE '\\' - ORDER BY started_at DESC` - ) - .all(pattern) as Record[] - return rows.map((row) => { - const runId = typeof row.id === 'string' ? row.id : `${jobId}:${String(row.started_at)}` - return { - kind: 'session', - id: runId, - job_id: jobId, - run_at: this.runAtFromUnixSeconds(row.started_at), - run_key: runId.split(`${jobId}_`).at(-1) ?? null - } - }) - } finally { - db.close() - } - } catch { - return [] - } - } - - private readHermesSessionDbRunById(jobId: string, runId: string): unknown { - if (!existsSync(HERMES_STATE_DB)) { - return null - } - const Database = this.getDatabaseConstructor() - if (!Database) { - return null - } - try { - const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) - try { - const row = db - .prepare( - `SELECT id, title, started_at, ended_at, end_reason, model, message_count, - input_tokens, output_tokens, estimated_cost_usd - FROM sessions - WHERE id = ?` - ) - .get(runId) as Record | undefined - if (!row) { - return null - } - const messages = db - .prepare( - `SELECT role, content, tool_name, reasoning, reasoning_content - FROM messages - WHERE session_id = ? - ORDER BY timestamp, id` - ) - .all(runId) as Record[] - const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null - const model = typeof row.model === 'string' && row.model.trim() ? row.model.trim() : null - const messageCount = typeof row.message_count === 'number' ? row.message_count : null - const tokenCount = - (typeof row.input_tokens === 'number' ? row.input_tokens : 0) + - (typeof row.output_tokens === 'number' ? row.output_tokens : 0) - const summaryParts = [ - title, - model ? `Model: ${model}` : null, - messageCount !== null ? `${messageCount} messages` : null, - tokenCount > 0 ? `${tokenCount} tokens` : null - ].filter(Boolean) - return { - id: runId, - job_id: jobId, - run_at: this.runAtFromUnixSeconds(row.started_at), - run_key: runId.split(`${jobId}_`).at(-1) ?? null, - status: typeof row.ended_at === 'number' ? 'completed' : 'unknown', - output_preview: summaryParts.join(' · ') || null, - output_content: this.formatSessionMessages(messages), - error: null, - output_path: HERMES_STATE_DB - } - } finally { - db.close() - } - } catch { - return null - } - } - - private async listJobs(params?: Record): Promise<{ - jobs: unknown[] - hermesAvailable: boolean - openclawAvailable: boolean - error: string | null - }> { - const provider = params?.provider === 'openclaw' ? 'openclaw' : 'hermes' - const [commandAvailable, jobsResult] = await Promise.allSettled([ - this.isCommandAvailable(provider), - this.readJobs(provider) - ]) - const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : [] - const available = commandAvailable.status === 'fulfilled' && commandAvailable.value - return { - jobs, - hermesAvailable: provider === 'hermes' && available, - openclawAvailable: provider === 'openclaw' && available, - error: jobsResult.status === 'rejected' ? String(jobsResult.reason) : null - } - } - - private hermesCommand(action: HermesAction): string { - switch (action) { - case 'pause': - return 'pause' - case 'resume': - return 'resume' - case 'run': - return 'run' - case 'delete': - return 'remove' - } - } - - private openClawCommand(action: HermesAction): string { - switch (action) { - case 'pause': - return 'disable' - case 'resume': - return 'enable' - case 'run': - return 'run' - case 'delete': - return 'rm' - } - } - - private normalizeHermesJobMutation(params: Record): { - name: string - prompt: string - schedule: string - workdir: string - } { - const provider = params.provider === 'openclaw' ? 'openclaw' : 'hermes' - if (provider !== 'hermes') { - throw new Error('Only Hermes cron creation and editing are supported.') - } - const name = typeof params.name === 'string' ? params.name.trim() : '' - const prompt = typeof params.prompt === 'string' ? params.prompt.trim() : '' - const schedule = typeof params.schedule === 'string' ? params.schedule.trim() : '' - const workdir = typeof params.workdir === 'string' ? params.workdir.trim() : '' - if (!prompt) { - throw new Error('Hermes cron requires a prompt.') - } - if (!schedule) { - throw new Error('Hermes cron requires a schedule.') - } - return { - name: name || prompt.slice(0, 50).trim(), - prompt, - schedule, - workdir - } - } - - private async runHermesCronCommand(args: string[]): Promise { - await execFileAsync('hermes', args, { - encoding: 'utf-8', - timeout: 30_000 - }) - } - - private async createJob(params: Record = {}): Promise<{ ok: true }> { - const input = this.normalizeHermesJobMutation(params) - const args = [ - 'cron', - 'create', - input.schedule, - input.prompt, - '--name', - input.name, - '--deliver', - 'local' - ] - if (input.workdir) { - args.push('--workdir', input.workdir) - } - await this.runHermesCronCommand(args) - this.clearHermesRunCountCache() - return { ok: true } - } - - private async updateJob(params: Record = {}): Promise<{ ok: true }> { - const input = this.normalizeHermesJobMutation(params) - const jobId = params.jobId - if (typeof jobId !== 'string' || !EXTERNAL_JOB_ID_PATTERN.test(jobId)) { - throw new Error('Invalid external automation job ID.') - } - const args = [ - 'cron', - 'edit', - jobId, - '--schedule', - input.schedule, - '--prompt', - input.prompt, - '--name', - input.name - ] - if (input.workdir) { - args.push('--workdir', input.workdir) - } - await this.runHermesCronCommand(args) - this.clearHermesRunCountCache(jobId) - return { ok: true } - } - - private async runAction(params: Record = {}): Promise<{ ok: true }> { - const provider = params.provider === 'openclaw' ? 'openclaw' : 'hermes' - const action = params.action - const jobId = params.jobId - if (action !== 'pause' && action !== 'resume' && action !== 'run' && action !== 'delete') { - throw new Error('Unsupported external automation action.') - } - if (typeof jobId !== 'string' || !EXTERNAL_JOB_ID_PATTERN.test(jobId)) { - throw new Error('Invalid external automation job ID.') - } - const command = - provider === 'hermes' ? this.hermesCommand(action) : this.openClawCommand(action) - await execFileAsync(provider, ['cron', command, jobId], { - encoding: 'utf-8', - timeout: 30_000 - }) - if (provider === 'hermes') { - this.clearHermesRunCountCache(jobId) - } - return { ok: true } + constructor(dispatcher: RelayDispatcher) { + dispatcher.onRequest('externalAutomations.list', (params) => this.providers.listJobs(params)) + dispatcher.onRequest('externalAutomations.runs', (params) => this.runHistory.listRuns(params)) + dispatcher.onRequest('externalAutomations.create', (params) => this.commands.createJob(params)) + dispatcher.onRequest('externalAutomations.update', (params) => this.commands.updateJob(params)) + dispatcher.onRequest('externalAutomations.act', (params) => this.commands.runAction(params)) } } diff --git a/src/relay/hermes-output-run-files.ts b/src/relay/hermes-output-run-files.ts new file mode 100644 index 00000000000..31aa213abe7 --- /dev/null +++ b/src/relay/hermes-output-run-files.ts @@ -0,0 +1,192 @@ +import { existsSync } from 'node:fs' +import { open, readdir, readFile, realpath, stat } from 'node:fs/promises' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { HERMES_HOME, HERMES_OUTPUT_DIR } from './external-automation-storage-paths' +import type { HermesOutputRunRef } from './hermes-run-correlation' + +const HERMES_OUTPUT_FILE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.md$/ +const MAX_REFERENCED_LOG_BYTES = 5 * 1024 * 1024 +const REFERENCED_LOG_HEADING = '## Latest log file' +const LATEST_LOG_PATH_PATTERN = + /\bLatest log path:\s*(?(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?)(?=\s+Run summary:|\r?\n|$)/i + +function runAtFromHermesOutputFile(filename: string): string | null { + const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename) + if (!match) { + return null + } + const [, year, month, day, hour, minute, second] = match + return `${year}-${month}-${day}T${hour}:${minute}:${second}` +} + +function runKeyFromHermesOutputFile(filename: string): string | null { + const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename) + if (!match) { + return null + } + const [, year, month, day, hour, minute, second] = match + return `${year}${month}${day}_${hour}${minute}${second}` +} + +function cleanRunPreview(value: string): string | null { + const normalized = value + .replace(/```[\s\S]*?```/g, ' ') + .replace(/[#*_`>()]/g, ' ') + .replaceAll('[', ' ') + .replaceAll(']', ' ') + .replace(/\s+/g, ' ') + .trim() + if (!normalized) { + return null + } + return normalized.length > 180 ? `${normalized.slice(0, 177)}...` : normalized +} + +function parseHermesOutput(content: string): { + status: 'completed' | 'failed' | 'unknown' + outputPreview: string | null + outputContent: string + error: string | null +} { + const failed = /^#\s+Cron Job:.*\(FAILED\)/m.test(content) || /^##\s+Error\b/m.test(content) + const errorMatch = /##\s+Error\s+```([\s\S]*?)```/m.exec(content) + const responseMatch = /##\s+Response\s+([\s\S]*)$/m.exec(content) + const error = errorMatch ? cleanRunPreview(errorMatch[1]) : null + return { + status: failed ? 'failed' : responseMatch ? 'completed' : 'unknown', + outputPreview: cleanRunPreview(responseMatch?.[1] ?? errorMatch?.[1] ?? content), + outputContent: content, + error + } +} + +function extractLatestLogPath(content: string): string | null { + const rawPath = LATEST_LOG_PATH_PATTERN.exec(content)?.groups?.path?.trim() + if (!rawPath) { + return null + } + return rawPath.replace(/^`|`$/g, '').trim() +} + +export async function readHermesReferencedLogFile(content: string): Promise<{ + path: string + content: string + truncated: boolean +} | null> { + const logPath = extractLatestLogPath(content) + if (!logPath || !isAbsolute(logPath)) { + return null + } + try { + const homeRealPath = await realpath(HERMES_HOME) + const logRealPath = await realpath(logPath) + const relativeToHermesHome = relative(resolve(homeRealPath), resolve(logRealPath)) + if ( + relativeToHermesHome === '..' || + relativeToHermesHome.startsWith(`..${sep}`) || + isAbsolute(relativeToHermesHome) + ) { + return null + } + const logStat = await stat(logPath) + if (!logStat.isFile()) { + return null + } + if (logStat.size <= MAX_REFERENCED_LOG_BYTES) { + return { + path: logPath, + content: await readFile(logPath, 'utf-8'), + truncated: false + } + } + const file = await open(logPath, 'r') + try { + const buffer = Buffer.alloc(MAX_REFERENCED_LOG_BYTES) + await file.read(buffer, 0, MAX_REFERENCED_LOG_BYTES, logStat.size - MAX_REFERENCED_LOG_BYTES) + return { + path: logPath, + content: buffer.toString('utf-8'), + truncated: true + } + } finally { + await file.close() + } + } catch { + return null + } +} + +async function appendReferencedLogFile(content: string): Promise { + if (content.includes(REFERENCED_LOG_HEADING)) { + return content + } + const logFile = await readHermesReferencedLogFile(content) + if (!logFile) { + return content + } + const note = logFile.truncated + ? `Showing the last ${MAX_REFERENCED_LOG_BYTES} bytes because the log file is larger.` + : null + return [ + content, + '---', + REFERENCED_LOG_HEADING, + '', + `Path: ${logFile.path}`, + note, + '```text', + logFile.content.trimEnd(), + '```' + ] + .filter((part) => part !== null) + .join('\n\n') +} + +export async function readHermesOutputFileRunRefs(jobId: string): Promise { + const outputDir = join(HERMES_OUTPUT_DIR, jobId) + if (!existsSync(outputDir)) { + return [] + } + const entries = await readdir(outputDir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile() && HERMES_OUTPUT_FILE_PATTERN.test(entry.name)) + .map((entry) => ({ + kind: 'output', + id: `${jobId}:${entry.name}`, + job_id: jobId, + run_at: runAtFromHermesOutputFile(entry.name), + run_key: runKeyFromHermesOutputFile(entry.name), + output_path: join(outputDir, entry.name) + })) +} + +export async function readHermesOutputFileRun(ref: HermesOutputRunRef): Promise { + try { + const content = await readFile(ref.output_path, 'utf-8') + const parsed = parseHermesOutput(content) + const outputContent = await appendReferencedLogFile(parsed.outputContent) + return { + id: ref.id, + job_id: ref.job_id, + run_at: ref.run_at, + run_key: ref.run_key, + status: parsed.status, + output_preview: parsed.outputPreview, + output_content: outputContent, + error: parsed.error, + output_path: ref.output_path + } + } catch (error) { + return { + id: ref.id, + job_id: ref.job_id, + run_at: ref.run_at, + run_key: ref.run_key, + status: 'unknown', + output_preview: null, + output_content: null, + error: error instanceof Error ? error.message : String(error), + output_path: ref.output_path + } + } +} diff --git a/src/relay/hermes-run-correlation.test.ts b/src/relay/hermes-run-correlation.test.ts new file mode 100644 index 00000000000..c009792e63f --- /dev/null +++ b/src/relay/hermes-run-correlation.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { mergeHermesOutputAndSessionRuns } from './hermes-run-correlation' + +function run(runKey: string, content: string): Record { + return { + id: runKey, + run_key: runKey, + output_preview: content, + output_content: content + } +} + +describe('mergeHermesOutputAndSessionRuns', () => { + it('prefers an exact run key and appends its full session transcript', () => { + const output = run('20260516_090000', 'summary') + const exact = run('20260516_090000', 'exact transcript') + const nearerButDifferent = run('20260516_085959', 'other transcript') + + const result = mergeHermesOutputAndSessionRuns([output], [nearerButDifferent, exact]) as Record< + string, + unknown + >[] + + expect(result[0].output_content).toBe( + 'summary\n\n---\n\n## Full session log\n\nexact transcript' + ) + expect(result[1]).toBe(nearerButDifferent) + }) + + it('matches only the nearest earlier session inside the 24-hour window', () => { + const output = run('20260516_090000', 'summary') + const later = run('20260516_090001', 'later') + const tooOld = run('20260515_085959', 'too old') + const nearestEarlier = run('20260516_085959', 'nearest earlier') + + const result = mergeHermesOutputAndSessionRuns( + [output], + [later, tooOld, nearestEarlier] + ) as Record[] + + expect(result[0].output_content).toContain('nearest earlier') + expect(result.slice(1)).toEqual([later, tooOld]) + }) + + it('uses each session run at most once', () => { + const first = run('20260516_090000', 'first') + const second = run('20260516_090100', 'second') + const session = run('20260516_085900', 'one transcript') + + const result = mergeHermesOutputAndSessionRuns([first, second], [session]) as Record< + string, + unknown + >[] + + expect( + result.filter((item) => String(item.output_content).includes('one transcript')) + ).toHaveLength(1) + }) +}) diff --git a/src/relay/hermes-run-correlation.ts b/src/relay/hermes-run-correlation.ts new file mode 100644 index 00000000000..75d3960a8e5 --- /dev/null +++ b/src/relay/hermes-run-correlation.ts @@ -0,0 +1,197 @@ +const HERMES_RUN_KEY_PATTERN = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/ +const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000 +const FULL_SESSION_LOG_HEADING = '## Full session log' + +export type HermesOutputRunRef = { + kind: 'output' + id: string + job_id: string + run_at: string | null + run_key: string | null + output_path: string +} + +export type HermesSessionRunRef = { + kind: 'session' + id: string + job_id: string + run_at: string | null + run_key: string | null +} + +export type HermesMergedRunRef = { + id: string + job_id: string + run_at: string | null + run_key: string | null + output: HermesOutputRunRef | null + session: HermesSessionRunRef | null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function getRunKey(run: unknown): string | null { + return isRecord(run) && typeof run.run_key === 'string' && run.run_key.trim() ? run.run_key : null +} + +function getRunOutputContent(run: unknown): string | null { + return isRecord(run) && typeof run.output_content === 'string' && run.output_content.trim() + ? run.output_content + : null +} + +function getRunOutputPreview(run: unknown): string | null { + return isRecord(run) && typeof run.output_preview === 'string' && run.output_preview.trim() + ? run.output_preview + : null +} + +function sortableTimeFromRunKey(runKey: string | null): number { + if (!runKey) { + return Number.NaN + } + const match = HERMES_RUN_KEY_PATTERN.exec(runKey) + if (!match) { + return Number.NaN + } + const [, year, month, day, hour, minute, second] = match + return Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second) + ) +} + +function findMatchingSessionRunIndex( + outputRun: unknown, + sessionRuns: unknown[], + usedSessionRunIndexes: Set +): number | null { + const outputRunKey = getRunKey(outputRun) + const exactMatchIndex = sessionRuns.findIndex( + (sessionRun, index) => + !usedSessionRunIndexes.has(index) && getRunKey(sessionRun) === outputRunKey + ) + if (exactMatchIndex !== -1) { + return exactMatchIndex + } + const outputTime = sortableTimeFromRunKey(outputRunKey) + if (!Number.isFinite(outputTime)) { + return null + } + let bestIndex: number | null = null + let bestGap = Number.POSITIVE_INFINITY + for (let index = 0; index < sessionRuns.length; index += 1) { + if (usedSessionRunIndexes.has(index)) { + continue + } + const sessionTime = sortableTimeFromRunKey(getRunKey(sessionRuns[index])) + if (!Number.isFinite(sessionTime)) { + continue + } + const gap = outputTime - sessionTime + if (gap < 0 || gap > MAX_SESSION_OUTPUT_GAP_MS || gap >= bestGap) { + continue + } + bestIndex = index + bestGap = gap + } + return bestIndex +} + +function mergeOutputAndSessionContent( + outputContent: string | null, + sessionContent: string | null +): string | null { + if (!sessionContent) { + return outputContent + } + if (!outputContent) { + return `${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` + } + if (outputContent.includes(FULL_SESSION_LOG_HEADING)) { + return outputContent + } + return `${outputContent}\n\n---\n\n${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` +} + +export function mergeHermesOutputAndSessionRuns( + outputRuns: unknown[], + sessionRuns: unknown[] +): unknown[] { + const usedSessionRunIndexes = new Set() + const mergedOutputRuns = outputRuns.map((outputRun) => { + if (!isRecord(outputRun)) { + return outputRun + } + const sessionRunIndex = findMatchingSessionRunIndex( + outputRun, + sessionRuns, + usedSessionRunIndexes + ) + if (sessionRunIndex === null) { + return outputRun + } + const sessionRun = sessionRuns[sessionRunIndex] + if (!isRecord(sessionRun)) { + return outputRun + } + usedSessionRunIndexes.add(sessionRunIndex) + return { + ...outputRun, + output_preview: getRunOutputPreview(outputRun) ?? getRunOutputPreview(sessionRun), + output_content: mergeOutputAndSessionContent( + getRunOutputContent(outputRun), + getRunOutputContent(sessionRun) + ) + } + }) + return [ + ...mergedOutputRuns, + ...sessionRuns.filter((_, index) => !usedSessionRunIndexes.has(index)) + ] +} + +export function mergeHermesOutputAndSessionRunRefs( + outputRefs: HermesOutputRunRef[], + sessionRefs: HermesSessionRunRef[] +): HermesMergedRunRef[] { + const usedSessionRunIndexes = new Set() + const mergedOutputRefs = outputRefs.map((outputRef) => { + const sessionRunIndex = findMatchingSessionRunIndex( + outputRef, + sessionRefs, + usedSessionRunIndexes + ) + const sessionRef = sessionRunIndex === null ? null : sessionRefs[sessionRunIndex] + if (sessionRunIndex !== null) { + usedSessionRunIndexes.add(sessionRunIndex) + } + return { + id: outputRef.id, + job_id: outputRef.job_id, + run_at: outputRef.run_at, + run_key: outputRef.run_key, + output: outputRef, + session: sessionRef + } + }) + return [ + ...mergedOutputRefs, + ...sessionRefs + .filter((_, index) => !usedSessionRunIndexes.has(index)) + .map((sessionRef) => ({ + id: sessionRef.id, + job_id: sessionRef.job_id, + run_at: sessionRef.run_at, + run_key: sessionRef.run_key, + output: null, + session: sessionRef + })) + ] +} diff --git a/src/relay/hermes-run-history.test.ts b/src/relay/hermes-run-history.test.ts new file mode 100644 index 00000000000..4c24c7d94f9 --- /dev/null +++ b/src/relay/hermes-run-history.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest' +import { HermesRunHistory, type HermesRunHistorySources } from './hermes-run-history' +import type { HermesOutputRunRef } from './hermes-run-correlation' + +function outputRef(day: number, jobId = 'job-1'): HermesOutputRunRef { + const dayText = String(day).padStart(2, '0') + return { + kind: 'output', + id: `${jobId}:2026-05-${dayText}_09-00-00.md`, + job_id: jobId, + run_at: `2026-05-${dayText}T09:00:00`, + run_key: `202605${dayText}_090000`, + output_path: `/output/${jobId}/${dayText}.md` + } +} + +function sources(overrides: Partial = {}): HermesRunHistorySources { + return { + readOutputRefs: async () => [], + readSessionRefs: () => [], + readOutputRun: async (ref) => ref, + readSessionRun: () => null, + ...overrides + } +} + +describe('HermesRunHistory', () => { + it('paginates refs before hydrating run content', async () => { + const readOutputRun = vi.fn(async (ref: HermesOutputRunRef) => ref) + const history = new HermesRunHistory( + sources({ + readOutputRefs: async () => [outputRef(16), outputRef(15), outputRef(14)], + readOutputRun + }) + ) + + const result = await history.listRuns({ + provider: 'hermes', + jobId: 'job-1', + page: 1, + pageSize: 2 + }) + + expect(result.total).toBe(3) + expect(result.runs).toEqual([outputRef(16), outputRef(15)]) + expect(readOutputRun).toHaveBeenCalledTimes(2) + }) + + it('uses a count-only path without hydrating content', async () => { + const readOutputRun = vi.fn() + const history = new HermesRunHistory( + sources({ readOutputRefs: async () => [outputRef(16), outputRef(15)], readOutputRun }) + ) + + await expect( + history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) + ).resolves.toEqual({ total: 2, runs: [] }) + expect(readOutputRun).not.toHaveBeenCalled() + }) + + it('deduplicates concurrent count reads', async () => { + let resolveRefs: (refs: HermesOutputRunRef[]) => void = () => {} + const readOutputRefs = vi.fn( + () => + new Promise((resolve) => { + resolveRefs = resolve + }) + ) + const history = new HermesRunHistory(sources({ readOutputRefs })) + + const first = history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) + const second = history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) + expect(readOutputRefs).toHaveBeenCalledTimes(1) + resolveRefs([outputRef(16), outputRef(15)]) + + await expect(Promise.all([first, second])).resolves.toEqual([ + { total: 2, runs: [] }, + { total: 2, runs: [] } + ]) + }) + + it('evicts a failed count read so the next call retries', async () => { + const readError = new Error('output directory unavailable') + const readOutputRefs = vi + .fn<() => Promise>() + .mockRejectedValueOnce(readError) + .mockResolvedValueOnce([outputRef(16)]) + const history = new HermesRunHistory(sources({ readOutputRefs })) + + await expect( + history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) + ).rejects.toBe(readError) + await expect( + history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) + ).resolves.toEqual({ total: 1, runs: [] }) + expect(readOutputRefs).toHaveBeenCalledTimes(2) + }) + + it('clears a cached count for one lifecycle-mutated job', async () => { + const readOutputRefs = vi + .fn<() => Promise>() + .mockResolvedValueOnce([outputRef(15)]) + .mockResolvedValueOnce([outputRef(16), outputRef(15)]) + const history = new HermesRunHistory(sources({ readOutputRefs })) + + await history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) + await history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) + history.clearRunCount('job-1') + + await expect( + history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) + ).resolves.toEqual({ total: 2, runs: [] }) + expect(readOutputRefs).toHaveBeenCalledTimes(2) + }) + + it('returns empty OpenClaw history before validating its job ID', async () => { + const history = new HermesRunHistory(sources()) + + await expect( + history.listRuns({ provider: 'openclaw', jobId: '--invalid', pageSize: 25 }) + ).resolves.toEqual({ total: 0, runs: [] }) + }) + + it('evicts the oldest cached job at the 200-entry bound', async () => { + const readOutputRefs = vi.fn(async (jobId: string) => + jobId === 'job-0' ? [outputRef(15, jobId)] : [] + ) + const history = new HermesRunHistory(sources({ readOutputRefs })) + + await history.listRuns({ provider: 'hermes', jobId: 'job-0', pageSize: 0 }) + for (let index = 1; index <= 200; index += 1) { + await history.listRuns({ provider: 'hermes', jobId: `job-${index}`, pageSize: 0 }) + } + await history.listRuns({ provider: 'hermes', jobId: 'job-0', pageSize: 0 }) + + expect(readOutputRefs).toHaveBeenCalledTimes(202) + }) +}) diff --git a/src/relay/hermes-run-history.ts b/src/relay/hermes-run-history.ts new file mode 100644 index 00000000000..4715f5cd455 --- /dev/null +++ b/src/relay/hermes-run-history.ts @@ -0,0 +1,169 @@ +import { + EXTERNAL_AUTOMATION_JOB_ID_PATTERN, + externalAutomationProvider +} from './external-automation-provider' +import { + mergeHermesOutputAndSessionRunRefs, + mergeHermesOutputAndSessionRuns, + type HermesMergedRunRef, + type HermesOutputRunRef, + type HermesSessionRunRef +} from './hermes-run-correlation' +import { readHermesOutputFileRun, readHermesOutputFileRunRefs } from './hermes-output-run-files' +import { + readHermesSessionDbRunById, + readHermesSessionDbRunRefs +} from './hermes-session-run-database' + +const HERMES_RUN_COUNT_CACHE_TTL_MS = 2000 +const HERMES_RUN_COUNT_CACHE_MAX_ENTRIES = 200 + +type HermesRunCountCacheEntry = { + promise: Promise + expiresAt: number +} + +export type HermesRunHistorySources = { + readOutputRefs: (jobId: string) => Promise + readSessionRefs: (jobId: string) => HermesSessionRunRef[] + readOutputRun: (ref: HermesOutputRunRef) => Promise + readSessionRun: (jobId: string, runId: string) => unknown +} + +const DEFAULT_SOURCES: HermesRunHistorySources = { + readOutputRefs: readHermesOutputFileRunRefs, + readSessionRefs: readHermesSessionDbRunRefs, + readOutputRun: readHermesOutputFileRun, + readSessionRun: readHermesSessionDbRunById +} + +function getRawRunId(run: unknown): string { + if (typeof run === 'object' && run !== null && 'id' in run) { + return String(run.id) + } + return '' +} + +function getRawRunTime(run: unknown): number { + if (typeof run !== 'object' || run === null || !('run_at' in run)) { + return Number.NaN + } + return typeof run.run_at === 'string' ? Date.parse(run.run_at) : Number.NaN +} + +export class HermesRunHistory { + private readonly runCountCache = new Map() + + constructor(private readonly sources: HermesRunHistorySources = DEFAULT_SOURCES) {} + + async listRuns(params: Record = {}): Promise<{ + total: number + runs: unknown[] + }> { + const provider = externalAutomationProvider(params.provider) + const jobId = params.jobId + const page = + typeof params.page === 'number' && Number.isFinite(params.page) + ? Math.max(1, Math.floor(params.page)) + : 1 + const pageSize = + typeof params.pageSize === 'number' && Number.isFinite(params.pageSize) + ? Math.min(100, Math.max(0, Math.floor(params.pageSize))) + : 25 + if (provider !== 'hermes') { + return { total: 0, runs: [] } + } + if (typeof jobId !== 'string' || !EXTERNAL_AUTOMATION_JOB_ID_PATTERN.test(jobId)) { + throw new Error('Invalid external automation job ID.') + } + if (pageSize === 0) { + return { total: await this.readRunCount(jobId), runs: [] } + } + const runRefs = await this.readRunRefs(jobId) + const start = (page - 1) * pageSize + return { + total: runRefs.length, + runs: await Promise.all( + runRefs.slice(start, start + pageSize).map((ref) => this.hydrateRunRef(jobId, ref)) + ) + } + } + + clearRunCount(jobId?: string): void { + if (jobId) { + this.runCountCache.delete(jobId) + return + } + this.runCountCache.clear() + } + + private async readRunRefs(jobId: string): Promise { + const outputRuns = await this.sources.readOutputRefs(jobId) + return mergeHermesOutputAndSessionRunRefs(outputRuns, this.sources.readSessionRefs(jobId)).sort( + (a, b) => { + const aTime = getRawRunTime(a) + const bTime = getRawRunTime(b) + if (Number.isFinite(aTime) && Number.isFinite(bTime)) { + return bTime - aTime + } + return getRawRunId(b).localeCompare(getRawRunId(a)) + } + ) + } + + private async hydrateRunRef(jobId: string, ref: HermesMergedRunRef): Promise { + const outputRun = ref.output ? await this.sources.readOutputRun(ref.output) : null + const sessionRun = ref.session ? this.sources.readSessionRun(jobId, ref.session.id) : null + return ( + mergeHermesOutputAndSessionRuns( + outputRun ? [outputRun] : [], + sessionRun ? [sessionRun] : [] + )[0] ?? + outputRun ?? + sessionRun ?? + ref + ) + } + + private async readRunCount(jobId: string): Promise { + const now = Date.now() + const cached = this.runCountCache.get(jobId) + if (cached && cached.expiresAt > now) { + return cached.promise + } + if (cached) { + this.runCountCache.delete(jobId) + } + this.pruneRunCountCache(now) + const entry: HermesRunCountCacheEntry = { + promise: this.readRunRefs(jobId).then((refs) => refs.length), + expiresAt: Number.POSITIVE_INFINITY + } + this.runCountCache.set(jobId, entry) + try { + const count = await entry.promise + entry.expiresAt = Date.now() + HERMES_RUN_COUNT_CACHE_TTL_MS + return count + } catch (error) { + if (this.runCountCache.get(jobId) === entry) { + this.runCountCache.delete(jobId) + } + throw error + } + } + + private pruneRunCountCache(now: number): void { + for (const [jobId, entry] of this.runCountCache) { + if (entry.expiresAt <= now) { + this.runCountCache.delete(jobId) + } + } + while (this.runCountCache.size >= HERMES_RUN_COUNT_CACHE_MAX_ENTRIES) { + const oldestJobId = this.runCountCache.keys().next().value + if (oldestJobId === undefined) { + return + } + this.runCountCache.delete(oldestJobId) + } + } +} diff --git a/src/relay/hermes-session-run-database.ts b/src/relay/hermes-session-run-database.ts new file mode 100644 index 00000000000..3cdd8badecb --- /dev/null +++ b/src/relay/hermes-session-run-database.ts @@ -0,0 +1,205 @@ +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { HERMES_STATE_DB } from './external-automation-storage-paths' +import type { HermesSessionRunRef } from './hermes-run-correlation' + +type SqliteStatement = { + get: (...args: unknown[]) => Record | undefined + all: (...args: unknown[]) => Record[] +} + +type SqliteDatabase = { + prepare: (sql: string) => SqliteStatement + close: () => void +} + +type DatabaseConstructor = new ( + path: string, + options?: { readonly?: boolean; fileMustExist?: boolean; timeout?: number } +) => SqliteDatabase + +type NodeSqliteDatabaseSync = new ( + path: string, + options?: { readOnly?: boolean; timeout?: number } +) => SqliteDatabase + +const requireOptional = createRequire(__filename) +let databaseConstructor: DatabaseConstructor | null | undefined + +function getDatabaseConstructor(): DatabaseConstructor | null { + if (databaseConstructor !== undefined) { + return databaseConstructor + } + try { + const loaded = requireOptional('node:sqlite') as { DatabaseSync?: NodeSqliteDatabaseSync } + const DatabaseSync = loaded.DatabaseSync + if (typeof DatabaseSync !== 'function') { + databaseConstructor = null + return databaseConstructor + } + const SqliteDatabaseSync = DatabaseSync + databaseConstructor = class RelaySqliteDatabase { + private readonly db: SqliteDatabase + + constructor( + path: string, + options: { readonly?: boolean; fileMustExist?: boolean; timeout?: number } = {} + ) { + if (options.fileMustExist && !existsSync(path)) { + throw new Error(`SQLite database does not exist: ${path}`) + } + this.db = new SqliteDatabaseSync(path, { + readOnly: options.readonly, + timeout: options.timeout + }) + } + + prepare(sql: string): SqliteStatement { + return this.db.prepare(sql) + } + + close(): void { + this.db.close() + } + } + } catch { + databaseConstructor = null + } + return databaseConstructor +} + +function escapeSqlLike(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_') +} + +function runAtFromUnixSeconds(value: unknown): string | null { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null + } + const date = new Date(value * 1000) + return Number.isNaN(date.getTime()) ? null : date.toISOString() +} + +function formatSessionMessages(messages: Record[]): string | null { + if (messages.length === 0) { + return null + } + return messages + .map((message) => { + const role = typeof message.role === 'string' ? message.role : 'message' + const content = typeof message.content === 'string' ? message.content.trim() : '' + const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : '' + const reasoning = + typeof message.reasoning_content === 'string' + ? message.reasoning_content.trim() + : typeof message.reasoning === 'string' + ? message.reasoning.trim() + : '' + const parts = [ + `## ${role}${toolName ? ` / ${toolName}` : ''}`, + reasoning ? `### Reasoning\n\n${reasoning}` : null, + content || '(empty)' + ].filter(Boolean) + return parts.join('\n\n') + }) + .join('\n\n---\n\n') +} + +export function readHermesSessionDbRunRefs(jobId: string): HermesSessionRunRef[] { + if (!existsSync(HERMES_STATE_DB)) { + return [] + } + const Database = getDatabaseConstructor() + if (!Database) { + return [] + } + try { + const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) + try { + const pattern = `cron\\_${escapeSqlLike(jobId)}\\_%` + const rows = db + .prepare( + `SELECT id, started_at + FROM sessions + WHERE id LIKE ? ESCAPE '\\' + ORDER BY started_at DESC` + ) + .all(pattern) as Record[] + return rows.map((row) => { + const runId = typeof row.id === 'string' ? row.id : `${jobId}:${String(row.started_at)}` + return { + kind: 'session', + id: runId, + job_id: jobId, + run_at: runAtFromUnixSeconds(row.started_at), + run_key: runId.split(`${jobId}_`).at(-1) ?? null + } + }) + } finally { + db.close() + } + } catch { + return [] + } +} + +export function readHermesSessionDbRunById(jobId: string, runId: string): unknown { + if (!existsSync(HERMES_STATE_DB)) { + return null + } + const Database = getDatabaseConstructor() + if (!Database) { + return null + } + try { + const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) + try { + const row = db + .prepare( + `SELECT id, title, started_at, ended_at, end_reason, model, message_count, + input_tokens, output_tokens, estimated_cost_usd + FROM sessions + WHERE id = ?` + ) + .get(runId) as Record | undefined + if (!row) { + return null + } + const messages = db + .prepare( + `SELECT role, content, tool_name, reasoning, reasoning_content + FROM messages + WHERE session_id = ? + ORDER BY timestamp, id` + ) + .all(runId) as Record[] + const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null + const model = typeof row.model === 'string' && row.model.trim() ? row.model.trim() : null + const messageCount = typeof row.message_count === 'number' ? row.message_count : null + const tokenCount = + (typeof row.input_tokens === 'number' ? row.input_tokens : 0) + + (typeof row.output_tokens === 'number' ? row.output_tokens : 0) + const summaryParts = [ + title, + model ? `Model: ${model}` : null, + messageCount !== null ? `${messageCount} messages` : null, + tokenCount > 0 ? `${tokenCount} tokens` : null + ].filter(Boolean) + return { + id: runId, + job_id: jobId, + run_at: runAtFromUnixSeconds(row.started_at), + run_key: runId.split(`${jobId}_`).at(-1) ?? null, + status: typeof row.ended_at === 'number' ? 'completed' : 'unknown', + output_preview: summaryParts.join(' · ') || null, + output_content: formatSessionMessages(messages), + error: null, + output_path: HERMES_STATE_DB + } + } finally { + db.close() + } + } catch { + return null + } +} diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts new file mode 100644 index 00000000000..1dca9bbfe62 --- /dev/null +++ b/src/relay/relay-agent-hook-runtime.ts @@ -0,0 +1,160 @@ +import type { RelayDispatcher } from './dispatcher' +import type { PtyEnvAugmenter, PtyHandler } from './pty-handler' +import { RelayAgentHookServer } from './agent-hook-server' +import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates' +import { PluginOverlayManager } from './plugin-overlay' +import { + AGENT_HOOK_INSTALL_PLUGINS_METHOD, + AGENT_HOOK_REQUEST_REPLAY_METHOD +} from '../shared/agent-hook-relay' +import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' +import { assertPluginSourceUnderByteCap } from './plugin-source-limit' +import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env' +import { + detectExplicitPiAgentKindFromCommand, + isPiCompatibleAgentType +} from '../shared/pi-agent-kind' +import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' +import { relayLogLine } from './relay-diagnostic-log' +import { registerManagedHookInstaller } from './managed-hook-installer' + +export class RelayAgentHookRuntime { + private readonly hookServer: RelayAgentHookServer + private readonly pluginOverlay = new PluginOverlayManager() + + constructor( + private readonly dispatcher: RelayDispatcher, + private readonly ptyHandler: PtyHandler, + sockPath: string, + endpointDir?: string + ) { + this.hookServer = new RelayAgentHookServer({ + endpointDir: endpointDir ?? endpointDirForRelaySocket(sockPath), + forward: (envelope) => publishAgentHookEnvelope(dispatcher, envelope) + }) + } + + async start(): Promise { + try { + await this.hookServer.start({ publishEndpoint: false }) + } catch (error) { + relayLogLine( + `[relay] agent-hook server failed to start: ${error instanceof Error ? error.message : String(error)}` + ) + } + this.registerPtyEnvironment() + this.registerHandlers() + } + + publishEndpointFile(): void { + this.hookServer.publishEndpointFile() + } + + stop(): void { + this.hookServer.stop() + } + + private registerPtyEnvironment(): void { + this.ptyHandler.addEnvAugmenter(() => this.hookServer.buildPtyEnv()) + this.ptyHandler.addEnvAugmenter((context) => this.buildPluginEnvironment(context)) + this.ptyHandler.setExitListener(({ paneKey, id }) => { + if (paneKey) { + this.hookServer.clearPaneState(paneKey) + } + this.pluginOverlay.clearOverlay(paneKey ?? id) + }) + } + + private buildPluginEnvironment(context: Parameters[0]): Record { + const env: Record = {} + const overlayId = context.paneKey ?? context.id + if (this.pluginOverlay.hasOpenCodeSource()) { + const sourceDir = resolveOpenCodeSourceConfigDir(context.env, context.shell) + const dir = this.pluginOverlay.materializeOpenCode(overlayId, sourceDir) + if (dir) { + env.OPENCODE_CONFIG_DIR = dir + env.ORCA_OPENCODE_CONFIG_DIR = dir + if (sourceDir) { + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = sourceDir + } + } + } + if (!this.pluginOverlay.hasPiSource()) { + return env + } + const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(context.env, context.command) + const explicitKind = isPiCompatibleAgentType(context.launchAgent) + ? context.launchAgent + : context.launchAgent === undefined + ? detectExplicitPiAgentKindFromCommand(launchCommandHint) + : null + const kind = explicitKind ?? 'pi' + const hasLaunchCommand = + typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 + if (kind === 'pi') { + const sourceDir = resolvePiSourceAgentDir(context.env, context.shell, 'pi') + const result = this.pluginOverlay.materializePi(overlayId, sourceDir, 'pi', { + materializeDefaultHome: explicitKind === 'pi' + }) + if (result?.sourceAgentDir) { + env.ORCA_PI_SOURCE_AGENT_DIR = result.sourceAgentDir + } + } + if (kind === 'omp' || !hasLaunchCommand) { + const sourceDir = + kind === 'omp' + ? resolvePiSourceAgentDir(context.env, context.shell, 'omp') + : context.env.ORCA_OMP_SOURCE_AGENT_DIR + const result = this.pluginOverlay.materializePi(overlayId, sourceDir, 'omp', { + materializeDefaultHome: explicitKind === 'omp' + }) + if (result?.statusExtensionPath) { + env.ORCA_OMP_STATUS_EXTENSION = result.statusExtensionPath + } + if (result?.sourceAgentDir) { + env.ORCA_OMP_SOURCE_AGENT_DIR = result.sourceAgentDir + } + } + if (kind === 'prime-agent') { + const sourceDir = resolvePiSourceAgentDir(context.env, context.shell, 'prime-agent') + const result = this.pluginOverlay.materializePi(overlayId, sourceDir, 'prime-agent', { + materializeDefaultHome: explicitKind === 'prime-agent' + }) + if (result?.sourceAgentDir) { + env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR = result.sourceAgentDir + } + } + return env + } + + private registerHandlers(): void { + this.dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({ + replayed: this.hookServer.replayCachedPayloadsForPanes() + })) + registerManagedHookInstaller(this.dispatcher) + this.dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { + const opencode = params.opencodePluginSource + const pi = params.piExtensionSource + const omp = params.ompExtensionSource + const primeAgent = params.primeAgentExtensionSource + assertPluginSourceUnderByteCap('opencodePluginSource', opencode) + assertPluginSourceUnderByteCap('piExtensionSource', pi) + assertPluginSourceUnderByteCap('ompExtensionSource', omp) + assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) + this.pluginOverlay.setSources({ + opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, + piExtensionSource: typeof pi === 'string' ? pi : undefined, + ompExtensionSource: typeof omp === 'string' ? omp : undefined, + primeAgentExtensionSource: typeof primeAgent === 'string' ? primeAgent : undefined + }) + return { + installed: { + opencode: this.pluginOverlay.hasOpenCodeSource(), + pi: this.pluginOverlay.hasPiSource('pi'), + omp: this.pluginOverlay.hasPiSource('omp'), + primeAgent: this.pluginOverlay.hasPiSource('prime-agent') + } + } + }) + } +} diff --git a/src/relay/relay-connect-channel.ts b/src/relay/relay-connect-channel.ts new file mode 100644 index 00000000000..3bf0b7ae870 --- /dev/null +++ b/src/relay/relay-connect-channel.ts @@ -0,0 +1,104 @@ +import { createConnection } from 'node:net' +import { DispatcherClientWriter } from './dispatcher-client-writer' +import { RELAY_SENTINEL } from './protocol' +import { readLaunchVersion, runConnectHandshake } from './relay-handshake' + +const CONNECT_TIMEOUT_MS = 5_000 + +// Why: bridges a new SSH channel to the relay process that owns the live PTYs. +export function runRelayConnectChannel(sockPath: string, endpointCredential?: string): void { + const myVersion = readLaunchVersion() + const sock = createConnection({ path: sockPath }) + const stdoutWriter = new DispatcherClientWriter( + (data, onSettled) => + process.stdout.write(data, (error) => { + onSettled(error ? { ok: false, error } : { ok: true }) + }), + { + supportsWriteCallback: true, + writableLength: () => process.stdout.writableLength, + writableHighWaterMark: () => process.stdout.writableHighWaterMark, + waitWriteDrain: (callback) => { + process.stdout.once('drain', callback) + return () => process.stdout.off('drain', callback) + } + }, + () => { + sock.destroy() + process.exit(1) + } + ) + + const connectTimeout = setTimeout(() => { + process.stderr.write(`[relay-connect] Connection timed out after ${CONNECT_TIMEOUT_MS}ms\n`) + sock.destroy() + process.exit(1) + }, CONNECT_TIMEOUT_MS) + + sock.on('connect', () => { + clearTimeout(connectTimeout) + runConnectHandshake( + sock, + myVersion, + { + onAccepted: (leftover: Buffer) => { + stdoutWriter.enqueue('control', () => Buffer.from(RELAY_SENTINEL), RELAY_SENTINEL.length) + if (leftover.length > 0) { + stdoutWriter.enqueue('control', () => leftover, leftover.length) + } + process.stdin.pipe(sock) + sock.on('data', (data: Buffer) => { + sock.pause() + let offset = 0 + const writeNext = (): void => { + if (offset >= data.length) { + sock.resume() + return + } + const bytes = Math.min(stdoutWriter.producerFrameCapacity, data.length - offset) + if (bytes <= 0) { + stdoutWriter.close(new Error('Relay stdout has no producer capacity')) + return + } + const chunk = data.subarray(offset, offset + bytes) + if ( + !stdoutWriter.enqueue( + 'ordinary', + () => chunk, + chunk.length, + (result) => { + if (!result.ok) { + return + } + offset += bytes + writeNext() + } + ) + ) { + stdoutWriter.close(new Error('Relay stdout bridge capacity exceeded')) + } + } + writeNext() + }) + } + }, + endpointCredential + ) + }) + + // Why: Node swallows EPIPE on stdout, so close the bridge and enter relay grace promptly. + process.stdout.on('error', () => { + stdoutWriter.close(new Error('Relay stdout closed')) + }) + + sock.on('error', (error) => { + clearTimeout(connectTimeout) + process.stderr.write(`[relay-connect] Socket error: ${error.message}\n`) + process.exit(1) + }) + + sock.on('close', async () => { + await stdoutWriter.waitForIdle() + process.exit(0) + }) +} diff --git a/src/relay/relay-daemon.ts b/src/relay/relay-daemon.ts new file mode 100644 index 00000000000..6d4968c2ea9 --- /dev/null +++ b/src/relay/relay-daemon.ts @@ -0,0 +1,154 @@ +import { installRelayLogRotation } from './rotating-log-writer' +import { readLaunchVersion } from './relay-handshake' +import type { RelayLaunchOptions } from './relay-launch-options' +import { RELAY_EMPTY_DETACHED_STARTUP_GRACE_MS, RELAY_IDLE_GRACE_MS } from './relay-launch-options' +import { relayLogLine } from './relay-diagnostic-log' +import { RelayPrimaryChannel } from './relay-primary-channel' +import { RelayRuntimeServices } from './relay-runtime-services' +import { RelayAgentHookRuntime } from './relay-agent-hook-runtime' +import { RelaySocketOwnership } from './relay-socket-ownership' +import { RelayReconnectListener } from './relay-reconnect-listener' +import { RelayGraceLifecycle } from './relay-grace-lifecycle' +import { SKILL_RELAY_CAPABILITIES } from './skill-install-handler' + +export async function runRelayDaemon( + options: RelayLaunchOptions, + endpointCredential: string | undefined +): Promise { + if (options.detached && options.logFile) { + installRelayLogRotation(options.logFile) + } + + const socketOwnership = new RelaySocketOwnership(options.sockPath) + process.on('uncaughtException', (error) => { + relayLogLine(`[relay] Uncaught exception: ${error.message}\n${error.stack}`) + socketOwnership.cleanup() + process.exit(1) + }) + process.on('unhandledRejection', (reason) => { + relayLogLine(`[relay] Unhandled rejection: ${String(reason)}`) + }) + + const primaryChannel = new RelayPrimaryChannel() + const launchVersion = readLaunchVersion() + const runtime = new RelayRuntimeServices( + primaryChannel.dispatcher, + options.graceTimeMs, + launchVersion + ) + let reconnectListener: RelayReconnectListener | null = null + const agentHooks = new RelayAgentHookRuntime( + primaryChannel.dispatcher, + runtime.ptyHandler, + options.sockPath, + options.endpointDir + ) + const lifecycle = new RelayGraceLifecycle({ + dispatcher: primaryChannel.dispatcher, + ptyHandler: runtime.ptyHandler, + detached: options.detached, + emptyDetachedStartupGraceMs: RELAY_EMPTY_DETACHED_STARTUP_GRACE_MS, + idleRelayGraceMs: RELAY_IDLE_GRACE_MS, + readSocketClientCount: () => reconnectListener?.clientCount ?? 0, + hasAcceptedSocketClient: () => reconnectListener?.hasAcceptedClient ?? false, + ownsSocketPath: () => socketOwnership.owned, + disposeOwnedProcesses: () => runtime.disposeOwnedProcesses(), + disposeRuntime: () => { + primaryChannel.dispatcher.dispose() + runtime.disposeHandlers() + agentHooks.stop() + socketOwnership.closeAndCleanup() + } + }) + + await agentHooks.start() + reconnectListener = new RelayReconnectListener( + primaryChannel.dispatcher, + socketOwnership, + launchVersion, + endpointCredential, + { + detachPrimaryInput: () => primaryChannel.detachInput(), + cancelGrace: (reason) => lifecycle.cancel(reason), + onLastClientClosed: () => { + if (!primaryChannel.isAlive) { + lifecycle.start('socket client closed') + } + } + } + ) + const startedAt = Date.now() + registerRelayStatus( + primaryChannel, + runtime, + reconnectListener, + socketOwnership, + lifecycle, + options, + startedAt + ) + + try { + await reconnectListener.start() + agentHooks.publishEndpointFile() + } catch { + process.exit(1) + return + } + + primaryChannel.startOutputFailureHandling() + if (options.detached) { + lifecycle.start('detached startup') + } else { + primaryChannel.startInput({ + onData: () => lifecycle.cancel('stdin data'), + onDisconnect: (reason) => { + if ((reconnectListener?.clientCount ?? 0) === 0) { + lifecycle.start(reason) + } + } + }) + } + lifecycle.installProcessLifecycle() + primaryChannel.writeSentinel() + if (options.detached) { + primaryChannel.detachPrimaryClient() + } +} + +function registerRelayStatus( + primaryChannel: RelayPrimaryChannel, + runtime: RelayRuntimeServices, + reconnectListener: RelayReconnectListener, + socketOwnership: RelaySocketOwnership, + lifecycle: RelayGraceLifecycle, + options: RelayLaunchOptions, + startedAt: number +): void { + primaryChannel.dispatcher.onRequest('relay.status', async () => ({ + capabilities: SKILL_RELAY_CAPABILITIES, + pid: process.pid, + uptimeMs: Date.now() - startedAt, + detached: options.detached, + stdoutAlive: primaryChannel.isAlive, + memory: process.memoryUsage(), + ptys: { active: runtime.ptyHandler.activePtyCount }, + ptySourceCredit: { + enabled: true, + session: runtime.ptyConsumerSessionAdapter.getDebugSnapshot(), + publication: runtime.ptySourcePublication.getDebugSnapshot() + }, + socket: { + path: options.sockPath, + owned: socketOwnership.owned, + listening: socketOwnership.server?.listening ?? false, + clients: reconnectListener.clientCount, + acceptedConnections: reconnectListener.acceptedConnections + }, + grace: { + active: runtime.ptyHandler.graceTimerActive, + deadlineAt: lifecycle.deadlineAt, + reason: lifecycle.reason + } + })) +} diff --git a/src/relay/relay-grace-lifecycle.ts b/src/relay/relay-grace-lifecycle.ts new file mode 100644 index 00000000000..116b8873115 --- /dev/null +++ b/src/relay/relay-grace-lifecycle.ts @@ -0,0 +1,160 @@ +import type { PtyHandler } from './pty-handler' +import { + applyRelayGraceTimeConfiguration, + decideRelayGrace, + type RelayGraceBranch +} from './relay-grace-branch' +import { relayLogLine } from './relay-diagnostic-log' +import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../shared/ssh-types' +import type { RelayDispatcher } from './dispatcher' + +type RelayGraceLifecycleOptions = { + dispatcher: RelayDispatcher + ptyHandler: PtyHandler + detached: boolean + emptyDetachedStartupGraceMs: number + idleRelayGraceMs: number + readSocketClientCount: () => number + hasAcceptedSocketClient: () => boolean + ownsSocketPath: () => boolean + disposeOwnedProcesses: () => Promise + disposeRuntime: () => void +} + +export class RelayGraceLifecycle { + private graceDeadlineAt: number | null = null + private graceReason: string | null = null + private graceBranch: RelayGraceBranch | null = null + private shutdownInFlight = false + private stopPoolWatch = (): void => {} + private stopPoolActiveWatch = (): void => {} + + constructor(private readonly options: RelayGraceLifecycleOptions) { + options.dispatcher.onNotification(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, (params) => { + this.configure(params) + }) + options.dispatcher.onRequest(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, async (params) => + this.configure(params) + ) + } + + get deadlineAt(): number | null { + return this.graceDeadlineAt + } + + get reason(): string | null { + return this.graceReason + } + + cancel(reason: string): void { + if (this.options.ptyHandler.graceTimerActive) { + relayLogLine(`[relay] Grace canceled: ${reason}`) + } + this.graceDeadlineAt = null + this.graceReason = null + this.graceBranch = null + this.options.ptyHandler.cancelGraceTimer() + } + + start(reason: string, options?: { retryDeferredShutdown?: boolean }): void { + const decision = decideRelayGrace({ + configuredGraceMs: this.options.ptyHandler.configuredGraceTimeMs, + relayIdle: this.isRelayIdle(), + detached: this.options.detached, + hasAcceptedSocketClient: this.options.hasAcceptedSocketClient(), + activePtyCount: this.options.ptyHandler.activePtyCount, + retryDeferredShutdown: options?.retryDeferredShutdown === true, + emptyDetachedStartupGraceMs: this.options.emptyDetachedStartupGraceMs, + idleRelayGraceMs: this.options.idleRelayGraceMs + }) + this.graceBranch = decision.branch + this.graceDeadlineAt = decision.timeoutMs === 0 ? null : Date.now() + decision.timeoutMs + this.graceReason = reason + relayLogLine( + `[relay] Grace started (${reason}): timeoutMs=${decision.timeoutMs}, branch=${this.graceBranch}, ptys=${this.options.ptyHandler.activePtyCount}, clients=${this.options.readSocketClientCount()}` + ) + this.options.ptyHandler.startGraceTimer(() => { + if (this.graceBranch === 'idle-no-ptys' && !this.isRelayIdle()) { + relayLogLine(`[relay] Grace expired (${reason}) but relay is no longer idle; re-evaluating`) + this.start(reason) + return + } + relayLogLine(`[relay] Grace expired (${reason}); shutting down`) + this.shutdown() + }, decision.timeoutMs) + } + + installProcessLifecycle(): void { + this.stopPoolWatch = this.options.ptyHandler.onPtyPoolEmpty(() => { + if (this.graceReason !== null && this.graceDeadlineAt === null && !this.shutdownInFlight) { + this.start('last pty exited') + } + }) + this.stopPoolActiveWatch = this.options.ptyHandler.onPtyPoolActive(() => { + if ( + this.graceBranch === 'idle-no-ptys' && + this.graceReason !== null && + !this.shutdownInFlight + ) { + this.start(this.graceReason) + } + }) + process.on('SIGTERM', this.shutdown) + process.on('SIGINT', this.shutdown) + process.on('SIGHUP', () => { + relayLogLine('[relay] Received SIGHUP (SSH session dropped), ignoring') + }) + process.on('exit', (code) => { + relayLogLine(`[relay] Process exiting with code ${code}`) + }) + } + + readonly shutdown = (): void => { + if (this.shutdownInFlight) { + return + } + this.shutdownInFlight = true + relayLogLine( + `[relay] Shutdown: ptys=${this.options.ptyHandler.activePtyCount}, clients=${this.options.readSocketClientCount()}, ownsSocket=${this.options.ownsSocketPath()}` + ) + this.graceDeadlineAt = null + this.graceReason = null + this.graceBranch = null + void this.options.ptyHandler + .dispose() + .then(async () => { + await this.options.disposeOwnedProcesses() + this.stopPoolWatch() + this.stopPoolActiveWatch() + this.options.disposeRuntime() + process.exit(0) + }) + .catch((error) => { + this.shutdownInFlight = false + relayLogLine( + `[relay] Shutdown deferred: ${error instanceof Error ? error.message : String(error)}` + ) + if (this.options.readSocketClientCount() === 0) { + this.start('shutdown deferred', { retryDeferredShutdown: true }) + } + }) + } + + private configure(params: Record): { graceTimeMs: number } { + return applyRelayGraceTimeConfiguration(params.graceTimeSeconds, { + readConfiguredGraceMs: () => this.options.ptyHandler.configuredGraceTimeMs, + writeConfiguredGraceMs: (graceMs) => this.options.ptyHandler.setGraceTimeMs(graceMs), + isGraceTimerArmed: () => this.graceDeadlineAt !== null && this.graceReason !== null, + isShutdownInFlight: () => this.shutdownInFlight, + readGraceBranch: () => this.graceBranch, + startGrace: (reason, startOptions) => this.start(reason, startOptions) + }) + } + + private isRelayIdle(): boolean { + return ( + this.options.ptyHandler.activePtyCount === 0 && + this.options.ptyHandler.pendingPtyCreationCount === 0 + ) + } +} diff --git a/src/relay/relay-launch-options.test.ts b/src/relay/relay-launch-options.test.ts new file mode 100644 index 00000000000..bfcbf9143fc --- /dev/null +++ b/src/relay/relay-launch-options.test.ts @@ -0,0 +1,82 @@ +import { chmodSync, mkdtempSync, statSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseRelayLaunchOptions, readRelayEndpointCredential } from './relay-launch-options' + +describe('relay launch options', () => { + const temporaryDirectories: string[] = [] + + afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })) + ) + }) + + it('preserves daemon mode flags and grace seconds conversion', () => { + expect( + parseRelayLaunchOptions([ + 'node', + 'relay.js', + '--detached', + '--connect', + '--grace-time', + '17', + '--sock-path', + 'relay-endpoint', + '--endpoint-dir', + 'hooks', + '--log-file', + 'relay.log', + '--credential-file', + 'relay.credential' + ]) + ).toEqual({ + graceTimeMs: 17_000, + connectMode: true, + detached: true, + cliMode: false, + sockPath: 'relay-endpoint', + endpointDir: 'hooks', + logFile: 'relay.log', + credentialFile: 'relay.credential' + }) + }) + + it('keeps zero grace unlimited and ignores invalid replacements', () => { + expect( + parseRelayLaunchOptions(['node', 'relay.js', '--grace-time', '0', '--grace-time', '-1']) + .graceTimeMs + ).toBe(0) + }) + + it('validates and restricts the endpoint credential file', () => { + const directory = mkdtempSync(join(tmpdir(), 'relay-launch-options-')) + temporaryDirectories.push(directory) + const credentialFile = join(directory, 'endpoint.credential') + const credential = 'a'.repeat(32) + writeFileSync(credentialFile, `${credential}\n`) + if (process.platform !== 'win32') { + chmodSync(credentialFile, 0o644) + } + + expect(readRelayEndpointCredential(credentialFile)).toBe(credential) + if (process.platform !== 'win32') { + expect(statSync(credentialFile).mode & 0o777).toBe(0o600) + } + }) + + it('rejects credentials that cannot authenticate a reconnect client', () => { + const directory = mkdtempSync(join(tmpdir(), 'relay-launch-options-')) + temporaryDirectories.push(directory) + const credentialFile = join(directory, 'endpoint.credential') + writeFileSync(credentialFile, 'too-short') + + expect(() => readRelayEndpointCredential(credentialFile)).toThrow( + 'Relay endpoint credential is missing or invalid' + ) + }) +}) diff --git a/src/relay/relay-launch-options.ts b/src/relay/relay-launch-options.ts new file mode 100644 index 00000000000..95fafae818a --- /dev/null +++ b/src/relay/relay-launch-options.ts @@ -0,0 +1,102 @@ +import { chmodSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' + +const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 +const DEFAULT_SOCKET_NAME = 'relay.sock' + +export const RELAY_EMPTY_DETACHED_STARTUP_GRACE_MS = parseNonNegativeIntEnv( + 'ORCA_RELAY_EMPTY_STARTUP_GRACE_MS', + 60_000 +) +// Why: a relay holding zero PTYs preserves nothing, so an unlimited grace only accumulates idle daemons. +// The env override is test-only — the remote relay is launched over a non-interactive SSH exec channel that carries no client env. +export const RELAY_IDLE_GRACE_MS = parseNonNegativeIntEnv('ORCA_RELAY_IDLE_GRACE_MS', 15 * 60_000) + +export type RelayLaunchOptions = { + graceTimeMs: number + connectMode: boolean + detached: boolean + cliMode: boolean + sockPath: string + endpointDir?: string + logFile?: string + credentialFile?: string +} + +export function parseRelayLaunchOptions(argv: string[]): RelayLaunchOptions { + let graceTimeMs = DEFAULT_GRACE_MS + let connectMode = false + let detached = false + let cliMode = false + let sockPath = '' + let endpointDir: string | undefined + let logFile: string | undefined + let credentialFile: string | undefined + for (let i = 2; i < argv.length; i++) { + if (argv[i] === '--grace-time' && argv[i + 1]) { + const parsed = Number.parseInt(argv[i + 1], 10) + // Why: flag is seconds (internally ms); 0 keeps the relay alive until explicitly terminated for synced workspaces. + if (!Number.isNaN(parsed) && parsed >= 0) { + graceTimeMs = parsed * 1000 + } + i++ + } else if (argv[i] === '--connect') { + connectMode = true + } else if (argv[i] === '--orca-cli') { + cliMode = true + } else if (argv[i] === '--detached') { + detached = true + } else if (argv[i] === '--sock-path' && argv[i + 1]) { + sockPath = argv[i + 1] + i++ + } else if (argv[i] === '--endpoint-dir' && argv[i + 1]) { + endpointDir = argv[i + 1] + i++ + } else if (argv[i] === '--log-file' && argv[i + 1]) { + logFile = argv[i + 1] + i++ + } else if (argv[i] === '--credential-file' && argv[i + 1]) { + credentialFile = argv[i + 1] + i++ + } + } + if (!sockPath) { + sockPath = join(process.cwd(), DEFAULT_SOCKET_NAME) + } + return { + graceTimeMs, + connectMode, + detached, + cliMode, + sockPath, + endpointDir, + logFile, + credentialFile + } +} + +export function readRelayEndpointCredential( + credentialFile: string | undefined +): string | undefined { + if (!credentialFile) { + return undefined + } + const credential = readFileSync(credentialFile, 'utf8').trim() + if (!/^[A-Za-z0-9_-]{32,256}$/.test(credential)) { + throw new Error('Relay endpoint credential is missing or invalid') + } + if (process.platform !== 'win32') { + chmodSync(credentialFile, 0o600) + } + return credential +} + +function parseNonNegativeIntEnv(name: string, fallback: number): number { + const raw = process.env[name] + if (raw === undefined) { + return fallback + } + const parsed = Number.parseInt(raw, 10) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback +} diff --git a/src/relay/relay-orca-cli-channel.ts b/src/relay/relay-orca-cli-channel.ts new file mode 100644 index 00000000000..2afbd363fe5 --- /dev/null +++ b/src/relay/relay-orca-cli-channel.ts @@ -0,0 +1,241 @@ +import { createConnection } from 'node:net' +import { DispatcherClientWriter } from './dispatcher-client-writer' +import { pickRemoteCliEnv } from './remote-cli-env' +import { shouldReadRemoteCliStdin } from './remote-cli-stdin' +import { prepareRemoteArtifactCliInput } from './remote-artifact-cli-input' +import { + assertRemoteArtifactCliForwardingFits, + type RemoteArtifactCliForwardingParams +} from './remote-artifact-cli-forwarding' +import { + FrameDecoder, + MessageType, + encodeJsonRpcFrame, + parseJsonRpcMessage, + type DecodedFrame, + type JsonRpcResponse +} from './protocol' +import { readLaunchVersion, runConnectHandshake } from './relay-handshake' + +const CONNECT_TIMEOUT_MS = 5_000 + +export async function runRelayOrcaCliChannel( + sockPath: string, + argv: string[], + endpointCredential?: string +): Promise { + const myVersion = readLaunchVersion() + let preparedArtifact: Awaited> + try { + preparedArtifact = await prepareRemoteArtifactCliInput(argv, process.cwd()) + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + return + } + const stdin = + preparedArtifact.stdin ?? + (shouldReadRemoteCliStdin(argv) ? await readOrcaCliStdin() : undefined) + const env = pickRemoteCliEnv(process.env) + const requestParams: RemoteArtifactCliForwardingParams = { + argv, + cwd: process.cwd(), + env, + ...(stdin !== undefined ? { stdin } : {}), + ...(preparedArtifact.artifactInput ? { artifactInput: preparedArtifact.artifactInput } : {}) + } + if (preparedArtifact.artifactInput) { + try { + assertRemoteArtifactCliForwardingFits(requestParams) + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + return + } + } + const sock = createConnection({ path: sockPath }) + const stdoutWriter = new DispatcherClientWriter( + (data, onSettled) => + process.stdout.write(data, (error) => { + onSettled(error ? { ok: false, error } : { ok: true }) + }), + { + supportsWriteCallback: true, + writableLength: () => process.stdout.writableLength, + writableHighWaterMark: () => process.stdout.writableHighWaterMark, + waitWriteDrain: (callback) => { + process.stdout.once('drain', callback) + return () => process.stdout.off('drain', callback) + } + }, + () => process.exit(1) + ) + let nextSeq = 1 + let highestReceivedSeq = 0 + const requestId = 1 + const postOutputRequestId = 2 + let initialExitCode = 0 + + const sendRequest = (): void => { + const frame = encodeJsonRpcFrame( + { + jsonrpc: '2.0', + id: requestId, + method: 'orca.cli', + params: requestParams + }, + nextSeq++, + highestReceivedSeq + ) + sock.write(frame) + } + + const finish = (exitCode: number): void => { + sock.destroy() + process.exit(exitCode) + } + + const sendPostOutput = (postOutput: unknown): void => { + sock.write( + encodeJsonRpcFrame( + { + jsonrpc: '2.0', + id: postOutputRequestId, + method: 'orca.cli.postOutput', + params: { postOutput, env: pickRemoteCliEnv(process.env) } + }, + nextSeq++, + highestReceivedSeq + ) + ) + } + + const writeOutput = ( + result: { stdout?: unknown; stderr?: unknown }, + onFlushed: (error?: Error) => void + ): void => { + let pending = 0 + let completed = false + const settle = (error?: Error): void => { + if (completed) { + return + } + if (error) { + completed = true + onFlushed(error) + return + } + pending -= 1 + if (pending === 0) { + completed = true + onFlushed() + } + } + if (typeof result.stdout === 'string' && result.stdout.length > 0) { + pending += 1 + const output = Buffer.from(result.stdout) + stdoutWriter.enqueue( + 'control', + () => output, + output.length, + (settlement) => settle(settlement.ok ? undefined : settlement.error) + ) + } + if (typeof result.stderr === 'string' && result.stderr.length > 0) { + pending += 1 + process.stderr.write(result.stderr, 'utf8', (error) => settle(error ?? undefined)) + } + if (pending === 0) { + completed = true + onFlushed() + } + } + + const decoder = new FrameDecoder((frame: DecodedFrame) => { + if (frame.id > highestReceivedSeq) { + highestReceivedSeq = frame.id + } + if (frame.type !== MessageType.Regular) { + return + } + const msg = parseJsonRpcMessage(frame.payload) + if ( + !('id' in msg) || + (msg.id !== requestId && msg.id !== postOutputRequestId) || + !('result' in msg || 'error' in msg) + ) { + return + } + const response = msg as JsonRpcResponse + if (response.error) { + process.stderr.write(`${response.error.message}\n`) + finish(1) + return + } + if (response.id === postOutputRequestId) { + finish(initialExitCode) + return + } + const result = (response.result ?? {}) as { + stdout?: unknown + stderr?: unknown + exitCode?: unknown + postOutput?: unknown + } + initialExitCode = typeof result.exitCode === 'number' ? result.exitCode : 0 + writeOutput(result, (error) => { + if (error) { + finish(1) + return + } + if (result.postOutput === undefined) { + finish(initialExitCode) + return + } + sendPostOutput(result.postOutput) + }) + }) + + const connectTimeout = setTimeout(() => { + process.stderr.write(`[orca-cli] Relay connection timed out after ${CONNECT_TIMEOUT_MS}ms\n`) + sock.destroy() + process.exit(1) + }, CONNECT_TIMEOUT_MS) + + sock.on('connect', () => { + clearTimeout(connectTimeout) + runConnectHandshake( + sock, + myVersion, + { + onAccepted: (leftover) => { + if (leftover.length > 0) { + decoder.feed(leftover) + } + sock.on('data', (chunk) => + decoder.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + ) + sendRequest() + } + }, + endpointCredential + ) + }) + + sock.on('error', (error) => { + clearTimeout(connectTimeout) + process.stderr.write(`[orca-cli] Relay socket error: ${error.message}\n`) + process.exit(1) + }) +} + +async function readOrcaCliStdin(): Promise { + if (process.stdin.isTTY) { + return undefined + } + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) + } + return Buffer.concat(chunks).toString('utf8') +} diff --git a/src/relay/relay-primary-channel.ts b/src/relay/relay-primary-channel.ts new file mode 100644 index 00000000000..9b2e50eaaa1 --- /dev/null +++ b/src/relay/relay-primary-channel.ts @@ -0,0 +1,126 @@ +import { closeSync, openSync } from 'node:fs' +import { RelayDispatcher } from './dispatcher' +import { RELAY_SENTINEL } from './protocol' + +export class RelayPrimaryChannel { + readonly dispatcher: RelayDispatcher + private stdoutAlive = true + private readonly stdoutDrainWaiters = new Set<() => void>() + + constructor() { + process.stdout.on('drain', this.flushStdoutDrainWaiters) + this.dispatcher = new RelayDispatcher( + (data, onSettled) => { + if (!this.stdoutAlive) { + onSettled({ ok: false, error: new Error('Relay stdout is closed') }) + return false + } + try { + return process.stdout.write(data, (error) => { + onSettled(error ? { ok: false, error } : { ok: true }) + }) + } catch (error) { + this.stdoutAlive = false + this.flushStdoutDrainWaiters() + onSettled({ + ok: false, + error: error instanceof Error ? error : new Error(String(error)) + }) + return false + } + }, + { + supportsWriteCallback: true, + writableLength: () => process.stdout.writableLength, + writableHighWaterMark: () => process.stdout.writableHighWaterMark, + waitWriteDrain: (callback) => { + if (!this.stdoutAlive) { + callback() + return + } + this.stdoutDrainWaiters.add(callback) + return () => this.stdoutDrainWaiters.delete(callback) + }, + close: () => this.closePrimaryDescriptors() + }, + undefined, + { pauseReads: () => process.stdin.pause(), resumeReads: () => process.stdin.resume() } + ) + } + + get isAlive(): boolean { + return this.stdoutAlive + } + + detachInput(): void { + process.stdin.pause() + process.stdin.removeAllListeners('data') + } + + startOutputFailureHandling(): void { + process.stdout.on('error', () => { + this.markClosed('peer-closed') + }) + } + + startInput(callbacks: { onData: () => void; onDisconnect: (reason: string) => void }): void { + process.stdin.on('data', (chunk: Buffer) => { + callbacks.onData() + this.dispatcher.feed(chunk) + }) + process.stdin.on('end', () => { + this.markClosed('peer-closed') + callbacks.onDisconnect('stdin ended') + }) + process.stdin.on('error', () => { + this.markClosed('peer-closed') + callbacks.onDisconnect('stdin error') + }) + } + + writeSentinel(): void { + this.dispatcher.writePrimaryBytes(Buffer.from(RELAY_SENTINEL)) + } + + detachPrimaryClient(): void { + this.stdoutAlive = false + this.flushStdoutDrainWaiters() + this.dispatcher.invalidateClient() + } + + private readonly flushStdoutDrainWaiters = (): void => { + for (const callback of Array.from(this.stdoutDrainWaiters)) { + this.stdoutDrainWaiters.delete(callback) + callback() + } + } + + private markClosed(cause: 'peer-closed'): void { + this.stdoutAlive = false + this.flushStdoutDrainWaiters() + this.dispatcher.invalidateClient(cause) + } + + private closePrimaryDescriptors(): void { + this.stdoutAlive = false + this.flushStdoutDrainWaiters() + for (const fd of [process.stdin.fd, process.stdout.fd]) { + try { + closeSync(fd) + } catch { + // Already closed by the peer. + } + } + const devNull = process.platform === 'win32' ? 'NUL' : '/dev/null' + try { + openSync(devNull, 'r') + } catch { + // Best-effort pin of the lowest free descriptor. + } + try { + openSync(devNull, 'w') + } catch { + // Best-effort pin of the next free descriptor. + } + } +} diff --git a/src/relay/relay-reconnect-listener.ts b/src/relay/relay-reconnect-listener.ts new file mode 100644 index 00000000000..0b0c3dcfd46 --- /dev/null +++ b/src/relay/relay-reconnect-listener.ts @@ -0,0 +1,131 @@ +import type { Socket } from 'node:net' +import type { RelayDispatcher } from './dispatcher' +import { setupDaemonHandshake } from './relay-handshake' +import { relayLogLine } from './relay-diagnostic-log' +import type { RelaySocketOwnership } from './relay-socket-ownership' + +type RelayReconnectCallbacks = { + detachPrimaryInput: () => void + cancelGrace: (reason: string) => void + onLastClientClosed: () => void +} + +export class RelayReconnectListener { + private readonly socketClients = new Map() + private acceptedSocketConnections = 0 + private acceptedSocketClient = false + + constructor( + private readonly dispatcher: RelayDispatcher, + readonly ownership: RelaySocketOwnership, + private readonly launchVersion: string, + private readonly endpointCredential: string | undefined, + private readonly callbacks: RelayReconnectCallbacks + ) {} + + get clientCount(): number { + return this.socketClients.size + } + + get hasAcceptedClient(): boolean { + return this.acceptedSocketClient + } + + get acceptedConnections(): number { + return this.acceptedSocketConnections + } + + async start(): Promise { + await this.ownership.listen((socket) => this.acceptConnection(socket)) + } + + private acceptConnection(socket: Socket): void { + setupDaemonHandshake(socket, { + launchVersion: this.launchVersion, + endpointCredential: this.endpointCredential, + onAccepted: (acceptedSocket, leftover) => this.attachAcceptedSocket(acceptedSocket, leftover) + }) + socket.on('end', () => { + if (!socket.destroyed) { + socket.destroy() + } + }) + socket.on('error', () => { + // The close event owns client cleanup and grace startup. + }) + socket.on('close', () => this.handleSocketClose(socket)) + } + + private attachAcceptedSocket(socket: Socket, leftover: Buffer): void { + this.callbacks.detachPrimaryInput() + this.acceptedSocketClient = true + this.acceptedSocketConnections++ + relayLogLine( + `[relay] Socket client accepted (clients=${this.socketClients.size + 1}, accepted=${this.acceptedSocketConnections})` + ) + this.callbacks.cancelGrace('socket client accepted') + + const drainWaiters = new Set<() => void>() + const flushDrainWaiters = (): void => { + for (const callback of Array.from(drainWaiters)) { + drainWaiters.delete(callback) + callback() + } + } + socket.on('drain', flushDrainWaiters) + socket.on('close', flushDrainWaiters) + socket.on('error', flushDrainWaiters) + const clientId = this.dispatcher.attachClient( + (data, onSettled) => { + if (!socket.destroyed) { + return socket.write(data, (error) => { + onSettled(error ? { ok: false, error } : { ok: true }) + }) + } + onSettled({ ok: false, error: new Error('Relay socket is closed') }) + return false + }, + { + supportsWriteCallback: true, + writableLength: () => socket.writableLength, + writableHighWaterMark: () => socket.writableHighWaterMark, + close: () => socket.destroy(), + waitWriteDrain: (callback) => { + if (socket.destroyed) { + callback() + return + } + drainWaiters.add(callback) + return () => drainWaiters.delete(callback) + } + }, + { + principal: `relay-endpoint:${this.launchVersion}`, + authenticated: this.endpointCredential !== undefined, + allowSessionOwner: this.endpointCredential !== undefined, + authenticationKind: this.endpointCredential ? 'endpoint-credential' : 'unproved' + }, + { pauseReads: () => socket.pause(), resumeReads: () => socket.resume() } + ) + this.socketClients.set(socket, clientId) + if (leftover.length > 0) { + this.dispatcher.feedClient(clientId, leftover) + } + socket.on('data', (chunk: Buffer) => { + this.callbacks.cancelGrace('socket client data') + this.dispatcher.feedClient(clientId, chunk) + }) + } + + private handleSocketClose(socket: Socket): void { + const clientId = this.socketClients.get(socket) + this.socketClients.delete(socket) + if (clientId !== undefined) { + this.dispatcher.detachClient(clientId, 'peer-closed') + } + relayLogLine(`[relay] Socket client closed (clients=${this.socketClients.size})`) + if (this.socketClients.size === 0) { + this.callbacks.onLastClientClosed() + } + } +} diff --git a/src/relay/relay-runtime-services.ts b/src/relay/relay-runtime-services.ts new file mode 100644 index 00000000000..36276ed9b70 --- /dev/null +++ b/src/relay/relay-runtime-services.ts @@ -0,0 +1,143 @@ +import { homedir } from 'node:os' +import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol' +import type { RelayDispatcher } from './dispatcher' +import { RelayContext, expandTilde } from './context' +import { PtyHandler } from './pty-handler' +import { FsHandler } from './fs-handler' +import { GitHandler } from './git-handler' +import { PreflightHandler } from './preflight-handler' +import { ExternalAutomationsHandler } from './external-automations-handler' +import { PortScanHandler } from './port-scan-handler' +import { AgentExecHandler } from './agent-exec-handler' +import { WorkspaceSessionHandler } from './workspace-session-handler' +import { AiVaultHandler } from './ai-vault-handler' +import { createRelayAiVaultService } from './ai-vault-service-factory' +import { registerRelayPluginHostCallHandlers } from './plugin-host-call-handler' +import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' +import { RelayPtySourcePublication } from './relay-pty-source-publication' +import { SkillInstallHandler } from './skill-install-handler' +import { relayLogLine } from './relay-diagnostic-log' +import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' + +export class RelayRuntimeServices { + readonly ptyHandler: PtyHandler + readonly ptyConsumerSessionAdapter: SshPtyConsumerSessionAdapter + readonly ptySourcePublication: RelayPtySourcePublication + readonly fsHandler: FsHandler + readonly gitHandler: GitHandler + readonly skillInstallHandler: SkillInstallHandler + private readonly aiVaultService: ReturnType | null + private readonly registeredHandlers: readonly unknown[] + + constructor( + readonly dispatcher: RelayDispatcher, + graceTimeMs: number, + launchVersion: string + ) { + const context = new RelayContext() + this.registerSessionHandlers(context) + this.ptyHandler = new PtyHandler(dispatcher, graceTimeMs) + this.ptyConsumerSessionAdapter = new SshPtyConsumerSessionAdapter( + dispatcher, + launchVersion, + (id, paused) => this.ptyHandler.setConsumerDeliveryPaused(id, paused), + (id) => this.ptyHandler.handleSourceCreditAvailable(id) + ) + this.ptySourcePublication = new RelayPtySourcePublication( + dispatcher, + this.ptyConsumerSessionAdapter, + (id) => this.ptyHandler.handleSourcePublicationCapacity(id) + ) + this.ptyHandler.setSourcePublication(this.ptySourcePublication) + + this.fsHandler = new FsHandler(dispatcher, context) + const watchRegistry = this.fsHandler.getWatchRegistry() + this.ptyHandler.setWorktreeRemovalCoordinator(watchRegistry) + watchRegistry.setWorktreePtyTeardown((rootPath) => + this.ptyHandler.shutdownForWorktreePath(rootPath) + ) + this.gitHandler = new GitHandler(dispatcher, context, watchRegistry) + const preflightHandler = new PreflightHandler(dispatcher) + this.skillInstallHandler = new SkillInstallHandler(dispatcher) + const externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher) + const portScanHandler = new PortScanHandler(dispatcher) + const agentExecHandler = new AgentExecHandler(dispatcher) + const workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) + const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) + const hostPlatform = relayPlatform ? getRemoteHostPlatform(relayPlatform) : undefined + this.aiVaultService = hostPlatform ? createRelayAiVaultService(homedir(), hostPlatform) : null + this.registeredHandlers = [ + preflightHandler, + this.skillInstallHandler, + externalAutomationsHandler, + portScanHandler, + agentExecHandler, + workspaceSessionHandler, + new AiVaultHandler(dispatcher, { + hostPlatform, + service: this.aiVaultService ?? undefined + }) + ] + + registerRelayPluginHostCallHandlers( + dispatcher, + () => null, + () => ({ grantedCapabilities: null, services: null }) + ) + this.registerRemoteCliRoutes() + } + + async disposeOwnedProcesses(): Promise { + await this.skillInstallHandler.dispose().catch((error) => { + relayLogLine( + `[relay] Skill upload cleanup failed: ${error instanceof Error ? error.message : String(error)}` + ) + }) + await this.aiVaultService?.dispose().catch((error) => { + relayLogLine( + `[relay] AI Vault sidecar shutdown failed: ${error instanceof Error ? error.message : String(error)}` + ) + }) + } + + disposeHandlers(): void { + this.fsHandler.dispose() + this.gitHandler.dispose() + void this.registeredHandlers + } + + private registerSessionHandlers(context: RelayContext): void { + this.dispatcher.onNotification('session.registerRoot', (params) => { + const rootPath = params.rootPath as string + if (rootPath) { + context.registerRoot(rootPath) + } + }) + this.dispatcher.onRequest('session.registerRoot', async (params) => { + const rootPath = params.rootPath as string + if (rootPath) { + context.registerRoot(rootPath) + } + return { ok: true } + }) + this.dispatcher.onRequest('session.resolveHome', async (params) => ({ + resolvedPath: expandTilde(params.path as string) + })) + } + + private registerRemoteCliRoutes(): void { + this.dispatcher.onRequest('orca.cli', async (params, context) => + this.dispatcher.requestAnyClient('orca.cli', params, { + excludeClientId: context.clientId, + timeoutMs: remoteCliRequestTimeoutMs(params) + }) + ) + this.dispatcher.onRequest('orca.cli.postOutput', async (params, context) => + this.dispatcher.requestAnyClient('orca.cli.postOutput', params, { + excludeClientId: context.clientId, + timeoutMs: remoteCliRequestTimeoutMs(params) + }) + ) + } +} diff --git a/src/relay/relay-socket-ownership.ts b/src/relay/relay-socket-ownership.ts new file mode 100644 index 00000000000..b7c2c9611fe --- /dev/null +++ b/src/relay/relay-socket-ownership.ts @@ -0,0 +1,219 @@ +import { existsSync, statSync, unlinkSync } from 'node:fs' +import { createConnection, createServer, type Server, type Socket } from 'node:net' +import { relayLogLine } from './relay-diagnostic-log' + +const STALE_SOCKET_PROBE_TIMEOUT_MS = 500 + +type SocketIdentity = { + dev: bigint + ino: bigint + ctimeNs: bigint +} + +function sameSocketIdentity(a: SocketIdentity, b: SocketIdentity): boolean { + return a.dev === b.dev && a.ino === b.ino && a.ctimeNs === b.ctimeNs +} + +export function isRelayNamedPipePath(sockPath: string): boolean { + return process.platform === 'win32' && /^\\\\[.?]\\pipe\\/i.test(sockPath) +} + +export class RelaySocketOwnership { + private ownsSocketPath = false + private ownedSocketIdentity: SocketIdentity | null = null + private socketServer: Server | null = null + + constructor(readonly sockPath: string) {} + + get server(): Server | null { + return this.socketServer + } + + get owned(): boolean { + return this.ownsSocketPath + } + + ownsCurrentPath(): boolean { + if (isRelayNamedPipePath(this.sockPath)) { + return this.ownsSocketPath + } + const currentIdentity = this.readIdentity() + return ( + this.ownsSocketPath && + this.ownedSocketIdentity !== null && + currentIdentity !== null && + sameSocketIdentity(currentIdentity, this.ownedSocketIdentity) + ) + } + + async listen(onConnection: (socket: Socket) => void): Promise { + const server = createServer(onConnection) + const shouldSetUmask = !isRelayNamedPipePath(this.sockPath) + const previousUmask = shouldSetUmask ? process.umask(0o177) : 0 + let umaskRestored = false + const restoreUmask = (): void => { + if (shouldSetUmask && !umaskRestored) { + process.umask(previousUmask) + umaskRestored = true + } + } + + await new Promise((resolve, reject) => { + let staleRetryAttempted = false + + const removeStartupListeners = (): void => { + server.off('listening', onListening) + server.off('error', onInitialError) + server.off('error', failInitial) + } + const listenForStartupError = (onError: (error: NodeJS.ErrnoException) => void): void => { + server.once('listening', onListening) + server.once('error', onError) + server.listen(this.sockPath) + } + const onListening = (): void => { + removeStartupListeners() + restoreUmask() + this.ownsSocketPath = true + this.ownedSocketIdentity = this.readIdentity() + this.socketServer = server + server.on('error', (error) => { + relayLogLine(`[relay] Socket server error: ${error.message}`) + }) + relayLogLine(`[relay] Socket server listening: ${this.sockPath}`) + resolve() + } + const failInitial = (error: NodeJS.ErrnoException): void => { + removeStartupListeners() + restoreUmask() + if (error.code === 'EADDRINUSE') { + relayLogLine( + `[relay] Socket path already in use: ${this.sockPath}; another relay is likely active. Use --connect instead of starting a new daemon.` + ) + } else { + relayLogLine(`[relay] Socket server error before listen: ${error.message}`) + } + reject(error) + } + const onInitialError = (error: NodeJS.ErrnoException): void => { + if ( + error.code !== 'EADDRINUSE' || + staleRetryAttempted || + isRelayNamedPipePath(this.sockPath) + ) { + failInitial(error) + return + } + staleRetryAttempted = true + this.probeBlockedPath(error, failInitial, () => { + relayLogLine(`[relay] Removed stale socket at ${this.sockPath} and retrying listen`) + removeStartupListeners() + listenForStartupError(failInitial) + }) + } + listenForStartupError(onInitialError) + }) + return server + } + + closeAndCleanup(): void { + if (this.socketServer && this.ownsCurrentPath()) { + this.socketServer.close() + } + this.cleanup() + } + + cleanup(): void { + if (this.ownsCurrentPath()) { + this.unlinkPath() + } + this.ownsSocketPath = false + this.ownedSocketIdentity = null + } + + private readIdentity(): SocketIdentity | null { + if (isRelayNamedPipePath(this.sockPath)) { + return null + } + try { + const stat = statSync(this.sockPath, { bigint: true }) + return { dev: stat.dev, ino: stat.ino, ctimeNs: stat.ctimeNs } + } catch { + return null + } + } + + private probeBlockedPath( + listenError: NodeJS.ErrnoException, + fail: (error: NodeJS.ErrnoException) => void, + retry: () => void + ): void { + const blockedIdentity = this.readIdentity() + const probe = createConnection({ path: this.sockPath }) + let settled = false + let timeout: NodeJS.Timeout | null = null + const finish = (callback: () => void): void => { + if (settled) { + return + } + settled = true + if (timeout) { + clearTimeout(timeout) + } + callback() + } + probe.once('connect', () => { + finish(() => { + probe.destroy() + fail(listenError) + }) + }) + probe.once('error', (probeError: NodeJS.ErrnoException) => { + finish(() => { + if ( + (probeError.code !== 'ECONNREFUSED' && probeError.code !== 'ENOENT') || + !this.unlinkIfStillStale(blockedIdentity) + ) { + fail(listenError) + return + } + retry() + }) + }) + timeout = setTimeout(() => { + finish(() => { + probe.destroy() + fail(listenError) + }) + }, STALE_SOCKET_PROBE_TIMEOUT_MS) + } + + private unlinkIfStillStale(blockedIdentity: SocketIdentity | null): boolean { + const currentIdentity = this.readIdentity() + if (currentIdentity === null) { + return true + } + if (blockedIdentity === null || !sameSocketIdentity(currentIdentity, blockedIdentity)) { + return false + } + try { + unlinkSync(this.sockPath) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' + } + } + + private unlinkPath(): void { + if (isRelayNamedPipePath(this.sockPath)) { + return + } + try { + if (existsSync(this.sockPath)) { + unlinkSync(this.sockPath) + } + } catch { + // Best-effort cleanup. + } + } +} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index a2dcb1bcf40..b4f05d836ec 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -1,1403 +1,35 @@ #!/usr/bin/env node -/* oxlint-disable max-lines -- Why: the entry point keeps process lifecycle and handler registration in one file so the boot sequence stays in topological order. */ -/* eslint-disable max-lines -- Why: splitting the entrypoint's startup/reconnect/registration would hide the startup order, the key invariant here. */ +// Orca Relay — remote-host daemon and reconnect bridge entry point. -// Orca Relay — lightweight daemon deployed to remote hosts over SCP and launched via an SSH exec channel. -// Communicates over stdin/stdout using the framed JSON-RPC protocol. -// On client disconnect it enters a grace period, keeping PTYs alive on a Unix domain socket; a later launch -// reconnects via `relay.js --connect`, bridging the new SSH channel's stdio to the existing relay's socket. - -import { createServer, createConnection, type Socket, type Server } from 'node:net' -import { homedir } from 'node:os' -import { join } from 'node:path' -import { - unlinkSync, - existsSync, - statSync, - readFileSync, - chmodSync, - closeSync, - openSync -} from 'node:fs' -import { - RELAY_SENTINEL, - FrameDecoder, - MessageType, - encodeJsonRpcFrame, - parseJsonRpcMessage, - type DecodedFrame, - type JsonRpcResponse -} from './protocol' -import { readLaunchVersion, runConnectHandshake, setupDaemonHandshake } from './relay-handshake' -import { RelayDispatcher } from './dispatcher' -import { RelayContext, expandTilde } from './context' -import { PtyHandler } from './pty-handler' -import { FsHandler } from './fs-handler' -import { installRelayLogRotation } from './rotating-log-writer' -import { GitHandler } from './git-handler' -import { PreflightHandler } from './preflight-handler' -import { ExternalAutomationsHandler } from './external-automations-handler' -import { PortScanHandler } from './port-scan-handler' -import { AgentExecHandler } from './agent-exec-handler' -import { WorkspaceSessionHandler } from './workspace-session-handler' -import { AiVaultHandler } from './ai-vault-handler' -import { createRelayAiVaultService } from './ai-vault-service-factory' -import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' -import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol' -import { RelayAgentHookServer } from './agent-hook-server' -import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates' -import { PluginOverlayManager } from './plugin-overlay' -import { - AGENT_HOOK_INSTALL_PLUGINS_METHOD, - AGENT_HOOK_REQUEST_REPLAY_METHOD -} from '../shared/agent-hook-relay' -import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' -import { - DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, - SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD -} from '../shared/ssh-types' -import { assertPluginSourceUnderByteCap } from './plugin-source-limit' -import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env' -import { - detectExplicitPiAgentKindFromCommand, - isPiCompatibleAgentType -} from '../shared/pi-agent-kind' -import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' -import { pickRemoteCliEnv } from './remote-cli-env' -import { - applyRelayGraceTimeConfiguration, - decideRelayGrace, - type RelayGraceBranch -} from './relay-grace-branch' +import { parseRelayLaunchOptions, readRelayEndpointCredential } from './relay-launch-options' +import { runRelayConnectChannel } from './relay-connect-channel' +import { runRelayOrcaCliChannel } from './relay-orca-cli-channel' +import { runRelayDaemon } from './relay-daemon' import { relayLogLine } from './relay-diagnostic-log' -import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' -import { shouldReadRemoteCliStdin } from './remote-cli-stdin' -import { prepareRemoteArtifactCliInput } from './remote-artifact-cli-input' -import { - assertRemoteArtifactCliForwardingFits, - type RemoteArtifactCliForwardingParams -} from './remote-artifact-cli-forwarding' -import { registerManagedHookInstaller } from './managed-hook-installer' -import { registerRelayPluginHostCallHandlers } from './plugin-host-call-handler' -import { DispatcherClientWriter } from './dispatcher-client-writer' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' -import { RelayPtySourcePublication } from './relay-pty-source-publication' -import { SKILL_RELAY_CAPABILITIES, SkillInstallHandler } from './skill-install-handler' - -const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 -const SOCK_NAME = 'relay.sock' -const CONNECT_TIMEOUT_MS = 5_000 -const STALE_SOCKET_PROBE_TIMEOUT_MS = 500 -const EMPTY_DETACHED_STARTUP_GRACE_MS = parseNonNegativeIntEnv( - 'ORCA_RELAY_EMPTY_STARTUP_GRACE_MS', - 60_000 -) -// Why: a relay holding zero PTYs preserves nothing, so an unlimited grace only accumulates idle daemons. -// The env override is test-only — the remote relay is launched over a non-interactive SSH exec channel that carries no client env. -const IDLE_RELAY_GRACE_MS = parseNonNegativeIntEnv('ORCA_RELAY_IDLE_GRACE_MS', 15 * 60_000) - -type SocketIdentity = { - dev: bigint - ino: bigint - ctimeNs: bigint -} - -function sameSocketIdentity(a: SocketIdentity, b: SocketIdentity): boolean { - return a.dev === b.dev && a.ino === b.ino && a.ctimeNs === b.ctimeNs -} - -function parseNonNegativeIntEnv(name: string, fallback: number): number { - const raw = process.env[name] - if (raw === undefined) { - return fallback - } - const parsed = Number.parseInt(raw, 10) - return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback -} - -function readSocketIdentity(sockPath: string): SocketIdentity | null { - if (isWindowsNamedPipePath(sockPath)) { - return null - } - try { - const stat = statSync(sockPath, { bigint: true }) - return { dev: stat.dev, ino: stat.ino, ctimeNs: stat.ctimeNs } - } catch { - return null - } -} - -function isWindowsNamedPipePath(sockPath: string): boolean { - return process.platform === 'win32' && /^\\\\[.?]\\pipe\\/i.test(sockPath) -} - -function parseArgs(argv: string[]): { - graceTimeMs: number - connectMode: boolean - detached: boolean - cliMode: boolean - sockPath: string - endpointDir?: string - logFile?: string - credentialFile?: string -} { - let graceTimeMs = DEFAULT_GRACE_MS - let connectMode = false - let detached = false - let cliMode = false - let sockPath = '' - let endpointDir: string | undefined - let logFile: string | undefined - let credentialFile: string | undefined - for (let i = 2; i < argv.length; i++) { - if (argv[i] === '--grace-time' && argv[i + 1]) { - const parsed = Number.parseInt(argv[i + 1], 10) - // Why: flag is seconds (internally ms); 0 keeps the relay alive until explicitly terminated for synced workspaces. - if (!Number.isNaN(parsed) && parsed >= 0) { - graceTimeMs = parsed * 1000 - } - i++ - } else if (argv[i] === '--connect') { - connectMode = true - } else if (argv[i] === '--orca-cli') { - cliMode = true - } else if (argv[i] === '--detached') { - detached = true - } else if (argv[i] === '--sock-path' && argv[i + 1]) { - sockPath = argv[i + 1] - i++ - } else if (argv[i] === '--endpoint-dir' && argv[i + 1]) { - endpointDir = argv[i + 1] - i++ - } else if (argv[i] === '--log-file' && argv[i + 1]) { - logFile = argv[i + 1] - i++ - } else if (argv[i] === '--credential-file' && argv[i + 1]) { - credentialFile = argv[i + 1] - i++ - } - } - if (!sockPath) { - sockPath = join(process.cwd(), SOCK_NAME) - } - return { - graceTimeMs, - connectMode, - detached, - cliMode, - sockPath, - endpointDir, - logFile, - credentialFile - } -} - -function readEndpointCredential(credentialFile: string | undefined): string | undefined { - if (!credentialFile) { - return undefined - } - const credential = readFileSync(credentialFile, 'utf8').trim() - if (!/^[A-Za-z0-9_-]{32,256}$/.test(credential)) { - throw new Error('Relay endpoint credential is missing or invalid') - } - if (process.platform !== 'win32') { - chmodSync(credentialFile, 0o600) - } - return credential -} - -// ── Connect mode ───────────────────────────────────────────────────── -// Why: --connect bridges a new SSH channel's stdin/stdout to the existing relay's socket so the client keeps talking to the process that owns the live PTYs. - -function runConnectMode(sockPath: string, endpointCredential?: string): void { - const myVersion = readLaunchVersion() - const sock = createConnection({ path: sockPath }) - const stdoutWriter = new DispatcherClientWriter( - (data, onSettled) => - process.stdout.write(data, (error) => { - onSettled(error ? { ok: false, error } : { ok: true }) - }), - { - supportsWriteCallback: true, - writableLength: () => process.stdout.writableLength, - writableHighWaterMark: () => process.stdout.writableHighWaterMark, - waitWriteDrain: (callback) => { - process.stdout.once('drain', callback) - return () => process.stdout.off('drain', callback) - } - }, - () => { - sock.destroy() - process.exit(1) - } - ) - - const connectTimeout = setTimeout(() => { - process.stderr.write(`[relay-connect] Connection timed out after ${CONNECT_TIMEOUT_MS}ms\n`) - sock.destroy() - process.exit(1) - }, CONNECT_TIMEOUT_MS) - - sock.on('connect', () => { - clearTimeout(connectTimeout) - runConnectHandshake( - sock, - myVersion, - { - onAccepted: (leftover: Buffer) => { - stdoutWriter.enqueue('control', () => Buffer.from(RELAY_SENTINEL), RELAY_SENTINEL.length) - if (leftover.length > 0) { - stdoutWriter.enqueue('control', () => leftover, leftover.length) - } - process.stdin.pipe(sock) - sock.on('data', (data: Buffer) => { - sock.pause() - let offset = 0 - const writeNext = (): void => { - if (offset >= data.length) { - sock.resume() - return - } - const bytes = Math.min(stdoutWriter.producerFrameCapacity, data.length - offset) - if (bytes <= 0) { - stdoutWriter.close(new Error('Relay stdout has no producer capacity')) - return - } - const chunk = data.subarray(offset, offset + bytes) - if ( - !stdoutWriter.enqueue( - 'ordinary', - () => chunk, - chunk.length, - (result) => { - if (!result.ok) { - return - } - offset += bytes - writeNext() - } - ) - ) { - stdoutWriter.close(new Error('Relay stdout bridge capacity exceeded')) - } - } - writeNext() - }) - } - }, - endpointCredential - ) - }) - - // Why: Node swallows EPIPE on stdout, so the bridge would zombie and drop frames; exit on stdout error so the relay enters grace promptly. - process.stdout.on('error', () => { - stdoutWriter.close(new Error('Relay stdout closed')) - }) - - sock.on('error', (err) => { - clearTimeout(connectTimeout) - process.stderr.write(`[relay-connect] Socket error: ${err.message}\n`) - process.exit(1) - }) - - sock.on('close', async () => { - await stdoutWriter.waitForIdle() - process.exit(0) - }) -} - -async function runOrcaCliMode( - sockPath: string, - argv: string[], - endpointCredential?: string -): Promise { - const myVersion = readLaunchVersion() - let preparedArtifact: Awaited> - try { - preparedArtifact = await prepareRemoteArtifactCliInput(argv, process.cwd()) - } catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) - process.exitCode = 1 - return - } - const stdin = - preparedArtifact.stdin ?? - (shouldReadRemoteCliStdin(argv) ? await readOrcaCliStdin() : undefined) - const env = pickRemoteCliEnv(process.env) - const requestParams: RemoteArtifactCliForwardingParams = { - argv, - cwd: process.cwd(), - env, - ...(stdin !== undefined ? { stdin } : {}), - ...(preparedArtifact.artifactInput ? { artifactInput: preparedArtifact.artifactInput } : {}) - } - if (preparedArtifact.artifactInput) { - try { - assertRemoteArtifactCliForwardingFits(requestParams) - } catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) - process.exitCode = 1 - return - } - } - const sock = createConnection({ path: sockPath }) - const stdoutWriter = new DispatcherClientWriter( - (data, onSettled) => - process.stdout.write(data, (error) => { - onSettled(error ? { ok: false, error } : { ok: true }) - }), - { - supportsWriteCallback: true, - writableLength: () => process.stdout.writableLength, - writableHighWaterMark: () => process.stdout.writableHighWaterMark, - waitWriteDrain: (callback) => { - process.stdout.once('drain', callback) - return () => process.stdout.off('drain', callback) - } - }, - () => process.exit(1) - ) - let nextSeq = 1 - let highestReceivedSeq = 0 - const requestId = 1 - const postOutputRequestId = 2 - let initialExitCode = 0 - - const sendRequest = (): void => { - const frame = encodeJsonRpcFrame( - { - jsonrpc: '2.0', - id: requestId, - method: 'orca.cli', - params: requestParams - }, - nextSeq++, - highestReceivedSeq - ) - sock.write(frame) - } - - const finish = (exitCode: number): void => { - sock.destroy() - process.exit(exitCode) - } - - const sendPostOutput = (postOutput: unknown): void => { - sock.write( - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - id: postOutputRequestId, - method: 'orca.cli.postOutput', - params: { postOutput, env: pickRemoteCliEnv(process.env) } - }, - nextSeq++, - highestReceivedSeq - ) - ) - } - - const writeOutput = ( - result: { stdout?: unknown; stderr?: unknown }, - onFlushed: (error?: Error) => void - ): void => { - let pending = 0 - let completed = false - const settle = (error?: Error): void => { - if (completed) { - return - } - if (error) { - completed = true - onFlushed(error) - return - } - pending -= 1 - if (pending === 0) { - completed = true - onFlushed() - } - } - if (typeof result.stdout === 'string' && result.stdout.length > 0) { - pending += 1 - const output = Buffer.from(result.stdout) - stdoutWriter.enqueue( - 'control', - () => output, - output.length, - (settlement) => settle(settlement.ok ? undefined : settlement.error) - ) - } - if (typeof result.stderr === 'string' && result.stderr.length > 0) { - pending += 1 - process.stderr.write(result.stderr, 'utf8', (error) => settle(error ?? undefined)) - } - if (pending === 0) { - completed = true - onFlushed() - } - } - - const decoder = new FrameDecoder((frame: DecodedFrame) => { - if (frame.id > highestReceivedSeq) { - highestReceivedSeq = frame.id - } - if (frame.type !== MessageType.Regular) { - return - } - const msg = parseJsonRpcMessage(frame.payload) - if ( - !('id' in msg) || - (msg.id !== requestId && msg.id !== postOutputRequestId) || - !('result' in msg || 'error' in msg) - ) { - return - } - const response = msg as JsonRpcResponse - if (response.error) { - process.stderr.write(`${response.error.message}\n`) - finish(1) - return - } - if (response.id === postOutputRequestId) { - finish(initialExitCode) - return - } - const result = (response.result ?? {}) as { - stdout?: unknown - stderr?: unknown - exitCode?: unknown - postOutput?: unknown - } - initialExitCode = typeof result.exitCode === 'number' ? result.exitCode : 0 - writeOutput(result, (error) => { - if (error) { - finish(1) - return - } - if (result.postOutput === undefined) { - finish(initialExitCode) - return - } - sendPostOutput(result.postOutput) - }) - }) - - const connectTimeout = setTimeout(() => { - process.stderr.write(`[orca-cli] Relay connection timed out after ${CONNECT_TIMEOUT_MS}ms\n`) - sock.destroy() - process.exit(1) - }, CONNECT_TIMEOUT_MS) - - sock.on('connect', () => { - clearTimeout(connectTimeout) - runConnectHandshake( - sock, - myVersion, - { - onAccepted: (leftover) => { - if (leftover.length > 0) { - decoder.feed(leftover) - } - sock.on('data', (chunk) => - decoder.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) - ) - sendRequest() - } - }, - endpointCredential - ) - }) - - sock.on('error', (err) => { - clearTimeout(connectTimeout) - process.stderr.write(`[orca-cli] Relay socket error: ${err.message}\n`) - process.exit(1) - }) -} - -async function readOrcaCliStdin(): Promise { - if (process.stdin.isTTY) { - return undefined - } - const chunks: Buffer[] = [] - for await (const chunk of process.stdin) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) - } - return Buffer.concat(chunks).toString('utf8') -} - -// ── Normal mode ────────────────────────────────────────────────────── async function main(): Promise { - const { - graceTimeMs, - connectMode, - detached, - cliMode, - sockPath, - endpointDir, - logFile, - credentialFile - } = parseArgs(process.argv) - const endpointCredential = readEndpointCredential(credentialFile) - - if (connectMode) { - runConnectMode(sockPath, endpointCredential) + const options = parseRelayLaunchOptions(process.argv) + const endpointCredential = readRelayEndpointCredential(options.credentialFile) + if (options.connectMode) { + runRelayConnectChannel(options.sockPath, endpointCredential) return } - if (cliMode) { + if (options.cliMode) { const marker = process.argv.indexOf('--orca-cli') - await runOrcaCliMode( - sockPath, - marker !== -1 ? process.argv.slice(marker + 1) : [], + await runRelayOrcaCliChannel( + options.sockPath, + marker === -1 ? [] : process.argv.slice(marker + 1), endpointCredential ) return } - - // Why: only the long-lived detached daemon accumulates relay.log; route it through a size-capped rotator so it can't grow forever. - if (detached && logFile) { - installRelayLogRotation(logFile) - } - - let ownsSocketPath = false - let ownedSocketIdentity: SocketIdentity | null = null - const ownsCurrentSocketPath = (): boolean => { - if (isWindowsNamedPipePath(sockPath)) { - return ownsSocketPath - } - const currentIdentity = readSocketIdentity(sockPath) - return ( - ownsSocketPath && - ownedSocketIdentity !== null && - currentIdentity !== null && - sameSocketIdentity(currentIdentity, ownedSocketIdentity) - ) - } - const cleanupOwnedSocket = (): void => { - if (ownsCurrentSocketPath()) { - cleanupSocket(sockPath) - } - ownsSocketPath = false - ownedSocketIdentity = null - } - - // Why: after an uncaught exception Node's state may be corrupted; log and exit rather than risk data corruption or zombie PTYs. - process.on('uncaughtException', (err) => { - relayLogLine(`[relay] Uncaught exception: ${err.message}\n${err.stack}`) - cleanupOwnedSocket() - process.exit(1) - }) - - process.on('unhandledRejection', (reason) => { - relayLogLine(`[relay] Unhandled rejection: ${String(reason)}`) - }) - - // Why: guards writes after the stdin/SSH channel drops so keepalive/pty.data frames don't hit a dead pipe (EPIPE). - let stdoutAlive = true - // Why: one-shot waiters parked when stdout saturates (write() === false); flushed on 'drain' and every stdout-death path. - const stdoutDrainWaiters = new Set<() => void>() - const flushStdoutDrainWaiters = (): void => { - for (const cb of Array.from(stdoutDrainWaiters)) { - stdoutDrainWaiters.delete(cb) - cb() - } - } - process.stdout.on('drain', flushStdoutDrainWaiters) - const dispatcher = new RelayDispatcher( - (data, onSettled) => { - if (!stdoutAlive) { - onSettled({ ok: false, error: new Error('Relay stdout is closed') }) - return false - } - try { - return process.stdout.write(data, (error) => { - onSettled(error ? { ok: false, error } : { ok: true }) - }) - } catch (error) { - stdoutAlive = false - flushStdoutDrainWaiters() - onSettled({ - ok: false, - error: error instanceof Error ? error : new Error(String(error)) - }) - return false - } - }, - { - supportsWriteCallback: true, - writableLength: () => process.stdout.writableLength, - writableHighWaterMark: () => process.stdout.writableHighWaterMark, - waitWriteDrain: (cb) => { - if (!stdoutAlive) { - cb() - return - } - stdoutDrainWaiters.add(cb) - return () => stdoutDrainWaiters.delete(cb) - }, - close: () => { - stdoutAlive = false - flushStdoutDrainWaiters() - // Why close then re-pin: the SSH peer must see EOF, but a long-lived daemon that - // frees fds 0/1 lets accept()/open() recycle them while Node still treats - // process.stdin/stdout as those numbers — corrupting socket clients and shutdown. - for (const fd of [process.stdin.fd, process.stdout.fd]) { - try { - closeSync(fd) - } catch { - // Already closed by the peer. - } - } - const devNull = process.platform === 'win32' ? 'NUL' : '/dev/null' - try { - openSync(devNull, 'r') - } catch { - /* best-effort pin of the lowest free fd (normally 0) */ - } - try { - openSync(devNull, 'w') - } catch { - /* best-effort pin of the next free fd (normally 1) */ - } - } - }, - undefined, - { pauseReads: () => process.stdin.pause(), resumeReads: () => process.stdin.resume() } - ) - const launchVersion = readLaunchVersion() - - const context = new RelayContext() - - // Why: registerRoot is a no-op now (allowlist removed, docs/relay-fs-allowlist-removal.md); both handlers kept for version-skew compat until the version floor moves. - dispatcher.onNotification('session.registerRoot', (params) => { - const rootPath = params.rootPath as string - if (rootPath) { - context.registerRoot(rootPath) - } - }) - - dispatcher.onRequest('session.registerRoot', async (params) => { - const rootPath = params.rootPath as string - if (rootPath) { - context.registerRoot(rootPath) - } - return { ok: true } - }) - - // Why: `~` is a shell expansion Node's fs APIs don't understand; resolve it to an absolute path on the remote host before persisting. - dispatcher.onRequest('session.resolveHome', async (params) => { - const inputPath = params.path as string - // Use the shared expander so Windows `~\…` paths resolve too — a remote - // relay host can be Windows, where a literal `~\` would otherwise fall - // through unexpanded and break every downstream fs op. - return { resolvedPath: expandTilde(inputPath) } - }) - - const ptyHandler = new PtyHandler(dispatcher, graceTimeMs) - const ptyConsumerSessionAdapter = new SshPtyConsumerSessionAdapter( - dispatcher, - launchVersion, - (id, paused) => ptyHandler.setConsumerDeliveryPaused(id, paused), - (id) => ptyHandler.handleSourceCreditAvailable(id) - ) - const ptySourcePublication = new RelayPtySourcePublication( - dispatcher, - ptyConsumerSessionAdapter, - (id) => ptyHandler.handleSourcePublicationCapacity(id) - ) - ptyHandler.setSourcePublication(ptySourcePublication) - const fsHandler = new FsHandler(dispatcher, context) - const watchRegistry = fsHandler.getWatchRegistry() - ptyHandler.setWorktreeRemovalCoordinator(watchRegistry) - watchRegistry.setWorktreePtyTeardown((rootPath) => ptyHandler.shutdownForWorktreePath(rootPath)) - const gitHandler = new GitHandler(dispatcher, context, watchRegistry) - - const _preflightHandler = new PreflightHandler(dispatcher) - const _skillInstallHandler = new SkillInstallHandler(dispatcher) - const _externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher) - void _preflightHandler - void _skillInstallHandler - void _externalAutomationsHandler - - const _portScanHandler = new PortScanHandler(dispatcher) - void _portScanHandler - - const _agentExecHandler = new AgentExecHandler(dispatcher) - void _agentExecHandler - - const _workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) - void _workspaceSessionHandler - - const aiVaultRelayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) - const aiVaultHostPlatform = aiVaultRelayPlatform - ? getRemoteHostPlatform(aiVaultRelayPlatform) - : undefined - const aiVaultService = aiVaultHostPlatform - ? createRelayAiVaultService(homedir(), aiVaultHostPlatform) - : null - const _aiVaultHandler = new AiVaultHandler(dispatcher, { - hostPlatform: aiVaultHostPlatform, - service: aiVaultService ?? undefined - }) - void _aiVaultHandler - - // Why: relay-hosted plugin provisioning is a later phase. Register the - // enforcement boundary now with no consented identities or runtime services. - registerRelayPluginHostCallHandlers( - dispatcher, - () => null, - () => ({ grantedCapabilities: null, services: null }) - ) - - dispatcher.onRequest('orca.cli', async (params, context) => { - return await dispatcher.requestAnyClient('orca.cli', params, { - excludeClientId: context.clientId, - timeoutMs: remoteCliRequestTimeoutMs(params) - }) - }) - dispatcher.onRequest('orca.cli.postOutput', async (params, context) => { - return await dispatcher.requestAnyClient('orca.cli.postOutput', params, { - excludeClientId: context.clientId, - timeoutMs: remoteCliRequestTimeoutMs(params) - }) - }) - - function configureRelayGraceTime(params: Record): { graceTimeMs: number } { - return applyRelayGraceTimeConfiguration(params.graceTimeSeconds, { - readConfiguredGraceMs: () => ptyHandler.configuredGraceTimeMs, - writeConfiguredGraceMs: (graceMs) => ptyHandler.setGraceTimeMs(graceMs), - isGraceTimerArmed: () => graceDeadlineAt !== null && graceReason !== null, - isShutdownInFlight: () => shutdownInFlight, - readGraceBranch: () => graceBranch, - startGrace - }) - } - - dispatcher.onNotification(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, (params) => { - configureRelayGraceTime(params) - }) - dispatcher.onRequest(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, async (params) => - configureRelayGraceTime(params) - ) - - // ── Agent-hook server ───────────────────────────────────────────── - // Why: loopback HTTP receiver so remote-PTY agent CLIs post hook events to a local port (they can't reach Orca's host); the relay forwards them as agent.hook notifications. - const hookServer = new RelayAgentHookServer({ - // Why: scope endpoint.env/cmd by socket path so multiple relay daemons on one account can't overwrite each other's hook tokens. - endpointDir: endpointDir ?? endpointDirForRelaySocket(sockPath), - // Why: publication is fire-and-forget and drops during reconnect; the per-paneKey cache lets us replay last status after --connect. - forward: (envelope) => publishAgentHookEnvelope(dispatcher, envelope) - }) - // Why: await the bind before announcing readiness so the first PTY spawn already sees ORCA_AGENT_HOOK_* env; bind failure is soft (log and continue). - try { - await hookServer.start({ publishEndpoint: false }) - } catch (err) { - relayLogLine( - `[relay] agent-hook server failed to start: ${err instanceof Error ? err.message : String(err)}` - ) - } - - // Why: read the augmenter on every spawn so a late (or restarted) hook-server bind still lands in the next PTY's ORCA_AGENT_HOOK_* env. - ptyHandler.addEnvAugmenter(() => hookServer.buildPtyEnv()) - - // Why: plugin paths resolve on the relay host — OpenCode gets a relay-local overlay; Pi/OMP get extensions in their real remote dirs. - const pluginOverlay = new PluginOverlayManager() - ptyHandler.addEnvAugmenter((ctx) => { - const env: Record = {} - // Why: prefer paneKey for overlay identity so a renderer remount reusing it lands in the same dir; fall back to pty-id when absent. - const overlayId = ctx.paneKey ?? ctx.id - if (pluginOverlay.hasOpenCodeSource()) { - const sourceDir = resolveOpenCodeSourceConfigDir(ctx.env, ctx.shell) - const dir = pluginOverlay.materializeOpenCode(overlayId, sourceDir) - if (dir) { - env.OPENCODE_CONFIG_DIR = dir - env.ORCA_OPENCODE_CONFIG_DIR = dir - if (sourceDir) { - env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = sourceDir - } - } - } - if (pluginOverlay.hasPiSource()) { - // Why: install Orca's guarded extension into the launched agent's (Pi vs OMP) real remote dir without redirecting PI_CODING_AGENT_DIR. - const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command) - const explicitKind = isPiCompatibleAgentType(ctx.launchAgent) - ? ctx.launchAgent - : ctx.launchAgent === undefined - ? detectExplicitPiAgentKindFromCommand(launchCommandHint) - : null - const kind = explicitKind ?? 'pi' - const hasLaunchCommand = - typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 - const shouldPrepareOmpShadow = kind === 'omp' || !hasLaunchCommand - if (kind === 'pi') { - const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell, 'pi') - // Why: do not mkdir ~/. on bare shells when the agent home is - // missing — unused agents kept recreating deleted homes (#10196). - const result = pluginOverlay.materializePi(overlayId, sourceDir, 'pi', { - materializeDefaultHome: explicitKind === 'pi' - }) - if (result?.sourceAgentDir) { - env.ORCA_PI_SOURCE_AGENT_DIR = result.sourceAgentDir - } - } - if (shouldPrepareOmpShadow) { - // Why: prepare OMP's status extension for a bare shell so a typed `omp` gets integration, without making OMP the shell's home. - const sourceDir = - kind === 'omp' - ? resolvePiSourceAgentDir(ctx.env, ctx.shell, 'omp') - : ctx.env.ORCA_OMP_SOURCE_AGENT_DIR - const result = pluginOverlay.materializePi(overlayId, sourceDir, 'omp', { - materializeDefaultHome: explicitKind === 'omp' - }) - // Why: status-only fallback (no sourceAgentDir) is intentional for bare - // shells without ~/.omp — still export ORCA_OMP_STATUS_EXTENSION (#10196). - if (result?.statusExtensionPath) { - env.ORCA_OMP_STATUS_EXTENSION = result.statusExtensionPath - } - if (result?.sourceAgentDir) { - env.ORCA_OMP_SOURCE_AGENT_DIR = result.sourceAgentDir - } - } - if (kind === 'prime-agent') { - const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell, 'prime-agent') - const result = pluginOverlay.materializePi(overlayId, sourceDir, 'prime-agent', { - materializeDefaultHome: explicitKind === 'prime-agent' - }) - if (result?.sourceAgentDir) { - env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR = result.sourceAgentDir - } - } - } - return env - }) - - // Why: evict pane status cache + overlay dirs on PTY exit, else the next reconnect replays a dead pane's last status (ghost row) and overlay dirs leak. - ptyHandler.setExitListener(({ paneKey, id }) => { - if (paneKey) { - hookServer.clearPaneState(paneKey) - } - pluginOverlay.clearOverlay(paneKey ?? id) - }) - - // Why: forward cached entries as notifications before returning, so the response trails every replay and Orca can't treat replay as done while frames are still in flight. - dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => { - const replayed = hookServer.replayCachedPayloadsForPanes() - return { replayed } - }) - - // Why: relay-local installers collapse hundreds of SFTP request/response RTTs to one RPC. - registerManagedHookInstaller(dispatcher) - - // Why: plugin sources ship over the wire — the relay is versioned independently of Orca, so bundling them would make every agent-event change a relay redeploy. - // Why: bound per-source size so a buggy/hostile Orca can't OOM the relay by pushing a giant string. - dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { - const opencode = params.opencodePluginSource - const pi = params.piExtensionSource - const omp = params.ompExtensionSource - const primeAgent = params.primeAgentExtensionSource - assertPluginSourceUnderByteCap('opencodePluginSource', opencode) - assertPluginSourceUnderByteCap('piExtensionSource', pi) - assertPluginSourceUnderByteCap('ompExtensionSource', omp) - assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) - pluginOverlay.setSources({ - opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, - piExtensionSource: typeof pi === 'string' ? pi : undefined, - ompExtensionSource: typeof omp === 'string' ? omp : undefined, - primeAgentExtensionSource: typeof primeAgent === 'string' ? primeAgent : undefined - }) - return { - installed: { - opencode: pluginOverlay.hasOpenCodeSource(), - pi: pluginOverlay.hasPiSource('pi'), - omp: pluginOverlay.hasPiSource('omp'), - primeAgent: pluginOverlay.hasPiSource('prime-agent') - } - } - }) - - // ── Socket server for reconnection ────────────────────────────────── - // Why: the SSH channel dies on app restart; a Unix socket lets a new --connect bridge reach the dispatcher that owns live PTYs. - - const socketClients = new Map() - let socketServer: Server | null = null - const startedAt = Date.now() - let acceptedSocketConnections = 0 - let hasAcceptedSocketClient = false - let graceDeadlineAt: number | null = null - let graceReason: string | null = null - // Why: only the idle branch is a "nothing left to preserve" bet, so only it may be revoked when a - // PTY appears mid-window; the other branches keep their armed deadline. - let graceBranch: RelayGraceBranch | null = null - - dispatcher.onRequest('relay.status', async () => ({ - capabilities: SKILL_RELAY_CAPABILITIES, - pid: process.pid, - uptimeMs: Date.now() - startedAt, - detached, - stdoutAlive, - memory: process.memoryUsage(), - ptys: { - active: ptyHandler.activePtyCount - }, - ptySourceCredit: { - enabled: true, - session: ptyConsumerSessionAdapter.getDebugSnapshot(), - publication: ptySourcePublication.getDebugSnapshot() - }, - socket: { - path: sockPath, - owned: ownsSocketPath, - listening: socketServer?.listening ?? false, - clients: socketClients.size, - acceptedConnections: acceptedSocketConnections - }, - grace: { - active: ptyHandler.graceTimerActive, - deadlineAt: graceDeadlineAt, - reason: graceReason - } - })) - - function cancelGrace(reason: string): void { - if (ptyHandler.graceTimerActive) { - relayLogLine(`[relay] Grace canceled: ${reason}`) - } - graceDeadlineAt = null - graceReason = null - graceBranch = null - ptyHandler.cancelGraceTimer() - } - - function attachAcceptedSocket(sock: Socket, leftover: Buffer): void { - // Why: remove the initial stdin data listener once a socket client is accepted, so stale SSH-channel bytes can't interleave. - process.stdin.pause() - process.stdin.removeAllListeners('data') - - hasAcceptedSocketClient = true - acceptedSocketConnections++ - relayLogLine( - `[relay] Socket client accepted (clients=${socketClients.size + 1}, accepted=${acceptedSocketConnections})` - ) - cancelGrace('socket client accepted') - - // Why: same backpressure surface as stdout — bulk frames wait for socket drain so they can't bury interactive PTY frames. - const sockDrainWaiters = new Set<() => void>() - const flushSockDrainWaiters = (): void => { - for (const cb of Array.from(sockDrainWaiters)) { - sockDrainWaiters.delete(cb) - cb() - } - } - sock.on('drain', flushSockDrainWaiters) - sock.on('close', flushSockDrainWaiters) - sock.on('error', flushSockDrainWaiters) - const clientId = dispatcher.attachClient( - (data, onSettled) => { - if (!sock.destroyed) { - return sock.write(data, (error) => { - onSettled(error ? { ok: false, error } : { ok: true }) - }) - } - onSettled({ ok: false, error: new Error('Relay socket is closed') }) - return false - }, - { - supportsWriteCallback: true, - writableLength: () => sock.writableLength, - writableHighWaterMark: () => sock.writableHighWaterMark, - close: () => sock.destroy(), - waitWriteDrain: (cb) => { - if (sock.destroyed) { - cb() - return - } - sockDrainWaiters.add(cb) - return () => sockDrainWaiters.delete(cb) - } - }, - { - principal: `relay-endpoint:${launchVersion}`, - authenticated: endpointCredential !== undefined, - allowSessionOwner: endpointCredential !== undefined, - authenticationKind: endpointCredential ? 'endpoint-credential' : 'unproved' - }, - { - pauseReads: () => sock.pause(), - resumeReads: () => sock.resume() - } - ) - socketClients.set(sock, clientId) - - // Why: feed handshake-buffered leftover bytes before wiring sock.on('data') so frame ordering is preserved. - if (leftover.length > 0) { - dispatcher.feedClient(clientId, leftover) - } - - sock.on('data', (chunk: Buffer) => { - cancelGrace('socket client data') - dispatcher.feedClient(clientId, chunk) - }) - } - - async function startSocketServer(): Promise { - const server = createServer((sock) => { - // Why: pre-dispatcher version handshake — see relay-handshake.ts. - setupDaemonHandshake(sock, { - launchVersion, - endpointCredential, - onAccepted: attachAcceptedSocket - }) - - // Why: destroy on 'end' (FIN from --connect's dying channel) so the 'close' handler fires promptly and the daemon enters grace. - sock.on('end', () => { - if (!sock.destroyed) { - sock.destroy() - } - }) - - sock.on('error', () => { - // Why: Node emits 'error' then 'close'; the close handler owns cleanup and grace startup. - }) - - sock.on('close', () => { - const clientId = socketClients.get(sock) - socketClients.delete(sock) - if (clientId !== undefined) { - // Why 'peer-closed' only here: the socket itself ended, which is the one signal that - // actually says the client is gone rather than merely slow. - dispatcher.detachClient(clientId, 'peer-closed') - } - relayLogLine(`[relay] Socket client closed (clients=${socketClients.size})`) - if (!stdoutAlive && socketClients.size === 0) { - startGrace('socket client closed') - } - }) - }) - - // Why: umask 0o177 before listen makes the socket 0o600 atomically, closing the chmod-after-listen TOCTOU window. - const shouldSetSocketUmask = !isWindowsNamedPipePath(sockPath) - const prevUmask = shouldSetSocketUmask ? process.umask(0o177) : 0 - let umaskRestored = false - const restoreUmask = (): void => { - if (shouldSetSocketUmask && !umaskRestored) { - process.umask(prevUmask) - umaskRestored = true - } - } - - await new Promise((resolve, reject) => { - let staleRetryAttempted = false - - function removeStartupListeners(): void { - server.off('listening', onListening) - server.off('error', onInitialError) - server.off('error', failInitial) - } - - function listenForStartupError(onError: (err: NodeJS.ErrnoException) => void): void { - server.once('listening', onListening) - server.once('error', onError) - server.listen(sockPath) - } - - function onListening(): void { - removeStartupListeners() - restoreUmask() - ownsSocketPath = true - ownedSocketIdentity = readSocketIdentity(sockPath) - server.on('error', (err) => { - relayLogLine(`[relay] Socket server error: ${err.message}`) - }) - relayLogLine(`[relay] Socket server listening: ${sockPath}`) - resolve() - } - - function failInitial(err: NodeJS.ErrnoException): void { - removeStartupListeners() - restoreUmask() - if (err.code === 'EADDRINUSE') { - relayLogLine( - `[relay] Socket path already in use: ${sockPath}; another relay is likely active. Use --connect instead of starting a new daemon.` - ) - } else { - relayLogLine(`[relay] Socket server error before listen: ${err.message}`) - } - reject(err) - } - - function unlinkIfStillStale(blockedIdentity: SocketIdentity | null): boolean { - const currentIdentity = readSocketIdentity(sockPath) - if (currentIdentity === null) { - return true - } - if (blockedIdentity === null || !sameSocketIdentity(currentIdentity, blockedIdentity)) { - return false - } - try { - unlinkSync(sockPath) - return true - } catch (unlinkErr) { - const e = unlinkErr as NodeJS.ErrnoException - return e.code === 'ENOENT' - } - } - - // Why: EADDRINUSE may be a stale socket from a crashed relay, not a live one; probe-connect to tell them apart before unlinking. - function onInitialError(err: NodeJS.ErrnoException): void { - if (err.code !== 'EADDRINUSE' || staleRetryAttempted) { - failInitial(err) - return - } - if (isWindowsNamedPipePath(sockPath)) { - failInitial(err) - return - } - staleRetryAttempted = true - const blockedIdentity = readSocketIdentity(sockPath) - const probe = createConnection({ path: sockPath }) - let probeSettled = false - let probeTimeout: NodeJS.Timeout | null = null - const finishProbe = (callback: () => void): void => { - if (probeSettled) { - return - } - probeSettled = true - if (probeTimeout) { - clearTimeout(probeTimeout) - } - callback() - } - probe.once('connect', () => { - finishProbe(() => { - probe.destroy() - failInitial(err) - }) - }) - probe.once('error', (probeErr: NodeJS.ErrnoException) => { - finishProbe(() => { - if (probeErr.code !== 'ECONNREFUSED' && probeErr.code !== 'ENOENT') { - failInitial(err) - return - } - if (!unlinkIfStillStale(blockedIdentity)) { - failInitial(err) - return - } - relayLogLine(`[relay] Removed stale socket at ${sockPath} and retrying listen`) - removeStartupListeners() - listenForStartupError(failInitial) - }) - }) - probeTimeout = setTimeout(() => { - finishProbe(() => { - probe.destroy() - failInitial(err) - }) - }, STALE_SOCKET_PROBE_TIMEOUT_MS) - } - - listenForStartupError(onInitialError) - }) - - return server - } - - try { - socketServer = await startSocketServer() - // Why: publish endpoint.env only after socket ownership is proven, so a refused duplicate daemon can't poison hook coordinates. - hookServer.publishEndpointFile() - } catch { - process.exit(1) - } - - // ── stdin/stdout transport (initial connection) ───────────────────── - - // Why: without this handler an EPIPE/ERR_STREAM_DESTROYED on stdout becomes an uncaught exception, exiting before grace starts. - process.stdout.on('error', () => { - stdoutAlive = false - flushStdoutDrainWaiters() - dispatcher.invalidateClient('peer-closed') - }) - - function startGrace(reason: string, options?: { retryDeferredShutdown?: boolean }): void { - // Why: the live configured value, not the launch-time argv closure — the host can raise the grace - // after launch via relay.configureGraceTime, and a zero-only gate reading a stale zero would be - // zero-at-launch-only, i.e. correct only by coincidence. - const decision = decideRelayGrace({ - configuredGraceMs: ptyHandler.configuredGraceTimeMs, - relayIdle: isRelayIdle(), - detached, - hasAcceptedSocketClient, - activePtyCount: ptyHandler.activePtyCount, - retryDeferredShutdown: options?.retryDeferredShutdown === true, - emptyDetachedStartupGraceMs: EMPTY_DETACHED_STARTUP_GRACE_MS, - idleRelayGraceMs: IDLE_RELAY_GRACE_MS - }) - graceBranch = decision.branch - const timeoutMs = decision.timeoutMs - graceDeadlineAt = timeoutMs === 0 ? null : Date.now() + timeoutMs - graceReason = reason - relayLogLine( - `[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, branch=${graceBranch}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}` - ) - ptyHandler.startGraceTimer(() => { - // Why: last line of defense for the idle cap — a PTY that appeared without announcing itself - // must not be killed by a timer armed while the relay was still empty (#6955). - if (graceBranch === 'idle-no-ptys' && !isRelayIdle()) { - relayLogLine(`[relay] Grace expired (${reason}) but relay is no longer idle; re-evaluating`) - startGrace(reason) - return - } - relayLogLine(`[relay] Grace expired (${reason}); shutting down`) - shutdown() - }, timeoutMs) - } - - // Why: a creation admitted but not yet pooled already owns a shell, so it counts as non-idle. - function isRelayIdle(): boolean { - return ptyHandler.activePtyCount === 0 && ptyHandler.pendingPtyCreationCount === 0 - } - - if (detached) { - // Why: detached stdin is /dev/null, so listening would EOF → grace → shutdown before --connect arrives; use the socket instead. - startGrace('detached startup') - } else { - process.stdin.on('data', (chunk: Buffer) => { - cancelGrace('stdin data') - dispatcher.feed(chunk) - }) - - process.stdin.on('end', () => { - // Why: stdin close means the SSH channel is gone; mark stdout dead so its write callback no-ops instead of hitting a dead pipe. - stdoutAlive = false - flushStdoutDrainWaiters() - dispatcher.invalidateClient('peer-closed') - if (socketClients.size === 0) { - startGrace('stdin ended') - } - }) - - process.stdin.on('error', () => { - stdoutAlive = false - flushStdoutDrainWaiters() - dispatcher.invalidateClient('peer-closed') - if (socketClients.size === 0) { - startGrace('stdin error') - } - }) - } - - let shutdownInFlight = false - function shutdown(): void { - if (shutdownInFlight) { - return - } - shutdownInFlight = true - relayLogLine( - `[relay] Shutdown: ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}, ownsSocket=${ownsSocketPath}` - ) - graceDeadlineAt = null - graceReason = null - graceBranch = null - void ptyHandler - .dispose() - .then(async () => { - await _skillInstallHandler.dispose().catch((error) => { - relayLogLine( - `[relay] Skill upload cleanup failed: ${error instanceof Error ? error.message : String(error)}` - ) - }) - await aiVaultService?.dispose().catch((error) => { - relayLogLine( - `[relay] AI Vault sidecar shutdown failed: ${error instanceof Error ? error.message : String(error)}` - ) - }) - stopPoolWatch() - stopPoolActiveWatch() - dispatcher.dispose() - fsHandler.dispose() - gitHandler.dispose() - hookServer.stop() - // Why: server.close() unlinks the listen path; skip if a newer relay rebound it, else we strand that newer daemon. - if (socketServer && ownsCurrentSocketPath()) { - socketServer.close() - } - cleanupOwnedSocket() - process.exit(0) - }) - .catch((error) => { - // Why: keep owning a PTY whose native kill was rejected so a transient signal failure doesn't orphan a remote shell. - // Why: the pool watches stay registered — the socket server is still listening, so a client can - // reconnect and cancel this grace, and that revived relay still needs both re-evaluations. - shutdownInFlight = false - relayLogLine( - `[relay] Shutdown deferred: ${error instanceof Error ? error.message : String(error)}` - ) - // Why: shutdown() already cleared graceReason, so without re-arming a client-less relay - // whose kill was refused would stay resident forever with nothing left to retry it. - if (socketClients.size === 0) { - startGrace('shutdown deferred', { retryDeferredShutdown: true }) - } - }) - } - - // Why: with the shipped unlimited default the grace timer is never armed, so an expiry can't - // notice the pool emptying; re-evaluate on the last exit instead. graceReason (not - // graceTimerActive) is the only signal that a grace window is open in that configuration. - // Why: a null deadline is exactly that unlimited case; re-arming a scheduled one would restart - // an explicitly configured window and double it. - const stopPoolWatch = ptyHandler.onPtyPoolEmpty(() => { - if (graceReason !== null && graceDeadlineAt === null && !shutdownInFlight) { - startGrace('last pty exited') - } - }) - - // Why: the idle cap is armed on an empty pool, but creation is asynchronous — a spawn or revive - // admitted after that decision (the client dropped mid-`pty.revive`, say) makes the relay non-idle - // again, and re-evaluating disarms the timer instead of letting it kill a live PTY (#6955). - // The grace window itself stays open, so the PTY's own exit still re-arms the cap. - const stopPoolActiveWatch = ptyHandler.onPtyPoolActive(() => { - if (graceBranch === 'idle-no-ptys' && graceReason !== null && !shutdownInFlight) { - startGrace(graceReason) - } - }) - - process.on('SIGTERM', shutdown) - process.on('SIGINT', shutdown) - // Why: default SIGHUP exits immediately, killing PTYs before grace; ignore it so the relay survives SSH disconnect. - process.on('SIGHUP', () => { - relayLogLine('[relay] Received SIGHUP (SSH session dropped), ignoring') - }) - process.on('exit', (code) => { - relayLogLine(`[relay] Process exiting with code ${code}`) - }) - - dispatcher.writePrimaryBytes(Buffer.from(RELAY_SENTINEL)) - if (detached) { - stdoutAlive = false - dispatcher.invalidateClient() - } + await runRelayDaemon(options, endpointCredential) } -function cleanupSocket(sockPath: string): void { - if (isWindowsNamedPipePath(sockPath)) { - return - } - try { - if (existsSync(sockPath)) { - unlinkSync(sockPath) - } - } catch { - /* best-effort */ - } -} - -void main().catch((err) => { +void main().catch((error) => { relayLogLine( - `[relay] Fatal startup error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}` + `[relay] Fatal startup error: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}` ) process.exit(1) }) From 07b13c846899ed56bb0245a81e1c2b15f3edc9fe Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:44:34 -0700 Subject: [PATCH 29/65] refactor git repository host boundaries (#16177) --- config/max-lines-baseline.txt | 2 - src/main/git/repo-api-parity.test.ts | 37 + src/main/git/repo-base-ref-search.ts | 240 +++++ src/main/git/repo-branch-conflict.ts | 59 ++ src/main/git/repo-default-base-ref.ts | 133 +++ src/main/git/repo-default-remote.test.ts | 79 ++ src/main/git/repo-detection.ts | 141 +++ src/main/git/repo-git-marker-scan.ts | 251 +++++ src/main/git/repo.ts | 965 +----------------- .../ssh-git-noninteractive-provider.ts | 166 +++ .../providers/ssh-git-provider-api.test.ts | 71 ++ .../providers/ssh-git-provider-exec.test.ts | 34 + src/main/providers/ssh-git-provider.ts | 837 +-------------- src/main/providers/ssh-git-read-provider.ts | 187 ++++ src/main/providers/ssh-git-relay-errors.ts | 8 + .../providers/ssh-git-remote-sync-provider.ts | 84 ++ .../providers/ssh-git-review-head-provider.ts | 70 ++ .../ssh-git-working-tree-provider.ts | 122 +++ .../providers/ssh-git-worktree-provider.ts | 143 +++ 19 files changed, 1886 insertions(+), 1743 deletions(-) create mode 100644 src/main/git/repo-api-parity.test.ts create mode 100644 src/main/git/repo-base-ref-search.ts create mode 100644 src/main/git/repo-branch-conflict.ts create mode 100644 src/main/git/repo-default-base-ref.ts create mode 100644 src/main/git/repo-default-remote.test.ts create mode 100644 src/main/git/repo-detection.ts create mode 100644 src/main/git/repo-git-marker-scan.ts create mode 100644 src/main/providers/ssh-git-noninteractive-provider.ts create mode 100644 src/main/providers/ssh-git-provider-api.test.ts create mode 100644 src/main/providers/ssh-git-read-provider.ts create mode 100644 src/main/providers/ssh-git-relay-errors.ts create mode 100644 src/main/providers/ssh-git-remote-sync-provider.ts create mode 100644 src/main/providers/ssh-git-review-head-provider.ts create mode 100644 src/main/providers/ssh-git-working-tree-provider.ts create mode 100644 src/main/providers/ssh-git-worktree-provider.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index fb97a469747..79d033f010d 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -24,7 +24,6 @@ inline src/main/daemon/daemon-init.ts inline src/main/daemon/daemon-pty-adapter.ts inline src/main/daemon/daemon-server.ts inline src/main/daemon/pty-subprocess.ts -inline src/main/git/repo.ts inline src/main/git/runner.ts inline src/main/git/status.ts inline src/main/git/worktree.ts @@ -49,7 +48,6 @@ inline src/main/ports/advertised-url-watcher.ts inline src/main/ports/local-workspace-port-scanner.ts inline src/main/project-groups/nested-repo-discovery.ts inline src/main/providers/local-pty-provider.ts -inline src/main/providers/ssh-git-provider.ts inline src/main/rate-limits/claude-fetcher.ts inline src/main/rate-limits/claude-pty.ts inline src/main/rate-limits/codex-fetcher.ts diff --git a/src/main/git/repo-api-parity.test.ts b/src/main/git/repo-api-parity.test.ts new file mode 100644 index 00000000000..9143b7bf65f --- /dev/null +++ b/src/main/git/repo-api-parity.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import * as repo from './repo' + +describe('repo public API parity', () => { + it('keeps the historical runtime export surface', () => { + expect(Object.keys(repo).sort()).toEqual( + [ + 'DEFAULT_BASE_REF_PROBES', + 'buildSearchBaseRefsArgv', + 'getBaseRefDefault', + 'getBranchConflictKind', + 'getDefaultBaseRef', + 'getDefaultRemote', + 'getGitRepoRoot', + 'getLinkedWorktreeMainRepoRoot', + 'getRecentDriftSubjects', + 'getRemoteCommitUrl', + 'getRemoteCount', + 'getRemoteDrift', + 'getRemoteFileUrl', + 'getRemoteUrl', + 'getRepoName', + 'isForEachRefExcludeUnsupportedError', + 'isGitRepo', + 'mergeBaseRefSearchResultGroups', + 'normalizeGitRepoRootForInputPath', + 'normalizeRefSearchQuery', + 'parseAndFilterSearchRefDetails', + 'parseRemoteCount', + 'resolveDefaultBaseRefViaExec', + 'resolveDefaultBaseRefWithLocalGit', + 'searchBaseRefDetails', + 'searchBaseRefs' + ].sort() + ) + }) +}) diff --git a/src/main/git/repo-base-ref-search.ts b/src/main/git/repo-base-ref-search.ts new file mode 100644 index 00000000000..5ea01a7ec1c --- /dev/null +++ b/src/main/git/repo-base-ref-search.ts @@ -0,0 +1,240 @@ +import type { BaseRefSearchResult } from '../../shared/repo-types' +import { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' +import { getLocalGitCapabilityCache } from './git-capability-state' +import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-ref' +import { gitExecFileAsync } from './runner' + +const REF_SEARCH_CANDIDATE_MULTIPLIER = 4 +const REF_SEARCH_LEGACY_HEADROOM = 100 + +type RefSearchPatternGroup = 'all' | 'segmented' | 'branchRoot' + +function getRefSearchTokens(normalizedQuery: string): string[] { + return normalizedQuery.split('/').filter((token) => token.length > 0) +} + +function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): number { + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error('invalid_limit') + } + const baseCount = limit * REF_SEARCH_CANDIDATE_MULTIPLIER + return excludesRemoteHead ? baseCount : baseCount + REF_SEARCH_LEGACY_HEADROOM +} + +/** Build the bounded `for-each-ref` argv shared by local and remote searches. */ +export function buildSearchBaseRefsArgv( + normalizedQuery: string, + limit: number, + options: { + excludeRemoteHead?: boolean + remoteNames?: readonly string[] + patternGroup?: RefSearchPatternGroup + } = {} +): string[] { + const excludeRemoteHead = options.excludeRemoteHead ?? true + const candidateCount = getRefSearchCandidateCount(limit, excludeRemoteHead) + const base = [ + 'for-each-ref', + '--format=%(refname)%00%(refname:short)', + '--sort=-committerdate', + ...(excludeRemoteHead ? ['--exclude=refs/remotes/**/HEAD'] : []), + `--count=${candidateCount}` + ] + const tokens = getRefSearchTokens(normalizedQuery) + if (tokens.length <= 1) { + const query = tokens[0] ?? '' + return [ + ...base, + `refs/heads/**/*${query}*`, + `refs/heads/**/*${query}*/**`, + `refs/remotes/**/*${query}*`, + `refs/remotes/**/*${query}*/**` + ] + } + + const segmented = tokens.map((token) => `*${token}*`).join('/') + const substringQuery = tokens.join('/') + const remoteBranchRootPatterns = + options.remoteNames && options.remoteNames.length > 0 + ? options.remoteNames.flatMap((remote) => [ + `refs/remotes/${remote}/${substringQuery}*`, + `refs/remotes/${remote}/${substringQuery}*/**` + ]) + : [`refs/remotes/*/${substringQuery}*`, `refs/remotes/*/${substringQuery}*/**`] + const segmentedPatterns = [`refs/remotes/${segmented}`, `refs/heads/${segmented}`] + const branchRootPatterns = [ + `refs/heads/${substringQuery}*`, + `refs/heads/${substringQuery}*/**`, + ...remoteBranchRootPatterns + ] + const patterns = + options.patternGroup === 'segmented' + ? segmentedPatterns + : options.patternGroup === 'branchRoot' + ? branchRootPatterns + : [...segmentedPatterns, ...branchRootPatterns] + return [...base, ...patterns] +} + +async function runSearchBaseRefsGit( + path: string, + normalizedQuery: string, + limit: number, + options: { remoteNames: readonly string[]; patternGroup?: RefSearchPatternGroup } +): Promise<{ stdout: string }> { + return getLocalGitCapabilityCache({ cwd: path }).runWithFallback( + 'for-each-ref-exclude', + () => + gitExecFileAsync( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + remoteNames: options.remoteNames, + patternGroup: options.patternGroup + }), + { cwd: path } + ), + () => + gitExecFileAsync( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + excludeRemoteHead: false, + remoteNames: options.remoteNames, + patternGroup: options.patternGroup + }), + { cwd: path } + ), + isForEachRefExcludeUnsupportedError + ) +} + +export function mergeBaseRefSearchResultGroups( + groups: readonly BaseRefSearchResult[][], + limit: number +): BaseRefSearchResult[] { + const seen = new Set() + const merged: BaseRefSearchResult[] = [] + const maxLength = Math.max(0, ...groups.map((group) => group.length)) + for (let index = 0; index < maxLength && merged.length < limit; index += 1) { + for (const group of groups) { + const entry = group[index] + if (!entry || seen.has(entry.refName)) { + continue + } + seen.add(entry.refName) + merged.push(entry) + if (merged.length >= limit) { + break + } + } + } + return merged +} + +export async function searchBaseRefs(path: string, query: string, limit = 25): Promise { + return (await searchBaseRefDetails(path, query, limit)).map((entry) => entry.refName) +} + +export async function searchBaseRefDetails( + path: string, + query: string, + limit = 25 +): Promise { + if (!Number.isInteger(limit) || limit <= 0) { + return [] + } + const normalizedQuery = normalizeRefSearchQuery(query) + + try { + const remotes = await listRemoteNames(path) + const tokens = getRefSearchTokens(normalizedQuery) + if (tokens.length > 1) { + const results = await Promise.all([ + runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes, + patternGroup: 'segmented' + }), + runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes, + patternGroup: 'branchRoot' + }) + ]) + return mergeBaseRefSearchResultGroups( + results.map((entry) => parseAndFilterSearchRefDetails(entry.stdout, limit, remotes)), + limit + ) + } + + const result = await runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes + }) + return parseAndFilterSearchRefDetails(result.stdout, limit, remotes) + } catch (err) { + console.warn('[searchBaseRefs] for-each-ref failed', { path, err }) + return [] + } +} + +export async function listRemoteNames( + path: string, + options: LocalGitExecOptions = {} +): Promise { + try { + const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options)) + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + } catch { + return [] + } +} + +export function parseAndFilterSearchRefDetails( + stdout: string, + limit: number, + remotes: string[] = [] +): BaseRefSearchResult[] { + const seen = new Set() + const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length) + return stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const nul = line.indexOf('\0') + return nul === -1 ? null : { full: line.slice(0, nul), short: line.slice(nul + 1) } + }) + .filter((entry): entry is { full: string; short: string } => entry !== null) + .filter(({ full }) => !/^refs\/remotes\/.+\/HEAD$/.test(full)) + .filter(({ short }) => { + if (seen.has(short)) { + return false + } + seen.add(short) + return true + }) + .map(({ full, short }) => ({ + refName: short, + localBranchName: resolveLocalBranchName(full, short, sortedRemotes) + })) + .slice(0, Math.max(0, limit)) +} + +export function resolveLocalBranchName( + fullRef: string, + shortRef: string, + remotes: string[] +): string { + const remoteRefPrefix = 'refs/remotes/' + if (!fullRef.startsWith(remoteRefPrefix)) { + return shortRef + } + const remoteAndBranch = fullRef.slice(remoteRefPrefix.length) + const remote = remotes.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`)) + if (remote) { + return remoteAndBranch.slice(remote.length + 1) + } + return remoteAndBranch.split('/').slice(1).join('/') || shortRef +} + +export function normalizeRefSearchQuery(query: string): string { + return query.trim().replace(/[*?[\]\\]/g, '') +} diff --git a/src/main/git/repo-branch-conflict.ts b/src/main/git/repo-branch-conflict.ts new file mode 100644 index 00000000000..dbadf254d11 --- /dev/null +++ b/src/main/git/repo-branch-conflict.ts @@ -0,0 +1,59 @@ +import { listRemoteNames, resolveLocalBranchName } from './repo-base-ref-search' +import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-ref' +import { gitExecFileAsync } from './runner' + +export type BranchConflictKind = 'local' | 'remote' + +async function hasGitRefAsync( + path: string, + ref: string, + options: LocalGitExecOptions = {} +): Promise { + try { + await gitExecFileAsync(['rev-parse', '--verify', ref], gitExecOptions(path, options)) + return true + } catch { + return false + } +} + +export async function getBranchConflictKind( + path: string, + branchName: string, + allowedBaseRef?: string, + options: LocalGitExecOptions = {} +): Promise { + if (await hasGitRefAsync(path, `refs/heads/${branchName}`, options)) { + return 'local' + } + + try { + const remoteNames = (await listRemoteNames(path, options)).sort((a, b) => b.length - a.length) + const { stdout } = await gitExecFileAsync( + ['for-each-ref', '--format=%(refname)', 'refs/remotes'], + gitExecOptions(path, options) + ) + const hasRemoteConflict = stdout.split('\n').some((ref) => { + const trimmed = ref.trim() + if (isAllowedRemoteBaseRef(trimmed, allowedBaseRef)) { + return false + } + const shortRef = trimmed.replace(/^refs\/remotes\//, '') + return resolveLocalBranchName(trimmed, shortRef, remoteNames) === branchName + }) + + return hasRemoteConflict ? 'remote' : null + } catch { + return null + } +} + +function isAllowedRemoteBaseRef(refName: string, allowedBaseRef: string | undefined): boolean { + if (!allowedBaseRef) { + return false + } + const normalizedAllowedRef = allowedBaseRef.startsWith('refs/remotes/') + ? allowedBaseRef + : `refs/remotes/${allowedBaseRef}` + return refName === normalizedAllowedRef +} diff --git a/src/main/git/repo-default-base-ref.ts b/src/main/git/repo-default-base-ref.ts new file mode 100644 index 00000000000..59adaeed512 --- /dev/null +++ b/src/main/git/repo-default-base-ref.ts @@ -0,0 +1,133 @@ +import { gitExecFileAsync, gitExecFileSync } from './runner' + +export type LocalGitExecOptions = { + wslDistro?: string +} + +export type LocalDefaultBaseRefGitOptions = { + cwd: string + wslDistro?: string +} + +export const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000 + +export function gitExecOptions( + cwd: string, + options: LocalGitExecOptions = {} +): { cwd: string; wslDistro?: string } { + return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } +} + +export const DEFAULT_BASE_REF_PROBES: readonly { ref: string; returnAs: string }[] = [ + { ref: 'refs/remotes/origin/main', returnAs: 'origin/main' }, + { ref: 'refs/remotes/origin/master', returnAs: 'origin/master' }, + { ref: 'refs/heads/main', returnAs: 'main' }, + { ref: 'refs/heads/master', returnAs: 'master' } +] + +async function resolveDefaultBaseRefFromProbes( + hasRef: (ref: string) => Promise +): Promise { + for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { + if (await hasRef(ref)) { + return returnAs + } + } + return null +} + +function hasGitRef(path: string, ref: string): boolean { + try { + gitExecFileSync(['rev-parse', '--verify', ref], { cwd: path }) + return true + } catch { + return false + } +} + +function gitRefToDefaultBaseRef(ref: string): string { + return ref.replace(/^refs\/remotes\//, '') +} + +function getVerifiedOriginHeadBaseRef(path: string): string | null { + try { + const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { + cwd: path + }).trim() + return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null + } catch { + return null + } +} + +/** Resolve the default base ref without inventing a fallback branch. */ +export function getDefaultBaseRef(path: string): string | null { + const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path) + if (originHeadBaseRef) { + return originHeadBaseRef + } + for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { + if (hasGitRef(path, ref)) { + return returnAs + } + } + return null +} + +export async function getBaseRefDefault( + path: string, + options: LocalGitExecOptions = {} +): Promise { + return getDefaultBaseRefAsync(path, options) +} + +export type GitExec = (argv: string[]) => Promise<{ stdout: string }> + +async function hasGitRefViaExec(exec: GitExec, ref: string): Promise { + try { + await exec(['rev-parse', '--verify', '--quiet', ref]) + return true + } catch { + return false + } +} + +async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise { + try { + const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) + const ref = stdout.trim() + if (!ref || !(await hasGitRefViaExec(exec, ref))) { + return null + } + return gitRefToDefaultBaseRef(ref) + } catch { + return null + } +} + +/** Resolve the same default-base ordering through a host-owned Git executor. */ +export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise { + const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec) + if (originHeadBaseRef) { + return originHeadBaseRef + } + return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref)) +} + +export function resolveDefaultBaseRefWithLocalGit( + options: LocalDefaultBaseRefGitOptions +): Promise { + return resolveDefaultBaseRefViaExec((argv) => + gitExecFileAsync(argv, { + ...options, + timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS + }) + ) +} + +export function getDefaultBaseRefAsync( + path: string, + options: LocalGitExecOptions = {} +): Promise { + return resolveDefaultBaseRefWithLocalGit(gitExecOptions(path, options)) +} diff --git a/src/main/git/repo-default-remote.test.ts b/src/main/git/repo-default-remote.test.ts new file mode 100644 index 00000000000..a0764598fb8 --- /dev/null +++ b/src/main/git/repo-default-remote.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileSync: vi.fn() +})) + +import { getDefaultRemote } from './repo' + +describe('getDefaultRemote', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('prefers the configured remote for the resolved default branch', async () => { + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n' } + } + if (argv[0] === 'rev-parse') { + return { stdout: 'abc123\n' } + } + if (argv[0] === 'config') { + return { stdout: 'upstream\n' } + } + throw new Error('unexpected command') + }) + + await expect(getDefaultRemote('/repo')).resolves.toBe('upstream') + expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( + ['config', '--get', 'branch.main.remote'], + { cwd: '/repo' } + ) + }) + + it.each([ + ['origin\nupstream\n', 'origin'], + ['company\n', 'company'] + ])('falls back from unresolved defaults for remotes %j', async (stdout, expected) => { + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'remote') { + return { stdout } + } + throw new Error('missing ref') + }) + + await expect(getDefaultRemote('/repo')).resolves.toBe(expected) + }) + + it.each([ + ['', 'Repo has no configured git remotes.'], + [ + 'upstream\nfork\n', + 'Repo has multiple remotes (upstream, fork) and no default is configured. Set branch..remote.' + ] + ])('preserves the exact ambiguous-remote error for %j', async (stdout, message) => { + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'remote') { + return { stdout } + } + throw new Error('missing ref') + }) + + await expect(getDefaultRemote('/repo')).rejects.toThrow(message) + }) + + it('normalizes a non-Error remote rejection', async () => { + for (let probe = 0; probe < 5; probe += 1) { + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('missing ref')) + } + gitExecFileAsyncMock.mockRejectedValueOnce('transport failed') + + await expect(getDefaultRemote('/repo')).rejects.toThrow( + 'Failed to resolve default remote for repo.' + ) + }) +}) diff --git a/src/main/git/repo-detection.ts b/src/main/git/repo-detection.ts new file mode 100644 index 00000000000..2f67005a4e7 --- /dev/null +++ b/src/main/git/repo-detection.ts @@ -0,0 +1,141 @@ +import { existsSync, statSync } from 'node:fs' +import { basename, dirname, resolve } from 'node:path' +import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' +import { scanGitMarkerSync, resolveRealPathSync } from './repo-git-marker-scan' +import { gitExecFileSync } from './runner' + +type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' + +let warnedMarkerFallbackThisSession = false + +/** Check if a path is a valid git repository (regular or bare). */ +export function isGitRepo(path: string): boolean { + try { + if (!existsSync(path) || !statSync(path).isDirectory()) { + return false + } + } catch { + return false + } + + const gitProbeResult = probeGitRepo(path) + if (gitProbeResult === 'repo') { + return true + } + if (gitProbeResult === 'not-repo') { + return false + } + + const markerScan = scanGitMarkerSync(path) + if (markerScan.status === 'valid' && !warnedMarkerFallbackThisSession) { + warnedMarkerFallbackThisSession = true + console.warn('[isGitRepo] git rev-parse could not confirm repo; accepted via .git marker', { + path + }) + } + return markerScan.status === 'valid' +} + +/** Only a clean pair of negative Git answers is a definitive non-repo. */ +function probeGitRepo(path: string): GitRepoProbeResult { + let sawFailure = false + + try { + const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { + cwd: path + }).trim() + if (insideWorkTree === 'true') { + return 'repo' + } + if (insideWorkTree !== 'false') { + return 'indeterminate' + } + } catch { + sawFailure = true + } + + try { + const bareRepo = gitExecFileSync(['rev-parse', '--is-bare-repository'], { + cwd: path + }).trim() + if (bareRepo === 'true') { + return 'repo' + } + if (bareRepo !== 'false') { + return 'indeterminate' + } + } catch { + sawFailure = true + } + + return sawFailure ? 'indeterminate' : 'not-repo' +} + +export function getGitRepoRoot(path: string): string { + try { + if (!existsSync(path) || !statSync(path).isDirectory()) { + return path + } + const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { + cwd: path + }).trim() + if (insideWorkTree === 'true') { + const root = gitExecFileSync(['rev-parse', '--show-toplevel'], { + cwd: path + }).trim() + return normalizeGitRepoRootForInputPath(path, root) + } + } catch { + // Fall through to preserving the original path. + } + const markerScan = scanGitMarkerSync(path) + if (markerScan.status === 'valid') { + return normalizeGitRepoRootForInputPath(path, markerScan.rootPath) + } + return path +} + +function canonicalizeGitDirPath(path: string): string { + return resolveRealPathSync(path) ?? path +} + +/** Return the main-checkout path only when `path` is a linked worktree. */ +export function getLinkedWorktreeMainRepoRoot(path: string): string | null { + try { + if (!existsSync(path) || !statSync(path).isDirectory()) { + return null + } + if (gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { cwd: path }).trim() !== 'true') { + return null + } + const [gitDir, commonDir] = gitExecFileSync(['rev-parse', '--git-dir', '--git-common-dir'], { + cwd: path + }) + .split('\n') + .map((line) => line.trim()) + if (!gitDir || !commonDir) { + return null + } + const absoluteCommonDir = canonicalizeGitDirPath(resolve(path, commonDir)) + if (canonicalizeGitDirPath(resolve(path, gitDir)) === absoluteCommonDir) { + return null + } + if (basename(absoluteCommonDir) !== '.git') { + return null + } + return getGitRepoRoot(dirname(absoluteCommonDir)) + } catch { + return null + } +} + +export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: string): string { + const inputWsl = parseWslUncPath(inputPath) + if (inputWsl && rootPath.startsWith('/')) { + // Why: persist the UNC root so later Git calls keep routing through the WSL runner. + return toWindowsWslPath(rootPath, inputWsl.distro) + } + return normalizeRuntimePathSeparators(rootPath) +} diff --git a/src/main/git/repo-git-marker-scan.ts b/src/main/git/repo-git-marker-scan.ts new file mode 100644 index 00000000000..5442d74d391 --- /dev/null +++ b/src/main/git/repo-git-marker-scan.ts @@ -0,0 +1,251 @@ +import { readFileSync, realpathSync, statSync } from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' + +export type GitMarkerScanResult = + | { status: 'valid'; rootPath: string } + | { status: 'absent' | 'invalid' } + +/** Filesystem fallback for genuine Git metadata when git cannot answer cleanly. */ +export function scanGitMarkerSync(path: string): GitMarkerScanResult { + const realPath = resolveRealPathSync(path) + if (realPath && realPath !== path) { + const lexicalScan = scanGitMarkerAncestorsSync(path) + const realPathScan = scanGitMarkerAncestorsSync(realPath) + if ( + lexicalScan.status === 'valid' && + realPathScan.status === 'valid' && + pathsReferToSameEntry(lexicalScan.rootPath, realPathScan.rootPath) + ) { + // Why: preserve lexical spellings, but let a cross-repo symlink bind to its real target. + return lexicalScan + } + return realPathScan + } + return scanGitMarkerAncestorsSync(path) +} + +export function resolveRealPathSync(path: string): string | null { + try { + return realpathSync.native(path) + } catch { + try { + return realpathSync(path) + } catch { + return null + } + } +} + +function scanGitMarkerAncestorsSync(path: string): GitMarkerScanResult { + for (const candidate of ancestorDirectories(path)) { + if (!isInsideDotGitMarker(candidate, path)) { + const worktreeMarker = scanWorktreeMarkerSync(candidate) + if (worktreeMarker.status !== 'absent') { + return worktreeMarker + } + } + if (hasValidBareRepoMarkerSync(candidate)) { + return { status: 'valid', rootPath: candidate } + } + } + return { status: 'absent' } +} + +function ancestorDirectories(path: string): string[] { + const directories: string[] = [] + let current = path + while (true) { + directories.push(current) + const parent = dirname(current) + if (parent === current) { + return directories + } + current = parent + } +} + +function isInsideDotGitMarker(rootPath: string, targetPath: string): boolean { + const relativePath = relative(rootPath, targetPath) + if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) { + return false + } + const firstSegment = relativePath.split(/[\\/]+/)[0] + if (firstSegment === '.git') { + return true + } + if (firstSegment.toLowerCase() !== '.git') { + return false + } + return pathsReferToSameEntry(join(rootPath, firstSegment), join(rootPath, '.git')) +} + +function pathsReferToSameEntry(leftPath: string, rightPath: string): boolean { + try { + const leftStat = statSync(leftPath) + const rightStat = statSync(rightPath) + if (leftStat.ino !== 0 && leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino) { + return true + } + const leftRealPath = normalizeRuntimePathSeparators(realpathSync.native(leftPath)) + const rightRealPath = normalizeRuntimePathSeparators(realpathSync.native(rightPath)) + return process.platform === 'win32' + ? leftRealPath.toLowerCase() === rightRealPath.toLowerCase() + : leftRealPath === rightRealPath + } catch { + return false + } +} + +function scanWorktreeMarkerSync(worktreePath: string): GitMarkerScanResult { + const dotGit = join(worktreePath, '.git') + let marker: ReturnType + try { + marker = statSync(dotGit) + } catch { + return { status: 'absent' } + } + + if (marker.isDirectory()) { + return hasValidGitDirectorySync(dotGit) + ? { status: 'valid', rootPath: worktreePath } + : { status: 'invalid' } + } + if (marker.isFile()) { + let gitDir: string | null + try { + gitDir = parseGitdirFile(worktreePath, readFileSync(dotGit, 'utf8')) + } catch { + return { status: 'invalid' } + } + return gitDir !== null && hasValidGitDirectorySync(gitDir) + ? { status: 'valid', rootPath: worktreePath } + : { status: 'invalid' } + } + return { status: 'invalid' } +} + +function parseGitdirFile(basePath: string, content: string): string | null { + const firstLine = content.split(/\r?\n/, 1)[0] ?? '' + const match = firstLine.match(/^gitdir:\s*(.+?)\s*$/i) + if (!match) { + return null + } + return resolveGitMetadataPath(basePath, match[1]) +} + +function resolveGitMetadataPath(basePath: string, rawPath: string): string | null { + const value = rawPath.trim() + if (!value) { + return null + } + const baseWsl = parseWslUncPath(basePath) + if (baseWsl && value.startsWith('/')) { + return toWindowsWslPath(value, baseWsl.distro) + } + return isAbsolute(value) ? value : resolve(basePath, value) +} + +function hasValidGitDirectorySync(gitDir: string): boolean { + return hasValidCommonGitDirectorySync(gitDir) || hasValidLinkedWorktreeGitDirectorySync(gitDir) +} + +function hasValidCommonGitDirectorySync(gitDir: string): boolean { + try { + return ( + statSync(join(gitDir, 'HEAD')).isFile() && + statSync(join(gitDir, 'objects')).isDirectory() && + statSync(join(gitDir, 'refs')).isDirectory() + ) + } catch { + return false + } +} + +function hasValidLinkedWorktreeGitDirectorySync(gitDir: string): boolean { + try { + if (!statSync(join(gitDir, 'HEAD')).isFile() || !statSync(join(gitDir, 'commondir')).isFile()) { + return false + } + const commonDir = resolveGitMetadataPath( + gitDir, + readFileSync(join(gitDir, 'commondir'), 'utf8') + ) + return commonDir !== null && hasValidCommonGitDirectorySync(commonDir) + } catch { + return false + } +} + +function hasValidBareRepoMarkerSync(path: string): boolean { + return hasValidCommonGitDirectorySync(path) && !gitConfigDeclaresNonBare(path) +} + +function gitConfigDeclaresNonBare(gitDir: string): boolean { + try { + const config = readFileSync(join(gitDir, 'config'), 'utf8') + let inCoreSection = false + for (const line of config.split(/\r?\n/)) { + const section = line.match(/^\s*\[([^\]]+)\]/) + if (section) { + inCoreSection = section[1].trim().toLowerCase() === 'core' + continue + } + const bare = line.match(/^\s*bare\s*=\s*(.*?)\s*$/i) + if (inCoreSection && bare) { + return isGitBooleanFalse(normalizeGitConfigValue(bare[1])) + } + } + return false + } catch { + return false + } +} + +function normalizeGitConfigValue(value: string): string { + const unescaped = stripGitConfigInlineComment(value).trim().replace(/\\"/g, '"') + if ( + unescaped.length >= 2 && + ((unescaped.startsWith('"') && unescaped.endsWith('"')) || + (unescaped.startsWith("'") && unescaped.endsWith("'"))) + ) { + return unescaped.slice(1, -1) + } + return unescaped +} + +function stripGitConfigInlineComment(value: string): string { + let quote: '"' | "'" | null = null + let escaped = false + for (let i = 0; i < value.length; i++) { + const char = value[i] + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (quote) { + if (char === quote) { + quote = null + } + continue + } + if (char === '"' || char === "'") { + quote = char + continue + } + if (char === '#' || char === ';') { + return value.slice(0, i) + } + } + return value +} + +function isGitBooleanFalse(value: string): boolean { + return ['', 'false', 'no', 'off', '0'].includes(value.toLowerCase()) +} diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index 50d8a278be2..fcf5167f907 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -1,542 +1,57 @@ -/* oxlint-disable max-lines */ -import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' -import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' -import { gitExecFileSync, gitExecFileAsync } from './runner' -import type { BaseRefSearchResult } from '../../shared/repo-types' +import { basename } from 'node:path' import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' -import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' -import { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' -import { parseWslUncPath } from '../../shared/wsl-paths' -import { toWindowsWslPath } from '../wsl' import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url' -import { getLocalGitCapabilityCache } from './git-capability-state' +import { + DEFAULT_BASE_REF_PROBE_TIMEOUT_MS, + getDefaultBaseRef, + getDefaultBaseRefAsync, + gitExecOptions, + type LocalGitExecOptions +} from './repo-default-base-ref' +import { gitExecFileAsync, gitExecFileSync } from './runner' -type LocalGitExecOptions = { - wslDistro?: string -} - -type LocalDefaultBaseRefGitOptions = { - cwd: string - wslDistro?: string -} - -const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000 - -type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' -type GitMarkerScanResult = { status: 'valid'; rootPath: string } | { status: 'absent' | 'invalid' } - -function gitExecOptions( - cwd: string, - options: LocalGitExecOptions = {} -): { cwd: string; wslDistro?: string } { - return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } -} - -/** - * Ordered probe list for a repo's default base ref when no origin/HEAD symbolic-ref is set. - * `returnAs` is the short-name format the UI expects (as `for-each-ref --format=%(refname:short)` renders it). - * Shared local/SSH so both resolve identical defaults. - */ -export const DEFAULT_BASE_REF_PROBES: readonly { ref: string; returnAs: string }[] = [ - { ref: 'refs/remotes/origin/main', returnAs: 'origin/main' }, - { ref: 'refs/remotes/origin/master', returnAs: 'origin/master' }, - { ref: 'refs/heads/main', returnAs: 'main' }, - { ref: 'refs/heads/master', returnAs: 'master' } -] - -/** - * Walk DEFAULT_BASE_REF_PROBES in order, returning the first ref `hasRef` confirms, or null. - * Abstracts the existence test so local and SSH paths share one authoritative probe ordering. - */ -async function resolveDefaultBaseRefFromProbes( - hasRef: (ref: string) => Promise -): Promise { - for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { - if (await hasRef(ref)) { - return returnAs - } - } - return null -} - -/** Check if a path is a valid git repository (regular or bare). */ -export function isGitRepo(path: string): boolean { - try { - if (!existsSync(path) || !statSync(path).isDirectory()) { - return false - } - } catch { - return false - } - - // Ask git directly first — authoritative for work trees, linked worktrees, submodules, and bare repos. - const gitProbeResult = probeGitRepo(path) - if (gitProbeResult === 'repo') { - return true - } - if (gitProbeResult === 'not-repo') { - return false - } - - // Why: rev-parse can fail for reasons unrelated to repo-ness (spawn hiccup, config error); fall back to a - // validated `.git` marker instead of downgrading a real repo to a plain folder (the spurious "Open as Folder" bug). - const markerScan = scanGitMarkerSync(path) - if (markerScan.status === 'valid' && !warnedMarkerFallbackThisSession) { - // Why: warn once per session; the folder scanner calls isGitRepo for many paths and would otherwise flood logs. - warnedMarkerFallbackThisSession = true - console.warn('[isGitRepo] git rev-parse could not confirm repo; accepted via .git marker', { - path - }) - } - return markerScan.status === 'valid' -} - -let warnedMarkerFallbackThisSession = false - -/** - * Tri-state git probe: only a clean pair of negative answers is a definitive - * non-repo. Spawn/config failures stay indeterminate so marker fallback can run. - */ -function probeGitRepo(path: string): GitRepoProbeResult { - let sawFailure = false - - try { - const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { - cwd: path - }).trim() - if (insideWorkTree === 'true') { - return 'repo' - } - if (insideWorkTree !== 'false') { - return 'indeterminate' - } - } catch { - sawFailure = true - } - - try { - const bareRepo = gitExecFileSync(['rev-parse', '--is-bare-repository'], { - cwd: path - }).trim() - if (bareRepo === 'true') { - return 'repo' - } - if (bareRepo !== 'false') { - return 'indeterminate' - } - } catch { - sawFailure = true - } - - return sawFailure ? 'indeterminate' : 'not-repo' -} - -export function getGitRepoRoot(path: string): string { - try { - if (!existsSync(path) || !statSync(path).isDirectory()) { - return path - } - const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { - cwd: path - }).trim() - if (insideWorkTree === 'true') { - const root = gitExecFileSync(['rev-parse', '--show-toplevel'], { - cwd: path - }).trim() - return normalizeGitRepoRootForInputPath(path, root) - } - } catch { - // Fall through to preserving the original path. - } - const markerScan = scanGitMarkerSync(path) - if (markerScan.status === 'valid') { - return normalizeGitRepoRootForInputPath(path, markerScan.rootPath) - } - return path -} - -function canonicalizeGitDirPath(path: string): string { - return resolveRealPathSync(path) ?? path -} - -/** - * Main-checkout path when `path` is a *linked* worktree, else null (main worktree, bare repo, - * non-repo, or any git failure). A linked worktree's `--git-dir` is `/worktrees/` - * while the main worktree's equals `--git-common-dir`; comparing the two from one invocation is - * git's own canonical test and avoids symlink-canonicalization mismatches. Baseline-safe: both - * flags long predate Git 2.25, and a relative answer resolves against `path` as old Git reports it. - */ -export function getLinkedWorktreeMainRepoRoot(path: string): string | null { - try { - if (!existsSync(path) || !statSync(path).isDirectory()) { - return null - } - if (gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { cwd: path }).trim() !== 'true') { - return null - } - const [gitDir, commonDir] = gitExecFileSync(['rev-parse', '--git-dir', '--git-common-dir'], { - cwd: path - }) - .split('\n') - .map((line) => line.trim()) - if (!gitDir || !commonDir) { - return null - } - // Why realpath both: git answers one flag absolutely (already symlink-resolved) and the other - // relative to cwd, so a repo under a symlinked root (macOS /var -> /private/var) compares - // unequal on raw strings and a main checkout gets misread as a linked worktree. - const absoluteCommonDir = canonicalizeGitDirPath(resolve(path, commonDir)) - if (canonicalizeGitDirPath(resolve(path, gitDir)) === absoluteCommonDir) { - return null - } - // A bare/separate git dir has no adjacent working checkout to point at. - if (basename(absoluteCommonDir) !== '.git') { - return null - } - // Re-resolve through getGitRepoRoot so the returned path matches the canonical form - // add-project stores for the main checkout (symlinks resolved the way git reports them). - return getGitRepoRoot(dirname(absoluteCommonDir)) - } catch { - return null - } -} - -export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: string): string { - const inputWsl = parseWslUncPath(inputPath) - if (inputWsl && rootPath.startsWith('/')) { - // Why: WSL git reports Linux-native roots; persist the UNC path so later git calls keep routing through the WSL runner. - return toWindowsWslPath(rootPath, inputWsl.distro) - } - return normalizeRuntimePathSeparators(rootPath) -} - -/** - * Filesystem-only fallback check for genuine Git metadata when git can't answer cleanly. Strict enough to - * reject a garbage `.git` file (validation from 18ed7b27d): accepts a `.git` dir/file with real gitdir shape - * or a bare-repo root (HEAD + objects/ + refs/, not a worktree admin dir). - */ -function scanGitMarkerSync(path: string): GitMarkerScanResult { - const realPath = resolveRealPathSync(path) - if (realPath && realPath !== path) { - const lexicalScan = scanGitMarkerAncestorsSync(path) - const realPathScan = scanGitMarkerAncestorsSync(realPath) - if ( - lexicalScan.status === 'valid' && - realPathScan.status === 'valid' && - pathsReferToSameEntry(lexicalScan.rootPath, realPathScan.rootPath) - ) { - // Why: preserve lexical spellings (/var vs /private/var), but let a cross-repo symlink bind to the real target like git. - return lexicalScan - } - return realPathScan - } - return scanGitMarkerAncestorsSync(path) -} - -function resolveRealPathSync(path: string): string | null { - try { - return realpathSync.native(path) - } catch { - try { - return realpathSync(path) - } catch { - return null - } - } -} - -function scanGitMarkerAncestorsSync(path: string): GitMarkerScanResult { - for (const candidate of ancestorDirectories(path)) { - if (!isInsideDotGitMarker(candidate, path)) { - const worktreeMarker = scanWorktreeMarkerSync(candidate) - if (worktreeMarker.status !== 'absent') { - return worktreeMarker - } - } - if (hasValidBareRepoMarkerSync(candidate)) { - return { status: 'valid', rootPath: candidate } - } - } - return { status: 'absent' } -} - -function ancestorDirectories(path: string): string[] { - const directories: string[] = [] - let current = path - while (true) { - directories.push(current) - const parent = dirname(current) - if (parent === current) { - return directories - } - current = parent - } -} - -function isInsideDotGitMarker(rootPath: string, targetPath: string): boolean { - const relativePath = relative(rootPath, targetPath) - if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) { - return false - } - const firstSegment = relativePath.split(/[\\/]+/)[0] - if (firstSegment === '.git') { - return true - } - if (firstSegment.toLowerCase() !== '.git') { - return false - } - return pathsReferToSameEntry(join(rootPath, firstSegment), join(rootPath, '.git')) -} - -function pathsReferToSameEntry(leftPath: string, rightPath: string): boolean { - try { - const leftStat = statSync(leftPath) - const rightStat = statSync(rightPath) - if (leftStat.ino !== 0 && leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino) { - return true - } - const leftRealPath = normalizeRuntimePathSeparators(realpathSync.native(leftPath)) - const rightRealPath = normalizeRuntimePathSeparators(realpathSync.native(rightPath)) - return process.platform === 'win32' - ? leftRealPath.toLowerCase() === rightRealPath.toLowerCase() - : leftRealPath === rightRealPath - } catch { - return false - } -} - -function scanWorktreeMarkerSync(worktreePath: string): GitMarkerScanResult { - const dotGit = join(worktreePath, '.git') - let marker: ReturnType - try { - marker = statSync(dotGit) - } catch { - return { status: 'absent' } - } - - if (marker.isDirectory()) { - return hasValidGitDirectorySync(dotGit) - ? { status: 'valid', rootPath: worktreePath } - : { status: 'invalid' } - } - if (marker.isFile()) { - let gitDir: string | null - try { - gitDir = parseGitdirFile(worktreePath, readFileSync(dotGit, 'utf8')) - } catch { - return { status: 'invalid' } - } - return gitDir !== null && hasValidGitDirectorySync(gitDir) - ? { status: 'valid', rootPath: worktreePath } - : { status: 'invalid' } - } - return { status: 'invalid' } -} - -function parseGitdirFile(basePath: string, content: string): string | null { - const firstLine = content.split(/\r?\n/, 1)[0] ?? '' - const match = firstLine.match(/^gitdir:\s*(.+?)\s*$/i) - if (!match) { - return null - } - return resolveGitMetadataPath(basePath, match[1]) -} - -function resolveGitMetadataPath(basePath: string, rawPath: string): string | null { - const value = rawPath.trim() - if (!value) { - return null - } - const baseWsl = parseWslUncPath(basePath) - if (baseWsl && value.startsWith('/')) { - return toWindowsWslPath(value, baseWsl.distro) - } - return isAbsolute(value) ? value : resolve(basePath, value) -} - -function hasValidGitDirectorySync(gitDir: string): boolean { - return hasValidCommonGitDirectorySync(gitDir) || hasValidLinkedWorktreeGitDirectorySync(gitDir) -} - -function hasValidCommonGitDirectorySync(gitDir: string): boolean { - try { - return ( - statSync(join(gitDir, 'HEAD')).isFile() && - statSync(join(gitDir, 'objects')).isDirectory() && - statSync(join(gitDir, 'refs')).isDirectory() - ) - } catch { - return false - } -} - -function hasValidLinkedWorktreeGitDirectorySync(gitDir: string): boolean { - try { - if (!statSync(join(gitDir, 'HEAD')).isFile() || !statSync(join(gitDir, 'commondir')).isFile()) { - return false - } - const commonDir = resolveGitMetadataPath( - gitDir, - readFileSync(join(gitDir, 'commondir'), 'utf8') - ) - return commonDir !== null && hasValidCommonGitDirectorySync(commonDir) - } catch { - return false - } -} - -function hasValidBareRepoMarkerSync(path: string): boolean { - return hasValidCommonGitDirectorySync(path) && !gitConfigDeclaresNonBare(path) -} - -function gitConfigDeclaresNonBare(gitDir: string): boolean { - try { - const config = readFileSync(join(gitDir, 'config'), 'utf8') - let inCoreSection = false - for (const line of config.split(/\r?\n/)) { - const section = line.match(/^\s*\[([^\]]+)\]/) - if (section) { - inCoreSection = section[1].trim().toLowerCase() === 'core' - continue - } - const bare = line.match(/^\s*bare\s*=\s*(.*?)\s*$/i) - if (inCoreSection && bare) { - return isGitBooleanFalse(normalizeGitConfigValue(bare[1])) - } - } - return false - } catch { - return false - } -} - -function normalizeGitConfigValue(value: string): string { - const unescaped = stripGitConfigInlineComment(value).trim().replace(/\\"/g, '"') - if ( - unescaped.length >= 2 && - ((unescaped.startsWith('"') && unescaped.endsWith('"')) || - (unescaped.startsWith("'") && unescaped.endsWith("'"))) - ) { - return unescaped.slice(1, -1) - } - return unescaped -} - -function stripGitConfigInlineComment(value: string): string { - let quote: '"' | "'" | null = null - let escaped = false - for (let i = 0; i < value.length; i++) { - const char = value[i] - if (escaped) { - escaped = false - continue - } - if (char === '\\') { - escaped = true - continue - } - if (quote) { - if (char === quote) { - quote = null - } - continue - } - if (char === '"' || char === "'") { - quote = char - continue - } - if (char === '#' || char === ';') { - return value.slice(0, i) - } - } - return value -} - -function isGitBooleanFalse(value: string): boolean { - return ['', 'false', 'no', 'off', '0'].includes(value.toLowerCase()) -} +export { + isGitRepo, + getGitRepoRoot, + getLinkedWorktreeMainRepoRoot, + normalizeGitRepoRootForInputPath +} from './repo-detection' +export { + DEFAULT_BASE_REF_PROBES, + getDefaultBaseRef, + getBaseRefDefault, + resolveDefaultBaseRefViaExec, + resolveDefaultBaseRefWithLocalGit +} from './repo-default-base-ref' +export type { GitExec } from './repo-default-base-ref' +export { + buildSearchBaseRefsArgv, + mergeBaseRefSearchResultGroups, + searchBaseRefs, + searchBaseRefDetails, + parseAndFilterSearchRefDetails, + normalizeRefSearchQuery +} from './repo-base-ref-search' +export { getBranchConflictKind } from './repo-branch-conflict' +export type { BranchConflictKind } from './repo-branch-conflict' +export { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' /** Get a human-readable name for the repo from its path. */ export function getRepoName(path: string): string { const name = basename(path) - // Strip .git suffix from bare repos return name.endsWith('.git') ? name.slice(0, -4) : name } /** Get the remote origin URL, or null if not set. */ export function getRemoteUrl(path: string): string | null { try { - return getRemoteUrlByName(path, 'origin') + return gitExecFileSync(['remote', 'get-url', 'origin'], { cwd: path }).trim() } catch { return null } } -function getRemoteUrlByName(path: string, remote: string): string { - return gitExecFileSync(['remote', 'get-url', remote], { - cwd: path - }).trim() -} - -function hasGitRef(path: string, ref: string): boolean { - try { - gitExecFileSync(['rev-parse', '--verify', ref], { - cwd: path - }) - return true - } catch { - return false - } -} - -function gitRefToDefaultBaseRef(ref: string): string { - return ref.replace(/^refs\/remotes\//, '') -} - -function getVerifiedOriginHeadBaseRef(path: string): string | null { - try { - const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { - cwd: path - }).trim() - - // Why: origin/HEAD may survive a default-branch rename pointing at a deleted ref; verify before trusting it. - return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null - } catch { - return null - } -} - -/** - * Resolve the default base ref for new worktrees, preferring the remote primary over a stale local branch. - * Returns null when nothing resolves (rather than a hardcoded `origin/main`) so callers fail loudly or degrade. - */ -export function getDefaultBaseRef(path: string): string | null { - const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path) - if (originHeadBaseRef) { - return originHeadBaseRef - } - - // Why: walk the shared DEFAULT_BASE_REF_PROBES so sync and async/SSH paths can't drift on ref order. - for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { - if (hasGitRef(path, ref)) { - return returnAs - } - } - return null -} - -export async function getBaseRefDefault( - path: string, - options: LocalGitExecOptions = {} -): Promise { - return getDefaultBaseRefAsync(path, options) -} - -/** - * Return { ahead, behind } (merge-base-symmetric delta) for localRef vs remoteRef, or null on failure. - * ahead = commits on localRef not in remoteRef; behind = the reverse. Used by the stale-base dispatch guard (§3.1). - * Async because this runs before every orchestration dispatch on Electron main. - */ +/** Return the symmetric commit delta, or null when Git cannot prove it. */ export async function getRemoteDrift( repoPath: string, localRef: string, @@ -561,10 +76,7 @@ export async function getRemoteDrift( } } -/** - * Up to `limit` commit subjects on remoteRef but not localRef, recency order; [] on git failure. - * Powers the preamble drift section (§3.2) so a worker sees whether stale-base drift touches its area. - */ +/** Return recent subjects on the remote side of the local/remote delta. */ export async function getRecentDriftSubjects( repoPath: string, localRef: string, @@ -580,238 +92,34 @@ export async function getRecentDriftSubjects( timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS } ) - return stdout.split('\n').filter((s) => s.trim().length > 0) + return stdout.split('\n').filter((subject) => subject.trim().length > 0) } catch { return [] } } -/** Parse `git remote` stdout into a remote count. Shared local/SSH so count semantics can't drift. */ +/** Parse `git remote` stdout into a remote count. */ export function parseRemoteCount(stdout: string): number { return stdout.split('\n').filter((line) => line.trim().length > 0).length } -/** Count configured remotes via `git remote`; returns 0 on error (callers read 0 as "unknown / no hint"). */ +/** Count configured remotes; zero means either none or unavailable. */ export async function getRemoteCount(path: string): Promise { try { const { stdout } = await gitExecFileAsync(['remote'], { cwd: path }) return parseRemoteCount(stdout) } catch (err) { - // Why: log so a missing multi-remote hint is debuggable; callers still treat 0 as "unknown". console.warn('[getRemoteCount] git remote failed', { path, err }) return 0 } } -/** Callback shape for a git exec function that yields stdout. */ -export type GitExec = (argv: string[]) => Promise<{ stdout: string }> - -async function hasGitRefViaExec(exec: GitExec, ref: string): Promise { - try { - await exec(['rev-parse', '--verify', '--quiet', ref]) - return true - } catch { - return false - } -} - -async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise { - try { - const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) - const ref = stdout.trim() - if (!ref || !(await hasGitRefViaExec(exec, ref))) { - return null - } - return gitRefToDefaultBaseRef(ref) - } catch { - return null - } -} - -/** - * Resolve the default base ref via a git exec callback: prefer origin/HEAD's symbolic-ref target, - * else fall back to DEFAULT_BASE_REF_PROBES. Shared local/SSH so both transports agree. - * - * Why swallow symbolic-ref's error: a non-zero exit is the expected "origin/HEAD unset" signal, not a failure. - */ -export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise { - const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec) - if (originHeadBaseRef) { - return originHeadBaseRef - } - return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref)) -} - -export function resolveDefaultBaseRefWithLocalGit( - options: LocalDefaultBaseRefGitOptions -): Promise { - return resolveDefaultBaseRefViaExec((argv) => - gitExecFileAsync(argv, { - ...options, - // Why: async avoids main-thread stalls, but dead local/WSL filesystems still need a bound. - timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS - }) - ) -} - -async function getDefaultBaseRefAsync( - path: string, - options: LocalGitExecOptions = {} -): Promise { - return resolveDefaultBaseRefWithLocalGit(gitExecOptions(path, options)) -} - -/** - * Build the argv for `git for-each-ref` used by ref search, given an already-normalized query. - * - * Why: glob every remote (`refs/remotes/*\/*`), not just origin, so fork workflows can find branches like - * `upstream/main` — see docs/upstream-base-ref-design.md. Shared with the SSH relay path so argv can't diverge. - */ -const REF_SEARCH_CANDIDATE_MULTIPLIER = 4 -const REF_SEARCH_LEGACY_HEADROOM = 100 - -type RefSearchPatternGroup = 'all' | 'segmented' | 'branchRoot' - -function getRefSearchTokens(normalizedQuery: string): string[] { - return normalizedQuery.split('/').filter((t) => t.length > 0) -} - -function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): number { - if (!Number.isInteger(limit) || limit <= 0) { - throw new Error('invalid_limit') - } - const baseCount = limit * REF_SEARCH_CANDIDATE_MULTIPLIER - return excludesRemoteHead ? baseCount : baseCount + REF_SEARCH_LEGACY_HEADROOM -} - -export function buildSearchBaseRefsArgv( - normalizedQuery: string, - limit: number, - options: { - excludeRemoteHead?: boolean - remoteNames?: readonly string[] - patternGroup?: RefSearchPatternGroup - } = {} -): string[] { - const excludeRemoteHead = options.excludeRemoteHead ?? true - const candidateCount = getRefSearchCandidateCount(limit, excludeRemoteHead) - const base = [ - 'for-each-ref', - '--format=%(refname)%00%(refname:short)', - '--sort=-committerdate', - ...(excludeRemoteHead - ? [ - // Why: exclude remote HEAD pseudo-refs before --count so the candidate window holds displayable refs. - '--exclude=refs/remotes/**/HEAD' - ] - : []), - // Why: cap git output so broad globs don't overflow execFile/SSH buffers in very large repos. - `--count=${candidateCount}` - ] - // Why: split on `/` so display-format queries route each token to one ref segment; filter empties from stray slashes. - const tokens = getRefSearchTokens(normalizedQuery) - if (tokens.length <= 1) { - const q = tokens[0] ?? '' - // Why `**` not `*`: fnmatch `*` can't cross `/`, so match slash-named branches at both leaf and ancestor segments. - return [ - ...base, - `refs/heads/**/*${q}*`, - `refs/heads/**/*${q}*/**`, - `refs/remotes/**/*${q}*`, - `refs/remotes/**/*${q}*/**` - ] - } - // Why: one `*token*` per ref segment because fnmatch `*` can't cross `/`; lets a retyped `/` result match. - const segmented = tokens.map((token) => `*${token}*`).join('/') - const substringQuery = tokens.join('/') - const remoteBranchRootPatterns = - options.remoteNames && options.remoteNames.length > 0 - ? options.remoteNames.flatMap((remote) => [ - `refs/remotes/${remote}/${substringQuery}*`, - `refs/remotes/${remote}/${substringQuery}*/**` - ]) - : [`refs/remotes/*/${substringQuery}*`, `refs/remotes/*/${substringQuery}*/**`] - const segmentedPatterns = [`refs/remotes/${segmented}`, `refs/heads/${segmented}`] - const branchRootPatterns = [ - // Why: branch names often contain slashes (plan/docs); these root patterns also match a local branch beneath any remote. - `refs/heads/${substringQuery}*`, - `refs/heads/${substringQuery}*/**`, - ...remoteBranchRootPatterns - ] - const patterns = - options.patternGroup === 'segmented' - ? segmentedPatterns - : options.patternGroup === 'branchRoot' - ? branchRootPatterns - : [...segmentedPatterns, ...branchRootPatterns] - return [...base, ...patterns] -} - -async function runSearchBaseRefsGit( - path: string, - normalizedQuery: string, - limit: number, - options: { remoteNames: readonly string[]; patternGroup?: RefSearchPatternGroup } -): Promise<{ stdout: string }> { - return getLocalGitCapabilityCache({ cwd: path }).runWithFallback( - 'for-each-ref-exclude', - () => - gitExecFileAsync( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - remoteNames: options.remoteNames, - patternGroup: options.patternGroup - }), - { cwd: path } - ), - () => - gitExecFileAsync( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - excludeRemoteHead: false, - remoteNames: options.remoteNames, - patternGroup: options.patternGroup - }), - { cwd: path } - ), - isForEachRefExcludeUnsupportedError - ) -} - -export function mergeBaseRefSearchResultGroups( - groups: readonly BaseRefSearchResult[][], - limit: number -): BaseRefSearchResult[] { - const seen = new Set() - const merged: BaseRefSearchResult[] = [] - const maxLength = Math.max(0, ...groups.map((group) => group.length)) - for (let index = 0; index < maxLength && merged.length < limit; index += 1) { - for (const group of groups) { - const entry = group[index] - if (!entry || seen.has(entry.refName)) { - continue - } - seen.add(entry.refName) - merged.push(entry) - if (merged.length >= limit) { - break - } - } - } - return merged -} - -export { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' - -/** - * Resolve the default push remote for a repo. - * Order: remote configured on the current default branch → origin → the single - * remote when the repo has exactly one → error. - */ +/** Resolve the configured push remote without assuming a provider. */ export async function getDefaultRemote( path: string, options: LocalGitExecOptions = {} ): Promise { const defaultRef = await getDefaultBaseRefAsync(path, options) - // Why: getDefaultBaseRefAsync returns null when no default branch exists; guard so .includes() can't crash. const defaultBranch = defaultRef ? defaultRef.includes('/') ? defaultRef.split('/').slice(1).join('/') @@ -859,179 +167,7 @@ export async function getDefaultRemote( } } -export async function searchBaseRefs(path: string, query: string, limit = 25): Promise { - return (await searchBaseRefDetails(path, query, limit)).map((entry) => entry.refName) -} - -export async function searchBaseRefDetails( - path: string, - query: string, - limit = 25 -): Promise { - if (!Number.isInteger(limit) || limit <= 0) { - return [] - } - const normalizedQuery = normalizeRefSearchQuery(query) - - try { - // Why: argv lives in buildSearchBaseRefsArgv so the SSH sibling cannot drift. - const remotes = await listRemoteNames(path) - const tokens = getRefSearchTokens(normalizedQuery) - if (tokens.length > 1) { - // Why: slash queries need both display-format and local-branch matches; merge before the limit so neither starves. - const results = await Promise.all([ - runSearchBaseRefsGit(path, normalizedQuery, limit, { - remoteNames: remotes, - patternGroup: 'segmented' - }), - runSearchBaseRefsGit(path, normalizedQuery, limit, { - remoteNames: remotes, - patternGroup: 'branchRoot' - }) - ]) - return mergeBaseRefSearchResultGroups( - results.map((entry) => parseAndFilterSearchRefDetails(entry.stdout, limit, remotes)), - limit - ) - } - - const result = await runSearchBaseRefsGit(path, normalizedQuery, limit, { - remoteNames: remotes - }) - return parseAndFilterSearchRefDetails(result.stdout, limit, remotes) - } catch (err) { - // Why: log so a missing result set is debuggable; callers still treat [] as "no matches". - console.warn('[searchBaseRefs] for-each-ref failed', { path, err }) - return [] - } -} - -async function listRemoteNames(path: string, options: LocalGitExecOptions = {}): Promise { - try { - const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options)) - return stdout - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - } catch { - return [] - } -} - -export function parseAndFilterSearchRefDetails( - stdout: string, - limit: number, - remotes: string[] = [] -): BaseRefSearchResult[] { - const seen = new Set() - const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length) - return ( - stdout - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => { - const nul = line.indexOf('\0') - if (nul === -1) { - // Why: no NUL means an unexpected %(refname) format; drop it rather than hand callers an unusable "short" ref. - return null - } - return { full: line.slice(0, nul), short: line.slice(nul + 1) } - }) - .filter((entry): entry is { full: string; short: string } => entry !== null) - // Why: drop `/HEAD` pseudo-refs; `.+` (not `[^/]+`) since git allows slashes in remote names. - .filter(({ full }) => !/^refs\/remotes\/.+\/HEAD$/.test(full)) - .filter(({ short }) => { - if (seen.has(short)) { - return false - } - seen.add(short) - return true - }) - .map(({ full, short }) => ({ - refName: short, - localBranchName: resolveLocalBranchName(full, short, sortedRemotes) - })) - // Why: Math.max(0, limit) so pathological limit <= 0 yields zero results, not one. - .slice(0, Math.max(0, limit)) - ) -} - -function resolveLocalBranchName(fullRef: string, shortRef: string, remotes: string[]): string { - const remoteRefPrefix = 'refs/remotes/' - if (!fullRef.startsWith(remoteRefPrefix)) { - return shortRef - } - const remoteAndBranch = fullRef.slice(remoteRefPrefix.length) - const remote = remotes.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`)) - if (remote) { - return remoteAndBranch.slice(remote.length + 1) - } - return remoteAndBranch.split('/').slice(1).join('/') || shortRef -} - -export function normalizeRefSearchQuery(query: string): string { - return query.trim().replace(/[*?[\]\\]/g, '') -} - -async function hasGitRefAsync( - path: string, - ref: string, - options: LocalGitExecOptions = {} -): Promise { - try { - await gitExecFileAsync(['rev-parse', '--verify', ref], gitExecOptions(path, options)) - return true - } catch { - return false - } -} - -export type BranchConflictKind = 'local' | 'remote' - -export async function getBranchConflictKind( - path: string, - branchName: string, - allowedBaseRef?: string, - options: LocalGitExecOptions = {} -): Promise { - if (await hasGitRefAsync(path, `refs/heads/${branchName}`, options)) { - return 'local' - } - - try { - const remoteNames = (await listRemoteNames(path, options)).sort((a, b) => b.length - a.length) - const { stdout } = await gitExecFileAsync( - ['for-each-ref', '--format=%(refname)', 'refs/remotes'], - gitExecOptions(path, options) - ) - const hasRemoteConflict = stdout.split('\n').some((ref) => { - const trimmed = ref.trim() - if (isAllowedRemoteBaseRef(trimmed, allowedBaseRef)) { - return false - } - const shortRef = trimmed.replace(/^refs\/remotes\//, '') - // Why: git allows slashes in remote names; use the configured list so foo/bar/feature resolves to branch "feature". - return resolveLocalBranchName(trimmed, shortRef, remoteNames) === branchName - }) - - return hasRemoteConflict ? 'remote' : null - } catch { - return null - } -} - -function isAllowedRemoteBaseRef(refName: string, allowedBaseRef: string | undefined): boolean { - if (!allowedBaseRef) { - return false - } - const normalizedAllowedRef = allowedBaseRef.startsWith('refs/remotes/') - ? allowedBaseRef - : `refs/remotes/${allowedBaseRef}` - return refName === normalizedAllowedRef -} - -/** Build a hosted URL (GitHub/GitLab/Bitbucket) for a file+line; null when the remote isn't a recognized host. */ +/** Build a hosted file URL when the origin belongs to a supported provider. */ export function getRemoteFileUrl( repoPath: string, relativePath: string, @@ -1041,21 +177,20 @@ export function getRemoteFileUrl( if (!remoteUrl) { return null } - const defaultBaseRef = getDefaultBaseRef(repoPath) if (!defaultBaseRef) { return null } - const defaultBranch = defaultBaseRef.replace(/^origin\//, '') - - return buildHostedRemoteFileUrl(remoteUrl, relativePath, defaultBranch, line) + return buildHostedRemoteFileUrl( + remoteUrl, + relativePath, + defaultBaseRef.replace(/^origin\//, ''), + line + ) } -/** Build a hosted URL (GitHub/GitLab/Bitbucket) for a commit; null when origin isn't a recognized host. */ +/** Build a hosted commit URL when the origin belongs to a supported provider. */ export function getRemoteCommitUrl(repoPath: string, sha: string): string | null { const remoteUrl = getRemoteUrl(repoPath) - if (!remoteUrl) { - return null - } - return buildHostedRemoteCommitUrl(remoteUrl, sha) + return remoteUrl ? buildHostedRemoteCommitUrl(remoteUrl, sha) : null } diff --git a/src/main/providers/ssh-git-noninteractive-provider.ts b/src/main/providers/ssh-git-noninteractive-provider.ts new file mode 100644 index 00000000000..ee60b254134 --- /dev/null +++ b/src/main/providers/ssh-git-noninteractive-provider.ts @@ -0,0 +1,166 @@ +import type { CommitMessagePlan } from '../../shared/commit-message-plan' +import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' +import { SshGitReadProvider } from './ssh-git-read-provider' + +type NonInteractiveExecQueueEntry = { + started: boolean + canceled: boolean + done: Promise + release: () => void +} + +const NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS = 5_000 + +export class SshGitNoninteractiveProvider extends SshGitReadProvider { + private readonly nonInteractiveExecQueues = new Map() + + async executeCommitMessagePlan( + plan: CommitMessagePlan, + cwd: string, + timeoutMs: number, + operation = 'commit-message' + ): Promise { + return this.runQueuedNonInteractiveExec( + cwd, + { + binary: plan.binary, + args: plan.args, + cwd, + stdin: plan.stdinPayload, + timeoutMs, + operation + }, + undefined, + operation + ) + } + + async execNonInteractive( + binary: string, + args: string[], + cwd: string, + timeoutMs: number, + signal?: AbortSignal, + env?: Record + ): Promise { + return this.runQueuedNonInteractiveExec( + cwd, + { + binary, + args, + cwd, + stdin: null, + timeoutMs, + ...(env ? { env } : {}) + }, + signal + ) + } + + async cancelNonInteractiveExec(cwd: string, operation?: string): Promise { + const queue = this.nonInteractiveExecQueues.get(this.nonInteractiveLaneKey(cwd, operation)) + const queuedEntry = queue?.find((entry) => !entry.started && !entry.canceled) + if (queuedEntry) { + queuedEntry.canceled = true + return + } + await this.cancelActiveNonInteractiveExec(cwd, operation) + } + + async cancelGenerateCommitMessage( + worktreePath: string, + operation = 'commit-message' + ): Promise { + await this.cancelNonInteractiveExec(worktreePath, operation) + } + + private async cancelActiveNonInteractiveExec(cwd: string, operation?: string): Promise { + try { + await this.mux.request('agent.cancelExec', { + cwd, + ...(operation ? { operation } : {}) + }) + } catch { + // Best-effort: callers are already unwinding after cancellation. + } + } + + private nonInteractiveLaneKey(cwd: string, operation?: string): string { + return JSON.stringify([operation || 'default', cwd]) + } + + private async runQueuedNonInteractiveExec( + cwd: string, + payload: { + binary: string + args: string[] + cwd: string + stdin: string | null + timeoutMs: number + env?: Record + operation?: string + }, + signal?: AbortSignal, + operation?: string + ): Promise { + const laneKey = this.nonInteractiveLaneKey(cwd, operation) + const queue = this.nonInteractiveExecQueues.get(laneKey) ?? [] + const previous = queue.at(-1)?.done ?? Promise.resolve() + let releaseEntry!: () => void + const entry: NonInteractiveExecQueueEntry = { + started: false, + canceled: false, + done: previous + .catch(() => {}) + .then( + () => + new Promise((resolve) => { + releaseEntry = resolve + }) + ), + release: () => releaseEntry() + } + queue.push(entry) + this.nonInteractiveExecQueues.set(laneKey, queue) + const abortEntry = (): void => { + if (!entry.started) { + entry.canceled = true + return + } + void this.cancelActiveNonInteractiveExec(cwd, operation) + } + if (signal?.aborted) { + entry.canceled = true + } else { + signal?.addEventListener('abort', abortEntry, { once: true }) + } + + await previous.catch(() => {}) + try { + if (entry.canceled) { + return { + stdout: '', + stderr: '', + exitCode: null, + timedOut: false, + canceled: true + } + } + entry.started = true + return (await this.mux.request('agent.execNonInteractive', payload, { + timeoutMs: payload.timeoutMs + NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS + })) as RemoteCommitMessageExecResult + } finally { + signal?.removeEventListener('abort', abortEntry) + entry.release() + const currentQueue = this.nonInteractiveExecQueues.get(laneKey) + const entryIndex = currentQueue?.indexOf(entry) ?? -1 + if (entryIndex >= 0) { + currentQueue?.splice(entryIndex, 1) + } + if (currentQueue?.length === 0) { + this.nonInteractiveExecQueues.delete(laneKey) + } + } + } +} diff --git a/src/main/providers/ssh-git-provider-api.test.ts b/src/main/providers/ssh-git-provider-api.test.ts new file mode 100644 index 00000000000..dd0915dafe5 --- /dev/null +++ b/src/main/providers/ssh-git-provider-api.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import type { IGitProvider } from './types' +import { SshGitProvider } from './ssh-git-provider' +import { createMockMux } from './ssh-git-provider-test-harness' + +describe('SshGitProvider public API parity', () => { + it('retains every historical provider operation', () => { + const provider: IGitProvider & SshGitProvider = new SshGitProvider( + 'conn-1', + createMockMux() as never + ) + const methods = [ + 'getConnectionId', + 'getHostPlatform', + 'getStatus', + 'getSubmoduleStatus', + 'checkIgnoredPaths', + 'getHistory', + 'commit', + 'getStagedCommitContext', + 'executeCommitMessagePlan', + 'execNonInteractive', + 'cancelNonInteractiveExec', + 'cancelGenerateCommitMessage', + 'getDiff', + 'stageFile', + 'unstageFile', + 'bulkStageFiles', + 'bulkUnstageFiles', + 'discardChanges', + 'bulkDiscardChanges', + 'detectConflictOperation', + 'abortMerge', + 'abortRebase', + 'checkoutBranch', + 'listLocalBranches', + 'getBranchCompare', + 'getCommitCompare', + 'getUpstreamStatus', + 'pushBranch', + 'pullBranch', + 'fastForwardBranch', + 'rebaseFromBase', + 'fetchRemote', + 'syncForkDefaultBranch', + 'fetchRemoteTrackingRef', + 'fetchGitLabMergeRequestHead', + 'fetchGitHubPullRequestHead', + 'getBranchDiff', + 'getCommitDiff', + 'listWorktrees', + 'addWorktree', + 'removeWorktree', + 'worktreeIsClean', + 'refreshLocalBaseRefForWorktreeCreate', + 'renameCurrentBranch', + 'forceDeletePreservedBranch', + 'exec', + 'clone', + 'isGitRepoAsync', + 'isGitRepo', + 'getRemoteFileUrl', + 'getRemoteCommitUrl' + ] as const + + expect(methods).toHaveLength(51) + for (const method of methods) { + expect(provider[method], method).toBeTypeOf('function') + } + }) +}) diff --git a/src/main/providers/ssh-git-provider-exec.test.ts b/src/main/providers/ssh-git-provider-exec.test.ts index 9fefe4abfda..ef0c3198a8d 100644 --- a/src/main/providers/ssh-git-provider-exec.test.ts +++ b/src/main/providers/ssh-git-provider-exec.test.ts @@ -209,6 +209,40 @@ describe('SshGitProvider', () => { await second }) + it('isolates non-interactive lanes between provider instances', async () => { + const completeRequests: (() => void)[] = [] + mux.request.mockImplementation( + () => + new Promise((resolve) => { + completeRequests.push(() => + resolve({ stdout: '', stderr: '', exitCode: 0, timedOut: false }) + ) + }) + ) + const otherProvider = new SshGitProvider('conn-2', mux as never) + + const first = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) + const second = otherProvider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) + + await waitForRequestCount(mux.request, 2) + expect(mux.request).toHaveBeenCalledTimes(2) + completeRequests.forEach((complete) => complete()) + await Promise.all([first, second]) + }) + + it('forwards leading global Git options without reordering argv', async () => { + mux.request.mockResolvedValue({ stdout: '', stderr: '' }) + const args = ['-c', 'maintenance.auto=false', 'fetch', 'origin', 'main'] + + await provider.exec(args, '/home/user/repo') + + expect(mux.request).toHaveBeenCalledWith('git.exec', { + args, + cwd: '/home/user/repo', + __streamResponse: true + }) + }) + it('cancels a queued non-interactive exec without canceling the active relay child', async () => { const completeRequests: (() => void)[] = [] mux.request.mockImplementation( diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 8086548ed5d..5fd475a0698 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -1,229 +1,16 @@ -/* eslint-disable max-lines -- Why: this provider mirrors IGitProvider one - method per RPC call (~16 methods). Splitting it would only add - indirection — every method is a 1:1 forwarder to a relay RPC plus a - small amount of param plumbing. */ -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { GitProviderStatusOptions, IGitProvider } from './types' -import type { - GitBranchCompareResult, - GitCommitCompareResult, - GitDiffResult -} from '../../shared/git-diff-compare-types' -import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' -import type { - GitConflictOperation, - GitStagingArea, - GitStatusResult, - GitUpstreamStatus -} from '../../shared/git-status-types' -import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' -import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types' -import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' -import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from '../git/hosted-remote-url' -import { JsonRpcErrorCode } from '../ssh/relay-protocol' -import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' -import type { CommitMessagePlan } from '../../shared/commit-message-plan' -import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { gitExecMutatesRepository } from '../../shared/git-exec-mutation' +import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from '../git/hosted-remote-url' import { describeMaxBufferOverflowError, isMaxBufferOverflowError } from '../git/max-buffer-overflow' -import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' -import { gitExecMutatesRepository } from '../../shared/git-exec-mutation' -import { GitStatusReadLeaseOwner } from '../git/git-status-read-lease-owner' -import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../shared/git-rebase-source' -import { GitUpstreamStatusReadOwner } from '../git/git-upstream-status-read-owner' - -type NonInteractiveExecQueueEntry = { - started: boolean - canceled: boolean - done: Promise - release: () => void -} - -const NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS = 5_000 -const ABSENT_BRANCH_DIFF_HEAD_OID = { absent: true } as const - -function isJsonRpcMethodNotFoundError(error: unknown): boolean { - if (!error || typeof error !== 'object') { - return false - } - return (error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound -} - -// Why: the relay returns the durable ref it wrote; re-deriving it on the client -// can disagree (URL normalization) and leave resolve looking at the wrong path. -function readDurableReviewHeadLocalRef( - result: unknown, - kind: 'pull request' | 'merge request' -): string { - if (result && typeof result === 'object' && 'localRef' in result) { - const localRef = (result as { localRef: unknown }).localRef - if (typeof localRef === 'string') { - const trimmed = localRef.trim() - if (trimmed.startsWith('refs/orca/')) { - return trimmed - } - } - } - throw new Error( - `This SSH host did not return the durable ${kind} head ref. Reconnect to deploy the latest relay, then try again.` - ) -} - -function formatStatusEntriesForCleanCheck(entries: GitStatusResult['entries']): string | undefined { - if (entries.length === 0) { - return undefined - } - return entries.map((entry) => `${entry.area} ${entry.status}: ${entry.path}`).join('\n') -} - -function filterUntrackedPorcelainStatus(stdout: string | undefined): string | undefined { - const trackedLines = (stdout ?? '') - .split(/\r?\n/) - .filter((line) => line.trim().length > 0 && !line.startsWith('?? ')) - return trackedLines.length > 0 ? trackedLines.join('\n') : undefined -} - -export class SshGitProvider implements IGitProvider { - private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() - private readonly statusReadLeaseOwner = new GitStatusReadLeaseOwner() - private readonly upstreamStatusReadOwner = new GitUpstreamStatusReadOwner() - - private connectionId: string - private mux: SshChannelMultiplexer - private nonInteractiveExecQueues = new Map() - - private async runWithGitReadInvalidation(run: () => Promise): Promise { - this.invalidateGitReads() - try { - return await run() - } finally { - this.invalidateGitReads() - } - } - - private invalidateGitReads(): void { - this.gitDiffReadDedupe.clear() - this.statusReadLeaseOwner.invalidate() - this.upstreamStatusReadOwner.invalidate() - } - - private loggedWorktreeIsCleanFallback = false - - constructor( - connectionId: string, - mux: SshChannelMultiplexer, - private readonly hostPlatform: RemoteHostPlatform | null = null - ) { - this.connectionId = connectionId - this.mux = mux - } - - getConnectionId(): string { - return this.connectionId - } - - getHostPlatform(): RemoteHostPlatform | null { - return this.hostPlatform - } - - async getStatus( - worktreePath: string, - options?: GitProviderStatusOptions - ): Promise { - this.gitDiffReadDedupe.clear() - const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} - const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache - ? { bypassEffectiveUpstreamNegativeCache: true } - : {} - const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {} - const branchLineTotalArgs = - options?.branchLineTotalMergeBase === undefined - ? {} - : { branchLineTotalMergeBase: options.branchLineTotalMergeBase } - const request = { - worktreePath, - ...includeIgnoredArgs, - ...upstreamCacheBypassArgs, - ...lineStatsReuseArgs, - ...branchLineTotalArgs - } - const key = stableInFlightKey([ - worktreePath, - options?.includeIgnored === true, - options?.bypassEffectiveUpstreamNegativeCache === true, - options?.reuseLineStats === true, - // Why: the result carries a total only for callers who asked, and only for - // this fork point, so a shared lease must never serve one to the other. - options?.branchLineTotalMergeBase ?? '' - ]) - return this.statusReadLeaseOwner.lease(key, options?.signal, async (sharedSignal) => { - return (await this.mux.request('git.status', request, { - signal: sharedSignal - })) as GitStatusResult - }) - } - - async getSubmoduleStatus( - worktreePath: string, - submodulePath: string, - area: GitStagingArea = 'unstaged' - ): Promise { - // Why: mirror getStatus() — refreshing submodule state must invalidate - // in-flight diff reads so a later diff can't reuse a stale pending RPC. - this.gitDiffReadDedupe.clear() - try { - return (await this.mux.request('git.submoduleStatus', { - worktreePath, - submodulePath, - area - })) as GitStatusResult - } catch (error) { - // Why: a newer desktop client may talk to an older relay that predates - // git.submoduleStatus; surface an actionable reconnect hint instead of a - // raw JSON-RPC method-not-found error in Source Control. - if (isJsonRpcMethodNotFoundError(error)) { - throw new Error( - 'SSH submodule diff support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' - ) - } - throw error - } - } - - async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise { - return (await this.mux.request('git.checkIgnored', { - worktreePath, - paths: relativePaths - })) as string[] - } - - async getHistory( - worktreePath: string, - options: GitHistoryOptions = {} - ): Promise { - return (await this.mux.request('git.history', { - worktreePath, - ...options - })) as GitHistoryResult - } - - async commit( - worktreePath: string, - message: string - ): Promise<{ success: boolean; error?: string }> { - return this.runWithGitReadInvalidation( - async () => - (await this.mux.request('git.commit', { - worktreePath, - message - })) as { success: boolean; error?: string } - ) - } +import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' +import type { IGitProvider } from './types' +import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' +import { SshGitWorktreeProvider } from './ssh-git-worktree-provider' +export class SshGitProvider extends SshGitWorktreeProvider implements IGitProvider { async getStagedCommitContext(worktreePath: string): Promise { const branchPromise = this.exec(['branch', '--show-current'], worktreePath).catch(() => ({ stdout: '' @@ -247,9 +34,6 @@ export class SshGitProvider implements IGitProvider { if (!isMaxBufferOverflowError(error)) { throw error } - // Why: a very large staged diff can overflow the remote exec buffer. The - // patch is optional context (truncated later anyway), so degrade to the - // file-name summary instead of failing commit-message generation. console.warn( '[ssh-git] Staged patch too large to read; using file summary only:', describeMaxBufferOverflowError(error) @@ -262,594 +46,6 @@ export class SshGitProvider implements IGitProvider { } } - async executeCommitMessagePlan( - plan: CommitMessagePlan, - cwd: string, - timeoutMs: number, - operation = 'commit-message' - ): Promise { - return this.runQueuedNonInteractiveExec( - cwd, - { - binary: plan.binary, - args: plan.args, - cwd, - stdin: plan.stdinPayload, - timeoutMs, - operation - }, - undefined, - operation - ) - } - - async execNonInteractive( - binary: string, - args: string[], - cwd: string, - timeoutMs: number, - signal?: AbortSignal, - env?: Record - ): Promise { - return this.runQueuedNonInteractiveExec( - cwd, - { - binary, - args, - cwd, - stdin: null, - timeoutMs, - ...(env ? { env } : {}) - }, - signal - ) - } - - async cancelNonInteractiveExec(cwd: string, operation?: string): Promise { - const queue = this.nonInteractiveExecQueues.get(this.nonInteractiveLaneKey(cwd, operation)) - const queuedEntry = queue?.find((entry) => !entry.started && !entry.canceled) - if (queuedEntry) { - queuedEntry.canceled = true - return - } - await this.cancelActiveNonInteractiveExec(cwd, operation) - } - - private async cancelActiveNonInteractiveExec(cwd: string, operation?: string): Promise { - try { - await this.mux.request('agent.cancelExec', { - cwd, - ...(operation ? { operation } : {}) - }) - } catch { - // Best-effort: callers are already unwinding after cancellation. - } - } - - async cancelGenerateCommitMessage( - worktreePath: string, - operation = 'commit-message' - ): Promise { - // Why: best-effort — the relay returns `{canceled: false}` when there is - // nothing in flight. Callers should not block UI updates on this. - await this.cancelNonInteractiveExec(worktreePath, operation) - } - - private nonInteractiveLaneKey(cwd: string, operation?: string): string { - return JSON.stringify([operation || 'default', cwd]) - } - - private async runQueuedNonInteractiveExec( - cwd: string, - payload: { - binary: string - args: string[] - cwd: string - stdin: string | null - timeoutMs: number - env?: Record - operation?: string - }, - signal?: AbortSignal, - operation?: string - ): Promise { - const laneKey = this.nonInteractiveLaneKey(cwd, operation) - const queue = this.nonInteractiveExecQueues.get(laneKey) ?? [] - const previous = queue.at(-1)?.done ?? Promise.resolve() - let releaseEntry!: () => void - const entry: NonInteractiveExecQueueEntry = { - started: false, - canceled: false, - done: previous - .catch(() => {}) - .then( - () => - new Promise((resolve) => { - releaseEntry = resolve - }) - ), - release: () => releaseEntry() - } - queue.push(entry) - this.nonInteractiveExecQueues.set(laneKey, queue) - const abortEntry = (): void => { - if (!entry.started) { - entry.canceled = true - return - } - void this.cancelActiveNonInteractiveExec(cwd, operation) - } - if (signal?.aborted) { - entry.canceled = true - } else { - signal?.addEventListener('abort', abortEntry, { once: true }) - } - - // Why: the SSH relay tracks children per operation; serialize only matching - // lanes so commit-message and PR-field generation can coexist. - await previous.catch(() => {}) - try { - if (entry.canceled) { - return { - stdout: '', - stderr: '', - exitCode: null, - timedOut: false, - canceled: true - } - } - entry.started = true - return (await this.mux.request('agent.execNonInteractive', payload, { - timeoutMs: payload.timeoutMs + NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS - })) as RemoteCommitMessageExecResult - } finally { - signal?.removeEventListener('abort', abortEntry) - entry.release() - const currentQueue = this.nonInteractiveExecQueues.get(laneKey) - const entryIndex = currentQueue?.indexOf(entry) ?? -1 - if (entryIndex >= 0) { - currentQueue?.splice(entryIndex, 1) - } - if (currentQueue?.length === 0) { - this.nonInteractiveExecQueues.delete(laneKey) - } - } - } - - async getDiff( - worktreePath: string, - filePath: string, - staged: boolean, - compareAgainstHead?: boolean - ): Promise { - return this.gitDiffReadDedupe.run( - stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), - async () => - (await requestGitStreamable(this.mux, 'git.diff', { - worktreePath, - filePath, - staged, - compareAgainstHead - })) as GitDiffResult - ) as Promise - } - - async stageFile(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.stage', { worktreePath, filePath }) - }) - } - - async unstageFile(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.unstage', { worktreePath, filePath }) - }) - } - - async bulkStageFiles(worktreePath: string, filePaths: string[]): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.bulkStage', { worktreePath, filePaths }) - }) - } - - async bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.bulkUnstage', { worktreePath, filePaths }) - }) - } - - async discardChanges(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.discard', { worktreePath, filePath }) - }) - } - - async bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.bulkDiscard', { worktreePath, filePaths }) - }) - } - - async detectConflictOperation(worktreePath: string): Promise { - return (await this.mux.request('git.conflictOperation', { - worktreePath - })) as GitConflictOperation - } - - async abortMerge(worktreePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.abortMerge', { worktreePath }) - }) - } - - async abortRebase(worktreePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.abortRebase', { worktreePath }) - }) - } - - async checkoutBranch(worktreePath: string, branch: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.checkout', { worktreePath, branch }) - }) - } - - async listLocalBranches( - worktreePath: string - ): Promise<{ current: string | null; branches: string[] }> { - return (await this.mux.request('git.localBranches', { worktreePath })) as { - current: string | null - branches: string[] - } - } - - async getBranchCompare(worktreePath: string, baseRef: string): Promise { - return (await this.mux.request('git.branchCompare', { - worktreePath, - baseRef - })) as GitBranchCompareResult - } - - async getCommitCompare(worktreePath: string, commitId: string): Promise { - return (await this.mux.request('git.commitCompare', { - worktreePath, - commitId - })) as GitCommitCompareResult - } - - async getUpstreamStatus( - worktreePath: string, - pushTarget?: GitPushTarget - ): Promise { - return this.upstreamStatusReadOwner.read( - { kind: 'ssh-provider' }, - worktreePath, - pushTarget, - async () => - (await this.mux.request('git.upstreamStatus', { - worktreePath, - ...(pushTarget ? { pushTarget } : {}) - })) as GitUpstreamStatus - ) - } - - async pushBranch( - worktreePath: string, - publish = false, - pushTarget?: GitPushTarget, - options: { forceWithLease?: boolean } = {} - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.push', { - worktreePath, - publish, - pushTarget, - ...(options.forceWithLease === true ? { forceWithLease: true } : {}) - }) - }) - } - - async pullBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.pull', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) - }) - } - - async fastForwardBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fastForward', { - worktreePath, - ...(pushTarget ? { pushTarget } : {}) - }) - }) - } - - async rebaseFromBase(worktreePath: string, baseRef: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request( - 'git.rebaseFromBase', - { worktreePath, baseRef }, - { timeoutMs: REBASE_FROM_BASE_RPC_TIMEOUT_MS } - ) - }) - } - - async fetchRemote(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fetch', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) - }) - } - - async syncForkDefaultBranch( - worktreePath: string, - expectedUpstream: GitForkSyncExpectedUpstream - ): Promise { - return this.runWithGitReadInvalidation( - async () => - (await this.mux.request('git.forkSync', { - worktreePath, - ...(expectedUpstream ? { expectedUpstream } : {}) - })) as GitForkSyncResult - ) - } - - async fetchRemoteTrackingRef( - worktreePath: string, - remote: string, - branch: string, - ref: string, - options?: { skipAutoMaintenance?: boolean } - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fetchRemoteTrackingRef', { - worktreePath, - remote, - branch, - ref, - ...(options?.skipAutoMaintenance ? { skipAutoMaintenance: true } : {}) - }) - }) - } - - async fetchGitLabMergeRequestHead( - worktreePath: string, - remote: string, - mrIid: number - ): Promise { - try { - return await this.runWithGitReadInvalidation(async () => { - // Why: the durable-ref RPC is a NEW method name. Old relays only implement - // FETCH_HEAD-semantics git.fetchGitLabMergeRequestHead, so calling the ref - // variant makes them return -32601 rather than silently no-op the durable - // ref (which would leave the client resolving a stale/missing MR head). - const result = await this.mux.request('git.fetchGitLabMergeRequestHeadRef', { - worktreePath, - remote, - mrIid - }) - // Why: use the host-written path; a second client-side get-url can disagree. - return readDurableReviewHeadLocalRef(result, 'merge request') - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - // Why: older SSH relays predate the durable-ref MR fetch; surface a - // reconnect prompt instead of a raw JSON-RPC method-not-found error. - throw new Error( - 'This SSH host is running an older Orca relay that cannot fetch merge request heads. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } - - async fetchGitHubPullRequestHead( - worktreePath: string, - remote: string, - prNumber: number - ): Promise { - try { - return await this.runWithGitReadInvalidation(async () => { - const result = await this.mux.request('git.fetchGitHubPullRequestHead', { - worktreePath, - remote, - prNumber - }) - // Why: use the host-written path; a second client-side get-url can disagree. - return readDurableReviewHeadLocalRef(result, 'pull request') - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - // Why: older SSH relays predate git.fetchGitHubPullRequestHead; surface a - // reconnect prompt instead of a raw JSON-RPC method-not-found error. - throw new Error( - 'This SSH host is running an older Orca relay that cannot fetch pull request heads. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } - - async getBranchDiff( - worktreePath: string, - baseRef: string, - options?: { includePatch?: boolean; filePath?: string; oldPath?: string; headOid?: string } - ): Promise { - const keyOptions = options ?? {} - const { headOid: rawHeadOid, ...relayOptions } = keyOptions - // Why: compare snapshots type headOid as `string | null`, so collapse an - // unpinned null to absent instead of putting a field on the wire that a - // pinned-OID relay must reject. - const headOid = rawHeadOid == null ? undefined : rawHeadOid - return this.gitDiffReadDedupe.run( - stableInFlightKey([ - 'branchDiff', - worktreePath, - baseRef, - keyOptions.includePatch ?? null, - keyOptions.filePath ?? null, - keyOptions.oldPath ?? null, - headOid === undefined ? ABSENT_BRANCH_DIFF_HEAD_OID : headOid - ]), - async () => - (await requestGitStreamable(this.mux, 'git.branchDiff', { - worktreePath, - baseRef, - ...relayOptions, - ...(headOid === undefined ? {} : { headOid }) - })) as GitDiffResult[] - ) as Promise - } - - async getCommitDiff( - worktreePath: string, - args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } - ): Promise { - return this.gitDiffReadDedupe.run( - stableInFlightKey([ - 'commitDiff', - worktreePath, - args.commitOid, - args.parentOid ?? null, - args.filePath, - args.oldPath ?? null - ]), - async () => - (await requestGitStreamable(this.mux, 'git.commitDiff', { - worktreePath, - ...args - })) as GitDiffResult - ) as Promise - } - - async listWorktrees( - repoPath: string, - options?: { signal?: AbortSignal } - ): Promise { - return (await this.mux.request( - 'git.listWorktrees', - { - repoPath - }, - { signal: options?.signal } - )) as GitWorktreeInfo[] - } - - async addWorktree( - repoPath: string, - branchName: string, - targetDir: string, - options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean } - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.addWorktree', { - repoPath, - branchName, - targetDir, - ...options - }) - }) - } - - async removeWorktree( - worktreePath: string, - force?: boolean, - options?: { deleteBranch?: boolean; forceBranchDelete?: boolean } - ): Promise { - return this.runWithGitReadInvalidation( - async () => - ((await this.mux.request('git.removeWorktree', { - worktreePath, - force, - ...options - })) ?? {}) as RemoveWorktreeResult - ) - } - - async worktreeIsClean( - worktreePath: string, - options: { includeUntracked?: boolean } = {} - ): Promise<{ clean: boolean; stdout?: string }> { - try { - const result = (await this.mux.request('git.worktreeIsClean', { - worktreePath, - ...(options.includeUntracked === false ? { includeUntracked: false } : {}) - })) as { - clean: boolean - stdout?: string - } - if (options.includeUntracked === false) { - if (!result.clean && result.stdout === undefined) { - return result - } - const trackedStdout = filterUntrackedPorcelainStatus(result.stdout) - return { clean: !trackedStdout, ...(trackedStdout ? { stdout: trackedStdout } : {}) } - } - return result - } catch (error) { - if (!isJsonRpcMethodNotFoundError(error)) { - throw error - } - if (!this.loggedWorktreeIsCleanFallback) { - this.loggedWorktreeIsCleanFallback = true - console.warn( - '[ssh-git] Relay does not implement git.worktreeIsClean; falling back to git.status clean check' - ) - } - // Why: existing SSH relays may predate git.worktreeIsClean, but git.status - // is a narrow relay RPC and avoids the generic git.exec allowlist. - const status = await this.getStatus(worktreePath) - const entries = - options.includeUntracked === false - ? status.entries.filter((entry) => entry.area !== 'untracked') - : status.entries - const clean = entries.length === 0 - return { clean, stdout: formatStatusEntriesForCleanCheck(entries) } - } - } - - async refreshLocalBaseRefForWorktreeCreate(args: { - repoPath: string - fullRef: string - remoteTrackingRef: string - ownerWorktreePath?: string - checkOnly?: boolean - }): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.refreshLocalBaseRefForWorktreeCreate', args) - }) - } - - async renameCurrentBranch(worktreePath: string, newBranch: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch }) - }) - } - - async forceDeletePreservedBranch( - repoPath: string, - branchName: string, - expectedHead: string - ): Promise { - try { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.forceDeletePreservedBranch', { - repoPath, - branchName, - expectedHead - }) - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - // Why: older SSH relays predate git.forceDeletePreservedBranch; surface - // a reconnect prompt instead of a raw JSON-RPC method-not-found error. - throw new Error( - 'This SSH host is running an older Orca relay that cannot delete preserved branches. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } - async exec( args: string[], cwd: string, @@ -862,10 +58,7 @@ export class SshGitProvider implements IGitProvider { const result = gitExecMutatesRepository(args) ? await this.runWithGitReadInvalidation(run) : await run() - return result as { - stdout: string - stderr: string - } + return result as { stdout: string; stderr: string } } async clone( @@ -897,10 +90,7 @@ export class SshGitProvider implements IGitProvider { { args, cwd, progressId }, { signal: options?.signal, timeoutMs: options?.timeoutMs } ) - return result as { - stdout: string - stderr: string - } + return result as { stdout: string; stderr: string } } catch (error) { if (isJsonRpcMethodNotFoundError(error)) { throw new Error( @@ -921,16 +111,11 @@ export class SshGitProvider implements IGitProvider { } } - // Why: isGitRepo requires synchronous return in the interface, but remote - // operations are async. We always return true for remote paths since the - // relay validates git repos on its side. The renderer already guards git - // operations behind worktree registration which validates the path. + // Remote paths are validated asynchronously by the relay before registration. isGitRepo(_path: string): boolean { return true } - // Why: SSH worktrees need the remote URL from the relay-side .git/config - // before local code can map it to a hosted source link. private async readOriginRemoteUrl(worktreePath: string): Promise { try { const result = await this.exec(['remote', 'get-url', 'origin'], worktreePath) @@ -961,7 +146,7 @@ export class SshGitProvider implements IGitProvider { defaultBranch = ref.replace(/^refs\/remotes\/origin\//, '') } } catch { - // Fall back to 'main' + // Fall back to 'main'. } return buildHostedRemoteFileUrl(remoteUrl, relativePath, defaultBranch, line) diff --git a/src/main/providers/ssh-git-read-provider.ts b/src/main/providers/ssh-git-read-provider.ts new file mode 100644 index 00000000000..54720d8d9fb --- /dev/null +++ b/src/main/providers/ssh-git-read-provider.ts @@ -0,0 +1,187 @@ +import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' +import type { GitDiffResult } from '../../shared/git-diff-compare-types' +import type { + GitStagingArea, + GitStatusResult, + GitUpstreamStatus +} from '../../shared/git-status-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import { GitStatusReadLeaseOwner } from '../git/git-status-read-lease-owner' +import { GitUpstreamStatusReadOwner } from '../git/git-upstream-status-read-owner' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import type { GitProviderStatusOptions } from './types' +import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' + +const ABSENT_BRANCH_DIFF_HEAD_OID = { absent: true } as const + +export class SshGitReadProvider { + private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() + private readonly statusReadLeaseOwner = new GitStatusReadLeaseOwner() + private readonly upstreamStatusReadOwner = new GitUpstreamStatusReadOwner() + + constructor( + private readonly connectionId: string, + protected readonly mux: SshChannelMultiplexer, + private readonly hostPlatform: RemoteHostPlatform | null = null + ) {} + + getConnectionId(): string { + return this.connectionId + } + + getHostPlatform(): RemoteHostPlatform | null { + return this.hostPlatform + } + + protected async runWithGitReadInvalidation(run: () => Promise): Promise { + this.invalidateGitReads() + try { + return await run() + } finally { + this.invalidateGitReads() + } + } + + private invalidateGitReads(): void { + this.gitDiffReadDedupe.clear() + this.statusReadLeaseOwner.invalidate() + this.upstreamStatusReadOwner.invalidate() + } + + async getStatus( + worktreePath: string, + options?: GitProviderStatusOptions + ): Promise { + this.gitDiffReadDedupe.clear() + const request = { + worktreePath, + ...(options?.includeIgnored ? { includeIgnored: true } : {}), + ...(options?.bypassEffectiveUpstreamNegativeCache + ? { bypassEffectiveUpstreamNegativeCache: true } + : {}), + ...(options?.reuseLineStats ? { reuseLineStats: true } : {}), + ...(options?.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: options.branchLineTotalMergeBase }) + } + const key = stableInFlightKey([ + worktreePath, + options?.includeIgnored === true, + options?.bypassEffectiveUpstreamNegativeCache === true, + options?.reuseLineStats === true, + options?.branchLineTotalMergeBase ?? '' + ]) + return this.statusReadLeaseOwner.lease(key, options?.signal, async (sharedSignal) => { + return (await this.mux.request('git.status', request, { + signal: sharedSignal + })) as GitStatusResult + }) + } + + async getSubmoduleStatus( + worktreePath: string, + submodulePath: string, + area: GitStagingArea = 'unstaged' + ): Promise { + this.gitDiffReadDedupe.clear() + try { + return (await this.mux.request('git.submoduleStatus', { + worktreePath, + submodulePath, + area + })) as GitStatusResult + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'SSH submodule diff support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' + ) + } + throw error + } + } + + async getDiff( + worktreePath: string, + filePath: string, + staged: boolean, + compareAgainstHead?: boolean + ): Promise { + return this.gitDiffReadDedupe.run( + stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), + async () => + (await requestGitStreamable(this.mux, 'git.diff', { + worktreePath, + filePath, + staged, + compareAgainstHead + })) as GitDiffResult + ) as Promise + } + + async getBranchDiff( + worktreePath: string, + baseRef: string, + options?: { includePatch?: boolean; filePath?: string; oldPath?: string; headOid?: string } + ): Promise { + const keyOptions = options ?? {} + const { headOid: rawHeadOid, ...relayOptions } = keyOptions + const headOid = rawHeadOid == null ? undefined : rawHeadOid + return this.gitDiffReadDedupe.run( + stableInFlightKey([ + 'branchDiff', + worktreePath, + baseRef, + keyOptions.includePatch ?? null, + keyOptions.filePath ?? null, + keyOptions.oldPath ?? null, + headOid === undefined ? ABSENT_BRANCH_DIFF_HEAD_OID : headOid + ]), + async () => + (await requestGitStreamable(this.mux, 'git.branchDiff', { + worktreePath, + baseRef, + ...relayOptions, + ...(headOid === undefined ? {} : { headOid }) + })) as GitDiffResult[] + ) as Promise + } + + async getCommitDiff( + worktreePath: string, + args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } + ): Promise { + return this.gitDiffReadDedupe.run( + stableInFlightKey([ + 'commitDiff', + worktreePath, + args.commitOid, + args.parentOid ?? null, + args.filePath, + args.oldPath ?? null + ]), + async () => + (await requestGitStreamable(this.mux, 'git.commitDiff', { + worktreePath, + ...args + })) as GitDiffResult + ) as Promise + } + + async getUpstreamStatus( + worktreePath: string, + pushTarget?: GitPushTarget + ): Promise { + return this.upstreamStatusReadOwner.read( + { kind: 'ssh-provider' }, + worktreePath, + pushTarget, + async () => + (await this.mux.request('git.upstreamStatus', { + worktreePath, + ...(pushTarget ? { pushTarget } : {}) + })) as GitUpstreamStatus + ) + } +} diff --git a/src/main/providers/ssh-git-relay-errors.ts b/src/main/providers/ssh-git-relay-errors.ts new file mode 100644 index 00000000000..d8bd8fe9016 --- /dev/null +++ b/src/main/providers/ssh-git-relay-errors.ts @@ -0,0 +1,8 @@ +import { JsonRpcErrorCode } from '../ssh/relay-protocol' + +export function isJsonRpcMethodNotFoundError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + return (error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound +} diff --git a/src/main/providers/ssh-git-remote-sync-provider.ts b/src/main/providers/ssh-git-remote-sync-provider.ts new file mode 100644 index 00000000000..bead9414f3b --- /dev/null +++ b/src/main/providers/ssh-git-remote-sync-provider.ts @@ -0,0 +1,84 @@ +import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' +import type { GitPushTarget } from '../../shared/worktree/types' +import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../shared/git-rebase-source' +import { SshGitWorkingTreeProvider } from './ssh-git-working-tree-provider' + +export class SshGitRemoteSyncProvider extends SshGitWorkingTreeProvider { + async pushBranch( + worktreePath: string, + publish = false, + pushTarget?: GitPushTarget, + options: { forceWithLease?: boolean } = {} + ): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.push', { + worktreePath, + publish, + pushTarget, + ...(options.forceWithLease === true ? { forceWithLease: true } : {}) + }) + }) + } + + async pullBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.pull', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) + }) + } + + async fastForwardBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.fastForward', { + worktreePath, + ...(pushTarget ? { pushTarget } : {}) + }) + }) + } + + async rebaseFromBase(worktreePath: string, baseRef: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request( + 'git.rebaseFromBase', + { worktreePath, baseRef }, + { timeoutMs: REBASE_FROM_BASE_RPC_TIMEOUT_MS } + ) + }) + } + + async fetchRemote(worktreePath: string, pushTarget?: GitPushTarget): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.fetch', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) + }) + } + + async syncForkDefaultBranch( + worktreePath: string, + expectedUpstream: GitForkSyncExpectedUpstream + ): Promise { + return this.runWithGitReadInvalidation( + async () => + (await this.mux.request('git.forkSync', { + worktreePath, + ...(expectedUpstream ? { expectedUpstream } : {}) + })) as GitForkSyncResult + ) + } + + async fetchRemoteTrackingRef( + worktreePath: string, + remote: string, + branch: string, + ref: string, + options?: { skipAutoMaintenance?: boolean } + ): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.fetchRemoteTrackingRef', { + worktreePath, + remote, + branch, + ref, + ...(options?.skipAutoMaintenance ? { skipAutoMaintenance: true } : {}) + }) + }) + } +} diff --git a/src/main/providers/ssh-git-review-head-provider.ts b/src/main/providers/ssh-git-review-head-provider.ts new file mode 100644 index 00000000000..5ff5cc69048 --- /dev/null +++ b/src/main/providers/ssh-git-review-head-provider.ts @@ -0,0 +1,70 @@ +import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' +import { SshGitRemoteSyncProvider } from './ssh-git-remote-sync-provider' + +function readDurableReviewHeadLocalRef( + result: unknown, + kind: 'pull request' | 'merge request' +): string { + if (result && typeof result === 'object' && 'localRef' in result) { + const localRef = (result as { localRef: unknown }).localRef + if (typeof localRef === 'string') { + const trimmed = localRef.trim() + if (trimmed.startsWith('refs/orca/')) { + return trimmed + } + } + } + throw new Error( + `This SSH host did not return the durable ${kind} head ref. Reconnect to deploy the latest relay, then try again.` + ) +} + +export class SshGitReviewHeadProvider extends SshGitRemoteSyncProvider { + async fetchGitLabMergeRequestHead( + worktreePath: string, + remote: string, + mrIid: number + ): Promise { + try { + return await this.runWithGitReadInvalidation(async () => { + const result = await this.mux.request('git.fetchGitLabMergeRequestHeadRef', { + worktreePath, + remote, + mrIid + }) + return readDurableReviewHeadLocalRef(result, 'merge request') + }) + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'This SSH host is running an older Orca relay that cannot fetch merge request heads. Reconnect to deploy the latest relay, then try again.' + ) + } + throw error + } + } + + async fetchGitHubPullRequestHead( + worktreePath: string, + remote: string, + prNumber: number + ): Promise { + try { + return await this.runWithGitReadInvalidation(async () => { + const result = await this.mux.request('git.fetchGitHubPullRequestHead', { + worktreePath, + remote, + prNumber + }) + return readDurableReviewHeadLocalRef(result, 'pull request') + }) + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'This SSH host is running an older Orca relay that cannot fetch pull request heads. Reconnect to deploy the latest relay, then try again.' + ) + } + throw error + } + } +} diff --git a/src/main/providers/ssh-git-working-tree-provider.ts b/src/main/providers/ssh-git-working-tree-provider.ts new file mode 100644 index 00000000000..6942b91ca1b --- /dev/null +++ b/src/main/providers/ssh-git-working-tree-provider.ts @@ -0,0 +1,122 @@ +import type { + GitBranchCompareResult, + GitCommitCompareResult +} from '../../shared/git-diff-compare-types' +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import type { GitConflictOperation } from '../../shared/git-status-types' +import { SshGitNoninteractiveProvider } from './ssh-git-noninteractive-provider' + +export class SshGitWorkingTreeProvider extends SshGitNoninteractiveProvider { + async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise { + return (await this.mux.request('git.checkIgnored', { + worktreePath, + paths: relativePaths + })) as string[] + } + + async getHistory( + worktreePath: string, + options: GitHistoryOptions = {} + ): Promise { + return (await this.mux.request('git.history', { + worktreePath, + ...options + })) as GitHistoryResult + } + + async commit( + worktreePath: string, + message: string + ): Promise<{ success: boolean; error?: string }> { + return this.runWithGitReadInvalidation( + async () => + (await this.mux.request('git.commit', { + worktreePath, + message + })) as { success: boolean; error?: string } + ) + } + + async stageFile(worktreePath: string, filePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.stage', { worktreePath, filePath }) + }) + } + + async unstageFile(worktreePath: string, filePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.unstage', { worktreePath, filePath }) + }) + } + + async bulkStageFiles(worktreePath: string, filePaths: string[]): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.bulkStage', { worktreePath, filePaths }) + }) + } + + async bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.bulkUnstage', { worktreePath, filePaths }) + }) + } + + async discardChanges(worktreePath: string, filePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.discard', { worktreePath, filePath }) + }) + } + + async bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.bulkDiscard', { worktreePath, filePaths }) + }) + } + + async detectConflictOperation(worktreePath: string): Promise { + return (await this.mux.request('git.conflictOperation', { + worktreePath + })) as GitConflictOperation + } + + async abortMerge(worktreePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.abortMerge', { worktreePath }) + }) + } + + async abortRebase(worktreePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.abortRebase', { worktreePath }) + }) + } + + async checkoutBranch(worktreePath: string, branch: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.checkout', { worktreePath, branch }) + }) + } + + async listLocalBranches( + worktreePath: string + ): Promise<{ current: string | null; branches: string[] }> { + return (await this.mux.request('git.localBranches', { worktreePath })) as { + current: string | null + branches: string[] + } + } + + async getBranchCompare(worktreePath: string, baseRef: string): Promise { + return (await this.mux.request('git.branchCompare', { + worktreePath, + baseRef + })) as GitBranchCompareResult + } + + async getCommitCompare(worktreePath: string, commitId: string): Promise { + return (await this.mux.request('git.commitCompare', { + worktreePath, + commitId + })) as GitCommitCompareResult + } +} diff --git a/src/main/providers/ssh-git-worktree-provider.ts b/src/main/providers/ssh-git-worktree-provider.ts new file mode 100644 index 00000000000..ce509ccf847 --- /dev/null +++ b/src/main/providers/ssh-git-worktree-provider.ts @@ -0,0 +1,143 @@ +import type { GitStatusResult } from '../../shared/git-status-types' +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' +import { SshGitReviewHeadProvider } from './ssh-git-review-head-provider' + +function formatStatusEntriesForCleanCheck(entries: GitStatusResult['entries']): string | undefined { + if (entries.length === 0) { + return undefined + } + return entries.map((entry) => `${entry.area} ${entry.status}: ${entry.path}`).join('\n') +} + +function filterUntrackedPorcelainStatus(stdout: string | undefined): string | undefined { + const trackedLines = (stdout ?? '') + .split(/\r?\n/) + .filter((line) => line.trim().length > 0 && !line.startsWith('?? ')) + return trackedLines.length > 0 ? trackedLines.join('\n') : undefined +} + +export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { + private loggedWorktreeIsCleanFallback = false + + async listWorktrees( + repoPath: string, + options?: { signal?: AbortSignal } + ): Promise { + return (await this.mux.request( + 'git.listWorktrees', + { repoPath }, + { signal: options?.signal } + )) as GitWorktreeInfo[] + } + + async addWorktree( + repoPath: string, + branchName: string, + targetDir: string, + options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean } + ): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.addWorktree', { + repoPath, + branchName, + targetDir, + ...options + }) + }) + } + + async removeWorktree( + worktreePath: string, + force?: boolean, + options?: { deleteBranch?: boolean; forceBranchDelete?: boolean } + ): Promise { + return this.runWithGitReadInvalidation( + async () => + ((await this.mux.request('git.removeWorktree', { + worktreePath, + force, + ...options + })) ?? {}) as RemoveWorktreeResult + ) + } + + async worktreeIsClean( + worktreePath: string, + options: { includeUntracked?: boolean } = {} + ): Promise<{ clean: boolean; stdout?: string }> { + try { + const result = (await this.mux.request('git.worktreeIsClean', { + worktreePath, + ...(options.includeUntracked === false ? { includeUntracked: false } : {}) + })) as { clean: boolean; stdout?: string } + if (options.includeUntracked === false) { + if (!result.clean && result.stdout === undefined) { + return result + } + const trackedStdout = filterUntrackedPorcelainStatus(result.stdout) + return { clean: !trackedStdout, ...(trackedStdout ? { stdout: trackedStdout } : {}) } + } + return result + } catch (error) { + if (!isJsonRpcMethodNotFoundError(error)) { + throw error + } + if (!this.loggedWorktreeIsCleanFallback) { + this.loggedWorktreeIsCleanFallback = true + console.warn( + '[ssh-git] Relay does not implement git.worktreeIsClean; falling back to git.status clean check' + ) + } + const status = await this.getStatus(worktreePath) + const entries = + options.includeUntracked === false + ? status.entries.filter((entry) => entry.area !== 'untracked') + : status.entries + const clean = entries.length === 0 + return { clean, stdout: formatStatusEntriesForCleanCheck(entries) } + } + } + + async refreshLocalBaseRefForWorktreeCreate(args: { + repoPath: string + fullRef: string + remoteTrackingRef: string + ownerWorktreePath?: string + checkOnly?: boolean + }): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.refreshLocalBaseRefForWorktreeCreate', args) + }) + } + + async renameCurrentBranch(worktreePath: string, newBranch: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch }) + }) + } + + async forceDeletePreservedBranch( + repoPath: string, + branchName: string, + expectedHead: string + ): Promise { + try { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.forceDeletePreservedBranch', { + repoPath, + branchName, + expectedHead + }) + }) + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'This SSH host is running an older Orca relay that cannot delete preserved branches. Reconnect to deploy the latest relay, then try again.' + ) + } + throw error + } + } +} From d3c37b8e40dd0b14a68c492fdbaecb92a1738efa Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:44:53 -0700 Subject: [PATCH 30/65] refactor(renderer): split web preload API (#16181) --- config/max-lines-baseline.txt | 1 - .../web/preload-api/web-agent-accounts-api.ts | 49 + .../web/preload-api/web-agent-hooks-api.ts | 42 + .../web/preload-api/web-agent-status-api.ts | 24 + .../src/web/preload-api/web-ai-vault-api.ts | 93 + .../src/web/preload-api/web-app-api.ts | 55 + .../src/web/preload-api/web-browser-api.ts | 101 + .../src/web/preload-api/web-cli-api.ts | 27 + .../src/web/preload-api/web-clipboard-api.ts | 177 + .../web/preload-api/web-diagnostics-api.ts | 41 + .../src/web/preload-api/web-e2e-api.ts | 10 + .../src/web/preload-api/web-e2e-config.ts | 11 + .../src/web/preload-api/web-fallback-api.ts | 63 + .../src/web/preload-api/web-filesystem-api.ts | 200 + .../src/web/preload-api/web-git-api.ts | 291 ++ .../src/web/preload-api/web-github-api.ts | 193 + .../web/preload-api/web-github-cache-api.ts | 19 + .../src/web/preload-api/web-github-routes.ts | 150 + .../src/web/preload-api/web-gitlab-api.ts | 71 + .../src/web/preload-api/web-gitlab-routes.ts | 71 + .../preload-api/web-host-capability-api.ts | 195 + .../web-keybinding-normalization.ts | 174 + .../web/preload-api/web-keybindings-api.ts | 174 + .../src/web/preload-api/web-macos-tcc-api.ts | 13 + .../src/web/preload-api/web-memory-api.ts | 24 + .../src/web/preload-api/web-mobile-api.ts | 26 + .../web/preload-api/web-native-chat-api.ts | 156 + .../web/preload-api/web-notifications-api.ts | 14 + .../src/web/preload-api/web-onboarding-api.ts | 56 + .../web/preload-api/web-orca-profiles-api.ts | 70 + .../src/web/preload-api/web-platform-api.ts | 16 + .../web-preference-normalization.ts | 164 + .../web/preload-api/web-preferences-store.ts | 297 ++ .../web/preload-api/web-rate-limits-api.ts | 36 + .../web/preload-api/web-repositories-api.ts | 130 + .../src/web/preload-api/web-review-api.ts | 44 + .../src/web/preload-api/web-runtime-api.ts | 21 + .../src/web/preload-api/web-runtime-calls.ts | 120 + .../web-runtime-environments-api.ts | 198 + .../web/preload-api/web-runtime-session.ts | 145 + .../web-runtime-worktree-catalog.ts | 183 + .../src/web/preload-api/web-settings-api.ts | 118 + .../src/web/preload-api/web-shell-api.ts | 30 + .../src/web/preload-api/web-star-nag-api.ts | 21 + .../src/web/preload-api/web-storage.ts | 43 + .../src/web/preload-api/web-telemetry-api.ts | 11 + .../src/web/preload-api/web-terminal-api.ts | 180 + .../src/web/preload-api/web-ui-api.ts | 241 + .../src/web/preload-api/web-updater-api.ts | 34 + .../preload-api/web-workspace-ports-api.ts | 23 + .../preload-api/web-workspace-session-api.ts | 71 + .../src/web/preload-api/web-worktrees-api.ts | 188 + .../src/web/web-preload-api-clipboard.test.ts | 17 +- .../web/web-preload-api-composition.test.ts | 119 + .../src/web/web-preload-api-github.test.ts | 3 +- .../src/web/web-preload-api-gitlab.test.ts | 3 +- .../web/web-preload-api-runtime-calls.test.ts | 206 + ...eb-preload-api-runtime-environment.test.ts | 55 + .../web-preload-api-workspace-catalog.test.ts | 34 + src/renderer/src/web/web-preload-api.ts | 4596 +---------------- .../web-runtime-client-timeout-budget.test.ts | 78 + 61 files changed, 5480 insertions(+), 4536 deletions(-) create mode 100644 src/renderer/src/web/preload-api/web-agent-accounts-api.ts create mode 100644 src/renderer/src/web/preload-api/web-agent-hooks-api.ts create mode 100644 src/renderer/src/web/preload-api/web-agent-status-api.ts create mode 100644 src/renderer/src/web/preload-api/web-ai-vault-api.ts create mode 100644 src/renderer/src/web/preload-api/web-app-api.ts create mode 100644 src/renderer/src/web/preload-api/web-browser-api.ts create mode 100644 src/renderer/src/web/preload-api/web-cli-api.ts create mode 100644 src/renderer/src/web/preload-api/web-clipboard-api.ts create mode 100644 src/renderer/src/web/preload-api/web-diagnostics-api.ts create mode 100644 src/renderer/src/web/preload-api/web-e2e-api.ts create mode 100644 src/renderer/src/web/preload-api/web-e2e-config.ts create mode 100644 src/renderer/src/web/preload-api/web-fallback-api.ts create mode 100644 src/renderer/src/web/preload-api/web-filesystem-api.ts create mode 100644 src/renderer/src/web/preload-api/web-git-api.ts create mode 100644 src/renderer/src/web/preload-api/web-github-api.ts create mode 100644 src/renderer/src/web/preload-api/web-github-cache-api.ts create mode 100644 src/renderer/src/web/preload-api/web-github-routes.ts create mode 100644 src/renderer/src/web/preload-api/web-gitlab-api.ts create mode 100644 src/renderer/src/web/preload-api/web-gitlab-routes.ts create mode 100644 src/renderer/src/web/preload-api/web-host-capability-api.ts create mode 100644 src/renderer/src/web/preload-api/web-keybinding-normalization.ts create mode 100644 src/renderer/src/web/preload-api/web-keybindings-api.ts create mode 100644 src/renderer/src/web/preload-api/web-macos-tcc-api.ts create mode 100644 src/renderer/src/web/preload-api/web-memory-api.ts create mode 100644 src/renderer/src/web/preload-api/web-mobile-api.ts create mode 100644 src/renderer/src/web/preload-api/web-native-chat-api.ts create mode 100644 src/renderer/src/web/preload-api/web-notifications-api.ts create mode 100644 src/renderer/src/web/preload-api/web-onboarding-api.ts create mode 100644 src/renderer/src/web/preload-api/web-orca-profiles-api.ts create mode 100644 src/renderer/src/web/preload-api/web-platform-api.ts create mode 100644 src/renderer/src/web/preload-api/web-preference-normalization.ts create mode 100644 src/renderer/src/web/preload-api/web-preferences-store.ts create mode 100644 src/renderer/src/web/preload-api/web-rate-limits-api.ts create mode 100644 src/renderer/src/web/preload-api/web-repositories-api.ts create mode 100644 src/renderer/src/web/preload-api/web-review-api.ts create mode 100644 src/renderer/src/web/preload-api/web-runtime-api.ts create mode 100644 src/renderer/src/web/preload-api/web-runtime-calls.ts create mode 100644 src/renderer/src/web/preload-api/web-runtime-environments-api.ts create mode 100644 src/renderer/src/web/preload-api/web-runtime-session.ts create mode 100644 src/renderer/src/web/preload-api/web-runtime-worktree-catalog.ts create mode 100644 src/renderer/src/web/preload-api/web-settings-api.ts create mode 100644 src/renderer/src/web/preload-api/web-shell-api.ts create mode 100644 src/renderer/src/web/preload-api/web-star-nag-api.ts create mode 100644 src/renderer/src/web/preload-api/web-storage.ts create mode 100644 src/renderer/src/web/preload-api/web-telemetry-api.ts create mode 100644 src/renderer/src/web/preload-api/web-terminal-api.ts create mode 100644 src/renderer/src/web/preload-api/web-ui-api.ts create mode 100644 src/renderer/src/web/preload-api/web-updater-api.ts create mode 100644 src/renderer/src/web/preload-api/web-workspace-ports-api.ts create mode 100644 src/renderer/src/web/preload-api/web-workspace-session-api.ts create mode 100644 src/renderer/src/web/preload-api/web-worktrees-api.ts create mode 100644 src/renderer/src/web/web-preload-api-composition.test.ts create mode 100644 src/renderer/src/web/web-preload-api-runtime-calls.test.ts create mode 100644 src/renderer/src/web/web-runtime-client-timeout-budget.test.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 79d033f010d..ae05468b04a 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -146,7 +146,6 @@ inline src/renderer/src/store/slices/tabs.ts inline src/renderer/src/store/slices/terminals.ts inline src/renderer/src/store/slices/ui.ts inline src/renderer/src/store/slices/workspace-cleanup.ts -inline src/renderer/src/web/web-preload-api.ts inline src/renderer/src/web/web-runtime-client.ts inline src/shared/agent-hook-listener.ts inline src/shared/automation-schedules.ts diff --git a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts new file mode 100644 index 00000000000..921c8ca7a5d --- /dev/null +++ b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts @@ -0,0 +1,49 @@ +import type { PreloadApi } from '../../../../preload/api-types' + +export function createMiniMaxCredentialsApi(): NonNullable< + Partial['minimaxCredentials'] +> { + const notConfigured = { configured: false } + const unsupportedError = new Error('MiniMax cookie storage is only available in the desktop app.') + return { + getStatus: () => Promise.resolve(notConfigured), + saveCookie: () => Promise.reject(unsupportedError), + clearCookie: () => Promise.resolve(notConfigured) + } +} + +export function createGrokAccountsApi(): NonNullable['grokAccounts']> { + const unsigned = { + signedIn: false, + email: null, + teamId: null, + tokenFresh: false, + error: null + } + return { + getStatus: () => Promise.resolve(unsigned) + } +} + +export function createAccountsApi(): never { + const empty = { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } + return { + list: () => Promise.resolve(empty), + add: () => Promise.resolve(empty), + cancelPendingLogin: () => Promise.resolve(false), + reauthenticate: () => Promise.resolve(empty), + remove: () => Promise.resolve(empty), + select: () => Promise.resolve(empty), + // Why: launch accounts are recorded on the host that owns the PTY, which the + // web client never is — report no stale panes rather than reject the sweep. + listStalePanes: () => Promise.resolve([]), + // Why empty rather than absent: the same host owns both records, so a web + // client has no recorded lane to offer and every pane falls to derivation. + listRecordedPaneLanes: () => Promise.resolve({}), + forgetStalePanes: () => Promise.resolve() + } as never +} diff --git a/src/renderer/src/web/preload-api/web-agent-hooks-api.ts b/src/renderer/src/web/preload-api/web-agent-hooks-api.ts new file mode 100644 index 00000000000..41ed387d559 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-agent-hooks-api.ts @@ -0,0 +1,42 @@ +import type { PreloadApi } from '../../../../preload/api-types' + +export function createAgentHooksApi(): NonNullable['agentHooks']> { + const status = ( + agent: + | 'claude' + | 'openclaude' + | 'codex' + | 'gemini' + | 'antigravity' + | 'amp' + | 'cursor' + | 'droid' + | 'command-code' + | 'grok' + | 'copilot' + | 'hermes' + | 'devin' + ) => + Promise.resolve({ + agent, + state: 'not_installed', + configPath: '', + managedHooksPresent: false, + detail: 'Agent hook status is only available on the Orca server.' + } as const) + return { + claudeStatus: () => status('claude'), + openClaudeStatus: () => status('openclaude'), + codexStatus: () => status('codex'), + geminiStatus: () => status('gemini'), + antigravityStatus: () => status('antigravity'), + ampStatus: () => status('amp'), + cursorStatus: () => status('cursor'), + droidStatus: () => status('droid'), + commandCodeStatus: () => status('command-code'), + grokStatus: () => status('grok'), + copilotStatus: () => status('copilot'), + hermesStatus: () => status('hermes'), + devinStatus: () => status('devin') + } +} diff --git a/src/renderer/src/web/preload-api/web-agent-status-api.ts b/src/renderer/src/web/preload-api/web-agent-status-api.ts new file mode 100644 index 00000000000..ed4636ab5fb --- /dev/null +++ b/src/renderer/src/web/preload-api/web-agent-status-api.ts @@ -0,0 +1,24 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { noopUnsubscribe } from './web-storage' + +export function createWebAgentStatusApi(): Partial { + return { + agentStatus: { + onSet: () => noopUnsubscribe, + onClear: () => noopUnsubscribe, + getSnapshot: () => Promise.resolve([]), + inferInterrupt: () => Promise.resolve(false), + inferQuestionAnswered: () => Promise.resolve(false), + onMigrationUnsupported: () => noopUnsubscribe, + onMigrationUnsupportedClear: () => noopUnsubscribe, + onLegacyWorkerTerminalRecovery: () => noopUnsubscribe, + getMigrationUnsupportedSnapshot: () => Promise.resolve([]), + drop: () => {}, + reconcileEndedProcess: () => {}, + dropByTabPrefix: () => {}, + retirePaneAuthority: () => {}, + restorePaneAuthority: () => {}, + transferPaneAuthority: () => {} + } + } +} diff --git a/src/renderer/src/web/preload-api/web-ai-vault-api.ts b/src/renderer/src/web/preload-api/web-ai-vault-api.ts new file mode 100644 index 00000000000..383a657f7b8 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-ai-vault-api.ts @@ -0,0 +1,93 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../../../../shared/ai-vault-resume-preparation' +import type { AiVaultDeleteSessionArgs } from '../../../../shared/ai-vault-session-deletion' +import type { + AiVaultSessionTitlesArgs, + AiVaultSessionTitlesResult +} from '../../../../shared/ai-vault-session-title' +import type { AiVaultListArgs, AiVaultListResult } from '../../../../shared/ai-vault-types' +import { + normalizeExecutionHostScope, + toRuntimeExecutionHostId +} from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironment } from './web-runtime-session' +import { noopUnsubscribe } from './web-storage' +import { translate } from '@/i18n/i18n' + +export function createWebAiVaultApi(): NonNullable['aiVault']> { + return { + listSessions: (args?: AiVaultListArgs) => { + const environment = requireActiveEnvironment() + const executionHostId = toRuntimeExecutionHostId(environment.id) + const requestedScope = normalizeExecutionHostScope( + args?.executionHostScope ?? executionHostId + ) + if (requestedScope !== 'all' && requestedScope !== executionHostId) { + return Promise.resolve(webAiVaultUnavailableResult(requestedScope)) + } + // Why: no local filesystem in the browser, so every history scan runs on and is stamped as the paired runtime host. + return callRuntimeResult('aiVault.listSessions', { + limit: args?.limit, + force: args?.force, + scopePaths: args?.scopePaths, + executionHostId + }) + }, + resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => { + const environment = requireActiveEnvironment() + const executionHostId = toRuntimeExecutionHostId(environment.id) + if ( + args.executionHostScope && + normalizeExecutionHostScope(args.executionHostScope) !== executionHostId + ) { + return Promise.resolve({ titles: [] }) + } + return callRuntimeResult('aiVault.resolveSessionTitles', { + requests: args.requests + }).catch(() => ({ titles: [] })) + }, + // Why: the runtime RPC transport has no cancel verb, so the in-flight scan + // settles on its own timeout. The renderer's refreshId guard already drops + // the late result; this only means web pays for a scan nobody reads. + cancelListSessions: () => Promise.resolve(), + prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) => + callRuntimeResult('aiVault.prepareSessionResume', args), + // Why: no server-side RPC for subagent transcript listing yet, so report an empty (not erroring) result. + listSubagentSessions: () => Promise.resolve({ sessions: [], issues: [] }), + // Why: full first-prompt re-parse is local-FS only; web/runtime falls back to preview text. + getFirstUserPrompt: () => Promise.resolve({ prompt: null }), + // Why: session deletion is local-only and has no runtime RPC; a web + // client's sessions are runtime-hosted, so report the same non-local + // rejection the UI already gates on rather than pretend to delete. + deleteSession: (args: AiVaultDeleteSessionArgs) => + Promise.resolve({ + outcome: 'rejected', + agent: args.agent, + reason: 'non-local-host' as const + }), + onWindowFocused: () => noopUnsubscribe + } +} + +export function webAiVaultUnavailableResult(executionHostId: ExecutionHostId): AiVaultListResult { + return { + sessions: [], + issues: [ + { + executionHostId, + agent: 'codex', + path: executionHostId, + message: translate( + 'auto.web.webPreloadApi.aiVaultUnavailableForHost', + 'Agent Session History is not available for this execution host.' + ) + } + ], + scannedAt: new Date().toISOString() + } +} diff --git a/src/renderer/src/web/preload-api/web-app-api.ts b/src/renderer/src/web/preload-api/web-app-api.ts new file mode 100644 index 00000000000..68d9b80eb55 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-app-api.ts @@ -0,0 +1,55 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { sanitizeWebRuntimeWorkspaceSession } from '../web-workspace-session' +import { sessionStorageKeyForHost } from './web-workspace-session-api' +import { mergeWebUIState } from './web-preference-normalization' +import { readLocalWebUIState } from './web-preferences-store' +import { UI_STORAGE_KEY, writeJson } from './web-storage' + +export function createWebAppApi(): Partial { + return { + app: { + getIdentity: () => + Promise.resolve({ + name: 'Orca', + isDev: false, + devLabel: null, + devBranch: null, + devWorktreeName: null, + devRepoRoot: null, + dockBadgeLabel: null + }), + getFeatureWallAssetBaseUrl: () => Promise.resolve('/'), + relaunch: () => Promise.resolve(window.location.reload()), + restart: () => Promise.resolve(window.location.reload()), + reload: () => Promise.resolve(window.location.reload()), + stageBeforeUnloadSync: ({ sessions, ui }) => { + // Why: beforeunload cannot await the paired runtime, so the web adapter + // guarantees immediate browser-local durability for the final snapshot. + for (const { state, hostId } of sessions) { + writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(state)) + } + writeJson(UI_STORAGE_KEY, mergeWebUIState(readLocalWebUIState(), ui)) + }, + // Staging already wrote through to browser storage, so there is nothing left to join. + awaitBeforeUnloadCheckpoint: () => Promise.resolve(), + awaitFirstWindowStartupServices: () => Promise.resolve(), + recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), + startupDiagnostic: () => Promise.resolve(), + getKeyboardInputSourceId: () => Promise.resolve(null), + // The web client cannot inspect local Mission Control shortcuts. + getMacCapturedDigitRowChords: () => Promise.resolve([]), + getKeyboardLayoutSnapshot: () => Promise.resolve(null), + onKeyboardLayoutChanged: () => () => undefined, + setUnreadDockBadgeCount: () => Promise.resolve(), + getFloatingTerminalCwd: () => Promise.resolve(''), + getFloatingMarkdownDirectory: () => Promise.resolve(''), + pickFloatingMarkdownDocument: () => Promise.resolve(null), + pickFloatingWorkspaceDirectory: () => Promise.resolve(null), + // Browser fallback has no app-owned userData dir; reject so the sentinel can't claim sensitive evidence was persisted. + writeTerminalRenderDesyncEvidence: () => + Promise.reject( + new Error('Terminal render evidence is unavailable in the browser fallback.') + ) + } + } +} diff --git a/src/renderer/src/web/preload-api/web-browser-api.ts b/src/renderer/src/web/preload-api/web-browser-api.ts new file mode 100644 index 00000000000..259d7c73a60 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-browser-api.ts @@ -0,0 +1,101 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { translate } from '@/i18n/i18n' +import { noopUnsubscribe } from './web-storage' + +export function createBrowserApi(): NonNullable['browser']> { + return { + registerGuest: () => Promise.resolve(false), + isGuestRegistered: () => Promise.resolve(false), + repairGuestRegistration: () => Promise.resolve(false), + unregisterGuest: () => Promise.resolve(), + openDevTools: () => Promise.resolve(false), + setViewportOverride: () => Promise.resolve(false), + setAnnotationViewportBridge: () => Promise.resolve(false), + onGuestLoadFailed: () => noopUnsubscribe, + onCertificateFailureChanged: () => noopUnsubscribe, + proceedCertificate: () => Promise.resolve({ ok: false, reason: 'missing' }), + onPermissionDenied: () => noopUnsubscribe, + onPopup: () => noopUnsubscribe, + onDownloadRequested: () => noopUnsubscribe, + onDownloadProgress: () => noopUnsubscribe, + onDownloadFinished: () => noopUnsubscribe, + onContextMenuRequested: () => noopUnsubscribe, + onContextMenuDismissed: () => noopUnsubscribe, + onNavigationUpdate: () => noopUnsubscribe, + onActivateView: () => noopUnsubscribe, + onPaneFocus: () => noopUnsubscribe, + onOpenLinkInOrcaTab: () => noopUnsubscribe, + cancelDownload: () => Promise.resolve(false), + setGrabMode: () => + Promise.resolve({ + ok: false, + error: translate( + 'auto.web.web.preload.api.31bea294d5', + 'Grab mode is unavailable in the web client.' + ) + }), + awaitGrabSelection: () => + Promise.resolve({ + ok: false, + error: translate( + 'auto.web.web.preload.api.31bea294d5', + 'Grab mode is unavailable in the web client.' + ) + }), + cancelGrab: () => Promise.resolve(false), + captureSelectionScreenshot: () => + Promise.resolve({ + ok: false, + error: translate( + 'auto.web.web.preload.api.8dfcb7a351', + 'Selection screenshots are unavailable in the web client.' + ) + }), + extractHoverPayload: () => + Promise.resolve({ + ok: false, + error: translate( + 'auto.web.web.preload.api.275a776357', + 'Hover extraction is unavailable in the web client.' + ) + }), + onGrabModeToggle: () => noopUnsubscribe, + onGrabActionShortcut: () => noopUnsubscribe, + sessionListProfiles: () => Promise.resolve([]), + sessionCreateProfile: () => Promise.resolve(null), + sessionDeleteProfile: () => Promise.resolve(false), + sessionImportCookies: () => + Promise.resolve({ + ok: false, + summary: null, + error: translate( + 'auto.web.web.preload.api.67ec964791', + 'Cookie import is unavailable in the web client.' + ) + }), + sessionResolvePartition: () => Promise.resolve(null), + sessionDetectBrowsers: () => Promise.resolve([]), + sessionImportFromBrowser: () => + Promise.resolve({ + ok: false, + summary: null, + error: translate( + 'auto.web.web.preload.api.67ec964791', + 'Cookie import is unavailable in the web client.' + ) + }), + sessionClearDefaultCookies: () => Promise.resolve(false), + notifyActiveTabChanged: () => Promise.resolve(false) + } as unknown as NonNullable['browser']> +} + +export function createEmulatorApi(): NonNullable['emulator']> { + return { + onPaneFocus: () => noopUnsubscribe, + onAutoAttach: () => noopUnsubscribe, + startFrameStream: () => Promise.reject(new Error('Mobile emulator is unavailable on web.')), + stopFrameStream: () => Promise.resolve(), + onFrameStreamFrame: () => noopUnsubscribe, + onFrameStreamError: () => noopUnsubscribe + } as unknown as NonNullable['emulator']> +} diff --git a/src/renderer/src/web/preload-api/web-cli-api.ts b/src/renderer/src/web/preload-api/web-cli-api.ts new file mode 100644 index 00000000000..c85bba87e14 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-cli-api.ts @@ -0,0 +1,27 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { getBrowserPlatform } from './web-storage' + +export function createCliApi(): NonNullable['cli']> { + const status = { + platform: getBrowserPlatform(), + commandName: getBrowserPlatform() === 'linux' ? 'orca-ide' : 'orca', + commandPath: null, + pathDirectory: null, + pathConfigured: false, + launcherPath: null, + installMethod: null, + supported: false, + state: 'unsupported', + currentTarget: null, + unsupportedReason: 'launch_mode_unavailable', + detail: 'CLI registration is managed on the Orca server, not in the web browser.' + } as const + return { + getInstallStatus: () => Promise.resolve(status), + install: () => Promise.resolve(status), + remove: () => Promise.resolve(status), + getWslInstallStatus: (_args?: { distro?: string | null }) => Promise.resolve(status), + installWsl: (_args?: { distro?: string | null }) => Promise.resolve(status), + removeWsl: (_args?: { distro?: string | null }) => Promise.resolve(status) + } as NonNullable['cli']> +} diff --git a/src/renderer/src/web/preload-api/web-clipboard-api.ts b/src/renderer/src/web/preload-api/web-clipboard-api.ts new file mode 100644 index 00000000000..d86ac8c45b3 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-clipboard-api.ts @@ -0,0 +1,177 @@ +import { + CLIPBOARD_IMAGE_MAX_BASE64_CHARS, + CLIPBOARD_IMAGE_MAX_PIXELS, + CLIPBOARD_IMAGE_MAX_SOURCE_BYTES, + CLIPBOARD_IMAGE_TOO_LARGE_ERROR, + assertClipboardImageByteLengthWithinLimit, + assertClipboardImageDimensionsWithinLimit +} from '../../../../shared/clipboard-image' +import { assertClipboardTextWriteWithinLimitWithYield } from '../../../../shared/clipboard-text' +import { copyClipboardTextViaExecCommand } from '../web-clipboard-copy-fallback' +import { callRuntimeEnvelope, callRuntimeResult } from './web-runtime-calls' + +export const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS + +export const MAX_CLIPBOARD_IMAGE_SOURCE_BYTES = CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + +export const MAX_CLIPBOARD_IMAGE_PIXELS = CLIPBOARD_IMAGE_MAX_PIXELS + +export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 + +export const CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024 + +export const CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS = 30_000 + +export function blobToBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const result = typeof reader.result === 'string' ? reader.result : '' + const commaIndex = result.indexOf(',') + resolve(commaIndex === -1 ? result : result.slice(commaIndex + 1)) + } + reader.onerror = () => reject(reader.error ?? new Error('Failed to read clipboard image')) + reader.readAsDataURL(blob) + }) +} + +export function assertClipboardImageBlobWithinLimit(blob: Blob): void { + assertClipboardImageByteLengthWithinLimit(blob.size) +} + +export async function convertImageBlobToPng(blob: Blob): Promise { + assertClipboardImageBlobWithinLimit(blob) + const bitmap = await createImageBitmap(blob) + try { + assertClipboardImageDimensionsWithinLimit(bitmap) + const canvas = document.createElement('canvas') + canvas.width = bitmap.width + canvas.height = bitmap.height + const context = canvas.getContext('2d') + if (!context || canvas.width <= 0 || canvas.height <= 0) { + throw new Error('Clipboard image could not be decoded') + } + context.drawImage(bitmap, 0, 0) + return await new Promise((resolve, reject) => { + canvas.toBlob((png) => { + if (!png) { + reject(new Error('Clipboard image could not be encoded as PNG')) + return + } + try { + assertClipboardImageBlobWithinLimit(png) + } catch (error) { + reject(error) + return + } + resolve(png) + }, 'image/png') + }) + } finally { + bitmap.close() + } +} + +export async function readClipboardImagePngBase64(): Promise { + const clipboard = navigator.clipboard as + | (Clipboard & { read?: () => Promise }) + | undefined + if (!clipboard?.read) { + return null + } + const items = await clipboard.read() + for (const item of items) { + const imageType = item.types.find((type) => type.startsWith('image/')) + if (!imageType) { + continue + } + const blob = await item.getType(imageType) + assertClipboardImageBlobWithinLimit(blob) + const pngBlob = imageType === 'image/png' ? blob : await convertImageBlobToPng(blob) + return blobToBase64(pngBlob) + } + return null +} + +export async function writeWebClipboardText(text: string): Promise { + await assertClipboardTextWriteWithinLimitWithYield(text) + const clipboard = navigator.clipboard + if (typeof clipboard?.writeText === 'function') { + try { + await clipboard.writeText(text) + return + } catch (error) { + // Preserve the current user-activation turn for the synchronous fallback. + if (copyClipboardTextViaExecCommand(text)) { + return + } + throw error + } + } + if (!copyClipboardTextViaExecCommand(text)) { + throw new Error('Clipboard write is unavailable in this browser context') + } +} + +export async function saveClipboardImageAsTempFileInRuntime( + contentBase64: string, + args?: { connectionId?: string | null; runtimeEnvironmentId?: string | null } +): Promise { + if (contentBase64.length > MAX_CLIPBOARD_IMAGE_BASE64_CHARS) { + throw new Error(CLIPBOARD_IMAGE_TOO_LARGE_ERROR) + } + const connectionId = args?.connectionId ?? null + const startResponse = await callRuntimeEnvelope<{ uploadId: string }>( + 'clipboard.startImageUpload', + { expectedBase64Length: contentBase64.length, connectionId }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ) + if (!startResponse.ok) { + if ( + startResponse.error.code === 'method_not_found' && + contentBase64.length <= CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS + ) { + return callRuntimeResult( + 'clipboard.saveImageAsTempFile', + { contentBase64, connectionId }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ) + } + throw new Error(startResponse.error.message) + } + + const { uploadId } = startResponse.result + try { + for ( + let offset = 0; + offset < contentBase64.length; + offset += CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + ) { + await callRuntimeResult( + 'clipboard.appendImageUploadChunk', + { + uploadId, + offset, + contentBase64: contentBase64.slice( + offset, + offset + CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + ) + }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ) + } + return await callRuntimeResult( + 'clipboard.commitImageUpload', + { uploadId }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ) + } catch (error) { + // Why: after chunked paste holds server-side state, release the bounded slot on failure rather than wait for TTL cleanup. + await callRuntimeResult( + 'clipboard.abortImageUpload', + { uploadId }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ).catch(() => {}) + throw error + } +} diff --git a/src/renderer/src/web/preload-api/web-diagnostics-api.ts b/src/renderer/src/web/preload-api/web-diagnostics-api.ts new file mode 100644 index 00000000000..a426edc3a0b --- /dev/null +++ b/src/renderer/src/web/preload-api/web-diagnostics-api.ts @@ -0,0 +1,41 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { translate } from '@/i18n/i18n' + +export function createWebDiagnosticsApi(): Partial { + return { + crashReports: { + getLatestPending: () => Promise.resolve(null), + getLatestReport: () => Promise.resolve(null), + dismiss: () => Promise.resolve(null), + recordRendererError: () => Promise.resolve({ ok: true, report: null, deduped: true }), + recordBreadcrumb: () => {}, + submit: () => + Promise.resolve({ + ok: false, + status: null, + error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.') + }), + copyLatestDiagnostics: () => + Promise.resolve({ + ok: false, + error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.') + }), + // Why: no Electron process on web; the caller falls back to performance.memory. + readHeapStatistics: () => null + }, + diagnostics: { + getStatus: () => + Promise.resolve({ + localFileEnabled: false, + bundleEnabled: false, + traceFilePath: '', + traceFamilySize: 0 + }), + collectBundle: () => Promise.reject(new Error('Review files are unavailable on web.')), + openBundlePreview: () => Promise.reject(new Error('Review files are unavailable on web.')), + discardBundlePreview: () => Promise.resolve(), + uploadBundle: () => Promise.reject(new Error('Sending diagnostics is unavailable on web.')), + deleteBundle: () => Promise.reject(new Error('Sent diagnostics are unavailable on web.')) + } + } +} diff --git a/src/renderer/src/web/preload-api/web-e2e-api.ts b/src/renderer/src/web/preload-api/web-e2e-api.ts new file mode 100644 index 00000000000..98a0694eb13 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-e2e-api.ts @@ -0,0 +1,10 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { webE2EConfig } from './web-e2e-config' + +export function createWebE2EApi(): Partial { + return { + e2e: { + getConfig: () => webE2EConfig + } + } +} diff --git a/src/renderer/src/web/preload-api/web-e2e-config.ts b/src/renderer/src/web/preload-api/web-e2e-config.ts new file mode 100644 index 00000000000..cddf982ad5f --- /dev/null +++ b/src/renderer/src/web/preload-api/web-e2e-config.ts @@ -0,0 +1,11 @@ +import { createE2EConfig } from '../../../../shared/e2e-config' + +// Why: this module stays dependency-light so the query/storage snapshot evaluates before runtime state. +const webE2EExposeStore = String(import.meta.env.VITE_EXPOSE_STORE) === 'true' +const webE2EQuery = webE2EExposeStore ? new URLSearchParams(window.location.search) : null + +export const webE2EConfig = createE2EConfig({ + exposeStore: webE2EExposeStore, + terminalParkingDelayMs: Number(webE2EQuery?.get('orcaE2ETerminalParkingDelayMs')) || null, + terminalRetentionLimit: Number(webE2EQuery?.get('orcaE2ETerminalRetentionLimit')) || null +}) diff --git a/src/renderer/src/web/preload-api/web-fallback-api.ts b/src/renderer/src/web/preload-api/web-fallback-api.ts new file mode 100644 index 00000000000..3cfef000ef1 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-fallback-api.ts @@ -0,0 +1,63 @@ +import { noopUnsubscribe } from './web-storage' + +export function withFallback(target: T, path: string[]): T { + return new Proxy(target, { + get(current, property, receiver) { + if (property in current) { + const value = Reflect.get(current, property, receiver) as unknown + if (value && typeof value === 'object' && !Array.isArray(value)) { + return withFallback(value as object, [...path, String(property)]) + } + return value + } + return createFallbackProxy([...path, String(property)]) + } + }) +} + +export function createFallbackProxy( + path: string[], + applyOverride?: (path: string[], args: unknown[]) => unknown +): never { + const fn = () => undefined + return new Proxy(fn, { + get(_target, property) { + if (property === 'then') { + return undefined + } + return createFallbackProxy([...path, String(property)], applyOverride) + }, + apply(_target, _thisArg, args) { + if (applyOverride) { + return applyOverride(path, args) + } + return getFallbackResult(path, args) + } + }) as never +} + +export function getFallbackResult(path: string[], args: unknown[]): unknown { + const name = path.at(-1) ?? '' + if (name.startsWith('on')) { + return noopUnsubscribe + } + if (name.startsWith('is') || name.startsWith('has') || name === 'pathExists') { + return Promise.resolve(false) + } + if (name.startsWith('list') || name.startsWith('detect')) { + return Promise.resolve([]) + } + if (name.startsWith('preview')) { + return Promise.resolve({ found: false, diff: {}, unsupportedKeys: [] }) + } + if (name.startsWith('get') && name.endsWith('Status')) { + return Promise.resolve([]) + } + if (name === 'write' || name === 'resize' || name === 'reportGeometry') { + return undefined + } + if (args.length === 0 && (name === 'getZoomLevel' || name === 'declarePendingPaneSerializer')) { + return 0 + } + return Promise.resolve(undefined) +} diff --git a/src/renderer/src/web/preload-api/web-filesystem-api.ts b/src/renderer/src/web/preload-api/web-filesystem-api.ts new file mode 100644 index 00000000000..dcf93839828 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-filesystem-api.ts @@ -0,0 +1,200 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import type { SearchResult } from '../../../../shared/code-search-types' +import { assertFileMutationOwnershipCapability } from '../../../../shared/file-mutation-ownership' +import type { DirEntry } from '../../../../shared/filesystem-entry-types' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' +import { createWebFileMutationMethods } from '../web-file-mutation-methods' +import { callRuntimeResult } from './web-runtime-calls' +import type { WebRuntimeEnvelopeCaller, WebRuntimeResultCaller } from './web-runtime-calls' +import { + getClientForEnvironment, + requireActiveEnvironment, + requireActiveEnvironmentOrNull, + runtimeCallQueuePool, + updateEnvironmentFromResponse, + webRuntimeState +} from './web-runtime-session' +import { isMissingPathError, resolveRuntimeFilePath } from './web-runtime-worktree-catalog' +import { noopUnsubscribe } from './web-storage' + +export function createFileApi(): NonNullable['fs']> { + return { + readDir: async ({ dirPath }) => { + const file = await resolveRuntimeFilePath(dirPath) + return callRuntimeResult('files.readDir', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + relativePath: file.relativePath + }) + }, + readFile: async ({ filePath }) => { + const file = await resolveRuntimeFilePath(filePath) + return callRuntimeResult('files.readPreview', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + relativePath: file.relativePath + }) + }, + readLocalLogTail: async () => { + throw new Error('Local log tailing is unavailable in paired web clients.') + }, + startLocalLogTail: async () => { + throw new Error('Local log tailing is unavailable in paired web clients.') + }, + stopLocalLogTail: async () => {}, + onLocalLogTailChanged: () => noopUnsubscribe, + downloadFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + downloadFolder: async () => { + throw new Error('Remote folder download is unavailable in paired web clients.') + }, + saveDownloadedFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + startDownloadedFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + appendDownloadedFileChunk: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + finishDownloadedFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + cancelDownloadedFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + listMarkdownDocuments: async ({ rootPath }) => { + const file = await resolveRuntimeFilePath(rootPath) + return callRuntimeResult('files.listMarkdownDocuments', { + worktree: toRuntimeWorktreeSelector(file.worktree.id) + }) + }, + ...createWebFileMutationMethods({ + captureSession: captureWebFileMutationSession + }), + authorizeExternalPath: () => Promise.resolve(), + stat: async ({ filePath }) => { + const file = await resolveRuntimeFilePath(filePath) + return callRuntimeResult('files.stat', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + relativePath: file.relativePath + }) + }, + pathExists: async ({ filePath }) => { + try { + const file = await resolveRuntimeFilePath(filePath) + await callRuntimeResult('files.stat', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + relativePath: file.relativePath + }) + return true + } catch (error) { + if (isMissingPathError(error)) { + return false + } + throw error + } + }, + listFiles: async ({ rootPath, excludePaths }) => { + const file = await resolveRuntimeFilePath(rootPath) + const result = await callRuntimeResult<{ files: { relativePath: string }[] }>( + 'files.listAll', + { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + excludePaths + } + ) + return result.files.map((entry) => entry.relativePath) + }, + cancelListFiles: async () => { + // Why: paired-web lists files over runtime RPC with its own timeout; there's no host-side scan to abort here. + }, + search: async (args) => { + const file = await resolveRuntimeFilePath(args.rootPath) + return callRuntimeResult('files.search', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + query: args.query, + caseSensitive: args.caseSensitive, + wholeWord: args.wholeWord, + useRegex: args.useRegex, + includePattern: args.includePattern, + excludePattern: args.excludePattern, + maxResults: args.maxResults + }) + }, + importExternalPaths: async () => ({ results: [] }), + stageExternalPathsForRuntimeUpload: async () => ({ sources: [] }), + resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }), + watchWorktree: () => Promise.resolve(), + unwatchWorktree: () => Promise.resolve(), + onFsChanged: () => noopUnsubscribe + } +} + +export function captureWebFileMutationSession(): { + resolveFilePath: (filePath: string) => Promise>> + assertMutationSupported: () => Promise + callRuntimeResult: WebRuntimeResultCaller + getSshState: (targetId: string) => Promise +} { + const environment = requireActiveEnvironment() + const client = getClientForEnvironment(environment) + const assertCurrent = (): void => { + if ( + webRuntimeState.activeClient !== client || + requireActiveEnvironmentOrNull()?.id !== environment.id + ) { + throw new Error('Runtime pairing changed; refresh and try again') + } + } + const callBoundRuntimeEnvelope: WebRuntimeEnvelopeCaller = async ( + method: string, + params?: unknown, + timeoutMs?: number + ): Promise> => { + assertCurrent() + const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { + assertCurrent() + return client.call(method, params, { timeoutMs }) + }) + assertCurrent() + updateEnvironmentFromResponse(environment, response) + return response as RuntimeRpcResponse + } + const callBoundRuntimeResult: WebRuntimeResultCaller = async ( + method: string, + params?: unknown, + timeoutMs?: number + ): Promise => { + const response = await callBoundRuntimeEnvelope(method, params, timeoutMs) + if (!response.ok) { + throw new Error(response.error.message) + } + return response.result as TResult + } + return { + resolveFilePath: (filePath) => + resolveRuntimeFilePath( + filePath, + undefined, + callBoundRuntimeResult, + callBoundRuntimeEnvelope, + false, + environment.id + ), + assertMutationSupported: async () => { + assertFileMutationOwnershipCapability( + await callBoundRuntimeResult('status.get', undefined, 15_000) + ) + }, + callRuntimeResult: callBoundRuntimeResult, + getSshState: async (targetId) => + ( + await callBoundRuntimeResult<{ state: SshConnectionState | null }>('ssh.getState', { + targetId + }) + ).state + } +} diff --git a/src/renderer/src/web/preload-api/web-git-api.ts b/src/renderer/src/web/preload-api/web-git-api.ts new file mode 100644 index 00000000000..f0b0423270a --- /dev/null +++ b/src/renderer/src/web/preload-api/web-git-api.ts @@ -0,0 +1,291 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { callAbortableRuntimeEnvironment } from '../../runtime/abortable-runtime-environment-call' +import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' +import { translate } from '@/i18n/i18n' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironment, updateEnvironmentFromResponse } from './web-runtime-session' +import { + resolveRuntimeFilePath, + resolveRuntimeWorktreeByPath +} from './web-runtime-worktree-catalog' + +export const webGitStatusAbortControllers = new Map() + +export async function callAbortableRuntimeStatus( + requestToken: string, + params: unknown +): Promise { + const environment = requireActiveEnvironment() + webGitStatusAbortControllers.get(requestToken)?.abort() + const controller = new AbortController() + webGitStatusAbortControllers.set(requestToken, controller) + try { + const response = await callAbortableRuntimeEnvironment( + environment.id, + 'git.status', + params, + undefined, + controller.signal + ) + updateEnvironmentFromResponse(environment, response) + if (!response.ok) { + throw new Error(response.error.message) + } + return response.result as TResult + } finally { + if (webGitStatusAbortControllers.get(requestToken) === controller) { + webGitStatusAbortControllers.delete(requestToken) + } + } +} + +export function createGitApi(): NonNullable['git']> { + return { + status: async ({ + worktreePath, + includeIgnored, + bypassEffectiveUpstreamNegativeCache, + reuseLineStats, + branchLineTotalMergeBase, + requestToken + }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + const params = { + worktree: toRuntimeWorktreeSelector(worktree.id), + includeIgnored, + bypassEffectiveUpstreamNegativeCache, + reuseLineStats, + ...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {}) + } + // Why: no token = nothing to cancel (pooled); a token routes via the subscription bridge so cancelStatus can abort. + if (!requestToken) { + return callRuntimeResult('git.status', params) + } + return callAbortableRuntimeStatus(requestToken, params) + }, + cancelStatus: async ({ requestToken }) => { + webGitStatusAbortControllers.get(requestToken)?.abort() + }, + setStatusUpstreamRefWatch: async () => {}, + submoduleStatus: async ({ worktreePath, submodulePath, area }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.submoduleStatus', { + worktree: toRuntimeWorktreeSelector(worktree.id), + submodulePath, + area + }) + }, + checkIgnored: async ({ worktreePath, paths }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.checkIgnored', { + worktree: toRuntimeWorktreeSelector(worktree.id), + paths + }) + }, + // Why: the "add huge folder to .gitignore" flow is desktop-only; the web runtime makes no offer, so return no candidates. + findHugeFoldersToIgnore: async () => [], + appendGitignore: async () => false, + history: async ({ worktreePath, limit, baseRef }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.history', { + worktree: toRuntimeWorktreeSelector(worktree.id), + limit, + baseRef + }) + }, + conflictOperation: async ({ worktreePath }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.conflictOperation', { + worktree: toRuntimeWorktreeSelector(worktree.id) + }) + }, + abortMerge: async ({ worktreePath }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.abortMerge', { + worktree: toRuntimeWorktreeSelector(worktree.id) + }) + }, + abortRebase: async ({ worktreePath }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.abortRebase', { + worktree: toRuntimeWorktreeSelector(worktree.id) + }) + }, + diff: async ({ worktreePath, filePath, staged, compareAgainstHead }) => { + const file = await resolveRuntimeFilePath(filePath, worktreePath) + return callRuntimeResult('git.diff', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + filePath: file.relativePath, + staged, + compareAgainstHead + }) + }, + branchCompare: async ({ worktreePath, baseRef }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.branchCompare', { + worktree: toRuntimeWorktreeSelector(worktree.id), + baseRef + }) + }, + commitCompare: async ({ worktreePath, commitId }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.commitCompare', { + worktree: toRuntimeWorktreeSelector(worktree.id), + commitId + }) + }, + upstreamStatus: async ({ worktreePath, pushTarget }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.upstreamStatus', { + worktree: toRuntimeWorktreeSelector(worktree.id), + pushTarget + }) + }, + fetch: async ({ worktreePath, pushTarget }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.fetch', { + worktree: toRuntimeWorktreeSelector(worktree.id), + pushTarget + }) + }, + syncFork: async ({ worktreePath, expectedUpstream }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult( + 'git.forkSync', + { + worktree: toRuntimeWorktreeSelector(worktree.id), + expectedUpstream + }, + 60_000 + ) + }, + push: async ({ worktreePath, publish, pushTarget }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.push', { + worktree: toRuntimeWorktreeSelector(worktree.id), + publish, + pushTarget + }) + }, + pull: async ({ worktreePath, pushTarget }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.pull', { + worktree: toRuntimeWorktreeSelector(worktree.id), + pushTarget + }) + }, + fastForward: async ({ worktreePath, pushTarget }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.fastForward', { + worktree: toRuntimeWorktreeSelector(worktree.id), + pushTarget + }) + }, + rebaseFromBase: async ({ worktreePath, baseRef }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.rebaseFromBase', { + worktree: toRuntimeWorktreeSelector(worktree.id), + baseRef + }) + }, + branchDiff: async ({ worktreePath, filePath, compare, oldPath }) => { + const file = await resolveRuntimeFilePath(filePath, worktreePath) + return callRuntimeResult('git.branchDiff', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + filePath: file.relativePath, + compare, + oldPath + }) + }, + commitDiff: async ({ worktreePath, filePath, commitOid, parentOid, oldPath }) => { + const file = await resolveRuntimeFilePath(filePath, worktreePath) + return callRuntimeResult('git.commitDiff', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + filePath: file.relativePath, + commitOid, + parentOid, + oldPath + }) + }, + commit: async ({ worktreePath, message }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.commit', { + worktree: toRuntimeWorktreeSelector(worktree.id), + message + }) + }, + generateCommitMessage: async () => ({ + success: false, + error: translate( + 'auto.web.web.preload.api.9fc90740b6', + 'Commit message generation is unavailable in the web client.' + ) + }), + discoverCommitMessageModels: async () => ({ + success: false, + error: translate( + 'auto.web.web.preload.api.e57c82d276', + 'Commit message model discovery is unavailable in the web client.' + ) + }), + cancelGenerateCommitMessage: () => Promise.resolve(), + generatePullRequestFields: async () => ({ + success: false, + error: translate( + 'auto.web.web.preload.api.b8a1618172', + 'Pull request detail generation is unavailable in the web client.' + ) + }), + cancelGeneratePullRequestFields: () => Promise.resolve(), + stage: async ({ worktreePath, filePath }) => mutateGitPath('git.stage', worktreePath, filePath), + bulkStage: async ({ worktreePath, filePaths }) => + mutateGitPaths('git.bulkStage', worktreePath, filePaths), + unstage: async ({ worktreePath, filePath }) => + mutateGitPath('git.unstage', worktreePath, filePath), + bulkUnstage: async ({ worktreePath, filePaths }) => + mutateGitPaths('git.bulkUnstage', worktreePath, filePaths), + discard: async ({ worktreePath, filePath }) => + mutateGitPath('git.discard', worktreePath, filePath), + bulkDiscard: async ({ worktreePath, filePaths }) => { + for (const filePath of filePaths) { + await mutateGitPath('git.discard', worktreePath, filePath) + } + }, + remoteFileUrl: async ({ worktreePath, relativePath, line }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.remoteFileUrl', { + worktree: toRuntimeWorktreeSelector(worktree.id), + relativePath, + line + }) + }, + remoteCommitUrl: async ({ worktreePath, sha }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.remoteCommitUrl', { + worktree: toRuntimeWorktreeSelector(worktree.id), + sha + }) + } + } +} + +export async function mutateGitPath( + method: string, + worktreePath: string, + filePath: string +): Promise { + const file = await resolveRuntimeFilePath(filePath, worktreePath) + await callRuntimeResult(method, { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + filePath: file.relativePath + }) +} + +export async function mutateGitPaths( + method: string, + worktreePath: string, + filePaths: string[] +): Promise { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult(method, { worktree: toRuntimeWorktreeSelector(worktree.id), filePaths }) +} diff --git a/src/renderer/src/web/preload-api/web-github-api.ts b/src/renderer/src/web/preload-api/web-github-api.ts new file mode 100644 index 00000000000..ce22933ab30 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-github-api.ts @@ -0,0 +1,193 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { translate } from '@/i18n/i18n' +import { GITHUB_WEB_RPC_METHODS } from './web-github-routes' +import type { WebGitHubRuntimeMethod } from './web-github-routes' +import { mapRepoPathArg } from './web-review-api' +import { callRuntimeResult } from './web-runtime-calls' +import { noopUnsubscribe } from './web-storage' + +export type WebGitHubApi = NonNullable + +export type WebGitHubResult = Awaited> + +export function createGitHubApi(): WebGitHubApi { + const route = (method: WebGitHubRuntimeMethod, args?: unknown): Promise => + callRuntimeResult(method, mapRepoPathArg(args)) + const githubApi = { + viewer: () => Promise.resolve(null), + repoSlug: (args) => route>(GITHUB_WEB_RPC_METHODS.repoSlug, args), + repoUpstream: (args) => + route>(GITHUB_WEB_RPC_METHODS.repoUpstream, args), + prForBranch: (args) => + route>(GITHUB_WEB_RPC_METHODS.prForBranch, args), + refreshPRNow: async ({ candidate }) => { + const acceptMergedFallbackPR = + candidate.linkedPRNumber == null && + candidate.fallbackPRNumber != null && + candidate.fallbackPRSource != null + const pr = await route>(GITHUB_WEB_RPC_METHODS.prForBranch, { + repoPath: candidate.repoPath, + repoId: candidate.repoId, + branch: candidate.branch, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: candidate.fallbackPRNumber ?? null, + currentHeadOid: candidate.currentHeadOid ?? null, + ...(acceptMergedFallbackPR ? { acceptMergedFallbackPR: true } : {}) + }) + return pr + ? { kind: 'found', pr, fetchedAt: Date.now() } + : { kind: 'no-pr', fetchedAt: Date.now() } + }, + enqueuePRRefresh: () => Promise.resolve(false), + reportVisiblePRRefreshCandidates: () => Promise.resolve(false), + onPRRefreshEvent: () => noopUnsubscribe, + issue: (args) => route>(GITHUB_WEB_RPC_METHODS.issue, args), + workItem: (args) => route>(GITHUB_WEB_RPC_METHODS.workItem, args), + workItemByOwnerRepo: ({ repo: ownerRepo, ...args }) => + route>(GITHUB_WEB_RPC_METHODS.workItemByOwnerRepo, { + ...args, + ownerRepo + }), + workItemDetails: (args) => + route>(GITHUB_WEB_RPC_METHODS.workItemDetails, args), + notifyWorkItemMutated: () => Promise.resolve(false), + prFileContents: (args) => + route>(GITHUB_WEB_RPC_METHODS.prFileContents, args), + listIssues: (args) => + route>(GITHUB_WEB_RPC_METHODS.listIssues, args), + createIssue: (args) => + route>(GITHUB_WEB_RPC_METHODS.createIssue, args), + countWorkItems: (args) => + route>(GITHUB_WEB_RPC_METHODS.countWorkItems, args), + listWorkItems: (args) => + route>(GITHUB_WEB_RPC_METHODS.listWorkItems, args), + prChecks: (args) => route>(GITHUB_WEB_RPC_METHODS.prChecks, args), + prCheckDetails: (args) => + route>(GITHUB_WEB_RPC_METHODS.prCheckDetails, args), + rerunPRChecks: (args) => + route>(GITHUB_WEB_RPC_METHODS.rerunPRChecks, args), + prComments: (args) => + route>(GITHUB_WEB_RPC_METHODS.prComments, args), + setPRCommentReaction: (args) => + route>( + GITHUB_WEB_RPC_METHODS.setPRCommentReaction, + args + ), + resolveReviewThread: (args) => + route>( + GITHUB_WEB_RPC_METHODS.resolveReviewThread, + args + ), + setPRFileViewed: (args) => + route>(GITHUB_WEB_RPC_METHODS.setPRFileViewed, args), + updatePRTitle: (args) => + route>(GITHUB_WEB_RPC_METHODS.updatePRTitle, args), + mergePR: (args) => route>(GITHUB_WEB_RPC_METHODS.mergePR, args), + setPRAutoMerge: (args) => + route>(GITHUB_WEB_RPC_METHODS.setPRAutoMerge, args), + updatePRState: (args) => + route>(GITHUB_WEB_RPC_METHODS.updatePRState, args), + requestPRReviewers: (args) => + route>(GITHUB_WEB_RPC_METHODS.requestPRReviewers, args), + removePRReviewers: (args) => + route>(GITHUB_WEB_RPC_METHODS.removePRReviewers, args), + updateIssue: (args) => + route>(GITHUB_WEB_RPC_METHODS.updateIssue, args), + addIssueComment: (args) => + route>(GITHUB_WEB_RPC_METHODS.addIssueComment, args), + addPRReviewCommentReply: (args) => + route>( + GITHUB_WEB_RPC_METHODS.addPRReviewCommentReply, + args + ), + addPRReviewComment: (args) => + route>(GITHUB_WEB_RPC_METHODS.addPRReviewComment, args), + listLabels: (args) => + route>(GITHUB_WEB_RPC_METHODS.listLabels, args), + listAssignableUsers: (args) => + route>( + GITHUB_WEB_RPC_METHODS.listAssignableUsers, + args + ), + onWorkItemMutated: () => noopUnsubscribe, + checkOrcaStarred: () => Promise.resolve(null), + starOrca: () => Promise.resolve(false), + rateLimit: (args) => + route>(GITHUB_WEB_RPC_METHODS.rateLimit, args), + diagnoseAuth: () => + Promise.resolve({ + ok: false, + message: translate('auto.web.web.preload.api.31bfe8ae1a', 'Unavailable in the web client.') + } as never), + listAccessibleProjects: (args) => + route>( + GITHUB_WEB_RPC_METHODS.listAccessibleProjects, + args + ), + resolveProjectRef: (args) => + route>(GITHUB_WEB_RPC_METHODS.resolveProjectRef, args), + listProjectViews: (args) => + route>(GITHUB_WEB_RPC_METHODS.listProjectViews, args), + getProjectViewTable: (args) => + route>( + GITHUB_WEB_RPC_METHODS.getProjectViewTable, + args + ), + projectWorkItemDetailsBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.projectWorkItemDetailsBySlug, + args + ), + updateProjectItemField: (args) => + route>( + GITHUB_WEB_RPC_METHODS.updateProjectItemField, + args + ), + clearProjectItemField: (args) => + route>( + GITHUB_WEB_RPC_METHODS.clearProjectItemField, + args + ), + updateIssueBySlug: (args) => + route>(GITHUB_WEB_RPC_METHODS.updateIssueBySlug, args), + updatePullRequestBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.updatePullRequestBySlug, + args + ), + addIssueCommentBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.addIssueCommentBySlug, + args + ), + updateIssueCommentBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.updateIssueCommentBySlug, + args + ), + deleteIssueCommentBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.deleteIssueCommentBySlug, + args + ), + listLabelsBySlug: (args) => + route>(GITHUB_WEB_RPC_METHODS.listLabelsBySlug, args), + listAssignableUsersBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.listAssignableUsersBySlug, + args + ), + listIssueTypesBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.listIssueTypesBySlug, + args + ), + updateIssueTypeBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.updateIssueTypeBySlug, + args + ) + } satisfies WebGitHubApi + + return githubApi +} diff --git a/src/renderer/src/web/preload-api/web-github-cache-api.ts b/src/renderer/src/web/preload-api/web-github-cache-api.ts new file mode 100644 index 00000000000..1881a74c0d6 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-github-cache-api.ts @@ -0,0 +1,19 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { GITHUB_CACHE_STORAGE_KEY, readJson, writeJson } from './web-storage' + +export function createWebGithubCacheApi(): Partial { + return { + cache: { + getGitHub: () => + Promise.resolve( + readJson(GITHUB_CACHE_STORAGE_KEY, { + pr: {}, + issue: {} + }) + ), + setGitHub: async ({ cache }) => { + writeJson(GITHUB_CACHE_STORAGE_KEY, cache) + } + } + } +} diff --git a/src/renderer/src/web/preload-api/web-github-routes.ts b/src/renderer/src/web/preload-api/web-github-routes.ts new file mode 100644 index 00000000000..ce688832ec5 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-github-routes.ts @@ -0,0 +1,150 @@ +export type WebGitHubRouteKey = + | 'repoSlug' + | 'repoUpstream' + | 'prForBranch' + | 'issue' + | 'workItem' + | 'workItemByOwnerRepo' + | 'workItemDetails' + | 'prFileContents' + | 'listIssues' + | 'createIssue' + | 'countWorkItems' + | 'listWorkItems' + | 'prChecks' + | 'prCheckDetails' + | 'rerunPRChecks' + | 'prComments' + | 'setPRCommentReaction' + | 'resolveReviewThread' + | 'setPRFileViewed' + | 'updatePRTitle' + | 'mergePR' + | 'setPRAutoMerge' + | 'updatePRState' + | 'requestPRReviewers' + | 'removePRReviewers' + | 'updateIssue' + | 'addIssueComment' + | 'addPRReviewCommentReply' + | 'addPRReviewComment' + | 'listLabels' + | 'listAssignableUsers' + | 'rateLimit' + | 'listAccessibleProjects' + | 'resolveProjectRef' + | 'listProjectViews' + | 'getProjectViewTable' + | 'projectWorkItemDetailsBySlug' + | 'updateProjectItemField' + | 'clearProjectItemField' + | 'updateIssueBySlug' + | 'updatePullRequestBySlug' + | 'addIssueCommentBySlug' + | 'updateIssueCommentBySlug' + | 'deleteIssueCommentBySlug' + | 'listLabelsBySlug' + | 'listAssignableUsersBySlug' + | 'listIssueTypesBySlug' + | 'updateIssueTypeBySlug' + +export type WebGitHubRuntimeMethod = + | 'github.repoSlug' + | 'github.repoUpstream' + | 'github.prForBranch' + | 'github.issue' + | 'github.workItem' + | 'github.workItemByOwnerRepo' + | 'github.workItemDetails' + | 'github.prFileContents' + | 'github.listIssues' + | 'github.createIssue' + | 'github.countWorkItems' + | 'github.listWorkItems' + | 'github.prChecks' + | 'github.prCheckDetails' + | 'github.rerunPRChecks' + | 'github.prComments' + | 'github.setPRCommentReaction' + | 'github.resolveReviewThread' + | 'github.setPRFileViewed' + | 'github.updatePRTitle' + | 'github.mergePR' + | 'github.setPRAutoMerge' + | 'github.updatePRState' + | 'github.requestPRReviewers' + | 'github.removePRReviewers' + | 'github.updateIssue' + | 'github.addIssueComment' + | 'github.addPRReviewCommentReply' + | 'github.addPRReviewComment' + | 'github.listLabels' + | 'github.listAssignableUsers' + | 'github.rateLimit' + | 'github.project.listAccessible' + | 'github.project.resolveRef' + | 'github.project.listViews' + | 'github.project.viewTable' + | 'github.project.workItemDetailsBySlug' + | 'github.project.updateItemField' + | 'github.project.clearItemField' + | 'github.project.updateIssueBySlug' + | 'github.project.updatePullRequestBySlug' + | 'github.project.addIssueCommentBySlug' + | 'github.project.updateIssueCommentBySlug' + | 'github.project.deleteIssueCommentBySlug' + | 'github.project.listLabelsBySlug' + | 'github.project.listAssignableUsersBySlug' + | 'github.project.listIssueTypesBySlug' + | 'github.project.updateIssueTypeBySlug' + +export const GITHUB_WEB_RPC_METHODS = { + repoSlug: 'github.repoSlug', + repoUpstream: 'github.repoUpstream', + prForBranch: 'github.prForBranch', + issue: 'github.issue', + workItem: 'github.workItem', + workItemByOwnerRepo: 'github.workItemByOwnerRepo', + workItemDetails: 'github.workItemDetails', + prFileContents: 'github.prFileContents', + listIssues: 'github.listIssues', + createIssue: 'github.createIssue', + countWorkItems: 'github.countWorkItems', + listWorkItems: 'github.listWorkItems', + prChecks: 'github.prChecks', + prCheckDetails: 'github.prCheckDetails', + rerunPRChecks: 'github.rerunPRChecks', + prComments: 'github.prComments', + setPRCommentReaction: 'github.setPRCommentReaction', + resolveReviewThread: 'github.resolveReviewThread', + setPRFileViewed: 'github.setPRFileViewed', + updatePRTitle: 'github.updatePRTitle', + mergePR: 'github.mergePR', + setPRAutoMerge: 'github.setPRAutoMerge', + updatePRState: 'github.updatePRState', + requestPRReviewers: 'github.requestPRReviewers', + removePRReviewers: 'github.removePRReviewers', + updateIssue: 'github.updateIssue', + addIssueComment: 'github.addIssueComment', + addPRReviewCommentReply: 'github.addPRReviewCommentReply', + addPRReviewComment: 'github.addPRReviewComment', + listLabels: 'github.listLabels', + listAssignableUsers: 'github.listAssignableUsers', + rateLimit: 'github.rateLimit', + listAccessibleProjects: 'github.project.listAccessible', + resolveProjectRef: 'github.project.resolveRef', + listProjectViews: 'github.project.listViews', + getProjectViewTable: 'github.project.viewTable', + projectWorkItemDetailsBySlug: 'github.project.workItemDetailsBySlug', + updateProjectItemField: 'github.project.updateItemField', + clearProjectItemField: 'github.project.clearItemField', + updateIssueBySlug: 'github.project.updateIssueBySlug', + updatePullRequestBySlug: 'github.project.updatePullRequestBySlug', + addIssueCommentBySlug: 'github.project.addIssueCommentBySlug', + updateIssueCommentBySlug: 'github.project.updateIssueCommentBySlug', + deleteIssueCommentBySlug: 'github.project.deleteIssueCommentBySlug', + listLabelsBySlug: 'github.project.listLabelsBySlug', + listAssignableUsersBySlug: 'github.project.listAssignableUsersBySlug', + listIssueTypesBySlug: 'github.project.listIssueTypesBySlug', + updateIssueTypeBySlug: 'github.project.updateIssueTypeBySlug' +} as const satisfies Record diff --git a/src/renderer/src/web/preload-api/web-gitlab-api.ts b/src/renderer/src/web/preload-api/web-gitlab-api.ts new file mode 100644 index 00000000000..a183ee88df2 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-gitlab-api.ts @@ -0,0 +1,71 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { GITLAB_WEB_RPC_METHODS } from './web-gitlab-routes' +import type { WebGitLabRuntimeMethod } from './web-gitlab-routes' +import { mapRepoPathArg } from './web-review-api' +import { callRuntimeResult } from './web-runtime-calls' + +export type WebGitLabApi = NonNullable + +export type WebGitLabResult = Awaited> + +export function createGitLabApi(): WebGitLabApi { + const route = (method: WebGitLabRuntimeMethod, args?: unknown): Promise => + callRuntimeResult(method, mapRepoPathArg(args)) + + const gitLabApi = { + viewer: () => Promise.resolve(null), + diagnoseAuth: () => route>(GITLAB_WEB_RPC_METHODS.diagnoseAuth), + rateLimit: (args) => + route>(GITLAB_WEB_RPC_METHODS.rateLimit, args), + projectSlug: () => Promise.resolve(null), + mrForBranch: () => Promise.resolve(null), + mr: () => Promise.resolve(null), + listMRs: (args) => route>(GITLAB_WEB_RPC_METHODS.listMRs, args), + listWorkItems: (args) => + route>(GITLAB_WEB_RPC_METHODS.listWorkItems, args), + issue: () => Promise.resolve(null), + listIssues: (args) => + route>(GITLAB_WEB_RPC_METHODS.listIssues, args), + createIssue: (args) => + route>(GITLAB_WEB_RPC_METHODS.createIssue, args), + updateIssue: (args) => + route>(GITLAB_WEB_RPC_METHODS.updateIssue, args), + addIssueComment: (args) => + route>(GITLAB_WEB_RPC_METHODS.addIssueComment, args), + listLabels: (args) => + route>(GITLAB_WEB_RPC_METHODS.listLabels, args), + listAssignableUsers: () => Promise.resolve([]), + todos: (args) => route>(GITLAB_WEB_RPC_METHODS.todos, args), + workItemDetails: (args) => + route>(GITLAB_WEB_RPC_METHODS.workItemDetails, args), + closeMR: (args) => + route>(GITLAB_WEB_RPC_METHODS.closeMR, { + ...args, + state: 'closed' + }), + reopenMR: (args) => + route>(GITLAB_WEB_RPC_METHODS.reopenMR, { + ...args, + state: 'opened' + }), + mergeMR: (args) => route>(GITLAB_WEB_RPC_METHODS.mergeMR, args), + updateMR: (args) => route>(GITLAB_WEB_RPC_METHODS.updateMR, args), + updateMRReviewers: (args) => + route>(GITLAB_WEB_RPC_METHODS.updateMRReviewers, args), + addMRComment: (args) => + route>(GITLAB_WEB_RPC_METHODS.addMRComment, args), + addMRInlineComment: (args) => + route>(GITLAB_WEB_RPC_METHODS.addMRInlineComment, args), + resolveMRDiscussion: (args) => + route>( + GITLAB_WEB_RPC_METHODS.resolveMRDiscussion, + args + ), + jobTrace: (args) => route>(GITLAB_WEB_RPC_METHODS.jobTrace, args), + retryJob: (args) => route>(GITLAB_WEB_RPC_METHODS.retryJob, args), + workItemByPath: (args) => + route>(GITLAB_WEB_RPC_METHODS.workItemByPath, args) + } satisfies WebGitLabApi + + return gitLabApi +} diff --git a/src/renderer/src/web/preload-api/web-gitlab-routes.ts b/src/renderer/src/web/preload-api/web-gitlab-routes.ts new file mode 100644 index 00000000000..e99f0780015 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-gitlab-routes.ts @@ -0,0 +1,71 @@ +export type WebGitLabRouteKey = + | 'diagnoseAuth' + | 'rateLimit' + | 'listMRs' + | 'listWorkItems' + | 'listIssues' + | 'createIssue' + | 'updateIssue' + | 'addIssueComment' + | 'listLabels' + | 'todos' + | 'workItemDetails' + | 'closeMR' + | 'reopenMR' + | 'mergeMR' + | 'updateMR' + | 'updateMRReviewers' + | 'addMRComment' + | 'addMRInlineComment' + | 'resolveMRDiscussion' + | 'jobTrace' + | 'retryJob' + | 'workItemByPath' + +export type WebGitLabRuntimeMethod = + | 'gitlab.diagnoseAuth' + | 'gitlab.rateLimit' + | 'gitlab.listMRs' + | 'gitlab.listWorkItems' + | 'gitlab.listIssues' + | 'gitlab.createIssue' + | 'gitlab.updateIssue' + | 'gitlab.addIssueComment' + | 'gitlab.listLabels' + | 'gitlab.todos' + | 'gitlab.workItemDetails' + | 'gitlab.updateMRState' + | 'gitlab.mergeMR' + | 'gitlab.updateMR' + | 'gitlab.updateMRReviewers' + | 'gitlab.addMRComment' + | 'gitlab.addMRInlineComment' + | 'gitlab.resolveMRDiscussion' + | 'gitlab.jobTrace' + | 'gitlab.retryJob' + | 'gitlab.workItemByPath' + +export const GITLAB_WEB_RPC_METHODS = { + diagnoseAuth: 'gitlab.diagnoseAuth', + rateLimit: 'gitlab.rateLimit', + listMRs: 'gitlab.listMRs', + listWorkItems: 'gitlab.listWorkItems', + listIssues: 'gitlab.listIssues', + createIssue: 'gitlab.createIssue', + updateIssue: 'gitlab.updateIssue', + addIssueComment: 'gitlab.addIssueComment', + listLabels: 'gitlab.listLabels', + todos: 'gitlab.todos', + workItemDetails: 'gitlab.workItemDetails', + closeMR: 'gitlab.updateMRState', + reopenMR: 'gitlab.updateMRState', + mergeMR: 'gitlab.mergeMR', + updateMR: 'gitlab.updateMR', + updateMRReviewers: 'gitlab.updateMRReviewers', + addMRComment: 'gitlab.addMRComment', + addMRInlineComment: 'gitlab.addMRInlineComment', + resolveMRDiscussion: 'gitlab.resolveMRDiscussion', + jobTrace: 'gitlab.jobTrace', + retryJob: 'gitlab.retryJob', + workItemByPath: 'gitlab.workItemByPath' +} as const satisfies Record diff --git a/src/renderer/src/web/preload-api/web-host-capability-api.ts b/src/renderer/src/web/preload-api/web-host-capability-api.ts new file mode 100644 index 00000000000..2b2849baaf6 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-host-capability-api.ts @@ -0,0 +1,195 @@ +import type { + PreflightStatus, + PreloadApi, + RefreshAgentsResult +} from '../../../../preload/api-types' +import type { + ComputerUsePermissionSetupResult, + ComputerUsePermissionStatusResult +} from '../../../../shared/computer-use-permissions-types' +import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' +import type { SkillDiscoveryResult } from '../../../../shared/skills' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironmentOrNull } from './web-runtime-session' +import { getBrowserPlatform } from './web-storage' + +export function createPreflightApi(): NonNullable['preflight']> { + const fallbackStatus: PreflightStatus = { + git: { installed: false }, + gh: { installed: false, authenticated: false }, + glab: { installed: false, authenticated: false }, + bitbucket: { configured: false, authenticated: false, account: null }, + azureDevOps: { + configured: false, + authenticated: false, + account: null, + baseUrl: null, + tokenConfigured: false + }, + gitea: { + configured: false, + authenticated: false, + account: null, + baseUrl: null, + tokenConfigured: false + } + } + const fallbackRefreshAgents: RefreshAgentsResult = { + agents: [], + addedPathSegments: [], + shellHydrationOk: false, + pathSource: 'sync_seed_only', + pathFailureReason: 'spawn_error' + } + type WindowsTerminalCapabilityBridgeResult = { + wslAvailable: boolean + wslDistros: string[] + pwshAvailable: boolean + gitBashAvailable: boolean + hostPlatform: NodeJS.Platform | null + } + const fallbackWindowsTerminalCapabilities = { + wslAvailable: false, + wslDistros: [], + pwshAvailable: false, + gitBashAvailable: false, + hostPlatform: null + } + return { + check: async (args) => { + if (!requireActiveEnvironmentOrNull()) { + return fallbackStatus + } + return callRuntimeResult('preflight.check', args) + }, + detectAgents: async () => { + if (!requireActiveEnvironmentOrNull()) { + return [] + } + return callRuntimeResult('preflight.detectAgents').catch(() => []) + }, + refreshAgents: () => + requireActiveEnvironmentOrNull() + ? callRuntimeResult('preflight.refreshAgents') + .then((result) => result as RefreshAgentsResult) + .catch(() => fallbackRefreshAgents) + : Promise.resolve(fallbackRefreshAgents), + detectRemoteAgents: async (args) => + requireActiveEnvironmentOrNull() + ? callRuntimeResult('preflight.detectRemoteAgents', args).catch(() => []) + : [], + detectRemoteWindowsTerminalCapabilities: async (args) => + requireActiveEnvironmentOrNull() + ? callRuntimeResult( + 'preflight.detectRemoteWindowsTerminalCapabilities', + args + ).catch(() => fallbackWindowsTerminalCapabilities) + : Promise.resolve(fallbackWindowsTerminalCapabilities) + } +} + +export function createDeveloperPermissionsApi(): NonNullable< + Partial['developerPermissions'] +> { + return { + getStatus: () => Promise.resolve([]), + request: ({ id }) => + Promise.resolve({ id, status: 'unsupported', openedSystemSettings: false } as const), + openSettings: () => Promise.resolve(), + testLocalNetworkConnection: ({ host, port }) => + Promise.resolve({ + ok: false, + host, + port, + testedAt: Date.now(), + failure: 'unsupported' + } as const) + } +} + +export function createComputerUsePermissionsApi(): NonNullable< + Partial['computerUsePermissions'] +> { + return { + getStatus: () => + callRuntimeResult( + 'computer.permissionsStatus', + {}, + 15_000 + ), + openSetup: (args) => + callRuntimeResult( + 'computer.permissions', + args ?? {}, + 15_000 + ).catch(() => ({ + platform: getBrowserPlatform(), + helperAppPath: null, + openedSettings: false, + launchedHelper: false, + nextStep: 'Computer-use permissions are managed on the Orca server.' + })), + reset: () => + Promise.resolve({ + platform: getBrowserPlatform(), + helperAppPath: null, + helperUnavailableReason: 'web_client', + bundleId: null, + permissions: [] + }) + } +} + +export function createSkillsApi(): NonNullable['skills']> { + return { + discover: (target) => + callRuntimeResult('skills.discover', target, 15_000), + // Why: browser clients have no local skill homes; remote-host freshness stays off until its update rail covers it. + freshnessInventory: (): Promise => + Promise.resolve({ + schemaVersion: 1, + installations: [], + eligibleUpdateNames: [], + scanIssues: [], + scannedAt: Date.now() + }), + // Why: with no local skill homes there is nothing to update, so the run rail + // reports a permanently idle state rather than spawning anything. + startUpdateRun: () => Promise.resolve({ started: false as const, reason: 'invalid-names' }), + cancelUpdateRun: () => Promise.resolve(), + acknowledgeUpdateRun: () => Promise.resolve(), + getUpdateRun: () => Promise.resolve({ state: 'idle' as const }), + prepareShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')), + publishShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')), + cancelShare: () => Promise.resolve(), + releaseShare: () => Promise.resolve(), + resolveShare: () => Promise.reject(new Error('Skill share links require the desktop app.')), + installShare: () => Promise.reject(new Error('Skill installation requires the desktop app.')), + installBundleShare: () => + Promise.reject(new Error('Skill installation requires the desktop app.')), + installPackageVersion: () => + Promise.reject(new Error('Skill installation requires the desktop app.')), + installBundlePackageVersion: () => + Promise.reject(new Error('Skill installation requires the desktop app.')), + cancelInstall: () => Promise.resolve({ cancelled: false }), + previewInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')), + previewBundleInstall: () => + Promise.reject(new Error('Skill installation requires the desktop app.')), + removeInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')), + listManagedInstalls: () => + Promise.reject(new Error('Skill installation requires the desktop app.')), + getPackage: () => Promise.reject(new Error('Skill installation requires the desktop app.')), + listOwnedShares: () => + Promise.reject(new Error('Skill package management requires the desktop app.')), + revokeShare: () => + Promise.reject(new Error('Skill package management requires the desktop app.')), + deletePackageVersion: () => + Promise.reject(new Error('Skill package management requires the desktop app.')), + deletePackage: () => + Promise.reject(new Error('Skill package management requires the desktop app.')), + listWslDistros: () => Promise.resolve([]), + onInstallProgress: () => () => {}, + onShareProgress: () => () => {}, + onUpdateRun: () => () => {} + } +} diff --git a/src/renderer/src/web/preload-api/web-keybinding-normalization.ts b/src/renderer/src/web/preload-api/web-keybinding-normalization.ts new file mode 100644 index 00000000000..c795bbfa1b1 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-keybinding-normalization.ts @@ -0,0 +1,174 @@ +import { + findKeybindingConflicts, + isKeybindingActionId, + normalizeKeybindingArrayForAction +} from '../../../../shared/keybindings' +import type { + KeybindingActionId, + KeybindingFileDiagnostic, + KeybindingOverrides, + KeybindingPlatform +} from '../../../../shared/keybindings' +import { translate } from '@/i18n/i18n' + +export type WebKeybindingDocument = { + version: 1 + keybindings: KeybindingOverrides + platforms: Partial> +} + +export const WEB_KEYBINDING_PLATFORMS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] + +export function isJsonObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +export function normalizeStoredWebOverrides( + value: unknown, + section: string, + diagnostics: KeybindingFileDiagnostic[] +): KeybindingOverrides { + if (value === undefined) { + return {} + } + if (!isJsonObject(value)) { + diagnostics.push({ + severity: 'error', + section, + message: translate('auto.web.web.preload.api.d2e43e426a', '{{value0}} must be an object.', { + value0: section + }) + }) + return {} + } + + const overrides: KeybindingOverrides = {} + for (const [actionId, rawBindings] of Object.entries(value)) { + if (!isKeybindingActionId(actionId)) { + diagnostics.push({ + severity: 'warning', + section, + actionId, + message: translate( + 'auto.web.web.preload.api.36761d9604', + 'Unknown keybinding action "{{value0}}" was ignored.', + { value0: actionId } + ) + }) + continue + } + if ( + !Array.isArray(rawBindings) || + !rawBindings.every((binding) => typeof binding === 'string') + ) { + diagnostics.push({ + severity: 'error', + section, + actionId, + message: translate( + 'auto.web.web.preload.api.10898045f3', + 'Shortcut for "{{value0}}" was ignored: Use a string array.', + { value0: actionId } + ) + }) + continue + } + const normalized = normalizeKeybindingArrayForAction(actionId, rawBindings) + if (!Array.isArray(normalized)) { + const error = normalized.ok ? 'Unable to parse shortcut.' : normalized.error + diagnostics.push({ + severity: 'error', + section, + actionId, + message: translate( + 'auto.web.web.preload.api.76122208ca', + 'Shortcut for "{{value0}}" was ignored: {{value1}}', + { value0: actionId, value1: error } + ) + }) + continue + } + overrides[actionId] = normalized + } + return overrides +} + +export function normalizeWebPlatformOverrides( + value: unknown, + diagnostics: KeybindingFileDiagnostic[] +): Partial> { + if (value === undefined) { + return {} + } + if (!isJsonObject(value)) { + diagnostics.push({ + severity: 'error', + section: 'platforms', + message: translate( + 'auto.web.web.preload.api.0a69fcd8bc', + 'platforms must be an object with darwin, linux, or win32 sections.' + ) + }) + return {} + } + + const result: Partial> = {} + for (const [platform, overrides] of Object.entries(value)) { + if (!WEB_KEYBINDING_PLATFORMS.includes(platform as KeybindingPlatform)) { + diagnostics.push({ + severity: 'warning', + section: `platforms.${platform}`, + message: translate( + 'auto.web.web.preload.api.32f15bdb0f', + 'Unknown platform "{{value0}}" was ignored.', + { value0: platform } + ) + }) + continue + } + result[platform as KeybindingPlatform] = normalizeStoredWebOverrides( + overrides, + `platforms.${platform}`, + diagnostics + ) + } + return result +} + +export function removeConflictingWebOverrides( + platform: KeybindingPlatform, + overrides: KeybindingOverrides, + diagnostics: KeybindingFileDiagnostic[] +): KeybindingOverrides { + let next = { ...overrides } + for (let attempt = 0; attempt < 20; attempt++) { + const conflicts = findKeybindingConflicts(platform, next) + const conflictingOverrides = new Set() + for (const conflict of conflicts) { + for (const actionId of conflict.actionIds) { + if (Object.hasOwn(next, actionId)) { + conflictingOverrides.add(actionId) + } + } + } + if (conflictingOverrides.size === 0) { + return next + } + for (const actionId of conflictingOverrides) { + delete next[actionId] + } + diagnostics.push({ + severity: 'error', + message: translate( + 'auto.web.web.preload.api.52bee9d8a0', + 'Conflicting custom shortcuts were ignored: {{value0}}.', + { + value0: Array.from(conflictingOverrides) + .map((actionId) => actionId) + .join(', ') + } + ) + }) + } + return next +} diff --git a/src/renderer/src/web/preload-api/web-keybindings-api.ts b/src/renderer/src/web/preload-api/web-keybindings-api.ts new file mode 100644 index 00000000000..1929d536b9d --- /dev/null +++ b/src/renderer/src/web/preload-api/web-keybindings-api.ts @@ -0,0 +1,174 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { + findKeybindingConflicts, + formatKeybindingList, + getKeybindingPlatform, + isKeybindingActionId, + normalizeKeybindingArrayForAction +} from '../../../../shared/keybindings' +import type { + KeybindingActionId, + KeybindingFileDiagnostic, + KeybindingFileSnapshot, + KeybindingOverrides, + KeybindingPlatform +} from '../../../../shared/keybindings' +import { + isJsonObject, + normalizeStoredWebOverrides, + normalizeWebPlatformOverrides, + removeConflictingWebOverrides +} from './web-keybinding-normalization' +import type { WebKeybindingDocument } from './web-keybinding-normalization' +import { KEYBINDINGS_STORAGE_KEY, getBrowserPlatform, readJson, writeJson } from './web-storage' + +export type WebKeybindingsApi = NonNullable + +export const webKeybindingListeners = new Set<(snapshot: KeybindingFileSnapshot) => void>() + +export function createEmptyWebKeybindingDocument(): WebKeybindingDocument { + return { + version: 1, + keybindings: {}, + platforms: { + darwin: {}, + linux: {}, + win32: {} + } + } +} + +export function getWebKeybindingPlatform(): KeybindingPlatform { + return getKeybindingPlatform(getBrowserPlatform()) +} + +export function readWebKeybindingDocument(): WebKeybindingDocument { + const document = readJson(KEYBINDINGS_STORAGE_KEY, createEmptyWebKeybindingDocument()) + return { + version: 1, + keybindings: isJsonObject(document.keybindings) + ? (document.keybindings as KeybindingOverrides) + : {}, + platforms: isJsonObject(document.platforms) + ? (document.platforms as Partial>) + : {} + } +} + +export function getWebKeybindingSnapshot(): KeybindingFileSnapshot { + const platform = getWebKeybindingPlatform() + const diagnostics: KeybindingFileDiagnostic[] = [] + const document = readWebKeybindingDocument() + const commonOverrides = normalizeStoredWebOverrides( + document.keybindings, + 'keybindings', + diagnostics + ) + const platformOverrides = normalizeWebPlatformOverrides(document.platforms, diagnostics) + const overrides = removeConflictingWebOverrides( + platform, + { + ...commonOverrides, + ...platformOverrides[platform] + }, + diagnostics + ) + + return { + path: 'Browser local storage', + platform, + exists: window.localStorage.getItem(KEYBINDINGS_STORAGE_KEY) !== null, + overrides, + commonOverrides, + platformOverrides, + diagnostics + } +} + +export function writeWebKeybindingAction( + actionId: KeybindingActionId, + bindings: string[] | null +): KeybindingFileSnapshot { + if (!isKeybindingActionId(actionId)) { + throw new Error(`Unknown keybinding action "${String(actionId)}".`) + } + const normalizedBindings = + bindings === null ? null : normalizeKeybindingArrayForAction(actionId, bindings) + if (normalizedBindings !== null && !Array.isArray(normalizedBindings)) { + throw new Error(normalizedBindings.ok ? 'Unable to parse shortcut.' : normalizedBindings.error) + } + + const platform = getWebKeybindingPlatform() + const currentSnapshot = getWebKeybindingSnapshot() + const candidateOverrides = { ...currentSnapshot.overrides } + if (normalizedBindings === null) { + delete candidateOverrides[actionId] + } else { + candidateOverrides[actionId] = normalizedBindings + } + const blockingConflict = findKeybindingConflicts(platform, candidateOverrides).find((conflict) => + conflict.actionIds.includes(actionId) + ) + if (blockingConflict) { + throw new Error( + `${formatKeybindingList([blockingConflict.binding], platform)} conflicts with another shortcut.` + ) + } + + const activePlatform: KeybindingOverrides = { ...currentSnapshot.platformOverrides[platform] } + if (normalizedBindings === null) { + delete activePlatform[actionId] + } else { + activePlatform[actionId] = normalizedBindings + } + + writeJson(KEYBINDINGS_STORAGE_KEY, { + version: 1, + keybindings: currentSnapshot.commonOverrides, + platforms: { + ...currentSnapshot.platformOverrides, + darwin: currentSnapshot.platformOverrides.darwin ?? {}, + linux: currentSnapshot.platformOverrides.linux ?? {}, + win32: currentSnapshot.platformOverrides.win32 ?? {}, + [platform]: activePlatform + } + } satisfies WebKeybindingDocument) + + const snapshot = getWebKeybindingSnapshot() + notifyWebKeybindingListeners(snapshot) + return snapshot +} + +export function notifyWebKeybindingListeners(snapshot: KeybindingFileSnapshot): void { + for (const listener of webKeybindingListeners) { + listener(snapshot) + } +} + +export function createWebKeybindingsApi(): WebKeybindingsApi { + return { + get: () => Promise.resolve(getWebKeybindingSnapshot()), + ensureFile: () => Promise.resolve(getWebKeybindingSnapshot()), + setAction: async ({ actionId, bindings }) => writeWebKeybindingAction(actionId, bindings), + reload: () => { + const snapshot = getWebKeybindingSnapshot() + notifyWebKeybindingListeners(snapshot) + return Promise.resolve(snapshot) + }, + openFile: () => Promise.resolve(getWebKeybindingSnapshot()), + revealFile: () => Promise.resolve(getWebKeybindingSnapshot()), + onChanged: (callback) => { + webKeybindingListeners.add(callback) + const onStorage = (event: StorageEvent): void => { + if (event.key === KEYBINDINGS_STORAGE_KEY) { + callback(getWebKeybindingSnapshot()) + } + } + window.addEventListener('storage', onStorage) + return () => { + webKeybindingListeners.delete(callback) + window.removeEventListener('storage', onStorage) + } + } + } +} diff --git a/src/renderer/src/web/preload-api/web-macos-tcc-api.ts b/src/renderer/src/web/preload-api/web-macos-tcc-api.ts new file mode 100644 index 00000000000..899545822ee --- /dev/null +++ b/src/renderer/src/web/preload-api/web-macos-tcc-api.ts @@ -0,0 +1,13 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { noopUnsubscribe } from './web-storage' + +export function createMacosTccPromptsApi(): NonNullable['macosTccPrompts']> { + // Why: TCC is a macOS-desktop concept; the web client has no log stream to watch. + return { + onThreshold: () => noopUnsubscribe, + consumePending: () => Promise.resolve(null), + acknowledgePending: () => Promise.resolve(), + releasePending: () => Promise.resolve(), + dismiss: () => Promise.resolve() + } +} diff --git a/src/renderer/src/web/preload-api/web-memory-api.ts b/src/renderer/src/web/preload-api/web-memory-api.ts new file mode 100644 index 00000000000..a99fc7ccfa7 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-memory-api.ts @@ -0,0 +1,24 @@ +import type { MemorySnapshot } from '../../../../shared/process-stats-types' +import { getBrowserPlatform } from './web-storage' + +export function createEmptyMemorySnapshot(): MemorySnapshot { + const emptyUsage = { cpu: 0, memory: 0 } + return { + app: { ...emptyUsage, main: emptyUsage, renderer: emptyUsage, other: emptyUsage, history: [] }, + worktrees: [], + host: { + totalMemory: 0, + freeMemory: 0, + availableMemory: 0, + availableMemorySource: 'free-memory', + usedMemory: 0, + memoryUsagePercent: 0, + cpuCoreCount: navigator.hardwareConcurrency || 1, + loadAverage1m: 0 + }, + processMemoryMetric: getBrowserPlatform() === 'win32' ? 'working-set' : 'rss', + totalCpu: 0, + totalMemory: 0, + collectedAt: Date.now() + } +} diff --git a/src/renderer/src/web/preload-api/web-mobile-api.ts b/src/renderer/src/web/preload-api/web-mobile-api.ts new file mode 100644 index 00000000000..6a631c743dd --- /dev/null +++ b/src/renderer/src/web/preload-api/web-mobile-api.ts @@ -0,0 +1,26 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { webRuntimeState } from './web-runtime-session' +import { noopUnsubscribe } from './web-storage' + +export function createWebMobileApi(): Partial { + return { + mobile: { + listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }), + getPairingQR: () => Promise.resolve({ available: false }), + getWindowsFirewallStatus: () => Promise.resolve({ supported: false }), + repairWindowsFirewall: () => Promise.resolve({ ok: false, reason: 'unsupported' }), + openWindowsNetworkSettings: () => Promise.resolve(false), + getRuntimePairingUrl: () => Promise.resolve({ available: false }), + listDevices: () => Promise.resolve({ devices: [] }), + revokeDevice: () => Promise.resolve({ revoked: false }), + listRuntimeAccessGrants: () => Promise.resolve({ grants: [] }), + revokeRuntimeAccess: () => Promise.resolve({ revoked: false }), + isWebSocketReady: () => + Promise.resolve({ ready: Boolean(webRuntimeState.activeEnvironment), endpoint: null }), + getRelayStatus: () => Promise.resolve({ status: 'offline' as const }), + onRelayStatusChanged: () => noopUnsubscribe, + consumePendingUnpairedDeviceAuthFailure: () => Promise.resolve(false), + onUnpairedDeviceAuthFailure: () => noopUnsubscribe + } + } +} diff --git a/src/renderer/src/web/preload-api/web-native-chat-api.ts b/src/renderer/src/web/preload-api/web-native-chat-api.ts new file mode 100644 index 00000000000..31eec6d92fa --- /dev/null +++ b/src/renderer/src/web/preload-api/web-native-chat-api.ts @@ -0,0 +1,156 @@ +import type { NativeChatApi, NativeChatAppendedMessages } from '../../../../preload/api-types' +import { buildNativeChatUnsubscribe } from '../../../../shared/native-chat-stream-unsubscribe' +import { + parseRuntimeNativeChatReadSessionResult, + parseRuntimeNativeChatTurnLifecycle +} from '@/components/native-chat/native-chat-runtime-contract' +import { translate } from '@/i18n/i18n' +import { callRuntimeResult } from './web-runtime-calls' +import { getClientForEnvironment, requireActiveEnvironmentOrNull } from './web-runtime-session' + +export function createWebNativeChatApi(): NativeChatApi { + return { + readSession: async (agent, sessionId, limit, transcriptPath) => + parseRuntimeNativeChatReadSessionResult( + await callRuntimeResult('nativeChat.readSession', { + agent, + sessionId, + limit, + transcriptPath + }) + ), + subscribe: (args, onFrame) => { + // No paired runtime yet: return a no-op teardown so the chat view mounts cleanly; only the not-paired case is swallowed. + const environment = requireActiveEnvironmentOrNull() + if (!environment) { + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: translate( + 'components.native-chat.state.pairHost', + 'Pair a host to view agent chat history.' + ) + }) + return () => {} + } + let handle: { unsubscribe: () => void } | null = null + let cancelled = false + let receivedInitial = false + void getClientForEnvironment(environment) + .subscribe( + 'nativeChat.subscribe', + { + agent: args.agent, + sessionId: args.sessionId, + subscriptionId: args.subscriptionId, + transcriptPath: args.transcriptPath, + limit: args.limit + }, + { + onResponse: (response) => { + if (cancelled) { + return + } + if (!response.ok) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: response.error.message + }) + } + return + } + const result = response.result as { + type?: string + messages?: NativeChatAppendedMessages + hasMore?: boolean + error?: string + lifecycle?: unknown + } + const lifecycle = parseRuntimeNativeChatTurnLifecycle(result?.lifecycle) + if ( + (result?.type === 'appended' || + result?.type === 'snapshot' || + result?.type === 'replacement') && + Array.isArray(result.messages) + ) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: result.messages, + hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300), + ...(result.error ? { error: result.error } : {}), + ...(lifecycle ? { lifecycle } : {}) + }) + } else if (result.type === 'snapshot') { + onFrame({ + type: 'snapshot', + messages: result.messages, + hasMore: result.hasMore ?? false, + ...(result.error ? { error: result.error } : {}), + ...(lifecycle ? { lifecycle } : {}) + }) + } else { + onFrame( + result.type === 'replacement' + ? { + type: 'replacement', + messages: result.messages, + hasMore: result.hasMore ?? false, + ...(lifecycle ? { lifecycle } : {}) + } + : { + type: 'appended', + messages: result.messages, + ...(lifecycle ? { lifecycle } : {}) + } + ) + } + } else if (!receivedInitial) { + // Why: an unrecognized ok payload never flips receivedInitial, stranding the view on 'loading'; settle it empty instead. + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + ...(result?.error ? { error: result.error } : {}) + }) + } + } + }, + { + // Why: unsubscribe reaps the fs-watcher on view-toggle (leak fix); echo the pane token so two panes don't tear down each other's watcher. + buildUnsubscribe: () => + buildNativeChatUnsubscribe(args.agent, args.sessionId, args.subscriptionId) + } + ) + .then((h) => { + if (cancelled) { + h.unsubscribe() + } else { + handle = h + } + }) + .catch((err: unknown) => { + if (!cancelled && !receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: err instanceof Error ? err.message : String(err) + }) + } + }) + return () => { + cancelled = true + handle?.unsubscribe() + } + } + } +} diff --git a/src/renderer/src/web/preload-api/web-notifications-api.ts b/src/renderer/src/web/preload-api/web-notifications-api.ts new file mode 100644 index 00000000000..813d694ba82 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-notifications-api.ts @@ -0,0 +1,14 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { getBrowserPlatform } from './web-storage' + +export function createNotificationsApi(): NonNullable['notifications']> { + return { + dispatch: () => Promise.resolve({ delivered: false, reason: 'not-supported' }), + dismiss: () => Promise.resolve({ dismissed: 0 }), + openSystemSettings: () => Promise.resolve(), + getPermissionStatus: () => + Promise.resolve({ supported: false, platform: getBrowserPlatform(), requested: false }), + probeDelivery: () => Promise.resolve({ state: 'unsupported' as const, authoritative: false }), + playSound: () => Promise.resolve({ played: false, reason: 'missing-path' }) + } +} diff --git a/src/renderer/src/web/preload-api/web-onboarding-api.ts b/src/renderer/src/web/preload-api/web-onboarding-api.ts new file mode 100644 index 00000000000..4fcf9593623 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-onboarding-api.ts @@ -0,0 +1,56 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { ONBOARDING_FLOW_VERSION, getDefaultOnboardingState } from '../../../../shared/constants' +import type { OnboardingState } from '../../../../shared/onboarding-state-types' +import { ONBOARDING_STORAGE_KEY, readJson, writeJson } from './web-storage' + +export function getStoredOnboarding(): OnboardingState { + const storedRaw = window.localStorage.getItem(ONBOARDING_STORAGE_KEY) + if (storedRaw) { + const stored = readJson(ONBOARDING_STORAGE_KEY, getDefaultOnboardingState()) + if (stored.checklist.dismissed) { + return stored + } + const closed = closeWebOnboarding(stored) + writeJson(ONBOARDING_STORAGE_KEY, closed) + return closed + } + const closed = closeWebOnboarding(getDefaultOnboardingState()) + // Why: paired clients already have an Orca server; skip desktop first-run onboarding that would probe browser-local tools. + writeJson(ONBOARDING_STORAGE_KEY, closed) + return closed +} + +export function closeWebOnboarding(base: OnboardingState): OnboardingState { + return { + ...base, + flowVersion: ONBOARDING_FLOW_VERSION, + closedAt: Date.now(), + outcome: 'dismissed', + checklist: { + ...base.checklist, + dismissed: true + } + } +} + +export function createWebOnboardingApi(): Partial { + return { + onboarding: { + get: () => Promise.resolve(getStoredOnboarding()), + update: async (updates) => { + const current = getStoredOnboarding() + const next: OnboardingState = { + ...current, + ...updates, + flowVersion: ONBOARDING_FLOW_VERSION, + checklist: { + ...current.checklist, + ...updates.checklist + } + } + writeJson(ONBOARDING_STORAGE_KEY, next) + return next + } + } + } +} diff --git a/src/renderer/src/web/preload-api/web-orca-profiles-api.ts b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts new file mode 100644 index 00000000000..0b552bf15c8 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts @@ -0,0 +1,70 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { + DEFAULT_LOCAL_ORCA_PROFILE_ID, + createDefaultLocalOrcaProfile +} from '../../../../shared/orca-profiles' + +export function createWebOrcaProfilesApi(): Partial { + const webOrcaProfileAuthStatus = () => + Promise.resolve({ + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + configured: false, + state: 'unconfigured' as const, + persistence: 'none' as const, + setupMessage: 'Orca Cloud sign-in is not available in the browser fallback.' + }) + return { + orcaProfiles: { + list: () => + Promise.resolve({ + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profiles: [createDefaultLocalOrcaProfile(0)], + multiProfileUi: false + }), + authStatus: webOrcaProfileAuthStatus, + createLocal: () => + Promise.resolve({ + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profiles: [createDefaultLocalOrcaProfile(0)], + profile: createDefaultLocalOrcaProfile(0) + }), + createCloudLinked: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + switchProfile: () => Promise.resolve({ status: 'already-active' }), + transferProject: (args) => + Promise.resolve({ + status: 'duplicate-target', + sourceProfileId: args.sourceProfileId, + targetProfileId: args.targetProfileId, + sourceRepoId: args.repoId, + duplicateRepoId: args.repoId + }), + findProjectProfiles: async () => ({ projects: [] }), + connectCurrent: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + refreshAuth: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + signOutCurrent: async () => ({ + status: 'signed-out', + auth: await webOrcaProfileAuthStatus(), + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profiles: [createDefaultLocalOrcaProfile(0)] + }), + selectOrg: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + orgMembersList: async () => ({ status: 'unconfigured' }), + orgMemberInvite: async () => ({ status: 'unconfigured' }), + orgInviteRevoke: async () => ({ status: 'unconfigured' }), + orgMemberChangeRole: async () => ({ status: 'unconfigured' }), + orgMemberRemove: async () => ({ status: 'unconfigured' }) + } + } +} diff --git a/src/renderer/src/web/preload-api/web-platform-api.ts b/src/renderer/src/web/preload-api/web-platform-api.ts new file mode 100644 index 00000000000..22e88ff6671 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-platform-api.ts @@ -0,0 +1,16 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { getBrowserPlatform } from './web-storage' + +export function createWebPlatformApi(): Partial { + return { + platform: { + get: () => ({ + platform: getBrowserPlatform(), + osRelease: '', + arch: '', + shell: '', + displayServer: null + }) + } + } +} diff --git a/src/renderer/src/web/preload-api/web-preference-normalization.ts b/src/renderer/src/web/preload-api/web-preference-normalization.ts new file mode 100644 index 00000000000..e6dfc33e286 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-preference-normalization.ts @@ -0,0 +1,164 @@ +import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../../shared/auto-rename-branch-from-work-settings' +import { + getDefaultSettings, + normalizeAgentActivityDisplayMode, + normalizeWorktreeCardProperties +} from '../../../../shared/constants' +import { normalizeContextualTourIds } from '../../../../shared/contextual-tours' +import type { ContextualTourId } from '../../../../shared/contextual-tours' +import { normalizeFeatureInteractions } from '../../../../shared/feature-interactions' +import type { + FeatureInteractionId, + FeatureInteractionState +} from '../../../../shared/feature-interactions' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { PairedUiState, PairingLocalUiField } from '../../../../shared/pairing-local-ui-fields' +import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' +import { normalizeStatusBarUsageMode } from '../../../../shared/status-bar-usage-mode' +import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custom-themes' +import { + normalizeTuiAgentArgsRecord, + normalizeTuiAgentEnvRecord +} from '../../../../shared/tui-agent-launch-defaults' +import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' +import { normalizeUiLanguage } from '../../../../shared/ui-language' +import { normalizeUsagePercentageDisplay } from '../../../../shared/usage-percentage-display' +import { mergeWorkspaceCleanupUIState } from '../../../../shared/workspace-cleanup-ui-state' + +export function mergeWebUIState( + base: PersistedUIState, + updates: Partial +): PersistedUIState { + const { featureInteractionTelemetryBuckets: _reserved, ...safeUpdates } = + updates as Partial & { + featureInteractionTelemetryBuckets?: unknown + } + void _reserved + return { + ...base, + ...safeUpdates, + workspaceCleanup: mergeWorkspaceCleanupUIState( + base.workspaceCleanup, + safeUpdates.workspaceCleanup + ), + worktreeCardProperties: normalizeWorktreeCardProperties( + safeUpdates.worktreeCardProperties ?? base.worktreeCardProperties + ), + _worktreeCardModeDefaulted: + safeUpdates._worktreeCardModeDefaulted ?? base._worktreeCardModeDefaulted, + agentActivityDisplayMode: normalizeAgentActivityDisplayMode( + safeUpdates.agentActivityDisplayMode ?? base.agentActivityDisplayMode + ), + usagePercentageDisplay: normalizeUsagePercentageDisplay( + safeUpdates.usagePercentageDisplay ?? base.usagePercentageDisplay + ), + statusBarUsageMode: normalizeStatusBarUsageMode( + safeUpdates.statusBarUsageMode ?? base.statusBarUsageMode + ) + } +} + +export function mergeHostWebUIState( + local: PersistedUIState, + incoming: PairedUiState +): PersistedUIState { + // Why `satisfies Record<...>` rather than a `Pick<...>` annotation: every member is optional in + // PersistedUIState, so Pick would accept a literal that silently skipped a newly added member. + const pinned = { + hideWorkspacesFromOtherDevices: local.hideWorkspacesFromOtherDevices === true, + manualRepoOrder: local.manualRepoOrder, + workspaceHostOrder: local.workspaceHostOrder + } satisfies Record & Partial + return { ...mergeWebUIState(local, incoming), ...pinned } +} + +export function mergeFeatureInteractionState( + current: PersistedUIState['featureInteractions'], + incoming: PersistedUIState['featureInteractions'] +): FeatureInteractionState { + const currentNormalized = normalizeFeatureInteractions(current) + const incomingNormalized = normalizeFeatureInteractions(incoming) + const merged: FeatureInteractionState = { ...currentNormalized } + for (const [id, incomingRecord] of Object.entries(incomingNormalized)) { + const featureId = id as FeatureInteractionId + const currentRecord = currentNormalized[featureId] + merged[featureId] = currentRecord + ? { + firstInteractedAt: Math.min( + currentRecord.firstInteractedAt, + incomingRecord.firstInteractedAt + ), + interactionCount: Math.max( + currentRecord.interactionCount, + incomingRecord.interactionCount + ) + } + : incomingRecord + } + return merged +} + +export function mergeContextualTourSeenIds( + current: PersistedUIState['contextualToursSeenIds'], + incoming: PersistedUIState['contextualToursSeenIds'] +): ContextualTourId[] { + const merged = new Set(normalizeContextualTourIds(current)) + for (const id of normalizeContextualTourIds(incoming)) { + merged.add(id) + } + return [...merged] +} + +export function mergeOsc52ClipboardNoticePending( + current: PersistedUIState, + incoming: PersistedUIState +): boolean { + return ( + current.osc52ClipboardDefaultOnNoticePending === true || + incoming.osc52ClipboardDefaultOnNoticePending === true + ) +} + +export function mergeSettings( + base: GlobalSettings, + updates: Partial, + options: { preserveAutoRenameBranchFromWorkUpdate?: boolean } = {} +): GlobalSettings { + const defaults = getDefaultSettings('~') + const merged = { + ...base, + ...updates, + notifications: { + ...base.notifications, + ...updates.notifications + }, + githubProjects: { + ...(base.githubProjects ?? defaults.githubProjects), + ...updates.githubProjects + } as GlobalSettings['githubProjects'], + disabledTuiAgents: normalizeDisabledTuiAgents( + updates.disabledTuiAgents ?? base.disabledTuiAgents + ), + agentDefaultArgs: normalizeTuiAgentArgsRecord( + updates.agentDefaultArgs ?? base.agentDefaultArgs + ), + agentDefaultEnv: normalizeTuiAgentEnvRecord(updates.agentDefaultEnv ?? base.agentDefaultEnv), + voice: { + ...(base.voice ?? defaults.voice), + ...updates.voice + } as NonNullable, + activeRuntimeEnvironmentId: Object.hasOwn(updates, 'activeRuntimeEnvironmentId') + ? (updates.activeRuntimeEnvironmentId ?? null) + : (base.activeRuntimeEnvironmentId ?? null), + terminalCustomThemes: normalizeTerminalCustomThemes( + updates.terminalCustomThemes ?? base.terminalCustomThemes + ), + uiLanguage: normalizeUiLanguage(updates.uiLanguage ?? base.uiLanguage) + } + return { + ...merged, + ...normalizeAutoRenameBranchFromWorkDefaultOn(merged, { + preserveExplicitValue: options.preserveAutoRenameBranchFromWorkUpdate + }) + } +} diff --git a/src/renderer/src/web/preload-api/web-preferences-store.ts b/src/renderer/src/web/preload-api/web-preferences-store.ts new file mode 100644 index 00000000000..13c755b1d87 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-preferences-store.ts @@ -0,0 +1,297 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../../shared/auto-rename-branch-from-work-settings' +import { + getDefaultSettings, + getDefaultUIState, + getWorktreeCardModeProperties +} from '../../../../shared/constants' +import { normalizeWorktreeVisibilityDefaults } from '../../../../shared/external-worktree-visibility' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { + normalizeOsc52ClipboardDefaultOn, + osc52ClipboardDefaultOnOverridesPersistedOff +} from '../../../../shared/osc52-clipboard-settings' +import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' +import { + applyPRBotAuthorOverride, + normalizePRBotAuthorOverrides +} from '../../../../shared/pr-bot-author-overrides' +import { normalizeTerminalCursorStyleDefault } from '../../../../shared/terminal-cursor-style-settings' +import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custom-themes' +import { normalizeUiLanguage } from '../../../../shared/ui-language' +import { readStoredWebRuntimeEnvironment } from '../web-runtime-environment' +import { mergeSettings, mergeWebUIState } from './web-preference-normalization' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironmentOrNull, webRuntimeState } from './web-runtime-session' +import { SETTINGS_STORAGE_KEY, UI_STORAGE_KEY, readJson, writeJson } from './web-storage' + +export type WebSettingsApi = NonNullable + +export function getStoredSettings(): GlobalSettings { + webRuntimeState.activeEnvironment = + webRuntimeState.activeEnvironment ?? readStoredWebRuntimeEnvironment() + const defaults = getDefaultSettings('~') + const rawStoredSettings = window.localStorage.getItem(SETTINGS_STORAGE_KEY) + const stored = readJson>(SETTINGS_STORAGE_KEY, {}) + const migratedStored = { + ...stored, + ...normalizeAutoRenameBranchFromWorkDefaultOn(stored), + ...normalizeTerminalCursorStyleDefault(stored), + ...normalizeOsc52ClipboardDefaultOn(stored), + terminalCustomThemes: normalizeTerminalCustomThemes(stored.terminalCustomThemes), + uiLanguage: normalizeUiLanguage(stored.uiLanguage) + } + if ( + rawStoredSettings && + (stored.autoRenameBranchFromWork !== migratedStored.autoRenameBranchFromWork || + stored.autoRenameBranchFromWorkDefaultedOn !== + migratedStored.autoRenameBranchFromWorkDefaultedOn || + stored.terminalCursorStyle !== migratedStored.terminalCursorStyle || + stored.terminalCursorStyleDefaultedToBlock !== + migratedStored.terminalCursorStyleDefaultedToBlock || + // Kept even though the terminalCustomThemes reference compare below already forces + // this branch for every stored blob: no migration should rely on that accident. + stored.terminalAllowOsc52Clipboard !== migratedStored.terminalAllowOsc52Clipboard || + stored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== + migratedStored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers || + stored.terminalCustomThemes !== migratedStored.terminalCustomThemes || + stored.uiLanguage !== migratedStored.uiLanguage) + ) { + try { + const parsed = JSON.parse(rawStoredSettings) as unknown + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + writeJson(SETTINGS_STORAGE_KEY, migratedStored) + if (osc52ClipboardDefaultOnOverridesPersistedOff(stored)) { + // Why a raw merge, not readLocalWebUIState(): that path calls back into + // getStoredSettings(), and writing through it here would recurse. + writeJson(UI_STORAGE_KEY, { + ...readJson>(UI_STORAGE_KEY, {}), + osc52ClipboardDefaultOnNoticePending: true + }) + } + } + } catch { + // Keep readJson's invalid-JSON fallback non-destructive. + } + } + return mergeSettings( + { + ...defaults, + floatingTerminalEnabled: false, + rightSidebarOpenByDefault: false, + activeRuntimeEnvironmentId: null + }, + migratedStored + ) +} + +export function writeStoredSettings( + settings: GlobalSettings, + explicitActiveRuntimeEnvironmentId?: string | null +): void { + const durable = { ...settings } + if (explicitActiveRuntimeEnvironmentId !== undefined) { + durable.activeRuntimeEnvironmentId = explicitActiveRuntimeEnvironmentId + } else { + const stored = readJson>(SETTINGS_STORAGE_KEY, {}) + if (Object.hasOwn(stored, 'activeRuntimeEnvironmentId')) { + durable.activeRuntimeEnvironmentId = stored.activeRuntimeEnvironmentId ?? null + } else { + delete durable.activeRuntimeEnvironmentId + } + } + writeJson(SETTINGS_STORAGE_KEY, durable) +} + +export async function getRuntimeBackedStoredSettings(): Promise { + const local = getStoredSettings() + const requestedEnvironment = requireActiveEnvironmentOrNull() + if (!requestedEnvironment) { + return local + } + try { + const result = await callRuntimeResult<{ settings: Partial }>( + 'settings.get', + undefined, + 15_000 + ) + const runtimeSettings: Partial = {} + const currentEnvironment = requireActiveEnvironmentOrNull() + if (currentEnvironment?.id === requestedEnvironment.id) { + const visibilityDefaults = normalizeWorktreeVisibilityDefaults( + result.settings.worktreeVisibilityDefaults + ) + webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId = visibilityDefaults + ? requestedEnvironment.id + : null + webRuntimeState.worktreeVisibilityDefaultsRuntimeValue = visibilityDefaults ?? null + } + if (typeof result.settings.experimentalNewWorktreeCardStyle === 'boolean') { + runtimeSettings.experimentalNewWorktreeCardStyle = + result.settings.experimentalNewWorktreeCardStyle + } + if (typeof result.settings.compactWorktreeCards === 'boolean') { + runtimeSettings.compactWorktreeCards = result.settings.compactWorktreeCards + } + if (typeof result.settings.minimaxGroupId === 'string') { + runtimeSettings.minimaxGroupId = result.settings.minimaxGroupId + } + if (typeof result.settings.minimaxUsageModels === 'string') { + runtimeSettings.minimaxUsageModels = result.settings.minimaxUsageModels + } + if (Array.isArray(result.settings.prBotAuthorOverrides)) { + runtimeSettings.prBotAuthorOverrides = normalizePRBotAuthorOverrides( + result.settings.prBotAuthorOverrides + ) + } + // Read-only mirror: the host owns this capability and `syncRuntimeBackedSettings` never + // sends it back, so web shows what the host enforces instead of a local value it ignores. + if (typeof result.settings.artifactSharingEnabled === 'boolean') { + runtimeSettings.artifactSharingEnabled = result.settings.artifactSharingEnabled + } + if (typeof result.settings.agentSkillSharingEnabled === 'boolean') { + runtimeSettings.agentSkillSharingEnabled = result.settings.agentSkillSharingEnabled + } + const next = mergeSettings(local, runtimeSettings) + writeStoredSettings(next) + return settingsForActiveVisibilityOwner(next) + } catch { + // Why: unpaired/offline web clients keep a local settings fallback. + return settingsForActiveVisibilityOwner(local) + } +} + +export function settingsForActiveVisibilityOwner(settings: GlobalSettings): GlobalSettings { + const environment = requireActiveEnvironmentOrNull() + if (!environment) { + return settings + } + if ( + environment.id === webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId && + webRuntimeState.worktreeVisibilityDefaultsRuntimeValue + ) { + return { + ...settings, + worktreeVisibilityDefaults: webRuntimeState.worktreeVisibilityDefaultsRuntimeValue + } + } + const { worktreeVisibilityDefaults: _unsupported, ...supportedSettings } = settings + return supportedSettings as GlobalSettings +} + +export async function syncRuntimeBackedSettings( + updates: Partial, + localNext: GlobalSettings +): Promise { + const requestedEnvironment = requireActiveEnvironmentOrNull() + if (!requestedEnvironment) { + return localNext + } + const runtimeUpdates: Partial = {} + const visibilityDefaults = normalizeWorktreeVisibilityDefaults(updates.worktreeVisibilityDefaults) + if (visibilityDefaults) { + runtimeUpdates.worktreeVisibilityDefaults = visibilityDefaults + } + if (typeof updates.experimentalNewWorktreeCardStyle === 'boolean') { + runtimeUpdates.experimentalNewWorktreeCardStyle = updates.experimentalNewWorktreeCardStyle + } + if (typeof updates.compactWorktreeCards === 'boolean') { + runtimeUpdates.compactWorktreeCards = updates.compactWorktreeCards + } + if (typeof updates.minimaxGroupId === 'string') { + runtimeUpdates.minimaxGroupId = updates.minimaxGroupId + } + if (typeof updates.minimaxUsageModels === 'string') { + runtimeUpdates.minimaxUsageModels = updates.minimaxUsageModels + } + if (Array.isArray(updates.prBotAuthorOverrides)) { + runtimeUpdates.prBotAuthorOverrides = normalizePRBotAuthorOverrides( + updates.prBotAuthorOverrides + ) + } + if (Object.keys(runtimeUpdates).length === 0) { + return localNext + } + try { + const result = await callRuntimeResult<{ settings: Partial }>( + 'settings.update', + runtimeUpdates, + 15_000 + ) + const runtimeSettings = { ...result.settings } + delete runtimeSettings.activeRuntimeEnvironmentId + const updatedVisibilityDefaults = normalizeWorktreeVisibilityDefaults( + runtimeSettings.worktreeVisibilityDefaults + ) + if ( + requireActiveEnvironmentOrNull()?.id === requestedEnvironment.id && + updatedVisibilityDefaults + ) { + webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId = requestedEnvironment.id + webRuntimeState.worktreeVisibilityDefaultsRuntimeValue = updatedVisibilityDefaults + } + delete runtimeSettings.worktreeVisibilityDefaults + const next = mergeSettings(localNext, runtimeSettings) + writeStoredSettings(next) + return next + } catch (error) { + if (visibilityDefaults) { + throw error + } + // Why: unpaired/offline web clients still need local settings persistence. + return localNext + } +} + +export async function updateRuntimePRBotAuthorOverride(args: { + author: string + isBot: boolean +}): Promise { + const local = getStoredSettings() + if (requireActiveEnvironmentOrNull()) { + // Why: don't report a successful mark the authoritative runtime failed to persist and will later overwrite. + const result = await callRuntimeResult<{ settings: Partial }>( + 'settings.updatePRBotAuthorOverride', + args, + 15_000 + ) + const next = mergeSettings(local, { + prBotAuthorOverrides: normalizePRBotAuthorOverrides(result.settings.prBotAuthorOverrides) + }) + writeStoredSettings(next) + return next + } + const next = mergeSettings(local, { + prBotAuthorOverrides: applyPRBotAuthorOverride( + local.prBotAuthorOverrides, + args.author, + args.isBot + ) + }) + writeStoredSettings(next) + return next +} + +export function readLocalWebUIState(): PersistedUIState { + const defaults = getDefaultUIState() + // Why settings first: getStoredSettings() runs the OSC 52 migration, which writes the + // notice arm into UI_STORAGE_KEY. Reading before it would snapshot a pre-arm state that + // every caller then writes back, erasing the arm the stamp can never raise again. + const storedSettings = getStoredSettings() + const stored = readJson>(UI_STORAGE_KEY, {}) + const base = { + ...defaults, + // Why: mirror the main-process missing-property seed from legacy card layout mode when runtime ui.get is unavailable. + worktreeCardProperties: getWorktreeCardModeProperties( + storedSettings.compactWorktreeCards ? 'Compact' : 'Default' + ) + } + if (typeof stored.rightSidebarOpen === 'boolean') { + return mergeWebUIState(base, stored) + } + return mergeWebUIState(base, { + ...stored, + // Why: web fallback lacks main-process normalization; migrate the retired setting only when local UI preference is absent. + rightSidebarOpen: storedSettings.rightSidebarOpenByDefault + }) +} diff --git a/src/renderer/src/web/preload-api/web-rate-limits-api.ts b/src/renderer/src/web/preload-api/web-rate-limits-api.ts new file mode 100644 index 00000000000..7587a838660 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-rate-limits-api.ts @@ -0,0 +1,36 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import type { RateLimitState } from '../../../../shared/rate-limit-types' +import { noopUnsubscribe } from './web-storage' + +export function createRateLimitsApi(): NonNullable['rateLimits']> { + const empty: RateLimitState = { + claude: null, + codex: null, + gemini: null, + opencodeGo: null, + kimi: null, + antigravity: null, + minimax: null, + grok: null, + minimaxCookieConfigured: false, + grokAuthConfigured: false, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null }, + inactiveClaudeAccounts: [], + inactiveCodexAccounts: [] + } + return { + get: () => Promise.resolve(empty), + refresh: () => Promise.resolve(empty), + refreshCodexForTarget: () => Promise.resolve(empty), + // Why: web clients don't own local Codex auth; report the safe no-credit outcome since redemption is desktop-only. + consumeCodexResetCredit: () => Promise.resolve({ outcome: 'noCredit', state: empty }), + refreshClaudeForTarget: () => Promise.resolve(empty), + setPollingInterval: () => Promise.resolve(), + fetchInactiveClaudeAccounts: () => Promise.resolve(), + fetchInactiveCodexAccounts: () => Promise.resolve(), + refreshMiniMax: () => Promise.resolve(empty), + refreshGrok: () => Promise.resolve(empty), + onUpdate: () => noopUnsubscribe + } +} diff --git a/src/renderer/src/web/preload-api/web-repositories-api.ts b/src/renderer/src/web/preload-api/web-repositories-api.ts new file mode 100644 index 00000000000..cf3974ac04d --- /dev/null +++ b/src/renderer/src/web/preload-api/web-repositories-api.ts @@ -0,0 +1,130 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { legacyBaseRefSearchResult } from '../../../../shared/base-ref-search-result' +import type { Repo } from '../../../../shared/repo-types' +import { getDefaultCreateProjectParent } from '@/components/sidebar/create-project-defaults' +import { + callRuntimeResult, + callRuntimeResultWithOwner, + withRuntimeRepoMutationOwner, + withRuntimeRepoOwner +} from './web-runtime-calls' +import { assertActiveEnvironment, invalidateRuntimeWorktreeCaches } from './web-runtime-session' +import { noopUnsubscribe } from './web-storage' + +export function createReposApi(): NonNullable['repos']> { + return { + list: async () => { + const owned = await callRuntimeResultWithOwner<{ repos: Repo[] }>('repo.list') + return owned.result.repos.map((repo) => withRuntimeRepoOwner(repo, owned.hostId)) + }, + add: async ({ path, kind }) => { + invalidateRuntimeWorktreeCaches() + const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>( + 'repo.add', + { path, kind } + ) + return withRuntimeRepoMutationOwner(owned.result, owned.hostId) + }, + remove: async ({ repoId }) => { + await callRuntimeResult('repo.rm', { repo: repoId }) + invalidateRuntimeWorktreeCaches() + }, + // Why: host-scoped forget targets a desktop-owned SSH host; a paired web client has one runtime and no ghost-host state. + removeForHost: () => { + throw new Error('Forgetting a host is unavailable in paired web clients.') + }, + reorder: async ({ orderedIds }) => callRuntimeResult('repo.reorder', { orderedIds }), + // Why: this persists desktop-owned local/SSH rows; paired web clients own one runtime and use repo.reorder directly. + reorderForHost: async () => { + throw new Error('Host-scoped project reordering is unavailable in paired web clients.') + }, + update: async ({ repoId, updates }) => { + const owned = await callRuntimeResultWithOwner<{ repo: Repo }>('repo.update', { + repo: repoId, + updates + }) + return withRuntimeRepoOwner(owned.result.repo, owned.hostId) + }, + pickFolder: () => Promise.resolve(null), + pickFolders: () => Promise.resolve([]), + pickDirectory: () => Promise.resolve(null), + clone: async ({ url, destination }) => { + invalidateRuntimeWorktreeCaches() + const owned = await callRuntimeResultWithOwner<{ repo: Repo }>( + 'repo.clone', + { url, destination }, + 10 * 60_000 + ) + return withRuntimeRepoOwner(owned.result.repo, owned.hostId) + }, + cloneRemote: async () => { + // Why: SSH relay cloning is owned by the desktop main process; paired web clients can't run that local IPC path. + throw new Error('SSH clone is unavailable in paired web clients.') + }, + createRemote: async () => { + // Why: SSH relay project creation is owned by the desktop main process; paired web clients can't use local SSH IPC. + throw new Error('Creating projects on SSH hosts is unavailable in paired web clients.') + }, + cloneAbort: () => Promise.resolve(), + addRemote: async ({ remotePath, displayName, kind }) => { + invalidateRuntimeWorktreeCaches() + const owned = await callRuntimeResultWithOwner<{ repo: Repo }>('repo.add', { + path: remotePath, + kind + }) + const result = { + repo: withRuntimeRepoOwner(owned.result.repo, owned.hostId) + } + if (!displayName) { + return result + } + assertActiveEnvironment(owned.environmentId) + return { + repo: await createReposApi().update({ + repoId: result.repo.id, + updates: { displayName } + }) + } + }, + create: async ({ parentPath, name, kind }) => { + invalidateRuntimeWorktreeCaches() + const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>( + 'repo.create', + { parentPath, name, kind } + ) + return withRuntimeRepoMutationOwner(owned.result, owned.hostId) + }, + isGitAvailable: async () => + (await callRuntimeResult<{ available: boolean }>('repo.gitAvailable')).available, + getDefaultCreateProjectParent: async () => { + const result = await callRuntimeResult<{ resolvedPath: string }>('files.browseServerDir', { + path: '~' + }) + return getDefaultCreateProjectParent(result.resolvedPath) + }, + onCloneProgress: () => noopUnsubscribe, + getGitUsername: () => Promise.resolve(''), + getBaseRefDefault: async ({ repoId }) => + callRuntimeResult('repo.baseRefDefault', { repo: repoId }), + searchBaseRefs: async ({ repoId, query, limit }) => + ( + await callRuntimeResult<{ refs: string[] }>('repo.searchRefs', { + repo: repoId, + query, + limit + }) + ).refs, + searchBaseRefDetails: async ({ repoId, query, limit }) => { + const result = await callRuntimeResult<{ + refs: string[] + refDetails?: { refName: string; localBranchName: string }[] + }>('repo.searchRefs', { + repo: repoId, + query, + limit + }) + return result.refDetails ?? result.refs.map(legacyBaseRefSearchResult) + }, + onChanged: () => noopUnsubscribe + } +} diff --git a/src/renderer/src/web/preload-api/web-review-api.ts b/src/renderer/src/web/preload-api/web-review-api.ts new file mode 100644 index 00000000000..4c4541d48d1 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-review-api.ts @@ -0,0 +1,44 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { createFallbackProxy } from './web-fallback-api' +import { callRuntimeResult } from './web-runtime-calls' + +export function createRuntimeNamespaceApi(prefix: string): never { + return createFallbackProxy([prefix], (path, args) => { + const method = `${prefix}.${path.at(-1) ?? ''}` + return callRuntimeResult(method, mapRuntimeNamespaceArg(prefix, args[0])) + }) as never +} + +export function createHooksApi(): NonNullable['hooks']> { + return { + check: async ({ repoId }) => callRuntimeResult('repo.hooksCheck', { repo: repoId }), + inspectSetupScriptImports: async ({ repoId }) => + callRuntimeResult('repo.setupScriptImports', { repo: repoId }), + createIssueCommandRunner: async () => ({ launched: false }) as never, + readIssueCommand: async ({ repoId }) => + callRuntimeResult('repo.issueCommandRead', { repo: repoId }), + writeIssueCommand: async ({ repoId, content }) => { + await callRuntimeResult('repo.issueCommandWrite', { repo: repoId, content }) + } + } +} + +export function mapRepoPathArg(args: unknown): unknown { + if (!args || typeof args !== 'object' || !('repoPath' in args)) { + return args + } + const record = args as Record + const repoId = typeof record.repoId === 'string' && record.repoId.trim() ? record.repoId : null + return { + ...record, + // Why: duplicate checked-out repos make path/name selectors ambiguous; prefer the explicit repo id the renderer passes. + repo: repoId ? `id:${repoId}` : record.repoPath + } +} + +export function mapRuntimeNamespaceArg(prefix: string, args: unknown): unknown { + if (prefix !== 'hostedReview') { + return args + } + return mapRepoPathArg(args) +} diff --git a/src/renderer/src/web/preload-api/web-runtime-api.ts b/src/renderer/src/web/preload-api/web-runtime-api.ts new file mode 100644 index 00000000000..e0c44484768 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-runtime-api.ts @@ -0,0 +1,21 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import type { RuntimeSyncWindowGraph } from '../../../../shared/runtime-types' +import { callRuntimeEnvelope, getRemoteRuntimeStatus } from './web-runtime-calls' +import { noopUnsubscribe } from './web-storage' + +export function createWebRuntimeApi(): NonNullable['runtime']> { + return { + syncWindowGraph: async (_graph: RuntimeSyncWindowGraph) => getRemoteRuntimeStatus(), + getStatus: () => getRemoteRuntimeStatus(), + call: ({ method, params }) => callRuntimeEnvelope(method, params), + getTerminalFitOverrides: () => Promise.resolve([]), + getTerminalDrivers: () => Promise.resolve([]), + getBrowserDrivers: () => Promise.resolve([]), + restoreTerminalFit: () => Promise.resolve({ restored: false }), + reclaimBrowserForDesktop: () => Promise.resolve({ reclaimed: false }), + onTerminalFitOverrideChanged: () => noopUnsubscribe, + onTerminalDriverChanged: () => noopUnsubscribe, + onNativeChatLaunchDraftResolved: () => noopUnsubscribe, + onBrowserDriverChanged: () => noopUnsubscribe + } +} diff --git a/src/renderer/src/web/preload-api/web-runtime-calls.ts b/src/renderer/src/web/preload-api/web-runtime-calls.ts new file mode 100644 index 00000000000..4470b0be37a --- /dev/null +++ b/src/renderer/src/web/preload-api/web-runtime-calls.ts @@ -0,0 +1,120 @@ +import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { + getClientForEnvironment, + manuallyDisconnectedEnvironmentIds, + manuallyDisconnectedResponse, + requireActiveEnvironment, + resolveEnvironment, + runtimeCallQueuePool, + updateEnvironmentFromResponse +} from './web-runtime-session' + +export type WebRuntimeResultCaller = ( + method: string, + params?: unknown, + timeoutMs?: number +) => Promise + +export type WebRuntimeEnvelopeCaller = ( + method: string, + params?: unknown, + timeoutMs?: number +) => Promise> + +export async function callRuntimeEnvelope( + method: string, + params?: unknown, + timeoutMs?: number +): Promise> { + const environment = requireActiveEnvironment() + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return manuallyDisconnectedResponse(environment) + } + const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return Promise.resolve(manuallyDisconnectedResponse(environment)) + } + return getClientForEnvironment(environment).call(method, params, { timeoutMs }) + }) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return manuallyDisconnectedResponse(environment) + } + updateEnvironmentFromResponse(environment, response) + return response as RuntimeRpcResponse +} + +export async function callEnvironmentEnvelope( + selector: string, + method: string, + params?: unknown, + timeoutMs?: number +): Promise> { + const environment = resolveEnvironment(selector) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return manuallyDisconnectedResponse(environment) + } + const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return Promise.resolve(manuallyDisconnectedResponse(environment)) + } + return getClientForEnvironment(environment).call(method, params, { timeoutMs }) + }) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return manuallyDisconnectedResponse(environment) + } + updateEnvironmentFromResponse(environment, response) + return response as RuntimeRpcResponse +} + +export async function callRuntimeResult( + method: string, + params?: unknown, + timeoutMs?: number +): Promise { + const response = await callRuntimeEnvelope(method, params, timeoutMs) + if (!response.ok) { + throw new Error(response.error.message) + } + return response.result as TResult +} + +export async function callRuntimeResultWithOwner( + method: string, + params?: unknown, + timeoutMs?: number +): Promise<{ result: TResult; hostId: ExecutionHostId; environmentId: string }> { + const environmentId = requireActiveEnvironment().id + const result = await callRuntimeResult(method, params, timeoutMs) + return { result, hostId: toRuntimeExecutionHostId(environmentId), environmentId } +} + +export function withRuntimeRepoOwner(repo: Repo, hostId: ExecutionHostId): Repo { + return { ...repo, executionHostId: hostId } +} + +export function withRuntimeRepoMutationOwner( + result: { repo: Repo } | { error: string }, + hostId: ExecutionHostId +): { repo: Repo } | { error: string } { + return 'repo' in result ? { ...result, repo: withRuntimeRepoOwner(result.repo, hostId) } : result +} + +export function withRuntimeWorktreeOwner( + worktree: T, + hostId: ExecutionHostId +): T { + const runtimeOwner = parseExecutionHostId(hostId) + if (runtimeOwner?.kind !== 'runtime') { + return worktree + } + return { ...worktree, runtimeOwnerEnvironmentId: runtimeOwner.environmentId } +} + +export async function getRemoteRuntimeStatus(): Promise { + return callRuntimeResult('status.get', undefined, 15_000) +} diff --git a/src/renderer/src/web/preload-api/web-runtime-environments-api.ts b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts new file mode 100644 index 00000000000..378ce38c614 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts @@ -0,0 +1,198 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { parseHostAccessLink } from '../../../../shared/remote-pairing-address' +import { verifyRemotePairingRuntimeStatus } from '../../../../shared/remote-pairing-verification' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { parseWebPairingInput } from '../web-pairing' +import { WebRuntimeClient } from '../web-runtime-client' +import { isWebRuntimeUnauthorizedError } from '../web-runtime-client-error' +import { + createStoredWebRuntimeEnvironment, + redactStoredWebRuntimeEnvironment, + saveStoredWebRuntimeEnvironment +} from '../web-runtime-environment' +import { translate } from '@/i18n/i18n' +import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy' +import { callEnvironmentEnvelope } from './web-runtime-calls' +import { + closeActiveRuntimeClients, + disconnectActiveRuntimeEnvironment, + getClientForEnvironment, + manuallyDisconnectedEnvironmentIds, + removeActiveRuntimeEnvironment, + requireActiveEnvironmentOrNull, + resolveEnvironment, + webRuntimeState +} from './web-runtime-session' + +export function createRuntimeEnvironmentsApi(): NonNullable< + Partial['runtimeEnvironments'] +> { + return { + list: async () => { + const environment = requireActiveEnvironmentOrNull() + return environment ? [redactStoredWebRuntimeEnvironment(environment)] : [] + }, + addFromPairingCode: async ({ name, pairingCode }) => { + const offer = parseWebPairingInput(pairingCode) + if (!offer) { + throw new Error('Invalid Orca pairing code.') + } + const previousEnvironment = webRuntimeState.activeEnvironment + closeActiveRuntimeClients() + webRuntimeState.activeEnvironment = createStoredWebRuntimeEnvironment({ + name, + offer, + previousEnvironment + }) + manuallyDisconnectedEnvironmentIds.clear() + saveStoredWebRuntimeEnvironment(webRuntimeState.activeEnvironment) + return { environment: redactStoredWebRuntimeEnvironment(webRuntimeState.activeEnvironment) } + }, + verifyAndAddFromPairingCode: async ({ name, pairingCode, allowLoopback }) => { + const parsed = parseHostAccessLink(pairingCode) + if (!parsed.ok) { + return { + ok: false, + kind: 'access-link-invalid', + message: translateHostAccessLinkError(parsed.kind) + } + } + if (parsed.value.endpointKind === 'loopback' && !allowLoopback) { + return { + ok: false, + kind: 'host-unreachable', + message: translate( + 'auto.web.webPreloadApi.loopbackPairingBlocked', + 'This access link points back to this device.' + ) + } + } + let client: WebRuntimeClient | null = null + let runtimeStatus: RuntimeStatus + try { + client = new WebRuntimeClient(parsed.value.pairing) + const response = (await client.call('status.get', undefined, { + timeoutMs: 15_000 + })) as RuntimeRpcResponse + if (!response.ok) { + return { + ok: false, + kind: 'connection-interrupted', + message: response.error.message + } + } + const statusVerification = verifyRemotePairingRuntimeStatus(response.result) + if (!statusVerification.ok) { + return statusVerification + } + runtimeStatus = statusVerification.runtimeStatus + } catch (error) { + if (error instanceof Error && error.message.startsWith('Invalid public key')) { + return { + ok: false, + kind: 'access-link-invalid', + message: translate( + 'auto.web.webPreloadApi.remotePairingInvalidDetails', + 'This access link contains invalid connection details.' + ) + } + } + if ( + isWebRuntimeUnauthorizedError(error) || + (error instanceof Error && error.message.startsWith('Unauthorized.')) + ) { + return { + ok: false, + kind: 'access-link-invalid', + message: error.message + } + } + return { + ok: false, + kind: 'host-unreachable', + message: translate( + 'auto.web.webPreloadApi.remotePairingUnreachable', + 'Cannot reach Orca at {{endpoint}}.', + { endpoint: parsed.value.displayEndpoint } + ) + } + } finally { + client?.close() + } + const usesSshTunnel = parsed.value.endpointKind === 'loopback' && allowLoopback === true + const nextEnvironment = { + ...createStoredWebRuntimeEnvironment({ + name, + offer: parsed.value.pairing, + previousEnvironment: webRuntimeState.activeEnvironment, + ...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {}) + }), + ...(runtimeStatus.pairedDeviceId ? { pairedDeviceId: runtimeStatus.pairedDeviceId } : {}) + } + // Why: a browser storage failure must leave the currently active host usable. + try { + saveStoredWebRuntimeEnvironment(nextEnvironment) + } catch { + return { + ok: false, + kind: 'environment-save-failed', + message: translate( + 'auto.web.webPreloadApi.remotePairingSaveFailed', + 'Orca verified the host but could not save it. Check browser storage and try again.' + ) + } + } + manuallyDisconnectedEnvironmentIds.clear() + closeActiveRuntimeClients() + webRuntimeState.activeEnvironment = nextEnvironment + return { + ok: true, + environment: redactStoredWebRuntimeEnvironment(nextEnvironment), + runtimeStatus + } + }, + resolve: async ({ selector }) => + redactStoredWebRuntimeEnvironment(resolveEnvironment(selector)), + remove: async ({ selector }) => { + const environment = resolveEnvironment(selector) + if (webRuntimeState.activeEnvironment?.id === environment.id) { + removeActiveRuntimeEnvironment() + } + manuallyDisconnectedEnvironmentIds.delete(environment.id) + return { removed: redactStoredWebRuntimeEnvironment(environment) } + }, + disconnect: async ({ selector }) => { + const environment = resolveEnvironment(selector) + if (webRuntimeState.activeEnvironment?.id === environment.id) { + manuallyDisconnectedEnvironmentIds.add(environment.id) + disconnectActiveRuntimeEnvironment() + } + return { disconnected: redactStoredWebRuntimeEnvironment(environment) } + }, + connect: ({ selector, timeoutMs }) => { + const environment = resolveEnvironment(selector) + manuallyDisconnectedEnvironmentIds.delete(environment.id) + return callEnvironmentEnvelope( + environment.id, + 'status.get', + undefined, + timeoutMs + ) + }, + getStatus: ({ selector, timeoutMs }) => + callEnvironmentEnvelope(selector, 'status.get', undefined, timeoutMs), + call: ({ selector, method, params, timeoutMs }) => + callEnvironmentEnvelope(selector, method, params, timeoutMs), + subscribe: async ({ selector, method, params, timeoutMs }, callbacks) => { + const environment = resolveEnvironment(selector) + const client = getClientForEnvironment(environment) + const subscription = await client.subscribe(method, params, callbacks, { timeoutMs }) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + subscription.unsubscribe() + throw new Error('runtime_manually_disconnected') + } + return subscription + } + } +} diff --git a/src/renderer/src/web/preload-api/web-runtime-session.ts b/src/renderer/src/web/preload-api/web-runtime-session.ts new file mode 100644 index 00000000000..17a19136002 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-runtime-session.ts @@ -0,0 +1,145 @@ +import type { WorktreeVisibilityDefaults } from '../../../../shared/global-settings-types' +import { RuntimeRpcCallQueuePool } from '../../../../shared/runtime-rpc-call-queue' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { Worktree } from '../../../../shared/worktree/types' +import { WebRuntimeClient } from '../web-runtime-client' +import { + clearStoredWebRuntimeEnvironment, + getPreferredWebPairingOffer, + readStoredWebRuntimeEnvironment, + updateStoredEnvironmentRuntimeId +} from '../web-runtime-environment' +import type { StoredWebRuntimeEnvironment } from '../web-runtime-environment' +import { translate } from '@/i18n/i18n' + +export const webRuntimeState: { + activeEnvironment: StoredWebRuntimeEnvironment | null + worktreeVisibilityDefaultsRuntimeEnvironmentId: string | null + worktreeVisibilityDefaultsRuntimeValue: WorktreeVisibilityDefaults | null + activeClient: WebRuntimeClient | null + activeClientEnvironmentId: string | null + cachedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null + cachedDetectedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null +} = { + activeEnvironment: readStoredWebRuntimeEnvironment(), + worktreeVisibilityDefaultsRuntimeEnvironmentId: null, + worktreeVisibilityDefaultsRuntimeValue: null, + activeClient: null, + activeClientEnvironmentId: null, + cachedWorktrees: null, + cachedDetectedWorktrees: null +} + +export const manuallyDisconnectedEnvironmentIds = new Set() + +export const runtimeCallQueuePool = new RuntimeRpcCallQueuePool() + +export function invalidateRuntimeWorktreeCaches(): void { + webRuntimeState.cachedWorktrees = null + webRuntimeState.cachedDetectedWorktrees = null +} + +export function getClientForEnvironment( + environment: StoredWebRuntimeEnvironment +): WebRuntimeClient { + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + throw new Error('runtime_manually_disconnected') + } + if ( + !webRuntimeState.activeClient || + webRuntimeState.activeClientEnvironmentId !== environment.id + ) { + webRuntimeState.activeClient?.close() + webRuntimeState.activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment)) + webRuntimeState.activeClientEnvironmentId = environment.id + } + return webRuntimeState.activeClient +} + +export function closeActiveRuntimeClients(): void { + webRuntimeState.activeClient?.close() + webRuntimeState.activeClient = null + webRuntimeState.activeClientEnvironmentId = null + invalidateRuntimeWorktreeCaches() +} + +export function disconnectActiveRuntimeEnvironment(): void { + closeActiveRuntimeClients() +} + +export function removeActiveRuntimeEnvironment(): void { + disconnectActiveRuntimeEnvironment() + clearStoredWebRuntimeEnvironment() + webRuntimeState.activeEnvironment = null +} + +export function manuallyDisconnectedResponse( + environment: StoredWebRuntimeEnvironment +): RuntimeRpcResponse { + return { + id: 'runtime.manualDisconnect', + ok: false, + error: { + code: 'runtime_manually_disconnected', + message: translate( + 'auto.web.webPreloadApi.runtimeEnvironmentManuallyDisconnected', + 'Runtime environment is manually disconnected.' + ) + }, + _meta: { runtimeId: environment.runtimeId } + } +} + +export function resolveEnvironment(selector: string): StoredWebRuntimeEnvironment { + const environment = requireActiveEnvironment() + if (selector === environment.id || selector === environment.name || selector === 'active') { + return environment + } + if (environment.compatibleEnvironmentIds?.includes(selector)) { + return environment + } + throw new Error(`Unknown Orca runtime environment: ${selector}`) +} + +export function requireActiveEnvironment(): StoredWebRuntimeEnvironment { + webRuntimeState.activeEnvironment = + webRuntimeState.activeEnvironment ?? readStoredWebRuntimeEnvironment() + if (!webRuntimeState.activeEnvironment) { + throw new Error('Pair this web client with an Orca server first.') + } + return webRuntimeState.activeEnvironment +} + +export function requireActiveEnvironmentOrNull(): StoredWebRuntimeEnvironment | null { + webRuntimeState.activeEnvironment = + webRuntimeState.activeEnvironment ?? readStoredWebRuntimeEnvironment() + return webRuntimeState.activeEnvironment +} + +export function assertActiveEnvironment(environmentId: string): void { + if (requireActiveEnvironment().id !== environmentId) { + throw new Error('The paired Orca server changed while the request was in progress.') + } +} + +export function updateEnvironmentFromResponse( + environment: StoredWebRuntimeEnvironment, + response: RuntimeRpcResponse +): void { + if (webRuntimeState.activeEnvironment?.id !== environment.id) { + return + } + const runtimeId = response.ok ? response._meta.runtimeId : (response._meta?.runtimeId ?? null) + const pairedDeviceId = + response.ok && + typeof response.result === 'object' && + response.result !== null && + typeof (response.result as { pairedDeviceId?: unknown }).pairedDeviceId === 'string' + ? (response.result as { pairedDeviceId: string }).pairedDeviceId + : undefined + webRuntimeState.activeEnvironment = updateStoredEnvironmentRuntimeId( + environment, + runtimeId, + pairedDeviceId + ) +} diff --git a/src/renderer/src/web/preload-api/web-runtime-worktree-catalog.ts b/src/renderer/src/web/preload-api/web-runtime-worktree-catalog.ts new file mode 100644 index 00000000000..1cc6de1273e --- /dev/null +++ b/src/renderer/src/web/preload-api/web-runtime-worktree-catalog.ts @@ -0,0 +1,183 @@ +import { relativePathInsideRoot } from '../../../../shared/cross-platform-path' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/worktree/types' +import { + callRuntimeEnvelope, + callRuntimeResult, + callRuntimeResultWithOwner, + withRuntimeWorktreeOwner +} from './web-runtime-calls' +import type { WebRuntimeEnvelopeCaller, WebRuntimeResultCaller } from './web-runtime-calls' +import { + assertActiveEnvironment, + requireActiveEnvironment, + webRuntimeState +} from './web-runtime-session' + +export const WEB_RUNTIME_WORKTREE_LIST_LIMIT = 10_000 + +export async function listAllRuntimeWorktrees(): Promise { + if ( + webRuntimeState.cachedWorktrees && + Date.now() - webRuntimeState.cachedWorktrees.loadedAt < 5_000 + ) { + return webRuntimeState.cachedWorktrees.worktrees + } + const owned = await callRuntimeResultWithOwner<{ worktrees: Worktree[] }>('worktree.list', { + limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT + }) + const worktrees = owned.result.worktrees.map((worktree) => + withRuntimeWorktreeOwner(worktree, owned.hostId) + ) + assertActiveEnvironment(owned.environmentId) + webRuntimeState.cachedWorktrees = { loadedAt: Date.now(), worktrees } + return worktrees +} + +export async function listAllRuntimeDetectedWorktrees( + callResult: WebRuntimeResultCaller = callRuntimeResult, + callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, + useCache = true, + expectedEnvironmentId = requireActiveEnvironment().id +): Promise { + if ( + useCache && + webRuntimeState.cachedDetectedWorktrees && + Date.now() - webRuntimeState.cachedDetectedWorktrees.loadedAt < 5_000 + ) { + return webRuntimeState.cachedDetectedWorktrees.worktrees + } + + assertActiveEnvironment(expectedEnvironmentId) + const repos = (await callResult<{ repos: Repo[] }>('repo.list')).repos + const detectedLists = await Promise.all( + repos.map((repo) => + callRuntimeDetectedWorktrees(repo.id, expectedEnvironmentId, callResult, callEnvelope) + ) + ) + const worktrees = detectedLists.flatMap((result) => result.worktrees) + assertActiveEnvironment(expectedEnvironmentId) + if (useCache) { + webRuntimeState.cachedDetectedWorktrees = { loadedAt: Date.now(), worktrees } + } + return worktrees +} + +export async function callRuntimeDetectedWorktrees( + repoId: string, + expectedEnvironmentId = requireActiveEnvironment().id, + callResult: WebRuntimeResultCaller = callRuntimeResult, + callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope +): Promise { + assertActiveEnvironment(expectedEnvironmentId) + const hostId = toRuntimeExecutionHostId(expectedEnvironmentId) + const response = await callEnvelope( + 'worktree.detectedList', + { repo: repoId }, + 15_000 + ) + if (response.ok) { + return { + ...response.result, + worktrees: response.result.worktrees.map((worktree) => + withRuntimeWorktreeOwner(worktree, hostId) + ) + } + } + if (response.error.code !== 'method_not_found') { + throw new Error(response.error.message) + } + + assertActiveEnvironment(expectedEnvironmentId) + const legacy = await callResult<{ worktrees: Worktree[] }>( + 'worktree.list', + { repo: repoId, limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT }, + 15_000 + ) + return toLegacyDetectedWorktreeResult( + repoId, + legacy.worktrees.map((worktree) => withRuntimeWorktreeOwner(worktree, hostId)) + ) +} + +export function toLegacyDetectedWorktreeResult( + repoId: string, + worktrees: Worktree[] +): DetectedWorktreeListResult { + return { + repoId, + authoritative: true, + source: 'session-fallback', + worktrees: worktrees.map((worktree) => ({ + ...worktree, + ownership: 'orca-managed', + selectedCheckout: false, + visible: true + })) + } +} + +export function isMissingPathError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + return /\bENOENT\b|not found|no such file/i.test(error.message) +} + +export async function resolveRuntimeWorktreeByPath( + worktreePath: string, + callResult: WebRuntimeResultCaller = callRuntimeResult, + callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, + useDetectedWorktreeCache = true, + expectedEnvironmentId = requireActiveEnvironment().id +): Promise { + // Why: hidden-but-open worktrees must still resolve, but `worktree.list` is sidebar-visible only — resolve via detected rows. + const worktrees = await listAllRuntimeDetectedWorktrees( + callResult, + callEnvelope, + useDetectedWorktreeCache, + expectedEnvironmentId + ) + const match = worktrees + .map((worktree) => ({ + worktree, + relativePath: relativePathInsideRoot(worktree.path, worktreePath) + })) + .filter((entry) => entry.relativePath !== null) + .sort((a, b) => b.worktree.path.length - a.worktree.path.length)[0] + if (!match) { + throw new Error(`No runtime worktree owns ${worktreePath}`) + } + return match.worktree +} + +export async function resolveRuntimeFilePath( + filePath: string, + preferredWorktreePath?: string, + callResult: WebRuntimeResultCaller = callRuntimeResult, + callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, + useDetectedWorktreeCache = true, + expectedEnvironmentId = requireActiveEnvironment().id +): Promise<{ worktree: Worktree; relativePath: string }> { + const worktree = preferredWorktreePath + ? await resolveRuntimeWorktreeByPath( + preferredWorktreePath, + callResult, + callEnvelope, + useDetectedWorktreeCache, + expectedEnvironmentId + ) + : await resolveRuntimeWorktreeByPath( + filePath, + callResult, + callEnvelope, + useDetectedWorktreeCache, + expectedEnvironmentId + ) + const relativePath = relativePathInsideRoot(worktree.path, filePath) + if (relativePath === null) { + throw new Error(`File is outside runtime worktree: ${filePath}`) + } + return { worktree, relativePath } +} diff --git a/src/renderer/src/web/preload-api/web-settings-api.ts b/src/renderer/src/web/preload-api/web-settings-api.ts new file mode 100644 index 00000000000..f032b0fa9ff --- /dev/null +++ b/src/renderer/src/web/preload-api/web-settings-api.ts @@ -0,0 +1,118 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode +} from '../../../../shared/computer-awake-mode' +import { normalizeTerminalCursorStyleDefault } from '../../../../shared/terminal-cursor-style-settings' +import { mergeSettings } from './web-preference-normalization' +import { + getRuntimeBackedStoredSettings, + getStoredSettings, + settingsForActiveVisibilityOwner, + syncRuntimeBackedSettings, + updateRuntimePRBotAuthorOverride, + writeStoredSettings +} from './web-preferences-store' +import type { WebSettingsApi } from './web-preferences-store' +import { + requireActiveEnvironmentOrNull, + resolveEnvironment, + webRuntimeState +} from './web-runtime-session' +import { noopUnsubscribe } from './web-storage' + +export function createWebSettingsApi(): Partial { + return { + settings: { + get: async () => getRuntimeBackedStoredSettings(), + // Why: localStorage-backed settings are synchronous, so the pre-hydration kill-switch read works the same as desktop. + getSync: () => settingsForActiveVisibilityOwner(getStoredSettings()), + set: async (updates) => { + const sanitizedUpdates = { ...updates } + const runtimeEnvironment = requireActiveEnvironmentOrNull() + delete sanitizedUpdates.activeRuntimeEnvironmentId + if ( + 'worktreeVisibilityDefaults' in sanitizedUpdates && + runtimeEnvironment && + runtimeEnvironment.id !== webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId + ) { + delete sanitizedUpdates.worktreeVisibilityDefaults + } + if ('worktreeVisibilityDefaults' in sanitizedUpdates) { + sanitizedUpdates.worktreeVisibilityDefaults = { + ...settingsForActiveVisibilityOwner(getStoredSettings()).worktreeVisibilityDefaults, + ...sanitizedUpdates.worktreeVisibilityDefaults + } + } + if ('computerAwakeMode' in sanitizedUpdates) { + Object.assign( + sanitizedUpdates, + computerAwakeSettingsForMode( + normalizeComputerAwakeMode( + sanitizedUpdates.computerAwakeMode, + sanitizedUpdates.keepComputerAwakeWhileAgentsRun + ) + ) + ) + } else if ('keepComputerAwakeWhileAgentsRun' in sanitizedUpdates) { + Object.assign( + sanitizedUpdates, + computerAwakeSettingsForMode( + sanitizedUpdates.keepComputerAwakeWhileAgentsRun ? 'auto' : 'off' + ) + ) + } + if ('autoRenameBranchFromWorkDefaultedOn' in sanitizedUpdates) { + sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true + } + if ('terminalCursorStyle' in sanitizedUpdates) { + Object.assign( + sanitizedUpdates, + normalizeTerminalCursorStyleDefault( + { terminalCursorStyle: sanitizedUpdates.terminalCursorStyle }, + { preserveExplicitValue: true } + ) + ) + } + const localUpdates = { ...sanitizedUpdates } + if (runtimeEnvironment) { + delete localUpdates.worktreeVisibilityDefaults + } + const next = mergeSettings(getStoredSettings(), localUpdates, { + preserveAutoRenameBranchFromWorkUpdate: 'autoRenameBranchFromWork' in sanitizedUpdates + }) + writeStoredSettings(next) + return settingsForActiveVisibilityOwner( + await syncRuntimeBackedSettings(sanitizedUpdates, next) + ) + }, + setActiveRuntimeEnvironmentPreference: async ({ environmentId }) => { + const requestedEnvironmentId = environmentId?.trim() || null + const activeRuntimeEnvironmentId = requestedEnvironmentId + ? resolveEnvironment(requestedEnvironmentId).id + : null + const next = mergeSettings(getStoredSettings(), { + activeRuntimeEnvironmentId + }) + writeStoredSettings(next, activeRuntimeEnvironmentId) + return next + }, + updatePRBotAuthorOverride: (args) => updateRuntimePRBotAuthorOverride(args), + listFonts: () => Promise.resolve([]), + onChanged: () => noopUnsubscribe + } satisfies Partial as unknown as WebSettingsApi, + agentAwake: { + getStatus: async () => { + const settings = getStoredSettings() + return { + mode: normalizeComputerAwakeMode( + settings.computerAwakeMode, + settings.keepComputerAwakeWhileAgentsRun + ), + active: false + } + }, + onChanged: () => noopUnsubscribe + } + } +} diff --git a/src/renderer/src/web/preload-api/web-shell-api.ts b/src/renderer/src/web/preload-api/web-shell-api.ts new file mode 100644 index 00000000000..59b067fb329 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-shell-api.ts @@ -0,0 +1,30 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { resolveRuntimeFilePath } from './web-runtime-worktree-catalog' + +export function createShellApi(): NonNullable['shell']> { + const openResult = { ok: true } as const + return { + openPath: (path) => + Promise.resolve(window.open(path, '_blank', 'noopener,noreferrer') as never), + openInFileManager: () => Promise.resolve(openResult), + openInExternalEditor: () => Promise.resolve(openResult), + openUrl: (url) => Promise.resolve(window.open(url, '_blank', 'noopener,noreferrer') as never), + openFilePath: () => Promise.resolve(false), + openFileUri: (uri) => + Promise.resolve(window.open(uri, '_blank', 'noopener,noreferrer') as never), + pathExists: async (path) => { + try { + await resolveRuntimeFilePath(path) + return true + } catch { + return false + } + }, + pickAttachment: () => Promise.resolve(null), + pickImage: () => Promise.resolve(null), + pickRepoIconImage: () => Promise.resolve(null), + pickAudio: () => Promise.resolve(null), + pickDirectory: () => Promise.resolve(null), + copyFile: () => Promise.resolve() + } +} diff --git a/src/renderer/src/web/preload-api/web-star-nag-api.ts b/src/renderer/src/web/preload-api/web-star-nag-api.ts new file mode 100644 index 00000000000..bbbe722eab3 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-star-nag-api.ts @@ -0,0 +1,21 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { noopUnsubscribe } from './web-storage' + +export function createWebStarNagApi(): Partial { + return { + starNag: { + onShow: () => noopUnsubscribe, + onHide: () => noopUnsubscribe, + dismiss: () => Promise.resolve(), + later: () => Promise.resolve(), + complete: () => Promise.resolve(), + disable: () => Promise.resolve(), + openWeb: () => Promise.resolve(), + starOrca: () => Promise.resolve(false), + forceShow: () => Promise.resolve(), + agentValueMoment: () => Promise.resolve({ status: 'skipped' }), + showAgentValueMoment: () => Promise.resolve(), + onboardingCompleted: () => Promise.resolve() + } + } +} diff --git a/src/renderer/src/web/preload-api/web-storage.ts b/src/renderer/src/web/preload-api/web-storage.ts new file mode 100644 index 00000000000..8f33d0c71ec --- /dev/null +++ b/src/renderer/src/web/preload-api/web-storage.ts @@ -0,0 +1,43 @@ +export const SETTINGS_STORAGE_KEY = 'orca.web.settings.v1' + +export const UI_STORAGE_KEY = 'orca.web.ui.v1' + +export const SESSION_STORAGE_KEY = 'orca.web.workspaceSession.v1' + +export const ONBOARDING_STORAGE_KEY = 'orca.web.onboarding.v1' + +export const GITHUB_CACHE_STORAGE_KEY = 'orca.web.githubCache.v1' + +export const KEYBINDINGS_STORAGE_KEY = 'orca.web.keybindings.v1' + +export function getBrowserPlatform(): NodeJS.Platform { + if (navigator.userAgent.includes('Windows')) { + return 'win32' + } + if (navigator.userAgent.includes('Linux')) { + return 'linux' + } + return 'darwin' +} + +export function readJson(key: string, fallback: T): T { + const raw = window.localStorage.getItem(key) + if (!raw) { + return cloneJson(fallback) + } + try { + return { ...cloneJson(fallback), ...JSON.parse(raw) } as T + } catch { + return cloneJson(fallback) + } +} + +export function writeJson(key: string, value: T): void { + window.localStorage.setItem(key, JSON.stringify(value)) +} + +export function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T +} + +export function noopUnsubscribe(): void {} diff --git a/src/renderer/src/web/preload-api/web-telemetry-api.ts b/src/renderer/src/web/preload-api/web-telemetry-api.ts new file mode 100644 index 00000000000..1fce49016be --- /dev/null +++ b/src/renderer/src/web/preload-api/web-telemetry-api.ts @@ -0,0 +1,11 @@ +import type { PreloadApi } from '../../../../preload/api-types' + +export function createWebTelemetryApi(): Partial { + return { + telemetryTrack: () => Promise.resolve(), + telemetrySetOptIn: () => Promise.resolve(), + telemetryGetConsentState: () => + Promise.resolve({ optedIn: false, source: 'default', blockedByEnv: false } as never), + telemetryAcknowledgeBanner: () => Promise.resolve() + } +} diff --git a/src/renderer/src/web/preload-api/web-terminal-api.ts b/src/renderer/src/web/preload-api/web-terminal-api.ts new file mode 100644 index 00000000000..df0c84f262b --- /dev/null +++ b/src/renderer/src/web/preload-api/web-terminal-api.ts @@ -0,0 +1,180 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS } from '../../../../shared/pty-delivery-diagnostics' +import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types' +import { translate } from '@/i18n/i18n' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironmentOrNull } from './web-runtime-session' +import { noopUnsubscribe } from './web-storage' + +export function createPtyApi(): NonNullable['pty']> { + return { + spawn: () => Promise.reject(new Error('Local PTYs are unavailable in the web client.')), + write: () => {}, + writeAccepted: () => Promise.resolve(false), + resize: () => {}, + claimViewport: () => {}, + reportGeometry: () => {}, + signal: () => {}, + // Web panes clear the host buffer via the terminal.clearBuffer runtime RPC. + clearBuffer: () => {}, + kill: () => Promise.resolve(), + ackColdRestore: () => {}, + ackData: () => {}, + onDeliveryResyncRequest: () => noopUnsubscribe, + respondDeliveryResync: () => {}, + // Why: web terminals bypass main's delivery gate; a zero-in-flight reply keeps the watchdog idle. + reportRendererDeliveryState: () => + Promise.resolve({ inFlightTotalChars: 0, inFlightPtyCount: 0, msSinceLastAck: null }), + getPtyDataListenerCount: () => 0, + rendererDispatcherReady: () => {}, + setActiveRendererPty: () => {}, + setRendererPtyVisible: () => {}, + setHiddenRendererPty: () => {}, + setPtyDeliveryInterest: () => {}, + // Why: remote-runtime PTYs are never hidden-gate markable, so there's no main-side responder to feed. + publishTerminalViewAttributes: () => {}, + hasChildProcesses: () => Promise.resolve(false), + getForegroundProcess: () => Promise.resolve(null), + inspectProcess: () => Promise.reject(new Error('terminal_liveness_unavailable')), + // Why: paired web panes cannot provide a local post-boundary process scan. + confirmForegroundProcess: () => Promise.resolve(null), + getCwd: () => Promise.resolve('~'), + getSize: () => Promise.resolve(null), + listSessions: () => Promise.resolve([]), + getAuthoritativeBufferSnapshotCapabilities: (ids) => + Promise.resolve(ids.map((id) => ({ id, authoritative: false }))), + hasPty: () => Promise.resolve(null), + getMainBufferSnapshot: () => Promise.resolve(null), + // Why: remote-runtime PTYs skip local main (no side-effect source); renderer byte parsing stays authoritative. + onSideEffect: () => noopUnsubscribe, + getSideEffectSnapshot: () => Promise.resolve(null), + getRendererDeliveryDebugSnapshot: () => + Promise.resolve({ + pendingPtyCount: 0, + pendingChars: 0, + maxPendingCharsByPty: 0, + rendererInFlightPtyCount: 0, + rendererInFlightChars: 0, + maxRendererInFlightCharsByPty: 0, + activeRendererPtyCount: 0, + flushScheduled: false, + peakPendingChars: 0, + peakMaxPendingCharsByPty: 0, + peakRendererInFlightChars: 0, + peakMaxRendererInFlightCharsByPty: 0, + ackGatedFlushSkipCount: 0, + hiddenDeliveryGatedPtyCount: 0, + hiddenDeliveryGatedVisiblePtyCount: 0, + hiddenDeliveryGatedActivePtyCount: 0, + deliveryInterestPtyCount: 0, + hiddenDeliveryDroppedChars: 0, + hiddenDeliveryDroppedChunks: 0, + pendingDroppedChars: 0, + diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, + rendererLifecycleResetCount: 0, + lastLifecycleResetClearedChars: 0, + rendererPtyDispatcherReady: false, + rendererDispatcherReadyForcedCount: 0 + }), + resetRendererDeliveryDebug: () => Promise.resolve(), + onData: () => noopUnsubscribe, + onReplay: () => noopUnsubscribe, + onModelRestoreNeeded: () => noopUnsubscribe, + onExit: () => noopUnsubscribe, + onSpawned: () => noopUnsubscribe, + onSerializeBufferRequest: () => noopUnsubscribe, + onClearBufferRequest: () => noopUnsubscribe, + sendSerializedBuffer: () => {}, + declarePendingPaneSerializer: () => Promise.resolve(0), + settlePaneSerializer: () => Promise.resolve(), + clearPendingPaneSerializer: () => Promise.resolve(), + reportRendererSerializerReady: () => Promise.resolve(), + management: { + listSessions: () => Promise.resolve({ sessions: [], degraded: false }), + killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }), + killOne: () => Promise.resolve({ success: false }), + restart: () => Promise.resolve({ success: false }), + // Why: web clients can't inspect the host daemon's pid record; 'unknown' keeps the banner hidden. + macTccAttribution: () => Promise.resolve({ health: 'unknown' as const }) + } + } +} + +export function createSshApi(): NonNullable['ssh']> { + return { + // Why: SSH is owned by the paired host; route read/connect to runtime RPC for state/reconnect (STA-1468). Target mgmt is desktop-only. + listTargets: async () => { + if (!requireActiveEnvironmentOrNull()) { + return [] + } + const { targets } = await callRuntimeResult<{ targets: SshTarget[] }>( + 'ssh.listTargetSummaries' + ) + return targets + }, + listRemovedTargetLabels: async () => { + if (!requireActiveEnvironmentOrNull()) { + return {} + } + const { labels } = await callRuntimeResult<{ labels: Record }>( + 'ssh.listRemovedTargetLabels' + ) + return labels + }, + addTarget: () => + Promise.reject(new Error('SSH target management is unavailable in the web client.')), + updateTarget: () => + Promise.reject(new Error('SSH target management is unavailable in the web client.')), + removeTarget: () => Promise.resolve(), + importConfig: () => Promise.resolve({ targets: [], repoReadoptions: [] }), + listConfigHosts: () => + Promise.resolve({ + hosts: [], + totalHostCount: 0, + newHostCount: 0, + matchCount: 0, + hasMore: false + }), + resolveConfigHost: () => Promise.resolve(null), + connect: async (args) => { + const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( + 'ssh.connect', + { targetId: args.targetId } + ) + return state + }, + disconnect: () => Promise.resolve(), + terminateSessions: () => Promise.resolve(), + resetRelay: () => Promise.resolve(), + getState: async (args) => { + if (!requireActiveEnvironmentOrNull()) { + return null + } + const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( + 'ssh.getState', + { targetId: args.targetId } + ) + return state + }, + needsPassphrasePrompt: () => Promise.resolve(false), + testConnection: () => + Promise.resolve({ + success: false, + error: translate('auto.web.web.preload.api.31bfe8ae1a', 'Unavailable in the web client.') + }), + onStateChanged: () => noopUnsubscribe, + addPortForward: () => + Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')), + updatePortForward: () => + Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')), + removePortForward: () => Promise.resolve(null), + listPortForwards: () => Promise.resolve([]), + listDetectedPorts: () => Promise.resolve([]), + onPortForwardsChanged: () => noopUnsubscribe, + onDetectedPortsChanged: () => noopUnsubscribe, + browseDir: () => Promise.resolve({ entries: [], resolvedPath: '', pathFlavor: 'posix' }), + onCredentialRequest: () => noopUnsubscribe, + onCredentialResolved: () => noopUnsubscribe, + submitCredential: () => Promise.resolve() + } +} diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts new file mode 100644 index 00000000000..974132fc49c --- /dev/null +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -0,0 +1,241 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { assertClipboardTextWithinLimitWithYield } from '../../../../shared/clipboard-text' +import type { ReadClipboardTextOptions } from '../../../../shared/clipboard-text' +import { normalizeFeatureInteractions } from '../../../../shared/feature-interactions' +import type { FeatureInteractionId } from '../../../../shared/feature-interactions' +import { omitPairingLocalUiFields } from '../../../../shared/pairing-local-ui-fields' +import type { PairedUiState } from '../../../../shared/pairing-local-ui-fields' +import { + readClipboardImagePngBase64, + saveClipboardImageAsTempFileInRuntime, + writeWebClipboardText +} from './web-clipboard-api' +import { + mergeContextualTourSeenIds, + mergeFeatureInteractionState, + mergeHostWebUIState, + mergeOsc52ClipboardNoticePending, + mergeWebUIState +} from './web-preference-normalization' +import { readLocalWebUIState } from './web-preferences-store' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironmentOrNull } from './web-runtime-session' +import { UI_STORAGE_KEY, noopUnsubscribe, writeJson } from './web-storage' + +export function createWebUiApi(): NonNullable['ui']> { + let zoomLevel = readLocalWebUIState().uiZoomLevel + return { + get: async () => { + try { + const result = await callRuntimeResult<{ ui: PairedUiState }>('ui.get', undefined, 15_000) + const local = readLocalWebUIState() + const next = { + ...mergeHostWebUIState(local, result.ui), + osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), + featureInteractions: mergeFeatureInteractionState( + local.featureInteractions, + result.ui.featureInteractions + ), + contextualToursSeenIds: mergeContextualTourSeenIds( + local.contextualToursSeenIds, + result.ui.contextualToursSeenIds + ) + } + writeJson(UI_STORAGE_KEY, next) + zoomLevel = next.uiZoomLevel + return next + } catch { + return readLocalWebUIState() + } + }, + set: async (updates) => { + const next = mergeWebUIState(readLocalWebUIState(), updates) + writeJson(UI_STORAGE_KEY, next) + zoomLevel = next.uiZoomLevel + // Why strip here too when the host also strips: an old host predating that strip would + // otherwise persist this browser's runtime:web-* keys over the desktop profile's order. + const hostUpdates = omitPairingLocalUiFields(updates) + try { + await callRuntimeResult('ui.set', hostUpdates, 15_000) + } catch { + // Why: unpaired/offline web clients still need local UI persistence. + } + }, + recordFeatureInteraction: async (id: FeatureInteractionId) => { + const current = readLocalWebUIState() + const featureInteractions = normalizeFeatureInteractions(current.featureInteractions) + const existing = featureInteractions[id] + const optimistic = mergeWebUIState(current, { + featureInteractions: { + ...featureInteractions, + [id]: { + firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), + interactionCount: (existing?.interactionCount ?? 0) + 1 + } + } + }) + writeJson(UI_STORAGE_KEY, optimistic) + try { + const result = await callRuntimeResult<{ ui: PairedUiState }>( + 'ui.recordFeatureInteraction', + id, + 15_000 + ) + const local = readLocalWebUIState() + const next = { + ...mergeHostWebUIState(local, result.ui), + osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), + featureInteractions: mergeFeatureInteractionState( + local.featureInteractions, + result.ui.featureInteractions + ), + contextualToursSeenIds: mergeContextualTourSeenIds( + local.contextualToursSeenIds, + result.ui.contextualToursSeenIds + ) + } + writeJson(UI_STORAGE_KEY, next) + zoomLevel = next.uiZoomLevel + return next + } catch { + return optimistic + } + }, + readClipboardText: async (options?: ReadClipboardTextOptions) => + assertClipboardTextWithinLimitWithYield( + await (navigator.clipboard?.readText?.() ?? ''), + options + ), + readSelectionClipboardText: () => + Promise.reject(new Error('Selection clipboard is unavailable in the web client')), + saveClipboardImageAsTempFile: async (args?: { + connectionId?: string | null + runtimeEnvironmentId?: string | null + }) => { + if (!requireActiveEnvironmentOrNull()) { + return null + } + const contentBase64 = await readClipboardImagePngBase64() + if (!contentBase64) { + return null + } + return saveClipboardImageAsTempFileInRuntime(contentBase64, args) + }, + writeClipboardText: writeWebClipboardText, + writeTerminalClipboardText: writeWebClipboardText, + writeSelectionClipboardText: () => + Promise.reject(new Error('Selection clipboard is unavailable in the web client')), + writeClipboardImage: () => Promise.resolve(), + writeClipboardFile: () => Promise.resolve({ ok: false, reason: 'unsupported-platform' }), + performNativePaste: () => { + document.execCommand?.('paste') + }, + performNativeSelectionAction: (action) => { + document.execCommand?.(action === 'copy' ? 'copy' : 'selectAll') + }, + onExportPdfRequested: () => noopUnsubscribe, + onAppMenuPaste: () => noopUnsubscribe, + onAppMenuSelectionAction: () => noopUnsubscribe, + onEditableContextPaste: () => noopUnsubscribe, + getZoomLevel: () => zoomLevel, + setZoomLevel: (level) => { + zoomLevel = level + }, + isMaximized: () => Promise.resolve(false), + onOpenSettings: () => noopUnsubscribe, + // Why: the web client has no native tray/menu bar, so there's never a queued open-settings intent to consume. + consumePendingOpenSettings: () => Promise.resolve(false), + onOpenSkillShare: () => noopUnsubscribe, + consumePendingSkillShare: () => Promise.resolve(null), + onOpenSetupGuide: () => noopUnsubscribe, + onOpenFeatureTour: () => noopUnsubscribe, + onOpenCrashReport: () => noopUnsubscribe, + // No desktop main process to push state changes; the web client re-reads via ui.get on interaction. + onStateChanged: () => noopUnsubscribe, + onToggleLeftSidebar: () => noopUnsubscribe, + onToggleRightSidebar: () => noopUnsubscribe, + onToggleWorktreePalette: () => noopUnsubscribe, + onToggleFloatingTerminal: () => noopUnsubscribe, + onTerminalShortcutCaptured: () => noopUnsubscribe, + onOpenQuickOpen: () => noopUnsubscribe, + onToggleQuickCommandsMenu: () => noopUnsubscribe, + onOpenTasks: () => noopUnsubscribe, + onOpenNewWorkspace: () => noopUnsubscribe, + onDeleteCurrentWorkspace: () => noopUnsubscribe, + onOpenWorkspaceBoard: () => noopUnsubscribe, + onToggleAgentDashboard: () => noopUnsubscribe, + onJumpToWorktreeIndex: () => noopUnsubscribe, + onJumpToTabIndex: () => noopUnsubscribe, + onWorktreeHistoryNavigate: () => noopUnsubscribe, + onNewBrowserTab: () => noopUnsubscribe, + onNewMarkdownTab: () => noopUnsubscribe, + onNewSimulatorTab: () => noopUnsubscribe, + onRequestTabCreate: () => noopUnsubscribe, + replyTabCreate: () => {}, + onRequestTabSetProfile: () => noopUnsubscribe, + replyTabSetProfile: () => {}, + onRequestTabClose: () => noopUnsubscribe, + replyTabClose: () => {}, + onNewTerminalTab: () => noopUnsubscribe, + onFocusBrowserAddressBar: () => noopUnsubscribe, + onFindInBrowserPage: () => noopUnsubscribe, + onReloadBrowserPage: () => noopUnsubscribe, + onBrowserHistoryNavigate: () => noopUnsubscribe, + onZoomBrowserPage: () => noopUnsubscribe, + onHardReloadBrowserPage: () => noopUnsubscribe, + onCloseActiveTab: () => noopUnsubscribe, + onCloseFloatingItem: () => noopUnsubscribe, + onSelectFloatingIndex: () => noopUnsubscribe, + onSwitchTab: () => noopUnsubscribe, + onSwitchTabAcrossAllTypes: () => noopUnsubscribe, + onSwitchRecentTab: () => noopUnsubscribe, + onSwitchTerminalTab: () => noopUnsubscribe, + onCtrlTabKeyDown: () => noopUnsubscribe, + onCtrlTabKeyUp: () => noopUnsubscribe, + onToggleStatusBar: () => noopUnsubscribe, + onDictationKeyDown: () => noopUnsubscribe, + onActivateWorktree: () => noopUnsubscribe, + onCreateTerminal: () => noopUnsubscribe, + onRequestTerminalCreate: () => noopUnsubscribe, + onRequestTerminalTabMount: () => noopUnsubscribe, + replyTerminalCreate: () => {}, + onSplitTerminal: () => noopUnsubscribe, + onRenameTerminal: () => noopUnsubscribe, + onFocusTerminal: () => noopUnsubscribe, + onFocusEditorTab: () => noopUnsubscribe, + onCloseSessionTab: () => noopUnsubscribe, + onSessionTabCloseRequest: () => noopUnsubscribe, + respondSessionTabClose: () => {}, + onMoveSessionTab: () => noopUnsubscribe, + onOpenFileFromMobile: () => noopUnsubscribe, + onOpenDiffFromMobile: () => noopUnsubscribe, + onMobileMarkdownRequest: () => noopUnsubscribe, + respondMobileMarkdownRequest: () => {}, + onCloseTerminal: () => noopUnsubscribe, + onTerminalTabCloseRequest: () => noopUnsubscribe, + respondTerminalTabClose: () => {}, + onSleepWorktree: () => noopUnsubscribe, + // Why: paired web is a full renderer that wakes on activation; mobile wake is desktop-host-scoped and never reaches web. + onResumeSleepingAgents: () => noopUnsubscribe, + onTerminalZoom: () => noopUnsubscribe, + // Why: a paired web client has no OS sleep signal; occlusion-driven visibilitychange already covers wake recovery. + onSystemResumed: () => noopUnsubscribe, + onFileDrop: () => noopUnsubscribe, + syncTrafficLights: () => {}, + setMarkdownEditorFocused: () => {}, + setRichMarkdownContextMenuTarget: () => {}, + setTerminalInputFocused: () => {}, + setFloatingFocus: () => {}, + setShortcutRecorderFocused: () => {}, + onRichMarkdownContextCommand: () => noopUnsubscribe, + onFullscreenChanged: () => noopUnsubscribe, + minimize: () => {}, + maximize: () => {}, + onMaximizeChanged: () => noopUnsubscribe, + requestClose: () => {}, + popupMenu: () => {}, + onWindowCloseRequested: () => noopUnsubscribe, + confirmWindowClose: () => {}, + notifyWindowRevealed: () => {} + } +} diff --git a/src/renderer/src/web/preload-api/web-updater-api.ts b/src/renderer/src/web/preload-api/web-updater-api.ts new file mode 100644 index 00000000000..18d69f2b8e0 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-updater-api.ts @@ -0,0 +1,34 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { translate } from '@/i18n/i18n' +import { noopUnsubscribe } from './web-storage' + +export function createUpdaterApi(): NonNullable['updater']> { + // Why: the linux-package-install recovery status can only originate in the native main process, so + // the web renderer never reaches these branches — reject loudly rather than resolve a fake result. + // A fresh Error per rejection: one shared instance would carry this function's stack, not the caller's. + const desktopOnlyMessage = 'Linux package install recovery is only available in the desktop app.' + return { + getVersion: () => Promise.resolve('web'), + getStatus: () => Promise.resolve({ state: 'idle' } as never), + check: () => Promise.resolve(), + download: () => Promise.resolve(), + quitAndInstall: () => Promise.resolve(), + dismissNudge: () => Promise.resolve(), + dismissAvailableUpdate: () => Promise.resolve(), + getLinuxPackageInstallInstructions: () => Promise.reject(new Error(desktopOnlyMessage)), + showLinuxPackage: () => Promise.reject(new Error(desktopOnlyMessage)), + // Why: the web client cannot install a desktop build, so channel switching + // reports unavailable rather than an empty list that looks like a fetch miss. + listBuilds: (channel) => + Promise.resolve({ + ok: false, + channel, + message: translate( + 'auto.components.settings.ReleaseChannelSection.webUnavailable', + 'Switching builds is only available in the desktop app.' + ) + }), + onStatus: () => noopUnsubscribe, + onClearDismissal: () => noopUnsubscribe + } +} diff --git a/src/renderer/src/web/preload-api/web-workspace-ports-api.ts b/src/renderer/src/web/preload-api/web-workspace-ports-api.ts new file mode 100644 index 00000000000..dcc18462a94 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-workspace-ports-api.ts @@ -0,0 +1,23 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { getBrowserPlatform, noopUnsubscribe } from './web-storage' + +export function createWebWorkspacePortsApi(): Partial { + return { + workspacePorts: { + // Why: browser-local workspaces have no host process to inspect; return capability state instead of the generic undefined fallback. + scan: () => + Promise.resolve({ + platform: getBrowserPlatform(), + scannedAt: Date.now(), + ports: [], + unavailableReason: 'Workspace port scanning is unavailable for browser-local workspaces.' + }), + kill: () => + Promise.resolve({ + ok: false, + reason: 'Workspace port management is unavailable for browser-local workspaces.' + }), + onAdvertisedUrlChanged: () => noopUnsubscribe + } + } +} diff --git a/src/renderer/src/web/preload-api/web-workspace-session-api.ts b/src/renderer/src/web/preload-api/web-workspace-session-api.ts new file mode 100644 index 00000000000..4150949b802 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-workspace-session-api.ts @@ -0,0 +1,71 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId +} from '../../../../shared/execution-host' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../../shared/workspace-session-state-types' +import { sanitizeWebRuntimeWorkspaceSession } from '../web-workspace-session' +import { readLocalWebUIState } from './web-preferences-store' +import { requireActiveEnvironmentOrNull } from './web-runtime-session' +import { SESSION_STORAGE_KEY, readJson, writeJson } from './web-storage' + +export function sessionStorageKeyForHost(hostId?: string | null): string { + const resolved = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID + return resolved === LOCAL_EXECUTION_HOST_ID + ? SESSION_STORAGE_KEY + : `${SESSION_STORAGE_KEY}.${resolved}` +} + +export function getStoredWorkspaceSession(hostId?: string | null): WorkspaceSessionState { + const resolvedHostId = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID + if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { + return sanitizeWebRuntimeWorkspaceSession( + readJson(sessionStorageKeyForHost(resolvedHostId), getDefaultWorkspaceSession()) + ) + } + const localSession = sanitizeWebRuntimeWorkspaceSession( + readJson(SESSION_STORAGE_KEY, getDefaultWorkspaceSession()) + ) + if (!requireActiveEnvironmentOrNull()) { + return localSession + } + const ui = readLocalWebUIState() + // Why: replaying browser-local terminal handles first creates stale remote PTYs; mirror host session-tabs instead. + return sanitizeWebRuntimeWorkspaceSession({ + ...getDefaultWorkspaceSession(), + activeRepoId: ui.lastActiveRepoId, + activeWorktreeId: ui.lastActiveWorktreeId, + lastVisitedAtByWorktreeId: localSession.lastVisitedAtByWorktreeId + }) +} + +export function createWebWorkspaceSessionApi(): Partial { + return { + session: { + // Mirrors desktop bridge: non-local hosts persist under a host-suffixed key so their sessions stay isolated from local. + get: (hostId) => Promise.resolve(getStoredWorkspaceSession(hostId)), + set: async (session, hostId) => { + writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) + }, + patch: async (patch: WorkspaceSessionPatch, hostId) => { + writeJson( + sessionStorageKeyForHost(hostId), + sanitizeWebRuntimeWorkspaceSession({ + ...getStoredWorkspaceSession(hostId), + ...patch + }) + ) + }, + // localStorage writes synchronously, so there is no deferred web flush. + flush: async () => {}, + readTerminalScrollback: () => null, + setSync: (session, hostId) => { + writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) + } + } + } +} diff --git a/src/renderer/src/web/preload-api/web-worktrees-api.ts b/src/renderer/src/web/preload-api/web-worktrees-api.ts new file mode 100644 index 00000000000..5d3b9bd1bd9 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-worktrees-api.ts @@ -0,0 +1,188 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import type { + ForceDeleteWorktreeBranchResult, + RemoveWorktreeResult +} from '../../../../shared/worktree/create-types' +import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import { readRetiredNameRegistryForRepo } from '../../../../shared/worktree/retired-name-cache' +import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry' +import type { Worktree } from '../../../../shared/worktree/types' +import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' +import { + callRuntimeResult, + callRuntimeResultWithOwner, + withRuntimeWorktreeOwner +} from './web-runtime-calls' +import { invalidateRuntimeWorktreeCaches } from './web-runtime-session' +import { + WEB_RUNTIME_WORKTREE_LIST_LIMIT, + callRuntimeDetectedWorktrees, + listAllRuntimeWorktrees +} from './web-runtime-worktree-catalog' +import { noopUnsubscribe } from './web-storage' + +export function createWorktreesApi(): NonNullable['worktrees']> { + return { + list: async ({ repoId }) => { + const owned = await callRuntimeResultWithOwner<{ worktrees: Worktree[] }>('worktree.list', { + repo: repoId, + limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT + }) + return owned.result.worktrees.map((worktree) => + withRuntimeWorktreeOwner(worktree, owned.hostId) + ) + }, + // Why the catch: a host predating this method answers `method_not_found`, and an empty list + // degrades the suggestion to the pre-existing behavior rather than blocking workspace create. + listRetiredNames: async ({ repoId }) => { + try { + return readRetiredNameRegistryForRepo( + await callRuntimeResult('worktree.listRetiredNames', { repo: repoId }), + repoId + ) + } catch { + return EMPTY_RETIRED_NAME_REGISTRY + } + }, + listDetected: async ({ repoId }) => callRuntimeDetectedWorktrees(repoId), + listAll: () => listAllRuntimeWorktrees(), + create: async (args) => { + invalidateRuntimeWorktreeCaches() + const owned = await callRuntimeResultWithOwner<{ worktree: Worktree }>('worktree.create', { + repo: args.repoId, + name: args.name, + // Absent means user-typed, which is what the host must assume — so send it only when true. + ...(args.nameWasGenerated ? { nameWasGenerated: true } : {}), + baseBranch: args.baseBranch, + compareBaseRef: args.compareBaseRef, + branchNameOverride: args.branchNameOverride, + linkedIssue: args.linkedIssue, + linkedPR: args.linkedPR, + linkedLinearIssue: args.linkedLinearIssue, + linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId, + linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey, + linkedGitLabIssue: args.linkedGitLabIssue, + linkedGitLabMR: args.linkedGitLabMR, + linkedBitbucketPR: args.linkedBitbucketPR, + linkedAzureDevOpsPR: args.linkedAzureDevOpsPR, + linkedGiteaPR: args.linkedGiteaPR, + displayName: args.displayName, + sparseCheckout: args.sparseCheckout, + pushTarget: args.pushTarget, + setupDecision: args.setupDecision, + createdWithAgent: args.createdWithAgent, + pendingFirstAgentMessageRename: args.pendingFirstAgentMessageRename, + ...(args.startup + ? { + startupCommand: args.startup.command, + ...(args.startup.env ? { startupEnv: args.startup.env } : {}), + ...(args.startup.launchConfig + ? { startupLaunchConfig: args.startup.launchConfig } + : {}), + ...(args.startup.startupCommandDelivery + ? { startupCommandDelivery: args.startup.startupCommandDelivery } + : {}), + activate: true + } + : {}), + parentWorkspace: args.parentWorkspace, + workspaceStatus: args.workspaceStatus, + manualOrder: args.manualOrder, + automationProvenanceRequest: args.automationProvenanceRequest + }) + return { + ...owned.result, + worktree: withRuntimeWorktreeOwner(owned.result.worktree, owned.hostId) + } + }, + // Why: adoption verifies a desktop-owned hidden SSH target and is intentionally not a remote-server operation. + adoptProvisionedRoot: () => + Promise.reject( + new Error('Provisioned-root recipes require a direct SSH connection from the desktop app.') + ), + // Why: the runtime create path emits no two-phase progress, so the panel falls back to an indeterminate spinner. + onCreateProgress: () => noopUnsubscribe, + prefetchCreateBase: async ({ repoId, baseBranch }) => { + await callRuntimeResult('worktree.prefetchCreateBase', { + repo: repoId, + baseBranch + }) + }, + resolvePrBase: async ({ repoId, prNumber, headRefName, baseRefName, isCrossRepository }) => + callRuntimeResult('worktree.resolvePrBase', { + repo: repoId, + prNumber, + headRefName, + baseRefName, + isCrossRepository + }), + resolveMrBase: async ({ repoId, mrIid, sourceBranch, targetBranch, isCrossRepository }) => + callRuntimeResult('worktree.resolveMrBase', { + repo: repoId, + mrIid, + sourceBranch, + targetBranch, + isCrossRepository + }), + remove: async ({ worktreeId, hostId, force, allowUnverifiedPtyStop, skipArchive }) => { + invalidateRuntimeWorktreeCaches() + return callRuntimeResult('worktree.rm', { + worktree: toRuntimeWorktreeSelector(worktreeId), + ...(hostId ? { hostId } : {}), + force, + // Why (#11960): the web client renders the same Force Delete affordances, so + // dropping this field here would leave paired clients permanently wedged. + allowUnverifiedPtyStop, + runHooks: skipArchive !== true + }) + }, + // Why: forget-locally clears a desktop workspace pinned to a dead SSH host; a paired web client has no such ghost state. + forgetLocal: () => { + throw new Error('Forgetting a workspace is unavailable in paired web clients.') + }, + forceDeletePreservedBranch: ({ worktreeId, branchName, expectedHead, hostId }) => + callRuntimeResult('worktree.forceDeleteBranch', { + worktree: toRuntimeWorktreeSelector(worktreeId), + branchName, + expectedHead, + ...(hostId ? { hostId } : {}) + }), + updateMeta: async ({ worktreeId, updates }) => { + const rpcUpdates = + Object.hasOwn(updates, 'pushTarget') && updates.pushTarget === undefined + ? { ...updates, pushTarget: null } + : updates + const owned = await callRuntimeResultWithOwner<{ worktree: Worktree }>('worktree.set', { + worktree: toRuntimeWorktreeSelector(worktreeId), + ...rpcUpdates + }) + return withRuntimeWorktreeOwner(owned.result.worktree, owned.hostId) + }, + listLineage: async () => + await callRuntimeResult<{ + lineage: Record + workspaceLineage?: Record + }>('worktree.lineageList'), + updateLineage: async ({ worktreeId, parentWorktreeId, noParent }) => { + invalidateRuntimeWorktreeCaches() + const result = await callRuntimeResult<{ + worktree: Worktree & { lineage?: WorktreeLineage | null } + }>('worktree.set', { + worktree: toRuntimeWorktreeSelector(worktreeId), + parentWorktree: parentWorktreeId, + noParent + }) + return result.worktree.lineage ?? null + }, + persistSortOrder: async ({ orderedIds }) => { + await callRuntimeResult('worktree.persistSortOrder', { orderedIds }) + }, + // Why: the capture lives in desktop main memory, unexposed over pairing; the dialog falls back to the persisted excerpt. + getBranchRenameFailureOutput: async () => null, + onChanged: () => noopUnsubscribe, + onGitStatusMetadataChanged: () => noopUnsubscribe, + onHeadIdentitiesChanged: () => noopUnsubscribe, + onBaseStatus: () => noopUnsubscribe, + onRemoteBranchConflict: () => noopUnsubscribe + } +} diff --git a/src/renderer/src/web/web-preload-api-clipboard.test.ts b/src/renderer/src/web/web-preload-api-clipboard.test.ts index 492a2520e94..f5a6f76f8a0 100644 --- a/src/renderer/src/web/web-preload-api-clipboard.test.ts +++ b/src/renderer/src/web/web-preload-api-clipboard.test.ts @@ -301,8 +301,9 @@ describe('web UI preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, installWebPreloadApi } = - await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS } = + await import('./preload-api/web-clipboard-api') const contentBase64 = `${'A'.repeat(CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS)}AAAA` installClipboardImageBase64(contentBase64) installWebPreloadApi() @@ -414,8 +415,9 @@ describe('web UI preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS, installWebPreloadApi } = - await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS } = + await import('./preload-api/web-clipboard-api') installClipboardImageBase64('A'.repeat(CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS + 4)) installWebPreloadApi() @@ -578,8 +580,8 @@ describe('web UI preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { MAX_CLIPBOARD_IMAGE_SOURCE_BYTES, installWebPreloadApi } = - await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { MAX_CLIPBOARD_IMAGE_SOURCE_BYTES } = await import('./preload-api/web-clipboard-api') const clipboard = installClipboardImageBlob( new Blob([new Uint8Array(MAX_CLIPBOARD_IMAGE_SOURCE_BYTES + 1)], { type: 'image/png' }) ) @@ -624,7 +626,8 @@ describe('web UI preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { MAX_CLIPBOARD_IMAGE_PIXELS, installWebPreloadApi } = await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { MAX_CLIPBOARD_IMAGE_PIXELS } = await import('./preload-api/web-clipboard-api') installClipboardImageBlob(new Blob(['small'], { type: 'image/jpeg' })) vi.stubGlobal( 'createImageBitmap', diff --git a/src/renderer/src/web/web-preload-api-composition.test.ts b/src/renderer/src/web/web-preload-api-composition.test.ts new file mode 100644 index 00000000000..85769ffa45c --- /dev/null +++ b/src/renderer/src/web/web-preload-api-composition.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installBrowserGlobals } from './web-preload-api-test-harness' + +describe('web preload API composition', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + vi.doUnmock('../../../shared/e2e-config') + }) + + it('installs the exact concrete surface without enumerating fallback namespaces', async () => { + const globals = installBrowserGlobals('Linux') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + expect(Object.keys(globals.window.api)).toEqual([ + 'app', + 'starNag', + 'platform', + 'workspacePorts', + 'orcaProfiles', + 'e2e', + 'settings', + 'agentAwake', + 'keybindings', + 'ui', + 'crashReports', + 'diagnostics', + 'session', + 'onboarding', + 'cache', + 'runtime', + 'nativeChat', + 'runtimeEnvironments', + 'repos', + 'worktrees', + 'fs', + 'git', + 'browser', + 'emulator', + 'gh', + 'gl', + 'hostedReview', + 'linear', + 'hooks', + 'stats', + 'memory', + 'aiVault', + 'preflight', + 'notifications', + 'rateLimits', + 'minimaxCredentials', + 'grokAccounts', + 'codexAccounts', + 'claudeAccounts', + 'cli', + 'agentHooks', + 'macosTccPrompts', + 'codexConfigSync', + 'developerPermissions', + 'computerUsePermissions', + 'updater', + 'shell', + 'skills', + 'pty', + 'ssh', + 'wsl', + 'pwsh', + 'gitBash', + 'agentStatus', + 'mobile', + 'telemetryTrack', + 'telemetrySetOptIn', + 'telemetryGetConsentState', + 'telemetryAcknowledgeBanner' + ]) + expect(Object.keys(globals.window.api.projects)).toEqual([]) + expect(Reflect.get(globals.window.api.projects, 'then')).toBeUndefined() + expect(Object.keys(globals.window.electron)).toEqual([]) + }) + + it('snapshots E2E config before runtime storage initialization', async () => { + const evaluationOrder: string[] = [] + const createE2EConfig = vi.fn((config: unknown) => { + evaluationOrder.push('e2e') + return config + }) + vi.doMock('../../../shared/e2e-config', () => ({ createE2EConfig })) + vi.stubEnv('VITE_EXPOSE_STORE', 'true') + const globals = installBrowserGlobals('Linux') + Object.assign(globals.window.location, { + search: '?orcaE2ETerminalParkingDelayMs=23&orcaE2ETerminalRetentionLimit=7' + }) + const readStored = globals.storage.getItem.bind(globals.storage) + vi.spyOn(globals.storage, 'getItem').mockImplementation((key) => { + evaluationOrder.push(`storage:${key}`) + return readStored(key) + }) + + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + expect(evaluationOrder[0]).toBe('e2e') + expect(createE2EConfig).toHaveBeenCalledWith({ + exposeStore: true, + terminalParkingDelayMs: 23, + terminalRetentionLimit: 7 + }) + expect(globals.window.api.e2e.getConfig()).toEqual({ + exposeStore: true, + terminalParkingDelayMs: 23, + terminalRetentionLimit: 7 + }) + }) +}) diff --git a/src/renderer/src/web/web-preload-api-github.test.ts b/src/renderer/src/web/web-preload-api-github.test.ts index 4f0a8312dd4..3483ab8fd40 100644 --- a/src/renderer/src/web/web-preload-api-github.test.ts +++ b/src/renderer/src/web/web-preload-api-github.test.ts @@ -105,7 +105,8 @@ describe('web GitHub preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { GITHUB_WEB_RPC_METHODS, installWebPreloadApi } = await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { GITHUB_WEB_RPC_METHODS } = await import('./preload-api/web-github-routes') installWebPreloadApi() const api = globals.window.api const repoPath = '/workspace/repo' diff --git a/src/renderer/src/web/web-preload-api-gitlab.test.ts b/src/renderer/src/web/web-preload-api-gitlab.test.ts index 899b2f4b596..558c3527773 100644 --- a/src/renderer/src/web/web-preload-api-gitlab.test.ts +++ b/src/renderer/src/web/web-preload-api-gitlab.test.ts @@ -56,7 +56,8 @@ describe('web GitLab preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { GITLAB_WEB_RPC_METHODS, installWebPreloadApi } = await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { GITLAB_WEB_RPC_METHODS } = await import('./preload-api/web-gitlab-routes') installWebPreloadApi() const api = globals.window.api const repoPath = '/workspace/repo' diff --git a/src/renderer/src/web/web-preload-api-runtime-calls.test.ts b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts new file mode 100644 index 00000000000..6960ba5b83f --- /dev/null +++ b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { + installBrowserGlobals, + writeStoredRuntimeEnvironment +} from './web-preload-api-test-harness' + +describe('web preload runtime calls', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) + + it('preserves success and failure envelopes while persisting response runtime metadata', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + if (method === 'runtime.failure') { + return Promise.resolve({ + id: method, + ok: false, + error: { code: 'remote_failure', message: 'Remote failed', data: { retry: false } }, + _meta: { runtimeId: 'runtime-failure' } + }) + } + return Promise.resolve({ + id: method, + ok: true, + result: { value: 42 }, + _meta: { runtimeId: 'runtime-success' } + }) + } + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect(globals.window.api.runtime.call({ method: 'runtime.success' })).resolves.toEqual({ + id: 'runtime.success', + ok: true, + result: { value: 42 }, + _meta: { runtimeId: 'runtime-success' } + }) + expect( + JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') + ).toMatchObject({ runtimeId: 'runtime-success' }) + + await expect(globals.window.api.runtime.call({ method: 'runtime.failure' })).resolves.toEqual({ + id: 'runtime.failure', + ok: false, + error: { code: 'remote_failure', message: 'Remote failed', data: { retry: false } }, + _meta: { runtimeId: 'runtime-failure' } + }) + expect( + JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') + ).toMatchObject({ runtimeId: 'runtime-failure' }) + }) + + it('unwraps domain failures to their message after persisting failure metadata', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + return Promise.resolve({ + id: method, + ok: false, + error: { + code: 'repo_unavailable', + message: 'Repository catalog is unavailable', + data: { host: 'runtime-a' } + }, + _meta: { runtimeId: 'runtime-domain-failure' } + }) + } + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + let rejection: unknown + try { + await globals.window.api.repos.list() + } catch (error) { + rejection = error + } + + expect(rejection).toEqual(new Error('Repository catalog is unavailable')) + if (!(rejection instanceof Error)) { + throw new Error('Expected a domain Error rejection') + } + expect(Reflect.get(rejection, 'code')).toBeUndefined() + expect( + JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') + ).toMatchObject({ runtimeId: 'runtime-domain-failure' }) + }) + + it('surfaces per-environment queue overload without invoking the client', async () => { + let release!: () => void + const blocked = new Promise>((resolve) => { + release = () => + resolve({ + id: 'runtime.blocked', + ok: true, + result: null, + _meta: { runtimeId: 'runtime-a' } + }) + }) + const call = vi.fn(() => blocked) + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call = call + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const accepted = Array.from({ length: 264 }, (_, index) => + globals.window.api.runtime.call({ method: `runtime.blocked.${index}` }) + ) + const overloaded = globals.window.api.runtime.call({ method: 'runtime.overloaded' }) + + await expect(overloaded).rejects.toMatchObject({ + code: 'runtime_rpc_queue_overloaded', + scope: 'selector' + }) + expect(call).toHaveBeenCalledTimes(8) + release() + await expect(Promise.all(accepted)).resolves.toHaveLength(264) + }) + + it('does not spend a selected call timeout while queued and forwards it unchanged', async () => { + vi.useFakeTimers() + let release!: () => void + const blocked = new Promise>((resolve) => { + release = () => + resolve({ + id: 'runtime.blocker', + ok: true, + result: null, + _meta: { runtimeId: 'runtime-a' } + }) + }) + const call = vi.fn((method: string): Promise> => { + if (method.startsWith('runtime.blocker')) { + return blocked + } + return Promise.resolve({ + id: method, + ok: true, + result: 'done', + _meta: { runtimeId: 'runtime-a' } + }) + }) + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call = call + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const blockers = Array.from({ length: 8 }, (_, index) => + globals.window.api.runtime.call({ method: `runtime.blocker.${index}` }) + ) + const queued = globals.window.api.runtimeEnvironments.call({ + selector: 'web-server-a', + method: 'runtime.queued-timeout', + timeoutMs: 25 + }) + let settled = false + void queued.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + + await vi.advanceTimersByTimeAsync(60_000) + expect(settled).toBe(false) + expect(call).toHaveBeenCalledTimes(8) + release() + await Promise.all(blockers) + await expect(queued).resolves.toMatchObject({ ok: true, result: 'done' }) + expect(call).toHaveBeenLastCalledWith('runtime.queued-timeout', undefined, { timeoutMs: 25 }) + }) +}) diff --git a/src/renderer/src/web/web-preload-api-runtime-environment.test.ts b/src/renderer/src/web/web-preload-api-runtime-environment.test.ts index 9917a8e97f4..58ecb5e96c0 100644 --- a/src/renderer/src/web/web-preload-api-runtime-environment.test.ts +++ b/src/renderer/src/web/web-preload-api-runtime-environment.test.ts @@ -525,4 +525,59 @@ describe('web runtime environment identity', () => { message: 'Access grant rejected.' }) }) + + it('replaces API closures on reinstall while retaining the runtime client singleton', async () => { + let clientCount = 0 + const call = vi.fn( + (method: string): Promise> => + Promise.resolve({ + id: method, + ok: true, + result: { method }, + _meta: { runtimeId: 'runtime-a' } + }) + ) + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + constructor() { + clientCount += 1 + } + + call = call + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + const firstApi = globals.window.api + const initialZoom = firstApi.ui.getZoomLevel() + firstApi.ui.setZoomLevel(initialZoom + 1) + await expect(firstApi.runtime.call({ method: 'status.first' })).resolves.toEqual({ + id: 'status.first', + ok: true, + result: { method: 'status.first' }, + _meta: { runtimeId: 'runtime-a' } + }) + expect( + JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') + ).toMatchObject({ runtimeId: 'runtime-a' }) + + installWebPreloadApi() + const secondApi = globals.window.api + await secondApi.runtime.call({ method: 'status.second' }) + await firstApi.runtime.call({ method: 'status.old-capture' }) + + expect(secondApi).not.toBe(firstApi) + expect(secondApi.ui.getZoomLevel()).toBe(initialZoom) + expect(firstApi.ui.getZoomLevel()).toBe(initialZoom + 1) + expect(clientCount).toBe(1) + expect(call.mock.calls.map(([method]) => method)).toEqual([ + 'status.first', + 'status.second', + 'status.old-capture' + ]) + }) }) diff --git a/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts b/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts index 5276e741e52..cb6ca0eb3e8 100644 --- a/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts +++ b/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts @@ -343,6 +343,40 @@ describe('web worktree preload API', () => { ]) }) + it('reuses the worktree catalog cache until disconnect invalidates its runtime owner', async () => { + const calls: string[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + calls.push(method) + return Promise.resolve({ + id: method, + ok: true, + result: + method === 'status.get' + ? { runtimeId: 'runtime-a', version: '1.0.0' } + : { worktrees: [{ id: 'worktree-a', repoId: 'repo-a', path: '/srv/a' }] }, + _meta: { runtimeId: 'runtime-a' } + }) + } + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await globals.window.api.worktrees.listAll() + await globals.window.api.worktrees.listAll() + await globals.window.api.runtimeEnvironments.disconnect({ selector: 'web-server-a' }) + await globals.window.api.runtimeEnvironments.connect({ selector: 'web-server-a' }) + await globals.window.api.worktrees.listAll() + + expect(calls).toEqual(['worktree.list', 'status.get', 'worktree.list']) + }) + it('preserves runtime-routed detected-worktree host ownership in the compatibility shape', async () => { vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 5e492168b58..50a63270d80 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1,881 +1,83 @@ -/* eslint-disable max-lines -- Why: browser-side Electron-preload replacement; compatibility surface centralizes here. */ -import type { - PreloadApi, - PreflightStatus, - RefreshAgentsResult, - NativeChatApi, - NativeChatAppendedMessages -} from '../../../preload/api-types' -import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' -import { parseHostAccessLink } from '../../../shared/remote-pairing-address' -import { verifyRemotePairingRuntimeStatus } from '../../../shared/remote-pairing-verification' -import type { AiVaultDeleteSessionArgs } from '../../../shared/ai-vault-session-deletion' -import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types' -import type { - AiVaultSessionTitlesArgs, - AiVaultSessionTitlesResult -} from '../../../shared/ai-vault-session-title' -import type { - AiVaultPrepareSessionResumeArgs, - AiVaultPrepareSessionResumeResult -} from '../../../shared/ai-vault-resume-preparation' -import { buildNativeChatUnsubscribe } from '../../../shared/native-chat-stream-unsubscribe' -import type { - ComputerUsePermissionSetupResult, - ComputerUsePermissionStatusResult -} from '../../../shared/computer-use-permissions-types' -import type { SearchResult } from '../../../shared/code-search-types' -import type { DirEntry } from '../../../shared/filesystem-entry-types' -import type { - GlobalSettings, - WorktreeVisibilityDefaults -} from '../../../shared/global-settings-types' -import type { OnboardingState } from '../../../shared/onboarding-state-types' -import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' +import type { PreloadApi } from '../../../preload/api-types' +import type { StatsSummary } from '../../../shared/process-stats-types' +import { createWebE2EApi } from './preload-api/web-e2e-api' import { - omitPairingLocalUiFields, - type PairedUiState, - type PairingLocalUiField -} from '../../../shared/pairing-local-ui-fields' -import type { MemorySnapshot, StatsSummary } from '../../../shared/process-stats-types' -import type { Repo } from '../../../shared/repo-types' -import type { - WorkspaceSessionPatch, - WorkspaceSessionState -} from '../../../shared/workspace-session-state-types' -import type { - ForceDeleteWorktreeBranchResult, - RemoveWorktreeResult -} from '../../../shared/worktree/create-types' -import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' -import type { DetectedWorktreeListResult, Worktree } from '../../../shared/worktree/types' -import type { SkillDiscoveryResult } from '../../../shared/skills' -import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' -import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types' + createAccountsApi, + createGrokAccountsApi, + createMiniMaxCredentialsApi +} from './preload-api/web-agent-accounts-api' +import { createAgentHooksApi } from './preload-api/web-agent-hooks-api' +import { createWebAgentStatusApi } from './preload-api/web-agent-status-api' +import { createWebAiVaultApi } from './preload-api/web-ai-vault-api' +import { createWebAppApi } from './preload-api/web-app-api' +import { createBrowserApi, createEmulatorApi } from './preload-api/web-browser-api' +import { createCliApi } from './preload-api/web-cli-api' +import { createWebDiagnosticsApi } from './preload-api/web-diagnostics-api' +import { createFallbackProxy, withFallback } from './preload-api/web-fallback-api' +import { createFileApi } from './preload-api/web-filesystem-api' +import { createGitApi } from './preload-api/web-git-api' +import { createWebGithubCacheApi } from './preload-api/web-github-cache-api' +import { createGitHubApi } from './preload-api/web-github-api' +import { createGitLabApi } from './preload-api/web-gitlab-api' import { - getDefaultOnboardingState, - getDefaultSettings, - getDefaultUIState, - getDefaultWorkspaceSession, - getWorktreeCardModeProperties, - normalizeAgentActivityDisplayMode, - normalizeWorktreeCardProperties, - ONBOARDING_FLOW_VERSION -} from '../../../shared/constants' -import { - createDefaultLocalOrcaProfile, - DEFAULT_LOCAL_ORCA_PROFILE_ID -} from '../../../shared/orca-profiles' -import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result' -import { EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS } from '../../../shared/pty-delivery-diagnostics' -import { createE2EConfig } from '../../../shared/e2e-config' -import { relativePathInsideRoot } from '../../../shared/cross-platform-path' -import { readRetiredNameRegistryForRepo } from '../../../shared/worktree/retired-name-cache' -import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../shared/worktree/retired-name-registry' -import { - applyPRBotAuthorOverride, - normalizePRBotAuthorOverrides -} from '../../../shared/pr-bot-author-overrides' -import { - LOCAL_EXECUTION_HOST_ID, - normalizeExecutionHostScope, - normalizeExecutionHostId, - parseExecutionHostId, - toRuntimeExecutionHostId, - type ExecutionHostId -} from '../../../shared/execution-host' -import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector' -import { callAbortableRuntimeEnvironment } from '../runtime/abortable-runtime-environment-call' -import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' -import { - normalizeTuiAgentArgsRecord, - normalizeTuiAgentEnvRecord -} from '../../../shared/tui-agent-launch-defaults' -import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings' -import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' -import { - normalizeOsc52ClipboardDefaultOn, - osc52ClipboardDefaultOnOverridesPersistedOff -} from '../../../shared/osc52-clipboard-settings' -import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' -import { normalizeUiLanguage } from '../../../shared/ui-language' -import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display' -import { normalizeStatusBarUsageMode } from '../../../shared/status-bar-usage-mode' -import { - computerAwakeSettingsForMode, - normalizeComputerAwakeMode -} from '../../../shared/computer-awake-mode' -import { normalizeWorktreeVisibilityDefaults } from '../../../shared/external-worktree-visibility' -import type { RateLimitState } from '../../../shared/rate-limit-types' -import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types' -import { assertFileMutationOwnershipCapability } from '../../../shared/file-mutation-ownership' -import { - findKeybindingConflicts, - formatKeybindingList, - getKeybindingPlatform, - isKeybindingActionId, - normalizeKeybindingArrayForAction, - type KeybindingActionId, - type KeybindingFileDiagnostic, - type KeybindingFileSnapshot, - type KeybindingOverrides, - type KeybindingPlatform -} from '../../../shared/keybindings' -import { - clearStoredWebRuntimeEnvironment, - createStoredWebRuntimeEnvironment, - getPreferredWebPairingOffer, - readStoredWebRuntimeEnvironment, - redactStoredWebRuntimeEnvironment, - saveStoredWebRuntimeEnvironment, - updateStoredEnvironmentRuntimeId, - type StoredWebRuntimeEnvironment -} from './web-runtime-environment' -import { parseWebPairingInput } from './web-pairing' -import { copyClipboardTextViaExecCommand } from './web-clipboard-copy-fallback' -import { WebRuntimeClient } from './web-runtime-client' -import { isWebRuntimeUnauthorizedError } from './web-runtime-client-error' -import { RuntimeRpcCallQueuePool } from '../../../shared/runtime-rpc-call-queue' -import { - assertClipboardTextWriteWithinLimitWithYield, - assertClipboardTextWithinLimitWithYield, - type ReadClipboardTextOptions -} from '../../../shared/clipboard-text' -import { - CLIPBOARD_IMAGE_MAX_BASE64_CHARS, - CLIPBOARD_IMAGE_MAX_PIXELS, - CLIPBOARD_IMAGE_MAX_SOURCE_BYTES, - CLIPBOARD_IMAGE_TOO_LARGE_ERROR, - assertClipboardImageByteLengthWithinLimit, - assertClipboardImageDimensionsWithinLimit -} from '../../../shared/clipboard-image' -import { sanitizeWebRuntimeWorkspaceSession } from './web-workspace-session' -import { - normalizeFeatureInteractions, - type FeatureInteractionId, - type FeatureInteractionState -} from '../../../shared/feature-interactions' -import { normalizeContextualTourIds, type ContextualTourId } from '../../../shared/contextual-tours' -import { translate } from '@/i18n/i18n' -import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy' -import { getDefaultCreateProjectParent } from '@/components/sidebar/create-project-defaults' -import { - parseRuntimeNativeChatReadSessionResult, - parseRuntimeNativeChatTurnLifecycle -} from '@/components/native-chat/native-chat-runtime-contract' -import { createWebFileMutationMethods } from './web-file-mutation-methods' -import { mergeWorkspaceCleanupUIState } from '../../../shared/workspace-cleanup-ui-state' - -const SETTINGS_STORAGE_KEY = 'orca.web.settings.v1' -const UI_STORAGE_KEY = 'orca.web.ui.v1' -const SESSION_STORAGE_KEY = 'orca.web.workspaceSession.v1' -const ONBOARDING_STORAGE_KEY = 'orca.web.onboarding.v1' -const GITHUB_CACHE_STORAGE_KEY = 'orca.web.githubCache.v1' -const KEYBINDINGS_STORAGE_KEY = 'orca.web.keybindings.v1' -// Why: paired web clients lack Electron env/preload state; the E2E build gate keeps URL overrides out of releases. -const webE2EExposeStore = String(import.meta.env.VITE_EXPOSE_STORE) === 'true' -const webE2EQuery = webE2EExposeStore ? new URLSearchParams(window.location.search) : null -const webE2EConfig = createE2EConfig({ - exposeStore: webE2EExposeStore, - terminalParkingDelayMs: Number(webE2EQuery?.get('orcaE2ETerminalParkingDelayMs')) || null, - terminalRetentionLimit: Number(webE2EQuery?.get('orcaE2ETerminalRetentionLimit')) || null -}) -// Why: paired clients need parity for large dev sessions; the runtime default stays capped for lower-level RPC callers. -const WEB_RUNTIME_WORKTREE_LIST_LIMIT = 10_000 -const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS -export const MAX_CLIPBOARD_IMAGE_SOURCE_BYTES = CLIPBOARD_IMAGE_MAX_SOURCE_BYTES -export const MAX_CLIPBOARD_IMAGE_PIXELS = CLIPBOARD_IMAGE_MAX_PIXELS -export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 -export const CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024 -const CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS = 30_000 - -let activeEnvironment: StoredWebRuntimeEnvironment | null = readStoredWebRuntimeEnvironment() -let worktreeVisibilityDefaultsRuntimeEnvironmentId: string | null = null -let worktreeVisibilityDefaultsRuntimeValue: WorktreeVisibilityDefaults | null = null -let activeClient: WebRuntimeClient | null = null -let activeClientEnvironmentId: string | null = null -const manuallyDisconnectedEnvironmentIds = new Set() -let cachedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null = null -let cachedDetectedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null = null -const runtimeCallQueuePool = new RuntimeRpcCallQueuePool() - -function blobToBase64(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => { - const result = typeof reader.result === 'string' ? reader.result : '' - const commaIndex = result.indexOf(',') - resolve(commaIndex === -1 ? result : result.slice(commaIndex + 1)) - } - reader.onerror = () => reject(reader.error ?? new Error('Failed to read clipboard image')) - reader.readAsDataURL(blob) - }) -} - -function assertClipboardImageBlobWithinLimit(blob: Blob): void { - assertClipboardImageByteLengthWithinLimit(blob.size) -} - -async function convertImageBlobToPng(blob: Blob): Promise { - assertClipboardImageBlobWithinLimit(blob) - const bitmap = await createImageBitmap(blob) - try { - assertClipboardImageDimensionsWithinLimit(bitmap) - const canvas = document.createElement('canvas') - canvas.width = bitmap.width - canvas.height = bitmap.height - const context = canvas.getContext('2d') - if (!context || canvas.width <= 0 || canvas.height <= 0) { - throw new Error('Clipboard image could not be decoded') - } - context.drawImage(bitmap, 0, 0) - return await new Promise((resolve, reject) => { - canvas.toBlob((png) => { - if (!png) { - reject(new Error('Clipboard image could not be encoded as PNG')) - return - } - try { - assertClipboardImageBlobWithinLimit(png) - } catch (error) { - reject(error) - return - } - resolve(png) - }, 'image/png') - }) - } finally { - bitmap.close() - } -} - -async function readClipboardImagePngBase64(): Promise { - const clipboard = navigator.clipboard as - | (Clipboard & { read?: () => Promise }) - | undefined - if (!clipboard?.read) { - return null - } - const items = await clipboard.read() - for (const item of items) { - const imageType = item.types.find((type) => type.startsWith('image/')) - if (!imageType) { - continue - } - const blob = await item.getType(imageType) - assertClipboardImageBlobWithinLimit(blob) - const pngBlob = imageType === 'image/png' ? blob : await convertImageBlobToPng(blob) - return blobToBase64(pngBlob) - } - return null -} - -function invalidateRuntimeWorktreeCaches(): void { - cachedWorktrees = null - cachedDetectedWorktrees = null -} - -type WebSettingsApi = NonNullable -type WebKeybindingsApi = NonNullable -type WebGitHubApi = NonNullable -type WebGitHubResult = Awaited> -type WebRuntimeResultCaller = ( - method: string, - params?: unknown, - timeoutMs?: number -) => Promise -type WebRuntimeEnvelopeCaller = ( - method: string, - params?: unknown, - timeoutMs?: number -) => Promise> -type WebGitHubRouteKey = - | 'repoSlug' - | 'repoUpstream' - | 'prForBranch' - | 'issue' - | 'workItem' - | 'workItemByOwnerRepo' - | 'workItemDetails' - | 'prFileContents' - | 'listIssues' - | 'createIssue' - | 'countWorkItems' - | 'listWorkItems' - | 'prChecks' - | 'prCheckDetails' - | 'rerunPRChecks' - | 'prComments' - | 'setPRCommentReaction' - | 'resolveReviewThread' - | 'setPRFileViewed' - | 'updatePRTitle' - | 'mergePR' - | 'setPRAutoMerge' - | 'updatePRState' - | 'requestPRReviewers' - | 'removePRReviewers' - | 'updateIssue' - | 'addIssueComment' - | 'addPRReviewCommentReply' - | 'addPRReviewComment' - | 'listLabels' - | 'listAssignableUsers' - | 'rateLimit' - | 'listAccessibleProjects' - | 'resolveProjectRef' - | 'listProjectViews' - | 'getProjectViewTable' - | 'projectWorkItemDetailsBySlug' - | 'updateProjectItemField' - | 'clearProjectItemField' - | 'updateIssueBySlug' - | 'updatePullRequestBySlug' - | 'addIssueCommentBySlug' - | 'updateIssueCommentBySlug' - | 'deleteIssueCommentBySlug' - | 'listLabelsBySlug' - | 'listAssignableUsersBySlug' - | 'listIssueTypesBySlug' - | 'updateIssueTypeBySlug' -type WebGitHubRuntimeMethod = - | 'github.repoSlug' - | 'github.repoUpstream' - | 'github.prForBranch' - | 'github.issue' - | 'github.workItem' - | 'github.workItemByOwnerRepo' - | 'github.workItemDetails' - | 'github.prFileContents' - | 'github.listIssues' - | 'github.createIssue' - | 'github.countWorkItems' - | 'github.listWorkItems' - | 'github.prChecks' - | 'github.prCheckDetails' - | 'github.rerunPRChecks' - | 'github.prComments' - | 'github.setPRCommentReaction' - | 'github.resolveReviewThread' - | 'github.setPRFileViewed' - | 'github.updatePRTitle' - | 'github.mergePR' - | 'github.setPRAutoMerge' - | 'github.updatePRState' - | 'github.requestPRReviewers' - | 'github.removePRReviewers' - | 'github.updateIssue' - | 'github.addIssueComment' - | 'github.addPRReviewCommentReply' - | 'github.addPRReviewComment' - | 'github.listLabels' - | 'github.listAssignableUsers' - | 'github.rateLimit' - | 'github.project.listAccessible' - | 'github.project.resolveRef' - | 'github.project.listViews' - | 'github.project.viewTable' - | 'github.project.workItemDetailsBySlug' - | 'github.project.updateItemField' - | 'github.project.clearItemField' - | 'github.project.updateIssueBySlug' - | 'github.project.updatePullRequestBySlug' - | 'github.project.addIssueCommentBySlug' - | 'github.project.updateIssueCommentBySlug' - | 'github.project.deleteIssueCommentBySlug' - | 'github.project.listLabelsBySlug' - | 'github.project.listAssignableUsersBySlug' - | 'github.project.listIssueTypesBySlug' - | 'github.project.updateIssueTypeBySlug' -type WebGitLabApi = NonNullable -type WebGitLabResult = Awaited> -type WebGitLabRouteKey = - | 'diagnoseAuth' - | 'rateLimit' - | 'listMRs' - | 'listWorkItems' - | 'listIssues' - | 'createIssue' - | 'updateIssue' - | 'addIssueComment' - | 'listLabels' - | 'todos' - | 'workItemDetails' - | 'closeMR' - | 'reopenMR' - | 'mergeMR' - | 'updateMR' - | 'updateMRReviewers' - | 'addMRComment' - | 'addMRInlineComment' - | 'resolveMRDiscussion' - | 'jobTrace' - | 'retryJob' - | 'workItemByPath' -type WebGitLabRuntimeMethod = - | 'gitlab.diagnoseAuth' - | 'gitlab.rateLimit' - | 'gitlab.listMRs' - | 'gitlab.listWorkItems' - | 'gitlab.listIssues' - | 'gitlab.createIssue' - | 'gitlab.updateIssue' - | 'gitlab.addIssueComment' - | 'gitlab.listLabels' - | 'gitlab.todos' - | 'gitlab.workItemDetails' - | 'gitlab.updateMRState' - | 'gitlab.mergeMR' - | 'gitlab.updateMR' - | 'gitlab.updateMRReviewers' - | 'gitlab.addMRComment' - | 'gitlab.addMRInlineComment' - | 'gitlab.resolveMRDiscussion' - | 'gitlab.jobTrace' - | 'gitlab.retryJob' - | 'gitlab.workItemByPath' -type WebKeybindingDocument = { - version: 1 - keybindings: KeybindingOverrides - platforms: Partial> -} - -export const GITHUB_WEB_RPC_METHODS = { - repoSlug: 'github.repoSlug', - repoUpstream: 'github.repoUpstream', - prForBranch: 'github.prForBranch', - issue: 'github.issue', - workItem: 'github.workItem', - workItemByOwnerRepo: 'github.workItemByOwnerRepo', - workItemDetails: 'github.workItemDetails', - prFileContents: 'github.prFileContents', - listIssues: 'github.listIssues', - createIssue: 'github.createIssue', - countWorkItems: 'github.countWorkItems', - listWorkItems: 'github.listWorkItems', - prChecks: 'github.prChecks', - prCheckDetails: 'github.prCheckDetails', - rerunPRChecks: 'github.rerunPRChecks', - prComments: 'github.prComments', - setPRCommentReaction: 'github.setPRCommentReaction', - resolveReviewThread: 'github.resolveReviewThread', - setPRFileViewed: 'github.setPRFileViewed', - updatePRTitle: 'github.updatePRTitle', - mergePR: 'github.mergePR', - setPRAutoMerge: 'github.setPRAutoMerge', - updatePRState: 'github.updatePRState', - requestPRReviewers: 'github.requestPRReviewers', - removePRReviewers: 'github.removePRReviewers', - updateIssue: 'github.updateIssue', - addIssueComment: 'github.addIssueComment', - addPRReviewCommentReply: 'github.addPRReviewCommentReply', - addPRReviewComment: 'github.addPRReviewComment', - listLabels: 'github.listLabels', - listAssignableUsers: 'github.listAssignableUsers', - rateLimit: 'github.rateLimit', - listAccessibleProjects: 'github.project.listAccessible', - resolveProjectRef: 'github.project.resolveRef', - listProjectViews: 'github.project.listViews', - getProjectViewTable: 'github.project.viewTable', - projectWorkItemDetailsBySlug: 'github.project.workItemDetailsBySlug', - updateProjectItemField: 'github.project.updateItemField', - clearProjectItemField: 'github.project.clearItemField', - updateIssueBySlug: 'github.project.updateIssueBySlug', - updatePullRequestBySlug: 'github.project.updatePullRequestBySlug', - addIssueCommentBySlug: 'github.project.addIssueCommentBySlug', - updateIssueCommentBySlug: 'github.project.updateIssueCommentBySlug', - deleteIssueCommentBySlug: 'github.project.deleteIssueCommentBySlug', - listLabelsBySlug: 'github.project.listLabelsBySlug', - listAssignableUsersBySlug: 'github.project.listAssignableUsersBySlug', - listIssueTypesBySlug: 'github.project.listIssueTypesBySlug', - updateIssueTypeBySlug: 'github.project.updateIssueTypeBySlug' -} as const satisfies Record - -export const GITLAB_WEB_RPC_METHODS = { - diagnoseAuth: 'gitlab.diagnoseAuth', - rateLimit: 'gitlab.rateLimit', - listMRs: 'gitlab.listMRs', - listWorkItems: 'gitlab.listWorkItems', - listIssues: 'gitlab.listIssues', - createIssue: 'gitlab.createIssue', - updateIssue: 'gitlab.updateIssue', - addIssueComment: 'gitlab.addIssueComment', - listLabels: 'gitlab.listLabels', - todos: 'gitlab.todos', - workItemDetails: 'gitlab.workItemDetails', - closeMR: 'gitlab.updateMRState', - reopenMR: 'gitlab.updateMRState', - mergeMR: 'gitlab.mergeMR', - updateMR: 'gitlab.updateMR', - updateMRReviewers: 'gitlab.updateMRReviewers', - addMRComment: 'gitlab.addMRComment', - addMRInlineComment: 'gitlab.addMRInlineComment', - resolveMRDiscussion: 'gitlab.resolveMRDiscussion', - jobTrace: 'gitlab.jobTrace', - retryJob: 'gitlab.retryJob', - workItemByPath: 'gitlab.workItemByPath' -} as const satisfies Record - -const WEB_KEYBINDING_PLATFORMS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] -const webKeybindingListeners = new Set<(snapshot: KeybindingFileSnapshot) => void>() + createComputerUsePermissionsApi, + createDeveloperPermissionsApi, + createPreflightApi, + createSkillsApi +} from './preload-api/web-host-capability-api' +import { createWebKeybindingsApi } from './preload-api/web-keybindings-api' +import { createMacosTccPromptsApi } from './preload-api/web-macos-tcc-api' +import { createEmptyMemorySnapshot } from './preload-api/web-memory-api' +import { createWebMobileApi } from './preload-api/web-mobile-api' +import { createWebNativeChatApi } from './preload-api/web-native-chat-api' +import { createNotificationsApi } from './preload-api/web-notifications-api' +import { createWebOnboardingApi } from './preload-api/web-onboarding-api' +import { createWebOrcaProfilesApi } from './preload-api/web-orca-profiles-api' +import { createWebPlatformApi } from './preload-api/web-platform-api' +import { createRateLimitsApi } from './preload-api/web-rate-limits-api' +import { createReposApi } from './preload-api/web-repositories-api' +import { createHooksApi, createRuntimeNamespaceApi } from './preload-api/web-review-api' +import { callRuntimeResult } from './preload-api/web-runtime-calls' +import { createWebRuntimeApi } from './preload-api/web-runtime-api' +import { createRuntimeEnvironmentsApi } from './preload-api/web-runtime-environments-api' +import { webRuntimeState } from './preload-api/web-runtime-session' +import { createWebSettingsApi } from './preload-api/web-settings-api' +import { createShellApi } from './preload-api/web-shell-api' +import { createWebStarNagApi } from './preload-api/web-star-nag-api' +import { createWebTelemetryApi } from './preload-api/web-telemetry-api' +import { createPtyApi, createSshApi } from './preload-api/web-terminal-api' +import { createWebUiApi } from './preload-api/web-ui-api' +import { createUpdaterApi } from './preload-api/web-updater-api' +import { createWebWorkspacePortsApi } from './preload-api/web-workspace-ports-api' +import { createWebWorkspaceSessionApi } from './preload-api/web-workspace-session-api' +import { createWorktreesApi } from './preload-api/web-worktrees-api' +import { readStoredWebRuntimeEnvironment } from './web-runtime-environment' export function installWebPreloadApi(): void { - activeEnvironment = readStoredWebRuntimeEnvironment() + webRuntimeState.activeEnvironment = readStoredWebRuntimeEnvironment() const webWindow = window as unknown as { __ORCA_WEB_CLIENT__?: boolean } webWindow.__ORCA_WEB_CLIENT__ = true window.electron = createFallbackProxy(['electron']) as Window['electron'] window.api = withFallback(createWebPreloadApi(), []) as PreloadApi } -async function writeWebClipboardText(text: string): Promise { - await assertClipboardTextWriteWithinLimitWithYield(text) - const clipboard = navigator.clipboard - if (typeof clipboard?.writeText === 'function') { - try { - await clipboard.writeText(text) - return - } catch (error) { - // Preserve the current user-activation turn for the synchronous fallback. - if (copyClipboardTextViaExecCommand(text)) { - return - } - throw error - } - } - if (!copyClipboardTextViaExecCommand(text)) { - throw new Error('Clipboard write is unavailable in this browser context') - } -} - function createWebPreloadApi(): Partial { - const webOrcaProfileAuthStatus = () => - Promise.resolve({ - activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, - configured: false, - state: 'unconfigured' as const, - persistence: 'none' as const, - setupMessage: 'Orca Cloud sign-in is not available in the browser fallback.' - }) - return { - app: { - getIdentity: () => - Promise.resolve({ - name: 'Orca', - isDev: false, - devLabel: null, - devBranch: null, - devWorktreeName: null, - devRepoRoot: null, - dockBadgeLabel: null - }), - getFeatureWallAssetBaseUrl: () => Promise.resolve('/'), - relaunch: () => Promise.resolve(window.location.reload()), - restart: () => Promise.resolve(window.location.reload()), - reload: () => Promise.resolve(window.location.reload()), - stageBeforeUnloadSync: ({ sessions, ui }) => { - // Why: beforeunload cannot await the paired runtime, so the web adapter - // guarantees immediate browser-local durability for the final snapshot. - for (const { state, hostId } of sessions) { - writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(state)) - } - writeJson(UI_STORAGE_KEY, mergeWebUIState(readLocalWebUIState(), ui)) - }, - // Staging already wrote through to browser storage, so there is nothing left to join. - awaitBeforeUnloadCheckpoint: () => Promise.resolve(), - awaitFirstWindowStartupServices: () => Promise.resolve(), - recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), - startupDiagnostic: () => Promise.resolve(), - getKeyboardInputSourceId: () => Promise.resolve(null), - // The web client cannot inspect local Mission Control shortcuts. - getMacCapturedDigitRowChords: () => Promise.resolve([]), - getKeyboardLayoutSnapshot: () => Promise.resolve(null), - onKeyboardLayoutChanged: () => () => undefined, - setUnreadDockBadgeCount: () => Promise.resolve(), - getFloatingTerminalCwd: () => Promise.resolve(''), - getFloatingMarkdownDirectory: () => Promise.resolve(''), - pickFloatingMarkdownDocument: () => Promise.resolve(null), - pickFloatingWorkspaceDirectory: () => Promise.resolve(null), - // Browser fallback has no app-owned userData dir; reject so the sentinel can't claim sensitive evidence was persisted. - writeTerminalRenderDesyncEvidence: () => - Promise.reject( - new Error('Terminal render evidence is unavailable in the browser fallback.') - ) - }, - starNag: { - onShow: () => noopUnsubscribe, - onHide: () => noopUnsubscribe, - dismiss: () => Promise.resolve(), - later: () => Promise.resolve(), - complete: () => Promise.resolve(), - disable: () => Promise.resolve(), - openWeb: () => Promise.resolve(), - starOrca: () => Promise.resolve(false), - forceShow: () => Promise.resolve(), - agentValueMoment: () => Promise.resolve({ status: 'skipped' }), - showAgentValueMoment: () => Promise.resolve(), - onboardingCompleted: () => Promise.resolve() - }, - platform: { - get: () => ({ - platform: getBrowserPlatform(), - osRelease: '', - arch: '', - shell: '', - displayServer: null - }) - }, - workspacePorts: { - // Why: browser-local workspaces have no host process to inspect; return capability state instead of the generic undefined fallback. - scan: () => - Promise.resolve({ - platform: getBrowserPlatform(), - scannedAt: Date.now(), - ports: [], - unavailableReason: 'Workspace port scanning is unavailable for browser-local workspaces.' - }), - kill: () => - Promise.resolve({ - ok: false, - reason: 'Workspace port management is unavailable for browser-local workspaces.' - }), - onAdvertisedUrlChanged: () => noopUnsubscribe - }, - orcaProfiles: { - list: () => - Promise.resolve({ - activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, - profiles: [createDefaultLocalOrcaProfile(0)], - multiProfileUi: false - }), - authStatus: webOrcaProfileAuthStatus, - createLocal: () => - Promise.resolve({ - activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, - profiles: [createDefaultLocalOrcaProfile(0)], - profile: createDefaultLocalOrcaProfile(0) - }), - createCloudLinked: async () => ({ - status: 'unconfigured', - auth: await webOrcaProfileAuthStatus() - }), - switchProfile: () => Promise.resolve({ status: 'already-active' }), - transferProject: (args) => - Promise.resolve({ - status: 'duplicate-target', - sourceProfileId: args.sourceProfileId, - targetProfileId: args.targetProfileId, - sourceRepoId: args.repoId, - duplicateRepoId: args.repoId - }), - findProjectProfiles: async () => ({ projects: [] }), - connectCurrent: async () => ({ - status: 'unconfigured', - auth: await webOrcaProfileAuthStatus() - }), - refreshAuth: async () => ({ - status: 'unconfigured', - auth: await webOrcaProfileAuthStatus() - }), - signOutCurrent: async () => ({ - status: 'signed-out', - auth: await webOrcaProfileAuthStatus(), - activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, - profiles: [createDefaultLocalOrcaProfile(0)] - }), - selectOrg: async () => ({ - status: 'unconfigured', - auth: await webOrcaProfileAuthStatus() - }), - orgMembersList: async () => ({ status: 'unconfigured' }), - orgMemberInvite: async () => ({ status: 'unconfigured' }), - orgInviteRevoke: async () => ({ status: 'unconfigured' }), - orgMemberChangeRole: async () => ({ status: 'unconfigured' }), - orgMemberRemove: async () => ({ status: 'unconfigured' }) - }, - e2e: { - getConfig: () => webE2EConfig - }, - settings: { - get: async () => getRuntimeBackedStoredSettings(), - // Why: localStorage-backed settings are synchronous, so the pre-hydration kill-switch read works the same as desktop. - getSync: () => settingsForActiveVisibilityOwner(getStoredSettings()), - set: async (updates) => { - const sanitizedUpdates = { ...updates } - const runtimeEnvironment = requireActiveEnvironmentOrNull() - delete sanitizedUpdates.activeRuntimeEnvironmentId - if ( - 'worktreeVisibilityDefaults' in sanitizedUpdates && - runtimeEnvironment && - runtimeEnvironment.id !== worktreeVisibilityDefaultsRuntimeEnvironmentId - ) { - delete sanitizedUpdates.worktreeVisibilityDefaults - } - if ('worktreeVisibilityDefaults' in sanitizedUpdates) { - sanitizedUpdates.worktreeVisibilityDefaults = { - ...settingsForActiveVisibilityOwner(getStoredSettings()).worktreeVisibilityDefaults, - ...sanitizedUpdates.worktreeVisibilityDefaults - } - } - if ('computerAwakeMode' in sanitizedUpdates) { - Object.assign( - sanitizedUpdates, - computerAwakeSettingsForMode( - normalizeComputerAwakeMode( - sanitizedUpdates.computerAwakeMode, - sanitizedUpdates.keepComputerAwakeWhileAgentsRun - ) - ) - ) - } else if ('keepComputerAwakeWhileAgentsRun' in sanitizedUpdates) { - Object.assign( - sanitizedUpdates, - computerAwakeSettingsForMode( - sanitizedUpdates.keepComputerAwakeWhileAgentsRun ? 'auto' : 'off' - ) - ) - } - if ('autoRenameBranchFromWorkDefaultedOn' in sanitizedUpdates) { - sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true - } - if ('terminalCursorStyle' in sanitizedUpdates) { - Object.assign( - sanitizedUpdates, - normalizeTerminalCursorStyleDefault( - { terminalCursorStyle: sanitizedUpdates.terminalCursorStyle }, - { preserveExplicitValue: true } - ) - ) - } - const localUpdates = { ...sanitizedUpdates } - if (runtimeEnvironment) { - delete localUpdates.worktreeVisibilityDefaults - } - const next = mergeSettings(getStoredSettings(), localUpdates, { - preserveAutoRenameBranchFromWorkUpdate: 'autoRenameBranchFromWork' in sanitizedUpdates - }) - writeStoredSettings(next) - return settingsForActiveVisibilityOwner( - await syncRuntimeBackedSettings(sanitizedUpdates, next) - ) - }, - setActiveRuntimeEnvironmentPreference: async ({ environmentId }) => { - const requestedEnvironmentId = environmentId?.trim() || null - const activeRuntimeEnvironmentId = requestedEnvironmentId - ? resolveEnvironment(requestedEnvironmentId).id - : null - const next = mergeSettings(getStoredSettings(), { - activeRuntimeEnvironmentId - }) - writeStoredSettings(next, activeRuntimeEnvironmentId) - return next - }, - updatePRBotAuthorOverride: (args) => updateRuntimePRBotAuthorOverride(args), - listFonts: () => Promise.resolve([]), - onChanged: () => noopUnsubscribe - } satisfies Partial as unknown as WebSettingsApi, - agentAwake: { - getStatus: async () => { - const settings = getStoredSettings() - return { - mode: normalizeComputerAwakeMode( - settings.computerAwakeMode, - settings.keepComputerAwakeWhileAgentsRun - ), - active: false - } - }, - onChanged: () => noopUnsubscribe - }, + ...createWebAppApi(), + ...createWebStarNagApi(), + ...createWebPlatformApi(), + ...createWebWorkspacePortsApi(), + ...createWebOrcaProfilesApi(), + ...createWebE2EApi(), + ...createWebSettingsApi(), keybindings: createWebKeybindingsApi(), ui: createWebUiApi(), - crashReports: { - getLatestPending: () => Promise.resolve(null), - getLatestReport: () => Promise.resolve(null), - dismiss: () => Promise.resolve(null), - recordRendererError: () => Promise.resolve({ ok: true, report: null, deduped: true }), - recordBreadcrumb: () => {}, - submit: () => - Promise.resolve({ - ok: false, - status: null, - error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.') - }), - copyLatestDiagnostics: () => - Promise.resolve({ - ok: false, - error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.') - }), - // Why: no Electron process on web; the caller falls back to performance.memory. - readHeapStatistics: () => null - }, - diagnostics: { - getStatus: () => - Promise.resolve({ - localFileEnabled: false, - bundleEnabled: false, - traceFilePath: '', - traceFamilySize: 0 - }), - collectBundle: () => Promise.reject(new Error('Review files are unavailable on web.')), - openBundlePreview: () => Promise.reject(new Error('Review files are unavailable on web.')), - discardBundlePreview: () => Promise.resolve(), - uploadBundle: () => Promise.reject(new Error('Sending diagnostics is unavailable on web.')), - deleteBundle: () => Promise.reject(new Error('Sent diagnostics are unavailable on web.')) - }, - session: { - // Mirrors desktop bridge: non-local hosts persist under a host-suffixed key so their sessions stay isolated from local. - get: (hostId) => Promise.resolve(getStoredWorkspaceSession(hostId)), - set: async (session, hostId) => { - writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) - }, - patch: async (patch: WorkspaceSessionPatch, hostId) => { - writeJson( - sessionStorageKeyForHost(hostId), - sanitizeWebRuntimeWorkspaceSession({ - ...getStoredWorkspaceSession(hostId), - ...patch - }) - ) - }, - // localStorage writes synchronously, so there is no deferred web flush. - flush: async () => {}, - readTerminalScrollback: () => null, - setSync: (session, hostId) => { - writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) - } - }, - onboarding: { - get: () => Promise.resolve(getStoredOnboarding()), - update: async (updates) => { - const current = getStoredOnboarding() - const next: OnboardingState = { - ...current, - ...updates, - flowVersion: ONBOARDING_FLOW_VERSION, - checklist: { - ...current.checklist, - ...updates.checklist - } - } - writeJson(ONBOARDING_STORAGE_KEY, next) - return next - } - }, - cache: { - getGitHub: () => - Promise.resolve( - readJson(GITHUB_CACHE_STORAGE_KEY, { - pr: {}, - issue: {} - }) - ), - setGitHub: async ({ cache }) => { - writeJson(GITHUB_CACHE_STORAGE_KEY, cache) - } - }, - runtime: createRuntimeApi(), - nativeChat: createNativeChatApi(), + ...createWebDiagnosticsApi(), + ...createWebWorkspaceSessionApi(), + ...createWebOnboardingApi(), + ...createWebGithubCacheApi(), + runtime: createWebRuntimeApi(), + nativeChat: createWebNativeChatApi(), runtimeEnvironments: createRuntimeEnvironmentsApi(), repos: createReposApi(), worktrees: createWorktreesApi(), @@ -900,7 +102,7 @@ function createWebPreloadApi(): Partial { memory: { getSnapshot: () => Promise.resolve(createEmptyMemorySnapshot()) }, - aiVault: createAiVaultApi(), + aiVault: createWebAiVaultApi(), preflight: createPreflightApi(), notifications: createNotificationsApi(), rateLimits: createRateLimitsApi(), @@ -911,8 +113,6 @@ function createWebPreloadApi(): Partial { cli: createCliApi(), agentHooks: createAgentHooksApi(), macosTccPrompts: createMacosTccPromptsApi(), - // Why: the desktop derives this from the host filesystem, which the web - // client has no view of; reporting synced keeps the warning banner silent. codexConfigSync: { status: () => Promise.resolve({ state: 'synced', reason: null, systemConfigPath: '' } as const) @@ -934,3664 +134,8 @@ function createWebPreloadApi(): Partial { gitBash: { isAvailable: () => callRuntimeResult('host.gitBash.isAvailable').catch(() => false) }, - agentStatus: { - onSet: () => noopUnsubscribe, - onClear: () => noopUnsubscribe, - getSnapshot: () => Promise.resolve([]), - inferInterrupt: () => Promise.resolve(false), - inferQuestionAnswered: () => Promise.resolve(false), - onMigrationUnsupported: () => noopUnsubscribe, - onMigrationUnsupportedClear: () => noopUnsubscribe, - onLegacyWorkerTerminalRecovery: () => noopUnsubscribe, - getMigrationUnsupportedSnapshot: () => Promise.resolve([]), - drop: () => {}, - reconcileEndedProcess: () => {}, - dropByTabPrefix: () => {}, - retirePaneAuthority: () => {}, - restorePaneAuthority: () => {}, - transferPaneAuthority: () => {} - }, - mobile: { - listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }), - getPairingQR: () => Promise.resolve({ available: false }), - getWindowsFirewallStatus: () => Promise.resolve({ supported: false }), - repairWindowsFirewall: () => Promise.resolve({ ok: false, reason: 'unsupported' }), - openWindowsNetworkSettings: () => Promise.resolve(false), - getRuntimePairingUrl: () => Promise.resolve({ available: false }), - listDevices: () => Promise.resolve({ devices: [] }), - revokeDevice: () => Promise.resolve({ revoked: false }), - listRuntimeAccessGrants: () => Promise.resolve({ grants: [] }), - revokeRuntimeAccess: () => Promise.resolve({ revoked: false }), - isWebSocketReady: () => - Promise.resolve({ ready: Boolean(activeEnvironment), endpoint: null }), - getRelayStatus: () => Promise.resolve({ status: 'offline' as const }), - onRelayStatusChanged: () => noopUnsubscribe, - consumePendingUnpairedDeviceAuthFailure: () => Promise.resolve(false), - onUnpairedDeviceAuthFailure: () => noopUnsubscribe - }, - telemetryTrack: () => Promise.resolve(), - telemetrySetOptIn: () => Promise.resolve(), - telemetryGetConsentState: () => - Promise.resolve({ optedIn: false, source: 'default', blockedByEnv: false } as never), - telemetryAcknowledgeBanner: () => Promise.resolve() + ...createWebAgentStatusApi(), + ...createWebMobileApi(), + ...createWebTelemetryApi() } } - -function createEmptyWebKeybindingDocument(): WebKeybindingDocument { - return { - version: 1, - keybindings: {}, - platforms: { - darwin: {}, - linux: {}, - win32: {} - } - } -} - -function getWebKeybindingPlatform(): KeybindingPlatform { - return getKeybindingPlatform(getBrowserPlatform()) -} - -function isJsonObject(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - -function normalizeStoredWebOverrides( - value: unknown, - section: string, - diagnostics: KeybindingFileDiagnostic[] -): KeybindingOverrides { - if (value === undefined) { - return {} - } - if (!isJsonObject(value)) { - diagnostics.push({ - severity: 'error', - section, - message: translate('auto.web.web.preload.api.d2e43e426a', '{{value0}} must be an object.', { - value0: section - }) - }) - return {} - } - - const overrides: KeybindingOverrides = {} - for (const [actionId, rawBindings] of Object.entries(value)) { - if (!isKeybindingActionId(actionId)) { - diagnostics.push({ - severity: 'warning', - section, - actionId, - message: translate( - 'auto.web.web.preload.api.36761d9604', - 'Unknown keybinding action "{{value0}}" was ignored.', - { value0: actionId } - ) - }) - continue - } - if ( - !Array.isArray(rawBindings) || - !rawBindings.every((binding) => typeof binding === 'string') - ) { - diagnostics.push({ - severity: 'error', - section, - actionId, - message: translate( - 'auto.web.web.preload.api.10898045f3', - 'Shortcut for "{{value0}}" was ignored: Use a string array.', - { value0: actionId } - ) - }) - continue - } - const normalized = normalizeKeybindingArrayForAction(actionId, rawBindings) - if (!Array.isArray(normalized)) { - const error = normalized.ok ? 'Unable to parse shortcut.' : normalized.error - diagnostics.push({ - severity: 'error', - section, - actionId, - message: translate( - 'auto.web.web.preload.api.76122208ca', - 'Shortcut for "{{value0}}" was ignored: {{value1}}', - { value0: actionId, value1: error } - ) - }) - continue - } - overrides[actionId] = normalized - } - return overrides -} - -function normalizeWebPlatformOverrides( - value: unknown, - diagnostics: KeybindingFileDiagnostic[] -): Partial> { - if (value === undefined) { - return {} - } - if (!isJsonObject(value)) { - diagnostics.push({ - severity: 'error', - section: 'platforms', - message: translate( - 'auto.web.web.preload.api.0a69fcd8bc', - 'platforms must be an object with darwin, linux, or win32 sections.' - ) - }) - return {} - } - - const result: Partial> = {} - for (const [platform, overrides] of Object.entries(value)) { - if (!WEB_KEYBINDING_PLATFORMS.includes(platform as KeybindingPlatform)) { - diagnostics.push({ - severity: 'warning', - section: `platforms.${platform}`, - message: translate( - 'auto.web.web.preload.api.32f15bdb0f', - 'Unknown platform "{{value0}}" was ignored.', - { value0: platform } - ) - }) - continue - } - result[platform as KeybindingPlatform] = normalizeStoredWebOverrides( - overrides, - `platforms.${platform}`, - diagnostics - ) - } - return result -} - -function removeConflictingWebOverrides( - platform: KeybindingPlatform, - overrides: KeybindingOverrides, - diagnostics: KeybindingFileDiagnostic[] -): KeybindingOverrides { - let next = { ...overrides } - for (let attempt = 0; attempt < 20; attempt++) { - const conflicts = findKeybindingConflicts(platform, next) - const conflictingOverrides = new Set() - for (const conflict of conflicts) { - for (const actionId of conflict.actionIds) { - if (Object.hasOwn(next, actionId)) { - conflictingOverrides.add(actionId) - } - } - } - if (conflictingOverrides.size === 0) { - return next - } - for (const actionId of conflictingOverrides) { - delete next[actionId] - } - diagnostics.push({ - severity: 'error', - message: translate( - 'auto.web.web.preload.api.52bee9d8a0', - 'Conflicting custom shortcuts were ignored: {{value0}}.', - { - value0: Array.from(conflictingOverrides) - .map((actionId) => actionId) - .join(', ') - } - ) - }) - } - return next -} - -function readWebKeybindingDocument(): WebKeybindingDocument { - const document = readJson(KEYBINDINGS_STORAGE_KEY, createEmptyWebKeybindingDocument()) - return { - version: 1, - keybindings: isJsonObject(document.keybindings) - ? (document.keybindings as KeybindingOverrides) - : {}, - platforms: isJsonObject(document.platforms) - ? (document.platforms as Partial>) - : {} - } -} - -function getWebKeybindingSnapshot(): KeybindingFileSnapshot { - const platform = getWebKeybindingPlatform() - const diagnostics: KeybindingFileDiagnostic[] = [] - const document = readWebKeybindingDocument() - const commonOverrides = normalizeStoredWebOverrides( - document.keybindings, - 'keybindings', - diagnostics - ) - const platformOverrides = normalizeWebPlatformOverrides(document.platforms, diagnostics) - const overrides = removeConflictingWebOverrides( - platform, - { - ...commonOverrides, - ...platformOverrides[platform] - }, - diagnostics - ) - - return { - path: 'Browser local storage', - platform, - exists: window.localStorage.getItem(KEYBINDINGS_STORAGE_KEY) !== null, - overrides, - commonOverrides, - platformOverrides, - diagnostics - } -} - -function writeWebKeybindingAction( - actionId: KeybindingActionId, - bindings: string[] | null -): KeybindingFileSnapshot { - if (!isKeybindingActionId(actionId)) { - throw new Error(`Unknown keybinding action "${String(actionId)}".`) - } - const normalizedBindings = - bindings === null ? null : normalizeKeybindingArrayForAction(actionId, bindings) - if (normalizedBindings !== null && !Array.isArray(normalizedBindings)) { - throw new Error(normalizedBindings.ok ? 'Unable to parse shortcut.' : normalizedBindings.error) - } - - const platform = getWebKeybindingPlatform() - const currentSnapshot = getWebKeybindingSnapshot() - const candidateOverrides = { ...currentSnapshot.overrides } - if (normalizedBindings === null) { - delete candidateOverrides[actionId] - } else { - candidateOverrides[actionId] = normalizedBindings - } - const blockingConflict = findKeybindingConflicts(platform, candidateOverrides).find((conflict) => - conflict.actionIds.includes(actionId) - ) - if (blockingConflict) { - throw new Error( - `${formatKeybindingList([blockingConflict.binding], platform)} conflicts with another shortcut.` - ) - } - - const activePlatform: KeybindingOverrides = { ...currentSnapshot.platformOverrides[platform] } - if (normalizedBindings === null) { - delete activePlatform[actionId] - } else { - activePlatform[actionId] = normalizedBindings - } - - writeJson(KEYBINDINGS_STORAGE_KEY, { - version: 1, - keybindings: currentSnapshot.commonOverrides, - platforms: { - ...currentSnapshot.platformOverrides, - darwin: currentSnapshot.platformOverrides.darwin ?? {}, - linux: currentSnapshot.platformOverrides.linux ?? {}, - win32: currentSnapshot.platformOverrides.win32 ?? {}, - [platform]: activePlatform - } - } satisfies WebKeybindingDocument) - - const snapshot = getWebKeybindingSnapshot() - notifyWebKeybindingListeners(snapshot) - return snapshot -} - -function notifyWebKeybindingListeners(snapshot: KeybindingFileSnapshot): void { - for (const listener of webKeybindingListeners) { - listener(snapshot) - } -} - -function createWebKeybindingsApi(): WebKeybindingsApi { - return { - get: () => Promise.resolve(getWebKeybindingSnapshot()), - ensureFile: () => Promise.resolve(getWebKeybindingSnapshot()), - setAction: async ({ actionId, bindings }) => writeWebKeybindingAction(actionId, bindings), - reload: () => { - const snapshot = getWebKeybindingSnapshot() - notifyWebKeybindingListeners(snapshot) - return Promise.resolve(snapshot) - }, - openFile: () => Promise.resolve(getWebKeybindingSnapshot()), - revealFile: () => Promise.resolve(getWebKeybindingSnapshot()), - onChanged: (callback) => { - webKeybindingListeners.add(callback) - const onStorage = (event: StorageEvent): void => { - if (event.key === KEYBINDINGS_STORAGE_KEY) { - callback(getWebKeybindingSnapshot()) - } - } - window.addEventListener('storage', onStorage) - return () => { - webKeybindingListeners.delete(callback) - window.removeEventListener('storage', onStorage) - } - } - } -} - -// Why: web has no IPC for native-chat transcripts, so route readSession/subscribe through runtime RPC (as mobile does). -function createNativeChatApi(): NativeChatApi { - return { - readSession: async (agent, sessionId, limit, transcriptPath) => - parseRuntimeNativeChatReadSessionResult( - await callRuntimeResult('nativeChat.readSession', { - agent, - sessionId, - limit, - transcriptPath - }) - ), - subscribe: (args, onFrame) => { - // No paired runtime yet: return a no-op teardown so the chat view mounts cleanly; only the not-paired case is swallowed. - const environment = requireActiveEnvironmentOrNull() - if (!environment) { - onFrame({ - type: 'snapshot', - messages: [], - hasMore: false, - error: translate( - 'components.native-chat.state.pairHost', - 'Pair a host to view agent chat history.' - ) - }) - return () => {} - } - let handle: { unsubscribe: () => void } | null = null - let cancelled = false - let receivedInitial = false - void getClientForEnvironment(environment) - .subscribe( - 'nativeChat.subscribe', - { - agent: args.agent, - sessionId: args.sessionId, - subscriptionId: args.subscriptionId, - transcriptPath: args.transcriptPath, - limit: args.limit - }, - { - onResponse: (response) => { - if (cancelled) { - return - } - if (!response.ok) { - if (!receivedInitial) { - receivedInitial = true - onFrame({ - type: 'snapshot', - messages: [], - hasMore: false, - error: response.error.message - }) - } - return - } - const result = response.result as { - type?: string - messages?: NativeChatAppendedMessages - hasMore?: boolean - error?: string - lifecycle?: unknown - } - const lifecycle = parseRuntimeNativeChatTurnLifecycle(result?.lifecycle) - if ( - (result?.type === 'appended' || - result?.type === 'snapshot' || - result?.type === 'replacement') && - Array.isArray(result.messages) - ) { - if (!receivedInitial) { - receivedInitial = true - onFrame({ - type: 'snapshot', - messages: result.messages, - hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300), - ...(result.error ? { error: result.error } : {}), - ...(lifecycle ? { lifecycle } : {}) - }) - } else if (result.type === 'snapshot') { - onFrame({ - type: 'snapshot', - messages: result.messages, - hasMore: result.hasMore ?? false, - ...(result.error ? { error: result.error } : {}), - ...(lifecycle ? { lifecycle } : {}) - }) - } else { - onFrame( - result.type === 'replacement' - ? { - type: 'replacement', - messages: result.messages, - hasMore: result.hasMore ?? false, - ...(lifecycle ? { lifecycle } : {}) - } - : { - type: 'appended', - messages: result.messages, - ...(lifecycle ? { lifecycle } : {}) - } - ) - } - } else if (!receivedInitial) { - // Why: an unrecognized ok payload never flips receivedInitial, stranding the view on 'loading'; settle it empty instead. - receivedInitial = true - onFrame({ - type: 'snapshot', - messages: [], - hasMore: false, - ...(result?.error ? { error: result.error } : {}) - }) - } - } - }, - { - // Why: unsubscribe reaps the fs-watcher on view-toggle (leak fix); echo the pane token so two panes don't tear down each other's watcher. - buildUnsubscribe: () => - buildNativeChatUnsubscribe(args.agent, args.sessionId, args.subscriptionId) - } - ) - .then((h) => { - if (cancelled) { - h.unsubscribe() - } else { - handle = h - } - }) - .catch((err: unknown) => { - if (!cancelled && !receivedInitial) { - receivedInitial = true - onFrame({ - type: 'snapshot', - messages: [], - hasMore: false, - error: err instanceof Error ? err.message : String(err) - }) - } - }) - return () => { - cancelled = true - handle?.unsubscribe() - } - } - } -} - -function createRuntimeApi(): NonNullable['runtime']> { - return { - syncWindowGraph: async (_graph: RuntimeSyncWindowGraph) => getRemoteRuntimeStatus(), - getStatus: () => getRemoteRuntimeStatus(), - call: ({ method, params }) => callRuntimeEnvelope(method, params), - getTerminalFitOverrides: () => Promise.resolve([]), - getTerminalDrivers: () => Promise.resolve([]), - getBrowserDrivers: () => Promise.resolve([]), - restoreTerminalFit: () => Promise.resolve({ restored: false }), - reclaimBrowserForDesktop: () => Promise.resolve({ reclaimed: false }), - onTerminalFitOverrideChanged: () => noopUnsubscribe, - onTerminalDriverChanged: () => noopUnsubscribe, - onNativeChatLaunchDraftResolved: () => noopUnsubscribe, - onBrowserDriverChanged: () => noopUnsubscribe - } -} - -function createRuntimeEnvironmentsApi(): NonNullable['runtimeEnvironments']> { - return { - list: async () => { - const environment = requireActiveEnvironmentOrNull() - return environment ? [redactStoredWebRuntimeEnvironment(environment)] : [] - }, - addFromPairingCode: async ({ name, pairingCode }) => { - const offer = parseWebPairingInput(pairingCode) - if (!offer) { - throw new Error('Invalid Orca pairing code.') - } - const previousEnvironment = activeEnvironment - closeActiveRuntimeClients() - activeEnvironment = createStoredWebRuntimeEnvironment({ name, offer, previousEnvironment }) - manuallyDisconnectedEnvironmentIds.clear() - saveStoredWebRuntimeEnvironment(activeEnvironment) - return { environment: redactStoredWebRuntimeEnvironment(activeEnvironment) } - }, - verifyAndAddFromPairingCode: async ({ name, pairingCode, allowLoopback }) => { - const parsed = parseHostAccessLink(pairingCode) - if (!parsed.ok) { - return { - ok: false, - kind: 'access-link-invalid', - message: translateHostAccessLinkError(parsed.kind) - } - } - if (parsed.value.endpointKind === 'loopback' && !allowLoopback) { - return { - ok: false, - kind: 'host-unreachable', - message: translate( - 'auto.web.webPreloadApi.loopbackPairingBlocked', - 'This access link points back to this device.' - ) - } - } - let client: WebRuntimeClient | null = null - let runtimeStatus: RuntimeStatus - try { - client = new WebRuntimeClient(parsed.value.pairing) - const response = (await client.call('status.get', undefined, { - timeoutMs: 15_000 - })) as RuntimeRpcResponse - if (!response.ok) { - return { - ok: false, - kind: 'connection-interrupted', - message: response.error.message - } - } - const statusVerification = verifyRemotePairingRuntimeStatus(response.result) - if (!statusVerification.ok) { - return statusVerification - } - runtimeStatus = statusVerification.runtimeStatus - } catch (error) { - if (error instanceof Error && error.message.startsWith('Invalid public key')) { - return { - ok: false, - kind: 'access-link-invalid', - message: translate( - 'auto.web.webPreloadApi.remotePairingInvalidDetails', - 'This access link contains invalid connection details.' - ) - } - } - if ( - isWebRuntimeUnauthorizedError(error) || - (error instanceof Error && error.message.startsWith('Unauthorized.')) - ) { - return { - ok: false, - kind: 'access-link-invalid', - message: error.message - } - } - return { - ok: false, - kind: 'host-unreachable', - message: translate( - 'auto.web.webPreloadApi.remotePairingUnreachable', - 'Cannot reach Orca at {{endpoint}}.', - { endpoint: parsed.value.displayEndpoint } - ) - } - } finally { - client?.close() - } - const usesSshTunnel = parsed.value.endpointKind === 'loopback' && allowLoopback === true - const nextEnvironment = { - ...createStoredWebRuntimeEnvironment({ - name, - offer: parsed.value.pairing, - previousEnvironment: activeEnvironment, - ...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {}) - }), - ...(runtimeStatus.pairedDeviceId ? { pairedDeviceId: runtimeStatus.pairedDeviceId } : {}) - } - // Why: a browser storage failure must leave the currently active host usable. - try { - saveStoredWebRuntimeEnvironment(nextEnvironment) - } catch { - return { - ok: false, - kind: 'environment-save-failed', - message: translate( - 'auto.web.webPreloadApi.remotePairingSaveFailed', - 'Orca verified the host but could not save it. Check browser storage and try again.' - ) - } - } - manuallyDisconnectedEnvironmentIds.clear() - closeActiveRuntimeClients() - activeEnvironment = nextEnvironment - return { - ok: true, - environment: redactStoredWebRuntimeEnvironment(nextEnvironment), - runtimeStatus - } - }, - resolve: async ({ selector }) => - redactStoredWebRuntimeEnvironment(resolveEnvironment(selector)), - remove: async ({ selector }) => { - const environment = resolveEnvironment(selector) - if (activeEnvironment?.id === environment.id) { - removeActiveRuntimeEnvironment() - } - manuallyDisconnectedEnvironmentIds.delete(environment.id) - return { removed: redactStoredWebRuntimeEnvironment(environment) } - }, - disconnect: async ({ selector }) => { - const environment = resolveEnvironment(selector) - if (activeEnvironment?.id === environment.id) { - manuallyDisconnectedEnvironmentIds.add(environment.id) - disconnectActiveRuntimeEnvironment() - } - return { disconnected: redactStoredWebRuntimeEnvironment(environment) } - }, - connect: ({ selector, timeoutMs }) => { - const environment = resolveEnvironment(selector) - manuallyDisconnectedEnvironmentIds.delete(environment.id) - return callEnvironmentEnvelope( - environment.id, - 'status.get', - undefined, - timeoutMs - ) - }, - getStatus: ({ selector, timeoutMs }) => - callEnvironmentEnvelope(selector, 'status.get', undefined, timeoutMs), - call: ({ selector, method, params, timeoutMs }) => - callEnvironmentEnvelope(selector, method, params, timeoutMs), - subscribe: async ({ selector, method, params, timeoutMs }, callbacks) => { - const environment = resolveEnvironment(selector) - const client = getClientForEnvironment(environment) - const subscription = await client.subscribe(method, params, callbacks, { timeoutMs }) - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - subscription.unsubscribe() - throw new Error('runtime_manually_disconnected') - } - return subscription - } - } -} - -function createAiVaultApi(): NonNullable['aiVault']> { - return { - listSessions: (args?: AiVaultListArgs) => { - const environment = requireActiveEnvironment() - const executionHostId = toRuntimeExecutionHostId(environment.id) - const requestedScope = normalizeExecutionHostScope( - args?.executionHostScope ?? executionHostId - ) - if (requestedScope !== 'all' && requestedScope !== executionHostId) { - return Promise.resolve(webAiVaultUnavailableResult(requestedScope)) - } - // Why: no local filesystem in the browser, so every history scan runs on and is stamped as the paired runtime host. - return callRuntimeResult('aiVault.listSessions', { - limit: args?.limit, - force: args?.force, - scopePaths: args?.scopePaths, - executionHostId - }) - }, - resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => { - const environment = requireActiveEnvironment() - const executionHostId = toRuntimeExecutionHostId(environment.id) - if ( - args.executionHostScope && - normalizeExecutionHostScope(args.executionHostScope) !== executionHostId - ) { - return Promise.resolve({ titles: [] }) - } - return callRuntimeResult('aiVault.resolveSessionTitles', { - requests: args.requests - }).catch(() => ({ titles: [] })) - }, - // Why: the runtime RPC transport has no cancel verb, so the in-flight scan - // settles on its own timeout. The renderer's refreshId guard already drops - // the late result; this only means web pays for a scan nobody reads. - cancelListSessions: () => Promise.resolve(), - prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) => - callRuntimeResult('aiVault.prepareSessionResume', args), - // Why: no server-side RPC for subagent transcript listing yet, so report an empty (not erroring) result. - listSubagentSessions: () => Promise.resolve({ sessions: [], issues: [] }), - // Why: full first-prompt re-parse is local-FS only; web/runtime falls back to preview text. - getFirstUserPrompt: () => Promise.resolve({ prompt: null }), - // Why: session deletion is local-only and has no runtime RPC; a web - // client's sessions are runtime-hosted, so report the same non-local - // rejection the UI already gates on rather than pretend to delete. - deleteSession: (args: AiVaultDeleteSessionArgs) => - Promise.resolve({ - outcome: 'rejected', - agent: args.agent, - reason: 'non-local-host' as const - }), - onWindowFocused: () => noopUnsubscribe - } -} - -function webAiVaultUnavailableResult(executionHostId: ExecutionHostId): AiVaultListResult { - return { - sessions: [], - issues: [ - { - executionHostId, - agent: 'codex', - path: executionHostId, - message: translate( - 'auto.web.webPreloadApi.aiVaultUnavailableForHost', - 'Agent Session History is not available for this execution host.' - ) - } - ], - scannedAt: new Date().toISOString() - } -} - -function createReposApi(): NonNullable['repos']> { - return { - list: async () => { - const owned = await callRuntimeResultWithOwner<{ repos: Repo[] }>('repo.list') - return owned.result.repos.map((repo) => withRuntimeRepoOwner(repo, owned.hostId)) - }, - add: async ({ path, kind }) => { - invalidateRuntimeWorktreeCaches() - const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>( - 'repo.add', - { path, kind } - ) - return withRuntimeRepoMutationOwner(owned.result, owned.hostId) - }, - remove: async ({ repoId }) => { - await callRuntimeResult('repo.rm', { repo: repoId }) - invalidateRuntimeWorktreeCaches() - }, - // Why: host-scoped forget targets a desktop-owned SSH host; a paired web client has one runtime and no ghost-host state. - removeForHost: () => { - throw new Error('Forgetting a host is unavailable in paired web clients.') - }, - reorder: async ({ orderedIds }) => callRuntimeResult('repo.reorder', { orderedIds }), - // Why: this persists desktop-owned local/SSH rows; paired web clients own one runtime and use repo.reorder directly. - reorderForHost: async () => { - throw new Error('Host-scoped project reordering is unavailable in paired web clients.') - }, - update: async ({ repoId, updates }) => { - const owned = await callRuntimeResultWithOwner<{ repo: Repo }>('repo.update', { - repo: repoId, - updates - }) - return withRuntimeRepoOwner(owned.result.repo, owned.hostId) - }, - pickFolder: () => Promise.resolve(null), - pickFolders: () => Promise.resolve([]), - pickDirectory: () => Promise.resolve(null), - clone: async ({ url, destination }) => { - invalidateRuntimeWorktreeCaches() - const owned = await callRuntimeResultWithOwner<{ repo: Repo }>( - 'repo.clone', - { url, destination }, - 10 * 60_000 - ) - return withRuntimeRepoOwner(owned.result.repo, owned.hostId) - }, - cloneRemote: async () => { - // Why: SSH relay cloning is owned by the desktop main process; paired web clients can't run that local IPC path. - throw new Error('SSH clone is unavailable in paired web clients.') - }, - createRemote: async () => { - // Why: SSH relay project creation is owned by the desktop main process; paired web clients can't use local SSH IPC. - throw new Error('Creating projects on SSH hosts is unavailable in paired web clients.') - }, - cloneAbort: () => Promise.resolve(), - addRemote: async ({ remotePath, displayName, kind }) => { - invalidateRuntimeWorktreeCaches() - const owned = await callRuntimeResultWithOwner<{ repo: Repo }>('repo.add', { - path: remotePath, - kind - }) - const result = { - repo: withRuntimeRepoOwner(owned.result.repo, owned.hostId) - } - if (!displayName) { - return result - } - assertActiveEnvironment(owned.environmentId) - return { - repo: await createReposApi().update({ - repoId: result.repo.id, - updates: { displayName } - }) - } - }, - create: async ({ parentPath, name, kind }) => { - invalidateRuntimeWorktreeCaches() - const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>( - 'repo.create', - { parentPath, name, kind } - ) - return withRuntimeRepoMutationOwner(owned.result, owned.hostId) - }, - isGitAvailable: async () => - (await callRuntimeResult<{ available: boolean }>('repo.gitAvailable')).available, - getDefaultCreateProjectParent: async () => { - const result = await callRuntimeResult<{ resolvedPath: string }>('files.browseServerDir', { - path: '~' - }) - return getDefaultCreateProjectParent(result.resolvedPath) - }, - onCloneProgress: () => noopUnsubscribe, - getGitUsername: () => Promise.resolve(''), - getBaseRefDefault: async ({ repoId }) => - callRuntimeResult('repo.baseRefDefault', { repo: repoId }), - searchBaseRefs: async ({ repoId, query, limit }) => - ( - await callRuntimeResult<{ refs: string[] }>('repo.searchRefs', { - repo: repoId, - query, - limit - }) - ).refs, - searchBaseRefDetails: async ({ repoId, query, limit }) => { - const result = await callRuntimeResult<{ - refs: string[] - refDetails?: { refName: string; localBranchName: string }[] - }>('repo.searchRefs', { - repo: repoId, - query, - limit - }) - return result.refDetails ?? result.refs.map(legacyBaseRefSearchResult) - }, - onChanged: () => noopUnsubscribe - } -} - -function createWorktreesApi(): NonNullable['worktrees']> { - return { - list: async ({ repoId }) => { - const owned = await callRuntimeResultWithOwner<{ worktrees: Worktree[] }>('worktree.list', { - repo: repoId, - limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT - }) - return owned.result.worktrees.map((worktree) => - withRuntimeWorktreeOwner(worktree, owned.hostId) - ) - }, - // Why the catch: a host predating this method answers `method_not_found`, and an empty list - // degrades the suggestion to the pre-existing behavior rather than blocking workspace create. - listRetiredNames: async ({ repoId }) => { - try { - return readRetiredNameRegistryForRepo( - await callRuntimeResult('worktree.listRetiredNames', { repo: repoId }), - repoId - ) - } catch { - return EMPTY_RETIRED_NAME_REGISTRY - } - }, - listDetected: async ({ repoId }) => callRuntimeDetectedWorktrees(repoId), - listAll: () => listAllRuntimeWorktrees(), - create: async (args) => { - invalidateRuntimeWorktreeCaches() - const owned = await callRuntimeResultWithOwner<{ worktree: Worktree }>('worktree.create', { - repo: args.repoId, - name: args.name, - // Absent means user-typed, which is what the host must assume — so send it only when true. - ...(args.nameWasGenerated ? { nameWasGenerated: true } : {}), - baseBranch: args.baseBranch, - compareBaseRef: args.compareBaseRef, - branchNameOverride: args.branchNameOverride, - linkedIssue: args.linkedIssue, - linkedPR: args.linkedPR, - linkedLinearIssue: args.linkedLinearIssue, - linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId, - linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey, - linkedGitLabIssue: args.linkedGitLabIssue, - linkedGitLabMR: args.linkedGitLabMR, - linkedBitbucketPR: args.linkedBitbucketPR, - linkedAzureDevOpsPR: args.linkedAzureDevOpsPR, - linkedGiteaPR: args.linkedGiteaPR, - displayName: args.displayName, - sparseCheckout: args.sparseCheckout, - pushTarget: args.pushTarget, - setupDecision: args.setupDecision, - createdWithAgent: args.createdWithAgent, - pendingFirstAgentMessageRename: args.pendingFirstAgentMessageRename, - ...(args.startup - ? { - startupCommand: args.startup.command, - ...(args.startup.env ? { startupEnv: args.startup.env } : {}), - ...(args.startup.launchConfig - ? { startupLaunchConfig: args.startup.launchConfig } - : {}), - ...(args.startup.startupCommandDelivery - ? { startupCommandDelivery: args.startup.startupCommandDelivery } - : {}), - activate: true - } - : {}), - parentWorkspace: args.parentWorkspace, - workspaceStatus: args.workspaceStatus, - manualOrder: args.manualOrder, - automationProvenanceRequest: args.automationProvenanceRequest - }) - return { - ...owned.result, - worktree: withRuntimeWorktreeOwner(owned.result.worktree, owned.hostId) - } - }, - // Why: adoption verifies a desktop-owned hidden SSH target and is intentionally not a remote-server operation. - adoptProvisionedRoot: () => - Promise.reject( - new Error('Provisioned-root recipes require a direct SSH connection from the desktop app.') - ), - // Why: the runtime create path emits no two-phase progress, so the panel falls back to an indeterminate spinner. - onCreateProgress: () => noopUnsubscribe, - prefetchCreateBase: async ({ repoId, baseBranch }) => { - await callRuntimeResult('worktree.prefetchCreateBase', { - repo: repoId, - baseBranch - }) - }, - resolvePrBase: async ({ repoId, prNumber, headRefName, baseRefName, isCrossRepository }) => - callRuntimeResult('worktree.resolvePrBase', { - repo: repoId, - prNumber, - headRefName, - baseRefName, - isCrossRepository - }), - resolveMrBase: async ({ repoId, mrIid, sourceBranch, targetBranch, isCrossRepository }) => - callRuntimeResult('worktree.resolveMrBase', { - repo: repoId, - mrIid, - sourceBranch, - targetBranch, - isCrossRepository - }), - remove: async ({ worktreeId, hostId, force, allowUnverifiedPtyStop, skipArchive }) => { - invalidateRuntimeWorktreeCaches() - return callRuntimeResult('worktree.rm', { - worktree: toRuntimeWorktreeSelector(worktreeId), - ...(hostId ? { hostId } : {}), - force, - // Why (#11960): the web client renders the same Force Delete affordances, so - // dropping this field here would leave paired clients permanently wedged. - allowUnverifiedPtyStop, - runHooks: skipArchive !== true - }) - }, - // Why: forget-locally clears a desktop workspace pinned to a dead SSH host; a paired web client has no such ghost state. - forgetLocal: () => { - throw new Error('Forgetting a workspace is unavailable in paired web clients.') - }, - forceDeletePreservedBranch: ({ worktreeId, branchName, expectedHead, hostId }) => - callRuntimeResult('worktree.forceDeleteBranch', { - worktree: toRuntimeWorktreeSelector(worktreeId), - branchName, - expectedHead, - ...(hostId ? { hostId } : {}) - }), - updateMeta: async ({ worktreeId, updates }) => { - const rpcUpdates = - Object.hasOwn(updates, 'pushTarget') && updates.pushTarget === undefined - ? { ...updates, pushTarget: null } - : updates - const owned = await callRuntimeResultWithOwner<{ worktree: Worktree }>('worktree.set', { - worktree: toRuntimeWorktreeSelector(worktreeId), - ...rpcUpdates - }) - return withRuntimeWorktreeOwner(owned.result.worktree, owned.hostId) - }, - listLineage: async () => - await callRuntimeResult<{ - lineage: Record - workspaceLineage?: Record - }>('worktree.lineageList'), - updateLineage: async ({ worktreeId, parentWorktreeId, noParent }) => { - invalidateRuntimeWorktreeCaches() - const result = await callRuntimeResult<{ - worktree: Worktree & { lineage?: WorktreeLineage | null } - }>('worktree.set', { - worktree: toRuntimeWorktreeSelector(worktreeId), - parentWorktree: parentWorktreeId, - noParent - }) - return result.worktree.lineage ?? null - }, - persistSortOrder: async ({ orderedIds }) => { - await callRuntimeResult('worktree.persistSortOrder', { orderedIds }) - }, - // Why: the capture lives in desktop main memory, unexposed over pairing; the dialog falls back to the persisted excerpt. - getBranchRenameFailureOutput: async () => null, - onChanged: () => noopUnsubscribe, - onGitStatusMetadataChanged: () => noopUnsubscribe, - onHeadIdentitiesChanged: () => noopUnsubscribe, - onBaseStatus: () => noopUnsubscribe, - onRemoteBranchConflict: () => noopUnsubscribe - } -} - -function createFileApi(): NonNullable['fs']> { - return { - readDir: async ({ dirPath }) => { - const file = await resolveRuntimeFilePath(dirPath) - return callRuntimeResult('files.readDir', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - relativePath: file.relativePath - }) - }, - readFile: async ({ filePath }) => { - const file = await resolveRuntimeFilePath(filePath) - return callRuntimeResult('files.readPreview', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - relativePath: file.relativePath - }) - }, - readLocalLogTail: async () => { - throw new Error('Local log tailing is unavailable in paired web clients.') - }, - startLocalLogTail: async () => { - throw new Error('Local log tailing is unavailable in paired web clients.') - }, - stopLocalLogTail: async () => {}, - onLocalLogTailChanged: () => noopUnsubscribe, - downloadFile: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - downloadFolder: async () => { - throw new Error('Remote folder download is unavailable in paired web clients.') - }, - saveDownloadedFile: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - startDownloadedFile: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - appendDownloadedFileChunk: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - finishDownloadedFile: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - cancelDownloadedFile: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - listMarkdownDocuments: async ({ rootPath }) => { - const file = await resolveRuntimeFilePath(rootPath) - return callRuntimeResult('files.listMarkdownDocuments', { - worktree: toRuntimeWorktreeSelector(file.worktree.id) - }) - }, - ...createWebFileMutationMethods({ - captureSession: captureWebFileMutationSession - }), - authorizeExternalPath: () => Promise.resolve(), - stat: async ({ filePath }) => { - const file = await resolveRuntimeFilePath(filePath) - return callRuntimeResult('files.stat', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - relativePath: file.relativePath - }) - }, - pathExists: async ({ filePath }) => { - try { - const file = await resolveRuntimeFilePath(filePath) - await callRuntimeResult('files.stat', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - relativePath: file.relativePath - }) - return true - } catch (error) { - if (isMissingPathError(error)) { - return false - } - throw error - } - }, - listFiles: async ({ rootPath, excludePaths }) => { - const file = await resolveRuntimeFilePath(rootPath) - const result = await callRuntimeResult<{ files: { relativePath: string }[] }>( - 'files.listAll', - { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - excludePaths - } - ) - return result.files.map((entry) => entry.relativePath) - }, - cancelListFiles: async () => { - // Why: paired-web lists files over runtime RPC with its own timeout; there's no host-side scan to abort here. - }, - search: async (args) => { - const file = await resolveRuntimeFilePath(args.rootPath) - return callRuntimeResult('files.search', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - query: args.query, - caseSensitive: args.caseSensitive, - wholeWord: args.wholeWord, - useRegex: args.useRegex, - includePattern: args.includePattern, - excludePattern: args.excludePattern, - maxResults: args.maxResults - }) - }, - importExternalPaths: async () => ({ results: [] }), - stageExternalPathsForRuntimeUpload: async () => ({ sources: [] }), - resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }), - watchWorktree: () => Promise.resolve(), - unwatchWorktree: () => Promise.resolve(), - onFsChanged: () => noopUnsubscribe - } -} - -// Why: track the in-flight abortable status request per token so cancelStatus can abort it and close its remote context. -const webGitStatusAbortControllers = new Map() - -async function callAbortableRuntimeStatus( - requestToken: string, - params: unknown -): Promise { - const environment = requireActiveEnvironment() - webGitStatusAbortControllers.get(requestToken)?.abort() - const controller = new AbortController() - webGitStatusAbortControllers.set(requestToken, controller) - try { - const response = await callAbortableRuntimeEnvironment( - environment.id, - 'git.status', - params, - undefined, - controller.signal - ) - updateEnvironmentFromResponse(environment, response) - if (!response.ok) { - throw new Error(response.error.message) - } - return response.result as TResult - } finally { - if (webGitStatusAbortControllers.get(requestToken) === controller) { - webGitStatusAbortControllers.delete(requestToken) - } - } -} - -function createGitApi(): NonNullable['git']> { - return { - status: async ({ - worktreePath, - includeIgnored, - bypassEffectiveUpstreamNegativeCache, - reuseLineStats, - branchLineTotalMergeBase, - requestToken - }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - const params = { - worktree: toRuntimeWorktreeSelector(worktree.id), - includeIgnored, - bypassEffectiveUpstreamNegativeCache, - reuseLineStats, - ...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {}) - } - // Why: no token = nothing to cancel (pooled); a token routes via the subscription bridge so cancelStatus can abort. - if (!requestToken) { - return callRuntimeResult('git.status', params) - } - return callAbortableRuntimeStatus(requestToken, params) - }, - cancelStatus: async ({ requestToken }) => { - webGitStatusAbortControllers.get(requestToken)?.abort() - }, - setStatusUpstreamRefWatch: async () => {}, - submoduleStatus: async ({ worktreePath, submodulePath, area }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.submoduleStatus', { - worktree: toRuntimeWorktreeSelector(worktree.id), - submodulePath, - area - }) - }, - checkIgnored: async ({ worktreePath, paths }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.checkIgnored', { - worktree: toRuntimeWorktreeSelector(worktree.id), - paths - }) - }, - // Why: the "add huge folder to .gitignore" flow is desktop-only; the web runtime makes no offer, so return no candidates. - findHugeFoldersToIgnore: async () => [], - appendGitignore: async () => false, - history: async ({ worktreePath, limit, baseRef }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.history', { - worktree: toRuntimeWorktreeSelector(worktree.id), - limit, - baseRef - }) - }, - conflictOperation: async ({ worktreePath }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.conflictOperation', { - worktree: toRuntimeWorktreeSelector(worktree.id) - }) - }, - abortMerge: async ({ worktreePath }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.abortMerge', { - worktree: toRuntimeWorktreeSelector(worktree.id) - }) - }, - abortRebase: async ({ worktreePath }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.abortRebase', { - worktree: toRuntimeWorktreeSelector(worktree.id) - }) - }, - diff: async ({ worktreePath, filePath, staged, compareAgainstHead }) => { - const file = await resolveRuntimeFilePath(filePath, worktreePath) - return callRuntimeResult('git.diff', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - filePath: file.relativePath, - staged, - compareAgainstHead - }) - }, - branchCompare: async ({ worktreePath, baseRef }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.branchCompare', { - worktree: toRuntimeWorktreeSelector(worktree.id), - baseRef - }) - }, - commitCompare: async ({ worktreePath, commitId }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.commitCompare', { - worktree: toRuntimeWorktreeSelector(worktree.id), - commitId - }) - }, - upstreamStatus: async ({ worktreePath, pushTarget }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.upstreamStatus', { - worktree: toRuntimeWorktreeSelector(worktree.id), - pushTarget - }) - }, - fetch: async ({ worktreePath, pushTarget }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.fetch', { - worktree: toRuntimeWorktreeSelector(worktree.id), - pushTarget - }) - }, - syncFork: async ({ worktreePath, expectedUpstream }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult( - 'git.forkSync', - { - worktree: toRuntimeWorktreeSelector(worktree.id), - expectedUpstream - }, - 60_000 - ) - }, - push: async ({ worktreePath, publish, pushTarget }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.push', { - worktree: toRuntimeWorktreeSelector(worktree.id), - publish, - pushTarget - }) - }, - pull: async ({ worktreePath, pushTarget }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.pull', { - worktree: toRuntimeWorktreeSelector(worktree.id), - pushTarget - }) - }, - fastForward: async ({ worktreePath, pushTarget }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.fastForward', { - worktree: toRuntimeWorktreeSelector(worktree.id), - pushTarget - }) - }, - rebaseFromBase: async ({ worktreePath, baseRef }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.rebaseFromBase', { - worktree: toRuntimeWorktreeSelector(worktree.id), - baseRef - }) - }, - branchDiff: async ({ worktreePath, filePath, compare, oldPath }) => { - const file = await resolveRuntimeFilePath(filePath, worktreePath) - return callRuntimeResult('git.branchDiff', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - filePath: file.relativePath, - compare, - oldPath - }) - }, - commitDiff: async ({ worktreePath, filePath, commitOid, parentOid, oldPath }) => { - const file = await resolveRuntimeFilePath(filePath, worktreePath) - return callRuntimeResult('git.commitDiff', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - filePath: file.relativePath, - commitOid, - parentOid, - oldPath - }) - }, - commit: async ({ worktreePath, message }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.commit', { - worktree: toRuntimeWorktreeSelector(worktree.id), - message - }) - }, - generateCommitMessage: async () => ({ - success: false, - error: translate( - 'auto.web.web.preload.api.9fc90740b6', - 'Commit message generation is unavailable in the web client.' - ) - }), - discoverCommitMessageModels: async () => ({ - success: false, - error: translate( - 'auto.web.web.preload.api.e57c82d276', - 'Commit message model discovery is unavailable in the web client.' - ) - }), - cancelGenerateCommitMessage: () => Promise.resolve(), - generatePullRequestFields: async () => ({ - success: false, - error: translate( - 'auto.web.web.preload.api.b8a1618172', - 'Pull request detail generation is unavailable in the web client.' - ) - }), - cancelGeneratePullRequestFields: () => Promise.resolve(), - stage: async ({ worktreePath, filePath }) => mutateGitPath('git.stage', worktreePath, filePath), - bulkStage: async ({ worktreePath, filePaths }) => - mutateGitPaths('git.bulkStage', worktreePath, filePaths), - unstage: async ({ worktreePath, filePath }) => - mutateGitPath('git.unstage', worktreePath, filePath), - bulkUnstage: async ({ worktreePath, filePaths }) => - mutateGitPaths('git.bulkUnstage', worktreePath, filePaths), - discard: async ({ worktreePath, filePath }) => - mutateGitPath('git.discard', worktreePath, filePath), - bulkDiscard: async ({ worktreePath, filePaths }) => { - for (const filePath of filePaths) { - await mutateGitPath('git.discard', worktreePath, filePath) - } - }, - remoteFileUrl: async ({ worktreePath, relativePath, line }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.remoteFileUrl', { - worktree: toRuntimeWorktreeSelector(worktree.id), - relativePath, - line - }) - }, - remoteCommitUrl: async ({ worktreePath, sha }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.remoteCommitUrl', { - worktree: toRuntimeWorktreeSelector(worktree.id), - sha - }) - } - } -} - -function createBrowserApi(): NonNullable['browser']> { - return { - registerGuest: () => Promise.resolve(false), - isGuestRegistered: () => Promise.resolve(false), - repairGuestRegistration: () => Promise.resolve(false), - unregisterGuest: () => Promise.resolve(), - openDevTools: () => Promise.resolve(false), - setViewportOverride: () => Promise.resolve(false), - setAnnotationViewportBridge: () => Promise.resolve(false), - onGuestLoadFailed: () => noopUnsubscribe, - onCertificateFailureChanged: () => noopUnsubscribe, - proceedCertificate: () => Promise.resolve({ ok: false, reason: 'missing' }), - onPermissionDenied: () => noopUnsubscribe, - onPopup: () => noopUnsubscribe, - onDownloadRequested: () => noopUnsubscribe, - onDownloadProgress: () => noopUnsubscribe, - onDownloadFinished: () => noopUnsubscribe, - onContextMenuRequested: () => noopUnsubscribe, - onContextMenuDismissed: () => noopUnsubscribe, - onNavigationUpdate: () => noopUnsubscribe, - onActivateView: () => noopUnsubscribe, - onPaneFocus: () => noopUnsubscribe, - onOpenLinkInOrcaTab: () => noopUnsubscribe, - cancelDownload: () => Promise.resolve(false), - setGrabMode: () => - Promise.resolve({ - ok: false, - error: translate( - 'auto.web.web.preload.api.31bea294d5', - 'Grab mode is unavailable in the web client.' - ) - }), - awaitGrabSelection: () => - Promise.resolve({ - ok: false, - error: translate( - 'auto.web.web.preload.api.31bea294d5', - 'Grab mode is unavailable in the web client.' - ) - }), - cancelGrab: () => Promise.resolve(false), - captureSelectionScreenshot: () => - Promise.resolve({ - ok: false, - error: translate( - 'auto.web.web.preload.api.8dfcb7a351', - 'Selection screenshots are unavailable in the web client.' - ) - }), - extractHoverPayload: () => - Promise.resolve({ - ok: false, - error: translate( - 'auto.web.web.preload.api.275a776357', - 'Hover extraction is unavailable in the web client.' - ) - }), - onGrabModeToggle: () => noopUnsubscribe, - onGrabActionShortcut: () => noopUnsubscribe, - sessionListProfiles: () => Promise.resolve([]), - sessionCreateProfile: () => Promise.resolve(null), - sessionDeleteProfile: () => Promise.resolve(false), - sessionImportCookies: () => - Promise.resolve({ - ok: false, - summary: null, - error: translate( - 'auto.web.web.preload.api.67ec964791', - 'Cookie import is unavailable in the web client.' - ) - }), - sessionResolvePartition: () => Promise.resolve(null), - sessionDetectBrowsers: () => Promise.resolve([]), - sessionImportFromBrowser: () => - Promise.resolve({ - ok: false, - summary: null, - error: translate( - 'auto.web.web.preload.api.67ec964791', - 'Cookie import is unavailable in the web client.' - ) - }), - sessionClearDefaultCookies: () => Promise.resolve(false), - notifyActiveTabChanged: () => Promise.resolve(false) - } as unknown as NonNullable['browser']> -} - -function createEmulatorApi(): NonNullable['emulator']> { - return { - onPaneFocus: () => noopUnsubscribe, - onAutoAttach: () => noopUnsubscribe, - startFrameStream: () => Promise.reject(new Error('Mobile emulator is unavailable on web.')), - stopFrameStream: () => Promise.resolve(), - onFrameStreamFrame: () => noopUnsubscribe, - onFrameStreamError: () => noopUnsubscribe - } as unknown as NonNullable['emulator']> -} - -function createGitHubApi(): WebGitHubApi { - const route = (method: WebGitHubRuntimeMethod, args?: unknown): Promise => - callRuntimeResult(method, mapRepoPathArg(args)) - const githubApi = { - viewer: () => Promise.resolve(null), - repoSlug: (args) => route>(GITHUB_WEB_RPC_METHODS.repoSlug, args), - repoUpstream: (args) => - route>(GITHUB_WEB_RPC_METHODS.repoUpstream, args), - prForBranch: (args) => - route>(GITHUB_WEB_RPC_METHODS.prForBranch, args), - refreshPRNow: async ({ candidate }) => { - const acceptMergedFallbackPR = - candidate.linkedPRNumber == null && - candidate.fallbackPRNumber != null && - candidate.fallbackPRSource != null - const pr = await route>(GITHUB_WEB_RPC_METHODS.prForBranch, { - repoPath: candidate.repoPath, - repoId: candidate.repoId, - branch: candidate.branch, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: candidate.fallbackPRNumber ?? null, - currentHeadOid: candidate.currentHeadOid ?? null, - ...(acceptMergedFallbackPR ? { acceptMergedFallbackPR: true } : {}) - }) - return pr - ? { kind: 'found', pr, fetchedAt: Date.now() } - : { kind: 'no-pr', fetchedAt: Date.now() } - }, - enqueuePRRefresh: () => Promise.resolve(false), - reportVisiblePRRefreshCandidates: () => Promise.resolve(false), - onPRRefreshEvent: () => noopUnsubscribe, - issue: (args) => route>(GITHUB_WEB_RPC_METHODS.issue, args), - workItem: (args) => route>(GITHUB_WEB_RPC_METHODS.workItem, args), - workItemByOwnerRepo: ({ repo: ownerRepo, ...args }) => - route>(GITHUB_WEB_RPC_METHODS.workItemByOwnerRepo, { - ...args, - ownerRepo - }), - workItemDetails: (args) => - route>(GITHUB_WEB_RPC_METHODS.workItemDetails, args), - notifyWorkItemMutated: () => Promise.resolve(false), - prFileContents: (args) => - route>(GITHUB_WEB_RPC_METHODS.prFileContents, args), - listIssues: (args) => - route>(GITHUB_WEB_RPC_METHODS.listIssues, args), - createIssue: (args) => - route>(GITHUB_WEB_RPC_METHODS.createIssue, args), - countWorkItems: (args) => - route>(GITHUB_WEB_RPC_METHODS.countWorkItems, args), - listWorkItems: (args) => - route>(GITHUB_WEB_RPC_METHODS.listWorkItems, args), - prChecks: (args) => route>(GITHUB_WEB_RPC_METHODS.prChecks, args), - prCheckDetails: (args) => - route>(GITHUB_WEB_RPC_METHODS.prCheckDetails, args), - rerunPRChecks: (args) => - route>(GITHUB_WEB_RPC_METHODS.rerunPRChecks, args), - prComments: (args) => - route>(GITHUB_WEB_RPC_METHODS.prComments, args), - setPRCommentReaction: (args) => - route>( - GITHUB_WEB_RPC_METHODS.setPRCommentReaction, - args - ), - resolveReviewThread: (args) => - route>( - GITHUB_WEB_RPC_METHODS.resolveReviewThread, - args - ), - setPRFileViewed: (args) => - route>(GITHUB_WEB_RPC_METHODS.setPRFileViewed, args), - updatePRTitle: (args) => - route>(GITHUB_WEB_RPC_METHODS.updatePRTitle, args), - mergePR: (args) => route>(GITHUB_WEB_RPC_METHODS.mergePR, args), - setPRAutoMerge: (args) => - route>(GITHUB_WEB_RPC_METHODS.setPRAutoMerge, args), - updatePRState: (args) => - route>(GITHUB_WEB_RPC_METHODS.updatePRState, args), - requestPRReviewers: (args) => - route>(GITHUB_WEB_RPC_METHODS.requestPRReviewers, args), - removePRReviewers: (args) => - route>(GITHUB_WEB_RPC_METHODS.removePRReviewers, args), - updateIssue: (args) => - route>(GITHUB_WEB_RPC_METHODS.updateIssue, args), - addIssueComment: (args) => - route>(GITHUB_WEB_RPC_METHODS.addIssueComment, args), - addPRReviewCommentReply: (args) => - route>( - GITHUB_WEB_RPC_METHODS.addPRReviewCommentReply, - args - ), - addPRReviewComment: (args) => - route>(GITHUB_WEB_RPC_METHODS.addPRReviewComment, args), - listLabels: (args) => - route>(GITHUB_WEB_RPC_METHODS.listLabels, args), - listAssignableUsers: (args) => - route>( - GITHUB_WEB_RPC_METHODS.listAssignableUsers, - args - ), - onWorkItemMutated: () => noopUnsubscribe, - checkOrcaStarred: () => Promise.resolve(null), - starOrca: () => Promise.resolve(false), - rateLimit: (args) => - route>(GITHUB_WEB_RPC_METHODS.rateLimit, args), - diagnoseAuth: () => - Promise.resolve({ - ok: false, - message: translate('auto.web.web.preload.api.31bfe8ae1a', 'Unavailable in the web client.') - } as never), - listAccessibleProjects: (args) => - route>( - GITHUB_WEB_RPC_METHODS.listAccessibleProjects, - args - ), - resolveProjectRef: (args) => - route>(GITHUB_WEB_RPC_METHODS.resolveProjectRef, args), - listProjectViews: (args) => - route>(GITHUB_WEB_RPC_METHODS.listProjectViews, args), - getProjectViewTable: (args) => - route>( - GITHUB_WEB_RPC_METHODS.getProjectViewTable, - args - ), - projectWorkItemDetailsBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.projectWorkItemDetailsBySlug, - args - ), - updateProjectItemField: (args) => - route>( - GITHUB_WEB_RPC_METHODS.updateProjectItemField, - args - ), - clearProjectItemField: (args) => - route>( - GITHUB_WEB_RPC_METHODS.clearProjectItemField, - args - ), - updateIssueBySlug: (args) => - route>(GITHUB_WEB_RPC_METHODS.updateIssueBySlug, args), - updatePullRequestBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.updatePullRequestBySlug, - args - ), - addIssueCommentBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.addIssueCommentBySlug, - args - ), - updateIssueCommentBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.updateIssueCommentBySlug, - args - ), - deleteIssueCommentBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.deleteIssueCommentBySlug, - args - ), - listLabelsBySlug: (args) => - route>(GITHUB_WEB_RPC_METHODS.listLabelsBySlug, args), - listAssignableUsersBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.listAssignableUsersBySlug, - args - ), - listIssueTypesBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.listIssueTypesBySlug, - args - ), - updateIssueTypeBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.updateIssueTypeBySlug, - args - ) - } satisfies WebGitHubApi - - return githubApi -} - -function createGitLabApi(): WebGitLabApi { - const route = (method: WebGitLabRuntimeMethod, args?: unknown): Promise => - callRuntimeResult(method, mapRepoPathArg(args)) - - const gitLabApi = { - viewer: () => Promise.resolve(null), - diagnoseAuth: () => route>(GITLAB_WEB_RPC_METHODS.diagnoseAuth), - rateLimit: (args) => - route>(GITLAB_WEB_RPC_METHODS.rateLimit, args), - projectSlug: () => Promise.resolve(null), - mrForBranch: () => Promise.resolve(null), - mr: () => Promise.resolve(null), - listMRs: (args) => route>(GITLAB_WEB_RPC_METHODS.listMRs, args), - listWorkItems: (args) => - route>(GITLAB_WEB_RPC_METHODS.listWorkItems, args), - issue: () => Promise.resolve(null), - listIssues: (args) => - route>(GITLAB_WEB_RPC_METHODS.listIssues, args), - createIssue: (args) => - route>(GITLAB_WEB_RPC_METHODS.createIssue, args), - updateIssue: (args) => - route>(GITLAB_WEB_RPC_METHODS.updateIssue, args), - addIssueComment: (args) => - route>(GITLAB_WEB_RPC_METHODS.addIssueComment, args), - listLabels: (args) => - route>(GITLAB_WEB_RPC_METHODS.listLabels, args), - listAssignableUsers: () => Promise.resolve([]), - todos: (args) => route>(GITLAB_WEB_RPC_METHODS.todos, args), - workItemDetails: (args) => - route>(GITLAB_WEB_RPC_METHODS.workItemDetails, args), - closeMR: (args) => - route>(GITLAB_WEB_RPC_METHODS.closeMR, { - ...args, - state: 'closed' - }), - reopenMR: (args) => - route>(GITLAB_WEB_RPC_METHODS.reopenMR, { - ...args, - state: 'opened' - }), - mergeMR: (args) => route>(GITLAB_WEB_RPC_METHODS.mergeMR, args), - updateMR: (args) => route>(GITLAB_WEB_RPC_METHODS.updateMR, args), - updateMRReviewers: (args) => - route>(GITLAB_WEB_RPC_METHODS.updateMRReviewers, args), - addMRComment: (args) => - route>(GITLAB_WEB_RPC_METHODS.addMRComment, args), - addMRInlineComment: (args) => - route>(GITLAB_WEB_RPC_METHODS.addMRInlineComment, args), - resolveMRDiscussion: (args) => - route>( - GITLAB_WEB_RPC_METHODS.resolveMRDiscussion, - args - ), - jobTrace: (args) => route>(GITLAB_WEB_RPC_METHODS.jobTrace, args), - retryJob: (args) => route>(GITLAB_WEB_RPC_METHODS.retryJob, args), - workItemByPath: (args) => - route>(GITLAB_WEB_RPC_METHODS.workItemByPath, args) - } satisfies WebGitLabApi - - return gitLabApi -} - -function createRuntimeNamespaceApi(prefix: string): never { - return createFallbackProxy([prefix], (path, args) => { - const method = `${prefix}.${path.at(-1) ?? ''}` - return callRuntimeResult(method, mapRuntimeNamespaceArg(prefix, args[0])) - }) as never -} - -function createHooksApi(): NonNullable['hooks']> { - return { - check: async ({ repoId }) => callRuntimeResult('repo.hooksCheck', { repo: repoId }), - inspectSetupScriptImports: async ({ repoId }) => - callRuntimeResult('repo.setupScriptImports', { repo: repoId }), - createIssueCommandRunner: async () => ({ launched: false }) as never, - readIssueCommand: async ({ repoId }) => - callRuntimeResult('repo.issueCommandRead', { repo: repoId }), - writeIssueCommand: async ({ repoId, content }) => { - await callRuntimeResult('repo.issueCommandWrite', { repo: repoId, content }) - } - } -} - -function createWebUiApi(): NonNullable['ui']> { - let zoomLevel = readLocalWebUIState().uiZoomLevel - return { - get: async () => { - try { - const result = await callRuntimeResult<{ ui: PairedUiState }>('ui.get', undefined, 15_000) - const local = readLocalWebUIState() - const next = { - ...mergeHostWebUIState(local, result.ui), - osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), - featureInteractions: mergeFeatureInteractionState( - local.featureInteractions, - result.ui.featureInteractions - ), - contextualToursSeenIds: mergeContextualTourSeenIds( - local.contextualToursSeenIds, - result.ui.contextualToursSeenIds - ) - } - writeJson(UI_STORAGE_KEY, next) - zoomLevel = next.uiZoomLevel - return next - } catch { - return readLocalWebUIState() - } - }, - set: async (updates) => { - const next = mergeWebUIState(readLocalWebUIState(), updates) - writeJson(UI_STORAGE_KEY, next) - zoomLevel = next.uiZoomLevel - // Why strip here too when the host also strips: an old host predating that strip would - // otherwise persist this browser's runtime:web-* keys over the desktop profile's order. - const hostUpdates = omitPairingLocalUiFields(updates) - try { - await callRuntimeResult('ui.set', hostUpdates, 15_000) - } catch { - // Why: unpaired/offline web clients still need local UI persistence. - } - }, - recordFeatureInteraction: async (id: FeatureInteractionId) => { - const current = readLocalWebUIState() - const featureInteractions = normalizeFeatureInteractions(current.featureInteractions) - const existing = featureInteractions[id] - const optimistic = mergeWebUIState(current, { - featureInteractions: { - ...featureInteractions, - [id]: { - firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), - interactionCount: (existing?.interactionCount ?? 0) + 1 - } - } - }) - writeJson(UI_STORAGE_KEY, optimistic) - try { - const result = await callRuntimeResult<{ ui: PairedUiState }>( - 'ui.recordFeatureInteraction', - id, - 15_000 - ) - const local = readLocalWebUIState() - const next = { - ...mergeHostWebUIState(local, result.ui), - osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), - featureInteractions: mergeFeatureInteractionState( - local.featureInteractions, - result.ui.featureInteractions - ), - contextualToursSeenIds: mergeContextualTourSeenIds( - local.contextualToursSeenIds, - result.ui.contextualToursSeenIds - ) - } - writeJson(UI_STORAGE_KEY, next) - zoomLevel = next.uiZoomLevel - return next - } catch { - return optimistic - } - }, - readClipboardText: async (options?: ReadClipboardTextOptions) => - assertClipboardTextWithinLimitWithYield( - await (navigator.clipboard?.readText?.() ?? ''), - options - ), - readSelectionClipboardText: () => - Promise.reject(new Error('Selection clipboard is unavailable in the web client')), - saveClipboardImageAsTempFile: async (args?: { - connectionId?: string | null - runtimeEnvironmentId?: string | null - }) => { - if (!requireActiveEnvironmentOrNull()) { - return null - } - const contentBase64 = await readClipboardImagePngBase64() - if (!contentBase64) { - return null - } - return saveClipboardImageAsTempFileInRuntime(contentBase64, args) - }, - writeClipboardText: writeWebClipboardText, - writeTerminalClipboardText: writeWebClipboardText, - writeSelectionClipboardText: () => - Promise.reject(new Error('Selection clipboard is unavailable in the web client')), - writeClipboardImage: () => Promise.resolve(), - writeClipboardFile: () => Promise.resolve({ ok: false, reason: 'unsupported-platform' }), - performNativePaste: () => { - document.execCommand?.('paste') - }, - performNativeSelectionAction: (action) => { - document.execCommand?.(action === 'copy' ? 'copy' : 'selectAll') - }, - onExportPdfRequested: () => noopUnsubscribe, - onAppMenuPaste: () => noopUnsubscribe, - onAppMenuSelectionAction: () => noopUnsubscribe, - onEditableContextPaste: () => noopUnsubscribe, - getZoomLevel: () => zoomLevel, - setZoomLevel: (level) => { - zoomLevel = level - }, - isMaximized: () => Promise.resolve(false), - onOpenSettings: () => noopUnsubscribe, - // Why: the web client has no native tray/menu bar, so there's never a queued open-settings intent to consume. - consumePendingOpenSettings: () => Promise.resolve(false), - onOpenSkillShare: () => noopUnsubscribe, - consumePendingSkillShare: () => Promise.resolve(null), - onOpenSetupGuide: () => noopUnsubscribe, - onOpenFeatureTour: () => noopUnsubscribe, - onOpenCrashReport: () => noopUnsubscribe, - // No desktop main process to push state changes; the web client re-reads via ui.get on interaction. - onStateChanged: () => noopUnsubscribe, - onToggleLeftSidebar: () => noopUnsubscribe, - onToggleRightSidebar: () => noopUnsubscribe, - onToggleWorktreePalette: () => noopUnsubscribe, - onToggleFloatingTerminal: () => noopUnsubscribe, - onTerminalShortcutCaptured: () => noopUnsubscribe, - onOpenQuickOpen: () => noopUnsubscribe, - onToggleQuickCommandsMenu: () => noopUnsubscribe, - onOpenTasks: () => noopUnsubscribe, - onOpenNewWorkspace: () => noopUnsubscribe, - onDeleteCurrentWorkspace: () => noopUnsubscribe, - onOpenWorkspaceBoard: () => noopUnsubscribe, - onToggleAgentDashboard: () => noopUnsubscribe, - onJumpToWorktreeIndex: () => noopUnsubscribe, - onJumpToTabIndex: () => noopUnsubscribe, - onWorktreeHistoryNavigate: () => noopUnsubscribe, - onNewBrowserTab: () => noopUnsubscribe, - onNewMarkdownTab: () => noopUnsubscribe, - onNewSimulatorTab: () => noopUnsubscribe, - onRequestTabCreate: () => noopUnsubscribe, - replyTabCreate: () => {}, - onRequestTabSetProfile: () => noopUnsubscribe, - replyTabSetProfile: () => {}, - onRequestTabClose: () => noopUnsubscribe, - replyTabClose: () => {}, - onNewTerminalTab: () => noopUnsubscribe, - onFocusBrowserAddressBar: () => noopUnsubscribe, - onFindInBrowserPage: () => noopUnsubscribe, - onReloadBrowserPage: () => noopUnsubscribe, - onBrowserHistoryNavigate: () => noopUnsubscribe, - onZoomBrowserPage: () => noopUnsubscribe, - onHardReloadBrowserPage: () => noopUnsubscribe, - onCloseActiveTab: () => noopUnsubscribe, - onCloseFloatingItem: () => noopUnsubscribe, - onSelectFloatingIndex: () => noopUnsubscribe, - onSwitchTab: () => noopUnsubscribe, - onSwitchTabAcrossAllTypes: () => noopUnsubscribe, - onSwitchRecentTab: () => noopUnsubscribe, - onSwitchTerminalTab: () => noopUnsubscribe, - onCtrlTabKeyDown: () => noopUnsubscribe, - onCtrlTabKeyUp: () => noopUnsubscribe, - onToggleStatusBar: () => noopUnsubscribe, - onDictationKeyDown: () => noopUnsubscribe, - onActivateWorktree: () => noopUnsubscribe, - onCreateTerminal: () => noopUnsubscribe, - onRequestTerminalCreate: () => noopUnsubscribe, - onRequestTerminalTabMount: () => noopUnsubscribe, - replyTerminalCreate: () => {}, - onSplitTerminal: () => noopUnsubscribe, - onRenameTerminal: () => noopUnsubscribe, - onFocusTerminal: () => noopUnsubscribe, - onFocusEditorTab: () => noopUnsubscribe, - onCloseSessionTab: () => noopUnsubscribe, - onSessionTabCloseRequest: () => noopUnsubscribe, - respondSessionTabClose: () => {}, - onMoveSessionTab: () => noopUnsubscribe, - onOpenFileFromMobile: () => noopUnsubscribe, - onOpenDiffFromMobile: () => noopUnsubscribe, - onMobileMarkdownRequest: () => noopUnsubscribe, - respondMobileMarkdownRequest: () => {}, - onCloseTerminal: () => noopUnsubscribe, - onTerminalTabCloseRequest: () => noopUnsubscribe, - respondTerminalTabClose: () => {}, - onSleepWorktree: () => noopUnsubscribe, - // Why: paired web is a full renderer that wakes on activation; mobile wake is desktop-host-scoped and never reaches web. - onResumeSleepingAgents: () => noopUnsubscribe, - onTerminalZoom: () => noopUnsubscribe, - // Why: a paired web client has no OS sleep signal; occlusion-driven visibilitychange already covers wake recovery. - onSystemResumed: () => noopUnsubscribe, - onFileDrop: () => noopUnsubscribe, - syncTrafficLights: () => {}, - setMarkdownEditorFocused: () => {}, - setRichMarkdownContextMenuTarget: () => {}, - setTerminalInputFocused: () => {}, - setFloatingFocus: () => {}, - setShortcutRecorderFocused: () => {}, - onRichMarkdownContextCommand: () => noopUnsubscribe, - onFullscreenChanged: () => noopUnsubscribe, - minimize: () => {}, - maximize: () => {}, - onMaximizeChanged: () => noopUnsubscribe, - requestClose: () => {}, - popupMenu: () => {}, - onWindowCloseRequested: () => noopUnsubscribe, - confirmWindowClose: () => {}, - notifyWindowRevealed: () => {} - } -} - -function createPreflightApi(): NonNullable['preflight']> { - const fallbackStatus: PreflightStatus = { - git: { installed: false }, - gh: { installed: false, authenticated: false }, - glab: { installed: false, authenticated: false }, - bitbucket: { configured: false, authenticated: false, account: null }, - azureDevOps: { - configured: false, - authenticated: false, - account: null, - baseUrl: null, - tokenConfigured: false - }, - gitea: { - configured: false, - authenticated: false, - account: null, - baseUrl: null, - tokenConfigured: false - } - } - const fallbackRefreshAgents: RefreshAgentsResult = { - agents: [], - addedPathSegments: [], - shellHydrationOk: false, - pathSource: 'sync_seed_only', - pathFailureReason: 'spawn_error' - } - type WindowsTerminalCapabilityBridgeResult = { - wslAvailable: boolean - wslDistros: string[] - pwshAvailable: boolean - gitBashAvailable: boolean - hostPlatform: NodeJS.Platform | null - } - const fallbackWindowsTerminalCapabilities = { - wslAvailable: false, - wslDistros: [], - pwshAvailable: false, - gitBashAvailable: false, - hostPlatform: null - } - return { - check: async (args) => { - if (!requireActiveEnvironmentOrNull()) { - return fallbackStatus - } - return callRuntimeResult('preflight.check', args) - }, - detectAgents: async () => { - if (!requireActiveEnvironmentOrNull()) { - return [] - } - return callRuntimeResult('preflight.detectAgents').catch(() => []) - }, - refreshAgents: () => - requireActiveEnvironmentOrNull() - ? callRuntimeResult('preflight.refreshAgents') - .then((result) => result as RefreshAgentsResult) - .catch(() => fallbackRefreshAgents) - : Promise.resolve(fallbackRefreshAgents), - detectRemoteAgents: async (args) => - requireActiveEnvironmentOrNull() - ? callRuntimeResult('preflight.detectRemoteAgents', args).catch(() => []) - : [], - detectRemoteWindowsTerminalCapabilities: async (args) => - requireActiveEnvironmentOrNull() - ? callRuntimeResult( - 'preflight.detectRemoteWindowsTerminalCapabilities', - args - ).catch(() => fallbackWindowsTerminalCapabilities) - : Promise.resolve(fallbackWindowsTerminalCapabilities) - } -} - -function createCliApi(): NonNullable['cli']> { - const status = { - platform: getBrowserPlatform(), - commandName: getBrowserPlatform() === 'linux' ? 'orca-ide' : 'orca', - commandPath: null, - pathDirectory: null, - pathConfigured: false, - launcherPath: null, - installMethod: null, - supported: false, - state: 'unsupported', - currentTarget: null, - unsupportedReason: 'launch_mode_unavailable', - detail: 'CLI registration is managed on the Orca server, not in the web browser.' - } as const - return { - getInstallStatus: () => Promise.resolve(status), - install: () => Promise.resolve(status), - remove: () => Promise.resolve(status), - getWslInstallStatus: (_args?: { distro?: string | null }) => Promise.resolve(status), - installWsl: (_args?: { distro?: string | null }) => Promise.resolve(status), - removeWsl: (_args?: { distro?: string | null }) => Promise.resolve(status) - } as NonNullable['cli']> -} - -function createAgentHooksApi(): NonNullable['agentHooks']> { - const status = ( - agent: - | 'claude' - | 'openclaude' - | 'codex' - | 'gemini' - | 'antigravity' - | 'amp' - | 'cursor' - | 'droid' - | 'command-code' - | 'grok' - | 'copilot' - | 'hermes' - | 'devin' - ) => - Promise.resolve({ - agent, - state: 'not_installed', - configPath: '', - managedHooksPresent: false, - detail: 'Agent hook status is only available on the Orca server.' - } as const) - return { - claudeStatus: () => status('claude'), - openClaudeStatus: () => status('openclaude'), - codexStatus: () => status('codex'), - geminiStatus: () => status('gemini'), - antigravityStatus: () => status('antigravity'), - ampStatus: () => status('amp'), - cursorStatus: () => status('cursor'), - droidStatus: () => status('droid'), - commandCodeStatus: () => status('command-code'), - grokStatus: () => status('grok'), - copilotStatus: () => status('copilot'), - hermesStatus: () => status('hermes'), - devinStatus: () => status('devin') - } -} - -function createMacosTccPromptsApi(): NonNullable['macosTccPrompts']> { - // Why: TCC is a macOS-desktop concept; the web client has no log stream to watch. - return { - onThreshold: () => noopUnsubscribe, - consumePending: () => Promise.resolve(null), - acknowledgePending: () => Promise.resolve(), - releasePending: () => Promise.resolve(), - dismiss: () => Promise.resolve() - } -} - -function createDeveloperPermissionsApi(): NonNullable['developerPermissions']> { - return { - getStatus: () => Promise.resolve([]), - request: ({ id }) => - Promise.resolve({ id, status: 'unsupported', openedSystemSettings: false } as const), - openSettings: () => Promise.resolve(), - testLocalNetworkConnection: ({ host, port }) => - Promise.resolve({ - ok: false, - host, - port, - testedAt: Date.now(), - failure: 'unsupported' - } as const) - } -} - -function createComputerUsePermissionsApi(): NonNullable< - Partial['computerUsePermissions'] -> { - return { - getStatus: () => - callRuntimeResult( - 'computer.permissionsStatus', - {}, - 15_000 - ), - openSetup: (args) => - callRuntimeResult( - 'computer.permissions', - args ?? {}, - 15_000 - ).catch(() => ({ - platform: getBrowserPlatform(), - helperAppPath: null, - openedSettings: false, - launchedHelper: false, - nextStep: 'Computer-use permissions are managed on the Orca server.' - })), - reset: () => - Promise.resolve({ - platform: getBrowserPlatform(), - helperAppPath: null, - helperUnavailableReason: 'web_client', - bundleId: null, - permissions: [] - }) - } -} - -function createSkillsApi(): NonNullable['skills']> { - return { - discover: (target) => - callRuntimeResult('skills.discover', target, 15_000), - // Why: browser clients have no local skill homes; remote-host freshness stays off until its update rail covers it. - freshnessInventory: (): Promise => - Promise.resolve({ - schemaVersion: 1, - installations: [], - eligibleUpdateNames: [], - scanIssues: [], - scannedAt: Date.now() - }), - // Why: with no local skill homes there is nothing to update, so the run rail - // reports a permanently idle state rather than spawning anything. - startUpdateRun: () => Promise.resolve({ started: false as const, reason: 'invalid-names' }), - cancelUpdateRun: () => Promise.resolve(), - acknowledgeUpdateRun: () => Promise.resolve(), - getUpdateRun: () => Promise.resolve({ state: 'idle' as const }), - prepareShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')), - publishShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')), - cancelShare: () => Promise.resolve(), - releaseShare: () => Promise.resolve(), - resolveShare: () => Promise.reject(new Error('Skill share links require the desktop app.')), - installShare: () => Promise.reject(new Error('Skill installation requires the desktop app.')), - installBundleShare: () => - Promise.reject(new Error('Skill installation requires the desktop app.')), - installPackageVersion: () => - Promise.reject(new Error('Skill installation requires the desktop app.')), - installBundlePackageVersion: () => - Promise.reject(new Error('Skill installation requires the desktop app.')), - cancelInstall: () => Promise.resolve({ cancelled: false }), - previewInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')), - previewBundleInstall: () => - Promise.reject(new Error('Skill installation requires the desktop app.')), - removeInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')), - listManagedInstalls: () => - Promise.reject(new Error('Skill installation requires the desktop app.')), - getPackage: () => Promise.reject(new Error('Skill installation requires the desktop app.')), - listOwnedShares: () => - Promise.reject(new Error('Skill package management requires the desktop app.')), - revokeShare: () => - Promise.reject(new Error('Skill package management requires the desktop app.')), - deletePackageVersion: () => - Promise.reject(new Error('Skill package management requires the desktop app.')), - deletePackage: () => - Promise.reject(new Error('Skill package management requires the desktop app.')), - listWslDistros: () => Promise.resolve([]), - onInstallProgress: () => () => {}, - onShareProgress: () => () => {}, - onUpdateRun: () => () => {} - } -} - -function createNotificationsApi(): NonNullable['notifications']> { - return { - dispatch: () => Promise.resolve({ delivered: false, reason: 'not-supported' }), - dismiss: () => Promise.resolve({ dismissed: 0 }), - openSystemSettings: () => Promise.resolve(), - getPermissionStatus: () => - Promise.resolve({ supported: false, platform: getBrowserPlatform(), requested: false }), - probeDelivery: () => Promise.resolve({ state: 'unsupported' as const, authoritative: false }), - playSound: () => Promise.resolve({ played: false, reason: 'missing-path' }) - } -} - -function createRateLimitsApi(): NonNullable['rateLimits']> { - const empty: RateLimitState = { - claude: null, - codex: null, - gemini: null, - opencodeGo: null, - kimi: null, - antigravity: null, - minimax: null, - grok: null, - minimaxCookieConfigured: false, - grokAuthConfigured: false, - claudeTarget: { runtime: 'host', wslDistro: null }, - codexTarget: { runtime: 'host', wslDistro: null }, - inactiveClaudeAccounts: [], - inactiveCodexAccounts: [] - } - return { - get: () => Promise.resolve(empty), - refresh: () => Promise.resolve(empty), - refreshCodexForTarget: () => Promise.resolve(empty), - // Why: web clients don't own local Codex auth; report the safe no-credit outcome since redemption is desktop-only. - consumeCodexResetCredit: () => Promise.resolve({ outcome: 'noCredit', state: empty }), - refreshClaudeForTarget: () => Promise.resolve(empty), - setPollingInterval: () => Promise.resolve(), - fetchInactiveClaudeAccounts: () => Promise.resolve(), - fetchInactiveCodexAccounts: () => Promise.resolve(), - refreshMiniMax: () => Promise.resolve(empty), - refreshGrok: () => Promise.resolve(empty), - onUpdate: () => noopUnsubscribe - } -} - -function createMiniMaxCredentialsApi(): NonNullable['minimaxCredentials']> { - const notConfigured = { configured: false } - const unsupportedError = new Error('MiniMax cookie storage is only available in the desktop app.') - return { - getStatus: () => Promise.resolve(notConfigured), - saveCookie: () => Promise.reject(unsupportedError), - clearCookie: () => Promise.resolve(notConfigured) - } -} - -function createGrokAccountsApi(): NonNullable['grokAccounts']> { - const unsigned = { - signedIn: false, - email: null, - teamId: null, - tokenFresh: false, - error: null - } - return { - getStatus: () => Promise.resolve(unsigned) - } -} - -function createAccountsApi(): never { - const empty = { - accounts: [], - activeAccountId: null, - activeAccountIdsByRuntime: { host: null, wsl: {} } - } - return { - list: () => Promise.resolve(empty), - add: () => Promise.resolve(empty), - cancelPendingLogin: () => Promise.resolve(false), - reauthenticate: () => Promise.resolve(empty), - remove: () => Promise.resolve(empty), - select: () => Promise.resolve(empty), - // Why: launch accounts are recorded on the host that owns the PTY, which the - // web client never is — report no stale panes rather than reject the sweep. - listStalePanes: () => Promise.resolve([]), - // Why empty rather than absent: the same host owns both records, so a web - // client has no recorded lane to offer and every pane falls to derivation. - listRecordedPaneLanes: () => Promise.resolve({}), - forgetStalePanes: () => Promise.resolve() - } as never -} - -function createUpdaterApi(): NonNullable['updater']> { - // Why: the linux-package-install recovery status can only originate in the native main process, so - // the web renderer never reaches these branches — reject loudly rather than resolve a fake result. - // A fresh Error per rejection: one shared instance would carry this function's stack, not the caller's. - const desktopOnlyMessage = 'Linux package install recovery is only available in the desktop app.' - return { - getVersion: () => Promise.resolve('web'), - getStatus: () => Promise.resolve({ state: 'idle' } as never), - check: () => Promise.resolve(), - download: () => Promise.resolve(), - quitAndInstall: () => Promise.resolve(), - dismissNudge: () => Promise.resolve(), - dismissAvailableUpdate: () => Promise.resolve(), - getLinuxPackageInstallInstructions: () => Promise.reject(new Error(desktopOnlyMessage)), - showLinuxPackage: () => Promise.reject(new Error(desktopOnlyMessage)), - // Why: the web client cannot install a desktop build, so channel switching - // reports unavailable rather than an empty list that looks like a fetch miss. - listBuilds: (channel) => - Promise.resolve({ - ok: false, - channel, - message: translate( - 'auto.components.settings.ReleaseChannelSection.webUnavailable', - 'Switching builds is only available in the desktop app.' - ) - }), - onStatus: () => noopUnsubscribe, - onClearDismissal: () => noopUnsubscribe - } -} - -function createShellApi(): NonNullable['shell']> { - const openResult = { ok: true } as const - return { - openPath: (path) => - Promise.resolve(window.open(path, '_blank', 'noopener,noreferrer') as never), - openInFileManager: () => Promise.resolve(openResult), - openInExternalEditor: () => Promise.resolve(openResult), - openUrl: (url) => Promise.resolve(window.open(url, '_blank', 'noopener,noreferrer') as never), - openFilePath: () => Promise.resolve(false), - openFileUri: (uri) => - Promise.resolve(window.open(uri, '_blank', 'noopener,noreferrer') as never), - pathExists: async (path) => { - try { - await resolveRuntimeFilePath(path) - return true - } catch { - return false - } - }, - pickAttachment: () => Promise.resolve(null), - pickImage: () => Promise.resolve(null), - pickRepoIconImage: () => Promise.resolve(null), - pickAudio: () => Promise.resolve(null), - pickDirectory: () => Promise.resolve(null), - copyFile: () => Promise.resolve() - } -} - -function createPtyApi(): NonNullable['pty']> { - return { - spawn: () => Promise.reject(new Error('Local PTYs are unavailable in the web client.')), - write: () => {}, - writeAccepted: () => Promise.resolve(false), - resize: () => {}, - claimViewport: () => {}, - reportGeometry: () => {}, - signal: () => {}, - // Web panes clear the host buffer via the terminal.clearBuffer runtime RPC. - clearBuffer: () => {}, - kill: () => Promise.resolve(), - ackColdRestore: () => {}, - ackData: () => {}, - onDeliveryResyncRequest: () => noopUnsubscribe, - respondDeliveryResync: () => {}, - // Why: web terminals bypass main's delivery gate; a zero-in-flight reply keeps the watchdog idle. - reportRendererDeliveryState: () => - Promise.resolve({ inFlightTotalChars: 0, inFlightPtyCount: 0, msSinceLastAck: null }), - getPtyDataListenerCount: () => 0, - rendererDispatcherReady: () => {}, - setActiveRendererPty: () => {}, - setRendererPtyVisible: () => {}, - setHiddenRendererPty: () => {}, - setPtyDeliveryInterest: () => {}, - // Why: remote-runtime PTYs are never hidden-gate markable, so there's no main-side responder to feed. - publishTerminalViewAttributes: () => {}, - hasChildProcesses: () => Promise.resolve(false), - getForegroundProcess: () => Promise.resolve(null), - inspectProcess: () => Promise.reject(new Error('terminal_liveness_unavailable')), - // Why: paired web panes cannot provide a local post-boundary process scan. - confirmForegroundProcess: () => Promise.resolve(null), - getCwd: () => Promise.resolve('~'), - getSize: () => Promise.resolve(null), - listSessions: () => Promise.resolve([]), - getAuthoritativeBufferSnapshotCapabilities: (ids) => - Promise.resolve(ids.map((id) => ({ id, authoritative: false }))), - hasPty: () => Promise.resolve(null), - getMainBufferSnapshot: () => Promise.resolve(null), - // Why: remote-runtime PTYs skip local main (no side-effect source); renderer byte parsing stays authoritative. - onSideEffect: () => noopUnsubscribe, - getSideEffectSnapshot: () => Promise.resolve(null), - getRendererDeliveryDebugSnapshot: () => - Promise.resolve({ - pendingPtyCount: 0, - pendingChars: 0, - maxPendingCharsByPty: 0, - rendererInFlightPtyCount: 0, - rendererInFlightChars: 0, - maxRendererInFlightCharsByPty: 0, - activeRendererPtyCount: 0, - flushScheduled: false, - peakPendingChars: 0, - peakMaxPendingCharsByPty: 0, - peakRendererInFlightChars: 0, - peakMaxRendererInFlightCharsByPty: 0, - ackGatedFlushSkipCount: 0, - hiddenDeliveryGatedPtyCount: 0, - hiddenDeliveryGatedVisiblePtyCount: 0, - hiddenDeliveryGatedActivePtyCount: 0, - deliveryInterestPtyCount: 0, - hiddenDeliveryDroppedChars: 0, - hiddenDeliveryDroppedChunks: 0, - pendingDroppedChars: 0, - diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, - rendererLifecycleResetCount: 0, - lastLifecycleResetClearedChars: 0, - rendererPtyDispatcherReady: false, - rendererDispatcherReadyForcedCount: 0 - }), - resetRendererDeliveryDebug: () => Promise.resolve(), - onData: () => noopUnsubscribe, - onReplay: () => noopUnsubscribe, - onModelRestoreNeeded: () => noopUnsubscribe, - onExit: () => noopUnsubscribe, - onSpawned: () => noopUnsubscribe, - onSerializeBufferRequest: () => noopUnsubscribe, - onClearBufferRequest: () => noopUnsubscribe, - sendSerializedBuffer: () => {}, - declarePendingPaneSerializer: () => Promise.resolve(0), - settlePaneSerializer: () => Promise.resolve(), - clearPendingPaneSerializer: () => Promise.resolve(), - reportRendererSerializerReady: () => Promise.resolve(), - management: { - listSessions: () => Promise.resolve({ sessions: [], degraded: false }), - killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }), - killOne: () => Promise.resolve({ success: false }), - restart: () => Promise.resolve({ success: false }), - // Why: web clients can't inspect the host daemon's pid record; 'unknown' keeps the banner hidden. - macTccAttribution: () => Promise.resolve({ health: 'unknown' as const }) - } - } -} - -function createSshApi(): NonNullable['ssh']> { - return { - // Why: SSH is owned by the paired host; route read/connect to runtime RPC for state/reconnect (STA-1468). Target mgmt is desktop-only. - listTargets: async () => { - if (!requireActiveEnvironmentOrNull()) { - return [] - } - const { targets } = await callRuntimeResult<{ targets: SshTarget[] }>( - 'ssh.listTargetSummaries' - ) - return targets - }, - listRemovedTargetLabels: async () => { - if (!requireActiveEnvironmentOrNull()) { - return {} - } - const { labels } = await callRuntimeResult<{ labels: Record }>( - 'ssh.listRemovedTargetLabels' - ) - return labels - }, - addTarget: () => - Promise.reject(new Error('SSH target management is unavailable in the web client.')), - updateTarget: () => - Promise.reject(new Error('SSH target management is unavailable in the web client.')), - removeTarget: () => Promise.resolve(), - importConfig: () => Promise.resolve({ targets: [], repoReadoptions: [] }), - listConfigHosts: () => - Promise.resolve({ - hosts: [], - totalHostCount: 0, - newHostCount: 0, - matchCount: 0, - hasMore: false - }), - resolveConfigHost: () => Promise.resolve(null), - connect: async (args) => { - const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( - 'ssh.connect', - { targetId: args.targetId } - ) - return state - }, - disconnect: () => Promise.resolve(), - terminateSessions: () => Promise.resolve(), - resetRelay: () => Promise.resolve(), - getState: async (args) => { - if (!requireActiveEnvironmentOrNull()) { - return null - } - const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( - 'ssh.getState', - { targetId: args.targetId } - ) - return state - }, - needsPassphrasePrompt: () => Promise.resolve(false), - testConnection: () => - Promise.resolve({ - success: false, - error: translate('auto.web.web.preload.api.31bfe8ae1a', 'Unavailable in the web client.') - }), - onStateChanged: () => noopUnsubscribe, - addPortForward: () => - Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')), - updatePortForward: () => - Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')), - removePortForward: () => Promise.resolve(null), - listPortForwards: () => Promise.resolve([]), - listDetectedPorts: () => Promise.resolve([]), - onPortForwardsChanged: () => noopUnsubscribe, - onDetectedPortsChanged: () => noopUnsubscribe, - browseDir: () => Promise.resolve({ entries: [], resolvedPath: '', pathFlavor: 'posix' }), - onCredentialRequest: () => noopUnsubscribe, - onCredentialResolved: () => noopUnsubscribe, - submitCredential: () => Promise.resolve() - } -} - -async function callRuntimeEnvelope( - method: string, - params?: unknown, - timeoutMs?: number -): Promise> { - const environment = requireActiveEnvironment() - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return manuallyDisconnectedResponse(environment) - } - const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return Promise.resolve(manuallyDisconnectedResponse(environment)) - } - return getClientForEnvironment(environment).call(method, params, { timeoutMs }) - }) - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return manuallyDisconnectedResponse(environment) - } - updateEnvironmentFromResponse(environment, response) - return response as RuntimeRpcResponse -} - -async function callEnvironmentEnvelope( - selector: string, - method: string, - params?: unknown, - timeoutMs?: number -): Promise> { - const environment = resolveEnvironment(selector) - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return manuallyDisconnectedResponse(environment) - } - const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return Promise.resolve(manuallyDisconnectedResponse(environment)) - } - return getClientForEnvironment(environment).call(method, params, { timeoutMs }) - }) - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return manuallyDisconnectedResponse(environment) - } - updateEnvironmentFromResponse(environment, response) - return response as RuntimeRpcResponse -} - -async function callRuntimeResult( - method: string, - params?: unknown, - timeoutMs?: number -): Promise { - const response = await callRuntimeEnvelope(method, params, timeoutMs) - if (!response.ok) { - throw new Error(response.error.message) - } - return response.result as TResult -} - -async function callRuntimeResultWithOwner( - method: string, - params?: unknown, - timeoutMs?: number -): Promise<{ result: TResult; hostId: ExecutionHostId; environmentId: string }> { - const environmentId = requireActiveEnvironment().id - const result = await callRuntimeResult(method, params, timeoutMs) - return { result, hostId: toRuntimeExecutionHostId(environmentId), environmentId } -} - -function withRuntimeRepoOwner(repo: Repo, hostId: ExecutionHostId): Repo { - return { ...repo, executionHostId: hostId } -} - -function withRuntimeRepoMutationOwner( - result: { repo: Repo } | { error: string }, - hostId: ExecutionHostId -): { repo: Repo } | { error: string } { - return 'repo' in result ? { ...result, repo: withRuntimeRepoOwner(result.repo, hostId) } : result -} - -function withRuntimeWorktreeOwner(worktree: T, hostId: ExecutionHostId): T { - const runtimeOwner = parseExecutionHostId(hostId) - if (runtimeOwner?.kind !== 'runtime') { - return worktree - } - return { ...worktree, runtimeOwnerEnvironmentId: runtimeOwner.environmentId } -} - -function captureWebFileMutationSession(): { - resolveFilePath: (filePath: string) => Promise>> - assertMutationSupported: () => Promise - callRuntimeResult: WebRuntimeResultCaller - getSshState: (targetId: string) => Promise -} { - const environment = requireActiveEnvironment() - const client = getClientForEnvironment(environment) - const assertCurrent = (): void => { - if (activeClient !== client || requireActiveEnvironmentOrNull()?.id !== environment.id) { - throw new Error('Runtime pairing changed; refresh and try again') - } - } - const callBoundRuntimeEnvelope: WebRuntimeEnvelopeCaller = async ( - method: string, - params?: unknown, - timeoutMs?: number - ): Promise> => { - assertCurrent() - const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { - assertCurrent() - return client.call(method, params, { timeoutMs }) - }) - assertCurrent() - updateEnvironmentFromResponse(environment, response) - return response as RuntimeRpcResponse - } - const callBoundRuntimeResult: WebRuntimeResultCaller = async ( - method: string, - params?: unknown, - timeoutMs?: number - ): Promise => { - const response = await callBoundRuntimeEnvelope(method, params, timeoutMs) - if (!response.ok) { - throw new Error(response.error.message) - } - return response.result as TResult - } - return { - resolveFilePath: (filePath) => - resolveRuntimeFilePath( - filePath, - undefined, - callBoundRuntimeResult, - callBoundRuntimeEnvelope, - false, - environment.id - ), - assertMutationSupported: async () => { - assertFileMutationOwnershipCapability( - await callBoundRuntimeResult('status.get', undefined, 15_000) - ) - }, - callRuntimeResult: callBoundRuntimeResult, - getSshState: async (targetId) => - ( - await callBoundRuntimeResult<{ state: SshConnectionState | null }>('ssh.getState', { - targetId - }) - ).state - } -} - -async function saveClipboardImageAsTempFileInRuntime( - contentBase64: string, - args?: { connectionId?: string | null; runtimeEnvironmentId?: string | null } -): Promise { - if (contentBase64.length > MAX_CLIPBOARD_IMAGE_BASE64_CHARS) { - throw new Error(CLIPBOARD_IMAGE_TOO_LARGE_ERROR) - } - const connectionId = args?.connectionId ?? null - const startResponse = await callRuntimeEnvelope<{ uploadId: string }>( - 'clipboard.startImageUpload', - { expectedBase64Length: contentBase64.length, connectionId }, - CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS - ) - if (!startResponse.ok) { - if ( - startResponse.error.code === 'method_not_found' && - contentBase64.length <= CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS - ) { - return callRuntimeResult( - 'clipboard.saveImageAsTempFile', - { contentBase64, connectionId }, - CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS - ) - } - throw new Error(startResponse.error.message) - } - - const { uploadId } = startResponse.result - try { - for ( - let offset = 0; - offset < contentBase64.length; - offset += CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS - ) { - await callRuntimeResult( - 'clipboard.appendImageUploadChunk', - { - uploadId, - offset, - contentBase64: contentBase64.slice( - offset, - offset + CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS - ) - }, - CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS - ) - } - return await callRuntimeResult( - 'clipboard.commitImageUpload', - { uploadId }, - CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS - ) - } catch (error) { - // Why: after chunked paste holds server-side state, release the bounded slot on failure rather than wait for TTL cleanup. - await callRuntimeResult( - 'clipboard.abortImageUpload', - { uploadId }, - CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS - ).catch(() => {}) - throw error - } -} - -async function getRemoteRuntimeStatus(): Promise { - return callRuntimeResult('status.get', undefined, 15_000) -} - -function getClientForEnvironment(environment: StoredWebRuntimeEnvironment): WebRuntimeClient { - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - throw new Error('runtime_manually_disconnected') - } - if (!activeClient || activeClientEnvironmentId !== environment.id) { - activeClient?.close() - activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment)) - activeClientEnvironmentId = environment.id - } - return activeClient -} - -function closeActiveRuntimeClients(): void { - activeClient?.close() - activeClient = null - activeClientEnvironmentId = null - invalidateRuntimeWorktreeCaches() -} - -function disconnectActiveRuntimeEnvironment(): void { - closeActiveRuntimeClients() -} - -function removeActiveRuntimeEnvironment(): void { - disconnectActiveRuntimeEnvironment() - clearStoredWebRuntimeEnvironment() - activeEnvironment = null -} - -function manuallyDisconnectedResponse( - environment: StoredWebRuntimeEnvironment -): RuntimeRpcResponse { - return { - id: 'runtime.manualDisconnect', - ok: false, - error: { - code: 'runtime_manually_disconnected', - message: translate( - 'auto.web.webPreloadApi.runtimeEnvironmentManuallyDisconnected', - 'Runtime environment is manually disconnected.' - ) - }, - _meta: { runtimeId: environment.runtimeId } - } -} - -function resolveEnvironment(selector: string): StoredWebRuntimeEnvironment { - const environment = requireActiveEnvironment() - if (selector === environment.id || selector === environment.name || selector === 'active') { - return environment - } - if (environment.compatibleEnvironmentIds?.includes(selector)) { - return environment - } - throw new Error(`Unknown Orca runtime environment: ${selector}`) -} - -function requireActiveEnvironment(): StoredWebRuntimeEnvironment { - activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment() - if (!activeEnvironment) { - throw new Error('Pair this web client with an Orca server first.') - } - return activeEnvironment -} - -function requireActiveEnvironmentOrNull(): StoredWebRuntimeEnvironment | null { - activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment() - return activeEnvironment -} - -function assertActiveEnvironment(environmentId: string): void { - if (requireActiveEnvironment().id !== environmentId) { - throw new Error('The paired Orca server changed while the request was in progress.') - } -} - -function updateEnvironmentFromResponse( - environment: StoredWebRuntimeEnvironment, - response: RuntimeRpcResponse -): void { - if (activeEnvironment?.id !== environment.id) { - return - } - const runtimeId = response.ok ? response._meta.runtimeId : (response._meta?.runtimeId ?? null) - const pairedDeviceId = - response.ok && - typeof response.result === 'object' && - response.result !== null && - typeof (response.result as { pairedDeviceId?: unknown }).pairedDeviceId === 'string' - ? (response.result as { pairedDeviceId: string }).pairedDeviceId - : undefined - activeEnvironment = updateStoredEnvironmentRuntimeId(environment, runtimeId, pairedDeviceId) -} - -function getStoredSettings(): GlobalSettings { - activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment() - const defaults = getDefaultSettings('~') - const rawStoredSettings = window.localStorage.getItem(SETTINGS_STORAGE_KEY) - const stored = readJson>(SETTINGS_STORAGE_KEY, {}) - const migratedStored = { - ...stored, - ...normalizeAutoRenameBranchFromWorkDefaultOn(stored), - ...normalizeTerminalCursorStyleDefault(stored), - ...normalizeOsc52ClipboardDefaultOn(stored), - terminalCustomThemes: normalizeTerminalCustomThemes(stored.terminalCustomThemes), - uiLanguage: normalizeUiLanguage(stored.uiLanguage) - } - if ( - rawStoredSettings && - (stored.autoRenameBranchFromWork !== migratedStored.autoRenameBranchFromWork || - stored.autoRenameBranchFromWorkDefaultedOn !== - migratedStored.autoRenameBranchFromWorkDefaultedOn || - stored.terminalCursorStyle !== migratedStored.terminalCursorStyle || - stored.terminalCursorStyleDefaultedToBlock !== - migratedStored.terminalCursorStyleDefaultedToBlock || - // Kept even though the terminalCustomThemes reference compare below already forces - // this branch for every stored blob: no migration should rely on that accident. - stored.terminalAllowOsc52Clipboard !== migratedStored.terminalAllowOsc52Clipboard || - stored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== - migratedStored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers || - stored.terminalCustomThemes !== migratedStored.terminalCustomThemes || - stored.uiLanguage !== migratedStored.uiLanguage) - ) { - try { - const parsed = JSON.parse(rawStoredSettings) as unknown - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - writeJson(SETTINGS_STORAGE_KEY, migratedStored) - if (osc52ClipboardDefaultOnOverridesPersistedOff(stored)) { - // Why a raw merge, not readLocalWebUIState(): that path calls back into - // getStoredSettings(), and writing through it here would recurse. - writeJson(UI_STORAGE_KEY, { - ...readJson>(UI_STORAGE_KEY, {}), - osc52ClipboardDefaultOnNoticePending: true - }) - } - } - } catch { - // Keep readJson's invalid-JSON fallback non-destructive. - } - } - return mergeSettings( - { - ...defaults, - floatingTerminalEnabled: false, - rightSidebarOpenByDefault: false, - activeRuntimeEnvironmentId: null - }, - migratedStored - ) -} - -function writeStoredSettings( - settings: GlobalSettings, - explicitActiveRuntimeEnvironmentId?: string | null -): void { - const durable = { ...settings } - if (explicitActiveRuntimeEnvironmentId !== undefined) { - durable.activeRuntimeEnvironmentId = explicitActiveRuntimeEnvironmentId - } else { - const stored = readJson>(SETTINGS_STORAGE_KEY, {}) - if (Object.hasOwn(stored, 'activeRuntimeEnvironmentId')) { - durable.activeRuntimeEnvironmentId = stored.activeRuntimeEnvironmentId ?? null - } else { - delete durable.activeRuntimeEnvironmentId - } - } - writeJson(SETTINGS_STORAGE_KEY, durable) -} - -async function getRuntimeBackedStoredSettings(): Promise { - const local = getStoredSettings() - const requestedEnvironment = requireActiveEnvironmentOrNull() - if (!requestedEnvironment) { - return local - } - try { - const result = await callRuntimeResult<{ settings: Partial }>( - 'settings.get', - undefined, - 15_000 - ) - const runtimeSettings: Partial = {} - const currentEnvironment = requireActiveEnvironmentOrNull() - if (currentEnvironment?.id === requestedEnvironment.id) { - const visibilityDefaults = normalizeWorktreeVisibilityDefaults( - result.settings.worktreeVisibilityDefaults - ) - worktreeVisibilityDefaultsRuntimeEnvironmentId = visibilityDefaults - ? requestedEnvironment.id - : null - worktreeVisibilityDefaultsRuntimeValue = visibilityDefaults ?? null - } - if (typeof result.settings.experimentalNewWorktreeCardStyle === 'boolean') { - runtimeSettings.experimentalNewWorktreeCardStyle = - result.settings.experimentalNewWorktreeCardStyle - } - if (typeof result.settings.compactWorktreeCards === 'boolean') { - runtimeSettings.compactWorktreeCards = result.settings.compactWorktreeCards - } - if (typeof result.settings.minimaxGroupId === 'string') { - runtimeSettings.minimaxGroupId = result.settings.minimaxGroupId - } - if (typeof result.settings.minimaxUsageModels === 'string') { - runtimeSettings.minimaxUsageModels = result.settings.minimaxUsageModels - } - if (Array.isArray(result.settings.prBotAuthorOverrides)) { - runtimeSettings.prBotAuthorOverrides = normalizePRBotAuthorOverrides( - result.settings.prBotAuthorOverrides - ) - } - // Read-only mirror: the host owns this capability and `syncRuntimeBackedSettings` never - // sends it back, so web shows what the host enforces instead of a local value it ignores. - if (typeof result.settings.artifactSharingEnabled === 'boolean') { - runtimeSettings.artifactSharingEnabled = result.settings.artifactSharingEnabled - } - if (typeof result.settings.agentSkillSharingEnabled === 'boolean') { - runtimeSettings.agentSkillSharingEnabled = result.settings.agentSkillSharingEnabled - } - const next = mergeSettings(local, runtimeSettings) - writeStoredSettings(next) - return settingsForActiveVisibilityOwner(next) - } catch { - // Why: unpaired/offline web clients keep a local settings fallback. - return settingsForActiveVisibilityOwner(local) - } -} - -function settingsForActiveVisibilityOwner(settings: GlobalSettings): GlobalSettings { - const environment = requireActiveEnvironmentOrNull() - if (!environment) { - return settings - } - if ( - environment.id === worktreeVisibilityDefaultsRuntimeEnvironmentId && - worktreeVisibilityDefaultsRuntimeValue - ) { - return { ...settings, worktreeVisibilityDefaults: worktreeVisibilityDefaultsRuntimeValue } - } - const { worktreeVisibilityDefaults: _unsupported, ...supportedSettings } = settings - return supportedSettings as GlobalSettings -} - -async function syncRuntimeBackedSettings( - updates: Partial, - localNext: GlobalSettings -): Promise { - const requestedEnvironment = requireActiveEnvironmentOrNull() - if (!requestedEnvironment) { - return localNext - } - const runtimeUpdates: Partial = {} - const visibilityDefaults = normalizeWorktreeVisibilityDefaults(updates.worktreeVisibilityDefaults) - if (visibilityDefaults) { - runtimeUpdates.worktreeVisibilityDefaults = visibilityDefaults - } - if (typeof updates.experimentalNewWorktreeCardStyle === 'boolean') { - runtimeUpdates.experimentalNewWorktreeCardStyle = updates.experimentalNewWorktreeCardStyle - } - if (typeof updates.compactWorktreeCards === 'boolean') { - runtimeUpdates.compactWorktreeCards = updates.compactWorktreeCards - } - if (typeof updates.minimaxGroupId === 'string') { - runtimeUpdates.minimaxGroupId = updates.minimaxGroupId - } - if (typeof updates.minimaxUsageModels === 'string') { - runtimeUpdates.minimaxUsageModels = updates.minimaxUsageModels - } - if (Array.isArray(updates.prBotAuthorOverrides)) { - runtimeUpdates.prBotAuthorOverrides = normalizePRBotAuthorOverrides( - updates.prBotAuthorOverrides - ) - } - if (Object.keys(runtimeUpdates).length === 0) { - return localNext - } - try { - const result = await callRuntimeResult<{ settings: Partial }>( - 'settings.update', - runtimeUpdates, - 15_000 - ) - const runtimeSettings = { ...result.settings } - delete runtimeSettings.activeRuntimeEnvironmentId - const updatedVisibilityDefaults = normalizeWorktreeVisibilityDefaults( - runtimeSettings.worktreeVisibilityDefaults - ) - if ( - requireActiveEnvironmentOrNull()?.id === requestedEnvironment.id && - updatedVisibilityDefaults - ) { - worktreeVisibilityDefaultsRuntimeEnvironmentId = requestedEnvironment.id - worktreeVisibilityDefaultsRuntimeValue = updatedVisibilityDefaults - } - delete runtimeSettings.worktreeVisibilityDefaults - const next = mergeSettings(localNext, runtimeSettings) - writeStoredSettings(next) - return next - } catch (error) { - if (visibilityDefaults) { - throw error - } - // Why: unpaired/offline web clients still need local settings persistence. - return localNext - } -} - -async function updateRuntimePRBotAuthorOverride(args: { - author: string - isBot: boolean -}): Promise { - const local = getStoredSettings() - if (requireActiveEnvironmentOrNull()) { - // Why: don't report a successful mark the authoritative runtime failed to persist and will later overwrite. - const result = await callRuntimeResult<{ settings: Partial }>( - 'settings.updatePRBotAuthorOverride', - args, - 15_000 - ) - const next = mergeSettings(local, { - prBotAuthorOverrides: normalizePRBotAuthorOverrides(result.settings.prBotAuthorOverrides) - }) - writeStoredSettings(next) - return next - } - const next = mergeSettings(local, { - prBotAuthorOverrides: applyPRBotAuthorOverride( - local.prBotAuthorOverrides, - args.author, - args.isBot - ) - }) - writeStoredSettings(next) - return next -} - -function getStoredOnboarding(): OnboardingState { - const storedRaw = window.localStorage.getItem(ONBOARDING_STORAGE_KEY) - if (storedRaw) { - const stored = readJson(ONBOARDING_STORAGE_KEY, getDefaultOnboardingState()) - if (stored.checklist.dismissed) { - return stored - } - const closed = closeWebOnboarding(stored) - writeJson(ONBOARDING_STORAGE_KEY, closed) - return closed - } - const closed = closeWebOnboarding(getDefaultOnboardingState()) - // Why: paired clients already have an Orca server; skip desktop first-run onboarding that would probe browser-local tools. - writeJson(ONBOARDING_STORAGE_KEY, closed) - return closed -} - -/** Resolve the localStorage key for a session partition; non-'local' hosts get a host-suffixed key so they never clobber the local one. */ -function sessionStorageKeyForHost(hostId?: string | null): string { - const resolved = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID - return resolved === LOCAL_EXECUTION_HOST_ID - ? SESSION_STORAGE_KEY - : `${SESSION_STORAGE_KEY}.${resolved}` -} - -function getStoredWorkspaceSession(hostId?: string | null): WorkspaceSessionState { - const resolvedHostId = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID - if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { - return sanitizeWebRuntimeWorkspaceSession( - readJson(sessionStorageKeyForHost(resolvedHostId), getDefaultWorkspaceSession()) - ) - } - const localSession = sanitizeWebRuntimeWorkspaceSession( - readJson(SESSION_STORAGE_KEY, getDefaultWorkspaceSession()) - ) - if (!requireActiveEnvironmentOrNull()) { - return localSession - } - const ui = readLocalWebUIState() - // Why: replaying browser-local terminal handles first creates stale remote PTYs; mirror host session-tabs instead. - return sanitizeWebRuntimeWorkspaceSession({ - ...getDefaultWorkspaceSession(), - activeRepoId: ui.lastActiveRepoId, - activeWorktreeId: ui.lastActiveWorktreeId, - lastVisitedAtByWorktreeId: localSession.lastVisitedAtByWorktreeId - }) -} - -function closeWebOnboarding(base: OnboardingState): OnboardingState { - return { - ...base, - flowVersion: ONBOARDING_FLOW_VERSION, - closedAt: Date.now(), - outcome: 'dismissed', - checklist: { - ...base.checklist, - dismissed: true - } - } -} - -function readLocalWebUIState(): PersistedUIState { - const defaults = getDefaultUIState() - // Why settings first: getStoredSettings() runs the OSC 52 migration, which writes the - // notice arm into UI_STORAGE_KEY. Reading before it would snapshot a pre-arm state that - // every caller then writes back, erasing the arm the stamp can never raise again. - const storedSettings = getStoredSettings() - const stored = readJson>(UI_STORAGE_KEY, {}) - const base = { - ...defaults, - // Why: mirror the main-process missing-property seed from legacy card layout mode when runtime ui.get is unavailable. - worktreeCardProperties: getWorktreeCardModeProperties( - storedSettings.compactWorktreeCards ? 'Compact' : 'Default' - ) - } - if (typeof stored.rightSidebarOpen === 'boolean') { - return mergeWebUIState(base, stored) - } - return mergeWebUIState(base, { - ...stored, - // Why: web fallback lacks main-process normalization; migrate the retired setting only when local UI preference is absent. - rightSidebarOpen: storedSettings.rightSidebarOpenByDefault - }) -} - -function mergeWebUIState( - base: PersistedUIState, - updates: Partial -): PersistedUIState { - const { featureInteractionTelemetryBuckets: _reserved, ...safeUpdates } = - updates as Partial & { - featureInteractionTelemetryBuckets?: unknown - } - void _reserved - return { - ...base, - ...safeUpdates, - workspaceCleanup: mergeWorkspaceCleanupUIState( - base.workspaceCleanup, - safeUpdates.workspaceCleanup - ), - worktreeCardProperties: normalizeWorktreeCardProperties( - safeUpdates.worktreeCardProperties ?? base.worktreeCardProperties - ), - _worktreeCardModeDefaulted: - safeUpdates._worktreeCardModeDefaulted ?? base._worktreeCardModeDefaulted, - agentActivityDisplayMode: normalizeAgentActivityDisplayMode( - safeUpdates.agentActivityDisplayMode ?? base.agentActivityDisplayMode - ), - usagePercentageDisplay: normalizeUsagePercentageDisplay( - safeUpdates.usagePercentageDisplay ?? base.usagePercentageDisplay - ), - statusBarUsageMode: normalizeStatusBarUsageMode( - safeUpdates.statusBarUsageMode ?? base.statusBarUsageMode - ) - } -} - -// Why pin instead of trusting the host's strip: an old host still returns these fields, and for a -// profile a pre-fix drag poisoned they echo back the same runtime:web-* keys this browser minted, -// which then match its own repos and beat every newer drag. -function mergeHostWebUIState(local: PersistedUIState, incoming: PairedUiState): PersistedUIState { - // Why `satisfies Record<...>` rather than a `Pick<...>` annotation: every member is optional in - // PersistedUIState, so Pick would accept a literal that silently skipped a newly added member. - const pinned = { - hideWorkspacesFromOtherDevices: local.hideWorkspacesFromOtherDevices === true, - manualRepoOrder: local.manualRepoOrder, - workspaceHostOrder: local.workspaceHostOrder - } satisfies Record & Partial - return { ...mergeWebUIState(local, incoming), ...pinned } -} - -function mergeFeatureInteractionState( - current: PersistedUIState['featureInteractions'], - incoming: PersistedUIState['featureInteractions'] -): FeatureInteractionState { - const currentNormalized = normalizeFeatureInteractions(current) - const incomingNormalized = normalizeFeatureInteractions(incoming) - const merged: FeatureInteractionState = { ...currentNormalized } - for (const [id, incomingRecord] of Object.entries(incomingNormalized)) { - const featureId = id as FeatureInteractionId - const currentRecord = currentNormalized[featureId] - merged[featureId] = currentRecord - ? { - firstInteractedAt: Math.min( - currentRecord.firstInteractedAt, - incomingRecord.firstInteractedAt - ), - interactionCount: Math.max( - currentRecord.interactionCount, - incomingRecord.interactionCount - ) - } - : incomingRecord - } - return merged -} - -function mergeContextualTourSeenIds( - current: PersistedUIState['contextualToursSeenIds'], - incoming: PersistedUIState['contextualToursSeenIds'] -): ContextualTourId[] { - const merged = new Set(normalizeContextualTourIds(current)) - for (const id of normalizeContextualTourIds(incoming)) { - merged.add(id) - } - return [...merged] -} - -/** Why OR rather than let the host win: the web client migrates its own localStorage - * settings, so an arm raised here has no counterpart on the host, and the plain spread - * would drop it — flipping the opt-out in silence, which is what the notice exists to stop. */ -function mergeOsc52ClipboardNoticePending( - current: PersistedUIState, - incoming: PersistedUIState -): boolean { - return ( - current.osc52ClipboardDefaultOnNoticePending === true || - incoming.osc52ClipboardDefaultOnNoticePending === true - ) -} - -function mergeSettings( - base: GlobalSettings, - updates: Partial, - options: { preserveAutoRenameBranchFromWorkUpdate?: boolean } = {} -): GlobalSettings { - const defaults = getDefaultSettings('~') - const merged = { - ...base, - ...updates, - notifications: { - ...base.notifications, - ...updates.notifications - }, - githubProjects: { - ...(base.githubProjects ?? defaults.githubProjects), - ...updates.githubProjects - } as GlobalSettings['githubProjects'], - disabledTuiAgents: normalizeDisabledTuiAgents( - updates.disabledTuiAgents ?? base.disabledTuiAgents - ), - agentDefaultArgs: normalizeTuiAgentArgsRecord( - updates.agentDefaultArgs ?? base.agentDefaultArgs - ), - agentDefaultEnv: normalizeTuiAgentEnvRecord(updates.agentDefaultEnv ?? base.agentDefaultEnv), - voice: { - ...(base.voice ?? defaults.voice), - ...updates.voice - } as NonNullable, - activeRuntimeEnvironmentId: Object.hasOwn(updates, 'activeRuntimeEnvironmentId') - ? (updates.activeRuntimeEnvironmentId ?? null) - : (base.activeRuntimeEnvironmentId ?? null), - terminalCustomThemes: normalizeTerminalCustomThemes( - updates.terminalCustomThemes ?? base.terminalCustomThemes - ), - uiLanguage: normalizeUiLanguage(updates.uiLanguage ?? base.uiLanguage) - } - return { - ...merged, - ...normalizeAutoRenameBranchFromWorkDefaultOn(merged, { - preserveExplicitValue: options.preserveAutoRenameBranchFromWorkUpdate - }) - } -} - -async function listAllRuntimeWorktrees(): Promise { - if (cachedWorktrees && Date.now() - cachedWorktrees.loadedAt < 5_000) { - return cachedWorktrees.worktrees - } - const owned = await callRuntimeResultWithOwner<{ worktrees: Worktree[] }>('worktree.list', { - limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT - }) - const worktrees = owned.result.worktrees.map((worktree) => - withRuntimeWorktreeOwner(worktree, owned.hostId) - ) - assertActiveEnvironment(owned.environmentId) - cachedWorktrees = { loadedAt: Date.now(), worktrees } - return worktrees -} - -async function listAllRuntimeDetectedWorktrees( - callResult: WebRuntimeResultCaller = callRuntimeResult, - callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, - useCache = true, - expectedEnvironmentId = requireActiveEnvironment().id -): Promise { - if ( - useCache && - cachedDetectedWorktrees && - Date.now() - cachedDetectedWorktrees.loadedAt < 5_000 - ) { - return cachedDetectedWorktrees.worktrees - } - - assertActiveEnvironment(expectedEnvironmentId) - const repos = (await callResult<{ repos: Repo[] }>('repo.list')).repos - const detectedLists = await Promise.all( - repos.map((repo) => - callRuntimeDetectedWorktrees(repo.id, expectedEnvironmentId, callResult, callEnvelope) - ) - ) - const worktrees = detectedLists.flatMap((result) => result.worktrees) - assertActiveEnvironment(expectedEnvironmentId) - if (useCache) { - cachedDetectedWorktrees = { loadedAt: Date.now(), worktrees } - } - return worktrees -} - -async function callRuntimeDetectedWorktrees( - repoId: string, - expectedEnvironmentId = requireActiveEnvironment().id, - callResult: WebRuntimeResultCaller = callRuntimeResult, - callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope -): Promise { - assertActiveEnvironment(expectedEnvironmentId) - const hostId = toRuntimeExecutionHostId(expectedEnvironmentId) - const response = await callEnvelope( - 'worktree.detectedList', - { repo: repoId }, - 15_000 - ) - if (response.ok) { - return { - ...response.result, - worktrees: response.result.worktrees.map((worktree) => - withRuntimeWorktreeOwner(worktree, hostId) - ) - } - } - if (response.error.code !== 'method_not_found') { - throw new Error(response.error.message) - } - - assertActiveEnvironment(expectedEnvironmentId) - const legacy = await callResult<{ worktrees: Worktree[] }>( - 'worktree.list', - { repo: repoId, limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT }, - 15_000 - ) - return toLegacyDetectedWorktreeResult( - repoId, - legacy.worktrees.map((worktree) => withRuntimeWorktreeOwner(worktree, hostId)) - ) -} - -function toLegacyDetectedWorktreeResult( - repoId: string, - worktrees: Worktree[] -): DetectedWorktreeListResult { - return { - repoId, - authoritative: true, - source: 'session-fallback', - worktrees: worktrees.map((worktree) => ({ - ...worktree, - ownership: 'orca-managed', - selectedCheckout: false, - visible: true - })) - } -} - -function isMissingPathError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false - } - return /\bENOENT\b|not found|no such file/i.test(error.message) -} - -async function resolveRuntimeWorktreeByPath( - worktreePath: string, - callResult: WebRuntimeResultCaller = callRuntimeResult, - callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, - useDetectedWorktreeCache = true, - expectedEnvironmentId = requireActiveEnvironment().id -): Promise { - // Why: hidden-but-open worktrees must still resolve, but `worktree.list` is sidebar-visible only — resolve via detected rows. - const worktrees = await listAllRuntimeDetectedWorktrees( - callResult, - callEnvelope, - useDetectedWorktreeCache, - expectedEnvironmentId - ) - const match = worktrees - .map((worktree) => ({ - worktree, - relativePath: relativePathInsideRoot(worktree.path, worktreePath) - })) - .filter((entry) => entry.relativePath !== null) - .sort((a, b) => b.worktree.path.length - a.worktree.path.length)[0] - if (!match) { - throw new Error(`No runtime worktree owns ${worktreePath}`) - } - return match.worktree -} - -async function resolveRuntimeFilePath( - filePath: string, - preferredWorktreePath?: string, - callResult: WebRuntimeResultCaller = callRuntimeResult, - callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, - useDetectedWorktreeCache = true, - expectedEnvironmentId = requireActiveEnvironment().id -): Promise<{ worktree: Worktree; relativePath: string }> { - const worktree = preferredWorktreePath - ? await resolveRuntimeWorktreeByPath( - preferredWorktreePath, - callResult, - callEnvelope, - useDetectedWorktreeCache, - expectedEnvironmentId - ) - : await resolveRuntimeWorktreeByPath( - filePath, - callResult, - callEnvelope, - useDetectedWorktreeCache, - expectedEnvironmentId - ) - const relativePath = relativePathInsideRoot(worktree.path, filePath) - if (relativePath === null) { - throw new Error(`File is outside runtime worktree: ${filePath}`) - } - return { worktree, relativePath } -} - -async function mutateGitPath( - method: string, - worktreePath: string, - filePath: string -): Promise { - const file = await resolveRuntimeFilePath(filePath, worktreePath) - await callRuntimeResult(method, { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - filePath: file.relativePath - }) -} - -async function mutateGitPaths( - method: string, - worktreePath: string, - filePaths: string[] -): Promise { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult(method, { worktree: toRuntimeWorktreeSelector(worktree.id), filePaths }) -} - -function mapRepoPathArg(args: unknown): unknown { - if (!args || typeof args !== 'object' || !('repoPath' in args)) { - return args - } - const record = args as Record - const repoId = typeof record.repoId === 'string' && record.repoId.trim() ? record.repoId : null - return { - ...record, - // Why: duplicate checked-out repos make path/name selectors ambiguous; prefer the explicit repo id the renderer passes. - repo: repoId ? `id:${repoId}` : record.repoPath - } -} - -function mapRuntimeNamespaceArg(prefix: string, args: unknown): unknown { - if (prefix !== 'hostedReview') { - return args - } - return mapRepoPathArg(args) -} - -function createEmptyMemorySnapshot(): MemorySnapshot { - const emptyUsage = { cpu: 0, memory: 0 } - return { - app: { ...emptyUsage, main: emptyUsage, renderer: emptyUsage, other: emptyUsage, history: [] }, - worktrees: [], - host: { - totalMemory: 0, - freeMemory: 0, - availableMemory: 0, - availableMemorySource: 'free-memory', - usedMemory: 0, - memoryUsagePercent: 0, - cpuCoreCount: navigator.hardwareConcurrency || 1, - loadAverage1m: 0 - }, - processMemoryMetric: getBrowserPlatform() === 'win32' ? 'working-set' : 'rss', - totalCpu: 0, - totalMemory: 0, - collectedAt: Date.now() - } -} - -function getBrowserPlatform(): NodeJS.Platform { - if (navigator.userAgent.includes('Windows')) { - return 'win32' - } - if (navigator.userAgent.includes('Linux')) { - return 'linux' - } - return 'darwin' -} - -function readJson(key: string, fallback: T): T { - const raw = window.localStorage.getItem(key) - if (!raw) { - return cloneJson(fallback) - } - try { - return { ...cloneJson(fallback), ...JSON.parse(raw) } as T - } catch { - return cloneJson(fallback) - } -} - -function writeJson(key: string, value: T): void { - window.localStorage.setItem(key, JSON.stringify(value)) -} - -function cloneJson(value: T): T { - return JSON.parse(JSON.stringify(value)) as T -} - -function withFallback(target: T, path: string[]): T { - return new Proxy(target, { - get(current, property, receiver) { - if (property in current) { - const value = Reflect.get(current, property, receiver) as unknown - if (value && typeof value === 'object' && !Array.isArray(value)) { - return withFallback(value as object, [...path, String(property)]) - } - return value - } - return createFallbackProxy([...path, String(property)]) - } - }) -} - -function createFallbackProxy( - path: string[], - applyOverride?: (path: string[], args: unknown[]) => unknown -): never { - const fn = () => undefined - return new Proxy(fn, { - get(_target, property) { - if (property === 'then') { - return undefined - } - return createFallbackProxy([...path, String(property)], applyOverride) - }, - apply(_target, _thisArg, args) { - if (applyOverride) { - return applyOverride(path, args) - } - return getFallbackResult(path, args) - } - }) as never -} - -function getFallbackResult(path: string[], args: unknown[]): unknown { - const name = path.at(-1) ?? '' - if (name.startsWith('on')) { - return noopUnsubscribe - } - if (name.startsWith('is') || name.startsWith('has') || name === 'pathExists') { - return Promise.resolve(false) - } - if (name.startsWith('list') || name.startsWith('detect')) { - return Promise.resolve([]) - } - if (name.startsWith('preview')) { - return Promise.resolve({ found: false, diff: {}, unsupportedKeys: [] }) - } - if (name.startsWith('get') && name.endsWith('Status')) { - return Promise.resolve([]) - } - if (name === 'write' || name === 'resize' || name === 'reportGeometry') { - return undefined - } - if (args.length === 0 && (name === 'getZoomLevel' || name === 'declarePendingPaneSerializer')) { - return 0 - } - return Promise.resolve(undefined) -} - -function noopUnsubscribe(): void {} diff --git a/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts b/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts new file mode 100644 index 00000000000..4696643e08a --- /dev/null +++ b/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WebRuntimeClient } from './web-runtime-client' + +class TimeoutBudgetWebSocket { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + readyState = TimeoutBudgetWebSocket.CONNECTING + binaryType = 'arraybuffer' + onopen: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onclose: (() => void) | null = null + onerror: (() => void) | null = null + close = vi.fn() + send = vi.fn() +} + +describe('WebRuntimeClient timeout budget', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('window', { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + atob: (value: string) => Buffer.from(value, 'base64').toString('binary'), + btoa: (value: string) => Buffer.from(value, 'binary').toString('base64') + }) + vi.stubGlobal('WebSocket', TimeoutBudgetWebSocket) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('starts a fresh request timeout after the connection wait completes', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + client.close() + let resolveConnection!: () => void + const connection = new Promise((resolve) => { + resolveConnection = resolve + }) + // Test seam: isolate call's connection and request phases from socket handshaking. + const internals = client as unknown as { + waitForConnected: (timeoutMs?: number) => Promise + sendEncrypted: (message: unknown) => boolean + } + internals.waitForConnected = vi.fn(() => connection) + internals.sendEncrypted = vi.fn(() => true) + + const call = client.call('runtime.two-phase-timeout', undefined, { timeoutMs: 25 }) + let settled = false + void call.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + + await vi.advanceTimersByTimeAsync(60_000) + expect(settled).toBe(false) + expect(internals.waitForConnected).toHaveBeenCalledWith(25) + + resolveConnection() + await Promise.resolve() + await vi.advanceTimersByTimeAsync(24) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await expect(call).rejects.toThrow('Request timed out: runtime.two-phase-timeout') + }) +}) From f57114a106dc0be85c5927130d707e0a2b3777fe Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:45:08 -0700 Subject: [PATCH 31/65] refactor(renderer): split terminal store slice (#16184) --- config/max-lines-baseline.txt | 1 - .../codex-restart-status-summary.ts | 2 +- src/renderer/src/lib/use-tab-agent.ts | 2 +- .../direct-ssh-terminal-recovery-types.ts | 2 +- .../slices/direct-ssh-terminal-recovery.ts | 2 +- .../store-session-terminal-reconnect.test.ts | 187 + .../slices/store-sleep-shutdown-order.test.ts | 134 + .../terminal-tab-retirement-store.test.ts | 83 + src/renderer/src/store/slices/terminals.ts | 4313 +---------------- .../terminals/direct-ssh-terminal-bindings.ts | 105 + .../src/store/terminals/terminal-actions.ts | 271 ++ .../terminal-active-workspace-creation.ts | 69 + .../src/store/terminals/terminal-contracts.ts | 38 + .../terminals/terminal-ephemeral-state.ts | 237 + .../store/terminals/terminal-layout-state.ts | 154 + .../terminals/terminal-pane-hibernation.ts | 243 + .../store/terminals/terminal-pty-bindings.ts | 240 + .../terminals/terminal-pty-identities.ts | 102 + .../store/terminals/terminal-pty-release.ts | 161 + .../store/terminals/terminal-restart-state.ts | 202 + .../terminals/terminal-shutdown-guards.ts | 185 + .../terminals/terminal-shutdown-state.ts | 194 + .../src/store/terminals/terminal-shutdown.ts | 172 + .../terminals/terminal-startup-queues.ts | 133 + .../src/store/terminals/terminal-state.ts | 125 + .../store/terminals/terminal-tab-attention.ts | 126 + .../terminals/terminal-tab-close-providers.ts | 68 + .../src/store/terminals/terminal-tab-close.ts | 266 + .../store/terminals/terminal-tab-creation.ts | 245 + .../terminals/terminal-tab-navigation.ts | 121 + .../terminals/terminal-tab-presentation.ts | 203 + .../terminals/terminal-workspace-routing.ts | 115 + .../workspace-terminal-hydration-patch.ts | 222 + .../terminals/workspace-terminal-hydration.ts | 209 + .../workspace-terminal-layout-plan.ts | 58 + .../workspace-terminal-placeholders.ts | 91 + .../workspace-terminal-reconnect-plan.ts | 88 + .../terminals/workspace-terminal-reconnect.ts | 195 + .../terminals/workspace-terminal-row-plan.ts | 101 + .../workspace-terminal-ssh-placeholders.ts | 51 + src/renderer/src/store/types.ts | 2 +- 41 files changed, 5234 insertions(+), 4284 deletions(-) create mode 100644 src/renderer/src/store/slices/store-sleep-shutdown-order.test.ts create mode 100644 src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts create mode 100644 src/renderer/src/store/terminals/terminal-actions.ts create mode 100644 src/renderer/src/store/terminals/terminal-active-workspace-creation.ts create mode 100644 src/renderer/src/store/terminals/terminal-contracts.ts create mode 100644 src/renderer/src/store/terminals/terminal-ephemeral-state.ts create mode 100644 src/renderer/src/store/terminals/terminal-layout-state.ts create mode 100644 src/renderer/src/store/terminals/terminal-pane-hibernation.ts create mode 100644 src/renderer/src/store/terminals/terminal-pty-bindings.ts create mode 100644 src/renderer/src/store/terminals/terminal-pty-identities.ts create mode 100644 src/renderer/src/store/terminals/terminal-pty-release.ts create mode 100644 src/renderer/src/store/terminals/terminal-restart-state.ts create mode 100644 src/renderer/src/store/terminals/terminal-shutdown-guards.ts create mode 100644 src/renderer/src/store/terminals/terminal-shutdown-state.ts create mode 100644 src/renderer/src/store/terminals/terminal-shutdown.ts create mode 100644 src/renderer/src/store/terminals/terminal-startup-queues.ts create mode 100644 src/renderer/src/store/terminals/terminal-state.ts create mode 100644 src/renderer/src/store/terminals/terminal-tab-attention.ts create mode 100644 src/renderer/src/store/terminals/terminal-tab-close-providers.ts create mode 100644 src/renderer/src/store/terminals/terminal-tab-close.ts create mode 100644 src/renderer/src/store/terminals/terminal-tab-creation.ts create mode 100644 src/renderer/src/store/terminals/terminal-tab-navigation.ts create mode 100644 src/renderer/src/store/terminals/terminal-tab-presentation.ts create mode 100644 src/renderer/src/store/terminals/terminal-workspace-routing.ts create mode 100644 src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts create mode 100644 src/renderer/src/store/terminals/workspace-terminal-hydration.ts create mode 100644 src/renderer/src/store/terminals/workspace-terminal-layout-plan.ts create mode 100644 src/renderer/src/store/terminals/workspace-terminal-placeholders.ts create mode 100644 src/renderer/src/store/terminals/workspace-terminal-reconnect-plan.ts create mode 100644 src/renderer/src/store/terminals/workspace-terminal-reconnect.ts create mode 100644 src/renderer/src/store/terminals/workspace-terminal-row-plan.ts create mode 100644 src/renderer/src/store/terminals/workspace-terminal-ssh-placeholders.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index ae05468b04a..67f8212e06d 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -143,7 +143,6 @@ inline src/renderer/src/store/slices/jira.ts inline src/renderer/src/store/slices/linear.ts inline src/renderer/src/store/slices/repos.ts inline src/renderer/src/store/slices/tabs.ts -inline src/renderer/src/store/slices/terminals.ts inline src/renderer/src/store/slices/ui.ts inline src/renderer/src/store/slices/workspace-cleanup.ts inline src/renderer/src/web/web-runtime-client.ts diff --git a/src/renderer/src/components/status-bar/codex-restart-status-summary.ts b/src/renderer/src/components/status-bar/codex-restart-status-summary.ts index 8d7c44d458f..9a26d136c9b 100644 --- a/src/renderer/src/components/status-bar/codex-restart-status-summary.ts +++ b/src/renderer/src/components/status-bar/codex-restart-status-summary.ts @@ -1,5 +1,5 @@ import { awaitsCodexRestartAnswer } from '@/components/codex-restart-notice-state' -import type { CodexRestartNotice } from '@/store/slices/terminals' +import type { CodexRestartNotice } from '@/store/terminals/terminal-contracts' type TabLookup = Record diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 09afd63c43d..b6c88400632 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { useAppStore } from '@/store' import { isShellProcess } from '../../../shared/agent-detection' -import { worktreeUsesRemoteConnection } from '@/store/slices/terminals' +import { worktreeUsesRemoteConnection } from '@/store/terminals/terminal-workspace-routing' import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' import { diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-recovery-types.ts b/src/renderer/src/store/slices/direct-ssh-terminal-recovery-types.ts index f59015b6ee7..7ca0b189071 100644 --- a/src/renderer/src/store/slices/direct-ssh-terminal-recovery-types.ts +++ b/src/renderer/src/store/slices/direct-ssh-terminal-recovery-types.ts @@ -1,6 +1,6 @@ import type { DirectSshAuthority } from '../../../../shared/ssh-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import type { CodexRestartNotice } from './terminals' +import type { CodexRestartNotice } from '../terminals/terminal-contracts' export type DirectSshPaneRetryAttemptId = string & { readonly __directSshPaneRetryAttemptId: unique symbol diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts b/src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts index 4bc678a0437..ef67b0ce642 100644 --- a/src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts +++ b/src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts @@ -1,6 +1,6 @@ import type { DirectSshAuthority } from '../../../../shared/ssh-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import type { CodexRestartNotice } from './terminals' +import type { CodexRestartNotice } from '../terminals/terminal-contracts' import { directSshAuthoritiesEqual, liveBindingMatches, diff --git a/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts b/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts index 26ed7a6c9f9..81c6350680d 100644 --- a/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts +++ b/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts @@ -5,6 +5,7 @@ import { createTestStore, makeWorktree, makeTab, makeLayout } from './store-test import { computeVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { createStoreSessionMockApi } from './store-session-test-harness' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' // Mock sonner (imported by repos.ts) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -458,4 +459,190 @@ describe('reconnectPersistedTerminals', () => { }) ).toEqual([]) }) + it('leaves reconnect state untouched when cancellation is already signaled', async () => { + const store = createDaemonEnabledStore() + const worktreeId = 'repo1::/path/wt1' + const tab = makeTab({ id: 'tab1', worktreeId, ptyId: null }) + store.setState({ + tabsByWorktree: { [worktreeId]: [tab] }, + ptyIdsByTabId: { tab1: [] }, + pendingReconnectWorktreeIds: [worktreeId], + pendingReconnectTabByWorktree: { [worktreeId]: ['tab1'] }, + pendingReconnectPtyIdByTabId: { tab1: 'old-pty-1' }, + deferredSshSessionIdsByTabId: { tab1: 'deferred-pty-1' }, + workspaceSessionReady: false + }) + const before = store.getState() + const controller = new AbortController() + controller.abort() + + await store.getState().reconnectPersistedTerminals(controller.signal) + + const after = store.getState() + expect(after.tabsByWorktree).toBe(before.tabsByWorktree) + expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId) + expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds) + expect(after.pendingReconnectTabByWorktree).toBe(before.pendingReconnectTabByWorktree) + expect(after.pendingReconnectPtyIdByTabId).toBe(before.pendingReconnectPtyIdByTabId) + expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId) + expect(after.workspaceSessionReady).toBe(false) + }) + it('leaves reconnect state untouched when cancellation arrives before final publication', async () => { + const store = createDaemonEnabledStore() + const worktreeId = 'repo1::/path/wt1' + store.setState({ + tabsByWorktree: { + [worktreeId]: [makeTab({ id: 'tab1', worktreeId, ptyId: null })] + }, + ptyIdsByTabId: { tab1: [] }, + pendingReconnectWorktreeIds: [worktreeId], + pendingReconnectTabByWorktree: { [worktreeId]: ['tab1'] }, + pendingReconnectPtyIdByTabId: { tab1: 'old-pty-1' }, + deferredSshSessionIdsByTabId: { tab1: 'deferred-pty-1' }, + workspaceSessionReady: false + }) + const before = store.getState() + const controller = new AbortController() + let abortReads = 0 + Object.defineProperty(controller.signal, 'aborted', { + configurable: true, + get: () => { + abortReads += 1 + return abortReads > 1 + } + }) + + await store.getState().reconnectPersistedTerminals(controller.signal) + + const after = store.getState() + expect(abortReads).toBe(2) + expect(after.tabsByWorktree).toBe(before.tabsByWorktree) + expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId) + expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds) + expect(after.pendingReconnectTabByWorktree).toBe(before.pendingReconnectTabByWorktree) + expect(after.pendingReconnectPtyIdByTabId).toBe(before.pendingReconnectPtyIdByTabId) + expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId) + expect(after.workspaceSessionReady).toBe(false) + }) + + it('leaves scoped reconnect state untouched when direct SSH authority is stale at entry', async () => { + const store = createDaemonEnabledStore() + const targetId = 'ssh-1' + const worktreeId = 'repo1::/remote/wt1' + const staleAuthority: DirectSshAuthority = { + targetId, + providerEpoch: 'epoch-1' as SshProviderEpoch, + connectionGeneration: 1 + } + store.setState({ + tabsByWorktree: { + [worktreeId]: [makeTab({ id: 'tab1', worktreeId, ptyId: null })] + }, + ptyIdsByTabId: { tab1: [] }, + pendingReconnectWorktreeIds: [worktreeId], + pendingReconnectTabByWorktree: { [worktreeId]: ['tab1'] }, + pendingReconnectPtyIdByTabId: { tab1: 'ssh:ssh-1@@relay-1' }, + deferredSshSessionIdsByTabId: { tab1: 'ssh:ssh-1@@deferred-1' }, + sshConnectionStates: new Map([ + [ + targetId, + { + targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: staleAuthority.providerEpoch, + connectionGeneration: 2 + } + ] + ]), + workspaceSessionReady: false + }) + const before = store.getState() + + await store.getState().reconnectPersistedTerminals(undefined, { + directSshAuthority: staleAuthority, + workspaceKeys: [worktreeId] + }) + + const after = store.getState() + expect(after.tabsByWorktree).toBe(before.tabsByWorktree) + expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId) + expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds) + expect(after.pendingReconnectTabByWorktree).toBe(before.pendingReconnectTabByWorktree) + expect(after.pendingReconnectPtyIdByTabId).toBe(before.pendingReconnectPtyIdByTabId) + expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId) + expect(after.workspaceSessionReady).toBe(before.workspaceSessionReady) + }) + + it('leaves scoped reconnect state untouched when direct SSH authority rotates before publication', async () => { + const store = createDaemonEnabledStore() + const targetId = 'ssh-1' + const worktreeId = 'repo1::/remote/wt1' + const authority: DirectSshAuthority = { + targetId, + providerEpoch: 'epoch-1' as SshProviderEpoch, + connectionGeneration: 1 + } + let authorityReads = 0 + const currentAuthorityState = { + targetId, + status: 'connected' as const, + error: null, + reconnectAttempt: 0, + providerEpoch: authority.providerEpoch, + connectionGeneration: authority.connectionGeneration + } + const rotatedAuthorityState = { + ...currentAuthorityState, + connectionGeneration: 2 + } + const sshConnectionStates = new Map([[targetId, currentAuthorityState]]) + const originalGet = sshConnectionStates.get.bind(sshConnectionStates) + sshConnectionStates.get = ((key: string) => { + authorityReads += 1 + return authorityReads >= 4 ? rotatedAuthorityState : originalGet(key) + }) as typeof sshConnectionStates.get + store.setState({ + repos: [ + { + id: 'repo1', + path: '/repo1', + displayName: 'Repo 1', + badgeColor: '#000', + addedAt: 0, + connectionId: targetId + } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/remote/wt1' })] + }, + tabsByWorktree: { + [worktreeId]: [makeTab({ id: 'tab1', worktreeId, ptyId: null })] + }, + ptyIdsByTabId: { tab1: [] }, + pendingReconnectWorktreeIds: [worktreeId], + pendingReconnectTabByWorktree: { [worktreeId]: ['tab1'] }, + pendingReconnectPtyIdByTabId: { tab1: 'ssh:ssh-1@@relay-1' }, + deferredSshSessionIdsByTabId: {}, + sshConnectionStates, + workspaceSessionReady: false + }) + const before = store.getState() + + await store.getState().reconnectPersistedTerminals(undefined, { + directSshAuthority: authority, + workspaceKeys: [worktreeId] + }) + + const after = store.getState() + expect(authorityReads).toBeGreaterThanOrEqual(4) + expect(after.tabsByWorktree).toBe(before.tabsByWorktree) + expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId) + expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds) + expect(after.pendingReconnectTabByWorktree).toBe(before.pendingReconnectTabByWorktree) + expect(after.pendingReconnectPtyIdByTabId).toBe(before.pendingReconnectPtyIdByTabId) + expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId) + expect(after.workspaceSessionReady).toBe(false) + }) }) diff --git a/src/renderer/src/store/slices/store-sleep-shutdown-order.test.ts b/src/renderer/src/store/slices/store-sleep-shutdown-order.test.ts new file mode 100644 index 00000000000..9e4bf3d9970 --- /dev/null +++ b/src/renderer/src/store/slices/store-sleep-shutdown-order.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { getDefaultSettings } from '../../../../shared/constants' +import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' +import { createCompatibleRuntimeStatusResponseIfNeeded } from '../../runtime/runtime-compatibility-test-fixture' +import { createTestStore, makeRuntimeOwnedWorktree, makeTab, seedStore } from './store-test-helpers' +import { + applySleepRuntimeRpcDefault, + createStoreCascadesMockApi +} from './store-cascades-test-harness' + +const orderHarness = vi.hoisted(() => { + const events: string[] = [] + return { + events, + unregister: vi.fn(() => [ + { + ptyId: 'pty-1', + commit: () => events.push('handler-commit'), + rollback: vi.fn() + } + ]), + settleDeferred: vi.fn(() => events.push('deferred-exit-settlement')) + } +}) + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: orderHarness.unregister +})) + +vi.mock('@/components/terminal-pane/pty-shutdown-exit-deferral', () => ({ + clearCommittedPtyShutdownSettlements: vi.fn(), + hasCommittedPtyShutdownSettlement: vi.fn(() => false), + markCommittedPtyShutdowns: vi.fn(), + noteCommittedPtyShutdownSettlements: vi.fn(), + settleDeferredPtyShutdownExits: orderHarness.settleDeferred +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const mockApi = createStoreCascadesMockApi() + +describe('shutdownWorktreeTerminals ordering', () => { + beforeEach(() => { + vi.clearAllMocks() + orderHarness.events.length = 0 + shutdownBufferCaptures.clear() + applySleepRuntimeRpcDefault(mockApi) + }) + + it('orders capture, guards, handlers, owner stop, renderer kill, commit, cleanup, and exit settlement', async () => { + const store = createTestStore() + const worktreeId = 'repo1::/path/wt1' + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + worktreesByRepo: { + repo1: [makeRuntimeOwnedWorktree({ id: worktreeId, repoId: 'repo1' })] + }, + tabsByWorktree: { + [worktreeId]: [makeTab({ id: 'tab-1', worktreeId, ptyId: 'pty-1' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + dropAgentStatusByWorktree: vi.fn(() => orderHarness.events.push('agent-cleanup')), + clearPaneForegroundAgentByWorktree: vi.fn() + }) + shutdownBufferCaptures.set('tab-1', () => orderHarness.events.push('buffer-capture')) + const unsubscribe = store.subscribe((state, previous) => { + if (!previous.pendingPtyShutdownIds['pty-1'] && state.pendingPtyShutdownIds['pty-1']) { + orderHarness.events.push('shutdown-guard-publication') + } + if (previous.ptyIdsByTabId['tab-1']?.length && state.ptyIdsByTabId['tab-1']?.length === 0) { + orderHarness.events.push('terminal-state-cleanup') + } + }) + orderHarness.unregister.mockImplementationOnce(() => { + orderHarness.events.push('handler-unregister') + return [ + { + ptyId: 'pty-1', + commit: () => orderHarness.events.push('handler-commit'), + rollback: vi.fn() + } + ] + }) + mockApi.runtimeEnvironments.call.mockImplementation(async (args: { method: string }) => { + const compatibilityResponse = createCompatibleRuntimeStatusResponseIfNeeded(args) + if (compatibilityResponse) { + return compatibilityResponse + } + if (args.method === 'terminal.sleep') { + orderHarness.events.push('owner-runtime-rpc') + } + return { + id: 'rpc-order', + ok: true, + result: + args.method === 'terminal.sleep' + ? { + stopped: 0, + stoppedPtyIds: [], + livePtyIds: [], + postStopVerified: true + } + : {}, + _meta: { runtimeId: 'runtime-1' } + } + }) + mockApi.pty.kill.mockImplementationOnce(async () => { + orderHarness.events.push('renderer-kill') + }) + + await store.getState().shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true }) + expect(orderHarness.events).toEqual([ + 'buffer-capture', + 'shutdown-guard-publication', + 'handler-unregister', + 'owner-runtime-rpc', + 'renderer-kill', + 'handler-commit', + 'terminal-state-cleanup', + 'agent-cleanup', + 'deferred-exit-settlement' + ]) + unsubscribe() + }) +}) diff --git a/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts b/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts index 5727f5474c6..b6a2bab7b4a 100644 --- a/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts +++ b/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts @@ -244,6 +244,89 @@ describe('terminal tab retirement store boundary', () => { expect(store.getState().tabsByWorktree['wt-1']).toEqual([]) }) + it('retires parked ownership before teardown and sweeps published state before unified close', () => { + const store = createRetirementStore() + const events: string[] = [] + const unified = makeUnifiedTab({ + id: 'unified-tab-1', + entityId: 'tab-1', + worktreeId: 'wt-1', + groupId: 'group-1' + }) + mockKill.mockImplementationOnce(() => { + events.push('provider-kill') + return Promise.resolve() + }) + seedStore(store, { + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'pty-1' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + unifiedTabsByWorktree: { 'wt-1': [unified] }, + dropAgentStatusByTabPrefix: vi.fn(() => { + expect(store.getState().tabsByWorktree['wt-1']).toEqual([]) + events.push('state-sweep') + }), + closeUnifiedTab: vi.fn(() => { + events.push('unified-close') + return null + }) + }) + parkedWatchersByTabId.set('tab-1', { + worktreeId: 'wt-1', + tabPtyId: 'pty-1', + paneIdByPtyId: new Map([['pty-1', 1]]), + disposersByPtyId: new Map([['pty-1', () => events.push('parked-retire')]]) + }) + const unsubscribe = store.subscribe((state, previous) => { + const wasPresent = previous.tabsByWorktree['wt-1']?.some((tab) => tab.id === 'tab-1') + const isPresent = state.tabsByWorktree['wt-1']?.some((tab) => tab.id === 'tab-1') + if (wasPresent && !isPresent) { + events.push('row-removal') + } + }) + + expect(() => store.getState().closeTab('tab-1')).not.toThrow() + + expect(events).toEqual([ + 'parked-retire', + 'provider-kill', + 'row-removal', + 'state-sweep', + 'unified-close' + ]) + unsubscribe() + }) + + it('starts mixed provider teardown and classifies both rejection kinds after synchronous retirement', async () => { + const store = createRetirementStore() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockKill.mockRejectedValueOnce(new Error('local unavailable')) + mockRuntimeCall.mockRejectedValueOnce(new Error('runtime unavailable')) + seedStore(store, { + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'pty-local' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-local', 'remote:runtime-terminal'] } + }) + + expect(() => store.getState().closeTab('tab-1')).not.toThrow() + expect(mockKill).toHaveBeenCalledWith('pty-local') + expect(mockRuntimeCall).toHaveBeenCalledWith({ + method: 'terminal.close', + params: { terminal: 'runtime-terminal' } + }) + expect(store.getState().tabsByWorktree['wt-1']).toEqual([]) + await vi.waitFor(() => + expect(warn).toHaveBeenCalledWith('[terminal-retirement] provider teardown failed', { + tabId: 'tab-1', + localOrSshFailures: 1, + runtimeFailures: 1 + }) + ) + warn.mockRestore() + }) + it('keeps the tab retired and reports provider rejection without an unhandled promise', async () => { const store = createRetirementStore() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 90cea807728..4286dd0ac53 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -1,1030 +1,23 @@ -/* eslint-disable max-lines */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import type { WorkspaceKey } from '../../../../shared/folder-workspace-types' -import type { Repo } from '../../../../shared/repo-types' -import type { Tab } from '../../../../shared/tab-types' -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' -import type { TuiAgent } from '../../../../shared/tui-agent' -import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' -import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' -import type { Worktree } from '../../../../shared/worktree/types' -import type { - AgentProviderSessionMetadata, - SleepingAgentLaunchConfig -} from '../../../../shared/agent-session-resume' -import type { DirectSshAuthority } from '../../../../shared/ssh-types' -import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' -import { - DEFAULT_REPO_BADGE_COLOR, - FLOATING_TERMINAL_WORKTREE_ID -} from '../../../../shared/constants' -import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' -import { - folderWorkspaceKey, - parseWorkspaceKey, - worktreeWorkspaceKey -} from '../../../../shared/workspace-scope' -import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature' -import { - isTerminalLeafId, - makePaneKey, - parseLegacyNumericPaneKey, - parsePaneKey -} from '../../../../shared/stable-pane-id' -import { isValidHostTerminalTabId } from '../../../../shared/terminal-tab-id' -import { buildByIdIndex, buildWorktreeByIdIndex } from './worktree-by-id-index' -import { resolveActiveTabOwnerWorktreeId } from './active-tab-owner-worktree' -import { isSameCodexRestartNoticeAccount } from './codex-restart-notice-account-identity' -import { - getRepoIdFromWorktreeId, - splitWorktreeIdForFilesystem -} from '../../../../shared/worktree/id' -import { isWslUncPath } from '../../../../shared/wsl-paths' -import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' -import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' -import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' -import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../../shared/local-windows-terminal-runtime' -import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' -import type { AgentStartedTelemetry } from '../../lib/worktree-startup-payload' -import type { AiVaultSessionTitle } from '../../../../shared/ai-vault-session-title' -import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' -import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' -import { sweepRetiredTerminalTabState } from './retired-terminal-tab-state-sweep' -import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers' -import { - collectReleasedLeafIds, - hydrateWorkspaceTerminalRows, - releaseTerminalLayoutPtyIds -} from './terminal-session-row-hydration' -import { - getRecentlyClosedTabPosition, - pushClosedTerminalTabSnapshot, - pushRecentlyClosedTabKind -} from './recently-closed-tabs' -import { isClaudeAgent } from '@/lib/agent-status' -import { recordTerminalInputActivity } from '@/lib/terminal-input-activity-coalescing' -import { classifyTitleActivity } from '@/lib/pane-agent-evidence' -import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers' -import { - applyGeneratedTabTitleUpdates, - applyTerminalTabTitleUpdates, - type GeneratedTabTitleUpdate, - type TerminalTabTitleUpdate -} from './terminal-tab-title-batch' -import { - adoptTerminalTabOwnerMetadataOnlyBuckets, - getTerminalTabOwnerWorktreeId -} from './terminal-tab-owner-index' -import { - dedupeTabOrder, - ensureGroup, - findTabByEntityInGroup, - pushRecentTabId, - sanitizeRecentTabIds, - updateGroup -} from './tab-group-state' -import { - restorePtyDataHandlersAfterFailedShutdown, - unregisterPtyDataHandlers -} from '@/components/terminal-pane/pty-transport' -// Why: use the store-free registry (not terminal-parked-tab-watchers, which imports @/store) to avoid re-entering store creation during this slice's eval. -import { - disposeParkedTerminalWatchersForPtyIds, - retireParkedTerminalTab -} from '@/components/terminal-pane/terminal-parked-watcher-registry' -import { - clearCommittedPtyShutdownSettlements, - hasCommittedPtyShutdownSettlement, - markCommittedPtyShutdowns, - noteCommittedPtyShutdownSettlements, - settleDeferredPtyShutdownExits -} from '@/components/terminal-pane/pty-shutdown-exit-deferral' -import { - normalizeTerminalLayoutSnapshot, - resolvePtyBoundActiveLeafId -} from '@/components/terminal-pane/terminal-layout-leaf-ids' -import { - normalizeTerminalLayoutPtyOwnership, - resolveTerminalLayoutPtyOwnershipTransfers -} from '@/components/terminal-pane/terminal-layout-pty-ownership' -import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' -import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' -import { parseRemoteRuntimePtyId, toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' -import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' -import { requestRemoteWorktreeSleep } from '@/runtime/remote-worktree-sleep' -import { createBrowserUuid } from '@/lib/browser-uuid' -import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' -import { - clearDirectSshTerminalBindings, - invalidateStaleDirectSshTerminalBindings, - type DirectSshLivePtyBinding, - type DirectSshPaneRetryAttempt, - type DirectSshPaneRetryAttemptId, - type DirectSshPaneRetryHistory, - type DirectSshPaneRetryResult -} from './direct-ssh-terminal-recovery' -import { - retryDirectSshTerminalPanes, - retrySettledDirectSshTerminalPane -} from './direct-ssh-pane-retry-ledger' -import { - directSshAuthoritiesEqual, - settleDirectSshPaneRetryState, - transferDirectSshPaneDetachLedger -} from './direct-ssh-terminal-authority-ledger' -import { resolveDirectSshTerminalWorkspaceKeys } from './direct-ssh-terminal-workspace-scope' -import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' -import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' -import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' -import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route' -import { isWebClientLocation } from '@/lib/web-client-location' -import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import type { NativeChatLaunchDraft, NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' -import { resolveAgentPaneAuthorityKey } from './agent-pane-authority' -import { - addAdditionalValidWorkspaceKeys, - type WorkspaceSessionHydrationOptions -} from '@/lib/workspace-session-hydration-keys' -import { - buildValidWorktreeIdsForSessionHydration, - collectPersistedWorktreeIdsForSessionHydration -} from './degraded-repo-worktree-validity' -import { - collectHibernatedCompletionEvidenceForWorktree, - collectSleepingAgentSessionRecordsForWorktree, - removeSleepingRecordsReplacedByManualWorktreeSleep, - type AgentStatusWorktreeShutdownReason -} from './agent-status' -import { - buildTerminalTabRetirementPlan, - classifyTerminalRetirementWorktree, - isTerminalTabPresent, - removeSleepingAgentSessionsForTab, - type TerminalTabCloseReason, - type TerminalTabRetirementPlan -} from './terminal-tab-retirement' - -function getNextTerminalOrdinal(tabs: TerminalTab[]): number { - const usedOrdinals = new Set() - for (const tab of tabs) { - const match = /^Terminal (\d+)$/.exec(tab.defaultTitle ?? tab.title) - if (!match) { - continue - } - usedOrdinals.add(Number(match[1])) - } - - let nextOrdinal = 1 - while (usedOrdinals.has(nextOrdinal)) { - nextOrdinal += 1 - } - return nextOrdinal -} - -function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { - return typeof ptyId === 'string' && parseRemoteRuntimePtyId(ptyId) !== null -} - -function isCurrentDirectSshAuthority(state: AppState, authority: DirectSshAuthority): boolean { - const current = state.sshConnectionStates.get(authority.targetId) - return Boolean( - current?.status === 'connected' && - current.providerEpoch === authority.providerEpoch && - current.connectionGeneration === authority.connectionGeneration - ) -} - -function resolveDirectSshTerminalKeys(state: AppState, targetId: string): Set { - return resolveDirectSshTerminalWorkspaceKeys( - { - targetId, - catalogRevision: 0, - repos: state.repos, - worktreesByRepo: state.worktreesByRepo, - detectedWorktreesByRepo: state.detectedWorktreesByRepo, - folderWorkspaces: state.folderWorkspaces, - projectGroups: state.projectGroups, - restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey - }, - state.tabsByWorktree, - state.lastKnownRelayPtyIdByTabId - ) -} - -function getPendingActivationSpawnCount(value: boolean | number | undefined): number { - if (value === true) { - return 1 - } - return typeof value === 'number' && value > 0 ? value : 0 -} - -function consumePendingActivationSpawn( - value: boolean | number | undefined -): boolean | number | undefined { - const count = getPendingActivationSpawnCount(value) - if (count <= 1) { - return undefined - } - return count === 2 ? true : count - 1 -} - -function getPathDisplayName(path: string, fallback: string): string { - const normalized = path.trim().replace(/[\\/]+$/g, '') - const basename = normalized.split(/[\\/]/).findLast(Boolean)?.trim() - return basename || fallback -} - -function buildRuntimeSessionPlaceholders({ - repos, - runtimeHostIdByWorkspaceSessionKey, - worktreesByRepo -}: { - repos: readonly Repo[] - runtimeHostIdByWorkspaceSessionKey: Record - worktreesByRepo: Record -}): { repos: Repo[]; worktreesByRepo: Record } { - let nextRepos = repos.slice() - let nextWorktreesByRepo = worktreesByRepo - for (const workspaceSessionKey of Object.keys(runtimeHostIdByWorkspaceSessionKey)) { - const hostId = runtimeHostIdByWorkspaceSessionKey[workspaceSessionKey] - if (parseExecutionHostId(hostId)?.kind !== 'runtime') { - continue - } - const workspaceScope = parseWorkspaceKey(workspaceSessionKey) - if (workspaceScope?.type === 'folder') { - continue - } - const worktreeId = - workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : workspaceSessionKey - // Why: strip the synthetic `::workspace:` suffix so path is the real folder — Git callers must not spawn against a nonexistent cwd. - const parsed = splitWorktreeIdForFilesystem(worktreeId) - if (!parsed) { - continue - } - const existingRepo = nextRepos.some((repo) => repo.id === parsed.repoId) - if (!existingRepo) { - // Why: remote catalogs load after hydration but host-split session writes need owner metadata; skip if the repo id already exists to avoid duplicates. - nextRepos = [ - ...nextRepos, - { - id: parsed.repoId, - path: parsed.worktreePath, - displayName: getPathDisplayName(parsed.worktreePath, parsed.repoId), - badgeColor: DEFAULT_REPO_BADGE_COLOR, - addedAt: 0, - connectionId: null, - executionHostId: hostId - } - ] - } - const current = nextWorktreesByRepo[parsed.repoId] ?? [] - if (current.some((worktree) => worktree.id === worktreeId)) { - continue - } - const placeholder: Worktree = { - id: worktreeId, - repoId: parsed.repoId, - hostId, - displayName: getPathDisplayName(parsed.worktreePath, parsed.repoId), - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - linkedGitLabMR: null, - linkedGitLabIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0, - path: parsed.worktreePath, - head: '', - branch: '', - isBare: false, - isMainWorktree: false - } - nextWorktreesByRepo = - nextWorktreesByRepo === worktreesByRepo ? { ...worktreesByRepo } : nextWorktreesByRepo - nextWorktreesByRepo[parsed.repoId] = [...current, placeholder] - } - return { repos: nextRepos, worktreesByRepo: nextWorktreesByRepo } -} - -function getTabIdFromPaneKey(paneKey: string): string | null { - return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null -} - -function isWindowsRendererRuntime(): boolean { - return typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows') -} - -function isAllowedRemoteWindowsTerminalShell(shell: string | undefined): boolean { - return ( - shell === 'powershell.exe' || - shell === 'pwsh.exe' || - shell === 'cmd.exe' || - shell === 'wsl.exe' || - shell === WINDOWS_GIT_BASH_SHELL - ) -} - -function resolveCreatedTabShellOverride( - explicitShellOverride: string | undefined, - defaultWindowsShell: string | undefined, - isRemoteWorktree: boolean, - remotePlatform: NodeJS.Platform | null, - isWslWorktree: boolean, - projectRuntime: ProjectExecutionRuntimeResolution | undefined -): string | undefined { - if (isRemoteWorktree) { - if (remotePlatform === 'win32' && isAllowedRemoteWindowsTerminalShell(explicitShellOverride)) { - return explicitShellOverride - } - return undefined - } - if (isWindowsRendererRuntime()) { - return resolveLocalWindowsTerminalShellOverrideForTab({ - explicitShellOverride, - defaultWindowsShell, - isWslWorktree, - projectRuntime - }) - } - if (explicitShellOverride !== undefined) { - return explicitShellOverride - } - return undefined -} - -function worktreeUsesWslPath( - state: Pick, - worktreeId: string -): boolean { - const parsed = parseWorkspaceKey(worktreeId) - if (parsed?.type === 'folder') { - const folderWorkspace = state.folderWorkspaces.find( - (workspace) => workspace.id === parsed.folderWorkspaceId - ) - return folderWorkspace ? isWslUncPath(folderWorkspace.folderPath) : false - } - const worktree = Object.values(state.worktreesByRepo) - .flat() - .find((entry) => entry.id === worktreeId) - return worktree ? isWslUncPath(worktree.path) : false -} - -export function worktreeUsesRemoteConnection( - state: Pick, - worktreeId: string -): boolean { - const parsedWorkspaceKey = parseWorkspaceKey(worktreeId) - if (parsedWorkspaceKey?.type === 'folder') { - return Boolean(getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId)) - } - const directRepoId = getRepoIdFromWorktreeId(worktreeId) - const directRepo = state.repos.find((repo) => repo.id === directRepoId) - if (directRepo) { - return Boolean(directRepo.connectionId) - } - - const worktree = Object.values(state.worktreesByRepo) - .flat() - .find((entry) => entry.id === worktreeId) - const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null - return Boolean(repo?.connectionId) -} - -function getRemoteConnectionIdForWorktree( - state: Pick, - worktreeId: string -): string | null { - const parsedWorkspaceKey = parseWorkspaceKey(worktreeId) - if (parsedWorkspaceKey?.type === 'folder') { - return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId) ?? null - } - const directRepoId = getRepoIdFromWorktreeId(worktreeId) - const directRepo = state.repos.find((repo) => repo.id === directRepoId) - if (directRepo) { - return directRepo.connectionId?.trim() || null - } - - const worktree = Object.values(state.worktreesByRepo) - .flat() - .find((entry) => entry.id === worktreeId) - const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null - return repo?.connectionId?.trim() || null -} - -function resolveTerminalStopRuntimeEnvironmentId( - state: Pick, - worktreeId: string -): string | null { - return getRuntimeEnvironmentIdForWorktree(state, worktreeId) -} - -function sortedUniquePtyIds(ptyIds: readonly string[] | undefined): string[] { - return [...new Set((ptyIds ?? []).filter((ptyId) => ptyId.length > 0))].sort() -} - -function equalStringSets(a: readonly string[], b: readonly string[]): boolean { - if (a.length !== b.length) { - return false - } - const bSet = new Set(b) - return a.every((value) => bSet.has(value)) -} - -function uniquePtyIds(ptyIds: readonly (string | null | undefined)[]): string[] { - return [...new Set(ptyIds.filter((ptyId): ptyId is string => Boolean(ptyId)))] -} - -function resolvePrimaryLayoutPtyId(layout: TerminalLayoutSnapshot): string | null { - const ptyIdsByLeafId = layout.ptyIdsByLeafId ?? {} - const activePtyId = layout.activeLeafId ? ptyIdsByLeafId[layout.activeLeafId] : undefined - return activePtyId ?? Object.values(ptyIdsByLeafId)[0] ?? null -} - -function withTerminalTabPtyId( - tabsByWorktree: Record, - tabId: string, - ptyId: string | null -): Record { - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - const index = tabs.findIndex((tab) => tab.id === tabId) - if (index === -1) { - continue - } - if (tabs[index]?.ptyId === ptyId) { - return tabsByWorktree - } - const nextTabs = [...tabs] - nextTabs[index] = { ...nextTabs[index]!, ptyId } - return { ...tabsByWorktree, [worktreeId]: nextTabs } - } - return tabsByWorktree -} - -export type AutomaticAgentResumeClaim = { - worktreeId: string - launchAgent: TuiAgent - providerSession: AgentProviderSessionMetadata -} - -export type CodexRestartNotice = { - previousAccountLabel: string - nextAccountLabel: string - /** Ids behind the two labels, when the caller knows them (`null` is the system - * default). Two accounts can share a label, so only these can decide whether - * a re-mark actually points back at the account the pane launched under. */ - previousAccountId?: string | null - nextAccountId?: string | null - /** The launch account still matches, but its baked CODEX_HOME route is retired. */ - homeRouteChanged?: true - /** Set once the user asks for the restart. The record outlives the prompt - * because the previous-account fields are the only memory of the account this - * pane actually launched under, which drives the A -> B -> A collapse. */ - restartRequested?: true - /** Set when the user answers "Keep old account". Same reason the record has to - * survive: deleting it erased the launch account, so re-selecting it looked - * like a fresh switch and raised an inverted prompt that killed pane input. */ - dismissed?: true -} - -export type TerminalSlice = { - tabsByWorktree: Record - activeTabId: string | null - /** Per-worktree last-active tab, restored on worktree switch so the user returns to where they left, not tabs[0]. */ - activeTabIdByWorktree: Record - ptyIdsByTabId: Record - /** Live pane titles by tabId then paneId; preserves per-pane agent status (unlike the legacy tab title) while TerminalPane is mounted. */ - runtimePaneTitlesByTabId: Record> - /** Per-tab unread flags (BEL or agent-complete); ephemeral UI state, not persisted. Cleared when the user activates/interacts with the tab. */ - unreadTerminalTabs: Record - /** Pane-keyed attention marker (narrower than unreadTerminalTabs); clears when the user interacts with the exact pane that raised it. */ - unreadTerminalPanes: Record - /** Agent-completion marker for focus-return auto-ack; separate from unreadTerminalPanes so generic bells still show until interact. */ - unreadAgentCompletionPanes: Record - // Remote guard keys must use renderer-visible, environment-scoped PTY ids; raw runtime handles are only valid at the RPC boundary. - suppressedPtyExitIds: Record - /** Reference-counted so overlapping shutdowns retain renderer PTY bindings until every owner settles. */ - pendingPtyShutdownIds: Record - pendingCodexPaneRestartIds: Record - codexRestartNoticeByPtyId: Record - directSshPaneRetryByTabId: Record - directSshLivePtyBindingByTabId: Record - directSshPaneRetryHistoryByTabId: Record - expandedPaneByTabId: Record - canExpandPaneByTabId: Record - terminalLayoutsByTabId: Record - /** Most recent quick-command id per group; in-memory only so a deleted command's stale id can't surface as the split-button label. */ - recentQuickCommandIdByGroup: Record - setRecentQuickCommandForGroup: (groupId: string, quickCommandId: string) => void - /** Runtime-only claim for auto sleeping-session recovery tabs; bridges the gap between startup payload consumption and hooks going live. */ - automaticAgentResumeClaimsByTabId: Record - claimAutomaticAgentResume: (tabId: string, claim: AutomaticAgentResumeClaim) => void - /** Launch-time native-chat prompt echo, keyed by terminal tab. In-memory only. */ - nativeChatLaunchPromptByTabId: Record - seedNativeChatLaunchPrompt: (prompt: NativeChatLaunchPrompt) => void - markNativeChatLaunchPromptFailed: (tabId: string) => void - clearNativeChatLaunchPrompt: (tabId: string) => void - /** Launch context prefilled into the TUI input as an unsent draft; the chat composer adopts it. In-memory only. */ - nativeChatLaunchDraftByTabId: Record - seedNativeChatLaunchDraft: (draft: NativeChatLaunchDraft) => void - markNativeChatLaunchDraftAdopted: (tabId: string) => void - resolveNativeChatLaunchDraft: ( - tabId: string, - resolution: Pick - ) => void - clearNativeChatLaunchDraft: (tabId: string) => void - pendingStartupByTabId: Record< - string, - { - command: string - /** Renderer-delivered startup input for callers needing xterm paste semantics before the submit Enter. */ - delivery?: 'terminal-paste' - startupCommandDelivery?: StartupCommandDelivery - env?: Record - envToDelete?: string[] - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - /** Explicit CLI override for host-owned agent launches; omission uses host settings. */ - agentArgsOverride?: string | null - draftPrompt?: string - sessionOptions?: Record - /** Initial prompt-start status for agents that lack native prompt hooks. */ - initialAgentStatus?: { agent: TuiAgent; prompt: string } - /** Show the restored-session banner when this startup command mounts. */ - showSessionRestoredBanner?: boolean - /** Telemetry for the `agent_started` event; threaded to the pty:spawn handler so it fires only after spawn confirms, not on click-intent. */ - telemetry?: AgentStartedTelemetry - } - > - pendingInitialCwdByTabId: Record - /** Queued setup-split requests; TerminalPane splits and runs the command in a new pane so the main terminal stays immediately interactive. */ - pendingSetupSplitByTabId: Record< - string, - { command: string; env?: Record; direction: SetupSplitDirection } - > - /** Queued issue-command-split requests, triggered when an issue is linked at worktree creation and the repo's issue automation is enabled. */ - pendingIssueCommandSplitByTabId: Record }> - tabBarOrderByWorktree: Record - workspaceSessionReady: boolean - restoredRuntimeHostIdByWorkspaceSessionKey: Record - defaultTerminalTabsAppliedByWorktreeId: Record - markDefaultTerminalTabsApplied: (worktreeId: string) => void - /** True only after hydrateWorkspaceSession loaded real orca-data.json; guards the session writer so an early-startup crash can't overwrite good data on disk. */ - hydrationSucceeded: boolean - setHydrationSucceeded: (value: boolean) => void - pendingReconnectWorktreeIds: string[] - pendingReconnectTabByWorktree: Record - /** tabId → previous session's ptyId; for daemon backends it doubles as the sessionId, so spawn createOrAttach returns the surviving terminal. */ - pendingReconnectPtyIdByTabId: Record - // Why: clearTabPtyId nulls tab.ptyId on disconnect; keep the last relay ID here so session save can still capture it for reattach after restart. - lastKnownRelayPtyIdByTabId: Record - /** ANSI snapshots from daemon reattach, keyed by new ptyId; TerminalPane writes them to xterm.js to restore visual state. */ - pendingSnapshotByPtyId: Record< - string, - { snapshot: string; cols?: number; rows?: number; isAlternateScreen?: boolean } - > - consumePendingSnapshot: ( - ptyId: string - ) => { snapshot: string; cols?: number; rows?: number; isAlternateScreen?: boolean } | null - /** Cold-restore data (read-only scrollback shown above the fresh prompt) from disk history after a daemon crash, keyed by the new ptyId. */ - pendingColdRestoreByPtyId: Record - consumePendingColdRestore: (ptyId: string) => { scrollback: string; cwd: string } | null - createTab: ( - worktreeId: string, - targetGroupId?: string, - shellOverride?: string, - options?: { - pendingActivationSpawn?: boolean - initialPtyId?: string - activate?: boolean - recordInteraction?: boolean - /** Pre-allocated tab id (main mints it for CLI/runtime PTYs with a baked pane key); minted fresh on omit or cross-worktree collision. */ - id?: string - /** Coding-harness agent launched here, recorded so the tab bar shows the provider icon before the agent's first hook event. */ - launchAgent?: TuiAgent - quickCommandLabel?: string | null - /** Initial native-chat view mode; agent launches pass 'chat' when openAgentTabsInChatByDefault is on, else omitted for the 'terminal' default. */ - viewMode?: Tab['viewMode'] - startupCwd?: string - forceHostRuntime?: boolean - } - ) => TerminalTab - openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise - closeTab: ( - tabId: string, - opts?: { - recordInteraction?: boolean - reason?: TerminalTabCloseReason - captureRecentlyClosed?: boolean - remoteCloseOwnedByHost?: boolean - localPtyTeardownOwnedExternally?: boolean - precomputedRetirementPlan?: TerminalTabRetirementPlan - } - ) => void - reorderTabs: (worktreeId: string, tabIds: string[]) => void - setTabBarOrder: (worktreeId: string, order: string[]) => void - setActiveTab: (tabId: string) => void - setActiveTabForWorktree: (worktreeId: string, tabId: string) => void - /** Resolve the canonical legacy terminal-tab owner key for renderer lifecycle guards. */ - getTerminalTabOwnerWorktreeId?: (tabId: string) => string | null - updateTabTitle: (tabId: string, title: string) => void - updateTabTitles: (updates: readonly TerminalTabTitleUpdate[]) => void - setAiVaultTabTitle: (tabId: string, aiVaultTitle: AiVaultSessionTitle | null) => void - setGeneratedTabTitleFromAgentPrompt: ( - paneKey: string, - prompt: string, - options?: { replaceExistingGeneratedTitle?: boolean } - ) => void - setGeneratedTabTitlesFromAgentPrompts: (updates: readonly GeneratedTabTitleUpdate[]) => void - clearTabLaunchAgent: (tabId: string) => void - setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void - clearRuntimePaneTitle: (tabId: string, paneId: number) => void - /** Mark a tab unread (agent working→idle); skipped when the tab is visible, since a "seen" flag would never clear. */ - markTerminalTabUnread: (tabId: string) => void - markTerminalPaneUnread: (paneKey: string) => void - markAgentCompletionPaneUnread: (paneKey: string) => void - /** Clear a tab's unread indicator on user interaction (ghostty "show until interact" model). */ - clearTerminalTabUnread: (tabId: string) => void - clearTerminalPaneUnread: (paneKey: string) => void - setTabCustomTitle: ( - tabId: string, - title: string | null, - opts?: { recordInteraction?: boolean } - ) => void - setTabColor: (tabId: string, color: string | null) => void - updateTabPtyId: ( - tabId: string, - ptyId: string, - replacedPtyId?: string, - directSshRetryAttemptId?: DirectSshPaneRetryAttemptId - ) => void - clearTabPtyId: (tabId: string, ptyId?: string) => void - clearDirectSshTargetPtyBindings: (targetId: string) => number - invalidateStaleDirectSshTargetPtyBindings: (authority: DirectSshAuthority) => number - retryDirectSshTargetPanes: (authority: DirectSshAuthority, now?: number) => number - settleDirectSshPaneRetry: (result: DirectSshPaneRetryResult, now?: number) => void - shutdownWorktreeTerminals: ( - worktreeId: string, - opts?: { - keepIdentifiers?: boolean - shutdownReason?: AgentStatusWorktreeShutdownReason - sleepingPaneKeys?: string[] - expectedRuntimePtyIds?: string[] - /** Opt-in for callers that already tore the workspace down backend-side; omitting it keeps the runtime stop. */ - backendOwnsPtyTeardown?: boolean - } - ) => Promise - shutdownCompletedAgentPaneForHibernation: ( - worktreeId: string, - opts: { - paneKey: string - tabId: string - leafId: string - ptyId: string - expectedRuntimePtyId?: string - } - ) => Promise - suppressPtyExit: (ptyId: string) => void - consumeSuppressedPtyExit: (ptyId: string) => boolean - isPtyShutdownPending: (ptyId: string) => boolean - queueCodexPaneRestarts: (ptyIds: string[]) => void - consumePendingCodexPaneRestart: (ptyId: string) => boolean - /** Returns the ptyIds left holding a notice, so callers can tell a raised - * prompt from one the A -> B -> A collapse dropped. */ - markCodexRestartNotices: ( - notices: (Pick & - Partial> & { - /** Explicit false replaces a restored route notice after main rechecks it. */ - homeRouteChanged?: boolean - ptyId: string - })[] - ) => string[] - clearCodexRestartNotice: (ptyId: string) => void - dismissCodexRestartNotices: (ptyIds: string[]) => void - /** Puts an accepted-but-unexecuted restart back to the unanswered prompt, so a - * failed execution never leaves a pane input-blocked with nothing visible. */ - reopenCodexRestartPrompt: (ptyId: string) => void - /** Rebinds one layout leaf to a replacement PTY without a mounted pane. */ - replaceTerminalLayoutPanePtyId: (tabId: string, leafId: string, ptyId: string) => void - setTabPaneExpanded: (tabId: string, expanded: boolean) => void - setTabCanExpandPane: (tabId: string, canExpand: boolean) => void - setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void - syncPaneDetachPtyOwnership: (args: { - detachedLeafId: string - detachedPtyId: string | null - sourceLayout: TerminalLayoutSnapshot - sourceTabId: string - targetTabId: string - }) => void - queueTabStartupCommand: ( - tabId: string, - startup: { - command: string - delivery?: 'terminal-paste' - startupCommandDelivery?: StartupCommandDelivery - env?: Record - envToDelete?: string[] - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - agentArgsOverride?: string | null - draftPrompt?: string - sessionOptions?: Record - initialAgentStatus?: { agent: TuiAgent; prompt: string } - showSessionRestoredBanner?: boolean - telemetry?: AgentStartedTelemetry - } - ) => void - queueTabInitialCwd: (tabId: string, cwd: string) => void - consumeTabInitialCwd: (tabId: string) => string | null - consumeTabStartupCommand: (tabId: string) => { - command: string - delivery?: 'terminal-paste' - startupCommandDelivery?: StartupCommandDelivery - env?: Record - envToDelete?: string[] - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - agentArgsOverride?: string | null - draftPrompt?: string - sessionOptions?: Record - initialAgentStatus?: { agent: TuiAgent; prompt: string } - showSessionRestoredBanner?: boolean - telemetry?: AgentStartedTelemetry - } | null - queueTabSetupSplit: ( - tabId: string, - startup: { command: string; env?: Record; direction: SetupSplitDirection } - ) => void - consumeTabSetupSplit: ( - tabId: string - ) => { command: string; env?: Record; direction: SetupSplitDirection } | null - queueTabIssueCommandSplit: ( - tabId: string, - issueCommand: { command: string; env?: Record } - ) => void - consumeTabIssueCommandSplit: ( - tabId: string - ) => { command: string; env?: Record } | null - /** `${tabId}:${leafId}` → ms when the prompt-cache countdown started (agent idle); null means no active timer for that pane. */ - cacheTimerByKey: Record - setCacheTimerStartedAt: (key: string, ts: number | null) => void - /** paneKey → wall-clock user input time; hibernation uses it to avoid sleeping a completed agent pane the user turned into a shell. */ - lastTerminalInputAtByPaneKey: Record - recordTerminalInput: (paneKey: string, timestamp?: number) => void - /** Seed cache timers for idle Claude sessions missing one; called when the feature is enabled mid-session. */ - seedCacheTimersForIdleTabs: () => void - /** SSH target IDs needing a passphrase; reconnect is deferred until the user focuses an affected terminal tab. */ - deferredSshReconnectTargets: string[] - /** tabId → remote PTY session ID for deferred (passphrase) SSH tabs; survives the startup clear because reconnect runs later, on focus. */ - deferredSshSessionIdsByTabId: Record - setDeferredSshReconnectTargets: (targetIds: string[]) => void - removeDeferredSshReconnectTarget: (targetId: string) => void - removeDeferredSshSessionId: (tabId: string) => void - hydrateWorkspaceSession: ( - session: WorkspaceSessionState, - options?: HydrateWorkspaceSessionOptions - ) => void - reconnectPersistedTerminals: ( - signal?: AbortSignal, - options?: ReconnectPersistedTerminalsOptions - ) => Promise -} - -export type HydrateWorkspaceSessionOptions = { - directSshAuthority?: DirectSshAuthority - runtimeHostIdByWorkspaceSessionKey?: Record -} & WorkspaceSessionHydrationOptions - -export type ReconnectPersistedTerminalsOptions = { - directSshAuthority: DirectSshAuthority - workspaceKeys: readonly string[] -} - -type WorkspaceHydrationPatch = Pick< - AppState, - | 'activeRepoId' - | 'activeWorktreeId' - | 'activeWorkspaceKey' - | 'activeWorkspaceExecutionHostId' - | 'activeTabId' - | 'activeTabIdByWorktree' - | 'restoredRuntimeHostIdByWorkspaceSessionKey' - | 'repos' - | 'tabsByWorktree' - | 'worktreesByRepo' - | 'lastVisitedAtByWorktreeId' - | 'defaultTerminalTabsAppliedByWorktreeId' - | 'automaticAgentResumeClaimsByTabId' - | 'sleepingAgentSessionsByPaneKey' - | 'pendingReconnectWorktreeIds' - | 'pendingReconnectTabByWorktree' - | 'pendingReconnectPtyIdByTabId' - | 'everActivatedWorktreeIds' - | 'worktreeNavHistory' - | 'worktreeNavHistoryIndex' - | 'ptyIdsByTabId' - | 'terminalLayoutsByTabId' -> - -function replaceHydratedRecordKeys( - current: Record, - hydrated: Record, - replaceKeys: ReadonlySet -): Record { - return { - ...Object.fromEntries(Object.entries(current).filter(([key]) => !replaceKeys.has(key))), - ...Object.fromEntries(Object.entries(hydrated).filter(([key]) => replaceKeys.has(key))) - } -} - -type TerminalLayoutPtyOwnershipTransfer = ReturnType< - typeof resolveTerminalLayoutPtyOwnershipTransfers ->[number] - -function transferNormalizedTerminalLayoutPtyOwnership( - state: Pick, - tabId: string, - transfers: readonly TerminalLayoutPtyOwnershipTransfer[] -): void { - if (transfers.length === 0) { - return - } - const ptyIdsOwnedByOtherTabs = new Set() - for (const [candidateTabId, layout] of Object.entries(state.terminalLayoutsByTabId)) { - if (candidateTabId === tabId) { - continue - } - for (const ptyId of Object.values(layout.ptyIdsByLeafId ?? {})) { - ptyIdsOwnedByOtherTabs.add(ptyId) - } - } - for (const { removedLeafId, retainedLeafId, ptyId } of transfers) { - if ( - !isTerminalLeafId(removedLeafId) || - !isTerminalLeafId(retainedLeafId) || - ptyIdsOwnedByOtherTabs.has(ptyId) - ) { - continue - } - const fromPaneKey = makePaneKey(tabId, removedLeafId) - const currentOwner = parsePaneKey(resolveAgentPaneAuthorityKey(fromPaneKey)) - if (currentOwner && currentOwner.tabId !== tabId) { - continue - } - state.transferAgentPaneAuthority({ - fromPaneKey, - toPaneKey: makePaneKey(tabId, retainedLeafId), - ptyId - }) - } -} - -function targetScopedWorkspaceHydrationPatch( - state: AppState, - hydrated: WorkspaceHydrationPatch, - session: WorkspaceSessionState, - options: HydrateWorkspaceSessionOptions -): Partial { - const workspaceKeys = new Set(options.replaceWorkspaceKeys) - const targetTabIds = new Set( - [...workspaceKeys].flatMap((workspaceKey) => [ - ...(state.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id), - ...(session.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) - ]) - ) - const retainedTargetTabIds = new Set( - [...workspaceKeys].flatMap((workspaceKey) => - (hydrated.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) - ) - ) - const deletedTargetTabIds = new Set( - [...workspaceKeys] - .flatMap((workspaceKey) => (state.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id)) - .filter((tabId) => !retainedTargetTabIds.has(tabId)) - ) - const pendingReconnectPtyIdByTabId = replaceHydratedRecordKeys( - state.pendingReconnectPtyIdByTabId, - {}, - targetTabIds - ) - const authority = options.directSshAuthority - if (authority) { - for (const workspaceKey of hydrated.pendingReconnectWorktreeIds) { - if (!workspaceKeys.has(workspaceKey)) { - continue - } - for (const tab of session.tabsByWorktree[workspaceKey] ?? []) { - // Why: rows hydration dropped (invalid id, canonical duplicate) would leak reconnect keys nothing owns. - if (!retainedTargetTabIds.has(tab.id)) { - continue - } - const ptyId = session.remoteSessionIdsByTabId?.[tab.id] ?? tab.ptyId - if (ptyId && parseAppSshPtyId(ptyId)?.connectionId === authority.targetId) { - pendingReconnectPtyIdByTabId[tab.id] = ptyId - } - } - } - } - const activeOutsideScope = - state.activeWorktreeId != null && !workspaceKeys.has(state.activeWorktreeId) - const sleepingAgentSessionsByPaneKey = Object.fromEntries([ - ...Object.entries(state.sleepingAgentSessionsByPaneKey).filter( - ([, record]) => !workspaceKeys.has(record.worktreeId) - ), - ...Object.entries(hydrated.sleepingAgentSessionsByPaneKey).filter(([, record]) => - workspaceKeys.has(record.worktreeId) - ) - ]) - const everActivatedWorktreeIds = new Set(state.everActivatedWorktreeIds) - for (const workspaceKey of hydrated.everActivatedWorktreeIds) { - if (workspaceKeys.has(workspaceKey)) { - everActivatedWorktreeIds.add(workspaceKey) - } - } - return { - activeRepoId: activeOutsideScope ? state.activeRepoId : hydrated.activeRepoId, - activeWorktreeId: activeOutsideScope ? state.activeWorktreeId : hydrated.activeWorktreeId, - activeWorkspaceKey: activeOutsideScope ? state.activeWorkspaceKey : hydrated.activeWorkspaceKey, - activeWorkspaceExecutionHostId: activeOutsideScope - ? state.activeWorkspaceExecutionHostId - : hydrated.activeWorkspaceExecutionHostId, - activeTabId: activeOutsideScope ? state.activeTabId : hydrated.activeTabId, - activeTabIdByWorktree: replaceHydratedRecordKeys( - state.activeTabIdByWorktree, - hydrated.activeTabIdByWorktree, - workspaceKeys - ), - tabsByWorktree: replaceHydratedRecordKeys( - state.tabsByWorktree, - hydrated.tabsByWorktree, - workspaceKeys - ), - lastVisitedAtByWorktreeId: replaceHydratedRecordKeys( - state.lastVisitedAtByWorktreeId, - hydrated.lastVisitedAtByWorktreeId, - workspaceKeys - ), - defaultTerminalTabsAppliedByWorktreeId: replaceHydratedRecordKeys( - state.defaultTerminalTabsAppliedByWorktreeId, - hydrated.defaultTerminalTabsAppliedByWorktreeId, - workspaceKeys - ), - automaticAgentResumeClaimsByTabId: replaceHydratedRecordKeys( - state.automaticAgentResumeClaimsByTabId, - hydrated.automaticAgentResumeClaimsByTabId, - targetTabIds - ), - sleepingAgentSessionsByPaneKey, - pendingReconnectWorktreeIds: [ - ...state.pendingReconnectWorktreeIds.filter((key) => !workspaceKeys.has(key)), - ...hydrated.pendingReconnectWorktreeIds.filter((key) => workspaceKeys.has(key)) - ], - pendingReconnectTabByWorktree: replaceHydratedRecordKeys( - state.pendingReconnectTabByWorktree, - hydrated.pendingReconnectTabByWorktree, - workspaceKeys - ), - pendingReconnectPtyIdByTabId, - everActivatedWorktreeIds, - directSshPaneRetryByTabId: replaceHydratedRecordKeys( - state.directSshPaneRetryByTabId, - {}, - deletedTargetTabIds - ), - directSshLivePtyBindingByTabId: replaceHydratedRecordKeys( - state.directSshLivePtyBindingByTabId, - {}, - deletedTargetTabIds - ), - directSshPaneRetryHistoryByTabId: replaceHydratedRecordKeys( - state.directSshPaneRetryHistoryByTabId, - {}, - deletedTargetTabIds - ), - ptyIdsByTabId: replaceHydratedRecordKeys( - state.ptyIdsByTabId, - hydrated.ptyIdsByTabId, - targetTabIds - ), - terminalLayoutsByTabId: replaceHydratedRecordKeys( - state.terminalLayoutsByTabId, - hydrated.terminalLayoutsByTabId, - targetTabIds - ) - } -} +import type { TerminalSlice } from '../terminals/terminal-state' +import { createTerminalEphemeralActions } from '../terminals/terminal-ephemeral-state' +import { createTerminalTabCreationActions } from '../terminals/terminal-tab-creation' +import { createActiveWorkspaceTerminalActions } from '../terminals/terminal-active-workspace-creation' +import { createTerminalTabCloseActions } from '../terminals/terminal-tab-close' +import { createTerminalTabNavigationActions } from '../terminals/terminal-tab-navigation' +import { createTerminalTabPresentationActions } from '../terminals/terminal-tab-presentation' +import { createTerminalTabAttentionActions } from '../terminals/terminal-tab-attention' +import { createTerminalPtyBindingActions } from '../terminals/terminal-pty-bindings' +import { createTerminalPtyReleaseActions } from '../terminals/terminal-pty-release' +import { createTerminalPaneHibernationActions } from '../terminals/terminal-pane-hibernation' +import { createDirectSshTerminalBindingActions } from '../terminals/direct-ssh-terminal-bindings' +import { createTerminalShutdownActions } from '../terminals/terminal-shutdown' +import { createTerminalRestartActions } from '../terminals/terminal-restart-state' +import { createTerminalLayoutActions } from '../terminals/terminal-layout-state' +import { createTerminalStartupQueueActions } from '../terminals/terminal-startup-queues' +import { createWorkspaceTerminalHydrationActions } from '../terminals/workspace-terminal-hydration' +import { createWorkspaceTerminalReconnectActions } from '../terminals/workspace-terminal-reconnect' export const createTerminalSlice: StateCreator = (set, get) => ({ tabsByWorktree: {}, @@ -1056,22 +49,7 @@ export const createTerminalSlice: StateCreator workspaceSessionReady: false, restoredRuntimeHostIdByWorkspaceSessionKey: {}, defaultTerminalTabsAppliedByWorktreeId: {}, - markDefaultTerminalTabsApplied: (worktreeId) => - set((s) => { - if (s.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) { - return {} - } - return { - defaultTerminalTabsAppliedByWorktreeId: { - ...s.defaultTerminalTabsAppliedByWorktreeId, - [worktreeId]: true - } - } - }), hydrationSucceeded: false, - setHydrationSucceeded: (value) => { - set({ hydrationSucceeded: value }) - }, pendingReconnectWorktreeIds: [], pendingReconnectTabByWorktree: {}, pendingReconnectPtyIdByTabId: {}, @@ -1083,3242 +61,21 @@ export const createTerminalSlice: StateCreator cacheTimerByKey: {}, lastTerminalInputAtByPaneKey: {}, recentQuickCommandIdByGroup: {}, - - setRecentQuickCommandForGroup: (groupId, quickCommandId) => { - set((s) => ({ - recentQuickCommandIdByGroup: { - ...s.recentQuickCommandIdByGroup, - [groupId]: quickCommandId - } - })) - }, - - claimAutomaticAgentResume: (tabId, claim) => { - set((s) => ({ - automaticAgentResumeClaimsByTabId: { - ...s.automaticAgentResumeClaimsByTabId, - [tabId]: claim - } - })) - }, - - seedNativeChatLaunchPrompt: (prompt) => { - set((s) => ({ - nativeChatLaunchPromptByTabId: { - ...s.nativeChatLaunchPromptByTabId, - [prompt.tabId]: prompt - } - })) - }, - - markNativeChatLaunchPromptFailed: (tabId) => { - set((s) => { - const current = s.nativeChatLaunchPromptByTabId[tabId] - if (!current || current.failed) { - return {} - } - return { - nativeChatLaunchPromptByTabId: { - ...s.nativeChatLaunchPromptByTabId, - [tabId]: { ...current, failed: true } - } - } - }) - }, - - clearNativeChatLaunchPrompt: (tabId) => { - set((s) => { - if (!s.nativeChatLaunchPromptByTabId[tabId]) { - return {} - } - const next = { ...s.nativeChatLaunchPromptByTabId } - delete next[tabId] - return { nativeChatLaunchPromptByTabId: next } - }) - }, - - seedNativeChatLaunchDraft: (draft) => { - set((s) => ({ - nativeChatLaunchDraftByTabId: { - ...s.nativeChatLaunchDraftByTabId, - [draft.tabId]: draft - } - })) - }, - - markNativeChatLaunchDraftAdopted: (tabId) => { - set((s) => { - const current = s.nativeChatLaunchDraftByTabId[tabId] - if (!current || current.adopted) { - return {} - } - return { - nativeChatLaunchDraftByTabId: { - ...s.nativeChatLaunchDraftByTabId, - [tabId]: { ...current, adopted: true } - } - } - }) - }, - - resolveNativeChatLaunchDraft: (tabId, resolution) => { - set((s) => { - const current = s.nativeChatLaunchDraftByTabId[tabId] - if ( - !current || - current.resolved || - current.createdAt !== resolution.createdAt || - current.text !== resolution.text - ) { - return {} - } - return { - nativeChatLaunchDraftByTabId: { - ...s.nativeChatLaunchDraftByTabId, - [tabId]: { ...current, resolved: true } - } - } - }) - }, - - clearNativeChatLaunchDraft: (tabId) => { - set((s) => { - if (!s.nativeChatLaunchDraftByTabId[tabId]) { - return {} - } - const next = { ...s.nativeChatLaunchDraftByTabId } - delete next[tabId] - return { nativeChatLaunchDraftByTabId: next } - }) - }, - - recordTerminalInput: (paneKey, timestamp = Date.now()) => { - if (!paneKey || !Number.isFinite(timestamp)) { - return - } - recordTerminalInputActivity({ - paneKey, - timestamp, - // Why: the first stamp for a pane must land synchronously; automation take-over - // detection subscribes and compares undefined→value across a launch. - forceWrite: get().lastTerminalInputAtByPaneKey[paneKey] === undefined, - commit: { - insert: (key, at) => - set((s) => ({ - lastTerminalInputAtByPaneKey: { ...s.lastTerminalInputAtByPaneKey, [key]: at } - })), - refreshExisting: (entries) => - set((s) => { - let next: Record | null = null - for (const [key, at] of entries) { - // Why: teardown (close pane/tab/worktree purge) deletes keys; a late flush must not resurrect them. - const current = s.lastTerminalInputAtByPaneKey[key] - if (current === undefined || current >= at) { - continue - } - next ??= { ...s.lastTerminalInputAtByPaneKey } - next[key] = at - } - return next ? { lastTerminalInputAtByPaneKey: next } : {} - }) - } - }) - }, - - setCacheTimerStartedAt: (key, ts) => { - set((s) => { - // Why: a real pane write clears any ':seed' sentinel from seedCacheTimersForIdleTabs, avoiding phantom timers when the seed key doesn't match the real pane. - const colonIdx = key.indexOf(':') - const suffix = colonIdx === -1 ? null : key.slice(colonIdx + 1) - const seedKey = colonIdx !== -1 && suffix !== 'seed' ? `${key.slice(0, colonIdx)}:seed` : null - const hasStaleSeed = seedKey !== null && seedKey in s.cacheTimerByKey - // Why: parked-pane watchers replay null-over-null on every working/exit transition; each redundant write runs every subscriber's selector. - if (s.cacheTimerByKey[key] === ts && !hasStaleSeed) { - return s - } - const next = { ...s.cacheTimerByKey, [key]: ts } - if (seedKey !== null) { - delete next[seedKey] - } - return { cacheTimerByKey: next } - }) - }, - - seedCacheTimersForIdleTabs: () => { - // Why: tabs already idle when the feature is enabled mid-session missed their working→idle transition, so seed timers for them. - const s = get() - const now = Date.now() - const updates: Record = {} - for (const tabs of Object.values(s.tabsByWorktree)) { - for (const tab of tabs) { - if (!tab.title || !isClaudeAgent(tab.title)) { - continue - } - const status = classifyTitleActivity(tab.title) - if (status === null || status === 'working') { - continue - } - // Why: the store doesn't know which pane holds the idle session, so use a ':seed' sentinel; setCacheTimerStartedAt clears it on any real pane write. - const key = `${tab.id}:seed` - if (s.cacheTimerByKey[key] == null) { - updates[key] = now - } - } - } - if (Object.keys(updates).length > 0) { - set((s) => ({ - cacheTimerByKey: { ...s.cacheTimerByKey, ...updates } - })) - } - }, - - setDeferredSshReconnectTargets: (targetIds) => set({ deferredSshReconnectTargets: targetIds }), - removeDeferredSshReconnectTarget: (targetId) => - set((s) => ({ - deferredSshReconnectTargets: s.deferredSshReconnectTargets.filter((id) => id !== targetId) - })), - removeDeferredSshSessionId: (tabId) => - set((s) => { - if (!s.deferredSshSessionIdsByTabId[tabId]) { - return {} - } - const next = { ...s.deferredSshSessionIdsByTabId } - delete next[tabId] - return { deferredSshSessionIdsByTabId: next } - }), - - createTab: (worktreeId, targetGroupId, shellOverride, options) => { - let tab!: TerminalTab - set((s) => { - const orphanTerminalIds = getOrphanTerminalIds(s, worktreeId) - const orphanCleanupPatch = buildOrphanTerminalCleanupPatch(s, worktreeId, orphanTerminalIds) - const existing = (s.tabsByWorktree[worktreeId] ?? []).filter( - (entry) => !orphanTerminalIds.has(entry.id) - ) - // Why: honor a caller-supplied tab id but mint on collision — aliasing two PTYs to one id corrupts agent-status routing. See docs/cli-terminal-hook-pane-key.md. - // Why: only honor a non-empty trimmed hint; useIpcEvents spreads `id` whenever tabId !== undefined, so a stray '' would break paneKey routing. - const trimmedHint = typeof options?.id === 'string' ? options.id.trim() : '' - const hintedId = - trimmedHint.length > 0 && isValidHostTerminalTabId(trimmedHint) ? trimmedHint : undefined - const idCollides = - hintedId !== undefined && - Object.values(s.tabsByWorktree).some((tabs) => tabs.some((entry) => entry.id === hintedId)) - if (idCollides) { - console.warn( - `[createTab] tabId hint ${hintedId} already exists; minting a fresh id (hook attribution will degrade for this terminal)` - ) - } - const id = hintedId !== undefined && !idCollides ? hintedId : createBrowserUuid() - const shouldActivate = options?.activate !== false - const nextOrdinal = getNextTerminalOrdinal(existing) - const defaultTitle = `Terminal ${nextOrdinal}` - const quickCommandLabel = options?.quickCommandLabel?.trim() - const startupCwd = options?.startupCwd - const remoteConnectionId = getRemoteConnectionIdForWorktree(s, worktreeId) - const isRemoteWorktree = Boolean(remoteConnectionId) - const isWslWorktree = worktreeUsesWslPath(s, worktreeId) - const createdShellOverride = resolveCreatedTabShellOverride( - shellOverride, - s.settings?.terminalWindowsShell, - // Why: SSH PTYs ignore local Windows shell selection; a local shell icon would mislabel a remote terminal. - isRemoteWorktree, - remoteConnectionId - ? ((s.sshConnectionStates.get(remoteConnectionId) - ?.remotePlatform as NodeJS.Platform | null) ?? null) - : null, - // Why: new terminals enter the worktree's repo-scoped WSL distro even when the global Windows shell is PowerShell/cmd.exe. - isWslWorktree, - isRemoteWorktree || options?.forceHostRuntime - ? undefined - : getLocalProjectExecutionRuntimeContext(s, worktreeId) - ) - tab = { - id, - // Why: CLI-created background sessions already own a PTY, so reveal attaches instead of spawning a duplicate. - ptyId: options?.initialPtyId ?? null, - worktreeId, - // Why: reuse the lowest free ordinal so a fresh terminal stays "Terminal 1" after older tabs close, not a monotonic counter. - title: defaultTitle, - defaultTitle, - ...(quickCommandLabel ? { quickCommandLabel } : {}), - customTitle: null, - color: null, - sortOrder: existing.length, - createdAt: Date.now(), - ...(createdShellOverride !== undefined ? { shellOverride: createdShellOverride } : {}), - ...(startupCwd && startupCwd.length > 0 ? { startupCwd } : {}), - ...(options?.forceHostRuntime ? { forceHostRuntime: true } : {}), - ...(options?.launchAgent ? { launchAgent: options.launchAgent } : {}), - // Why: mark click-caused (not work-caused) spawns so updateTabPtyId skips the activity/sortEpoch bump that would reorder Recent/Smart on click. - ...(options?.pendingActivationSpawn ? { pendingActivationSpawn: true } : {}) - } - const validTargetGroupId = - targetGroupId && s.groupsByWorktree[worktreeId]?.some((group) => group.id === targetGroupId) - ? targetGroupId - : undefined - const { group, groupsByWorktree, activeGroupIdByWorktree } = ensureGroup( - s.groupsByWorktree, - s.activeGroupIdByWorktree, - worktreeId, - validTargetGroupId ?? s.activeGroupIdByWorktree[worktreeId] - ) - const nextActiveGroupIdByWorktree = - shouldActivate && validTargetGroupId - ? { ...activeGroupIdByWorktree, [worktreeId]: validTargetGroupId } - : activeGroupIdByWorktree - const existingUnifiedTabs = s.unifiedTabsByWorktree[worktreeId] ?? [] - const existingTerminalTab = findTabByEntityInGroup( - s.unifiedTabsByWorktree, - worktreeId, - group.id, - id, - 'terminal' - ) - const groupsForWorktree = groupsByWorktree[worktreeId] ?? [] - const cleanedGroups = - orphanTerminalIds.size === 0 - ? groupsForWorktree - : groupsForWorktree.map((entry) => { - // Why: repair every group before adding the new tab, or inactive/background creation can revive stale focus. - const tabOrder = dedupeTabOrder(entry.tabOrder).filter( - (tabId) => !orphanTerminalIds.has(tabId) - ) - const recentTabIds = sanitizeRecentTabIds(entry.recentTabIds, tabOrder) - const replacedActiveTabId = Boolean( - entry.activeTabId && orphanTerminalIds.has(entry.activeTabId) - ) - const fallbackActiveTabId = recentTabIds.at(-1) ?? tabOrder[0] ?? null - const activeTabId = replacedActiveTabId ? fallbackActiveTabId : entry.activeTabId - return { - ...entry, - activeTabId, - tabOrder, - recentTabIds: - replacedActiveTabId && activeTabId - ? pushRecentTabId(recentTabIds, activeTabId) - : recentTabIds - } - }) - const cleanedTargetGroup = cleanedGroups.find((entry) => entry.id === group.id) ?? group - const cleanedGroupOrder = dedupeTabOrder(cleanedTargetGroup.tabOrder).filter( - (tabId) => !orphanTerminalIds.has(tabId) - ) - const unifiedTab = existingTerminalTab ?? { - id, - entityId: id, - groupId: group.id, - worktreeId, - contentType: 'terminal' as const, - label: tab.title, - ...(tab.quickCommandLabel?.trim() - ? { quickCommandLabel: tab.quickCommandLabel.trim() } - : {}), - customLabel: tab.customTitle, - color: tab.color, - sortOrder: cleanedGroupOrder.length, - createdAt: tab.createdAt, - // Why: omit for non-agent tabs so they keep the implicit 'terminal' view mode. - ...(options?.viewMode ? { viewMode: options.viewMode } : {}) - } - const nextGroupOrder = dedupeTabOrder([...cleanedGroupOrder, unifiedTab.id]) - const nextRecent = shouldActivate - ? pushRecentTabId(sanitizeRecentTabIds(group.recentTabIds, nextGroupOrder), unifiedTab.id) - : sanitizeRecentTabIds(cleanedTargetGroup.recentTabIds, nextGroupOrder) - const cleanedActiveTabIdForWorktree = orphanCleanupPatch.activeTabIdByWorktree[worktreeId] - const cleanedGroupActiveTabId = - cleanedTargetGroup.activeTabId && !orphanTerminalIds.has(cleanedTargetGroup.activeTabId) - ? cleanedTargetGroup.activeTabId - : null - const nextActiveTabIdForWorktree = shouldActivate - ? tab.id - : (cleanedActiveTabIdForWorktree ?? cleanedGroupActiveTabId ?? tab.id) - return { - ...orphanCleanupPatch, - tabsByWorktree: { - ...orphanCleanupPatch.tabsByWorktree, - [worktreeId]: [...existing, tab] - }, - // Why: publish the unified tab atomically with the runtime tab so a transient legacy mount can't race the split host. - unifiedTabsByWorktree: { - ...s.unifiedTabsByWorktree, - [worktreeId]: existingTerminalTab - ? existingUnifiedTabs - : [...existingUnifiedTabs, unifiedTab] - }, - groupsByWorktree: { - ...groupsByWorktree, - [worktreeId]: updateGroup(cleanedGroups, { - ...cleanedTargetGroup, - activeTabId: shouldActivate - ? unifiedTab.id - : (cleanedGroupActiveTabId ?? unifiedTab.id), - tabOrder: nextGroupOrder, - recentTabIds: nextRecent - }) - }, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree, - layoutByWorktree: { - ...s.layoutByWorktree, - [worktreeId]: s.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: group.id } - }, - activeTabId: shouldActivate ? tab.id : orphanCleanupPatch.activeTabId, - activeTabIdByWorktree: { - ...orphanCleanupPatch.activeTabIdByWorktree, - [worktreeId]: nextActiveTabIdForWorktree - }, - ptyIdsByTabId: { - ...orphanCleanupPatch.ptyIdsByTabId, - [tab.id]: options?.initialPtyId ? [options.initialPtyId] : [] - }, - terminalLayoutsByTabId: { - ...orphanCleanupPatch.terminalLayoutsByTabId, - [tab.id]: emptyLayoutSnapshot() - } - } - }) - const shouldRecordInteraction = - options?.recordInteraction ?? (!options?.pendingActivationSpawn && !options?.initialPtyId) - if (shouldRecordInteraction) { - get().recordFeatureInteraction?.('terminal-tabs') - } - return tab - }, - - getTerminalTabOwnerWorktreeId: (tabId) => - getTerminalTabOwnerWorktreeId(get().tabsByWorktree, tabId), - - openNewTerminalTabInActiveWorkspace: async (groupId) => { - const state = get() - const worktreeId = state.activeWorktreeId - if (!worktreeId) { - return - } - const workspaceScope = parseWorkspaceKey(worktreeId) - const worktreeRoute = - worktreeId === FLOATING_TERMINAL_WORKTREE_ID || workspaceScope?.type === 'folder' - ? null - : resolveWorktreeOperationRouteResult(state, worktreeId) - if (worktreeRoute && worktreeRoute.kind !== 'resolved') { - return - } - const runtimeEnvironmentId = worktreeRoute - ? worktreeRoute.route.runtimeEnvironmentId - : getRuntimeEnvironmentIdForWorktree(state, worktreeId) - if (runtimeEnvironmentId) { - const { createWebRuntimeSessionTerminal } = await import('@/runtime/web-runtime-session') - await createWebRuntimeSessionTerminal({ - worktreeId, - environmentId: runtimeEnvironmentId, - targetGroupId: groupId, - activate: true - }) - return - } - if (isWebClientLocation() && worktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { - return - } - const terminal = get().createTab(worktreeId, groupId) - get().setActiveTab(terminal.id) - get().setActiveTabType('terminal') - const latest = get() - const currentTerminals = latest.tabsByWorktree[worktreeId] ?? [] - const currentEditors = latest.openFiles.filter((file) => file.worktreeId === worktreeId) - const currentBrowsers = latest.browserTabsByWorktree[worktreeId] ?? [] - const stored = latest.tabBarOrderByWorktree[worktreeId] - const validIds = new Set([ - ...currentTerminals.map((tab) => tab.id), - ...currentEditors.map((file) => file.id), - ...currentBrowsers.map((tab) => tab.id) - ]) - const base = (stored ?? []).filter((id) => validIds.has(id)) - const inBase = new Set(base) - for (const id of validIds) { - if (!inBase.has(id)) { - base.push(id) - } - } - // Why: Cmd+J shares the titlebar-button creation path, so append the new terminal after mixed editor/browser tabs, not first. - get().setTabBarOrder(worktreeId, [...base.filter((id) => id !== terminal.id), terminal.id]) - focusTerminalTabSurface(terminal.id) - }, - - closeTab: (tabId, opts) => { - const closeReason = opts?.reason ?? 'user' - const retiresSession = closeReason === 'user' || closeReason === 'cleanup' - const retirementPlan = - opts?.precomputedRetirementPlan?.tabId === tabId - ? opts.precomputedRetirementPlan - : buildTerminalTabRetirementPlan(get(), tabId) - let closingWorktreeId: string | null = null - - // Why: a parked tab has no mounted TerminalPane cleanup, so revoke its observer/candidate state before provider exit races. - retireParkedTerminalTab(tabId) - if (retiresSession) { - const fallbackWorktreeRoute = retirementPlan.worktreeId - ? resolveTerminalWorktreeRoute(get(), retirementPlan.worktreeId) - : { runtimeEnvironmentId: null } - const retirementTasks: Promise[] = opts?.localPtyTeardownOwnedExternally - ? [] - : retirementPlan.localOrSshPtyIds.map(async (ptyId) => window.api.pty.kill(ptyId)) - const localOrSshTaskCount = retirementTasks.length - if (!opts?.remoteCloseOwnedByHost) { - for (const terminal of retirementPlan.runtimeTerminals) { - if (!terminal.environmentId && !fallbackWorktreeRoute) { - continue - } - const environmentId = - terminal.environmentId ?? fallbackWorktreeRoute?.runtimeEnvironmentId - retirementTasks.push( - callRuntimeRpc( - environmentId ? { kind: 'environment', environmentId } : { kind: 'local' }, - 'terminal.close', - { terminal: terminal.handle } - ) - ) - } - } - if (retirementPlan.unroutablePtyIds.length > 0) { - // Why: log the worktree SHAPE, never the id — worktree ids embed absolute paths. The old - // "runtime handles" wording described ids that are usually plain local ones, hiding STA-2639. - console.warn('[terminal-retirement] skipped PTYs with no resolvable owner', { - tabId, - worktreeKind: classifyTerminalRetirementWorktree(retirementPlan.worktreeId), - count: retirementPlan.unroutablePtyIds.length - }) - } - // Why: keep close synchronous and idempotent — provider failures must not reject into the UI or block ownership revocation. - void Promise.allSettled(retirementTasks).then((results) => { - const localOrSshFailures = results - .slice(0, localOrSshTaskCount) - .filter((result) => result.status === 'rejected').length - const runtimeFailures = results - .slice(localOrSshTaskCount) - .filter((result) => result.status === 'rejected').length - if (localOrSshFailures > 0 || runtimeFailures > 0) { - console.warn('[terminal-retirement] provider teardown failed', { - tabId, - localOrSshFailures, - runtimeFailures - }) - } - }) - } - - set((s) => { - const next = { ...s.tabsByWorktree } - let closedTab: TerminalTab | null = null - let closedWorktreeId: string | null = null - for (const wId of Object.keys(next)) { - const before = next[wId] - const closing = before.find((t) => t.id === tabId) - if (closing) { - closingWorktreeId = wId - // Why: capture the first-matched tab's snapshot for the Cmd+Shift+T reopen stack (see capturedSnapshot below). - if (!closedTab) { - closedTab = closing - closedWorktreeId = wId - } - } - const after = before.filter((t) => t.id !== tabId) - if (after.length !== before.length) { - next[wId] = after - } - } - // Why: only explicit user closes feed the Cmd+Shift+T reopen stack; cleanup/PTY-exit closes must not pollute undo history. - const closedPosition = - closedWorktreeId && closedTab - ? getRecentlyClosedTabPosition(s, closedWorktreeId, closedTab.id) - : undefined - const capturedSnapshot = - closeReason === 'user' && - opts?.captureRecentlyClosed !== false && - closedTab && - closedWorktreeId - ? { - ...(closedTab.startupCwd ? { startupCwd: closedTab.startupCwd } : {}), - ...(closedTab.shellOverride ? { shellOverride: closedTab.shellOverride } : {}), - ...(closedTab.customTitle ? { customTitle: closedTab.customTitle } : {}), - ...(closedTab.color ? { color: closedTab.color } : {}), - ...(closedPosition ? { position: closedPosition } : {}) - } - : null - const nextExpanded = { ...s.expandedPaneByTabId } - delete nextExpanded[tabId] - const nextCanExpand = { ...s.canExpandPaneByTabId } - delete nextCanExpand[tabId] - const nextLayouts = { ...s.terminalLayoutsByTabId } - delete nextLayouts[tabId] - const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } - delete nextPtyIdsByTabId[tabId] - const nextLastKnownRelay = { ...s.lastKnownRelayPtyIdByTabId } - delete nextLastKnownRelay[tabId] - const nextDeferredSshSessionIdsByTabId = { ...s.deferredSshSessionIdsByTabId } - delete nextDeferredSshSessionIdsByTabId[tabId] - const nextPendingReconnectPtyIdByTabId = { ...s.pendingReconnectPtyIdByTabId } - delete nextPendingReconnectPtyIdByTabId[tabId] - const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } - delete nextRuntimePaneTitlesByTabId[tabId] - const nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } - delete nextDirectSshPaneRetryByTabId[tabId] - const nextDirectSshLivePtyBindingByTabId = { - ...s.directSshLivePtyBindingByTabId - } - delete nextDirectSshLivePtyBindingByTabId[tabId] - const nextDirectSshPaneRetryHistoryByTabId = { - ...s.directSshPaneRetryHistoryByTabId - } - delete nextDirectSshPaneRetryHistoryByTabId[tabId] - // Why: keep the same reference when the closing tab had no unread flag, so unrelated closes don't force full-state selector re-eval. - let nextUnreadTerminalTabs = s.unreadTerminalTabs - if (s.unreadTerminalTabs[tabId]) { - nextUnreadTerminalTabs = { ...s.unreadTerminalTabs } - delete nextUnreadTerminalTabs[tabId] - } - let nextUnreadTerminalPanes = s.unreadTerminalPanes - for (const paneKey of Object.keys(s.unreadTerminalPanes)) { - if (paneKey.startsWith(`${tabId}:`)) { - if (nextUnreadTerminalPanes === s.unreadTerminalPanes) { - nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } - } - delete nextUnreadTerminalPanes[paneKey] - } - } - let nextUnreadAgentCompletionPanes = s.unreadAgentCompletionPanes - for (const paneKey of Object.keys(s.unreadAgentCompletionPanes)) { - if (paneKey.startsWith(`${tabId}:`)) { - if (nextUnreadAgentCompletionPanes === s.unreadAgentCompletionPanes) { - nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes } - } - delete nextUnreadAgentCompletionPanes[paneKey] - } - } - const nextLastTerminalInputAtByPaneKey = { ...s.lastTerminalInputAtByPaneKey } - for (const paneKey of Object.keys(nextLastTerminalInputAtByPaneKey)) { - if (paneKey.startsWith(`${tabId}:`)) { - delete nextLastTerminalInputAtByPaneKey[paneKey] - } - } - const nextSleepingAgentSessionsByPaneKey = retiresSession - ? removeSleepingAgentSessionsForTab(s.sleepingAgentSessionsByPaneKey, tabId) - : s.sleepingAgentSessionsByPaneKey - const nextPendingStartupByTabId = { ...s.pendingStartupByTabId } - delete nextPendingStartupByTabId[tabId] - const nextAutomaticAgentResumeClaimsByTabId = { ...s.automaticAgentResumeClaimsByTabId } - delete nextAutomaticAgentResumeClaimsByTabId[tabId] - const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId } - delete nextNativeChatLaunchPromptByTabId[tabId] - const nextNativeChatLaunchDraftByTabId = { ...s.nativeChatLaunchDraftByTabId } - delete nextNativeChatLaunchDraftByTabId[tabId] - const nextPendingInitialCwdByTabId = { ...s.pendingInitialCwdByTabId } - delete nextPendingInitialCwdByTabId[tabId] - const nextPendingSetupSplitByTabId = { ...s.pendingSetupSplitByTabId } - delete nextPendingSetupSplitByTabId[tabId] - const nextPendingIssueCommandSplitByTabId = { ...s.pendingIssueCommandSplitByTabId } - delete nextPendingIssueCommandSplitByTabId[tabId] - const nextCacheTimer = { ...s.cacheTimerByKey } - // Why: cache timer keys are `${tabId}:${leafId}` composites; remove all entries for the closing tab. - for (const key of Object.keys(nextCacheTimer)) { - if (key.startsWith(`${tabId}:`)) { - delete nextCacheTimer[key] - } - } - // Why: keep activeTabIdByWorktree in sync when closing a background-worktree tab, else the stale remembered tab falls back to tabs[0] on switch. - const nextActiveTabIdByWorktree = { ...s.activeTabIdByWorktree } - for (const [wId, tabs] of Object.entries(next)) { - if (nextActiveTabIdByWorktree[wId] === tabId) { - nextActiveTabIdByWorktree[wId] = tabs[0]?.id ?? null - } - } - - // Why: keep tabBarOrderByWorktree in sync so stale terminal IDs don't linger and shift positions on later tab operations. - const nextTabBarOrderByWorktree: Record = { - ...s.tabBarOrderByWorktree - } - for (const wId of Object.keys(nextTabBarOrderByWorktree)) { - const order = nextTabBarOrderByWorktree[wId] - if (order?.includes(tabId)) { - nextTabBarOrderByWorktree[wId] = order.filter((entryId) => entryId !== tabId) - } - } - - // Why: clean up unconsumed snapshot/cold-restore data (e.g. tab closed before TerminalPane mounted) to prevent unbounded store growth across restarts. - let nextSnapshots = s.pendingSnapshotByPtyId - let nextColdRestores = s.pendingColdRestoreByPtyId - const closingPtyIds = new Set([ - ...retirementPlan.localOrSshPtyIds, - ...retirementPlan.runtimeTerminals.map((terminal) => terminal.ptyId), - ...retirementPlan.cleanupOnlyPtyIds, - ...retirementPlan.unroutablePtyIds - ]) - for (const closingId of closingPtyIds) { - if (closingId in nextSnapshots) { - nextSnapshots = { ...nextSnapshots } - delete nextSnapshots[closingId] - } - if (closingId in nextColdRestores) { - nextColdRestores = { ...nextColdRestores } - delete nextColdRestores[closingId] - } - } - - return { - tabsByWorktree: next, - activeTabId: s.activeTabId === tabId ? null : s.activeTabId, - activeTabIdByWorktree: nextActiveTabIdByWorktree, - ptyIdsByTabId: nextPtyIdsByTabId, - lastKnownRelayPtyIdByTabId: nextLastKnownRelay, - deferredSshSessionIdsByTabId: nextDeferredSshSessionIdsByTabId, - pendingReconnectPtyIdByTabId: nextPendingReconnectPtyIdByTabId, - runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, - directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, - directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId, - directSshPaneRetryHistoryByTabId: nextDirectSshPaneRetryHistoryByTabId, - ...(nextSleepingAgentSessionsByPaneKey !== s.sleepingAgentSessionsByPaneKey - ? { sleepingAgentSessionsByPaneKey: nextSleepingAgentSessionsByPaneKey } - : {}), - // Why: skip writing unreadTerminalTabs when unchanged to avoid a no-op state allocation that re-evaluates full-state selectors. Mirrors tabs.ts. - ...(nextUnreadTerminalTabs !== s.unreadTerminalTabs - ? { unreadTerminalTabs: nextUnreadTerminalTabs } - : {}), - ...(nextUnreadTerminalPanes !== s.unreadTerminalPanes - ? { unreadTerminalPanes: nextUnreadTerminalPanes } - : {}), - ...(nextUnreadAgentCompletionPanes !== s.unreadAgentCompletionPanes - ? { unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes } - : {}), - lastTerminalInputAtByPaneKey: nextLastTerminalInputAtByPaneKey, - expandedPaneByTabId: nextExpanded, - canExpandPaneByTabId: nextCanExpand, - terminalLayoutsByTabId: nextLayouts, - pendingStartupByTabId: nextPendingStartupByTabId, - automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, - nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, - nativeChatLaunchDraftByTabId: nextNativeChatLaunchDraftByTabId, - pendingInitialCwdByTabId: nextPendingInitialCwdByTabId, - pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, - pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, - cacheTimerByKey: nextCacheTimer, - tabBarOrderByWorktree: nextTabBarOrderByWorktree, - pendingSnapshotByPtyId: nextSnapshots, - pendingColdRestoreByPtyId: nextColdRestores, - ...(capturedSnapshot && closedWorktreeId - ? { - recentlyClosedTerminalTabsByWorktree: pushClosedTerminalTabSnapshot( - s.recentlyClosedTerminalTabsByWorktree, - closedWorktreeId, - capturedSnapshot - ), - recentlyClosedTabKindsByWorktree: pushRecentlyClosedTabKind( - s.recentlyClosedTabKindsByWorktree, - closedWorktreeId, - 'terminal' - ) - } - : {}) - } - }) - // Why shared with the paired snapshot apply: every path that removes a tab owes it the same sweep, and a second copy of the list is how one path silently misses a new entry. - sweepRetiredTerminalTabState(get(), tabId, closingWorktreeId) - for (const tabs of Object.values(get().unifiedTabsByWorktree)) { - const workspaceItem = tabs.find( - (entry) => entry.contentType === 'terminal' && entry.entityId === tabId - ) - if (workspaceItem) { - get().closeUnifiedTab(workspaceItem.id, { - recordInteraction: opts?.recordInteraction, - terminalRetirementHandled: true - }) - } - } - }, - - reorderTabs: (worktreeId, tabIds) => { - set((s) => { - const tabs = s.tabsByWorktree[worktreeId] ?? [] - const tabMap = new Map(tabs.map((t) => [t.id, t])) - const orderedSet = new Set(tabIds) - const missingTabs = tabs.filter((t) => !orderedSet.has(t.id)) - - const reordered = [ - ...tabIds.map((id) => tabMap.get(id)!).filter(Boolean), - ...missingTabs - ].map((tab, i) => ({ ...tab, sortOrder: i })) - - return { - tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: reordered } - } - }) - }, - - setTabBarOrder: (worktreeId, order) => { - set((s) => { - // Update unified visual order - const newTabBarOrder = { ...s.tabBarOrderByWorktree, [worktreeId]: order } - - // Keep terminal tab sortOrder in sync for persistence - const tabs = s.tabsByWorktree[worktreeId] - if (!tabs) { - return { tabBarOrderByWorktree: newTabBarOrder } - } - const tabMap = new Map(tabs.map((t) => [t.id, t])) - // Extract terminal IDs in their new relative order - const terminalIdsInOrder = order.filter((id) => tabMap.has(id)) - const orderedSet = new Set(terminalIdsInOrder) - const missingTabs = tabs.filter((t) => !orderedSet.has(t.id)) - - const updatedTabs = [ - ...terminalIdsInOrder.map((id) => tabMap.get(id)!).filter(Boolean), - ...missingTabs - ].map((tab, i) => ({ ...tab, sortOrder: i })) - - return { - tabBarOrderByWorktree: newTabBarOrder, - tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: updatedTabs } - } - }) - }, - - setActiveTab: (tabId) => { - let tabOwnerWorktreeId: string | null = null - set((s) => { - // Why: clear bells only for visible tabs; background bells must persist. - tabOwnerWorktreeId = resolveActiveTabOwnerWorktreeId( - s.tabsByWorktree, - s.activeWorktreeId, - tabId - ) - const isActiveWorktreeTab = - tabOwnerWorktreeId !== null && tabOwnerWorktreeId === s.activeWorktreeId - const nextUnreadTerminalTabs = - isActiveWorktreeTab && s.unreadTerminalTabs[tabId] - ? (() => { - const copy = { ...s.unreadTerminalTabs } - delete copy[tabId] - return copy - })() - : s.unreadTerminalTabs - // Why: activeTabId marks the visible tab, so background bells remain unread. - return { - activeTabId: isActiveWorktreeTab ? tabId : s.activeTabId, - // Why: a redundant activation must not reallocate this map — Terminal's - // active-terminal repair effect depends on it, so a re-activation that - // can't converge activeTabId (tab id reused by an earlier-scanned - // worktree) would otherwise re-trigger itself into React error #185. - activeTabIdByWorktree: - tabOwnerWorktreeId !== null && s.activeTabIdByWorktree[tabOwnerWorktreeId] !== tabId - ? { ...s.activeTabIdByWorktree, [tabOwnerWorktreeId]: tabId } - : s.activeTabIdByWorktree, - unreadTerminalTabs: nextUnreadTerminalTabs - } - }) - const state = get() - const ownerUnifiedTabs = - tabOwnerWorktreeId !== null && Object.hasOwn(state.unifiedTabsByWorktree, tabOwnerWorktreeId) - ? state.unifiedTabsByWorktree[tabOwnerWorktreeId] - : [] - // Why: a duplicated entity id must activate the same owner chosen above. - const item = - ownerUnifiedTabs.find( - (entry) => entry.contentType === 'terminal' && entry.entityId === tabId - ) ?? - Object.values(state.unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) - if (item) { - state.activateTab( - item.id, - tabOwnerWorktreeId !== null ? { worktreeId: tabOwnerWorktreeId } : undefined - ) - } - }, - - setActiveTabForWorktree: (worktreeId, tabId) => { - set((s) => ({ - activeTabIdByWorktree: { - ...s.activeTabIdByWorktree, - [worktreeId]: tabId - } - })) - }, - - updateTabTitle: (tabId, title) => { - set((state) => { - const result = applyTerminalTabTitleUpdates(state, [{ tabId, title }]) - if (result.runtimeGraphChanged) { - scheduleRuntimeGraphSync() - } - return result.patch ?? state - }) - }, - - updateTabTitles: (updates) => { - if (updates.length === 0) { - return - } - set((state) => { - const result = applyTerminalTabTitleUpdates(state, updates) - if (result.runtimeGraphChanged) { - scheduleRuntimeGraphSync() - } - return result.patch ?? state - }) - }, - - setAiVaultTabTitle: (tabId, aiVaultTitle) => { - set((s) => { - const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) - if (!ownerWorktreeId) { - return s - } - const tabs = s.tabsByWorktree[ownerWorktreeId] ?? [] - const current = tabs.find((tab) => tab.id === tabId) - const sameTitle = - current?.aiVaultTitle?.agent === aiVaultTitle?.agent && - current?.aiVaultTitle?.sessionId === aiVaultTitle?.sessionId && - current?.aiVaultTitle?.title === aiVaultTitle?.title - if (!current || sameTitle) { - return s - } - const ownerTabs = tabs.map((tab) => (tab.id === tabId ? { ...tab, aiVaultTitle } : tab)) - const nextTabsByWorktree = { ...s.tabsByWorktree, [ownerWorktreeId]: ownerTabs } - const unifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? [] - const nextUnifiedTabs = unifiedTabs.map((tab) => - tab.contentType === 'terminal' && tab.entityId === tabId ? { ...tab, aiVaultTitle } : tab - ) - adoptTerminalTabOwnerMetadataOnlyBuckets(s.tabsByWorktree, nextTabsByWorktree, [ - ownerWorktreeId - ]) - scheduleRuntimeGraphSync() - return { - tabsByWorktree: nextTabsByWorktree, - unifiedTabsByWorktree: { - ...s.unifiedTabsByWorktree, - [ownerWorktreeId]: nextUnifiedTabs - } - } - }) - }, - - setGeneratedTabTitleFromAgentPrompt: (paneKey, prompt, options) => { - // Why: setAgentStatus is high-frequency; skip derive/set unless the feature is on and this tab still needs a (re)generated title. - const state = get() - const tabId = getTabIdFromPaneKey(paneKey) - if (!tabId || prompt.length === 0 || state.settings?.tabAutoGenerateTitle !== true) { - return - } - const ownerWorktreeId = getTerminalTabOwnerWorktreeId(state.tabsByWorktree, tabId) - if (!ownerWorktreeId) { - return - } - const tabs = state.tabsByWorktree[ownerWorktreeId] ?? [] - const currentTab = tabs.find((tab) => tab.id === tabId) - if (!currentTab || currentTab.customTitle?.trim() || currentTab.quickCommandLabel?.trim()) { - return - } - const existingGeneratedTitle = currentTab.generatedTitle?.trim() - if (existingGeneratedTitle && options?.replaceExistingGeneratedTitle !== true) { - return - } - set((latestState) => { - const result = applyGeneratedTabTitleUpdates(latestState, [{ paneKey, prompt, options }]) - if (result.runtimeGraphChanged) { - scheduleRuntimeGraphSync() - } - return result.patch ?? latestState - }) - }, - - setGeneratedTabTitlesFromAgentPrompts: (updates) => { - if (updates.length === 0) { - return - } - set((state) => { - const result = applyGeneratedTabTitleUpdates(state, updates) - if (result.runtimeGraphChanged) { - scheduleRuntimeGraphSync() - } - return result.patch ?? state - }) - }, - - clearTabLaunchAgent: (tabId) => { - set((s) => { - const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) - if (!ownerWorktreeId) { - return s - } - const tabs = s.tabsByWorktree[ownerWorktreeId] ?? [] - const tabIndex = tabs.findIndex((t) => t.id === tabId) - const currentTab = tabs[tabIndex] - if (!currentTab?.launchAgent) { - return s - } - const { launchAgent: _launchAgent, ...tabWithoutLaunchAgent } = currentTab - void _launchAgent - const nextTabs = [...tabs] - nextTabs[tabIndex] = tabWithoutLaunchAgent - scheduleRuntimeGraphSync() - return { tabsByWorktree: { ...s.tabsByWorktree, [ownerWorktreeId]: nextTabs } } - }) - }, - - setRuntimePaneTitle: (tabId, paneId, title) => { - set((s) => { - const currentByPane = s.runtimePaneTitlesByTabId[tabId] ?? {} - const prevTitle = currentByPane[paneId] - if (prevTitle === title) { - return s - } - if (prevTitle && isDecorativeAgentTitleFrameChange(prevTitle, title)) { - return s - } - // Why: re-sort hookless title changes only when their activity classification changes. - const classificationChanged = - classifyTitleActivity(prevTitle ?? '') !== classifyTitleActivity(title) - // Why: skip active-worktree remount side effects and orphaned panes. - const ownerWorktreeId = classificationChanged - ? getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) - : null - const isActive = ownerWorktreeId !== null && ownerWorktreeId === s.activeWorktreeId - const shouldBump = classificationChanged && ownerWorktreeId !== null && !isActive - return { - runtimePaneTitlesByTabId: { - ...s.runtimePaneTitlesByTabId, - [tabId]: { ...currentByPane, [paneId]: title } - }, - ...(shouldBump ? { sortEpoch: s.sortEpoch + 1 } : {}) - } - }) - }, - - clearRuntimePaneTitle: (tabId, paneId) => { - set((s) => { - const currentByPane = s.runtimePaneTitlesByTabId[tabId] - if (!currentByPane || !(paneId in currentByPane)) { - return s - } - const prevTitle = currentByPane[paneId] - const nextByPane = { ...currentByPane } - delete nextByPane[paneId] - - const next = { ...s.runtimePaneTitlesByTabId } - if (Object.keys(nextByPane).length > 0) { - next[tabId] = nextByPane - } else { - delete next[tabId] - } - - // Why: clearing a classified title changes the smart-sort title-heuristic verdict, so it needs a re-sort. See setRuntimePaneTitle. - const hadClassification = classifyTitleActivity(prevTitle ?? '') !== null - // Why: same active-worktree gate as setRuntimePaneTitle — click-driven teardown clears must not re-rank the sidebar; skip when owner is missing (orphaned). - const ownerWorktreeId = hadClassification - ? getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) - : null - const isActive = ownerWorktreeId !== null && ownerWorktreeId === s.activeWorktreeId - const shouldBump = hadClassification && ownerWorktreeId !== null && !isActive - return { - runtimePaneTitlesByTabId: next, - ...(shouldBump ? { sortEpoch: s.sortEpoch + 1 } : {}) - } - }) - }, - - markTerminalTabUnread: (tabId) => { - const state = get() - const ownerTab = Object.values(state.tabsByWorktree ?? {}) - .flat() - .find((t) => t.id === tabId) - if (!ownerTab) { - return - } - // Why: terminal attention persists until real interaction. - set((s) => { - if (s.unreadTerminalTabs[tabId]) { - return s - } - return { unreadTerminalTabs: { ...s.unreadTerminalTabs, [tabId]: true as const } } - }) - }, - - markTerminalPaneUnread: (paneKey) => { - set((s) => { - if (s.unreadTerminalPanes[paneKey]) { - return s - } - return { unreadTerminalPanes: { ...s.unreadTerminalPanes, [paneKey]: true as const } } - }) - }, - - markAgentCompletionPaneUnread: (paneKey) => { - set((s) => { - if (s.unreadAgentCompletionPanes[paneKey]) { - return s - } - return { - unreadAgentCompletionPanes: { - ...s.unreadAgentCompletionPanes, - [paneKey]: true as const - } - } - }) - }, - - clearTerminalTabUnread: (tabId) => { - set((s) => { - if (!s.unreadTerminalTabs[tabId]) { - return s - } - const copy = { ...s.unreadTerminalTabs } - delete copy[tabId] - return { unreadTerminalTabs: copy } - }) - }, - - clearTerminalPaneUnread: (paneKey) => { - set((s) => { - if (!s.unreadTerminalPanes[paneKey] && !s.unreadAgentCompletionPanes[paneKey]) { - return s - } - const nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } - const nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes } - delete nextUnreadTerminalPanes[paneKey] - delete nextUnreadAgentCompletionPanes[paneKey] - return { - unreadTerminalPanes: nextUnreadTerminalPanes, - unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes - } - }) - }, - - setTabCustomTitle: (tabId, title, opts) => { - set((s) => { - const next = { ...s.tabsByWorktree } - for (const wId of Object.keys(next)) { - next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, customTitle: title } : t)) - } - scheduleRuntimeGraphSync() - return { tabsByWorktree: next } - }) - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) - if (item) { - get().setTabCustomLabel(item.id, title, opts) - } - }, - - setTabColor: (tabId, color) => { - set((s) => { - const next = { ...s.tabsByWorktree } - for (const wId of Object.keys(next)) { - next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, color } : t)) - } - return { tabsByWorktree: next } - }) - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) - if (item) { - get().setUnifiedTabColor(item.id, color) - // Why: tab color is host-authoritative for remote-server tabs; mirror it so it persists instead of reverting on the next snapshot. - const state = get() - const owningWorktreeId = Object.keys(state.unifiedTabsByWorktree).find((wId) => - (state.unifiedTabsByWorktree[wId] ?? []).some((entry) => entry.id === item.id) - ) - if ( - owningWorktreeId && - resolveTerminalWorktreeRoute(state, owningWorktreeId)?.runtimeEnvironmentId - ) { - void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) => - setWebRuntimeTabProps({ worktreeId: owningWorktreeId, tabId: item.id, color }) - ) - } - } - }, - - updateTabPtyId: (tabId, ptyId, replacedPtyId, directSshRetryAttemptId) => { - // Why: final guard preventing a late caller from recreating retired tab maps (async spawn owners still do their own provider teardown). - if (!isTerminalTabPresent(get(), tabId)) { - return - } - let worktreeId: string | null = null - let wasActivationSpawn = false - const isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId) - set((s) => { - if (directSshRetryAttemptId) { - const pendingRetry = s.directSshPaneRetryByTabId[tabId] - const liveRetry = s.directSshLivePtyBindingByTabId[tabId] - const retryLease = - pendingRetry?.attemptId === directSshRetryAttemptId - ? pendingRetry - : liveRetry?.attemptId === directSshRetryAttemptId - ? liveRetry - : undefined - const boundTab = Object.values(s.tabsByWorktree) - .flat() - .find((candidate) => candidate.id === tabId) - if ( - !retryLease || - !boundTab || - parseAppSshPtyId(ptyId)?.connectionId !== retryLease.authority.targetId || - !isCurrentDirectSshAuthority(s, retryLease.authority) || - (boundTab.generation ?? 0) !== retryLease.tabGeneration - ) { - return s - } - } - const existingPtyIds = s.ptyIdsByTabId[tabId] ?? [] - const remote = parseRemoteRuntimePtyId(ptyId) - const legacyRemotePtyId = remote?.environmentId ? toRemoteRuntimePtyId(remote.handle) : null - const hasLegacyPtyBinding = legacyRemotePtyId - ? existingPtyIds.includes(legacyRemotePtyId) - : false - const explicitReplacementPtyId = replacedPtyId !== ptyId ? replacedPtyId : undefined - const replacementPtyId = - explicitReplacementPtyId ?? (hasLegacyPtyBinding ? legacyRemotePtyId : null) - const boundReplacementPtyId = - replacementPtyId && existingPtyIds.includes(replacementPtyId) ? replacementPtyId : null - const nextPtyIds = boundReplacementPtyId - ? [...new Set(existingPtyIds.map((id) => (id === boundReplacementPtyId ? ptyId : id)))] - : existingPtyIds.includes(ptyId) - ? existingPtyIds - : [...existingPtyIds, ptyId] - let nextTabsByWorktree = s.tabsByWorktree - for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) { - const index = tabs.findIndex((t) => t.id === tabId) - if (index === -1) { - continue - } - worktreeId = wId - const tab = tabs[index] - if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { - wasActivationSpawn = true - } - // Why: consume one suppression per split-pane activation callback. - const { pendingActivationSpawn: _unused, ...rest } = tab - void _unused - // Why: tab.ptyId is the single-pane fallback for legacy attach; later split-pane spawns must not steal it or remount/close reattaches the tab to the wrong PTY. - const currentTabPtyId = tab.ptyId === replacementPtyId ? ptyId : tab.ptyId - const nextTabPtyId = currentTabPtyId ?? nextPtyIds[0] ?? null - const nextPendingActivationSpawn = consumePendingActivationSpawn(tab.pendingActivationSpawn) - if (tab.pendingActivationSpawn || tab.ptyId !== nextTabPtyId) { - const nextTabs = [...tabs] - nextTabs[index] = { - ...rest, - ...(nextPendingActivationSpawn - ? { pendingActivationSpawn: nextPendingActivationSpawn } - : {}), - ptyId: nextTabPtyId - } - nextTabsByWorktree = { ...s.tabsByWorktree, [wId]: nextTabs } - } - break - } - // Why: the first active PTY changes sorting, except activation-spawn side effects. - const isFirstPty = existingPtyIds.length === 0 - const isActiveWorktree = worktreeId != null && s.activeWorktreeId === worktreeId - const shouldBumpSortEpoch = isFirstPty && isActiveWorktree && !wasActivationSpawn - const shouldRetainSuppressedExit = Boolean( - explicitReplacementPtyId && - (s.suppressedPtyExitIds[ptyId] || - (replacementPtyId && s.suppressedPtyExitIds[replacementPtyId])) - ) - const nextSuppressedPtyExitIds = { ...s.suppressedPtyExitIds } - delete nextSuppressedPtyExitIds[ptyId] - if (replacementPtyId) { - delete nextSuppressedPtyExitIds[replacementPtyId] - } - if (shouldRetainSuppressedExit) { - // Why: handle rotation keeps the same terminal lifecycle; an intentional exit racing the rotation must stay suppressed once. - nextSuppressedPtyExitIds[ptyId] = true - } - const hasReplacementPendingRestart = replacementPtyId - ? replacementPtyId in s.pendingCodexPaneRestartIds - : false - const hasReplacementRestartNotice = replacementPtyId - ? replacementPtyId in s.codexRestartNoticeByPtyId - : false - const hasReplacementMigrationUnsupported = replacementPtyId - ? replacementPtyId in s.migrationUnsupportedByPtyId - : false - const nextPendingCodexPaneRestartIds = hasReplacementPendingRestart - ? { ...s.pendingCodexPaneRestartIds } - : s.pendingCodexPaneRestartIds - const nextCodexRestartNoticeByPtyId = hasReplacementRestartNotice - ? { ...s.codexRestartNoticeByPtyId } - : s.codexRestartNoticeByPtyId - const nextMigrationUnsupportedByPtyId = hasReplacementMigrationUnsupported - ? { ...s.migrationUnsupportedByPtyId } - : s.migrationUnsupportedByPtyId - if (replacementPtyId) { - if (hasReplacementPendingRestart) { - nextPendingCodexPaneRestartIds[ptyId] = true - delete nextPendingCodexPaneRestartIds[replacementPtyId] - } - if (hasReplacementRestartNotice) { - const replacedNotice = nextCodexRestartNoticeByPtyId[replacementPtyId] - nextCodexRestartNoticeByPtyId[ptyId] ??= replacedNotice - delete nextCodexRestartNoticeByPtyId[replacementPtyId] - } - if (hasReplacementMigrationUnsupported) { - const replacedMigrationUnsupported = nextMigrationUnsupportedByPtyId[replacementPtyId] - nextMigrationUnsupportedByPtyId[ptyId] ??= { - ...replacedMigrationUnsupported, - ptyId - } - delete nextMigrationUnsupportedByPtyId[replacementPtyId] - } - } - const pendingRetry = s.directSshPaneRetryByTabId[tabId] - const liveRetry = s.directSshLivePtyBindingByTabId[tabId] - const retryLease = - pendingRetry?.attemptId === directSshRetryAttemptId - ? pendingRetry - : liveRetry?.attemptId === directSshRetryAttemptId - ? liveRetry - : undefined - const boundTab = worktreeId - ? nextTabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) - : undefined - const parsedSshPty = parseAppSshPtyId(ptyId) - const acknowledgesDirectSshRetry = Boolean( - retryLease && - boundTab && - parsedSshPty?.connectionId === retryLease.authority.targetId && - isCurrentDirectSshAuthority(s, retryLease.authority) && - (boundTab.generation ?? 0) === retryLease.tabGeneration && - nextPtyIds.includes(ptyId) - ) - let nextDirectSshPaneRetryByTabId = s.directSshPaneRetryByTabId - let nextDirectSshLivePtyBindingByTabId = s.directSshLivePtyBindingByTabId - if (acknowledgesDirectSshRetry && retryLease && boundTab?.ptyId) { - if (pendingRetry?.attemptId === directSshRetryAttemptId) { - nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } - delete nextDirectSshPaneRetryByTabId[tabId] - } - if (!liveRetry || liveRetry.attemptId !== directSshRetryAttemptId) { - nextDirectSshLivePtyBindingByTabId = { - ...s.directSshLivePtyBindingByTabId, - [tabId]: { - attemptId: retryLease.attemptId, - authority: retryLease.authority, - tabGeneration: retryLease.tabGeneration, - ptyId: boundTab.ptyId - } - } - } else if ( - (replacementPtyId === liveRetry.ptyId && boundTab.ptyId === ptyId) || - !nextPtyIds.includes(liveRetry.ptyId) - ) { - nextDirectSshLivePtyBindingByTabId = { - ...s.directSshLivePtyBindingByTabId, - [tabId]: { ...liveRetry, ptyId: boundTab.ptyId } - } - } - } else { - const liveBinding = s.directSshLivePtyBindingByTabId[tabId] - if (liveBinding) { - if ( - replacementPtyId === liveBinding.ptyId && - boundTab?.ptyId === ptyId && - isCurrentDirectSshAuthority(s, liveBinding.authority) - ) { - nextDirectSshLivePtyBindingByTabId = { - ...s.directSshLivePtyBindingByTabId, - [tabId]: { ...liveBinding, ptyId } - } - } - } - } - return { - ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), - ptyIdsByTabId: { - ...s.ptyIdsByTabId, - [tabId]: nextPtyIds - }, - lastKnownRelayPtyIdByTabId: { - ...s.lastKnownRelayPtyIdByTabId, - [tabId]: ptyId - }, - suppressedPtyExitIds: nextSuppressedPtyExitIds, - pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds, - codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, - migrationUnsupportedByPtyId: nextMigrationUnsupportedByPtyId, - directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, - directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId, - ...(shouldBumpSortEpoch ? { sortEpoch: s.sortEpoch + 1 } : {}) - } - }) - - // Why: activation spawns come from clicking a worktree, not work in it — skip the lastActivityAt stamp and sortEpoch bump; other spawn reasons still bump. - if (worktreeId && !wasActivationSpawn && !isRemoteRuntimeMirror) { - get().bumpWorktreeActivity(worktreeId) - } - }, - - clearTabPtyId: (tabId, ptyId) => { - if (ptyId && get().pendingPtyShutdownIds[ptyId]) { - // Why: an owner exit can arrive before its post-stop inventory; keep the renderer binding retryable until verification commits. - return - } - let worktreeId: string | null = null - let wasActivationSpawn = false - let preservesDirectSshContinuationGap = false - let isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId) - set((s) => { - const existingPtyIds = s.ptyIdsByTabId[tabId] ?? [] - const remainingPtyIds = ptyId ? existingPtyIds.filter((id) => id !== ptyId) : [] - const liveBinding = s.directSshLivePtyBindingByTabId[tabId] - let nextTabsByWorktree = s.tabsByWorktree - for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) { - const index = tabs.findIndex((t) => t.id === tabId) - if (index === -1) { - continue - } - worktreeId = wId - const tab = tabs[index] - if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { - wasActivationSpawn = true - } - if (!ptyId) { - isRemoteRuntimeMirror = - existingPtyIds.length > 0 && existingPtyIds.every((id) => isRemoteRuntimePtyId(id)) - } - // Why: consume pendingActivationSpawn on real activation clears, but keep it when clearing a stale wake-hint id — its fallback spawn still needs the suppression. - const { pendingActivationSpawn: _unused, ...rest } = tab - void _unused - const nextTabPtyId = remainingPtyIds.at(-1) ?? null - preservesDirectSshContinuationGap = Boolean( - ptyId && - remainingPtyIds.length === 0 && - liveBinding?.ptyId === ptyId && - getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0 && - isCurrentDirectSshAuthority(s, liveBinding.authority) - ) - const shouldRetainActivationSpawn = - preservesDirectSshContinuationGap || - (wasActivationSpawn && ptyId != null && !existingPtyIds.includes(ptyId)) - const nextPendingActivationSpawn = shouldRetainActivationSpawn - ? tab.pendingActivationSpawn - : consumePendingActivationSpawn(tab.pendingActivationSpawn) - if (tab.pendingActivationSpawn || tab.ptyId !== nextTabPtyId) { - const nextTabs = [...tabs] - nextTabs[index] = { - ...rest, - ...(nextPendingActivationSpawn - ? { pendingActivationSpawn: nextPendingActivationSpawn } - : {}), - ptyId: nextTabPtyId - } - nextTabsByWorktree = { ...s.tabsByWorktree, [wId]: nextTabs } - } - break - } - const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } - if (worktreeId) { - nextPtyIdsByTabId[tabId] = remainingPtyIds - } else { - // Why: repo purge can retire the owning tab before its async exit arrives; don't resurrect an orphan PTY index. - delete nextPtyIdsByTabId[tabId] - } - const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } - const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } - if (ptyId) { - delete nextPendingCodexPaneRestartIds[ptyId] - delete nextCodexRestartNoticeByPtyId[ptyId] - } else { - for (const currentPtyId of s.ptyIdsByTabId[tabId] ?? []) { - delete nextPendingCodexPaneRestartIds[currentPtyId] - delete nextCodexRestartNoticeByPtyId[currentPtyId] - } - } - // Why: an explicit exit drops the dead relay ID; bulk clears retain it for relay grace. - const nextLastKnownRelay = { ...s.lastKnownRelayPtyIdByTabId } - if (ptyId && nextLastKnownRelay[tabId] === ptyId) { - // Why: the relay slot holds ONE id per tab (the last pane to bind). If - // that pane exits, promote a surviving pane instead of clearing — else the - // survivor is left visible only in the layout leaf map, and a later - // relay-drop bulk-clear lets the orphan sweep delete the still-live tab - // (the orphan predicate reads this map but not layout leaves) (#9911). - const survivingPtyId = remainingPtyIds.at(-1) - if (survivingPtyId) { - nextLastKnownRelay[tabId] = survivingPtyId - } else { - delete nextLastKnownRelay[tabId] - } - } - let nextDirectSshPaneRetryByTabId = s.directSshPaneRetryByTabId - const pendingRetry = s.directSshPaneRetryByTabId[tabId] - if ( - pendingRetry && - (!ptyId || - (existingPtyIds.includes(ptyId) && - parseAppSshPtyId(ptyId)?.connectionId === pendingRetry.authority.targetId)) - ) { - nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } - delete nextDirectSshPaneRetryByTabId[tabId] - } - let nextDirectSshLivePtyBindingByTabId = s.directSshLivePtyBindingByTabId - if (liveBinding && (!ptyId || liveBinding.ptyId === ptyId)) { - nextDirectSshLivePtyBindingByTabId = { - ...s.directSshLivePtyBindingByTabId - } - const promotedPtyId = ptyId ? remainingPtyIds.at(-1) : undefined - if ( - promotedPtyId && - parseAppSshPtyId(promotedPtyId)?.connectionId === liveBinding.authority.targetId && - isCurrentDirectSshAuthority(s, liveBinding.authority) - ) { - nextDirectSshLivePtyBindingByTabId[tabId] = { - ...liveBinding, - ptyId: promotedPtyId - } - } else if (!preservesDirectSshContinuationGap) { - delete nextDirectSshLivePtyBindingByTabId[tabId] - } - } - - return { - ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), - ptyIdsByTabId: nextPtyIdsByTabId, - lastKnownRelayPtyIdByTabId: nextLastKnownRelay, - pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds, - codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, - directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, - directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId - } - }) - - // Bump activity on PTY exit, but skip intentional shutdowns (suppressed exits) and click-driven pane unmounts (pendingActivationSpawn). - if ( - worktreeId && - !wasActivationSpawn && - !isRemoteRuntimeMirror && - !hasWorktreeSleepIntent(worktreeId) && - !(ptyId && get().suppressedPtyExitIds[ptyId]) - ) { - get().bumpWorktreeActivity(worktreeId) - } - }, - - shutdownCompletedAgentPaneForHibernation: async (worktreeId, opts) => { - const paneKeys = [opts.paneKey] - const expectedRuntimePtyIds = sortedUniquePtyIds( - opts.expectedRuntimePtyId ? [opts.expectedRuntimePtyId] : [] - ) - const rendererShutdownPtyIds = [opts.ptyId] - const state = get() - const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(state, worktreeId) - // Why: pane transports emit renderer PTY ids, not raw exact-stop handles; guard only the identity that can deliver an exit callback. - const exitGuardPtyIds = [opts.ptyId] - const tab = (state.tabsByWorktree[worktreeId] ?? []).find( - (candidate) => candidate.id === opts.tabId - ) - const parsed = parsePaneKey(opts.paneKey) - const layout = state.terminalLayoutsByTabId[opts.tabId] - const liveTabPtyIds = state.ptyIdsByTabId[opts.tabId] ?? [] - if ( - !tab || - !parsed || - parsed.tabId !== opts.tabId || - parsed.leafId !== opts.leafId || - layout?.ptyIdsByLeafId?.[opts.leafId] !== opts.ptyId || - (expectedRuntimePtyIds.length === 0 && !liveTabPtyIds.includes(opts.ptyId)) - ) { - throw new Error('agent_hibernation_pane_binding_mismatch') - } - - const sleepingAgentSessionRecords = collectSleepingAgentSessionRecordsForWorktree( - state, - worktreeId, - { - paneKeys, - captureMode: 'completed-agent-hibernation' - } - ) - const retainedCompletionEvidence = collectHibernatedCompletionEvidenceForWorktree( - state, - worktreeId, - paneKeys - ) - if (!sleepingAgentSessionRecords[opts.paneKey]) { - // Why: killing the PTY with no persisted resume record strands the pane unwakeable; abort instead of hibernating unrecoverably. - throw new Error('agent_hibernation_capture_missing') - } - - const capture = shutdownBufferCaptures.get(opts.tabId) - if (capture) { - try { - capture({ includeLocalBuffers: false }) - } catch { - // Don't let one tab's capture failure block the pane hibernation. - } - } - - // Why: store sleeping records before kill, since pty:exit can arrive first. - const sleepingRecordKeys = Object.keys(sleepingAgentSessionRecords) - const replacedSleepingRecords: Record = {} - for (const key of sleepingRecordKeys) { - const existing = state.sleepingAgentSessionsByPaneKey[key] - if (existing) { - replacedSleepingRecords[key] = existing - } - } - - const rollbackTargetShutdownState = (): void => { - set((s) => { - const next = { ...s.suppressedPtyExitIds } - for (const ptyId of exitGuardPtyIds) { - delete next[ptyId] - } - const nextSleeping = { ...s.sleepingAgentSessionsByPaneKey } - for (const key of sleepingRecordKeys) { - const replaced = replacedSleepingRecords[key] - if (replaced) { - nextSleeping[key] = replaced - } else { - delete nextSleeping[key] - } - } - return { suppressedPtyExitIds: next, sleepingAgentSessionsByPaneKey: nextSleeping } - }) - } - - set((s) => ({ - suppressedPtyExitIds: { - ...s.suppressedPtyExitIds, - ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) - }, - sleepingAgentSessionsByPaneKey: { - ...s.sleepingAgentSessionsByPaneKey, - ...sleepingAgentSessionRecords - } - })) - - if (expectedRuntimePtyIds.length > 0) { - if (!runtimeEnvironmentId) { - rollbackTargetShutdownState() - throw new Error('missing_runtime_for_exact_terminal_stop') - } - let stopResult: { - stoppedPtyIds?: string[] - livePtyIds?: string[] - postStopVerified?: boolean - postStopFailure?: string - } - try { - stopResult = await callRuntimeRpc<{ - stoppedPtyIds?: string[] - livePtyIds?: string[] - postStopVerified?: boolean - postStopFailure?: string - }>( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'terminal.stopExact', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - expectedPtyIds: expectedRuntimePtyIds, - keepHistory: true, - targetOnly: true - }, - { timeoutMs: 15_000 } - ) - } catch (err) { - rollbackTargetShutdownState() - throw err - } - const stoppedPtyIds = sortedUniquePtyIds(stopResult.stoppedPtyIds) - const livePtyIds = sortedUniquePtyIds(stopResult.livePtyIds) - const targetWasLive = expectedRuntimePtyIds.every((ptyId) => livePtyIds.includes(ptyId)) - if (!equalStringSets(stoppedPtyIds, expectedRuntimePtyIds) || !targetWasLive) { - rollbackTargetShutdownState() - throw new Error('exact_terminal_stop_mismatch') - } - if (stopResult.postStopVerified !== true) { - rollbackTargetShutdownState() - throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified') - } - for (const snapshot of unregisterPtyDataHandlers(rendererShutdownPtyIds) ?? []) { - snapshot.commit?.() - } - } else if (!opts.ptyId.startsWith('remote:')) { - // Why: pty.kill can flush final data before exit; unregister first so stale handlers can't fire phantom notifications during hibernation. - const handlerSnapshots = unregisterPtyDataHandlers(rendererShutdownPtyIds) ?? [] - try { - await window.api.pty.kill(opts.ptyId, { keepHistory: true }) - } catch (err) { - restorePtyDataHandlersAfterFailedShutdown(handlerSnapshots) - rollbackTargetShutdownState() - throw err - } - for (const snapshot of handlerSnapshots) { - snapshot.commit?.() - } - } - - set((s) => { - const existingPtyIds = s.ptyIdsByTabId[opts.tabId] ?? [] - const shutdownPtyIdSet = new Set(rendererShutdownPtyIds) - const remainingPtyIds = existingPtyIds.filter((ptyId) => !shutdownPtyIdSet.has(ptyId)) - const nextTabsByWorktree = { ...s.tabsByWorktree } - const tabs = nextTabsByWorktree[worktreeId] ?? [] - const tabIndex = tabs.findIndex((candidate) => candidate.id === opts.tabId) - if (tabIndex !== -1) { - const nextTabs = [...tabs] - nextTabs[tabIndex] = { - ...nextTabs[tabIndex], - ptyId: remainingPtyIds.at(-1) ?? null - } - nextTabsByWorktree[worktreeId] = nextTabs - } - - const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } - for (const ptyId of exitGuardPtyIds) { - delete nextCodexRestartNoticeByPtyId[ptyId] - } - const nextLastKnownRelay = - remainingPtyIds.length === 0 - ? { ...s.lastKnownRelayPtyIdByTabId } - : s.lastKnownRelayPtyIdByTabId - if (remainingPtyIds.length === 0) { - delete nextLastKnownRelay[opts.tabId] - } - - let nextRuntimePaneTitlesByTabId = s.runtimePaneTitlesByTabId - const numericPaneId = Number(opts.leafId) - if ( - Number.isInteger(numericPaneId) && - s.runtimePaneTitlesByTabId[opts.tabId]?.[numericPaneId] - ) { - const nextByPane = { ...s.runtimePaneTitlesByTabId[opts.tabId] } - delete nextByPane[numericPaneId] - nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } - if (Object.keys(nextByPane).length > 0) { - nextRuntimePaneTitlesByTabId[opts.tabId] = nextByPane - } else { - delete nextRuntimePaneTitlesByTabId[opts.tabId] - } - } - - const nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } - const nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes } - const nextLastTerminalInputAtByPaneKey = { ...s.lastTerminalInputAtByPaneKey } - delete nextUnreadTerminalPanes[opts.paneKey] - delete nextUnreadAgentCompletionPanes[opts.paneKey] - delete nextLastTerminalInputAtByPaneKey[opts.paneKey] - - return { - tabsByWorktree: nextTabsByWorktree, - ptyIdsByTabId: { - ...s.ptyIdsByTabId, - [opts.tabId]: remainingPtyIds - }, - lastKnownRelayPtyIdByTabId: nextLastKnownRelay, - suppressedPtyExitIds: { - ...s.suppressedPtyExitIds, - ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) - }, - codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, - ...(nextRuntimePaneTitlesByTabId !== s.runtimePaneTitlesByTabId - ? { runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId } - : {}), - unreadTerminalPanes: nextUnreadTerminalPanes, - unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes, - lastTerminalInputAtByPaneKey: nextLastTerminalInputAtByPaneKey - } - }) - - get().dropHibernatedAgentStatusPane(worktreeId, opts.paneKey, { - retainedCompletionEvidence - }) - }, - - clearDirectSshTargetPtyBindings: (targetId) => { - let clearedCount = 0 - set((s) => { - const result = clearDirectSshTerminalBindings(s, resolveDirectSshTerminalKeys(s, targetId)) - clearedCount = result.clearedCount - return result.patch ?? s - }) - return clearedCount - }, - - invalidateStaleDirectSshTargetPtyBindings: (authority) => { - let clearedCount = 0 - set((s) => { - if (!isCurrentDirectSshAuthority(s, authority)) { - return s - } - const result = invalidateStaleDirectSshTerminalBindings( - s, - resolveDirectSshTerminalKeys(s, authority.targetId), - authority - ) - clearedCount = result.clearedCount - return result.patch ?? s - }) - return clearedCount - }, - - retryDirectSshTargetPanes: (authority, now = Date.now()) => { - let retriedCount = 0 - set((s) => { - if (!isCurrentDirectSshAuthority(s, authority)) { - return s - } - const result = retryDirectSshTerminalPanes( - s, - resolveDirectSshTerminalKeys(s, authority.targetId), - authority, - now - ) - retriedCount = result.retriedCount - return result.patch ?? s - }) - return retriedCount - }, - - settleDirectSshPaneRetry: (result, now = Date.now()) => { - set((s) => { - if (!isCurrentDirectSshAuthority(s, result.authority)) { - return s - } - const history = s.directSshPaneRetryHistoryByTabId[result.tabId] - const preservesExhaustedSplitAttempt = - (result.status === 'failed' || result.status === 'timed-out') && - history != null && - directSshAuthoritiesEqual(history.authority, result.authority) && - history.attemptedAt.length >= 2 - if (preservesExhaustedSplitAttempt) { - return s - } - const settlement = settleDirectSshPaneRetryState(s, result) - if (!settlement) { - return s - } - const settledState = { ...s, ...settlement } - if (result.status !== 'failed' && result.status !== 'timed-out') { - return settledState - } - const retry = retrySettledDirectSshTerminalPane( - settledState, - resolveDirectSshTerminalKeys(settledState, result.authority.targetId), - result.authority, - result.tabId, - now - ) - return retry.patch ? { ...settledState, ...retry.patch } : settledState - }) - }, - - shutdownWorktreeTerminals: async (worktreeId, opts) => { - const keepIdentifiers = opts?.keepIdentifiers ?? false - const shutdownReason: AgentStatusWorktreeShutdownReason = - opts?.shutdownReason ?? (keepIdentifiers ? 'manual-sleep' : 'remove-worktree') - const tabs = get().tabsByWorktree[worktreeId] ?? [] - const ptyIds = tabs.flatMap((tab) => get().ptyIdsByTabId[tab.id] ?? []) - const rendererShutdownPtyIds = sortedUniquePtyIds(ptyIds) - const expectedRuntimePtyIds = sortedUniquePtyIds(opts?.expectedRuntimePtyIds) - const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId) - // Why: only renderer-bound ids emit pane exit callbacks, so they are the complete guard set (expectedRuntimePtyIds are raw RPC handles). - const exitGuardPtyIds = rendererShutdownPtyIds - const sleepingAgentSessionRecords = keepIdentifiers - ? collectSleepingAgentSessionRecordsForWorktree(get(), worktreeId, { - paneKeys: opts?.sleepingPaneKeys, - ...(shutdownReason === 'manual-sleep' ? { captureMode: 'manual-worktree-sleep' } : {}), - ...(shutdownReason === 'auto-hibernate-completed-agent' - ? { captureMode: 'completed-agent-hibernation' } - : {}) - }) - : {} - const retainedCompletionEvidence = - shutdownReason === 'auto-hibernate-completed-agent' - ? collectHibernatedCompletionEvidenceForWorktree(get(), worktreeId, opts?.sleepingPaneKeys) - : [] - let handlerSnapshots: ReturnType = [] - let partialRendererStopSettled = false - const markShutdownPending = (): void => { - set((s) => { - const nextPending = { ...s.pendingPtyShutdownIds } - for (const ptyId of exitGuardPtyIds) { - nextPending[ptyId] = (nextPending[ptyId] ?? 0) + 1 - } - return { - suppressedPtyExitIds: { - ...s.suppressedPtyExitIds, - ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) - }, - pendingPtyShutdownIds: nextPending - } - }) - } - const rollbackShutdown = (): void => { - if (handlerSnapshots.length > 0) { - restorePtyDataHandlersAfterFailedShutdown(handlerSnapshots) - } - set((s) => { - const nextSuppressed = { ...s.suppressedPtyExitIds } - const nextPending = { ...s.pendingPtyShutdownIds } - for (const ptyId of exitGuardPtyIds) { - const remainingOwners = (nextPending[ptyId] ?? 0) - 1 - if (remainingOwners > 0) { - nextPending[ptyId] = remainingOwners - } else { - delete nextPending[ptyId] - if (!hasCommittedPtyShutdownSettlement(ptyId)) { - delete nextSuppressed[ptyId] - } - } - } - return { - suppressedPtyExitIds: nextSuppressed, - pendingPtyShutdownIds: nextPending - } - }) - const settledPtyIds = exitGuardPtyIds.filter((ptyId) => !get().isPtyShutdownPending(ptyId)) - const committedPtyIds = settledPtyIds.filter(hasCommittedPtyShutdownSettlement) - const rolledBackPtyIds = settledPtyIds.filter( - (ptyId) => !hasCommittedPtyShutdownSettlement(ptyId) - ) - markCommittedPtyShutdowns(committedPtyIds) - settleDeferredPtyShutdownExits(committedPtyIds, 'committed') - settleDeferredPtyShutdownExits(rolledBackPtyIds, 'rolled-back') - clearCommittedPtyShutdownSettlements(settledPtyIds) - } - const stopRendererPtys = async (): Promise<{ - stoppedPtyIds: string[] - failure?: PromiseRejectedResult - }> => { - const localPtyIds = rendererShutdownPtyIds.filter((ptyId) => !ptyId.startsWith('remote:')) - const results = await Promise.allSettled( - localPtyIds.map((ptyId) => window.api.pty.kill(ptyId, { keepHistory: keepIdentifiers })) - ) - const stoppedPtyIds = [ - ...(runtimeEnvironmentId - ? rendererShutdownPtyIds.filter((ptyId) => ptyId.startsWith('remote:')) - : []), - ...localPtyIds.filter((_, index) => results[index]?.status === 'fulfilled') - ] - disposeParkedTerminalWatchersForPtyIds(stoppedPtyIds) - return { - stoppedPtyIds, - failure: results.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - } - } - const settlePartialRendererStop = (stoppedPtyIds: readonly string[]): void => { - partialRendererStopSettled = true - const stopped = new Set(stoppedPtyIds) - const stoppedSnapshots = handlerSnapshots.filter((snapshot) => stopped.has(snapshot.ptyId)) - const failedSnapshots = handlerSnapshots.filter((snapshot) => !stopped.has(snapshot.ptyId)) - for (const snapshot of stoppedSnapshots) { - snapshot.commit?.() - } - restorePtyDataHandlersAfterFailedShutdown(failedSnapshots) - noteCommittedPtyShutdownSettlements(stoppedPtyIds) - set((s) => { - const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } - for (const tab of tabs) { - nextPtyIdsByTabId[tab.id] = (s.ptyIdsByTabId[tab.id] ?? []).filter( - (ptyId) => !stopped.has(ptyId) - ) - } - const nextPending = { ...s.pendingPtyShutdownIds } - const nextSuppressed = { ...s.suppressedPtyExitIds } - for (const ptyId of exitGuardPtyIds) { - const remainingOwners = (nextPending[ptyId] ?? 0) - 1 - if (remainingOwners > 0) { - nextPending[ptyId] = remainingOwners - } else { - delete nextPending[ptyId] - if (!stopped.has(ptyId)) { - delete nextSuppressed[ptyId] - } - } - } - return { - ptyIdsByTabId: nextPtyIdsByTabId, - pendingPtyShutdownIds: nextPending, - suppressedPtyExitIds: nextSuppressed - } - }) - const failedPtyIds = exitGuardPtyIds.filter((ptyId) => !stopped.has(ptyId)) - markCommittedPtyShutdowns(stoppedPtyIds) - settleDeferredPtyShutdownExits(stoppedPtyIds, 'committed') - settleDeferredPtyShutdownExits(failedPtyIds, 'rolled-back') - clearCommittedPtyShutdownSettlements(exitGuardPtyIds) - } - - // Why: capture buffers before kill, which unmounts panes and drops SSH relay history. - // Why: capture() updates layouts, so the following updater must merge current state. - if (keepIdentifiers) { - for (const tab of tabs) { - const capture = shutdownBufferCaptures.get(tab.id) - if (capture) { - try { - capture({ includeLocalBuffers: false }) - } catch { - // Don't let one tab's capture failure block the rest. - } - } - } - } - - if (expectedRuntimePtyIds.length === 0) { - markShutdownPending() - handlerSnapshots = unregisterPtyDataHandlers(rendererShutdownPtyIds) ?? [] - try { - // Why an opt-in, not the reason: defaulting to the stop keeps callers without backend teardown from leaking remote PTYs. - if (runtimeEnvironmentId && opts?.backendOwnsPtyTeardown !== true) { - await (shutdownReason === 'manual-sleep' - ? requestRemoteWorktreeSleep({ - environmentId: runtimeEnvironmentId, - worktreeId - }) - : callRuntimeRpc( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'terminal.stop', - { worktree: toRuntimeWorktreeSelector(worktreeId) }, - { timeoutMs: 15_000 } - )) - } - - // Why: client-owned teardown waits for the owner RPC so a failure leaves renderer bindings retryable. - const rendererStop = await stopRendererPtys() - if (rendererStop.failure) { - settlePartialRendererStop(rendererStop.stoppedPtyIds) - throw rendererStop.failure.reason - } - } catch (err) { - if (!partialRendererStopSettled) { - rollbackShutdown() - } - throw err - } - } - - // Exact-stop callers own physical teardown and must not use the remove-worktree reason. - if (expectedRuntimePtyIds.length > 0) { - if (!runtimeEnvironmentId) { - throw new Error('missing_runtime_for_exact_terminal_stop') - } - markShutdownPending() - handlerSnapshots = unregisterPtyDataHandlers(rendererShutdownPtyIds) ?? [] - let stopResult: { - stoppedPtyIds?: string[] - livePtyIds?: string[] - postStopVerified?: boolean - postStopFailure?: string - remainingLivePtyIds?: string[] - } - try { - stopResult = await callRuntimeRpc<{ - stoppedPtyIds?: string[] - livePtyIds?: string[] - }>( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'terminal.stopExact', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - expectedPtyIds: expectedRuntimePtyIds, - keepHistory: keepIdentifiers - }, - { timeoutMs: 15_000 } - ) - } catch (err) { - rollbackShutdown() - throw err - } - const stoppedPtyIds = sortedUniquePtyIds(stopResult.stoppedPtyIds) - const livePtyIds = sortedUniquePtyIds(stopResult.livePtyIds) - if ( - !equalStringSets(stoppedPtyIds, expectedRuntimePtyIds) || - !equalStringSets(livePtyIds, expectedRuntimePtyIds) - ) { - rollbackShutdown() - throw new Error('exact_terminal_stop_mismatch') - } - if (stopResult.postStopVerified !== true) { - rollbackShutdown() - throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified') - } - try { - const rendererStop = await stopRendererPtys() - if (rendererStop.failure) { - settlePartialRendererStop(rendererStop.stoppedPtyIds) - throw rendererStop.failure.reason - } - } catch (err) { - if (!partialRendererStopSettled) { - rollbackShutdown() - } - throw err - } - } - - for (const snapshot of handlerSnapshots) { - snapshot.commit?.() - } - noteCommittedPtyShutdownSettlements(exitGuardPtyIds) - - set((s) => { - const nextTabsByWorktree = keepIdentifiers - ? s.tabsByWorktree - : { - ...s.tabsByWorktree, - [worktreeId]: (s.tabsByWorktree[worktreeId] ?? []).map((tab, index) => - clearTransientTerminalState(tab, index) - ) - } - const nextPtyIdsByTabId = { - ...s.ptyIdsByTabId, - ...Object.fromEntries(tabs.map((tab) => [tab.id, [] as string[]] as const)) - } - const nextRuntimePaneTitlesByTabId = keepIdentifiers - ? s.runtimePaneTitlesByTabId - : { ...s.runtimePaneTitlesByTabId } - const nextSuppressedPtyExitIds = { - ...s.suppressedPtyExitIds, - ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) - } - const nextPendingPtyShutdownIds = { ...s.pendingPtyShutdownIds } - for (const ptyId of exitGuardPtyIds) { - const remainingOwners = (nextPendingPtyShutdownIds[ptyId] ?? 0) - 1 - if (remainingOwners > 0) { - nextPendingPtyShutdownIds[ptyId] = remainingOwners - } else { - delete nextPendingPtyShutdownIds[ptyId] - } - } - // Why: keep pendingCodexPaneRestartIds (same ptyId survives sleep→wake), but clear codexRestartNoticeByPtyId since wake's post-spawn ptyId may differ. - const nextPendingCodexPaneRestartIds = keepIdentifiers - ? s.pendingCodexPaneRestartIds - : { ...s.pendingCodexPaneRestartIds } - const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } - for (const ptyId of exitGuardPtyIds) { - if (!keepIdentifiers) { - delete nextPendingCodexPaneRestartIds[ptyId] - } - delete nextCodexRestartNoticeByPtyId[ptyId] - } - // Why: setup-split and issue-command-split are transient new-tab one-shots, not sleep-recovery state; clear in both cases. - const nextPendingSetupSplitByTabId = { ...s.pendingSetupSplitByTabId } - const nextPendingIssueCommandSplitByTabId = { ...s.pendingIssueCommandSplitByTabId } - // Why: remove-worktree must clear ptyIdsByLeafId (dead IDs → zombie reattach pane on remount); sleep preserves it so wake can reattach via pty.spawn. - const nextTerminalLayoutsByTabId = { ...s.terminalLayoutsByTabId } - // Why: unread dots survive worktree switches, but shutdown/sleep both kill the PTYs behind them, so dead-ptyId unread state is stale — clear in both. - // Why: preserve the unreadTerminalTabs reference when no shutting-down tab was unread, avoiding a no-op state allocation that re-evaluates selectors. - let nextUnreadTerminalTabs = s.unreadTerminalTabs - let nextUnreadTerminalPanes = s.unreadTerminalPanes - let nextUnreadAgentCompletionPanes = s.unreadAgentCompletionPanes - let nextLastTerminalInputAtByPaneKey = s.lastTerminalInputAtByPaneKey - for (const tab of tabs) { - if (!keepIdentifiers) { - delete nextRuntimePaneTitlesByTabId[tab.id] - } - delete nextPendingSetupSplitByTabId[tab.id] - delete nextPendingIssueCommandSplitByTabId[tab.id] - if (nextUnreadTerminalTabs[tab.id]) { - if (nextUnreadTerminalTabs === s.unreadTerminalTabs) { - nextUnreadTerminalTabs = { ...s.unreadTerminalTabs } - } - delete nextUnreadTerminalTabs[tab.id] - } - for (const paneKey of Object.keys(nextUnreadTerminalPanes)) { - if (paneKey.startsWith(`${tab.id}:`)) { - if (nextUnreadTerminalPanes === s.unreadTerminalPanes) { - nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } - } - delete nextUnreadTerminalPanes[paneKey] - } - } - for (const paneKey of Object.keys(nextUnreadAgentCompletionPanes)) { - if (paneKey.startsWith(`${tab.id}:`)) { - if (nextUnreadAgentCompletionPanes === s.unreadAgentCompletionPanes) { - nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes } - } - delete nextUnreadAgentCompletionPanes[paneKey] - } - } - for (const paneKey of Object.keys(nextLastTerminalInputAtByPaneKey)) { - if (paneKey.startsWith(`${tab.id}:`)) { - if (nextLastTerminalInputAtByPaneKey === s.lastTerminalInputAtByPaneKey) { - nextLastTerminalInputAtByPaneKey = { ...s.lastTerminalInputAtByPaneKey } - } - delete nextLastTerminalInputAtByPaneKey[paneKey] - } - } - if (!keepIdentifiers) { - const existingLayout = nextTerminalLayoutsByTabId[tab.id] - if (existingLayout?.ptyIdsByLeafId) { - nextTerminalLayoutsByTabId[tab.id] = { - ...existingLayout, - ptyIdsByLeafId: {} - } - } - } - } - - // Why: remove-worktree kills the relay PTY, so a persisted session ID would fail reattach; sleep preserves it for wake's re-spawn over the relay. - const nextLastKnownRelay = keepIdentifiers - ? s.lastKnownRelayPtyIdByTabId - : { ...s.lastKnownRelayPtyIdByTabId } - if (!keepIdentifiers) { - for (const tab of tabs) { - delete nextLastKnownRelay[tab.id] - } - } - - return { - tabsByWorktree: nextTabsByWorktree, - ptyIdsByTabId: nextPtyIdsByTabId, - lastKnownRelayPtyIdByTabId: nextLastKnownRelay, - runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, - suppressedPtyExitIds: nextSuppressedPtyExitIds, - pendingPtyShutdownIds: nextPendingPtyShutdownIds, - pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds, - codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, - pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, - pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, - terminalLayoutsByTabId: nextTerminalLayoutsByTabId, - // Why: skip writing unreadTerminalTabs when unchanged to avoid a no-op state allocation that re-evaluates full-state selectors. Mirrors tabs.ts. - ...(nextUnreadTerminalTabs !== s.unreadTerminalTabs - ? { unreadTerminalTabs: nextUnreadTerminalTabs } - : {}), - ...(nextUnreadTerminalPanes !== s.unreadTerminalPanes - ? { unreadTerminalPanes: nextUnreadTerminalPanes } - : {}), - ...(nextUnreadAgentCompletionPanes !== s.unreadAgentCompletionPanes - ? { unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes } - : {}), - ...(nextLastTerminalInputAtByPaneKey !== s.lastTerminalInputAtByPaneKey - ? { lastTerminalInputAtByPaneKey: nextLastTerminalInputAtByPaneKey } - : {}) - } - }) - - if (keepIdentifiers) { - set((s) => { - const base = - shutdownReason === 'manual-sleep' - ? removeSleepingRecordsReplacedByManualWorktreeSleep( - s.sleepingAgentSessionsByPaneKey, - worktreeId, - opts?.sleepingPaneKeys, - sleepingAgentSessionRecords - ).records - : s.sleepingAgentSessionsByPaneKey - return { - sleepingAgentSessionsByPaneKey: { - ...base, - ...sleepingAgentSessionRecords - } - } - }) - } else { - get().clearSleepingAgentSessionsByWorktree(worktreeId) - } - - // Why: only automatic completed-agent sleep keeps passive completion evidence; manual sleep/remove fold the whole worktree surface. - get().dropAgentStatusByWorktree(worktreeId, { - shutdownReason, - sleepingPaneKeys: opts?.sleepingPaneKeys, - retainedCompletionEvidence - }) - get().clearPaneForegroundAgentByWorktree(worktreeId) - const settledPtyIds = exitGuardPtyIds.filter((ptyId) => !get().isPtyShutdownPending(ptyId)) - markCommittedPtyShutdowns(settledPtyIds) - settleDeferredPtyShutdownExits(settledPtyIds, 'committed') - clearCommittedPtyShutdownSettlements(settledPtyIds) - }, - - consumeSuppressedPtyExit: (ptyId) => { - let wasSuppressed = false - set((s) => { - if (!s.suppressedPtyExitIds[ptyId]) { - return {} - } - wasSuppressed = true - const next = { ...s.suppressedPtyExitIds } - delete next[ptyId] - return { suppressedPtyExitIds: next } - }) - return wasSuppressed - }, - - isPtyShutdownPending: (ptyId) => (get().pendingPtyShutdownIds[ptyId] ?? 0) > 0, - - suppressPtyExit: (ptyId) => { - set((s) => ({ - suppressedPtyExitIds: { ...s.suppressedPtyExitIds, [ptyId]: true } - })) - }, - - queueCodexPaneRestarts: (ptyIds) => { - if (ptyIds.length === 0) { - return - } - set((s) => { - // Why: the prompt is answered the moment the user asks for a restart. A - // pane whose tab isn't mounted can only restart when it next mounts, and - // leaving the prompt up re-showed a button that now does nothing. - const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } - for (const ptyId of ptyIds) { - const notice = nextCodexRestartNoticeByPtyId[ptyId] - if (notice) { - // Why: mirror of the strip in dismissCodexRestartNotices. A queued - // restart leaves the pane on the old account until it relaunches, so - // it must re-block input that an earlier dismissal had freed. - const { dismissed: _dismissed, ...kept } = notice - nextCodexRestartNoticeByPtyId[ptyId] = { ...kept, restartRequested: true } - } - } - return { - pendingCodexPaneRestartIds: { - ...s.pendingCodexPaneRestartIds, - ...Object.fromEntries(ptyIds.map((ptyId) => [ptyId, true] as const)) - }, - codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId - } - }) - }, - - consumePendingCodexPaneRestart: (ptyId) => { - let wasQueued = false - set((s) => { - if (!s.pendingCodexPaneRestartIds[ptyId]) { - return {} - } - wasQueued = true - const next = { ...s.pendingCodexPaneRestartIds } - delete next[ptyId] - return { pendingCodexPaneRestartIds: next } - }) - return wasQueued - }, - - markCodexRestartNotices: (notices) => { - if (notices.length === 0) { - return [] - } - const noticedPtyIds: string[] = [] - set((s) => { - const next = { ...s.codexRestartNoticeByPtyId } - const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } - for (const notice of notices) { - const existing = next[notice.ptyId] - // Why one record rather than two lookups: the label and the id of the - // launch account have to come from the same source, or they can end up - // describing two different accounts. - const launch = existing ?? notice - const target = { id: notice.nextAccountId, label: notice.nextAccountLabel } - const homeRouteChanged = - notice.homeRouteChanged === undefined - ? existing?.homeRouteChanged === true - : notice.homeRouteChanged - - // Why: a live Codex pane keeps its original launch account until it actually restarts, so A -> B -> A must not leave a stale restart notice. - if ( - !homeRouteChanged && - isSameCodexRestartNoticeAccount( - { id: launch.previousAccountId, label: launch.previousAccountLabel }, - target - ) - ) { - delete next[notice.ptyId] - delete nextPendingCodexPaneRestartIds[notice.ptyId] - continue - } - - next[notice.ptyId] = { - previousAccountLabel: launch.previousAccountLabel, - nextAccountLabel: notice.nextAccountLabel, - ...(launch.previousAccountId === undefined - ? {} - : { previousAccountId: launch.previousAccountId }), - ...(notice.nextAccountId === undefined ? {} : { nextAccountId: notice.nextAccountId }), - ...(homeRouteChanged ? { homeRouteChanged: true as const } : {}), - // Why: a queued restart relaunches under whatever account is selected - // when it runs, so a later switch does not reopen an answered prompt. - ...(existing?.restartRequested ? { restartRequested: true as const } : {}), - // Why: a dismissal answered one question — "keep the launch account - // instead of this one". Only a genuinely different target re-asks it, - // so a later C reopens the prompt while adding an account or - // reauthenticating the active one (both re-mark live panes with the - // selection unchanged) must not resurrect it and re-mute the pane. - ...(existing?.dismissed && - isSameCodexRestartNoticeAccount( - { id: existing.nextAccountId, label: existing.nextAccountLabel }, - target - ) - ? { dismissed: true as const } - : {}) - } - noticedPtyIds.push(notice.ptyId) - } - return { - codexRestartNoticeByPtyId: next, - pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds - } - }) - return noticedPtyIds - }, - - clearCodexRestartNotice: (ptyId) => { - set((s) => { - if (!s.codexRestartNoticeByPtyId[ptyId]) { - return {} - } - const next = { ...s.codexRestartNoticeByPtyId } - const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } - delete next[ptyId] - delete nextPendingCodexPaneRestartIds[ptyId] - return { - codexRestartNoticeByPtyId: next, - pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds - } - }) - }, - - dismissCodexRestartNotices: (ptyIds) => { - set((s) => { - // Why: keeping the old account is an answer, not a restart — the record - // stays so `previousAccountLabel` still names the pane's launch account, - // but every consumer treats it as answered (prompt hidden, input freed). - const next = { ...s.codexRestartNoticeByPtyId } - const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } - let changed = false - for (const ptyId of ptyIds) { - const notice = next[ptyId] - if (!notice || notice.dismissed) { - continue - } - const { restartRequested: _restartRequested, ...kept } = notice - next[ptyId] = { ...kept, dismissed: true } - delete nextPendingCodexPaneRestartIds[ptyId] - changed = true - } - if (!changed) { - return {} - } - return { - codexRestartNoticeByPtyId: next, - pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds - } - }) - }, - - reopenCodexRestartPrompt: (ptyId) => { - set((s) => { - const notice = s.codexRestartNoticeByPtyId[ptyId] - if (!notice?.restartRequested) { - return {} - } - const { restartRequested: _restartRequested, ...kept } = notice - const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } - delete nextPendingCodexPaneRestartIds[ptyId] - return { - codexRestartNoticeByPtyId: { ...s.codexRestartNoticeByPtyId, [ptyId]: kept }, - pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds - } - }) - }, - - replaceTerminalLayoutPanePtyId: (tabId, leafId, ptyId) => { - set((s) => { - const layout = s.terminalLayoutsByTabId[tabId] - if (!layout || layout.ptyIdsByLeafId?.[leafId] === ptyId) { - return {} - } - return { - terminalLayoutsByTabId: { - ...s.terminalLayoutsByTabId, - [tabId]: { - ...layout, - ptyIdsByLeafId: { ...layout.ptyIdsByLeafId, [leafId]: ptyId } - } - } - } - }) - }, - - setTabPaneExpanded: (tabId, expanded) => { - set((s) => ({ - expandedPaneByTabId: { ...s.expandedPaneByTabId, [tabId]: expanded } - })) - }, - - setTabCanExpandPane: (tabId, canExpand) => { - set((s) => ({ - canExpandPaneByTabId: { ...s.canExpandPaneByTabId, [tabId]: canExpand } - })) - }, - - setTabLayout: (tabId, layout) => { - let ownershipTransfers: ReturnType = [] - set((s) => { - if (!layout) { - if (!(tabId in s.terminalLayoutsByTabId)) { - return s - } - const next = { ...s.terminalLayoutsByTabId } - delete next[tabId] - return { terminalLayoutsByTabId: next } - } - const normalized = normalizeTerminalLayoutPtyOwnership(layout) - // Resolved before the bailout: normalization can transfer pane ownership even when the stored snapshot is untouched. - if (normalized.changed) { - ownershipTransfers = resolveTerminalLayoutPtyOwnershipTransfers(layout, normalized.snapshot) - } - // Why: pane-title churn re-persists structurally identical snapshots; bailing keeps every pane selector asleep. - const existing = s.terminalLayoutsByTabId[tabId] - if (existing && terminalLayoutEqual(existing, normalized.snapshot)) { - return s - } - return { - terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tabId]: normalized.snapshot } - } - }) - transferNormalizedTerminalLayoutPtyOwnership(get(), tabId, ownershipTransfers) - }, - - syncPaneDetachPtyOwnership: ({ - detachedLeafId, - detachedPtyId, - sourceLayout, - sourceTabId, - targetTabId - }) => { - const sourcePaneKey = makePaneKey(sourceTabId, detachedLeafId) - const targetPaneKey = makePaneKey(targetTabId, detachedLeafId) - set((s) => { - const layoutSourcePtyIds = uniquePtyIds(Object.values(sourceLayout.ptyIdsByLeafId ?? {})) - const existingSourcePtyIds = (s.ptyIdsByTabId[sourceTabId] ?? []).filter( - (ptyId) => ptyId !== detachedPtyId - ) - const sourcePtyIds = layoutSourcePtyIds.length > 0 ? layoutSourcePtyIds : existingSourcePtyIds - const sourcePrimaryPtyId = resolvePrimaryLayoutPtyId(sourceLayout) ?? sourcePtyIds[0] ?? null - const nextPtyIdsByTabId = { - ...s.ptyIdsByTabId, - [sourceTabId]: sourcePtyIds - } - if (detachedPtyId) { - nextPtyIdsByTabId[targetTabId] = uniquePtyIds([ - ...(nextPtyIdsByTabId[targetTabId] ?? []), - detachedPtyId - ]) - } - - const nextLastKnownRelayPtyIdByTabId = { ...s.lastKnownRelayPtyIdByTabId } - if (sourcePrimaryPtyId) { - nextLastKnownRelayPtyIdByTabId[sourceTabId] = sourcePrimaryPtyId - } else { - delete nextLastKnownRelayPtyIdByTabId[sourceTabId] - } - if (detachedPtyId) { - nextLastKnownRelayPtyIdByTabId[targetTabId] = detachedPtyId - } - - // Why: pane-to-tab detach moves a live PTY without spawning or exiting, so transfer identity without activity bumps. - const sourceTabsByWorktree = withTerminalTabPtyId( - s.tabsByWorktree, - sourceTabId, - sourcePrimaryPtyId - ) - const nextTabsByWorktree = detachedPtyId - ? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId) - : sourceTabsByWorktree - const directSshLedger = transferDirectSshPaneDetachLedger(s, { - detachedPtyId, - sourcePtyId: sourcePrimaryPtyId, - sourceTabId, - targetTabId, - isAuthorityCurrent: (authority) => isCurrentDirectSshAuthority(s, authority) - }) - - return { - ptyIdsByTabId: nextPtyIdsByTabId, - lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId, - ...directSshLedger, - ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}) - } - }) - // Why: detach keeps the process and its pane key alive, so move resume/status authority to the new surface before the source closes. - get().transferAgentPaneAuthority({ - fromPaneKey: sourcePaneKey, - toPaneKey: targetPaneKey, - ptyId: detachedPtyId - }) - }, - - queueTabStartupCommand: (tabId, startup) => { - // Why: launchToken is only meaningful for tracked launch-config reuse; plain startup commands must not mint a synthetic token. - const launchToken = startup.launchConfig - ? (startup.launchToken ?? createBrowserUuid()) - : undefined - set((s) => ({ - pendingStartupByTabId: { - ...s.pendingStartupByTabId, - [tabId]: { - ...startup, - ...(launchToken ? { launchToken } : {}) - } - } - })) - }, - - queueTabInitialCwd: (tabId, cwd) => { - set((s) => ({ - pendingInitialCwdByTabId: { - ...s.pendingInitialCwdByTabId, - [tabId]: cwd - } - })) - }, - - consumeTabInitialCwd: (tabId) => { - const pending = get().pendingInitialCwdByTabId[tabId] - if (!pending) { - return null - } - set((s) => { - const next = { ...s.pendingInitialCwdByTabId } - delete next[tabId] - return { pendingInitialCwdByTabId: next } - }) - return pending - }, - - consumeTabStartupCommand: (tabId) => { - const pending = get().pendingStartupByTabId[tabId] - if (!pending) { - return null - } - - set((s) => { - const next = { ...s.pendingStartupByTabId } - delete next[tabId] - return { pendingStartupByTabId: next } - }) - - return pending - }, - - queueTabSetupSplit: (tabId, startup) => { - set((s) => ({ - pendingSetupSplitByTabId: { - ...s.pendingSetupSplitByTabId, - [tabId]: startup - } - })) - }, - - consumeTabSetupSplit: (tabId) => { - const pending = get().pendingSetupSplitByTabId[tabId] - if (!pending) { - return null - } - - set((s) => { - const next = { ...s.pendingSetupSplitByTabId } - delete next[tabId] - return { pendingSetupSplitByTabId: next } - }) - - return pending - }, - - queueTabIssueCommandSplit: (tabId, issueCommand) => { - set((s) => ({ - pendingIssueCommandSplitByTabId: { - ...s.pendingIssueCommandSplitByTabId, - [tabId]: issueCommand - } - })) - }, - - consumeTabIssueCommandSplit: (tabId) => { - const pending = get().pendingIssueCommandSplitByTabId[tabId] - if (!pending) { - return null - } - - set((s) => { - const next = { ...s.pendingIssueCommandSplitByTabId } - delete next[tabId] - return { pendingIssueCommandSplitByTabId: next } - }) - - return pending - }, - - consumePendingSnapshot: (ptyId) => { - const snapshot = get().pendingSnapshotByPtyId[ptyId] - if (!snapshot) { - return null - } - set((s) => { - const next = { ...s.pendingSnapshotByPtyId } - delete next[ptyId] - return { pendingSnapshotByPtyId: next } - }) - return snapshot - }, - - consumePendingColdRestore: (ptyId) => { - const data = get().pendingColdRestoreByPtyId[ptyId] - if (!data) { - return null - } - set((s) => { - const next = { ...s.pendingColdRestoreByPtyId } - delete next[ptyId] - return { pendingColdRestoreByPtyId: next } - }) - return data - }, - - hydrateWorkspaceSession: (session, options) => { - const ownershipTransferTabIds = options?.replaceWorkspaceKeys - ? new Set( - options.replaceWorkspaceKeys.flatMap((workspaceKey) => - (session.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) - ) - ) - : null - const ownershipTransfersByTabId = new Map() - set((s) => { - const runtimeSessionPlaceholders = buildRuntimeSessionPlaceholders({ - repos: s.repos, - runtimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {}, - worktreesByRepo: s.worktreesByRepo - }) - const validWorktreeIds = buildValidWorktreeIdsForSessionHydration( - { - repos: runtimeSessionPlaceholders.repos, - worktreesByRepo: runtimeSessionPlaceholders.worktreesByRepo, - detectedWorktreesByRepo: s.detectedWorktreesByRepo - }, - collectPersistedWorktreeIdsForSessionHydration(session) - ) - const knownRepoIds = new Set(runtimeSessionPlaceholders.repos.map((r) => r.id)) - // Why: the Floating Workspace isn't a repo worktree, but its tabs use the normal session pipeline so daemon PTYs survive app restart. - validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) - for (const workspace of s.folderWorkspaces) { - validWorktreeIds.add(folderWorkspaceKey(workspace.id)) - } - addAdditionalValidWorkspaceKeys(validWorktreeIds, options) - // Why: rows for these keys came off the remote wire, which carries no unifiedTabs, so the - // session's canonical list describes a different snapshot and must not arbitrate their PTYs. - const remoteSnapshotWorkspaceKeys = new Set( - options?.directSshAuthority ? (options.replaceWorkspaceKeys ?? []) : [] - ) - const rowHydrationByWorktree = Object.entries(session.tabsByWorktree) - .filter(([worktreeId]) => validWorktreeIds.has(worktreeId)) - .map( - ([worktreeId, tabs]) => - [ - worktreeId, - hydrateWorkspaceTerminalRows(session, worktreeId, tabs, { - rowsFromRemoteSnapshot: remoteSnapshotWorkspaceKeys.has(worktreeId) - }) - ] as const - ) - const tabsByWorktree: Record = Object.fromEntries( - rowHydrationByWorktree - .map(([worktreeId, hydration]) => [worktreeId, hydration.rows] as const) - .filter( - ([worktreeId, tabs]) => - tabs.length > 0 || session.tabsByWorktree[worktreeId]?.length === 0 - ) - ) - const releasedPtyIdsByTabId = new Map>( - rowHydrationByWorktree.flatMap(([, hydration]) => [...hydration.releasedPtyIdsByTabId]) - ) - const reconnectPtyIdByRetainedTabId = new Map( - rowHydrationByWorktree.flatMap(([, hydration]) => [ - ...hydration.reconnectPtyIdByRetainedTabId - ]) - ) - const canonicalTabIdBySubsumedTabId = new Map( - rowHydrationByWorktree.flatMap(([, hydration]) => [ - ...hydration.canonicalTabIdBySubsumedTabId - ]) - ) - - const validTabIds = new Set( - Object.values(tabsByWorktree) - .flat() - .map((tab) => tab.id) - ) - let sleepingAgentSessionsByPaneKey = Object.fromEntries( - Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter(([, record]) => - validWorktreeIds.has(record.worktreeId) - ) - ) - // Why: a dropped row is never retired, so nothing else clears the sleeping records keyed to - // it — its worktree is still valid — and the workspace keeps a pane nobody can wake. - for (const [, hydration] of rowHydrationByWorktree) { - for (const tabId of [...hydration.subsumedTabIds, ...hydration.invalidTabIds]) { - sleepingAgentSessionsByPaneKey = removeSleepingAgentSessionsForTab( - sleepingAgentSessionsByPaneKey, - tabId - ) - } - } - // Why: a released leaf's agent session belongs to the canonical row now; leaving the record here - // would cold-restore the same provider session on a pane that no longer owns its PTY. - const releasedPaneKeys = new Set( - [...releasedPtyIdsByTabId].flatMap(([tabId, releasedPtyIds]) => - collectReleasedLeafIds(session.terminalLayoutsByTabId[tabId], releasedPtyIds) - // Why: persisted ptyIdsByLeafId keys are unvalidated strings, and makePaneKey throws on non-UUIDs. - .filter(isTerminalLeafId) - .map((leafId) => makePaneKey(tabId, leafId)) - ) - ) - if (releasedPaneKeys.size > 0) { - sleepingAgentSessionsByPaneKey = Object.fromEntries( - Object.entries(sleepingAgentSessionsByPaneKey).filter( - ([paneKey]) => !releasedPaneKeys.has(paneKey) - ) - ) - } - const fallbackActiveWorktreeId = - !session.activeWorktreeId && session.activeRepoId && knownRepoIds.has(session.activeRepoId) - ? (runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.find( - (worktree) => worktree.isMainWorktree - )?.id ?? - runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.[0]?.id ?? - null) - : null - const activeWorktreeId = (() => { - if (session.activeWorktreeId && validWorktreeIds.has(session.activeWorktreeId)) { - return session.activeWorktreeId - } - // Why: a workspace with no tabs is still valid; fall back from the active repo to avoid a blank landing screen when tabs were pruned or never created. - return fallbackActiveWorktreeId - })() - const activeWorkspaceKey: WorkspaceKey | null = - session.activeWorkspaceKey && validWorktreeIds.has(session.activeWorkspaceKey) - ? session.activeWorkspaceKey - : activeWorktreeId - ? parseWorkspaceKey(activeWorktreeId) - ? (activeWorktreeId as WorkspaceKey) - : worktreeWorkspaceKey(activeWorktreeId) - : null - const activeWorkspaceExecutionHostId = - activeWorkspaceKey && session.activeWorkspaceExecutionHostId - ? session.activeWorkspaceExecutionHostId - : null - // Why: follow a subsumed row to the canonical twin that inherited its PTY, else the app - // restarts with no active terminal even though the same session is still mounted. - const restoredActiveTabId = session.activeTabId - ? (canonicalTabIdBySubsumedTabId.get(session.activeTabId) ?? session.activeTabId) - : null - const activeTabId = - restoredActiveTabId && validTabIds.has(restoredActiveTabId) ? restoredActiveTabId : null - const activeRepoId = - session.activeRepoId && - runtimeSessionPlaceholders.repos.some((repo) => repo.id === session.activeRepoId) - ? session.activeRepoId - : null - - // Why: reconnect enables the session after eager spawns to prevent duplicate PTYs. - // Why: activeWorktreeIdsOnShutdown is authoritative when present; persisted tab/layout PTY IDs are only wake hints, not a full active-workspace list. - const shutdownIds = - session.activeWorktreeIdsOnShutdown ?? - Object.entries(session.tabsByWorktree) - .filter(([, tabs]) => tabs.some((t) => t.ptyId)) - .map(([wId]) => wId) - const pendingReconnectWorktreeIds = shutdownIds.filter((id) => validWorktreeIds.has(id)) - - // Why: capture live PTY tabs before transient state clears their IDs. - // Also include tabs whose relay session id survived in remoteSessionIdsByTabId (ptyId was null but the relay PTY is alive). - const remoteSessionIds = session.remoteSessionIdsByTabId ?? {} - const pendingReconnectTabByWorktree: Record = {} - for (const worktreeId of pendingReconnectWorktreeIds) { - const rawTabs = session.tabsByWorktree[worktreeId] ?? [] - const liveTabIds = rawTabs - .filter( - (t) => - // Why: a row that gave its tab-level PTY to the canonical twin still owns leaf sessions to advertise. - (t.ptyId || remoteSessionIds[t.id] || reconnectPtyIdByRetainedTabId.has(t.id)) && - validTabIds.has(t.id) - ) - .map((t) => t.id) - if (liveTabIds.length > 0) { - pendingReconnectTabByWorktree[worktreeId] = liveTabIds - } - } - - // Why: preserve each tab's prior ptyId so reconnect passes it as sessionId to the daemon's createOrAttach, triggering reattach instead of a fresh spawn. - const pendingReconnectPtyIdByTabId: Record = {} - const placeholderWorktreeById = buildWorktreeByIdIndex( - runtimeSessionPlaceholders.worktreesByRepo - ) - const placeholderRepoById = buildByIdIndex(runtimeSessionPlaceholders.repos) - for (const worktreeId of pendingReconnectWorktreeIds) { - const worktree = placeholderWorktreeById.get(worktreeId) - const repo = worktree ? placeholderRepoById.get(worktree.repoId) : null - if (repo?.connectionId) { - continue - } - const rawTabs = session.tabsByWorktree[worktreeId] ?? [] - for (const tab of rawTabs) { - // Why: a released PTY belongs to the canonical row now; reattaching it here would restore the duplicate ownership. - if ( - tab.ptyId && - validTabIds.has(tab.id) && - !releasedPtyIdsByTabId.get(tab.id)?.has(tab.ptyId) - ) { - pendingReconnectPtyIdByTabId[tab.id] = tab.ptyId - } - } - } - - // Why: remote PTY reattach uses the relay's pty.attach RPC, not the local daemon; the loop above skips SSH repos, so no overlap. - for (const [tabId, sessionId] of Object.entries(remoteSessionIds)) { - if (validTabIds.has(tabId) && !releasedPtyIdsByTabId.get(tabId)?.has(sessionId)) { - pendingReconnectPtyIdByTabId[tabId] = sessionId - } - } - - // Why: hydration nulls tab.ptyId and orphan detection ignores layout bindings, so a row whose - // tab-level PTY went to its canonical twin reads as dead and is swept before its own surviving - // pane can reattach. Anchor it on the PTY it still owns (#10486). - for (const [tabId, ptyId] of reconnectPtyIdByRetainedTabId) { - if (validTabIds.has(tabId) && !pendingReconnectPtyIdByTabId[tabId]) { - pendingReconnectPtyIdByTabId[tabId] = ptyId - } - } - - // Restore per-worktree active tab; validate ids when the map exists, else derive for legacy sessions. - let activeTabIdByWorktree: Record = {} - if (session.activeTabIdByWorktree) { - for (const [wId, tabId] of Object.entries(session.activeTabIdByWorktree)) { - if (!validWorktreeIds.has(wId) || !tabId) { - continue - } - // Why: a subsumed row's canonical twin holds the same PTY, so follow the pointer there - // instead of forgetting which terminal the workspace last focused. - const restored = validTabIds.has(tabId) ? tabId : canonicalTabIdBySubsumedTabId.get(tabId) - if (restored && validTabIds.has(restored)) { - activeTabIdByWorktree[wId] = restored - } - } - } else { - // Legacy sessions: best-effort derivation - if (activeWorktreeId && activeTabId) { - activeTabIdByWorktree[activeWorktreeId] = activeTabId - } - for (const [wId, tabs] of Object.entries(tabsByWorktree)) { - if (!activeTabIdByWorktree[wId] && tabs.length > 0) { - activeTabIdByWorktree[wId] = tabs[0].id - } - } - } - - // Why: synthesize SSH worktrees from session tabs until relay discovery replaces them. - // Why: only SSH gets placeholders; local metadata comes from the next successful fetch. - const sshRepoIds = new Set( - runtimeSessionPlaceholders.repos.filter((r) => r.connectionId).map((r) => r.id) - ) - const worktreesByRepo = { ...runtimeSessionPlaceholders.worktreesByRepo } - for (const worktreeId of Object.keys(tabsByWorktree)) { - const repoId = getRepoIdFromWorktreeId(worktreeId) - if (!sshRepoIds.has(repoId)) { - continue - } - const existing = (worktreesByRepo[repoId] ?? []).find((w) => w.id === worktreeId) - if (existing) { - continue - } - // Why: strip the synthetic `::workspace:` folder suffix so the placeholder path is a real cwd; `id` above keeps it for identity. - const path = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? '' - // Why: SSH worktree paths may use backslash separators on Windows remotes. - const displayName = path.split(/[/\\]/).pop() || path - const placeholder: Worktree = { - id: worktreeId, - repoId, - displayName, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - linkedGitLabMR: null, - linkedGitLabIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0, - path, - head: '', - branch: '', - isBare: false, - isMainWorktree: false - } - worktreesByRepo[repoId] = [...(worktreesByRepo[repoId] ?? []), placeholder] - } - - // Why: record restored active worktrees to avoid suppressing later real activity. - const nextEverActivated = new Set(s.everActivatedWorktreeIds) - if (activeWorktreeId) { - nextEverActivated.add(activeWorktreeId) - } - - // Why indexed: the layout map below looks up a tab per persisted layout, and - // re-flattening tabsByWorktree per entry is O(tabs x layouts). - const allTabs = Object.values(tabsByWorktree).flat() - const tabById = buildByIdIndex(allTabs) - - const hydrated: WorkspaceHydrationPatch = { - activeRepoId, - activeWorktreeId, - activeWorkspaceKey, - activeWorkspaceExecutionHostId, - activeTabId, - activeTabIdByWorktree, - restoredRuntimeHostIdByWorkspaceSessionKey: - options?.runtimeHostIdByWorkspaceSessionKey ?? {}, - repos: runtimeSessionPlaceholders.repos, - tabsByWorktree, - worktreesByRepo, - // Why: restore the focus-recency map; pruning is deferred to App.tsx (post-hydration) because SSH worktrees may still be appearing in worktreesByRepo. - lastVisitedAtByWorktreeId: session.lastVisitedAtByWorktreeId ?? {}, - defaultTerminalTabsAppliedByWorktreeId: - session.defaultTerminalTabsAppliedByWorktreeId ?? {}, - automaticAgentResumeClaimsByTabId: {}, - sleepingAgentSessionsByPaneKey, - pendingReconnectWorktreeIds, - pendingReconnectTabByWorktree, - pendingReconnectPtyIdByTabId, - everActivatedWorktreeIds: nextEverActivated, - // Why: seed hydrated active worktrees so the first activation has a Back target. - worktreeNavHistory: activeWorktreeId ? [activeWorktreeId] : [], - worktreeNavHistoryIndex: activeWorktreeId ? 0 : -1, - ptyIdsByTabId: Object.fromEntries(allTabs.map((tab) => [tab.id, []] as const)), - // Why: preserve daemon leaf PTY IDs to reattach every split-pane session. - terminalLayoutsByTabId: Object.fromEntries( - Object.entries(session.terminalLayoutsByTabId) - .filter(([tabId]) => validTabIds.has(tabId)) - .map(([tabId, persisted]) => { - const releasedPtyIds = releasedPtyIdsByTabId.get(tabId) - const layout = releasedPtyIds - ? releaseTerminalLayoutPtyIds(persisted, releasedPtyIds) - : persisted - // Why: old sessions can contain renderer-local pane:1-style leaf ids; normalize before runtime/mobile surfaces read them. - const normalization = normalizeTerminalLayoutSnapshot(layout) - const normalized = normalization.snapshot - if ( - normalization.changed && - (!ownershipTransferTabIds || ownershipTransferTabIds.has(tabId)) - ) { - ownershipTransfersByTabId.set( - tabId, - resolveTerminalLayoutPtyOwnershipTransfers(layout, normalized) - ) - } - const tab = tabById.get(tabId) - const sanitized = tab ? sanitizeTerminalLayoutPaneTitles(normalized, tab) : normalized - const activeLeafId = sanitized.root - ? resolvePtyBoundActiveLeafId({ - root: sanitized.root, - activeLeafId: sanitized.activeLeafId, - ptyIdsByLeafId: sanitized.ptyIdsByLeafId - }) - : sanitized.activeLeafId - return [tabId, { ...sanitized, activeLeafId }] - }) - ) - } - return options?.replaceWorkspaceKeys - ? targetScopedWorkspaceHydrationPatch(s, hydrated, session, options) - : hydrated - }) - for (const [tabId, transfers] of ownershipTransfersByTabId) { - transferNormalizedTerminalLayoutPtyOwnership(get(), tabId, transfers) - } - }, - - reconnectPersistedTerminals: async (signal, options) => { - if ( - signal?.aborted || - (options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority)) - ) { - return - } - const { - pendingReconnectWorktreeIds, - pendingReconnectTabByWorktree, - pendingReconnectPtyIdByTabId, - terminalLayoutsByTabId, - tabsByWorktree, - ptyIdsByTabId - } = get() - const scopedWorkspaceKeys = options ? new Set(options.workspaceKeys) : null - const ids = (pendingReconnectWorktreeIds ?? []).filter( - (id) => !scopedWorkspaceKeys || scopedWorkspaceKeys.has(id) - ) - - if (ids.length === 0) { - if (options) { - return - } - set({ - workspaceSessionReady: true, - pendingReconnectWorktreeIds: [], - pendingReconnectTabByWorktree: {}, - pendingReconnectPtyIdByTabId: {} - }) - return - } - - // Why: defer daemon attachment for real dimensions; eager 80×24 flushes garble output. - let reconnectedTabsByWorktree: Record | null = null - let reconnectedPtyIdsByTabId: Record | null = null - // Why indexed: the loop neither sets state nor awaits, so one index over the - // whole store snapshot serves every iteration. - const worktreeById = buildWorktreeByIdIndex(get().worktreesByRepo) - const repoById = buildByIdIndex(get().repos) - for (const worktreeId of ids) { - const tabs = tabsByWorktree[worktreeId] ?? [] - const worktree = worktreeById.get(worktreeId) - const repo = worktree ? (repoById.get(worktree.repoId) ?? null) : null - // Why: only allow deferred reattach when the SSH connection is active; reattaching to a not-yet-connected relay (deferred/passphrase targets) would fail. - const sshTargetId = options?.directSshAuthority.targetId ?? repo?.connectionId ?? null - const sshState = sshTargetId ? get().sshConnectionStates.get(sshTargetId) : null - const sshConnected = sshTargetId != null && sshState?.status === 'connected' - const supportsDeferredReattach = options ? sshConnected : !repo?.connectionId || sshConnected - console.debug( - `[reconnect-terminals] worktree=${worktreeId} connectionId=${repo?.connectionId} sshStatus=${sshState?.status} supportsDeferredReattach=${supportsDeferredReattach}` - ) - const targetTabIds = pendingReconnectTabByWorktree[worktreeId] ?? [] - const tabsToReconnect: TerminalTab[] = - targetTabIds.length > 0 - ? targetTabIds - .map((id) => tabs.find((t) => t.id === id)) - .filter((t): t is TerminalTab => t != null) - : tabs.slice(0, 1) - if (tabsToReconnect.length === 0) { - continue - } - - for (const tab of tabsToReconnect) { - const tabId = tab.id - const layout = terminalLayoutsByTabId[tabId] - const leafPtyMap = layout?.ptyIdsByLeafId ?? {} - const pendingPtyId = pendingReconnectPtyIdByTabId[tabId] - const tabLevelPtyId = - options && - parseAppSshPtyId(pendingPtyId ?? '')?.connectionId !== options.directSshAuthority.targetId - ? undefined - : pendingPtyId - const hasLeafMappings = Object.keys(leafPtyMap).length > 0 - - // Why: publish live PTY hints before mount; pty-connection reattaches later. - console.debug( - `[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}` - ) - // Why: populate ptyIdsByTabId so the sessions status segment maps daemon IDs to tabs; otherwise all sessions look like orphans until the pane mounts. - // A row whose tab.ptyId went to the canonical row has no tab-level id left, but its own leaf PTYs still need advertising. - const allPtyIds = hasLeafMappings - ? (Object.values(leafPtyMap).filter(Boolean) as string[]) - : tabLevelPtyId - ? [tabLevelPtyId] - : [] - if (allPtyIds.length > 0) { - // Why: hide-sleeping reads ptyIdsByTabId for liveness; restored daemon sessions run before their pane remounts, so advertise them. - reconnectedPtyIdsByTabId ??= { ...ptyIdsByTabId } - reconnectedPtyIdsByTabId[tabId] = allPtyIds - } - if (tabLevelPtyId) { - reconnectedTabsByWorktree ??= { ...tabsByWorktree } - const nextTabs = reconnectedTabsByWorktree[worktreeId] - if (!nextTabs) { - continue - } - reconnectedTabsByWorktree[worktreeId] = nextTabs.map((t) => - t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t - ) - } - } - } - - // Why: keep deferred SSH session IDs for post-cleanup reconnect. - const scopedTabIds = new Set( - [...(scopedWorkspaceKeys ?? ids)].flatMap((workspaceKey) => - (tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) - ) - ) - const deferredSshSessionIdsByTabId: Record = options - ? Object.fromEntries( - Object.entries(get().deferredSshSessionIdsByTabId).filter( - ([tabId]) => !scopedTabIds.has(tabId) - ) - ) - : {} - for (const worktreeId of ids) { - const worktree = worktreeById.get(worktreeId) - // Why: SSH worktrees aren't in worktreesByRepo at cold start; fall back to the repo id in the composite worktree id so sessions still reach the deferred map. - const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) - const repo = repoId ? (repoById.get(repoId) ?? null) : null - const connectionId = options?.directSshAuthority.targetId ?? repo?.connectionId - if (!connectionId) { - continue - } - // Why: a repo can outlive its SSH target when the target was removed out of - // band (a crash between removal and cleanup, or edited out of the config). - // Once the authoritative target list has loaded, don't re-defer sessions for - // a target it no longer lists — a stranded deferred id reads as liveness and - // the orphan sweep could never remove the dead tab. Defer while the list is - // still unknown so a normal cold-start reconnect isn't dropped (#9911). - if (get().sshTargetsHydrated && !get().sshTargetLabels.has(connectionId)) { - continue - } - const sshConnected = get().sshConnectionStates.get(connectionId)?.status === 'connected' - if (sshConnected) { - continue - } - const tabs = tabsByWorktree[worktreeId] ?? [] - for (const tab of tabs) { - const sessionId = pendingReconnectPtyIdByTabId[tab.id] - if (sessionId && (!options || parseAppSshPtyId(sessionId)?.connectionId === connectionId)) { - deferredSshSessionIdsByTabId[tab.id] = sessionId - } - } - } - - if ( - signal?.aborted || - (options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority)) - ) { - return - } - const remainingReconnectWorktreeIds = options - ? pendingReconnectWorktreeIds.filter((id) => !scopedWorkspaceKeys?.has(id)) - : [] - const remainingReconnectTabByWorktree = options - ? Object.fromEntries( - Object.entries(pendingReconnectTabByWorktree).filter( - ([workspaceKey]) => !scopedWorkspaceKeys?.has(workspaceKey) - ) - ) - : {} - const remainingReconnectPtyIdByTabId = options - ? Object.fromEntries( - Object.entries(pendingReconnectPtyIdByTabId).filter(([tabId]) => !scopedTabIds.has(tabId)) - ) - : {} - set({ - ...(reconnectedTabsByWorktree ? { tabsByWorktree: reconnectedTabsByWorktree } : {}), - ...(reconnectedPtyIdsByTabId ? { ptyIdsByTabId: reconnectedPtyIdsByTabId } : {}), - ...(options ? {} : { workspaceSessionReady: true }), - pendingReconnectWorktreeIds: remainingReconnectWorktreeIds, - pendingReconnectTabByWorktree: remainingReconnectTabByWorktree, - pendingReconnectPtyIdByTabId: remainingReconnectPtyIdByTabId, - deferredSshSessionIdsByTabId - }) - } + ...createTerminalEphemeralActions(set, get), + ...createTerminalTabCreationActions(set, get), + ...createActiveWorkspaceTerminalActions(set, get), + ...createTerminalTabCloseActions(set, get), + ...createTerminalTabNavigationActions(set, get), + ...createTerminalTabPresentationActions(set, get), + ...createTerminalTabAttentionActions(set, get), + ...createTerminalPtyBindingActions(set, get), + ...createTerminalPtyReleaseActions(set, get), + ...createTerminalPaneHibernationActions(set, get), + ...createDirectSshTerminalBindingActions(set, get), + ...createTerminalShutdownActions(set, get), + ...createTerminalRestartActions(set, get), + ...createTerminalLayoutActions(set, get), + ...createTerminalStartupQueueActions(set, get), + ...createWorkspaceTerminalHydrationActions(set, get), + ...createWorkspaceTerminalReconnectActions(set, get) }) diff --git a/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts b/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts new file mode 100644 index 00000000000..26c3bfd2760 --- /dev/null +++ b/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts @@ -0,0 +1,105 @@ +import { + clearDirectSshTerminalBindings, + invalidateStaleDirectSshTerminalBindings +} from '../slices/direct-ssh-terminal-recovery' +import { + retryDirectSshTerminalPanes, + retrySettledDirectSshTerminalPane +} from '../slices/direct-ssh-pane-retry-ledger' +import { + directSshAuthoritiesEqual, + settleDirectSshPaneRetryState +} from '../slices/direct-ssh-terminal-authority-ledger' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { + isCurrentDirectSshAuthority, + resolveDirectSshTerminalKeys +} from './terminal-pty-identities' + +export function createDirectSshTerminalBindingActions( + set: TerminalStoreSet, + _get: TerminalStoreGet +): Pick< + TerminalSlice, + | 'clearDirectSshTargetPtyBindings' + | 'invalidateStaleDirectSshTargetPtyBindings' + | 'retryDirectSshTargetPanes' + | 'settleDirectSshPaneRetry' +> { + return { + clearDirectSshTargetPtyBindings: (targetId) => { + let clearedCount = 0 + set((s) => { + const result = clearDirectSshTerminalBindings(s, resolveDirectSshTerminalKeys(s, targetId)) + clearedCount = result.clearedCount + return result.patch ?? s + }) + return clearedCount + }, + invalidateStaleDirectSshTargetPtyBindings: (authority) => { + let clearedCount = 0 + set((s) => { + if (!isCurrentDirectSshAuthority(s, authority)) { + return s + } + const result = invalidateStaleDirectSshTerminalBindings( + s, + resolveDirectSshTerminalKeys(s, authority.targetId), + authority + ) + clearedCount = result.clearedCount + return result.patch ?? s + }) + return clearedCount + }, + retryDirectSshTargetPanes: (authority, now = Date.now()) => { + let retriedCount = 0 + set((s) => { + if (!isCurrentDirectSshAuthority(s, authority)) { + return s + } + const result = retryDirectSshTerminalPanes( + s, + resolveDirectSshTerminalKeys(s, authority.targetId), + authority, + now + ) + retriedCount = result.retriedCount + return result.patch ?? s + }) + return retriedCount + }, + settleDirectSshPaneRetry: (result, now = Date.now()) => { + set((s) => { + if (!isCurrentDirectSshAuthority(s, result.authority)) { + return s + } + const history = s.directSshPaneRetryHistoryByTabId[result.tabId] + const preservesExhaustedSplitAttempt = + (result.status === 'failed' || result.status === 'timed-out') && + history != null && + directSshAuthoritiesEqual(history.authority, result.authority) && + history.attemptedAt.length >= 2 + if (preservesExhaustedSplitAttempt) { + return s + } + const settlement = settleDirectSshPaneRetryState(s, result) + if (!settlement) { + return s + } + const settledState = { ...s, ...settlement } + if (result.status !== 'failed' && result.status !== 'timed-out') { + return settledState + } + const retry = retrySettledDirectSshTerminalPane( + settledState, + resolveDirectSshTerminalKeys(settledState, result.authority.targetId), + result.authority, + result.tabId, + now + ) + return retry.patch ? { ...settledState, ...retry.patch } : settledState + }) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts new file mode 100644 index 00000000000..3779ea88cc0 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -0,0 +1,271 @@ +import type { Tab } from '../../../../shared/tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import type { AgentStartedTelemetry } from '../../lib/worktree-startup-payload' +import type { AiVaultSessionTitle } from '../../../../shared/ai-vault-session-title' +import type { + GeneratedTabTitleUpdate, + TerminalTabTitleUpdate +} from '../slices/terminal-tab-title-batch' +import type { + DirectSshPaneRetryAttemptId, + DirectSshPaneRetryResult +} from '../slices/direct-ssh-terminal-recovery' +import type { NativeChatLaunchDraft, NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' +import type { AgentStatusWorktreeShutdownReason } from '../slices/agent-status' +import type { + TerminalTabCloseReason, + TerminalTabRetirementPlan +} from '../slices/terminal-tab-retirement' +import type { + AutomaticAgentResumeClaim, + CodexRestartNotice, + HydrateWorkspaceSessionOptions, + ReconnectPersistedTerminalsOptions +} from './terminal-contracts' + +export type TerminalActions = { + setRecentQuickCommandForGroup: (groupId: string, quickCommandId: string) => void + claimAutomaticAgentResume: (tabId: string, claim: AutomaticAgentResumeClaim) => void + seedNativeChatLaunchPrompt: (prompt: NativeChatLaunchPrompt) => void + markNativeChatLaunchPromptFailed: (tabId: string) => void + clearNativeChatLaunchPrompt: (tabId: string) => void + seedNativeChatLaunchDraft: (draft: NativeChatLaunchDraft) => void + markNativeChatLaunchDraftAdopted: (tabId: string) => void + resolveNativeChatLaunchDraft: ( + tabId: string, + resolution: Pick + ) => void + clearNativeChatLaunchDraft: (tabId: string) => void + markDefaultTerminalTabsApplied: (worktreeId: string) => void + setHydrationSucceeded: (value: boolean) => void + consumePendingSnapshot: (ptyId: string) => { + snapshot: string + cols?: number + rows?: number + isAlternateScreen?: boolean + } | null + consumePendingColdRestore: (ptyId: string) => { + scrollback: string + cwd: string + } | null + /** Atomically publishes runtime and unified tab ownership, layout, ordering, and focus. */ + createTab: ( + worktreeId: string, + targetGroupId?: string, + shellOverride?: string, + options?: { + pendingActivationSpawn?: boolean + initialPtyId?: string + activate?: boolean + recordInteraction?: boolean + id?: string + launchAgent?: TuiAgent + quickCommandLabel?: string | null + viewMode?: Tab['viewMode'] + startupCwd?: string + forceHostRuntime?: boolean + } + ) => TerminalTab + openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise + /** Synchronous retirement: provider teardown starts before state removal but is never awaited. */ + closeTab: ( + tabId: string, + opts?: { + recordInteraction?: boolean + reason?: TerminalTabCloseReason + captureRecentlyClosed?: boolean + remoteCloseOwnedByHost?: boolean + localPtyTeardownOwnedExternally?: boolean + precomputedRetirementPlan?: TerminalTabRetirementPlan + } + ) => void + reorderTabs: (worktreeId: string, tabIds: string[]) => void + setTabBarOrder: (worktreeId: string, order: string[]) => void + setActiveTab: (tabId: string) => void + setActiveTabForWorktree: (worktreeId: string, tabId: string) => void + /** Resolve the canonical legacy terminal-tab owner key for renderer lifecycle guards. */ + getTerminalTabOwnerWorktreeId?: (tabId: string) => string | null + updateTabTitle: (tabId: string, title: string) => void + updateTabTitles: (updates: readonly TerminalTabTitleUpdate[]) => void + setAiVaultTabTitle: (tabId: string, aiVaultTitle: AiVaultSessionTitle | null) => void + setGeneratedTabTitleFromAgentPrompt: ( + paneKey: string, + prompt: string, + options?: { + replaceExistingGeneratedTitle?: boolean + } + ) => void + setGeneratedTabTitlesFromAgentPrompts: (updates: readonly GeneratedTabTitleUpdate[]) => void + clearTabLaunchAgent: (tabId: string) => void + setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void + clearRuntimePaneTitle: (tabId: string, paneId: number) => void + markTerminalTabUnread: (tabId: string) => void + markTerminalPaneUnread: (paneKey: string) => void + markAgentCompletionPaneUnread: (paneKey: string) => void + clearTerminalTabUnread: (tabId: string) => void + clearTerminalPaneUnread: (paneKey: string) => void + setTabCustomTitle: ( + tabId: string, + title: string | null, + opts?: { + recordInteraction?: boolean + } + ) => void + setTabColor: (tabId: string, color: string | null) => void + /** Binds only live tabs and migrates replacement identity state before publishing ownership. */ + updateTabPtyId: ( + tabId: string, + ptyId: string, + replacedPtyId?: string, + directSshRetryAttemptId?: DirectSshPaneRetryAttemptId + ) => void + /** Reconciles exact exits; bulk clear intentionally retains relay-grace identity. */ + clearTabPtyId: (tabId: string, ptyId?: string) => void + clearDirectSshTargetPtyBindings: (targetId: string) => number + invalidateStaleDirectSshTargetPtyBindings: (authority: DirectSshAuthority) => number + retryDirectSshTargetPanes: (authority: DirectSshAuthority, now?: number) => number + settleDirectSshPaneRetry: (result: DirectSshPaneRetryResult, now?: number) => void + /** Awaited verified shutdown; unlike closeTab, physical teardown gates the state commit. */ + shutdownWorktreeTerminals: ( + worktreeId: string, + opts?: { + keepIdentifiers?: boolean + shutdownReason?: AgentStatusWorktreeShutdownReason + sleepingPaneKeys?: string[] + expectedRuntimePtyIds?: string[] + backendOwnsPtyTeardown?: boolean + } + ) => Promise + /** Captures resumable pane evidence and publishes suppression before exact physical teardown. */ + shutdownCompletedAgentPaneForHibernation: ( + worktreeId: string, + opts: { + paneKey: string + tabId: string + leafId: string + ptyId: string + expectedRuntimePtyId?: string + } + ) => Promise + suppressPtyExit: (ptyId: string) => void + consumeSuppressedPtyExit: (ptyId: string) => boolean + isPtyShutdownPending: (ptyId: string) => boolean + queueCodexPaneRestarts: (ptyIds: string[]) => void + consumePendingCodexPaneRestart: (ptyId: string) => boolean + markCodexRestartNotices: ( + notices: (Pick & + Partial> & { + homeRouteChanged?: boolean + ptyId: string + })[] + ) => string[] + clearCodexRestartNotice: (ptyId: string) => void + dismissCodexRestartNotices: (ptyIds: string[]) => void + reopenCodexRestartPrompt: (ptyId: string) => void + replaceTerminalLayoutPanePtyId: (tabId: string, leafId: string, ptyId: string) => void + setTabPaneExpanded: (tabId: string, expanded: boolean) => void + setTabCanExpandPane: (tabId: string, canExpand: boolean) => void + setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void + syncPaneDetachPtyOwnership: (args: { + detachedLeafId: string + detachedPtyId: string | null + sourceLayout: TerminalLayoutSnapshot + sourceTabId: string + targetTabId: string + }) => void + queueTabStartupCommand: ( + tabId: string, + startup: { + command: string + delivery?: 'terminal-paste' + startupCommandDelivery?: StartupCommandDelivery + env?: Record + envToDelete?: string[] + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + agentArgsOverride?: string | null + draftPrompt?: string + sessionOptions?: Record + initialAgentStatus?: { + agent: TuiAgent + prompt: string + } + showSessionRestoredBanner?: boolean + telemetry?: AgentStartedTelemetry + } + ) => void + queueTabInitialCwd: (tabId: string, cwd: string) => void + consumeTabInitialCwd: (tabId: string) => string | null + consumeTabStartupCommand: (tabId: string) => { + command: string + delivery?: 'terminal-paste' + startupCommandDelivery?: StartupCommandDelivery + env?: Record + envToDelete?: string[] + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + agentArgsOverride?: string | null + draftPrompt?: string + sessionOptions?: Record + initialAgentStatus?: { + agent: TuiAgent + prompt: string + } + showSessionRestoredBanner?: boolean + telemetry?: AgentStartedTelemetry + } | null + queueTabSetupSplit: ( + tabId: string, + startup: { + command: string + env?: Record + direction: SetupSplitDirection + } + ) => void + consumeTabSetupSplit: (tabId: string) => { + command: string + env?: Record + direction: SetupSplitDirection + } | null + queueTabIssueCommandSplit: ( + tabId: string, + issueCommand: { + command: string + env?: Record + } + ) => void + consumeTabIssueCommandSplit: (tabId: string) => { + command: string + env?: Record + } | null + setCacheTimerStartedAt: (key: string, ts: number | null) => void + recordTerminalInput: (paneKey: string, timestamp?: number) => void + seedCacheTimersForIdleTabs: () => void + setDeferredSshReconnectTargets: (targetIds: string[]) => void + removeDeferredSshReconnectTarget: (targetId: string) => void + removeDeferredSshSessionId: (tabId: string) => void + /** Hydrates canonical rows first, then transfers normalized pane authority post-publication. */ + hydrateWorkspaceSession: ( + session: WorkspaceSessionState, + options?: HydrateWorkspaceSessionOptions + ) => void + /** Publishes wake hints without spawning; global completion alone opens the readiness gate. */ + reconnectPersistedTerminals: ( + signal?: AbortSignal, + options?: ReconnectPersistedTerminalsOptions + ) => Promise +} diff --git a/src/renderer/src/store/terminals/terminal-active-workspace-creation.ts b/src/renderer/src/store/terminals/terminal-active-workspace-creation.ts new file mode 100644 index 00000000000..f227b914641 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-active-workspace-creation.ts @@ -0,0 +1,69 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route' +import { isWebClientLocation } from '@/lib/web-client-location' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export function createActiveWorkspaceTerminalActions( + _set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + openNewTerminalTabInActiveWorkspace: async (groupId) => { + const state = get() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + return + } + const workspaceScope = parseWorkspaceKey(worktreeId) + const worktreeRoute = + worktreeId === FLOATING_TERMINAL_WORKTREE_ID || workspaceScope?.type === 'folder' + ? null + : resolveWorktreeOperationRouteResult(state, worktreeId) + if (worktreeRoute && worktreeRoute.kind !== 'resolved') { + return + } + const runtimeEnvironmentId = worktreeRoute + ? worktreeRoute.route.runtimeEnvironmentId + : getRuntimeEnvironmentIdForWorktree(state, worktreeId) + if (runtimeEnvironmentId) { + const { createWebRuntimeSessionTerminal } = await import('@/runtime/web-runtime-session') + await createWebRuntimeSessionTerminal({ + worktreeId, + environmentId: runtimeEnvironmentId, + targetGroupId: groupId, + activate: true + }) + return + } + if (isWebClientLocation() && worktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { + return + } + const terminal = get().createTab(worktreeId, groupId) + get().setActiveTab(terminal.id) + get().setActiveTabType('terminal') + const latest = get() + const currentTerminals = latest.tabsByWorktree[worktreeId] ?? [] + const currentEditors = latest.openFiles.filter((file) => file.worktreeId === worktreeId) + const currentBrowsers = latest.browserTabsByWorktree[worktreeId] ?? [] + const stored = latest.tabBarOrderByWorktree[worktreeId] + const validIds = new Set([ + ...currentTerminals.map((tab) => tab.id), + ...currentEditors.map((file) => file.id), + ...currentBrowsers.map((tab) => tab.id) + ]) + const base = (stored ?? []).filter((id) => validIds.has(id)) + const inBase = new Set(base) + for (const id of validIds) { + if (!inBase.has(id)) { + base.push(id) + } + } + // Why: Cmd+J shares the titlebar-button creation path, so append the new terminal after mixed editor/browser tabs, not first. + get().setTabBarOrder(worktreeId, [...base.filter((id) => id !== terminal.id), terminal.id]) + focusTerminalTabSurface(terminal.id) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-contracts.ts b/src/renderer/src/store/terminals/terminal-contracts.ts new file mode 100644 index 00000000000..8ed44aa4899 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-contracts.ts @@ -0,0 +1,38 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' + +/** In-memory recovery claim consumed only after the resumed terminal hook becomes live. */ +export type AutomaticAgentResumeClaim = { + worktreeId: string + launchAgent: TuiAgent + providerSession: AgentProviderSessionMetadata +} + +export type CodexRestartNotice = { + previousAccountLabel: string + nextAccountLabel: string + /** Labels are display-only; account ids disambiguate equal labels and A→B→A collapse. */ + previousAccountId?: string | null + nextAccountId?: string | null + /** Persists a home-route mismatch after ephemeral launch-account memory has expired. */ + homeRouteChanged?: true + /** Accepted restart remains tracked so a failed execution can reopen instead of input-blocking invisibly. */ + restartRequested?: true + /** Dismissal outlives the prompt while explicitly preventing that notice from blocking pane input. */ + dismissed?: true +} + +/** Scoped direct-SSH hydration replaces only named workspace keys and authority. */ +export type HydrateWorkspaceSessionOptions = { + directSshAuthority?: DirectSshAuthority + runtimeHostIdByWorkspaceSessionKey?: Record +} & WorkspaceSessionHydrationOptions + +/** Scoped reconnect must still match this exact provider epoch and connection generation. */ +export type ReconnectPersistedTerminalsOptions = { + directSshAuthority: DirectSshAuthority + workspaceKeys: readonly string[] +} diff --git a/src/renderer/src/store/terminals/terminal-ephemeral-state.ts b/src/renderer/src/store/terminals/terminal-ephemeral-state.ts new file mode 100644 index 00000000000..89a67bd72ae --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-ephemeral-state.ts @@ -0,0 +1,237 @@ +import { isClaudeAgent } from '@/lib/agent-status' +import { recordTerminalInputActivity } from '@/lib/terminal-input-activity-coalescing' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export function createTerminalEphemeralActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick< + TerminalSlice, + | 'markDefaultTerminalTabsApplied' + | 'setHydrationSucceeded' + | 'setRecentQuickCommandForGroup' + | 'claimAutomaticAgentResume' + | 'seedNativeChatLaunchPrompt' + | 'markNativeChatLaunchPromptFailed' + | 'clearNativeChatLaunchPrompt' + | 'seedNativeChatLaunchDraft' + | 'markNativeChatLaunchDraftAdopted' + | 'resolveNativeChatLaunchDraft' + | 'clearNativeChatLaunchDraft' + | 'recordTerminalInput' + | 'setCacheTimerStartedAt' + | 'seedCacheTimersForIdleTabs' + | 'setDeferredSshReconnectTargets' + | 'removeDeferredSshReconnectTarget' + | 'removeDeferredSshSessionId' +> { + return { + markDefaultTerminalTabsApplied: (worktreeId) => + set((s) => { + if (s.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) { + return {} + } + return { + defaultTerminalTabsAppliedByWorktreeId: { + ...s.defaultTerminalTabsAppliedByWorktreeId, + [worktreeId]: true + } + } + }), + setHydrationSucceeded: (value) => { + set({ hydrationSucceeded: value }) + }, + setRecentQuickCommandForGroup: (groupId, quickCommandId) => { + set((s) => ({ + recentQuickCommandIdByGroup: { + ...s.recentQuickCommandIdByGroup, + [groupId]: quickCommandId + } + })) + }, + claimAutomaticAgentResume: (tabId, claim) => { + set((s) => ({ + automaticAgentResumeClaimsByTabId: { + ...s.automaticAgentResumeClaimsByTabId, + [tabId]: claim + } + })) + }, + seedNativeChatLaunchPrompt: (prompt) => { + set((s) => ({ + nativeChatLaunchPromptByTabId: { + ...s.nativeChatLaunchPromptByTabId, + [prompt.tabId]: prompt + } + })) + }, + markNativeChatLaunchPromptFailed: (tabId) => { + set((s) => { + const current = s.nativeChatLaunchPromptByTabId[tabId] + if (!current || current.failed) { + return {} + } + return { + nativeChatLaunchPromptByTabId: { + ...s.nativeChatLaunchPromptByTabId, + [tabId]: { ...current, failed: true } + } + } + }) + }, + clearNativeChatLaunchPrompt: (tabId) => { + set((s) => { + if (!s.nativeChatLaunchPromptByTabId[tabId]) { + return {} + } + const next = { ...s.nativeChatLaunchPromptByTabId } + delete next[tabId] + return { nativeChatLaunchPromptByTabId: next } + }) + }, + seedNativeChatLaunchDraft: (draft) => { + set((s) => ({ + nativeChatLaunchDraftByTabId: { + ...s.nativeChatLaunchDraftByTabId, + [draft.tabId]: draft + } + })) + }, + markNativeChatLaunchDraftAdopted: (tabId) => { + set((s) => { + const current = s.nativeChatLaunchDraftByTabId[tabId] + if (!current || current.adopted) { + return {} + } + return { + nativeChatLaunchDraftByTabId: { + ...s.nativeChatLaunchDraftByTabId, + [tabId]: { ...current, adopted: true } + } + } + }) + }, + resolveNativeChatLaunchDraft: (tabId, resolution) => { + set((s) => { + const current = s.nativeChatLaunchDraftByTabId[tabId] + if ( + !current || + current.resolved || + current.createdAt !== resolution.createdAt || + current.text !== resolution.text + ) { + return {} + } + return { + nativeChatLaunchDraftByTabId: { + ...s.nativeChatLaunchDraftByTabId, + [tabId]: { ...current, resolved: true } + } + } + }) + }, + clearNativeChatLaunchDraft: (tabId) => { + set((s) => { + if (!s.nativeChatLaunchDraftByTabId[tabId]) { + return {} + } + const next = { ...s.nativeChatLaunchDraftByTabId } + delete next[tabId] + return { nativeChatLaunchDraftByTabId: next } + }) + }, + recordTerminalInput: (paneKey, timestamp = Date.now()) => { + if (!paneKey || !Number.isFinite(timestamp)) { + return + } + recordTerminalInputActivity({ + paneKey, + timestamp, + // Why: the first stamp for a pane must land synchronously; automation take-over + // detection subscribes and compares undefined→value across a launch. + forceWrite: get().lastTerminalInputAtByPaneKey[paneKey] === undefined, + commit: { + insert: (key, at) => + set((s) => ({ + lastTerminalInputAtByPaneKey: { ...s.lastTerminalInputAtByPaneKey, [key]: at } + })), + refreshExisting: (entries) => + set((s) => { + let next: Record | null = null + for (const [key, at] of entries) { + // Why: teardown (close pane/tab/worktree purge) deletes keys; a late flush must not resurrect them. + const current = s.lastTerminalInputAtByPaneKey[key] + if (current === undefined || current >= at) { + continue + } + next ??= { ...s.lastTerminalInputAtByPaneKey } + next[key] = at + } + return next ? { lastTerminalInputAtByPaneKey: next } : {} + }) + } + }) + }, + setCacheTimerStartedAt: (key, ts) => { + set((s) => { + // Why: a real pane write clears any ':seed' sentinel from seedCacheTimersForIdleTabs, avoiding phantom timers when the seed key doesn't match the real pane. + const colonIdx = key.indexOf(':') + const suffix = colonIdx === -1 ? null : key.slice(colonIdx + 1) + const seedKey = + colonIdx !== -1 && suffix !== 'seed' ? `${key.slice(0, colonIdx)}:seed` : null + const hasStaleSeed = seedKey !== null && seedKey in s.cacheTimerByKey + // Why: parked-pane watchers replay null-over-null on every working/exit transition; each redundant write runs every subscriber's selector. + if (s.cacheTimerByKey[key] === ts && !hasStaleSeed) { + return s + } + const next = { ...s.cacheTimerByKey, [key]: ts } + if (seedKey !== null) { + delete next[seedKey] + } + return { cacheTimerByKey: next } + }) + }, + seedCacheTimersForIdleTabs: () => { + // Why: tabs already idle when the feature is enabled mid-session missed their working→idle transition, so seed timers for them. + const s = get() + const now = Date.now() + const updates: Record = {} + for (const tabs of Object.values(s.tabsByWorktree)) { + for (const tab of tabs) { + if (!tab.title || !isClaudeAgent(tab.title)) { + continue + } + const status = classifyTitleActivity(tab.title) + if (status === null || status === 'working') { + continue + } + // Why: the store doesn't know which pane holds the idle session, so use a ':seed' sentinel; setCacheTimerStartedAt clears it on any real pane write. + const key = `${tab.id}:seed` + if (s.cacheTimerByKey[key] == null) { + updates[key] = now + } + } + } + if (Object.keys(updates).length > 0) { + set((s) => ({ + cacheTimerByKey: { ...s.cacheTimerByKey, ...updates } + })) + } + }, + setDeferredSshReconnectTargets: (targetIds) => set({ deferredSshReconnectTargets: targetIds }), + removeDeferredSshReconnectTarget: (targetId) => + set((s) => ({ + deferredSshReconnectTargets: s.deferredSshReconnectTargets.filter((id) => id !== targetId) + })), + removeDeferredSshSessionId: (tabId) => + set((s) => { + if (!s.deferredSshSessionIdsByTabId[tabId]) { + return {} + } + const next = { ...s.deferredSshSessionIdsByTabId } + delete next[tabId] + return { deferredSshSessionIdsByTabId: next } + }) + } +} diff --git a/src/renderer/src/store/terminals/terminal-layout-state.ts b/src/renderer/src/store/terminals/terminal-layout-state.ts new file mode 100644 index 00000000000..d95439cf5a8 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-layout-state.ts @@ -0,0 +1,154 @@ +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' +import { + normalizeTerminalLayoutPtyOwnership, + resolveTerminalLayoutPtyOwnershipTransfers +} from '@/components/terminal-pane/terminal-layout-pty-ownership' +import { transferDirectSshPaneDetachLedger } from '../slices/direct-ssh-terminal-authority-ledger' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { + isCurrentDirectSshAuthority, + resolvePrimaryLayoutPtyId, + uniquePtyIds, + withTerminalTabPtyId +} from './terminal-pty-identities' +import { transferNormalizedTerminalLayoutPtyOwnership } from './workspace-terminal-hydration-patch' + +export function createTerminalLayoutActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick< + TerminalSlice, + | 'replaceTerminalLayoutPanePtyId' + | 'setTabPaneExpanded' + | 'setTabCanExpandPane' + | 'setTabLayout' + | 'syncPaneDetachPtyOwnership' +> { + return { + replaceTerminalLayoutPanePtyId: (tabId, leafId, ptyId) => { + set((s) => { + const layout = s.terminalLayoutsByTabId[tabId] + if (!layout || layout.ptyIdsByLeafId?.[leafId] === ptyId) { + return {} + } + return { + terminalLayoutsByTabId: { + ...s.terminalLayoutsByTabId, + [tabId]: { + ...layout, + ptyIdsByLeafId: { ...layout.ptyIdsByLeafId, [leafId]: ptyId } + } + } + } + }) + }, + setTabPaneExpanded: (tabId, expanded) => { + set((s) => ({ + expandedPaneByTabId: { ...s.expandedPaneByTabId, [tabId]: expanded } + })) + }, + setTabCanExpandPane: (tabId, canExpand) => { + set((s) => ({ + canExpandPaneByTabId: { ...s.canExpandPaneByTabId, [tabId]: canExpand } + })) + }, + setTabLayout: (tabId, layout) => { + let ownershipTransfers: ReturnType = [] + set((s) => { + if (!layout) { + if (!(tabId in s.terminalLayoutsByTabId)) { + return s + } + const next = { ...s.terminalLayoutsByTabId } + delete next[tabId] + return { terminalLayoutsByTabId: next } + } + const normalized = normalizeTerminalLayoutPtyOwnership(layout) + // Resolved before the bailout: normalization can transfer pane ownership even when the stored snapshot is untouched. + if (normalized.changed) { + ownershipTransfers = resolveTerminalLayoutPtyOwnershipTransfers( + layout, + normalized.snapshot + ) + } + // Why: pane-title churn re-persists structurally identical snapshots; bailing keeps every pane selector asleep. + const existing = s.terminalLayoutsByTabId[tabId] + if (existing && terminalLayoutEqual(existing, normalized.snapshot)) { + return s + } + return { + terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tabId]: normalized.snapshot } + } + }) + transferNormalizedTerminalLayoutPtyOwnership(get(), tabId, ownershipTransfers) + }, + syncPaneDetachPtyOwnership: ({ + detachedLeafId, + detachedPtyId, + sourceLayout, + sourceTabId, + targetTabId + }) => { + const sourcePaneKey = makePaneKey(sourceTabId, detachedLeafId) + const targetPaneKey = makePaneKey(targetTabId, detachedLeafId) + set((s) => { + const layoutSourcePtyIds = uniquePtyIds(Object.values(sourceLayout.ptyIdsByLeafId ?? {})) + const existingSourcePtyIds = (s.ptyIdsByTabId[sourceTabId] ?? []).filter( + (ptyId) => ptyId !== detachedPtyId + ) + const sourcePtyIds = + layoutSourcePtyIds.length > 0 ? layoutSourcePtyIds : existingSourcePtyIds + const sourcePrimaryPtyId = + resolvePrimaryLayoutPtyId(sourceLayout) ?? sourcePtyIds[0] ?? null + const nextPtyIdsByTabId = { + ...s.ptyIdsByTabId, + [sourceTabId]: sourcePtyIds + } + if (detachedPtyId) { + nextPtyIdsByTabId[targetTabId] = uniquePtyIds([ + ...(nextPtyIdsByTabId[targetTabId] ?? []), + detachedPtyId + ]) + } + const nextLastKnownRelayPtyIdByTabId = { ...s.lastKnownRelayPtyIdByTabId } + if (sourcePrimaryPtyId) { + nextLastKnownRelayPtyIdByTabId[sourceTabId] = sourcePrimaryPtyId + } else { + delete nextLastKnownRelayPtyIdByTabId[sourceTabId] + } + if (detachedPtyId) { + nextLastKnownRelayPtyIdByTabId[targetTabId] = detachedPtyId + } + // Why: pane-to-tab detach moves a live PTY without spawning or exiting, so transfer identity without activity bumps. + const sourceTabsByWorktree = withTerminalTabPtyId( + s.tabsByWorktree, + sourceTabId, + sourcePrimaryPtyId + ) + const nextTabsByWorktree = detachedPtyId + ? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId) + : sourceTabsByWorktree + const directSshLedger = transferDirectSshPaneDetachLedger(s, { + detachedPtyId, + sourcePtyId: sourcePrimaryPtyId, + sourceTabId, + targetTabId, + isAuthorityCurrent: (authority) => isCurrentDirectSshAuthority(s, authority) + }) + return { + ptyIdsByTabId: nextPtyIdsByTabId, + lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId, + ...directSshLedger, + ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}) + } + }) + // Why: detach keeps the process and its pane key alive, so move resume/status authority to the new surface before the source closes. + get().transferAgentPaneAuthority({ + fromPaneKey: sourcePaneKey, + toPaneKey: targetPaneKey, + ptyId: detachedPtyId + }) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts new file mode 100644 index 00000000000..edade70fcf4 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts @@ -0,0 +1,243 @@ +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { + restorePtyDataHandlersAfterFailedShutdown, + unregisterPtyDataHandlers +} from '@/components/terminal-pane/pty-transport' +import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { + collectHibernatedCompletionEvidenceForWorktree, + collectSleepingAgentSessionRecordsForWorktree +} from '../slices/agent-status' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities' +import { resolveTerminalStopRuntimeEnvironmentId } from './terminal-workspace-routing' + +export function createTerminalPaneHibernationActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + shutdownCompletedAgentPaneForHibernation: async (worktreeId, opts) => { + const paneKeys = [opts.paneKey] + const expectedRuntimePtyIds = sortedUniquePtyIds( + opts.expectedRuntimePtyId ? [opts.expectedRuntimePtyId] : [] + ) + const rendererShutdownPtyIds = [opts.ptyId] + const state = get() + const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(state, worktreeId) + // Why: pane transports emit renderer PTY ids, not raw exact-stop handles; guard only the identity that can deliver an exit callback. + const exitGuardPtyIds = [opts.ptyId] + const tab = (state.tabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === opts.tabId + ) + const parsed = parsePaneKey(opts.paneKey) + const layout = state.terminalLayoutsByTabId[opts.tabId] + const liveTabPtyIds = state.ptyIdsByTabId[opts.tabId] ?? [] + if ( + !tab || + !parsed || + parsed.tabId !== opts.tabId || + parsed.leafId !== opts.leafId || + layout?.ptyIdsByLeafId?.[opts.leafId] !== opts.ptyId || + (expectedRuntimePtyIds.length === 0 && !liveTabPtyIds.includes(opts.ptyId)) + ) { + throw new Error('agent_hibernation_pane_binding_mismatch') + } + const sleepingAgentSessionRecords = collectSleepingAgentSessionRecordsForWorktree( + state, + worktreeId, + { + paneKeys, + captureMode: 'completed-agent-hibernation' + } + ) + const retainedCompletionEvidence = collectHibernatedCompletionEvidenceForWorktree( + state, + worktreeId, + paneKeys + ) + if (!sleepingAgentSessionRecords[opts.paneKey]) { + // Why: killing the PTY with no persisted resume record strands the pane unwakeable; abort instead of hibernating unrecoverably. + throw new Error('agent_hibernation_capture_missing') + } + const capture = shutdownBufferCaptures.get(opts.tabId) + if (capture) { + try { + capture({ includeLocalBuffers: false }) + } catch { + // Don't let one tab's capture failure block the pane hibernation. + } + } + // Why: store sleeping records before kill, since pty:exit can arrive first. + const sleepingRecordKeys = Object.keys(sleepingAgentSessionRecords) + const replacedSleepingRecords: Record = + {} + for (const key of sleepingRecordKeys) { + const existing = state.sleepingAgentSessionsByPaneKey[key] + if (existing) { + replacedSleepingRecords[key] = existing + } + } + const rollbackTargetShutdownState = (): void => { + set((s) => { + const next = { ...s.suppressedPtyExitIds } + for (const ptyId of exitGuardPtyIds) { + delete next[ptyId] + } + const nextSleeping = { ...s.sleepingAgentSessionsByPaneKey } + for (const key of sleepingRecordKeys) { + const replaced = replacedSleepingRecords[key] + if (replaced) { + nextSleeping[key] = replaced + } else { + delete nextSleeping[key] + } + } + return { suppressedPtyExitIds: next, sleepingAgentSessionsByPaneKey: nextSleeping } + }) + } + set((s) => ({ + suppressedPtyExitIds: { + ...s.suppressedPtyExitIds, + ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) + }, + sleepingAgentSessionsByPaneKey: { + ...s.sleepingAgentSessionsByPaneKey, + ...sleepingAgentSessionRecords + } + })) + if (expectedRuntimePtyIds.length > 0) { + if (!runtimeEnvironmentId) { + rollbackTargetShutdownState() + throw new Error('missing_runtime_for_exact_terminal_stop') + } + let stopResult: { + stoppedPtyIds?: string[] + livePtyIds?: string[] + postStopVerified?: boolean + postStopFailure?: string + } + try { + stopResult = await callRuntimeRpc<{ + stoppedPtyIds?: string[] + livePtyIds?: string[] + postStopVerified?: boolean + postStopFailure?: string + }>( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'terminal.stopExact', + { + worktree: toRuntimeWorktreeSelector(worktreeId), + expectedPtyIds: expectedRuntimePtyIds, + keepHistory: true, + targetOnly: true + }, + { timeoutMs: 15000 } + ) + } catch (err) { + rollbackTargetShutdownState() + throw err + } + const stoppedPtyIds = sortedUniquePtyIds(stopResult.stoppedPtyIds) + const livePtyIds = sortedUniquePtyIds(stopResult.livePtyIds) + const targetWasLive = expectedRuntimePtyIds.every((ptyId) => livePtyIds.includes(ptyId)) + if (!equalStringSets(stoppedPtyIds, expectedRuntimePtyIds) || !targetWasLive) { + rollbackTargetShutdownState() + throw new Error('exact_terminal_stop_mismatch') + } + if (stopResult.postStopVerified !== true) { + rollbackTargetShutdownState() + throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified') + } + for (const snapshot of unregisterPtyDataHandlers(rendererShutdownPtyIds) ?? []) { + snapshot.commit?.() + } + } else if (!opts.ptyId.startsWith('remote:')) { + // Why: pty.kill can flush final data before exit; unregister first so stale handlers can't fire phantom notifications during hibernation. + const handlerSnapshots = unregisterPtyDataHandlers(rendererShutdownPtyIds) ?? [] + try { + await window.api.pty.kill(opts.ptyId, { keepHistory: true }) + } catch (err) { + restorePtyDataHandlersAfterFailedShutdown(handlerSnapshots) + rollbackTargetShutdownState() + throw err + } + for (const snapshot of handlerSnapshots) { + snapshot.commit?.() + } + } + set((s) => { + const existingPtyIds = s.ptyIdsByTabId[opts.tabId] ?? [] + const shutdownPtyIdSet = new Set(rendererShutdownPtyIds) + const remainingPtyIds = existingPtyIds.filter((ptyId) => !shutdownPtyIdSet.has(ptyId)) + const nextTabsByWorktree = { ...s.tabsByWorktree } + const tabs = nextTabsByWorktree[worktreeId] ?? [] + const tabIndex = tabs.findIndex((candidate) => candidate.id === opts.tabId) + if (tabIndex !== -1) { + const nextTabs = [...tabs] + nextTabs[tabIndex] = { + ...nextTabs[tabIndex], + ptyId: remainingPtyIds.at(-1) ?? null + } + nextTabsByWorktree[worktreeId] = nextTabs + } + const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } + for (const ptyId of exitGuardPtyIds) { + delete nextCodexRestartNoticeByPtyId[ptyId] + } + const nextLastKnownRelay = + remainingPtyIds.length === 0 + ? { ...s.lastKnownRelayPtyIdByTabId } + : s.lastKnownRelayPtyIdByTabId + if (remainingPtyIds.length === 0) { + delete nextLastKnownRelay[opts.tabId] + } + let nextRuntimePaneTitlesByTabId = s.runtimePaneTitlesByTabId + const numericPaneId = Number(opts.leafId) + if ( + Number.isInteger(numericPaneId) && + s.runtimePaneTitlesByTabId[opts.tabId]?.[numericPaneId] + ) { + const nextByPane = { ...s.runtimePaneTitlesByTabId[opts.tabId] } + delete nextByPane[numericPaneId] + nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } + if (Object.keys(nextByPane).length > 0) { + nextRuntimePaneTitlesByTabId[opts.tabId] = nextByPane + } else { + delete nextRuntimePaneTitlesByTabId[opts.tabId] + } + } + const nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } + const nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes } + const nextLastTerminalInputAtByPaneKey = { ...s.lastTerminalInputAtByPaneKey } + delete nextUnreadTerminalPanes[opts.paneKey] + delete nextUnreadAgentCompletionPanes[opts.paneKey] + delete nextLastTerminalInputAtByPaneKey[opts.paneKey] + return { + tabsByWorktree: nextTabsByWorktree, + ptyIdsByTabId: { + ...s.ptyIdsByTabId, + [opts.tabId]: remainingPtyIds + }, + lastKnownRelayPtyIdByTabId: nextLastKnownRelay, + suppressedPtyExitIds: { + ...s.suppressedPtyExitIds, + ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) + }, + codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, + ...(nextRuntimePaneTitlesByTabId !== s.runtimePaneTitlesByTabId + ? { runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId } + : {}), + unreadTerminalPanes: nextUnreadTerminalPanes, + unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes, + lastTerminalInputAtByPaneKey: nextLastTerminalInputAtByPaneKey + } + }) + get().dropHibernatedAgentStatusPane(worktreeId, opts.paneKey, { + retainedCompletionEvidence + }) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-pty-bindings.ts b/src/renderer/src/store/terminals/terminal-pty-bindings.ts new file mode 100644 index 00000000000..c157e504abe --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-pty-bindings.ts @@ -0,0 +1,240 @@ +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { parseRemoteRuntimePtyId, toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { isTerminalTabPresent } from '../slices/terminal-tab-retirement' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { + consumePendingActivationSpawn, + getPendingActivationSpawnCount, + isCurrentDirectSshAuthority, + isRemoteRuntimePtyId +} from './terminal-pty-identities' + +export function createTerminalPtyBindingActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + updateTabPtyId: (tabId, ptyId, replacedPtyId, directSshRetryAttemptId) => { + // Why: final guard preventing a late caller from recreating retired tab maps (async spawn owners still do their own provider teardown). + if (!isTerminalTabPresent(get(), tabId)) { + return + } + let worktreeId: string | null = null + let wasActivationSpawn = false + const isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId) + set((s) => { + if (directSshRetryAttemptId) { + const pendingRetry = s.directSshPaneRetryByTabId[tabId] + const liveRetry = s.directSshLivePtyBindingByTabId[tabId] + const retryLease = + pendingRetry?.attemptId === directSshRetryAttemptId + ? pendingRetry + : liveRetry?.attemptId === directSshRetryAttemptId + ? liveRetry + : undefined + const boundTab = Object.values(s.tabsByWorktree) + .flat() + .find((candidate) => candidate.id === tabId) + if ( + !retryLease || + !boundTab || + parseAppSshPtyId(ptyId)?.connectionId !== retryLease.authority.targetId || + !isCurrentDirectSshAuthority(s, retryLease.authority) || + (boundTab.generation ?? 0) !== retryLease.tabGeneration + ) { + return s + } + } + const existingPtyIds = s.ptyIdsByTabId[tabId] ?? [] + const remote = parseRemoteRuntimePtyId(ptyId) + const legacyRemotePtyId = remote?.environmentId ? toRemoteRuntimePtyId(remote.handle) : null + const hasLegacyPtyBinding = legacyRemotePtyId + ? existingPtyIds.includes(legacyRemotePtyId) + : false + const explicitReplacementPtyId = replacedPtyId !== ptyId ? replacedPtyId : undefined + const replacementPtyId = + explicitReplacementPtyId ?? (hasLegacyPtyBinding ? legacyRemotePtyId : null) + const boundReplacementPtyId = + replacementPtyId && existingPtyIds.includes(replacementPtyId) ? replacementPtyId : null + const nextPtyIds = boundReplacementPtyId + ? [...new Set(existingPtyIds.map((id) => (id === boundReplacementPtyId ? ptyId : id)))] + : existingPtyIds.includes(ptyId) + ? existingPtyIds + : [...existingPtyIds, ptyId] + let nextTabsByWorktree = s.tabsByWorktree + for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) { + const index = tabs.findIndex((t) => t.id === tabId) + if (index === -1) { + continue + } + worktreeId = wId + const tab = tabs[index] + if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { + wasActivationSpawn = true + } + // Why: consume one suppression per split-pane activation callback. + const { pendingActivationSpawn: _unused, ...rest } = tab + void _unused + // Why: tab.ptyId is the single-pane fallback for legacy attach; later split-pane spawns must not steal it or remount/close reattaches the tab to the wrong PTY. + const currentTabPtyId = tab.ptyId === replacementPtyId ? ptyId : tab.ptyId + const nextTabPtyId = currentTabPtyId ?? nextPtyIds[0] ?? null + const nextPendingActivationSpawn = consumePendingActivationSpawn( + tab.pendingActivationSpawn + ) + if (tab.pendingActivationSpawn || tab.ptyId !== nextTabPtyId) { + const nextTabs = [...tabs] + nextTabs[index] = { + ...rest, + ...(nextPendingActivationSpawn + ? { pendingActivationSpawn: nextPendingActivationSpawn } + : {}), + ptyId: nextTabPtyId + } + nextTabsByWorktree = { ...s.tabsByWorktree, [wId]: nextTabs } + } + break + } + // Why: the first active PTY changes sorting, except activation-spawn side effects. + const isFirstPty = existingPtyIds.length === 0 + const isActiveWorktree = worktreeId != null && s.activeWorktreeId === worktreeId + const shouldBumpSortEpoch = isFirstPty && isActiveWorktree && !wasActivationSpawn + const shouldRetainSuppressedExit = Boolean( + explicitReplacementPtyId && + (s.suppressedPtyExitIds[ptyId] || + (replacementPtyId && s.suppressedPtyExitIds[replacementPtyId])) + ) + const nextSuppressedPtyExitIds = { ...s.suppressedPtyExitIds } + delete nextSuppressedPtyExitIds[ptyId] + if (replacementPtyId) { + delete nextSuppressedPtyExitIds[replacementPtyId] + } + if (shouldRetainSuppressedExit) { + // Why: handle rotation keeps the same terminal lifecycle; an intentional exit racing the rotation must stay suppressed once. + nextSuppressedPtyExitIds[ptyId] = true + } + const hasReplacementPendingRestart = replacementPtyId + ? replacementPtyId in s.pendingCodexPaneRestartIds + : false + const hasReplacementRestartNotice = replacementPtyId + ? replacementPtyId in s.codexRestartNoticeByPtyId + : false + const hasReplacementMigrationUnsupported = replacementPtyId + ? replacementPtyId in s.migrationUnsupportedByPtyId + : false + const nextPendingCodexPaneRestartIds = hasReplacementPendingRestart + ? { ...s.pendingCodexPaneRestartIds } + : s.pendingCodexPaneRestartIds + const nextCodexRestartNoticeByPtyId = hasReplacementRestartNotice + ? { ...s.codexRestartNoticeByPtyId } + : s.codexRestartNoticeByPtyId + const nextMigrationUnsupportedByPtyId = hasReplacementMigrationUnsupported + ? { ...s.migrationUnsupportedByPtyId } + : s.migrationUnsupportedByPtyId + if (replacementPtyId) { + if (hasReplacementPendingRestart) { + nextPendingCodexPaneRestartIds[ptyId] = true + delete nextPendingCodexPaneRestartIds[replacementPtyId] + } + if (hasReplacementRestartNotice) { + const replacedNotice = nextCodexRestartNoticeByPtyId[replacementPtyId] + nextCodexRestartNoticeByPtyId[ptyId] ??= replacedNotice + delete nextCodexRestartNoticeByPtyId[replacementPtyId] + } + if (hasReplacementMigrationUnsupported) { + const replacedMigrationUnsupported = nextMigrationUnsupportedByPtyId[replacementPtyId] + nextMigrationUnsupportedByPtyId[ptyId] ??= { + ...replacedMigrationUnsupported, + ptyId + } + delete nextMigrationUnsupportedByPtyId[replacementPtyId] + } + } + const pendingRetry = s.directSshPaneRetryByTabId[tabId] + const liveRetry = s.directSshLivePtyBindingByTabId[tabId] + const retryLease = + pendingRetry?.attemptId === directSshRetryAttemptId + ? pendingRetry + : liveRetry?.attemptId === directSshRetryAttemptId + ? liveRetry + : undefined + const boundTab = worktreeId + ? nextTabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) + : undefined + const parsedSshPty = parseAppSshPtyId(ptyId) + const acknowledgesDirectSshRetry = Boolean( + retryLease && + boundTab && + parsedSshPty?.connectionId === retryLease.authority.targetId && + isCurrentDirectSshAuthority(s, retryLease.authority) && + (boundTab.generation ?? 0) === retryLease.tabGeneration && + nextPtyIds.includes(ptyId) + ) + let nextDirectSshPaneRetryByTabId = s.directSshPaneRetryByTabId + let nextDirectSshLivePtyBindingByTabId = s.directSshLivePtyBindingByTabId + if (acknowledgesDirectSshRetry && retryLease && boundTab?.ptyId) { + if (pendingRetry?.attemptId === directSshRetryAttemptId) { + nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } + delete nextDirectSshPaneRetryByTabId[tabId] + } + if (!liveRetry || liveRetry.attemptId !== directSshRetryAttemptId) { + nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId, + [tabId]: { + attemptId: retryLease.attemptId, + authority: retryLease.authority, + tabGeneration: retryLease.tabGeneration, + ptyId: boundTab.ptyId + } + } + } else if ( + (replacementPtyId === liveRetry.ptyId && boundTab.ptyId === ptyId) || + !nextPtyIds.includes(liveRetry.ptyId) + ) { + nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId, + [tabId]: { ...liveRetry, ptyId: boundTab.ptyId } + } + } + } else { + const liveBinding = s.directSshLivePtyBindingByTabId[tabId] + if (liveBinding) { + if ( + replacementPtyId === liveBinding.ptyId && + boundTab?.ptyId === ptyId && + isCurrentDirectSshAuthority(s, liveBinding.authority) + ) { + nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId, + [tabId]: { ...liveBinding, ptyId } + } + } + } + } + return { + ...(nextTabsByWorktree !== s.tabsByWorktree + ? { tabsByWorktree: nextTabsByWorktree } + : {}), + ptyIdsByTabId: { + ...s.ptyIdsByTabId, + [tabId]: nextPtyIds + }, + lastKnownRelayPtyIdByTabId: { + ...s.lastKnownRelayPtyIdByTabId, + [tabId]: ptyId + }, + suppressedPtyExitIds: nextSuppressedPtyExitIds, + pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds, + codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, + migrationUnsupportedByPtyId: nextMigrationUnsupportedByPtyId, + directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, + directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId, + ...(shouldBumpSortEpoch ? { sortEpoch: s.sortEpoch + 1 } : {}) + } + }) + // Why: activation spawns come from clicking a worktree, not work in it — skip the lastActivityAt stamp and sortEpoch bump; other spawn reasons still bump. + if (worktreeId && !wasActivationSpawn && !isRemoteRuntimeMirror) { + get().bumpWorktreeActivity(worktreeId) + } + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-pty-identities.ts b/src/renderer/src/store/terminals/terminal-pty-identities.ts new file mode 100644 index 00000000000..52b29a29876 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-pty-identities.ts @@ -0,0 +1,102 @@ +import type { AppState } from '../types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { resolveDirectSshTerminalWorkspaceKeys } from '../slices/direct-ssh-terminal-workspace-scope' + +export function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { + return typeof ptyId === 'string' && parseRemoteRuntimePtyId(ptyId) !== null +} + +export function isCurrentDirectSshAuthority( + state: AppState, + authority: DirectSshAuthority +): boolean { + const current = state.sshConnectionStates.get(authority.targetId) + return Boolean( + current?.status === 'connected' && + current.providerEpoch === authority.providerEpoch && + current.connectionGeneration === authority.connectionGeneration + ) +} + +export function resolveDirectSshTerminalKeys(state: AppState, targetId: string): Set { + return resolveDirectSshTerminalWorkspaceKeys( + { + targetId, + catalogRevision: 0, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + folderWorkspaces: state.folderWorkspaces, + projectGroups: state.projectGroups, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey + }, + state.tabsByWorktree, + state.lastKnownRelayPtyIdByTabId + ) +} + +export function getPendingActivationSpawnCount(value: boolean | number | undefined): number { + if (value === true) { + return 1 + } + return typeof value === 'number' && value > 0 ? value : 0 +} + +export function consumePendingActivationSpawn( + value: boolean | number | undefined +): boolean | number | undefined { + const count = getPendingActivationSpawnCount(value) + if (count <= 1) { + return undefined + } + return count === 2 ? true : count - 1 +} + +export function getTabIdFromPaneKey(paneKey: string): string | null { + return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null +} + +export function sortedUniquePtyIds(ptyIds: readonly string[] | undefined): string[] { + return [...new Set((ptyIds ?? []).filter((ptyId) => ptyId.length > 0))].sort() +} + +export function equalStringSets(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) { + return false + } + const bSet = new Set(b) + return a.every((value) => bSet.has(value)) +} + +export function uniquePtyIds(ptyIds: readonly (string | null | undefined)[]): string[] { + return [...new Set(ptyIds.filter((ptyId): ptyId is string => Boolean(ptyId)))] +} + +export function resolvePrimaryLayoutPtyId(layout: TerminalLayoutSnapshot): string | null { + const ptyIdsByLeafId = layout.ptyIdsByLeafId ?? {} + const activePtyId = layout.activeLeafId ? ptyIdsByLeafId[layout.activeLeafId] : undefined + return activePtyId ?? Object.values(ptyIdsByLeafId)[0] ?? null +} + +export function withTerminalTabPtyId( + tabsByWorktree: Record, + tabId: string, + ptyId: string | null +): Record { + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + const index = tabs.findIndex((tab) => tab.id === tabId) + if (index === -1) { + continue + } + if (tabs[index]?.ptyId === ptyId) { + return tabsByWorktree + } + const nextTabs = [...tabs] + nextTabs[index] = { ...nextTabs[index]!, ptyId } + return { ...tabsByWorktree, [worktreeId]: nextTabs } + } + return tabsByWorktree +} diff --git a/src/renderer/src/store/terminals/terminal-pty-release.ts b/src/renderer/src/store/terminals/terminal-pty-release.ts new file mode 100644 index 00000000000..6a88cc0e85d --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-pty-release.ts @@ -0,0 +1,161 @@ +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { + consumePendingActivationSpawn, + getPendingActivationSpawnCount, + isCurrentDirectSshAuthority, + isRemoteRuntimePtyId +} from './terminal-pty-identities' + +export function createTerminalPtyReleaseActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + clearTabPtyId: (tabId, ptyId) => { + if (ptyId && get().pendingPtyShutdownIds[ptyId]) { + // Why: an owner exit can arrive before its post-stop inventory; keep the renderer binding retryable until verification commits. + return + } + let worktreeId: string | null = null + let wasActivationSpawn = false + let preservesDirectSshContinuationGap = false + let isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId) + set((s) => { + const existingPtyIds = s.ptyIdsByTabId[tabId] ?? [] + const remainingPtyIds = ptyId ? existingPtyIds.filter((id) => id !== ptyId) : [] + const liveBinding = s.directSshLivePtyBindingByTabId[tabId] + let nextTabsByWorktree = s.tabsByWorktree + for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) { + const index = tabs.findIndex((t) => t.id === tabId) + if (index === -1) { + continue + } + worktreeId = wId + const tab = tabs[index] + if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { + wasActivationSpawn = true + } + if (!ptyId) { + isRemoteRuntimeMirror = + existingPtyIds.length > 0 && existingPtyIds.every((id) => isRemoteRuntimePtyId(id)) + } + // Why: consume pendingActivationSpawn on real activation clears, but keep it when clearing a stale wake-hint id — its fallback spawn still needs the suppression. + const { pendingActivationSpawn: _unused, ...rest } = tab + void _unused + const nextTabPtyId = remainingPtyIds.at(-1) ?? null + preservesDirectSshContinuationGap = Boolean( + ptyId && + remainingPtyIds.length === 0 && + liveBinding?.ptyId === ptyId && + getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0 && + isCurrentDirectSshAuthority(s, liveBinding.authority) + ) + const shouldRetainActivationSpawn = + preservesDirectSshContinuationGap || + (wasActivationSpawn && ptyId != null && !existingPtyIds.includes(ptyId)) + const nextPendingActivationSpawn = shouldRetainActivationSpawn + ? tab.pendingActivationSpawn + : consumePendingActivationSpawn(tab.pendingActivationSpawn) + if (tab.pendingActivationSpawn || tab.ptyId !== nextTabPtyId) { + const nextTabs = [...tabs] + nextTabs[index] = { + ...rest, + ...(nextPendingActivationSpawn + ? { pendingActivationSpawn: nextPendingActivationSpawn } + : {}), + ptyId: nextTabPtyId + } + nextTabsByWorktree = { ...s.tabsByWorktree, [wId]: nextTabs } + } + break + } + const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } + if (worktreeId) { + nextPtyIdsByTabId[tabId] = remainingPtyIds + } else { + // Why: repo purge can retire the owning tab before its async exit arrives; don't resurrect an orphan PTY index. + delete nextPtyIdsByTabId[tabId] + } + const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } + const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } + if (ptyId) { + delete nextPendingCodexPaneRestartIds[ptyId] + delete nextCodexRestartNoticeByPtyId[ptyId] + } else { + for (const currentPtyId of s.ptyIdsByTabId[tabId] ?? []) { + delete nextPendingCodexPaneRestartIds[currentPtyId] + delete nextCodexRestartNoticeByPtyId[currentPtyId] + } + } + // Why: an explicit exit drops the dead relay ID; bulk clears retain it for relay grace. + const nextLastKnownRelay = { ...s.lastKnownRelayPtyIdByTabId } + if (ptyId && nextLastKnownRelay[tabId] === ptyId) { + // Why: the relay slot holds ONE id per tab (the last pane to bind). If + // that pane exits, promote a surviving pane instead of clearing — else the + // survivor is left visible only in the layout leaf map, and a later + // relay-drop bulk-clear lets the orphan sweep delete the still-live tab + // (the orphan predicate reads this map but not layout leaves) (#9911). + const survivingPtyId = remainingPtyIds.at(-1) + if (survivingPtyId) { + nextLastKnownRelay[tabId] = survivingPtyId + } else { + delete nextLastKnownRelay[tabId] + } + } + let nextDirectSshPaneRetryByTabId = s.directSshPaneRetryByTabId + const pendingRetry = s.directSshPaneRetryByTabId[tabId] + if ( + pendingRetry && + (!ptyId || + (existingPtyIds.includes(ptyId) && + parseAppSshPtyId(ptyId)?.connectionId === pendingRetry.authority.targetId)) + ) { + nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } + delete nextDirectSshPaneRetryByTabId[tabId] + } + let nextDirectSshLivePtyBindingByTabId = s.directSshLivePtyBindingByTabId + if (liveBinding && (!ptyId || liveBinding.ptyId === ptyId)) { + nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId + } + const promotedPtyId = ptyId ? remainingPtyIds.at(-1) : undefined + if ( + promotedPtyId && + parseAppSshPtyId(promotedPtyId)?.connectionId === liveBinding.authority.targetId && + isCurrentDirectSshAuthority(s, liveBinding.authority) + ) { + nextDirectSshLivePtyBindingByTabId[tabId] = { + ...liveBinding, + ptyId: promotedPtyId + } + } else if (!preservesDirectSshContinuationGap) { + delete nextDirectSshLivePtyBindingByTabId[tabId] + } + } + return { + ...(nextTabsByWorktree !== s.tabsByWorktree + ? { tabsByWorktree: nextTabsByWorktree } + : {}), + ptyIdsByTabId: nextPtyIdsByTabId, + lastKnownRelayPtyIdByTabId: nextLastKnownRelay, + pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds, + codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, + directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, + directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId + } + }) + // Bump activity on PTY exit, but skip intentional shutdowns (suppressed exits) and click-driven pane unmounts (pendingActivationSpawn). + if ( + worktreeId && + !wasActivationSpawn && + !isRemoteRuntimeMirror && + !hasWorktreeSleepIntent(worktreeId) && + !(ptyId && get().suppressedPtyExitIds[ptyId]) + ) { + get().bumpWorktreeActivity(worktreeId) + } + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-restart-state.ts b/src/renderer/src/store/terminals/terminal-restart-state.ts new file mode 100644 index 00000000000..f1075bdb698 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-restart-state.ts @@ -0,0 +1,202 @@ +import { isSameCodexRestartNoticeAccount } from '../slices/codex-restart-notice-account-identity' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export function createTerminalRestartActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick< + TerminalSlice, + | 'consumeSuppressedPtyExit' + | 'isPtyShutdownPending' + | 'suppressPtyExit' + | 'queueCodexPaneRestarts' + | 'consumePendingCodexPaneRestart' + | 'markCodexRestartNotices' + | 'clearCodexRestartNotice' + | 'dismissCodexRestartNotices' + | 'reopenCodexRestartPrompt' +> { + return { + consumeSuppressedPtyExit: (ptyId) => { + let wasSuppressed = false + set((s) => { + if (!s.suppressedPtyExitIds[ptyId]) { + return {} + } + wasSuppressed = true + const next = { ...s.suppressedPtyExitIds } + delete next[ptyId] + return { suppressedPtyExitIds: next } + }) + return wasSuppressed + }, + isPtyShutdownPending: (ptyId) => (get().pendingPtyShutdownIds[ptyId] ?? 0) > 0, + suppressPtyExit: (ptyId) => { + set((s) => ({ + suppressedPtyExitIds: { ...s.suppressedPtyExitIds, [ptyId]: true } + })) + }, + queueCodexPaneRestarts: (ptyIds) => { + if (ptyIds.length === 0) { + return + } + set((s) => { + // Why: the prompt is answered the moment the user asks for a restart. A + // pane whose tab isn't mounted can only restart when it next mounts, and + // leaving the prompt up re-showed a button that now does nothing. + const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } + for (const ptyId of ptyIds) { + const notice = nextCodexRestartNoticeByPtyId[ptyId] + if (notice) { + // Why: mirror of the strip in dismissCodexRestartNotices. A queued + // restart leaves the pane on the old account until it relaunches, so + // it must re-block input that an earlier dismissal had freed. + const { dismissed: _dismissed, ...kept } = notice + nextCodexRestartNoticeByPtyId[ptyId] = { ...kept, restartRequested: true } + } + } + return { + pendingCodexPaneRestartIds: { + ...s.pendingCodexPaneRestartIds, + ...Object.fromEntries(ptyIds.map((ptyId) => [ptyId, true] as const)) + }, + codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId + } + }) + }, + consumePendingCodexPaneRestart: (ptyId) => { + let wasQueued = false + set((s) => { + if (!s.pendingCodexPaneRestartIds[ptyId]) { + return {} + } + wasQueued = true + const next = { ...s.pendingCodexPaneRestartIds } + delete next[ptyId] + return { pendingCodexPaneRestartIds: next } + }) + return wasQueued + }, + markCodexRestartNotices: (notices) => { + if (notices.length === 0) { + return [] + } + const noticedPtyIds: string[] = [] + set((s) => { + const next = { ...s.codexRestartNoticeByPtyId } + const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } + for (const notice of notices) { + const existing = next[notice.ptyId] + // Why one record rather than two lookups: the label and the id of the + // launch account have to come from the same source, or they can end up + // describing two different accounts. + const launch = existing ?? notice + const target = { id: notice.nextAccountId, label: notice.nextAccountLabel } + const homeRouteChanged = + notice.homeRouteChanged === undefined + ? existing?.homeRouteChanged === true + : notice.homeRouteChanged + // Why: a live Codex pane keeps its original launch account until it actually restarts, so A -> B -> A must not leave a stale restart notice. + if ( + !homeRouteChanged && + isSameCodexRestartNoticeAccount( + { id: launch.previousAccountId, label: launch.previousAccountLabel }, + target + ) + ) { + delete next[notice.ptyId] + delete nextPendingCodexPaneRestartIds[notice.ptyId] + continue + } + next[notice.ptyId] = { + previousAccountLabel: launch.previousAccountLabel, + nextAccountLabel: notice.nextAccountLabel, + ...(launch.previousAccountId === undefined + ? {} + : { previousAccountId: launch.previousAccountId }), + ...(notice.nextAccountId === undefined ? {} : { nextAccountId: notice.nextAccountId }), + ...(homeRouteChanged ? { homeRouteChanged: true as const } : {}), + // Why: a queued restart relaunches under whatever account is selected + // when it runs, so a later switch does not reopen an answered prompt. + ...(existing?.restartRequested ? { restartRequested: true as const } : {}), + // Why: a dismissal answered one question — "keep the launch account + // instead of this one". Only a genuinely different target re-asks it, + // so a later C reopens the prompt while adding an account or + // reauthenticating the active one (both re-mark live panes with the + // selection unchanged) must not resurrect it and re-mute the pane. + ...(existing?.dismissed && + isSameCodexRestartNoticeAccount( + { id: existing.nextAccountId, label: existing.nextAccountLabel }, + target + ) + ? { dismissed: true as const } + : {}) + } + noticedPtyIds.push(notice.ptyId) + } + return { + codexRestartNoticeByPtyId: next, + pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds + } + }) + return noticedPtyIds + }, + clearCodexRestartNotice: (ptyId) => { + set((s) => { + if (!s.codexRestartNoticeByPtyId[ptyId]) { + return {} + } + const next = { ...s.codexRestartNoticeByPtyId } + const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } + delete next[ptyId] + delete nextPendingCodexPaneRestartIds[ptyId] + return { + codexRestartNoticeByPtyId: next, + pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds + } + }) + }, + dismissCodexRestartNotices: (ptyIds) => { + set((s) => { + // Why: keeping the old account is an answer, not a restart — the record + // stays so `previousAccountLabel` still names the pane's launch account, + // but every consumer treats it as answered (prompt hidden, input freed). + const next = { ...s.codexRestartNoticeByPtyId } + const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } + let changed = false + for (const ptyId of ptyIds) { + const notice = next[ptyId] + if (!notice || notice.dismissed) { + continue + } + const { restartRequested: _restartRequested, ...kept } = notice + next[ptyId] = { ...kept, dismissed: true } + delete nextPendingCodexPaneRestartIds[ptyId] + changed = true + } + if (!changed) { + return {} + } + return { + codexRestartNoticeByPtyId: next, + pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds + } + }) + }, + reopenCodexRestartPrompt: (ptyId) => { + set((s) => { + const notice = s.codexRestartNoticeByPtyId[ptyId] + if (!notice?.restartRequested) { + return {} + } + const { restartRequested: _restartRequested, ...kept } = notice + const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } + delete nextPendingCodexPaneRestartIds[ptyId] + return { + codexRestartNoticeByPtyId: { ...s.codexRestartNoticeByPtyId, [ptyId]: kept }, + pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds + } + }) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-shutdown-guards.ts b/src/renderer/src/store/terminals/terminal-shutdown-guards.ts new file mode 100644 index 00000000000..83342bd5041 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-shutdown-guards.ts @@ -0,0 +1,185 @@ +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { + restorePtyDataHandlersAfterFailedShutdown, + unregisterPtyDataHandlers +} from '@/components/terminal-pane/pty-transport' +import type { PtyDataHandlerShutdownSnapshot } from '@/components/terminal-pane/pty-shutdown-data-suspension' +import { disposeParkedTerminalWatchersForPtyIds } from '@/components/terminal-pane/terminal-parked-watcher-registry' +import { + clearCommittedPtyShutdownSettlements, + hasCommittedPtyShutdownSettlement, + markCommittedPtyShutdowns, + noteCommittedPtyShutdownSettlements, + settleDeferredPtyShutdownExits +} from '@/components/terminal-pane/pty-shutdown-exit-deferral' +import type { TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export type TerminalShutdownGuardController = { + commitHandlerSnapshots: () => void + markShutdownPending: () => void + rollbackShutdown: () => void + settlePartialRendererStop: (stoppedPtyIds: readonly string[]) => void + stopRendererPtys: () => Promise<{ + stoppedPtyIds: string[] + failure?: PromiseRejectedResult + }> + unregisterHandlers: () => void + wasPartialRendererStopSettled: () => boolean +} + +export function createTerminalShutdownGuardController({ + exitGuardPtyIds, + get, + keepIdentifiers, + rendererShutdownPtyIds, + runtimeEnvironmentId, + set, + tabs +}: { + exitGuardPtyIds: readonly string[] + get: TerminalStoreGet + keepIdentifiers: boolean + rendererShutdownPtyIds: readonly string[] + runtimeEnvironmentId: string | null + set: TerminalStoreSet + tabs: readonly TerminalTab[] +}): TerminalShutdownGuardController { + let handlerSnapshots: PtyDataHandlerShutdownSnapshot[] = [] + let partialRendererStopSettled = false + + const markShutdownPending = (): void => { + set((state) => { + const pendingPtyShutdownIds = { ...state.pendingPtyShutdownIds } + for (const ptyId of exitGuardPtyIds) { + pendingPtyShutdownIds[ptyId] = (pendingPtyShutdownIds[ptyId] ?? 0) + 1 + } + return { + suppressedPtyExitIds: { + ...state.suppressedPtyExitIds, + ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) + }, + pendingPtyShutdownIds + } + }) + } + + const settleGuards = ( + stoppedPtyIds: ReadonlySet, + clearStoppedTabBindings = false + ): void => { + set((state) => { + const pendingPtyShutdownIds = { ...state.pendingPtyShutdownIds } + const suppressedPtyExitIds = { ...state.suppressedPtyExitIds } + for (const ptyId of exitGuardPtyIds) { + const remainingOwners = (pendingPtyShutdownIds[ptyId] ?? 0) - 1 + if (remainingOwners > 0) { + pendingPtyShutdownIds[ptyId] = remainingOwners + } else { + delete pendingPtyShutdownIds[ptyId] + if (!stoppedPtyIds.has(ptyId)) { + delete suppressedPtyExitIds[ptyId] + } + } + } + if (!clearStoppedTabBindings) { + return { pendingPtyShutdownIds, suppressedPtyExitIds } + } + const ptyIdsByTabId = { ...state.ptyIdsByTabId } + for (const tab of tabs) { + ptyIdsByTabId[tab.id] = (state.ptyIdsByTabId[tab.id] ?? []).filter( + (ptyId) => !stoppedPtyIds.has(ptyId) + ) + } + return { ptyIdsByTabId, pendingPtyShutdownIds, suppressedPtyExitIds } + }) + } + + const rollbackShutdown = (): void => { + if (handlerSnapshots.length > 0) { + restorePtyDataHandlersAfterFailedShutdown(handlerSnapshots) + } + set((state) => { + const suppressedPtyExitIds = { ...state.suppressedPtyExitIds } + const pendingPtyShutdownIds = { ...state.pendingPtyShutdownIds } + for (const ptyId of exitGuardPtyIds) { + const remainingOwners = (pendingPtyShutdownIds[ptyId] ?? 0) - 1 + if (remainingOwners > 0) { + pendingPtyShutdownIds[ptyId] = remainingOwners + } else { + delete pendingPtyShutdownIds[ptyId] + if (!hasCommittedPtyShutdownSettlement(ptyId)) { + delete suppressedPtyExitIds[ptyId] + } + } + } + return { suppressedPtyExitIds, pendingPtyShutdownIds } + }) + const settledPtyIds = exitGuardPtyIds.filter((ptyId) => !get().isPtyShutdownPending(ptyId)) + const committedPtyIds = settledPtyIds.filter(hasCommittedPtyShutdownSettlement) + const rolledBackPtyIds = settledPtyIds.filter( + (ptyId) => !hasCommittedPtyShutdownSettlement(ptyId) + ) + markCommittedPtyShutdowns(committedPtyIds) + settleDeferredPtyShutdownExits(committedPtyIds, 'committed') + settleDeferredPtyShutdownExits(rolledBackPtyIds, 'rolled-back') + clearCommittedPtyShutdownSettlements(settledPtyIds) + } + + const stopRendererPtys = async (): Promise<{ + stoppedPtyIds: string[] + failure?: PromiseRejectedResult + }> => { + const localPtyIds = rendererShutdownPtyIds.filter((ptyId) => !ptyId.startsWith('remote:')) + const results = await Promise.allSettled( + localPtyIds.map((ptyId) => window.api.pty.kill(ptyId, { keepHistory: keepIdentifiers })) + ) + const stoppedPtyIds = [ + ...(runtimeEnvironmentId + ? rendererShutdownPtyIds.filter((ptyId) => ptyId.startsWith('remote:')) + : []), + ...localPtyIds.filter((_, index) => results[index]?.status === 'fulfilled') + ] + disposeParkedTerminalWatchersForPtyIds(stoppedPtyIds) + return { + stoppedPtyIds, + failure: results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ) + } + } + + const settlePartialRendererStop = (stoppedPtyIds: readonly string[]): void => { + partialRendererStopSettled = true + const stopped = new Set(stoppedPtyIds) + const stoppedSnapshots = handlerSnapshots.filter((snapshot) => stopped.has(snapshot.ptyId)) + const failedSnapshots = handlerSnapshots.filter((snapshot) => !stopped.has(snapshot.ptyId)) + for (const snapshot of stoppedSnapshots) { + snapshot.commit?.() + } + restorePtyDataHandlersAfterFailedShutdown(failedSnapshots) + noteCommittedPtyShutdownSettlements(stoppedPtyIds) + settleGuards(stopped, true) + const failedPtyIds = exitGuardPtyIds.filter((ptyId) => !stopped.has(ptyId)) + markCommittedPtyShutdowns(stoppedPtyIds) + settleDeferredPtyShutdownExits(stoppedPtyIds, 'committed') + settleDeferredPtyShutdownExits(failedPtyIds, 'rolled-back') + clearCommittedPtyShutdownSettlements(exitGuardPtyIds) + } + + return { + commitHandlerSnapshots: () => { + for (const snapshot of handlerSnapshots) { + snapshot.commit?.() + } + noteCommittedPtyShutdownSettlements(exitGuardPtyIds) + }, + markShutdownPending, + rollbackShutdown, + settlePartialRendererStop, + stopRendererPtys, + unregisterHandlers: () => { + handlerSnapshots = unregisterPtyDataHandlers([...rendererShutdownPtyIds]) ?? [] + }, + wasPartialRendererStopSettled: () => partialRendererStopSettled + } +} diff --git a/src/renderer/src/store/terminals/terminal-shutdown-state.ts b/src/renderer/src/store/terminals/terminal-shutdown-state.ts new file mode 100644 index 00000000000..a4cb7979978 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-shutdown-state.ts @@ -0,0 +1,194 @@ +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import { clearTransientTerminalState } from '../slices/terminal-helpers' +import { + clearCommittedPtyShutdownSettlements, + markCommittedPtyShutdowns, + settleDeferredPtyShutdownExits +} from '@/components/terminal-pane/pty-shutdown-exit-deferral' +import { + removeSleepingRecordsReplacedByManualWorktreeSleep, + type AgentStatusWorktreeShutdownReason, + type RetainedAgentEntry +} from '../slices/agent-status' +import type { TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export function commitTerminalShutdownState({ + exitGuardPtyIds, + get, + keepIdentifiers, + retainedCompletionEvidence, + set, + shutdownReason, + sleepingAgentSessionRecords, + sleepingPaneKeys, + tabs, + worktreeId +}: { + exitGuardPtyIds: readonly string[] + get: TerminalStoreGet + keepIdentifiers: boolean + retainedCompletionEvidence: readonly RetainedAgentEntry[] + set: TerminalStoreSet + shutdownReason: AgentStatusWorktreeShutdownReason + sleepingAgentSessionRecords: Record + sleepingPaneKeys?: readonly string[] + tabs: readonly TerminalTab[] + worktreeId: string +}): void { + set((state) => { + const tabsByWorktree = keepIdentifiers + ? state.tabsByWorktree + : { + ...state.tabsByWorktree, + [worktreeId]: (state.tabsByWorktree[worktreeId] ?? []).map((tab, index) => + clearTransientTerminalState(tab, index) + ) + } + const ptyIdsByTabId = { + ...state.ptyIdsByTabId, + ...Object.fromEntries(tabs.map((tab) => [tab.id, [] as string[]] as const)) + } + const runtimePaneTitlesByTabId = keepIdentifiers + ? state.runtimePaneTitlesByTabId + : { ...state.runtimePaneTitlesByTabId } + const suppressedPtyExitIds = { + ...state.suppressedPtyExitIds, + ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) + } + const pendingPtyShutdownIds = { ...state.pendingPtyShutdownIds } + for (const ptyId of exitGuardPtyIds) { + const remainingOwners = (pendingPtyShutdownIds[ptyId] ?? 0) - 1 + if (remainingOwners > 0) { + pendingPtyShutdownIds[ptyId] = remainingOwners + } else { + delete pendingPtyShutdownIds[ptyId] + } + } + + // Sleeping terminals retain restart intent, but a wake can receive a different live PTY id. + const pendingCodexPaneRestartIds = keepIdentifiers + ? state.pendingCodexPaneRestartIds + : { ...state.pendingCodexPaneRestartIds } + const codexRestartNoticeByPtyId = { ...state.codexRestartNoticeByPtyId } + for (const ptyId of exitGuardPtyIds) { + if (!keepIdentifiers) { + delete pendingCodexPaneRestartIds[ptyId] + } + delete codexRestartNoticeByPtyId[ptyId] + } + + const pendingSetupSplitByTabId = { ...state.pendingSetupSplitByTabId } + const pendingIssueCommandSplitByTabId = { ...state.pendingIssueCommandSplitByTabId } + const terminalLayoutsByTabId = { ...state.terminalLayoutsByTabId } + let unreadTerminalTabs = state.unreadTerminalTabs + let unreadTerminalPanes = state.unreadTerminalPanes + let unreadAgentCompletionPanes = state.unreadAgentCompletionPanes + let lastTerminalInputAtByPaneKey = state.lastTerminalInputAtByPaneKey + + for (const tab of tabs) { + if (!keepIdentifiers) { + delete runtimePaneTitlesByTabId[tab.id] + } + delete pendingSetupSplitByTabId[tab.id] + delete pendingIssueCommandSplitByTabId[tab.id] + if (unreadTerminalTabs[tab.id]) { + if (unreadTerminalTabs === state.unreadTerminalTabs) { + unreadTerminalTabs = { ...state.unreadTerminalTabs } + } + delete unreadTerminalTabs[tab.id] + } + for (const paneKey of Object.keys(unreadTerminalPanes)) { + if (paneKey.startsWith(`${tab.id}:`)) { + if (unreadTerminalPanes === state.unreadTerminalPanes) { + unreadTerminalPanes = { ...unreadTerminalPanes } + } + delete unreadTerminalPanes[paneKey] + } + } + for (const paneKey of Object.keys(unreadAgentCompletionPanes)) { + if (paneKey.startsWith(`${tab.id}:`)) { + if (unreadAgentCompletionPanes === state.unreadAgentCompletionPanes) { + unreadAgentCompletionPanes = { ...unreadAgentCompletionPanes } + } + delete unreadAgentCompletionPanes[paneKey] + } + } + for (const paneKey of Object.keys(lastTerminalInputAtByPaneKey)) { + if (paneKey.startsWith(`${tab.id}:`)) { + if (lastTerminalInputAtByPaneKey === state.lastTerminalInputAtByPaneKey) { + lastTerminalInputAtByPaneKey = { ...lastTerminalInputAtByPaneKey } + } + delete lastTerminalInputAtByPaneKey[paneKey] + } + } + if (!keepIdentifiers) { + const layout = terminalLayoutsByTabId[tab.id] + if (layout?.ptyIdsByLeafId) { + terminalLayoutsByTabId[tab.id] = { ...layout, ptyIdsByLeafId: {} } + } + } + } + + const lastKnownRelayPtyIdByTabId = keepIdentifiers + ? state.lastKnownRelayPtyIdByTabId + : { ...state.lastKnownRelayPtyIdByTabId } + if (!keepIdentifiers) { + for (const tab of tabs) { + delete lastKnownRelayPtyIdByTabId[tab.id] + } + } + + return { + tabsByWorktree, + ptyIdsByTabId, + lastKnownRelayPtyIdByTabId, + runtimePaneTitlesByTabId, + suppressedPtyExitIds, + pendingPtyShutdownIds, + pendingCodexPaneRestartIds, + codexRestartNoticeByPtyId, + pendingSetupSplitByTabId, + pendingIssueCommandSplitByTabId, + terminalLayoutsByTabId, + ...(unreadTerminalTabs !== state.unreadTerminalTabs ? { unreadTerminalTabs } : {}), + ...(unreadTerminalPanes !== state.unreadTerminalPanes ? { unreadTerminalPanes } : {}), + ...(unreadAgentCompletionPanes !== state.unreadAgentCompletionPanes + ? { unreadAgentCompletionPanes } + : {}), + ...(lastTerminalInputAtByPaneKey !== state.lastTerminalInputAtByPaneKey + ? { lastTerminalInputAtByPaneKey } + : {}) + } + }) + + if (keepIdentifiers) { + set((state) => { + const base = + shutdownReason === 'manual-sleep' + ? removeSleepingRecordsReplacedByManualWorktreeSleep( + state.sleepingAgentSessionsByPaneKey, + worktreeId, + sleepingPaneKeys, + sleepingAgentSessionRecords + ).records + : state.sleepingAgentSessionsByPaneKey + return { + sleepingAgentSessionsByPaneKey: { ...base, ...sleepingAgentSessionRecords } + } + }) + } else { + get().clearSleepingAgentSessionsByWorktree(worktreeId) + } + + get().dropAgentStatusByWorktree(worktreeId, { + shutdownReason, + sleepingPaneKeys, + retainedCompletionEvidence + }) + get().clearPaneForegroundAgentByWorktree(worktreeId) + const settledPtyIds = exitGuardPtyIds.filter((ptyId) => !get().isPtyShutdownPending(ptyId)) + markCommittedPtyShutdowns(settledPtyIds) + settleDeferredPtyShutdownExits(settledPtyIds, 'committed') + clearCommittedPtyShutdownSettlements(settledPtyIds) +} diff --git a/src/renderer/src/store/terminals/terminal-shutdown.ts b/src/renderer/src/store/terminals/terminal-shutdown.ts new file mode 100644 index 00000000000..3c4da6b3b0a --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-shutdown.ts @@ -0,0 +1,172 @@ +import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { requestRemoteWorktreeSleep } from '@/runtime/remote-worktree-sleep' +import { + collectHibernatedCompletionEvidenceForWorktree, + collectSleepingAgentSessionRecordsForWorktree, + type AgentStatusWorktreeShutdownReason +} from '../slices/agent-status' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities' +import { resolveTerminalStopRuntimeEnvironmentId } from './terminal-workspace-routing' +import { createTerminalShutdownGuardController } from './terminal-shutdown-guards' +import { commitTerminalShutdownState } from './terminal-shutdown-state' + +type ExactTerminalStopResult = { + stoppedPtyIds?: string[] + livePtyIds?: string[] + postStopVerified?: boolean + postStopFailure?: string +} + +export function createTerminalShutdownActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + shutdownWorktreeTerminals: async (worktreeId, opts) => { + const keepIdentifiers = opts?.keepIdentifiers ?? false + const shutdownReason: AgentStatusWorktreeShutdownReason = + opts?.shutdownReason ?? (keepIdentifiers ? 'manual-sleep' : 'remove-worktree') + const tabs = get().tabsByWorktree[worktreeId] ?? [] + const rendererShutdownPtyIds = sortedUniquePtyIds( + tabs.flatMap((tab) => get().ptyIdsByTabId[tab.id] ?? []) + ) + const expectedRuntimePtyIds = sortedUniquePtyIds(opts?.expectedRuntimePtyIds) + const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId) + // Only renderer-bound ids emit pane exit callbacks; raw RPC handles never enter this guard. + const exitGuardPtyIds = rendererShutdownPtyIds + const sleepingAgentSessionRecords = keepIdentifiers + ? collectSleepingAgentSessionRecordsForWorktree(get(), worktreeId, { + paneKeys: opts?.sleepingPaneKeys, + ...(shutdownReason === 'manual-sleep' ? { captureMode: 'manual-worktree-sleep' } : {}), + ...(shutdownReason === 'auto-hibernate-completed-agent' + ? { captureMode: 'completed-agent-hibernation' } + : {}) + }) + : {} + const retainedCompletionEvidence = + shutdownReason === 'auto-hibernate-completed-agent' + ? collectHibernatedCompletionEvidenceForWorktree( + get(), + worktreeId, + opts?.sleepingPaneKeys + ) + : [] + const guards = createTerminalShutdownGuardController({ + exitGuardPtyIds, + get, + keepIdentifiers, + rendererShutdownPtyIds, + runtimeEnvironmentId, + set, + tabs + }) + + // Capture before kill; capture() can publish layout changes consumed by the final commit. + if (keepIdentifiers) { + for (const tab of tabs) { + const capture = shutdownBufferCaptures.get(tab.id) + if (capture) { + try { + capture({ includeLocalBuffers: false }) + } catch { + // One unavailable pane must not block capture of the rest of the worktree. + } + } + } + } + + if (expectedRuntimePtyIds.length === 0) { + guards.markShutdownPending() + guards.unregisterHandlers() + try { + // Backend ownership is opt-in; the default owner RPC prevents remote PTY leaks. + if (runtimeEnvironmentId && opts?.backendOwnsPtyTeardown !== true) { + await (shutdownReason === 'manual-sleep' + ? requestRemoteWorktreeSleep({ environmentId: runtimeEnvironmentId, worktreeId }) + : callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'terminal.stop', + { worktree: toRuntimeWorktreeSelector(worktreeId) }, + { timeoutMs: 15_000 } + )) + } + // Renderer teardown waits for the owner RPC so failures leave bindings retryable. + const rendererStop = await guards.stopRendererPtys() + if (rendererStop.failure) { + guards.settlePartialRendererStop(rendererStop.stoppedPtyIds) + throw rendererStop.failure.reason + } + } catch (error) { + if (!guards.wasPartialRendererStopSettled()) { + guards.rollbackShutdown() + } + throw error + } + } else { + if (!runtimeEnvironmentId) { + throw new Error('missing_runtime_for_exact_terminal_stop') + } + guards.markShutdownPending() + guards.unregisterHandlers() + let stopResult: ExactTerminalStopResult + try { + stopResult = await callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'terminal.stopExact', + { + worktree: toRuntimeWorktreeSelector(worktreeId), + expectedPtyIds: expectedRuntimePtyIds, + keepHistory: keepIdentifiers + }, + { timeoutMs: 15_000 } + ) + } catch (error) { + guards.rollbackShutdown() + throw error + } + const stoppedPtyIds = sortedUniquePtyIds(stopResult.stoppedPtyIds) + const livePtyIds = sortedUniquePtyIds(stopResult.livePtyIds) + if ( + !equalStringSets(stoppedPtyIds, expectedRuntimePtyIds) || + !equalStringSets(livePtyIds, expectedRuntimePtyIds) + ) { + guards.rollbackShutdown() + throw new Error('exact_terminal_stop_mismatch') + } + if (stopResult.postStopVerified !== true) { + guards.rollbackShutdown() + throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified') + } + try { + const rendererStop = await guards.stopRendererPtys() + if (rendererStop.failure) { + guards.settlePartialRendererStop(rendererStop.stoppedPtyIds) + throw rendererStop.failure.reason + } + } catch (error) { + if (!guards.wasPartialRendererStopSettled()) { + guards.rollbackShutdown() + } + throw error + } + } + + guards.commitHandlerSnapshots() + commitTerminalShutdownState({ + exitGuardPtyIds, + get, + keepIdentifiers, + retainedCompletionEvidence, + set, + shutdownReason, + sleepingAgentSessionRecords, + sleepingPaneKeys: opts?.sleepingPaneKeys, + tabs, + worktreeId + }) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-startup-queues.ts b/src/renderer/src/store/terminals/terminal-startup-queues.ts new file mode 100644 index 00000000000..fb7ebdabbe2 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-startup-queues.ts @@ -0,0 +1,133 @@ +import { createBrowserUuid } from '@/lib/browser-uuid' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export function createTerminalStartupQueueActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick< + TerminalSlice, + | 'queueTabStartupCommand' + | 'queueTabInitialCwd' + | 'consumeTabInitialCwd' + | 'consumeTabStartupCommand' + | 'queueTabSetupSplit' + | 'consumeTabSetupSplit' + | 'queueTabIssueCommandSplit' + | 'consumeTabIssueCommandSplit' + | 'consumePendingSnapshot' + | 'consumePendingColdRestore' +> { + return { + queueTabStartupCommand: (tabId, startup) => { + // Why: launchToken is only meaningful for tracked launch-config reuse; plain startup commands must not mint a synthetic token. + const launchToken = startup.launchConfig + ? (startup.launchToken ?? createBrowserUuid()) + : undefined + set((s) => ({ + pendingStartupByTabId: { + ...s.pendingStartupByTabId, + [tabId]: { + ...startup, + ...(launchToken ? { launchToken } : {}) + } + } + })) + }, + queueTabInitialCwd: (tabId, cwd) => { + set((s) => ({ + pendingInitialCwdByTabId: { + ...s.pendingInitialCwdByTabId, + [tabId]: cwd + } + })) + }, + consumeTabInitialCwd: (tabId) => { + const pending = get().pendingInitialCwdByTabId[tabId] + if (!pending) { + return null + } + set((s) => { + const next = { ...s.pendingInitialCwdByTabId } + delete next[tabId] + return { pendingInitialCwdByTabId: next } + }) + return pending + }, + consumeTabStartupCommand: (tabId) => { + const pending = get().pendingStartupByTabId[tabId] + if (!pending) { + return null + } + set((s) => { + const next = { ...s.pendingStartupByTabId } + delete next[tabId] + return { pendingStartupByTabId: next } + }) + return pending + }, + queueTabSetupSplit: (tabId, startup) => { + set((s) => ({ + pendingSetupSplitByTabId: { + ...s.pendingSetupSplitByTabId, + [tabId]: startup + } + })) + }, + consumeTabSetupSplit: (tabId) => { + const pending = get().pendingSetupSplitByTabId[tabId] + if (!pending) { + return null + } + set((s) => { + const next = { ...s.pendingSetupSplitByTabId } + delete next[tabId] + return { pendingSetupSplitByTabId: next } + }) + return pending + }, + queueTabIssueCommandSplit: (tabId, issueCommand) => { + set((s) => ({ + pendingIssueCommandSplitByTabId: { + ...s.pendingIssueCommandSplitByTabId, + [tabId]: issueCommand + } + })) + }, + consumeTabIssueCommandSplit: (tabId) => { + const pending = get().pendingIssueCommandSplitByTabId[tabId] + if (!pending) { + return null + } + set((s) => { + const next = { ...s.pendingIssueCommandSplitByTabId } + delete next[tabId] + return { pendingIssueCommandSplitByTabId: next } + }) + return pending + }, + consumePendingSnapshot: (ptyId) => { + const snapshot = get().pendingSnapshotByPtyId[ptyId] + if (!snapshot) { + return null + } + set((s) => { + const next = { ...s.pendingSnapshotByPtyId } + delete next[ptyId] + return { pendingSnapshotByPtyId: next } + }) + return snapshot + }, + consumePendingColdRestore: (ptyId) => { + const data = get().pendingColdRestoreByPtyId[ptyId] + if (!data) { + return null + } + set((s) => { + const next = { ...s.pendingColdRestoreByPtyId } + delete next[ptyId] + return { pendingColdRestoreByPtyId: next } + }) + return data + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts new file mode 100644 index 00000000000..5f754cc6463 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -0,0 +1,125 @@ +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import type { AgentStartedTelemetry } from '../../lib/worktree-startup-payload' +import type { + DirectSshLivePtyBinding, + DirectSshPaneRetryAttempt, + DirectSshPaneRetryHistory +} from '../slices/direct-ssh-terminal-recovery' +import type { NativeChatLaunchDraft, NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' +import type { AutomaticAgentResumeClaim, CodexRestartNotice } from './terminal-contracts' +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { TerminalActions } from './terminal-actions' + +export type TerminalState = { + tabsByWorktree: Record + activeTabId: string | null + /** Per-worktree focus survives workspace switches independently of the global active tab. */ + activeTabIdByWorktree: Record + ptyIdsByTabId: Record + runtimePaneTitlesByTabId: Record> + unreadTerminalTabs: Record + unreadTerminalPanes: Record + unreadAgentCompletionPanes: Record + /** Scoped exit suppression and reference-counted shutdown ownership prevent teardown races. */ + suppressedPtyExitIds: Record + pendingPtyShutdownIds: Record + pendingCodexPaneRestartIds: Record + codexRestartNoticeByPtyId: Record + directSshPaneRetryByTabId: Record + directSshLivePtyBindingByTabId: Record + directSshPaneRetryHistoryByTabId: Record + expandedPaneByTabId: Record + canExpandPaneByTabId: Record + terminalLayoutsByTabId: Record + recentQuickCommandIdByGroup: Record + /** Runtime-only claim bridging startup payload consumption until terminal hooks mount. */ + automaticAgentResumeClaimsByTabId: Record + nativeChatLaunchPromptByTabId: Record + nativeChatLaunchDraftByTabId: Record + pendingStartupByTabId: Record< + string, + { + command: string + delivery?: 'terminal-paste' + startupCommandDelivery?: StartupCommandDelivery + env?: Record + envToDelete?: string[] + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + agentArgsOverride?: string | null + draftPrompt?: string + sessionOptions?: Record + initialAgentStatus?: { + agent: TuiAgent + prompt: string + } + showSessionRestoredBanner?: boolean + telemetry?: AgentStartedTelemetry + } + > + pendingInitialCwdByTabId: Record + pendingSetupSplitByTabId: Record< + string, + { + command: string + env?: Record + direction: SetupSplitDirection + } + > + pendingIssueCommandSplitByTabId: Record< + string, + { + command: string + env?: Record + } + > + tabBarOrderByWorktree: Record + /** False until global reconnect publishes every deferred wake hint. */ + workspaceSessionReady: boolean + restoredRuntimeHostIdByWorkspaceSessionKey: Record + defaultTerminalTabsAppliedByWorktreeId: Record + hydrationSucceeded: boolean + pendingReconnectWorktreeIds: string[] + pendingReconnectTabByWorktree: Record + /** Prior daemon/relay identities are wake hints; they are not proof of current liveness. */ + pendingReconnectPtyIdByTabId: Record + /** Retained across relay disconnect after tab.ptyId is cleared so persistence can reattach. */ + lastKnownRelayPtyIdByTabId: Record + /** Reattach snapshots are consumed once by the pane that receives the replacement PTY. */ + pendingSnapshotByPtyId: Record< + string, + { + snapshot: string + cols?: number + rows?: number + isAlternateScreen?: boolean + } + > + pendingColdRestoreByPtyId: Record< + string, + { + scrollback: string + cwd: string + } + > + cacheTimerByKey: Record + lastTerminalInputAtByPaneKey: Record + deferredSshReconnectTargets: string[] + deferredSshSessionIdsByTabId: Record +} +export type TerminalSlice = TerminalState & TerminalActions + +export type TerminalStoreSet = Parameters>[0] +export type TerminalStoreGet = Parameters>[1] diff --git a/src/renderer/src/store/terminals/terminal-tab-attention.ts b/src/renderer/src/store/terminals/terminal-tab-attention.ts new file mode 100644 index 00000000000..062043b546e --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-attention.ts @@ -0,0 +1,126 @@ +import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' +import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export function createTerminalTabAttentionActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick< + TerminalSlice, + | 'markTerminalTabUnread' + | 'markTerminalPaneUnread' + | 'markAgentCompletionPaneUnread' + | 'clearTerminalTabUnread' + | 'clearTerminalPaneUnread' + | 'setTabCustomTitle' + | 'setTabColor' +> { + return { + markTerminalTabUnread: (tabId) => { + const state = get() + const ownerTab = Object.values(state.tabsByWorktree ?? {}) + .flat() + .find((t) => t.id === tabId) + if (!ownerTab) { + return + } + // Why: terminal attention persists until real interaction. + set((s) => { + if (s.unreadTerminalTabs[tabId]) { + return s + } + return { unreadTerminalTabs: { ...s.unreadTerminalTabs, [tabId]: true as const } } + }) + }, + markTerminalPaneUnread: (paneKey) => { + set((s) => { + if (s.unreadTerminalPanes[paneKey]) { + return s + } + return { unreadTerminalPanes: { ...s.unreadTerminalPanes, [paneKey]: true as const } } + }) + }, + markAgentCompletionPaneUnread: (paneKey) => { + set((s) => { + if (s.unreadAgentCompletionPanes[paneKey]) { + return s + } + return { + unreadAgentCompletionPanes: { + ...s.unreadAgentCompletionPanes, + [paneKey]: true as const + } + } + }) + }, + clearTerminalTabUnread: (tabId) => { + set((s) => { + if (!s.unreadTerminalTabs[tabId]) { + return s + } + const copy = { ...s.unreadTerminalTabs } + delete copy[tabId] + return { unreadTerminalTabs: copy } + }) + }, + clearTerminalPaneUnread: (paneKey) => { + set((s) => { + if (!s.unreadTerminalPanes[paneKey] && !s.unreadAgentCompletionPanes[paneKey]) { + return s + } + const nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } + const nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes } + delete nextUnreadTerminalPanes[paneKey] + delete nextUnreadAgentCompletionPanes[paneKey] + return { + unreadTerminalPanes: nextUnreadTerminalPanes, + unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes + } + }) + }, + setTabCustomTitle: (tabId, title, opts) => { + set((s) => { + const next = { ...s.tabsByWorktree } + for (const wId of Object.keys(next)) { + next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, customTitle: title } : t)) + } + scheduleRuntimeGraphSync() + return { tabsByWorktree: next } + }) + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) + if (item) { + get().setTabCustomLabel(item.id, title, opts) + } + }, + setTabColor: (tabId, color) => { + set((s) => { + const next = { ...s.tabsByWorktree } + for (const wId of Object.keys(next)) { + next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, color } : t)) + } + return { tabsByWorktree: next } + }) + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) + if (item) { + get().setUnifiedTabColor(item.id, color) + // Why: tab color is host-authoritative for remote-server tabs; mirror it so it persists instead of reverting on the next snapshot. + const state = get() + const owningWorktreeId = Object.keys(state.unifiedTabsByWorktree).find((wId) => + (state.unifiedTabsByWorktree[wId] ?? []).some((entry) => entry.id === item.id) + ) + if ( + owningWorktreeId && + resolveTerminalWorktreeRoute(state, owningWorktreeId)?.runtimeEnvironmentId + ) { + void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) => + setWebRuntimeTabProps({ worktreeId: owningWorktreeId, tabId: item.id, color }) + ) + } + } + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-tab-close-providers.ts b/src/renderer/src/store/terminals/terminal-tab-close-providers.ts new file mode 100644 index 00000000000..322d4106c7e --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-close-providers.ts @@ -0,0 +1,68 @@ +import type { AppState } from '../types' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' +import { + classifyTerminalRetirementWorktree, + type TerminalTabRetirementPlan +} from '../slices/terminal-tab-retirement' + +export function startTerminalTabProviderRetirement({ + localPtyTeardownOwnedExternally, + remoteCloseOwnedByHost, + retirementPlan, + state, + tabId +}: { + localPtyTeardownOwnedExternally: boolean + remoteCloseOwnedByHost: boolean + retirementPlan: TerminalTabRetirementPlan + state: AppState + tabId: string +}): void { + const fallbackWorktreeRoute = retirementPlan.worktreeId + ? resolveTerminalWorktreeRoute(state, retirementPlan.worktreeId) + : { runtimeEnvironmentId: null } + const retirementTasks: Promise[] = localPtyTeardownOwnedExternally + ? [] + : retirementPlan.localOrSshPtyIds.map(async (ptyId) => window.api.pty.kill(ptyId)) + const localOrSshTaskCount = retirementTasks.length + if (!remoteCloseOwnedByHost) { + for (const terminal of retirementPlan.runtimeTerminals) { + if (!terminal.environmentId && !fallbackWorktreeRoute) { + continue + } + const environmentId = terminal.environmentId ?? fallbackWorktreeRoute?.runtimeEnvironmentId + retirementTasks.push( + callRuntimeRpc( + environmentId ? { kind: 'environment', environmentId } : { kind: 'local' }, + 'terminal.close', + { terminal: terminal.handle } + ) + ) + } + } + if (retirementPlan.unroutablePtyIds.length > 0) { + // Log the worktree shape, never its id, because worktree ids embed absolute paths. + console.warn('[terminal-retirement] skipped PTYs with no resolvable owner', { + tabId, + worktreeKind: classifyTerminalRetirementWorktree(retirementPlan.worktreeId), + count: retirementPlan.unroutablePtyIds.length + }) + } + // Close remains synchronous; provider failures cannot block ownership revocation. + void Promise.allSettled(retirementTasks).then((results) => { + const localOrSshFailures = results + .slice(0, localOrSshTaskCount) + .filter((result) => result.status === 'rejected').length + const runtimeFailures = results + .slice(localOrSshTaskCount) + .filter((result) => result.status === 'rejected').length + if (localOrSshFailures > 0 || runtimeFailures > 0) { + console.warn('[terminal-retirement] provider teardown failed', { + tabId, + localOrSshFailures, + runtimeFailures + }) + } + }) +} diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts new file mode 100644 index 00000000000..cea5f0bc474 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -0,0 +1,266 @@ +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { sweepRetiredTerminalTabState } from '../slices/retired-terminal-tab-state-sweep' +import { + getRecentlyClosedTabPosition, + pushClosedTerminalTabSnapshot, + pushRecentlyClosedTabKind +} from '../slices/recently-closed-tabs' +// Why: use the store-free registry (not terminal-parked-tab-watchers, which imports @/store) to avoid re-entering store creation during this slice's eval. +import { retireParkedTerminalTab } from '@/components/terminal-pane/terminal-parked-watcher-registry' +import { + buildTerminalTabRetirementPlan, + removeSleepingAgentSessionsForTab +} from '../slices/terminal-tab-retirement' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { startTerminalTabProviderRetirement } from './terminal-tab-close-providers' + +export function createTerminalTabCloseActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + closeTab: (tabId, opts) => { + const closeReason = opts?.reason ?? 'user' + const retiresSession = closeReason === 'user' || closeReason === 'cleanup' + const retirementPlan = + opts?.precomputedRetirementPlan?.tabId === tabId + ? opts.precomputedRetirementPlan + : buildTerminalTabRetirementPlan(get(), tabId) + let closingWorktreeId: string | null = null + // Why: a parked tab has no mounted TerminalPane cleanup, so revoke its observer/candidate state before provider exit races. + retireParkedTerminalTab(tabId) + if (retiresSession) { + startTerminalTabProviderRetirement({ + localPtyTeardownOwnedExternally: opts?.localPtyTeardownOwnedExternally === true, + remoteCloseOwnedByHost: opts?.remoteCloseOwnedByHost === true, + retirementPlan, + state: get(), + tabId + }) + } + set((s) => { + const next = { ...s.tabsByWorktree } + let closedTab: TerminalTab | null = null + let closedWorktreeId: string | null = null + for (const wId of Object.keys(next)) { + const before = next[wId] + const closing = before.find((t) => t.id === tabId) + if (closing) { + closingWorktreeId = wId + // Why: capture the first-matched tab's snapshot for the Cmd+Shift+T reopen stack (see capturedSnapshot below). + if (!closedTab) { + closedTab = closing + closedWorktreeId = wId + } + } + const after = before.filter((t) => t.id !== tabId) + if (after.length !== before.length) { + next[wId] = after + } + } + // Why: only explicit user closes feed the Cmd+Shift+T reopen stack; cleanup/PTY-exit closes must not pollute undo history. + const closedPosition = + closedWorktreeId && closedTab + ? getRecentlyClosedTabPosition(s, closedWorktreeId, closedTab.id) + : undefined + const capturedSnapshot = + closeReason === 'user' && + opts?.captureRecentlyClosed !== false && + closedTab && + closedWorktreeId + ? { + ...(closedTab.startupCwd ? { startupCwd: closedTab.startupCwd } : {}), + ...(closedTab.shellOverride ? { shellOverride: closedTab.shellOverride } : {}), + ...(closedTab.customTitle ? { customTitle: closedTab.customTitle } : {}), + ...(closedTab.color ? { color: closedTab.color } : {}), + ...(closedPosition ? { position: closedPosition } : {}) + } + : null + const nextExpanded = { ...s.expandedPaneByTabId } + delete nextExpanded[tabId] + const nextCanExpand = { ...s.canExpandPaneByTabId } + delete nextCanExpand[tabId] + const nextLayouts = { ...s.terminalLayoutsByTabId } + delete nextLayouts[tabId] + const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } + delete nextPtyIdsByTabId[tabId] + const nextLastKnownRelay = { ...s.lastKnownRelayPtyIdByTabId } + delete nextLastKnownRelay[tabId] + const nextDeferredSshSessionIdsByTabId = { ...s.deferredSshSessionIdsByTabId } + delete nextDeferredSshSessionIdsByTabId[tabId] + const nextPendingReconnectPtyIdByTabId = { ...s.pendingReconnectPtyIdByTabId } + delete nextPendingReconnectPtyIdByTabId[tabId] + const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } + delete nextRuntimePaneTitlesByTabId[tabId] + const nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } + delete nextDirectSshPaneRetryByTabId[tabId] + const nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId + } + delete nextDirectSshLivePtyBindingByTabId[tabId] + const nextDirectSshPaneRetryHistoryByTabId = { + ...s.directSshPaneRetryHistoryByTabId + } + delete nextDirectSshPaneRetryHistoryByTabId[tabId] + // Why: keep the same reference when the closing tab had no unread flag, so unrelated closes don't force full-state selector re-eval. + let nextUnreadTerminalTabs = s.unreadTerminalTabs + if (s.unreadTerminalTabs[tabId]) { + nextUnreadTerminalTabs = { ...s.unreadTerminalTabs } + delete nextUnreadTerminalTabs[tabId] + } + let nextUnreadTerminalPanes = s.unreadTerminalPanes + for (const paneKey of Object.keys(s.unreadTerminalPanes)) { + if (paneKey.startsWith(`${tabId}:`)) { + if (nextUnreadTerminalPanes === s.unreadTerminalPanes) { + nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } + } + delete nextUnreadTerminalPanes[paneKey] + } + } + let nextUnreadAgentCompletionPanes = s.unreadAgentCompletionPanes + for (const paneKey of Object.keys(s.unreadAgentCompletionPanes)) { + if (paneKey.startsWith(`${tabId}:`)) { + if (nextUnreadAgentCompletionPanes === s.unreadAgentCompletionPanes) { + nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes } + } + delete nextUnreadAgentCompletionPanes[paneKey] + } + } + const nextLastTerminalInputAtByPaneKey = { ...s.lastTerminalInputAtByPaneKey } + for (const paneKey of Object.keys(nextLastTerminalInputAtByPaneKey)) { + if (paneKey.startsWith(`${tabId}:`)) { + delete nextLastTerminalInputAtByPaneKey[paneKey] + } + } + const nextSleepingAgentSessionsByPaneKey = retiresSession + ? removeSleepingAgentSessionsForTab(s.sleepingAgentSessionsByPaneKey, tabId) + : s.sleepingAgentSessionsByPaneKey + const nextPendingStartupByTabId = { ...s.pendingStartupByTabId } + delete nextPendingStartupByTabId[tabId] + const nextAutomaticAgentResumeClaimsByTabId = { ...s.automaticAgentResumeClaimsByTabId } + delete nextAutomaticAgentResumeClaimsByTabId[tabId] + const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId } + delete nextNativeChatLaunchPromptByTabId[tabId] + const nextNativeChatLaunchDraftByTabId = { ...s.nativeChatLaunchDraftByTabId } + delete nextNativeChatLaunchDraftByTabId[tabId] + const nextPendingInitialCwdByTabId = { ...s.pendingInitialCwdByTabId } + delete nextPendingInitialCwdByTabId[tabId] + const nextPendingSetupSplitByTabId = { ...s.pendingSetupSplitByTabId } + delete nextPendingSetupSplitByTabId[tabId] + const nextPendingIssueCommandSplitByTabId = { ...s.pendingIssueCommandSplitByTabId } + delete nextPendingIssueCommandSplitByTabId[tabId] + const nextCacheTimer = { ...s.cacheTimerByKey } + // Why: cache timer keys are `${tabId}:${leafId}` composites; remove all entries for the closing tab. + for (const key of Object.keys(nextCacheTimer)) { + if (key.startsWith(`${tabId}:`)) { + delete nextCacheTimer[key] + } + } + // Why: keep activeTabIdByWorktree in sync when closing a background-worktree tab, else the stale remembered tab falls back to tabs[0] on switch. + const nextActiveTabIdByWorktree = { ...s.activeTabIdByWorktree } + for (const [wId, tabs] of Object.entries(next)) { + if (nextActiveTabIdByWorktree[wId] === tabId) { + nextActiveTabIdByWorktree[wId] = tabs[0]?.id ?? null + } + } + // Why: keep tabBarOrderByWorktree in sync so stale terminal IDs don't linger and shift positions on later tab operations. + const nextTabBarOrderByWorktree: Record = { + ...s.tabBarOrderByWorktree + } + for (const wId of Object.keys(nextTabBarOrderByWorktree)) { + const order = nextTabBarOrderByWorktree[wId] + if (order?.includes(tabId)) { + nextTabBarOrderByWorktree[wId] = order.filter((entryId) => entryId !== tabId) + } + } + // Why: clean up unconsumed snapshot/cold-restore data (e.g. tab closed before TerminalPane mounted) to prevent unbounded store growth across restarts. + let nextSnapshots = s.pendingSnapshotByPtyId + let nextColdRestores = s.pendingColdRestoreByPtyId + const closingPtyIds = new Set([ + ...retirementPlan.localOrSshPtyIds, + ...retirementPlan.runtimeTerminals.map((terminal) => terminal.ptyId), + ...retirementPlan.cleanupOnlyPtyIds, + ...retirementPlan.unroutablePtyIds + ]) + for (const closingId of closingPtyIds) { + if (closingId in nextSnapshots) { + nextSnapshots = { ...nextSnapshots } + delete nextSnapshots[closingId] + } + if (closingId in nextColdRestores) { + nextColdRestores = { ...nextColdRestores } + delete nextColdRestores[closingId] + } + } + return { + tabsByWorktree: next, + activeTabId: s.activeTabId === tabId ? null : s.activeTabId, + activeTabIdByWorktree: nextActiveTabIdByWorktree, + ptyIdsByTabId: nextPtyIdsByTabId, + lastKnownRelayPtyIdByTabId: nextLastKnownRelay, + deferredSshSessionIdsByTabId: nextDeferredSshSessionIdsByTabId, + pendingReconnectPtyIdByTabId: nextPendingReconnectPtyIdByTabId, + runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, + directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, + directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId, + directSshPaneRetryHistoryByTabId: nextDirectSshPaneRetryHistoryByTabId, + ...(nextSleepingAgentSessionsByPaneKey !== s.sleepingAgentSessionsByPaneKey + ? { sleepingAgentSessionsByPaneKey: nextSleepingAgentSessionsByPaneKey } + : {}), + // Why: skip writing unreadTerminalTabs when unchanged to avoid a no-op state allocation that re-evaluates full-state selectors. Mirrors tabs.ts. + ...(nextUnreadTerminalTabs !== s.unreadTerminalTabs + ? { unreadTerminalTabs: nextUnreadTerminalTabs } + : {}), + ...(nextUnreadTerminalPanes !== s.unreadTerminalPanes + ? { unreadTerminalPanes: nextUnreadTerminalPanes } + : {}), + ...(nextUnreadAgentCompletionPanes !== s.unreadAgentCompletionPanes + ? { unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes } + : {}), + lastTerminalInputAtByPaneKey: nextLastTerminalInputAtByPaneKey, + expandedPaneByTabId: nextExpanded, + canExpandPaneByTabId: nextCanExpand, + terminalLayoutsByTabId: nextLayouts, + pendingStartupByTabId: nextPendingStartupByTabId, + automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, + nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, + nativeChatLaunchDraftByTabId: nextNativeChatLaunchDraftByTabId, + pendingInitialCwdByTabId: nextPendingInitialCwdByTabId, + pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, + pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, + cacheTimerByKey: nextCacheTimer, + tabBarOrderByWorktree: nextTabBarOrderByWorktree, + pendingSnapshotByPtyId: nextSnapshots, + pendingColdRestoreByPtyId: nextColdRestores, + ...(capturedSnapshot && closedWorktreeId + ? { + recentlyClosedTerminalTabsByWorktree: pushClosedTerminalTabSnapshot( + s.recentlyClosedTerminalTabsByWorktree, + closedWorktreeId, + capturedSnapshot + ), + recentlyClosedTabKindsByWorktree: pushRecentlyClosedTabKind( + s.recentlyClosedTabKindsByWorktree, + closedWorktreeId, + 'terminal' + ) + } + : {}) + } + }) + // Why shared with the paired snapshot apply: every path that removes a tab owes it the same sweep, and a second copy of the list is how one path silently misses a new entry. + sweepRetiredTerminalTabState(get(), tabId, closingWorktreeId) + for (const tabs of Object.values(get().unifiedTabsByWorktree)) { + const workspaceItem = tabs.find( + (entry) => entry.contentType === 'terminal' && entry.entityId === tabId + ) + if (workspaceItem) { + get().closeUnifiedTab(workspaceItem.id, { + recordInteraction: opts?.recordInteraction, + terminalRetirementHandled: true + }) + } + } + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts new file mode 100644 index 00000000000..5f69aa19e74 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -0,0 +1,245 @@ +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { isValidHostTerminalTabId } from '../../../../shared/terminal-tab-id' +import { emptyLayoutSnapshot } from '../slices/terminal-helpers' +import { + buildOrphanTerminalCleanupPatch, + getOrphanTerminalIds +} from '../slices/terminal-orphan-helpers' +import { + dedupeTabOrder, + ensureGroup, + findTabByEntityInGroup, + pushRecentTabId, + sanitizeRecentTabIds, + updateGroup +} from '../slices/tab-group-state' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { + getRemoteConnectionIdForWorktree, + resolveCreatedTabShellOverride, + worktreeUsesWslPath +} from './terminal-workspace-routing' +export function getNextTerminalOrdinal(tabs: TerminalTab[]): number { + const usedOrdinals = new Set() + for (const tab of tabs) { + const match = /^Terminal (\d+)$/.exec(tab.defaultTitle ?? tab.title) + if (!match) { + continue + } + usedOrdinals.add(Number(match[1])) + } + let nextOrdinal = 1 + while (usedOrdinals.has(nextOrdinal)) { + nextOrdinal += 1 + } + return nextOrdinal +} + +export function createTerminalTabCreationActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + createTab: (worktreeId, targetGroupId, shellOverride, options) => { + let tab!: TerminalTab + set((s) => { + const orphanTerminalIds = getOrphanTerminalIds(s, worktreeId) + const orphanCleanupPatch = buildOrphanTerminalCleanupPatch(s, worktreeId, orphanTerminalIds) + const existing = (s.tabsByWorktree[worktreeId] ?? []).filter( + (entry) => !orphanTerminalIds.has(entry.id) + ) + // Why: honor a caller-supplied tab id but mint on collision — aliasing two PTYs to one id corrupts agent-status routing. See docs/cli-terminal-hook-pane-key.md. + // Why: only honor a non-empty trimmed hint; useIpcEvents spreads `id` whenever tabId !== undefined, so a stray '' would break paneKey routing. + const trimmedHint = typeof options?.id === 'string' ? options.id.trim() : '' + const hintedId = + trimmedHint.length > 0 && isValidHostTerminalTabId(trimmedHint) ? trimmedHint : undefined + const idCollides = + hintedId !== undefined && + Object.values(s.tabsByWorktree).some((tabs) => + tabs.some((entry) => entry.id === hintedId) + ) + if (idCollides) { + console.warn( + `[createTab] tabId hint ${hintedId} already exists; minting a fresh id (hook attribution will degrade for this terminal)` + ) + } + const id = hintedId !== undefined && !idCollides ? hintedId : createBrowserUuid() + const shouldActivate = options?.activate !== false + const nextOrdinal = getNextTerminalOrdinal(existing) + const defaultTitle = `Terminal ${nextOrdinal}` + const quickCommandLabel = options?.quickCommandLabel?.trim() + const startupCwd = options?.startupCwd + const remoteConnectionId = getRemoteConnectionIdForWorktree(s, worktreeId) + const isRemoteWorktree = Boolean(remoteConnectionId) + const isWslWorktree = worktreeUsesWslPath(s, worktreeId) + const createdShellOverride = resolveCreatedTabShellOverride( + shellOverride, + s.settings?.terminalWindowsShell, + // Why: SSH PTYs ignore local Windows shell selection; a local shell icon would mislabel a remote terminal. + isRemoteWorktree, + remoteConnectionId + ? ((s.sshConnectionStates.get(remoteConnectionId) + ?.remotePlatform as NodeJS.Platform | null) ?? null) + : null, + // Why: new terminals enter the worktree's repo-scoped WSL distro even when the global Windows shell is PowerShell/cmd.exe. + isWslWorktree, + isRemoteWorktree || options?.forceHostRuntime + ? undefined + : getLocalProjectExecutionRuntimeContext(s, worktreeId) + ) + tab = { + id, + // Why: CLI-created background sessions already own a PTY, so reveal attaches instead of spawning a duplicate. + ptyId: options?.initialPtyId ?? null, + worktreeId, + // Why: reuse the lowest free ordinal so a fresh terminal stays "Terminal 1" after older tabs close, not a monotonic counter. + title: defaultTitle, + defaultTitle, + ...(quickCommandLabel ? { quickCommandLabel } : {}), + customTitle: null, + color: null, + sortOrder: existing.length, + createdAt: Date.now(), + ...(createdShellOverride !== undefined ? { shellOverride: createdShellOverride } : {}), + ...(startupCwd && startupCwd.length > 0 ? { startupCwd } : {}), + ...(options?.forceHostRuntime ? { forceHostRuntime: true } : {}), + ...(options?.launchAgent ? { launchAgent: options.launchAgent } : {}), + // Why: mark click-caused (not work-caused) spawns so updateTabPtyId skips the activity/sortEpoch bump that would reorder Recent/Smart on click. + ...(options?.pendingActivationSpawn ? { pendingActivationSpawn: true } : {}) + } + const validTargetGroupId = + targetGroupId && + s.groupsByWorktree[worktreeId]?.some((group) => group.id === targetGroupId) + ? targetGroupId + : undefined + const { group, groupsByWorktree, activeGroupIdByWorktree } = ensureGroup( + s.groupsByWorktree, + s.activeGroupIdByWorktree, + worktreeId, + validTargetGroupId ?? s.activeGroupIdByWorktree[worktreeId] + ) + const nextActiveGroupIdByWorktree = + shouldActivate && validTargetGroupId + ? { ...activeGroupIdByWorktree, [worktreeId]: validTargetGroupId } + : activeGroupIdByWorktree + const existingUnifiedTabs = s.unifiedTabsByWorktree[worktreeId] ?? [] + const existingTerminalTab = findTabByEntityInGroup( + s.unifiedTabsByWorktree, + worktreeId, + group.id, + id, + 'terminal' + ) + const groupsForWorktree = groupsByWorktree[worktreeId] ?? [] + const cleanedGroups = + orphanTerminalIds.size === 0 + ? groupsForWorktree + : groupsForWorktree.map((entry) => { + // Why: repair every group before adding the new tab, or inactive/background creation can revive stale focus. + const tabOrder = dedupeTabOrder(entry.tabOrder).filter( + (tabId) => !orphanTerminalIds.has(tabId) + ) + const recentTabIds = sanitizeRecentTabIds(entry.recentTabIds, tabOrder) + const replacedActiveTabId = Boolean( + entry.activeTabId && orphanTerminalIds.has(entry.activeTabId) + ) + const fallbackActiveTabId = recentTabIds.at(-1) ?? tabOrder[0] ?? null + const activeTabId = replacedActiveTabId ? fallbackActiveTabId : entry.activeTabId + return { + ...entry, + activeTabId, + tabOrder, + recentTabIds: + replacedActiveTabId && activeTabId + ? pushRecentTabId(recentTabIds, activeTabId) + : recentTabIds + } + }) + const cleanedTargetGroup = cleanedGroups.find((entry) => entry.id === group.id) ?? group + const cleanedGroupOrder = dedupeTabOrder(cleanedTargetGroup.tabOrder).filter( + (tabId) => !orphanTerminalIds.has(tabId) + ) + const unifiedTab = existingTerminalTab ?? { + id, + entityId: id, + groupId: group.id, + worktreeId, + contentType: 'terminal' as const, + label: tab.title, + ...(tab.quickCommandLabel?.trim() + ? { quickCommandLabel: tab.quickCommandLabel.trim() } + : {}), + customLabel: tab.customTitle, + color: tab.color, + sortOrder: cleanedGroupOrder.length, + createdAt: tab.createdAt, + // Why: omit for non-agent tabs so they keep the implicit 'terminal' view mode. + ...(options?.viewMode ? { viewMode: options.viewMode } : {}) + } + const nextGroupOrder = dedupeTabOrder([...cleanedGroupOrder, unifiedTab.id]) + const nextRecent = shouldActivate + ? pushRecentTabId(sanitizeRecentTabIds(group.recentTabIds, nextGroupOrder), unifiedTab.id) + : sanitizeRecentTabIds(cleanedTargetGroup.recentTabIds, nextGroupOrder) + const cleanedActiveTabIdForWorktree = orphanCleanupPatch.activeTabIdByWorktree[worktreeId] + const cleanedGroupActiveTabId = + cleanedTargetGroup.activeTabId && !orphanTerminalIds.has(cleanedTargetGroup.activeTabId) + ? cleanedTargetGroup.activeTabId + : null + const nextActiveTabIdForWorktree = shouldActivate + ? tab.id + : (cleanedActiveTabIdForWorktree ?? cleanedGroupActiveTabId ?? tab.id) + return { + ...orphanCleanupPatch, + tabsByWorktree: { + ...orphanCleanupPatch.tabsByWorktree, + [worktreeId]: [...existing, tab] + }, + // Why: publish the unified tab atomically with the runtime tab so a transient legacy mount can't race the split host. + unifiedTabsByWorktree: { + ...s.unifiedTabsByWorktree, + [worktreeId]: existingTerminalTab + ? existingUnifiedTabs + : [...existingUnifiedTabs, unifiedTab] + }, + groupsByWorktree: { + ...groupsByWorktree, + [worktreeId]: updateGroup(cleanedGroups, { + ...cleanedTargetGroup, + activeTabId: shouldActivate + ? unifiedTab.id + : (cleanedGroupActiveTabId ?? unifiedTab.id), + tabOrder: nextGroupOrder, + recentTabIds: nextRecent + }) + }, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree, + layoutByWorktree: { + ...s.layoutByWorktree, + [worktreeId]: s.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: group.id } + }, + activeTabId: shouldActivate ? tab.id : orphanCleanupPatch.activeTabId, + activeTabIdByWorktree: { + ...orphanCleanupPatch.activeTabIdByWorktree, + [worktreeId]: nextActiveTabIdForWorktree + }, + ptyIdsByTabId: { + ...orphanCleanupPatch.ptyIdsByTabId, + [tab.id]: options?.initialPtyId ? [options.initialPtyId] : [] + }, + terminalLayoutsByTabId: { + ...orphanCleanupPatch.terminalLayoutsByTabId, + [tab.id]: emptyLayoutSnapshot() + } + } + }) + const shouldRecordInteraction = + options?.recordInteraction ?? (!options?.pendingActivationSpawn && !options?.initialPtyId) + if (shouldRecordInteraction) { + get().recordFeatureInteraction?.('terminal-tabs') + } + return tab + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-tab-navigation.ts b/src/renderer/src/store/terminals/terminal-tab-navigation.ts new file mode 100644 index 00000000000..e8f33d17e79 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-navigation.ts @@ -0,0 +1,121 @@ +import { resolveActiveTabOwnerWorktreeId } from '../slices/active-tab-owner-worktree' +import { getTerminalTabOwnerWorktreeId } from '../slices/terminal-tab-owner-index' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export function createTerminalTabNavigationActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick< + TerminalSlice, + | 'reorderTabs' + | 'setTabBarOrder' + | 'setActiveTab' + | 'setActiveTabForWorktree' + | 'getTerminalTabOwnerWorktreeId' +> { + return { + reorderTabs: (worktreeId, tabIds) => { + set((s) => { + const tabs = s.tabsByWorktree[worktreeId] ?? [] + const tabMap = new Map(tabs.map((t) => [t.id, t])) + const orderedSet = new Set(tabIds) + const missingTabs = tabs.filter((t) => !orderedSet.has(t.id)) + const reordered = [ + ...tabIds.map((id) => tabMap.get(id)!).filter(Boolean), + ...missingTabs + ].map((tab, i) => ({ ...tab, sortOrder: i })) + return { + tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: reordered } + } + }) + }, + setTabBarOrder: (worktreeId, order) => { + set((s) => { + // Update unified visual order + const newTabBarOrder = { ...s.tabBarOrderByWorktree, [worktreeId]: order } + // Keep terminal tab sortOrder in sync for persistence + const tabs = s.tabsByWorktree[worktreeId] + if (!tabs) { + return { tabBarOrderByWorktree: newTabBarOrder } + } + const tabMap = new Map(tabs.map((t) => [t.id, t])) + // Extract terminal IDs in their new relative order + const terminalIdsInOrder = order.filter((id) => tabMap.has(id)) + const orderedSet = new Set(terminalIdsInOrder) + const missingTabs = tabs.filter((t) => !orderedSet.has(t.id)) + const updatedTabs = [ + ...terminalIdsInOrder.map((id) => tabMap.get(id)!).filter(Boolean), + ...missingTabs + ].map((tab, i) => ({ ...tab, sortOrder: i })) + return { + tabBarOrderByWorktree: newTabBarOrder, + tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: updatedTabs } + } + }) + }, + setActiveTab: (tabId) => { + let tabOwnerWorktreeId: string | null = null + set((s) => { + // Why: clear bells only for visible tabs; background bells must persist. + tabOwnerWorktreeId = resolveActiveTabOwnerWorktreeId( + s.tabsByWorktree, + s.activeWorktreeId, + tabId + ) + const isActiveWorktreeTab = + tabOwnerWorktreeId !== null && tabOwnerWorktreeId === s.activeWorktreeId + const nextUnreadTerminalTabs = + isActiveWorktreeTab && s.unreadTerminalTabs[tabId] + ? (() => { + const copy = { ...s.unreadTerminalTabs } + delete copy[tabId] + return copy + })() + : s.unreadTerminalTabs + // Why: activeTabId marks the visible tab, so background bells remain unread. + return { + activeTabId: isActiveWorktreeTab ? tabId : s.activeTabId, + // Why: a redundant activation must not reallocate this map — Terminal's + // active-terminal repair effect depends on it, so a re-activation that + // can't converge activeTabId (tab id reused by an earlier-scanned + // worktree) would otherwise re-trigger itself into React error #185. + activeTabIdByWorktree: + tabOwnerWorktreeId !== null && s.activeTabIdByWorktree[tabOwnerWorktreeId] !== tabId + ? { ...s.activeTabIdByWorktree, [tabOwnerWorktreeId]: tabId } + : s.activeTabIdByWorktree, + unreadTerminalTabs: nextUnreadTerminalTabs + } + }) + const state = get() + const ownerUnifiedTabs = + tabOwnerWorktreeId !== null && + Object.hasOwn(state.unifiedTabsByWorktree, tabOwnerWorktreeId) + ? state.unifiedTabsByWorktree[tabOwnerWorktreeId] + : [] + // Why: a duplicated entity id must activate the same owner chosen above. + const item = + ownerUnifiedTabs.find( + (entry) => entry.contentType === 'terminal' && entry.entityId === tabId + ) ?? + Object.values(state.unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) + if (item) { + state.activateTab( + item.id, + tabOwnerWorktreeId !== null ? { worktreeId: tabOwnerWorktreeId } : undefined + ) + } + }, + getTerminalTabOwnerWorktreeId: (tabId) => + getTerminalTabOwnerWorktreeId(get().tabsByWorktree, tabId), + setActiveTabForWorktree: (worktreeId, tabId) => { + set((s) => ({ + activeTabIdByWorktree: { + ...s.activeTabIdByWorktree, + [worktreeId]: tabId + } + })) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-tab-presentation.ts b/src/renderer/src/store/terminals/terminal-tab-presentation.ts new file mode 100644 index 00000000000..7e1d2f64442 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-presentation.ts @@ -0,0 +1,203 @@ +import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature' +import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' +import { + applyGeneratedTabTitleUpdates, + applyTerminalTabTitleUpdates +} from '../slices/terminal-tab-title-batch' +import { + adoptTerminalTabOwnerMetadataOnlyBuckets, + getTerminalTabOwnerWorktreeId +} from '../slices/terminal-tab-owner-index' +import { getTabIdFromPaneKey } from './terminal-pty-identities' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export function createTerminalTabPresentationActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick< + TerminalSlice, + | 'updateTabTitle' + | 'updateTabTitles' + | 'setAiVaultTabTitle' + | 'setGeneratedTabTitleFromAgentPrompt' + | 'setGeneratedTabTitlesFromAgentPrompts' + | 'clearTabLaunchAgent' + | 'setRuntimePaneTitle' + | 'clearRuntimePaneTitle' +> { + return { + updateTabTitle: (tabId, title) => { + set((state) => { + const result = applyTerminalTabTitleUpdates(state, [{ tabId, title }]) + if (result.runtimeGraphChanged) { + scheduleRuntimeGraphSync() + } + return result.patch ?? state + }) + }, + updateTabTitles: (updates) => { + if (updates.length === 0) { + return + } + set((state) => { + const result = applyTerminalTabTitleUpdates(state, updates) + if (result.runtimeGraphChanged) { + scheduleRuntimeGraphSync() + } + return result.patch ?? state + }) + }, + setAiVaultTabTitle: (tabId, aiVaultTitle) => { + set((s) => { + const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) + if (!ownerWorktreeId) { + return s + } + const tabs = s.tabsByWorktree[ownerWorktreeId] ?? [] + const current = tabs.find((tab) => tab.id === tabId) + const sameTitle = + current?.aiVaultTitle?.agent === aiVaultTitle?.agent && + current?.aiVaultTitle?.sessionId === aiVaultTitle?.sessionId && + current?.aiVaultTitle?.title === aiVaultTitle?.title + if (!current || sameTitle) { + return s + } + const ownerTabs = tabs.map((tab) => (tab.id === tabId ? { ...tab, aiVaultTitle } : tab)) + const nextTabsByWorktree = { ...s.tabsByWorktree, [ownerWorktreeId]: ownerTabs } + const unifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? [] + const nextUnifiedTabs = unifiedTabs.map((tab) => + tab.contentType === 'terminal' && tab.entityId === tabId ? { ...tab, aiVaultTitle } : tab + ) + adoptTerminalTabOwnerMetadataOnlyBuckets(s.tabsByWorktree, nextTabsByWorktree, [ + ownerWorktreeId + ]) + scheduleRuntimeGraphSync() + return { + tabsByWorktree: nextTabsByWorktree, + unifiedTabsByWorktree: { + ...s.unifiedTabsByWorktree, + [ownerWorktreeId]: nextUnifiedTabs + } + } + }) + }, + setGeneratedTabTitleFromAgentPrompt: (paneKey, prompt, options) => { + // Why: setAgentStatus is high-frequency; skip derive/set unless the feature is on and this tab still needs a (re)generated title. + const state = get() + const tabId = getTabIdFromPaneKey(paneKey) + if (!tabId || prompt.length === 0 || state.settings?.tabAutoGenerateTitle !== true) { + return + } + const ownerWorktreeId = getTerminalTabOwnerWorktreeId(state.tabsByWorktree, tabId) + if (!ownerWorktreeId) { + return + } + const tabs = state.tabsByWorktree[ownerWorktreeId] ?? [] + const currentTab = tabs.find((tab) => tab.id === tabId) + if (!currentTab || currentTab.customTitle?.trim() || currentTab.quickCommandLabel?.trim()) { + return + } + const existingGeneratedTitle = currentTab.generatedTitle?.trim() + if (existingGeneratedTitle && options?.replaceExistingGeneratedTitle !== true) { + return + } + set((latestState) => { + const result = applyGeneratedTabTitleUpdates(latestState, [{ paneKey, prompt, options }]) + if (result.runtimeGraphChanged) { + scheduleRuntimeGraphSync() + } + return result.patch ?? latestState + }) + }, + setGeneratedTabTitlesFromAgentPrompts: (updates) => { + if (updates.length === 0) { + return + } + set((state) => { + const result = applyGeneratedTabTitleUpdates(state, updates) + if (result.runtimeGraphChanged) { + scheduleRuntimeGraphSync() + } + return result.patch ?? state + }) + }, + clearTabLaunchAgent: (tabId) => { + set((s) => { + const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) + if (!ownerWorktreeId) { + return s + } + const tabs = s.tabsByWorktree[ownerWorktreeId] ?? [] + const tabIndex = tabs.findIndex((t) => t.id === tabId) + const currentTab = tabs[tabIndex] + if (!currentTab?.launchAgent) { + return s + } + const { launchAgent: _launchAgent, ...tabWithoutLaunchAgent } = currentTab + void _launchAgent + const nextTabs = [...tabs] + nextTabs[tabIndex] = tabWithoutLaunchAgent + scheduleRuntimeGraphSync() + return { tabsByWorktree: { ...s.tabsByWorktree, [ownerWorktreeId]: nextTabs } } + }) + }, + setRuntimePaneTitle: (tabId, paneId, title) => { + set((s) => { + const currentByPane = s.runtimePaneTitlesByTabId[tabId] ?? {} + const prevTitle = currentByPane[paneId] + if (prevTitle === title) { + return s + } + if (prevTitle && isDecorativeAgentTitleFrameChange(prevTitle, title)) { + return s + } + // Why: re-sort hookless title changes only when their activity classification changes. + const classificationChanged = + classifyTitleActivity(prevTitle ?? '') !== classifyTitleActivity(title) + // Why: skip active-worktree remount side effects and orphaned panes. + const ownerWorktreeId = classificationChanged + ? getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) + : null + const isActive = ownerWorktreeId !== null && ownerWorktreeId === s.activeWorktreeId + const shouldBump = classificationChanged && ownerWorktreeId !== null && !isActive + return { + runtimePaneTitlesByTabId: { + ...s.runtimePaneTitlesByTabId, + [tabId]: { ...currentByPane, [paneId]: title } + }, + ...(shouldBump ? { sortEpoch: s.sortEpoch + 1 } : {}) + } + }) + }, + clearRuntimePaneTitle: (tabId, paneId) => { + set((s) => { + const currentByPane = s.runtimePaneTitlesByTabId[tabId] + if (!currentByPane || !(paneId in currentByPane)) { + return s + } + const prevTitle = currentByPane[paneId] + const nextByPane = { ...currentByPane } + delete nextByPane[paneId] + const next = { ...s.runtimePaneTitlesByTabId } + if (Object.keys(nextByPane).length > 0) { + next[tabId] = nextByPane + } else { + delete next[tabId] + } + // Why: clearing a classified title changes the smart-sort title-heuristic verdict, so it needs a re-sort. See setRuntimePaneTitle. + const hadClassification = classifyTitleActivity(prevTitle ?? '') !== null + // Why: same active-worktree gate as setRuntimePaneTitle — click-driven teardown clears must not re-rank the sidebar; skip when owner is missing (orphaned). + const ownerWorktreeId = hadClassification + ? getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) + : null + const isActive = ownerWorktreeId !== null && ownerWorktreeId === s.activeWorktreeId + const shouldBump = hadClassification && ownerWorktreeId !== null && !isActive + return { + runtimePaneTitlesByTabId: next, + ...(shouldBump ? { sortEpoch: s.sortEpoch + 1 } : {}) + } + }) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-workspace-routing.ts b/src/renderer/src/store/terminals/terminal-workspace-routing.ts new file mode 100644 index 00000000000..9148b9a5f7c --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-workspace-routing.ts @@ -0,0 +1,115 @@ +import type { AppState } from '../types' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { isWslUncPath } from '../../../../shared/wsl-paths' +import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' +import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../../shared/local-windows-terminal-runtime' +import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' +import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' + +export function isWindowsRendererRuntime(): boolean { + return typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows') +} + +export function isAllowedRemoteWindowsTerminalShell(shell: string | undefined): boolean { + return ( + shell === 'powershell.exe' || + shell === 'pwsh.exe' || + shell === 'cmd.exe' || + shell === 'wsl.exe' || + shell === WINDOWS_GIT_BASH_SHELL + ) +} + +export function resolveCreatedTabShellOverride( + explicitShellOverride: string | undefined, + defaultWindowsShell: string | undefined, + isRemoteWorktree: boolean, + remotePlatform: NodeJS.Platform | null, + isWslWorktree: boolean, + projectRuntime: ProjectExecutionRuntimeResolution | undefined +): string | undefined { + if (isRemoteWorktree) { + if (remotePlatform === 'win32' && isAllowedRemoteWindowsTerminalShell(explicitShellOverride)) { + return explicitShellOverride + } + return undefined + } + if (isWindowsRendererRuntime()) { + return resolveLocalWindowsTerminalShellOverrideForTab({ + explicitShellOverride, + defaultWindowsShell, + isWslWorktree, + projectRuntime + }) + } + if (explicitShellOverride !== undefined) { + return explicitShellOverride + } + return undefined +} + +export function worktreeUsesWslPath( + state: Pick, + worktreeId: string +): boolean { + const parsed = parseWorkspaceKey(worktreeId) + if (parsed?.type === 'folder') { + const folderWorkspace = state.folderWorkspaces.find( + (workspace) => workspace.id === parsed.folderWorkspaceId + ) + return folderWorkspace ? isWslUncPath(folderWorkspace.folderPath) : false + } + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((entry) => entry.id === worktreeId) + return worktree ? isWslUncPath(worktree.path) : false +} + +export function worktreeUsesRemoteConnection( + state: Pick, + worktreeId: string +): boolean { + const parsedWorkspaceKey = parseWorkspaceKey(worktreeId) + if (parsedWorkspaceKey?.type === 'folder') { + return Boolean(getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId)) + } + const directRepoId = getRepoIdFromWorktreeId(worktreeId) + const directRepo = state.repos.find((repo) => repo.id === directRepoId) + if (directRepo) { + return Boolean(directRepo.connectionId) + } + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((entry) => entry.id === worktreeId) + const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null + return Boolean(repo?.connectionId) +} + +export function getRemoteConnectionIdForWorktree( + state: Pick, + worktreeId: string +): string | null { + const parsedWorkspaceKey = parseWorkspaceKey(worktreeId) + if (parsedWorkspaceKey?.type === 'folder') { + return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId) ?? null + } + const directRepoId = getRepoIdFromWorktreeId(worktreeId) + const directRepo = state.repos.find((repo) => repo.id === directRepoId) + if (directRepo) { + return directRepo.connectionId?.trim() || null + } + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((entry) => entry.id === worktreeId) + const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null + return repo?.connectionId?.trim() || null +} + +export function resolveTerminalStopRuntimeEnvironmentId( + state: Pick, + worktreeId: string +): string | null { + return getRuntimeEnvironmentIdForWorktree(state, worktreeId) +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts new file mode 100644 index 00000000000..46fb906a9b9 --- /dev/null +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts @@ -0,0 +1,222 @@ +import type { AppState } from '../types' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import { resolveAgentPaneAuthorityKey } from '../slices/agent-pane-authority' +import type { HydrateWorkspaceSessionOptions } from './terminal-contracts' + +export type WorkspaceHydrationPatch = Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'activeWorkspaceKey' + | 'activeWorkspaceExecutionHostId' + | 'activeTabId' + | 'activeTabIdByWorktree' + | 'restoredRuntimeHostIdByWorkspaceSessionKey' + | 'repos' + | 'tabsByWorktree' + | 'worktreesByRepo' + | 'lastVisitedAtByWorktreeId' + | 'defaultTerminalTabsAppliedByWorktreeId' + | 'automaticAgentResumeClaimsByTabId' + | 'sleepingAgentSessionsByPaneKey' + | 'pendingReconnectWorktreeIds' + | 'pendingReconnectTabByWorktree' + | 'pendingReconnectPtyIdByTabId' + | 'everActivatedWorktreeIds' + | 'worktreeNavHistory' + | 'worktreeNavHistoryIndex' + | 'ptyIdsByTabId' + | 'terminalLayoutsByTabId' +> + +export function replaceHydratedRecordKeys( + current: Record, + hydrated: Record, + replaceKeys: ReadonlySet +): Record { + return { + ...Object.fromEntries(Object.entries(current).filter(([key]) => !replaceKeys.has(key))), + ...Object.fromEntries(Object.entries(hydrated).filter(([key]) => replaceKeys.has(key))) + } +} + +export type TerminalLayoutPtyOwnershipTransfer = { + removedLeafId: string + retainedLeafId: string + ptyId: string +} + +export function transferNormalizedTerminalLayoutPtyOwnership( + state: Pick, + tabId: string, + transfers: readonly TerminalLayoutPtyOwnershipTransfer[] +): void { + if (transfers.length === 0) { + return + } + const ptyIdsOwnedByOtherTabs = new Set() + for (const [candidateTabId, layout] of Object.entries(state.terminalLayoutsByTabId)) { + if (candidateTabId === tabId) { + continue + } + for (const ptyId of Object.values(layout.ptyIdsByLeafId ?? {})) { + ptyIdsOwnedByOtherTabs.add(ptyId) + } + } + for (const { removedLeafId, retainedLeafId, ptyId } of transfers) { + if ( + !isTerminalLeafId(removedLeafId) || + !isTerminalLeafId(retainedLeafId) || + ptyIdsOwnedByOtherTabs.has(ptyId) + ) { + continue + } + const fromPaneKey = makePaneKey(tabId, removedLeafId) + const currentOwner = parsePaneKey(resolveAgentPaneAuthorityKey(fromPaneKey)) + if (currentOwner && currentOwner.tabId !== tabId) { + continue + } + state.transferAgentPaneAuthority({ + fromPaneKey, + toPaneKey: makePaneKey(tabId, retainedLeafId), + ptyId + }) + } +} + +export function targetScopedWorkspaceHydrationPatch( + state: AppState, + hydrated: WorkspaceHydrationPatch, + session: WorkspaceSessionState, + options: HydrateWorkspaceSessionOptions +): Partial { + const workspaceKeys = new Set(options.replaceWorkspaceKeys) + const targetTabIds = new Set( + [...workspaceKeys].flatMap((workspaceKey) => [ + ...(state.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id), + ...(session.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) + ]) + ) + const retainedTargetTabIds = new Set( + [...workspaceKeys].flatMap((workspaceKey) => + (hydrated.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) + ) + ) + const deletedTargetTabIds = new Set( + [...workspaceKeys] + .flatMap((workspaceKey) => (state.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id)) + .filter((tabId) => !retainedTargetTabIds.has(tabId)) + ) + const pendingReconnectPtyIdByTabId = replaceHydratedRecordKeys( + state.pendingReconnectPtyIdByTabId, + {}, + targetTabIds + ) + const authority = options.directSshAuthority + if (authority) { + for (const workspaceKey of hydrated.pendingReconnectWorktreeIds) { + if (!workspaceKeys.has(workspaceKey)) { + continue + } + for (const tab of session.tabsByWorktree[workspaceKey] ?? []) { + // Why: rows hydration dropped (invalid id, canonical duplicate) would leak reconnect keys nothing owns. + if (!retainedTargetTabIds.has(tab.id)) { + continue + } + const ptyId = session.remoteSessionIdsByTabId?.[tab.id] ?? tab.ptyId + if (ptyId && parseAppSshPtyId(ptyId)?.connectionId === authority.targetId) { + pendingReconnectPtyIdByTabId[tab.id] = ptyId + } + } + } + } + const activeOutsideScope = + state.activeWorktreeId != null && !workspaceKeys.has(state.activeWorktreeId) + const sleepingAgentSessionsByPaneKey = Object.fromEntries([ + ...Object.entries(state.sleepingAgentSessionsByPaneKey).filter( + ([, record]) => !workspaceKeys.has(record.worktreeId) + ), + ...Object.entries(hydrated.sleepingAgentSessionsByPaneKey).filter(([, record]) => + workspaceKeys.has(record.worktreeId) + ) + ]) + const everActivatedWorktreeIds = new Set(state.everActivatedWorktreeIds) + for (const workspaceKey of hydrated.everActivatedWorktreeIds) { + if (workspaceKeys.has(workspaceKey)) { + everActivatedWorktreeIds.add(workspaceKey) + } + } + return { + activeRepoId: activeOutsideScope ? state.activeRepoId : hydrated.activeRepoId, + activeWorktreeId: activeOutsideScope ? state.activeWorktreeId : hydrated.activeWorktreeId, + activeWorkspaceKey: activeOutsideScope ? state.activeWorkspaceKey : hydrated.activeWorkspaceKey, + activeWorkspaceExecutionHostId: activeOutsideScope + ? state.activeWorkspaceExecutionHostId + : hydrated.activeWorkspaceExecutionHostId, + activeTabId: activeOutsideScope ? state.activeTabId : hydrated.activeTabId, + activeTabIdByWorktree: replaceHydratedRecordKeys( + state.activeTabIdByWorktree, + hydrated.activeTabIdByWorktree, + workspaceKeys + ), + tabsByWorktree: replaceHydratedRecordKeys( + state.tabsByWorktree, + hydrated.tabsByWorktree, + workspaceKeys + ), + lastVisitedAtByWorktreeId: replaceHydratedRecordKeys( + state.lastVisitedAtByWorktreeId, + hydrated.lastVisitedAtByWorktreeId, + workspaceKeys + ), + defaultTerminalTabsAppliedByWorktreeId: replaceHydratedRecordKeys( + state.defaultTerminalTabsAppliedByWorktreeId, + hydrated.defaultTerminalTabsAppliedByWorktreeId, + workspaceKeys + ), + automaticAgentResumeClaimsByTabId: replaceHydratedRecordKeys( + state.automaticAgentResumeClaimsByTabId, + hydrated.automaticAgentResumeClaimsByTabId, + targetTabIds + ), + sleepingAgentSessionsByPaneKey, + pendingReconnectWorktreeIds: [ + ...state.pendingReconnectWorktreeIds.filter((key) => !workspaceKeys.has(key)), + ...hydrated.pendingReconnectWorktreeIds.filter((key) => workspaceKeys.has(key)) + ], + pendingReconnectTabByWorktree: replaceHydratedRecordKeys( + state.pendingReconnectTabByWorktree, + hydrated.pendingReconnectTabByWorktree, + workspaceKeys + ), + pendingReconnectPtyIdByTabId, + everActivatedWorktreeIds, + directSshPaneRetryByTabId: replaceHydratedRecordKeys( + state.directSshPaneRetryByTabId, + {}, + deletedTargetTabIds + ), + directSshLivePtyBindingByTabId: replaceHydratedRecordKeys( + state.directSshLivePtyBindingByTabId, + {}, + deletedTargetTabIds + ), + directSshPaneRetryHistoryByTabId: replaceHydratedRecordKeys( + state.directSshPaneRetryHistoryByTabId, + {}, + deletedTargetTabIds + ), + ptyIdsByTabId: replaceHydratedRecordKeys( + state.ptyIdsByTabId, + hydrated.ptyIdsByTabId, + targetTabIds + ), + terminalLayoutsByTabId: replaceHydratedRecordKeys( + state.terminalLayoutsByTabId, + hydrated.terminalLayoutsByTabId, + targetTabIds + ) + } +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts new file mode 100644 index 00000000000..6d6169a22f4 --- /dev/null +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts @@ -0,0 +1,209 @@ +import type { WorkspaceKey } from '../../../../shared/folder-workspace-types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + folderWorkspaceKey, + parseWorkspaceKey, + worktreeWorkspaceKey +} from '../../../../shared/workspace-scope' +import { buildByIdIndex } from '../slices/worktree-by-id-index' +import { addAdditionalValidWorkspaceKeys } from '@/lib/workspace-session-hydration-keys' +import { + buildValidWorktreeIdsForSessionHydration, + collectPersistedWorktreeIdsForSessionHydration +} from '../slices/degraded-repo-worktree-validity' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { buildRuntimeSessionPlaceholders } from './workspace-terminal-placeholders' +import { + targetScopedWorkspaceHydrationPatch, + transferNormalizedTerminalLayoutPtyOwnership, + type TerminalLayoutPtyOwnershipTransfer, + type WorkspaceHydrationPatch +} from './workspace-terminal-hydration-patch' +import { buildWorkspaceTerminalRowPlan } from './workspace-terminal-row-plan' +import { buildWorkspaceTerminalReconnectPlan } from './workspace-terminal-reconnect-plan' +import { buildWorkspaceTerminalLayoutPlan } from './workspace-terminal-layout-plan' +import { addHydratedSshWorktreePlaceholders } from './workspace-terminal-ssh-placeholders' + +export function createWorkspaceTerminalHydrationActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + hydrateWorkspaceSession: (session, options) => { + const ownershipTransferTabIds = options?.replaceWorkspaceKeys + ? new Set( + options.replaceWorkspaceKeys.flatMap((workspaceKey) => + (session.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) + ) + ) + : null + const ownershipTransfersByTabId = new Map() + set((s) => { + const runtimeSessionPlaceholders = buildRuntimeSessionPlaceholders({ + repos: s.repos, + runtimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {}, + worktreesByRepo: s.worktreesByRepo + }) + const validWorktreeIds = buildValidWorktreeIdsForSessionHydration( + { + repos: runtimeSessionPlaceholders.repos, + worktreesByRepo: runtimeSessionPlaceholders.worktreesByRepo, + detectedWorktreesByRepo: s.detectedWorktreesByRepo + }, + collectPersistedWorktreeIdsForSessionHydration(session) + ) + const knownRepoIds = new Set(runtimeSessionPlaceholders.repos.map((r) => r.id)) + // Why: the Floating Workspace isn't a repo worktree, but its tabs use the normal session pipeline so daemon PTYs survive app restart. + validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) + for (const workspace of s.folderWorkspaces) { + validWorktreeIds.add(folderWorkspaceKey(workspace.id)) + } + addAdditionalValidWorkspaceKeys(validWorktreeIds, options) + const { + canonicalTabIdBySubsumedTabId, + reconnectPtyIdByRetainedTabId, + releasedPtyIdsByTabId, + sleepingAgentSessionsByPaneKey, + tabsByWorktree, + validTabIds + } = buildWorkspaceTerminalRowPlan(session, validWorktreeIds, options) + const fallbackActiveWorktreeId = + !session.activeWorktreeId && + session.activeRepoId && + knownRepoIds.has(session.activeRepoId) + ? (runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.find( + (worktree) => worktree.isMainWorktree + )?.id ?? + runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.[0]?.id ?? + null) + : null + const activeWorktreeId = (() => { + if (session.activeWorktreeId && validWorktreeIds.has(session.activeWorktreeId)) { + return session.activeWorktreeId + } + // Why: a workspace with no tabs is still valid; fall back from the active repo to avoid a blank landing screen when tabs were pruned or never created. + return fallbackActiveWorktreeId + })() + const activeWorkspaceKey: WorkspaceKey | null = + session.activeWorkspaceKey && validWorktreeIds.has(session.activeWorkspaceKey) + ? session.activeWorkspaceKey + : activeWorktreeId + ? parseWorkspaceKey(activeWorktreeId) + ? (activeWorktreeId as WorkspaceKey) + : worktreeWorkspaceKey(activeWorktreeId) + : null + const activeWorkspaceExecutionHostId = + activeWorkspaceKey && session.activeWorkspaceExecutionHostId + ? session.activeWorkspaceExecutionHostId + : null + // Why: follow a subsumed row to the canonical twin that inherited its PTY, else the app + // restarts with no active terminal even though the same session is still mounted. + const restoredActiveTabId = session.activeTabId + ? (canonicalTabIdBySubsumedTabId.get(session.activeTabId) ?? session.activeTabId) + : null + const activeTabId = + restoredActiveTabId && validTabIds.has(restoredActiveTabId) ? restoredActiveTabId : null + const activeRepoId = + session.activeRepoId && + runtimeSessionPlaceholders.repos.some((repo) => repo.id === session.activeRepoId) + ? session.activeRepoId + : null + const { + pendingReconnectPtyIdByTabId, + pendingReconnectTabByWorktree, + pendingReconnectWorktreeIds + } = buildWorkspaceTerminalReconnectPlan({ + reconnectPtyIdByRetainedTabId, + releasedPtyIdsByTabId, + repos: runtimeSessionPlaceholders.repos, + session, + validTabIds, + validWorktreeIds, + worktreesByRepo: runtimeSessionPlaceholders.worktreesByRepo + }) + // Restore per-worktree active tab; validate ids when the map exists, else derive for legacy sessions. + let activeTabIdByWorktree: Record = {} + if (session.activeTabIdByWorktree) { + for (const [wId, tabId] of Object.entries(session.activeTabIdByWorktree)) { + if (!validWorktreeIds.has(wId) || !tabId) { + continue + } + // Why: a subsumed row's canonical twin holds the same PTY, so follow the pointer there + // instead of forgetting which terminal the workspace last focused. + const restored = validTabIds.has(tabId) + ? tabId + : canonicalTabIdBySubsumedTabId.get(tabId) + if (restored && validTabIds.has(restored)) { + activeTabIdByWorktree[wId] = restored + } + } + } else { + // Legacy sessions: best-effort derivation + if (activeWorktreeId && activeTabId) { + activeTabIdByWorktree[activeWorktreeId] = activeTabId + } + for (const [wId, tabs] of Object.entries(tabsByWorktree)) { + if (!activeTabIdByWorktree[wId] && tabs.length > 0) { + activeTabIdByWorktree[wId] = tabs[0].id + } + } + } + const worktreesByRepo = addHydratedSshWorktreePlaceholders( + runtimeSessionPlaceholders.repos, + runtimeSessionPlaceholders.worktreesByRepo, + tabsByWorktree + ) + // Why: record restored active worktrees to avoid suppressing later real activity. + const nextEverActivated = new Set(s.everActivatedWorktreeIds) + if (activeWorktreeId) { + nextEverActivated.add(activeWorktreeId) + } + // Why indexed: the layout map below looks up a tab per persisted layout, and + // re-flattening tabsByWorktree per entry is O(tabs x layouts). + const allTabs = Object.values(tabsByWorktree).flat() + const tabById = buildByIdIndex(allTabs) + const hydrated: WorkspaceHydrationPatch = { + activeRepoId, + activeWorktreeId, + activeWorkspaceKey, + activeWorkspaceExecutionHostId, + activeTabId, + activeTabIdByWorktree, + restoredRuntimeHostIdByWorkspaceSessionKey: + options?.runtimeHostIdByWorkspaceSessionKey ?? {}, + repos: runtimeSessionPlaceholders.repos, + tabsByWorktree, + worktreesByRepo, + // Why: restore the focus-recency map; pruning is deferred to App.tsx (post-hydration) because SSH worktrees may still be appearing in worktreesByRepo. + lastVisitedAtByWorktreeId: session.lastVisitedAtByWorktreeId ?? {}, + defaultTerminalTabsAppliedByWorktreeId: + session.defaultTerminalTabsAppliedByWorktreeId ?? {}, + automaticAgentResumeClaimsByTabId: {}, + sleepingAgentSessionsByPaneKey, + pendingReconnectWorktreeIds, + pendingReconnectTabByWorktree, + pendingReconnectPtyIdByTabId, + everActivatedWorktreeIds: nextEverActivated, + // Why: seed hydrated active worktrees so the first activation has a Back target. + worktreeNavHistory: activeWorktreeId ? [activeWorktreeId] : [], + worktreeNavHistoryIndex: activeWorktreeId ? 0 : -1, + ptyIdsByTabId: Object.fromEntries(allTabs.map((tab) => [tab.id, []] as const)), + terminalLayoutsByTabId: buildWorkspaceTerminalLayoutPlan({ + ownershipTransfersByTabId, + ownershipTransferTabIds, + releasedPtyIdsByTabId, + session, + tabById, + validTabIds + }) + } + return options?.replaceWorkspaceKeys + ? targetScopedWorkspaceHydrationPatch(s, hydrated, session, options) + : hydrated + }) + for (const [tabId, transfers] of ownershipTransfersByTabId) { + transferNormalizedTerminalLayoutPtyOwnership(get(), tabId, transfers) + } + } + } +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-layout-plan.ts b/src/renderer/src/store/terminals/workspace-terminal-layout-plan.ts new file mode 100644 index 00000000000..a97af259580 --- /dev/null +++ b/src/renderer/src/store/terminals/workspace-terminal-layout-plan.ts @@ -0,0 +1,58 @@ +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { releaseTerminalLayoutPtyIds } from '../slices/terminal-session-row-hydration' +import { + normalizeTerminalLayoutSnapshot, + resolvePtyBoundActiveLeafId +} from '@/components/terminal-pane/terminal-layout-leaf-ids' +import { resolveTerminalLayoutPtyOwnershipTransfers } from '@/components/terminal-pane/terminal-layout-pty-ownership' +import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' +import type { TerminalLayoutPtyOwnershipTransfer } from './workspace-terminal-hydration-patch' + +export function buildWorkspaceTerminalLayoutPlan({ + ownershipTransfersByTabId, + ownershipTransferTabIds, + releasedPtyIdsByTabId, + session, + tabById, + validTabIds +}: { + ownershipTransfersByTabId: Map + ownershipTransferTabIds: ReadonlySet | null + releasedPtyIdsByTabId: ReadonlyMap> + session: WorkspaceSessionState + tabById: ReadonlyMap + validTabIds: ReadonlySet +}): Record { + return Object.fromEntries( + Object.entries(session.terminalLayoutsByTabId) + .filter(([tabId]) => validTabIds.has(tabId)) + .map(([tabId, persisted]) => { + const releasedPtyIds = releasedPtyIdsByTabId.get(tabId) + const layout = releasedPtyIds + ? releaseTerminalLayoutPtyIds(persisted, releasedPtyIds) + : persisted + const normalization = normalizeTerminalLayoutSnapshot(layout) + const normalized = normalization.snapshot + if ( + normalization.changed && + (!ownershipTransferTabIds || ownershipTransferTabIds.has(tabId)) + ) { + ownershipTransfersByTabId.set( + tabId, + resolveTerminalLayoutPtyOwnershipTransfers(layout, normalized) + ) + } + const tab = tabById.get(tabId) + const sanitized = tab ? sanitizeTerminalLayoutPaneTitles(normalized, tab) : normalized + const activeLeafId = sanitized.root + ? resolvePtyBoundActiveLeafId({ + root: sanitized.root, + activeLeafId: sanitized.activeLeafId, + ptyIdsByLeafId: sanitized.ptyIdsByLeafId + }) + : sanitized.activeLeafId + return [tabId, { ...sanitized, activeLeafId }] + }) + ) +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-placeholders.ts b/src/renderer/src/store/terminals/workspace-terminal-placeholders.ts new file mode 100644 index 00000000000..e83adf8b937 --- /dev/null +++ b/src/renderer/src/store/terminals/workspace-terminal-placeholders.ts @@ -0,0 +1,91 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { DEFAULT_REPO_BADGE_COLOR } from '../../../../shared/constants' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' + +export function getPathDisplayName(path: string, fallback: string): string { + const normalized = path.trim().replace(/[\\/]+$/g, '') + const basename = normalized.split(/[\\/]/).findLast(Boolean)?.trim() + return basename || fallback +} + +export function buildRuntimeSessionPlaceholders({ + repos, + runtimeHostIdByWorkspaceSessionKey, + worktreesByRepo +}: { + repos: readonly Repo[] + runtimeHostIdByWorkspaceSessionKey: Record + worktreesByRepo: Record +}): { + repos: Repo[] + worktreesByRepo: Record +} { + let nextRepos = repos.slice() + let nextWorktreesByRepo = worktreesByRepo + for (const workspaceSessionKey of Object.keys(runtimeHostIdByWorkspaceSessionKey)) { + const hostId = runtimeHostIdByWorkspaceSessionKey[workspaceSessionKey] + if (parseExecutionHostId(hostId)?.kind !== 'runtime') { + continue + } + const workspaceScope = parseWorkspaceKey(workspaceSessionKey) + if (workspaceScope?.type === 'folder') { + continue + } + const worktreeId = + workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : workspaceSessionKey + // Why: strip the synthetic `::workspace:` suffix so path is the real folder — Git callers must not spawn against a nonexistent cwd. + const parsed = splitWorktreeIdForFilesystem(worktreeId) + if (!parsed) { + continue + } + const existingRepo = nextRepos.some((repo) => repo.id === parsed.repoId) + if (!existingRepo) { + // Why: remote catalogs load after hydration but host-split session writes need owner metadata; skip if the repo id already exists to avoid duplicates. + nextRepos = [ + ...nextRepos, + { + id: parsed.repoId, + path: parsed.worktreePath, + displayName: getPathDisplayName(parsed.worktreePath, parsed.repoId), + badgeColor: DEFAULT_REPO_BADGE_COLOR, + addedAt: 0, + connectionId: null, + executionHostId: hostId + } + ] + } + const current = nextWorktreesByRepo[parsed.repoId] ?? [] + if (current.some((worktree) => worktree.id === worktreeId)) { + continue + } + const placeholder: Worktree = { + id: worktreeId, + repoId: parsed.repoId, + hostId, + displayName: getPathDisplayName(parsed.worktreePath, parsed.repoId), + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + path: parsed.worktreePath, + head: '', + branch: '', + isBare: false, + isMainWorktree: false + } + nextWorktreesByRepo = + nextWorktreesByRepo === worktreesByRepo ? { ...worktreesByRepo } : nextWorktreesByRepo + nextWorktreesByRepo[parsed.repoId] = [...current, placeholder] + } + return { repos: nextRepos, worktreesByRepo: nextWorktreesByRepo } +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-reconnect-plan.ts b/src/renderer/src/store/terminals/workspace-terminal-reconnect-plan.ts new file mode 100644 index 00000000000..86a7d9b6b31 --- /dev/null +++ b/src/renderer/src/store/terminals/workspace-terminal-reconnect-plan.ts @@ -0,0 +1,88 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { buildByIdIndex, buildWorktreeByIdIndex } from '../slices/worktree-by-id-index' + +export type WorkspaceTerminalReconnectPlan = { + pendingReconnectPtyIdByTabId: Record + pendingReconnectTabByWorktree: Record + pendingReconnectWorktreeIds: string[] +} + +export function buildWorkspaceTerminalReconnectPlan({ + reconnectPtyIdByRetainedTabId, + releasedPtyIdsByTabId, + repos, + session, + validTabIds, + validWorktreeIds, + worktreesByRepo +}: { + reconnectPtyIdByRetainedTabId: ReadonlyMap + releasedPtyIdsByTabId: ReadonlyMap> + repos: readonly Repo[] + session: WorkspaceSessionState + validTabIds: ReadonlySet + validWorktreeIds: ReadonlySet + worktreesByRepo: Record +}): WorkspaceTerminalReconnectPlan { + // The shutdown list is authoritative when present; PTY ids are wake hints, not activity state. + const shutdownIds = + session.activeWorktreeIdsOnShutdown ?? + Object.entries(session.tabsByWorktree) + .filter(([, tabs]) => tabs.some((tab) => tab.ptyId)) + .map(([worktreeId]) => worktreeId) + const pendingReconnectWorktreeIds = shutdownIds.filter((id) => validWorktreeIds.has(id)) + const remoteSessionIds = session.remoteSessionIdsByTabId ?? {} + const pendingReconnectTabByWorktree: Record = {} + for (const worktreeId of pendingReconnectWorktreeIds) { + const liveTabIds = (session.tabsByWorktree[worktreeId] ?? []) + .filter( + (tab) => + (tab.ptyId || remoteSessionIds[tab.id] || reconnectPtyIdByRetainedTabId.has(tab.id)) && + validTabIds.has(tab.id) + ) + .map((tab) => tab.id) + if (liveTabIds.length > 0) { + pendingReconnectTabByWorktree[worktreeId] = liveTabIds + } + } + + const pendingReconnectPtyIdByTabId: Record = {} + const worktreeById = buildWorktreeByIdIndex(worktreesByRepo) + const repoById = buildByIdIndex(repos) + for (const worktreeId of pendingReconnectWorktreeIds) { + const worktree = worktreeById.get(worktreeId) + const repo = worktree ? repoById.get(worktree.repoId) : null + // SSH sessions reconnect through their relay rather than the local daemon. + if (repo?.connectionId) { + continue + } + for (const tab of session.tabsByWorktree[worktreeId] ?? []) { + if ( + tab.ptyId && + validTabIds.has(tab.id) && + !releasedPtyIdsByTabId.get(tab.id)?.has(tab.ptyId) + ) { + pendingReconnectPtyIdByTabId[tab.id] = tab.ptyId + } + } + } + for (const [tabId, sessionId] of Object.entries(remoteSessionIds)) { + if (validTabIds.has(tabId) && !releasedPtyIdsByTabId.get(tabId)?.has(sessionId)) { + pendingReconnectPtyIdByTabId[tabId] = sessionId + } + } + // Retained split rows need an owned leaf PTY anchor until their pane remounts. + for (const [tabId, ptyId] of reconnectPtyIdByRetainedTabId) { + if (validTabIds.has(tabId) && !pendingReconnectPtyIdByTabId[tabId]) { + pendingReconnectPtyIdByTabId[tabId] = ptyId + } + } + + return { + pendingReconnectPtyIdByTabId, + pendingReconnectTabByWorktree, + pendingReconnectWorktreeIds + } +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts new file mode 100644 index 00000000000..5b1e0d39f93 --- /dev/null +++ b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts @@ -0,0 +1,195 @@ +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { buildByIdIndex, buildWorktreeByIdIndex } from '../slices/worktree-by-id-index' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { isCurrentDirectSshAuthority } from './terminal-pty-identities' + +export function createWorkspaceTerminalReconnectActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + reconnectPersistedTerminals: async (signal, options) => { + if ( + signal?.aborted || + (options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority)) + ) { + return + } + const { + pendingReconnectWorktreeIds, + pendingReconnectTabByWorktree, + pendingReconnectPtyIdByTabId, + terminalLayoutsByTabId, + tabsByWorktree, + ptyIdsByTabId + } = get() + const scopedWorkspaceKeys = options ? new Set(options.workspaceKeys) : null + const ids = (pendingReconnectWorktreeIds ?? []).filter( + (id) => !scopedWorkspaceKeys || scopedWorkspaceKeys.has(id) + ) + if (ids.length === 0) { + if (options) { + return + } + set({ + workspaceSessionReady: true, + pendingReconnectWorktreeIds: [], + pendingReconnectTabByWorktree: {}, + pendingReconnectPtyIdByTabId: {} + }) + return + } + // Why: defer daemon attachment for real dimensions; eager 80×24 flushes garble output. + let reconnectedTabsByWorktree: Record | null = null + let reconnectedPtyIdsByTabId: Record | null = null + // Why indexed: the loop neither sets state nor awaits, so one index over the + // whole store snapshot serves every iteration. + const worktreeById = buildWorktreeByIdIndex(get().worktreesByRepo) + const repoById = buildByIdIndex(get().repos) + for (const worktreeId of ids) { + const tabs = tabsByWorktree[worktreeId] ?? [] + const worktree = worktreeById.get(worktreeId) + const repo = worktree ? (repoById.get(worktree.repoId) ?? null) : null + // Why: only allow deferred reattach when the SSH connection is active; reattaching to a not-yet-connected relay (deferred/passphrase targets) would fail. + const sshTargetId = options?.directSshAuthority.targetId ?? repo?.connectionId ?? null + const sshState = sshTargetId ? get().sshConnectionStates.get(sshTargetId) : null + const sshConnected = sshTargetId != null && sshState?.status === 'connected' + const supportsDeferredReattach = options + ? sshConnected + : !repo?.connectionId || sshConnected + console.debug( + `[reconnect-terminals] worktree=${worktreeId} connectionId=${repo?.connectionId} sshStatus=${sshState?.status} supportsDeferredReattach=${supportsDeferredReattach}` + ) + const targetTabIds = pendingReconnectTabByWorktree[worktreeId] ?? [] + const tabsToReconnect: TerminalTab[] = + targetTabIds.length > 0 + ? targetTabIds + .map((id) => tabs.find((t) => t.id === id)) + .filter((t): t is TerminalTab => t != null) + : tabs.slice(0, 1) + if (tabsToReconnect.length === 0) { + continue + } + for (const tab of tabsToReconnect) { + const tabId = tab.id + const layout = terminalLayoutsByTabId[tabId] + const leafPtyMap = layout?.ptyIdsByLeafId ?? {} + const pendingPtyId = pendingReconnectPtyIdByTabId[tabId] + const tabLevelPtyId = + options && + parseAppSshPtyId(pendingPtyId ?? '')?.connectionId !== + options.directSshAuthority.targetId + ? undefined + : pendingPtyId + const hasLeafMappings = Object.keys(leafPtyMap).length > 0 + // Why: publish live PTY hints before mount; pty-connection reattaches later. + console.debug( + `[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}` + ) + // Why: populate ptyIdsByTabId so the sessions status segment maps daemon IDs to tabs; otherwise all sessions look like orphans until the pane mounts. + // A row whose tab.ptyId went to the canonical row has no tab-level id left, but its own leaf PTYs still need advertising. + const allPtyIds = hasLeafMappings + ? (Object.values(leafPtyMap).filter(Boolean) as string[]) + : tabLevelPtyId + ? [tabLevelPtyId] + : [] + if (allPtyIds.length > 0) { + // Why: hide-sleeping reads ptyIdsByTabId for liveness; restored daemon sessions run before their pane remounts, so advertise them. + reconnectedPtyIdsByTabId ??= { ...ptyIdsByTabId } + reconnectedPtyIdsByTabId[tabId] = allPtyIds + } + if (tabLevelPtyId) { + reconnectedTabsByWorktree ??= { ...tabsByWorktree } + const nextTabs = reconnectedTabsByWorktree[worktreeId] + if (!nextTabs) { + continue + } + reconnectedTabsByWorktree[worktreeId] = nextTabs.map((t) => + t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t + ) + } + } + } + // Why: keep deferred SSH session IDs for post-cleanup reconnect. + const scopedTabIds = new Set( + [...(scopedWorkspaceKeys ?? ids)].flatMap((workspaceKey) => + (tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) + ) + ) + const deferredSshSessionIdsByTabId: Record = options + ? Object.fromEntries( + Object.entries(get().deferredSshSessionIdsByTabId).filter( + ([tabId]) => !scopedTabIds.has(tabId) + ) + ) + : {} + for (const worktreeId of ids) { + const worktree = worktreeById.get(worktreeId) + // Why: SSH worktrees aren't in worktreesByRepo at cold start; fall back to the repo id in the composite worktree id so sessions still reach the deferred map. + const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) + const repo = repoId ? (repoById.get(repoId) ?? null) : null + const connectionId = options?.directSshAuthority.targetId ?? repo?.connectionId + if (!connectionId) { + continue + } + // Why: a repo can outlive its SSH target when the target was removed out of + // band (a crash between removal and cleanup, or edited out of the config). + // Once the authoritative target list has loaded, don't re-defer sessions for + // a target it no longer lists — a stranded deferred id reads as liveness and + // the orphan sweep could never remove the dead tab. Defer while the list is + // still unknown so a normal cold-start reconnect isn't dropped (#9911). + if (get().sshTargetsHydrated && !get().sshTargetLabels.has(connectionId)) { + continue + } + const sshConnected = get().sshConnectionStates.get(connectionId)?.status === 'connected' + if (sshConnected) { + continue + } + const tabs = tabsByWorktree[worktreeId] ?? [] + for (const tab of tabs) { + const sessionId = pendingReconnectPtyIdByTabId[tab.id] + if ( + sessionId && + (!options || parseAppSshPtyId(sessionId)?.connectionId === connectionId) + ) { + deferredSshSessionIdsByTabId[tab.id] = sessionId + } + } + } + if ( + signal?.aborted || + (options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority)) + ) { + return + } + const remainingReconnectWorktreeIds = options + ? pendingReconnectWorktreeIds.filter((id) => !scopedWorkspaceKeys?.has(id)) + : [] + const remainingReconnectTabByWorktree = options + ? Object.fromEntries( + Object.entries(pendingReconnectTabByWorktree).filter( + ([workspaceKey]) => !scopedWorkspaceKeys?.has(workspaceKey) + ) + ) + : {} + const remainingReconnectPtyIdByTabId = options + ? Object.fromEntries( + Object.entries(pendingReconnectPtyIdByTabId).filter( + ([tabId]) => !scopedTabIds.has(tabId) + ) + ) + : {} + set({ + ...(reconnectedTabsByWorktree ? { tabsByWorktree: reconnectedTabsByWorktree } : {}), + ...(reconnectedPtyIdsByTabId ? { ptyIdsByTabId: reconnectedPtyIdsByTabId } : {}), + ...(options ? {} : { workspaceSessionReady: true }), + pendingReconnectWorktreeIds: remainingReconnectWorktreeIds, + pendingReconnectTabByWorktree: remainingReconnectTabByWorktree, + pendingReconnectPtyIdByTabId: remainingReconnectPtyIdByTabId, + deferredSshSessionIdsByTabId + }) + } + } +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-row-plan.ts b/src/renderer/src/store/terminals/workspace-terminal-row-plan.ts new file mode 100644 index 00000000000..fc565f3a3b7 --- /dev/null +++ b/src/renderer/src/store/terminals/workspace-terminal-row-plan.ts @@ -0,0 +1,101 @@ +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' +import { + collectReleasedLeafIds, + hydrateWorkspaceTerminalRows +} from '../slices/terminal-session-row-hydration' +import { removeSleepingAgentSessionsForTab } from '../slices/terminal-tab-retirement' +import type { HydrateWorkspaceSessionOptions } from './terminal-contracts' + +export type WorkspaceTerminalRowPlan = { + canonicalTabIdBySubsumedTabId: Map + reconnectPtyIdByRetainedTabId: Map + releasedPtyIdsByTabId: Map> + sleepingAgentSessionsByPaneKey: Record + tabsByWorktree: Record + validTabIds: Set +} + +export function buildWorkspaceTerminalRowPlan( + session: WorkspaceSessionState, + validWorktreeIds: ReadonlySet, + options?: HydrateWorkspaceSessionOptions +): WorkspaceTerminalRowPlan { + // Remote wire rows are authoritative independently of locally persisted unified tabs. + const remoteSnapshotWorkspaceKeys = new Set( + options?.directSshAuthority ? (options.replaceWorkspaceKeys ?? []) : [] + ) + const rowHydrationByWorktree = Object.entries(session.tabsByWorktree) + .filter(([worktreeId]) => validWorktreeIds.has(worktreeId)) + .map( + ([worktreeId, tabs]) => + [ + worktreeId, + hydrateWorkspaceTerminalRows(session, worktreeId, tabs, { + rowsFromRemoteSnapshot: remoteSnapshotWorkspaceKeys.has(worktreeId) + }) + ] as const + ) + const tabsByWorktree: Record = Object.fromEntries( + rowHydrationByWorktree + .map(([worktreeId, hydration]) => [worktreeId, hydration.rows] as const) + .filter( + ([worktreeId, tabs]) => tabs.length > 0 || session.tabsByWorktree[worktreeId]?.length === 0 + ) + ) + const releasedPtyIdsByTabId = new Map>( + rowHydrationByWorktree.flatMap(([, hydration]) => [...hydration.releasedPtyIdsByTabId]) + ) + const reconnectPtyIdByRetainedTabId = new Map( + rowHydrationByWorktree.flatMap(([, hydration]) => [...hydration.reconnectPtyIdByRetainedTabId]) + ) + const canonicalTabIdBySubsumedTabId = new Map( + rowHydrationByWorktree.flatMap(([, hydration]) => [...hydration.canonicalTabIdBySubsumedTabId]) + ) + const validTabIds = new Set( + Object.values(tabsByWorktree) + .flat() + .map((tab) => tab.id) + ) + let sleepingAgentSessionsByPaneKey = Object.fromEntries( + Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter(([, record]) => + validWorktreeIds.has(record.worktreeId) + ) + ) + + // Dropped rows never reach retirement, so remove their otherwise-stranded sleeping records. + for (const [, hydration] of rowHydrationByWorktree) { + for (const tabId of [...hydration.subsumedTabIds, ...hydration.invalidTabIds]) { + sleepingAgentSessionsByPaneKey = removeSleepingAgentSessionsForTab( + sleepingAgentSessionsByPaneKey, + tabId + ) + } + } + // Released leaves transferred to canonical rows and must not retain a duplicate sleeping owner. + const releasedPaneKeys = new Set( + [...releasedPtyIdsByTabId].flatMap(([tabId, releasedPtyIds]) => + collectReleasedLeafIds(session.terminalLayoutsByTabId[tabId], releasedPtyIds) + .filter(isTerminalLeafId) + .map((leafId) => makePaneKey(tabId, leafId)) + ) + ) + if (releasedPaneKeys.size > 0) { + sleepingAgentSessionsByPaneKey = Object.fromEntries( + Object.entries(sleepingAgentSessionsByPaneKey).filter( + ([paneKey]) => !releasedPaneKeys.has(paneKey) + ) + ) + } + + return { + canonicalTabIdBySubsumedTabId, + reconnectPtyIdByRetainedTabId, + releasedPtyIdsByTabId, + sleepingAgentSessionsByPaneKey, + tabsByWorktree, + validTabIds + } +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-ssh-placeholders.ts b/src/renderer/src/store/terminals/workspace-terminal-ssh-placeholders.ts new file mode 100644 index 00000000000..3012b017e15 --- /dev/null +++ b/src/renderer/src/store/terminals/workspace-terminal-ssh-placeholders.ts @@ -0,0 +1,51 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { + getRepoIdFromWorktreeId, + splitWorktreeIdForFilesystem +} from '../../../../shared/worktree/id' + +export function addHydratedSshWorktreePlaceholders( + repos: readonly Repo[], + sourceWorktreesByRepo: Record, + tabsByWorktree: Record +): Record { + const sshRepoIds = new Set(repos.filter((repo) => repo.connectionId).map((repo) => repo.id)) + const worktreesByRepo = { ...sourceWorktreesByRepo } + for (const worktreeId of Object.keys(tabsByWorktree)) { + const repoId = getRepoIdFromWorktreeId(worktreeId) + if (!sshRepoIds.has(repoId)) { + continue + } + if ((worktreesByRepo[repoId] ?? []).some((worktree) => worktree.id === worktreeId)) { + continue + } + // Strip synthetic folder suffixes so the placeholder path remains a valid cwd. + const path = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? '' + const displayName = path.split(/[/\\]/).pop() || path + const placeholder: Worktree = { + id: worktreeId, + repoId, + displayName, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + path, + head: '', + branch: '', + isBare: false, + isMainWorktree: false + } + worktreesByRepo[repoId] = [...(worktreesByRepo[repoId] ?? []), placeholder] + } + return worktreesByRepo +} diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index a8d38c67342..214c6017eda 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -1,7 +1,7 @@ import type { RepoSlice } from './slices/repos' import type { SparsePresetsSlice } from './slices/sparse-presets' import type { WorktreeSlice } from './slices/worktrees' -import type { TerminalSlice } from './slices/terminals' +import type { TerminalSlice } from './terminals/terminal-state' import type { TabsSlice } from './slices/tabs' import type { UISlice } from './slices/ui' import type { SettingsSlice } from './slices/settings' From 2c77f71c4f2ce2bb00a8c61cf65e7f9442213680 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:45:23 -0700 Subject: [PATCH 32/65] refactor(renderer): split IPC event bridges (#16185) * refactor(renderer): split IPC event bridges * test(renderer): follow extracted IPC shortcut bridge --- config/max-lines-baseline.txt | 1 - .../app-shell/use-app-session-persistence.ts | 4 +- .../ipc-events/agent-dashboard-command.ts | 30 + .../ipc-events/agent-status-bridge-types.ts | 36 + .../agent-status-event-applicator.ts | 295 ++ .../ipc-events/agent-status-ipc-bridge.ts | 284 ++ .../ipc-events/agent-status-listeners.ts | 129 + .../agent-status-pane-routing-index.ts | 153 + .../hooks/ipc-events/agent-status-routing.ts | 217 + .../ipc-events/app-lifetime-ipc-bridge.ts | 122 + ...browser-automation-bootstrap-lease.test.ts | 63 + .../browser-automation-bootstrap-lease.ts | 75 + .../ipc-events/browser-request-ipc-bridge.ts | 242 + .../ipc-events/browser-session-tab-target.ts | 25 + .../ipc-events/browser-state-ipc-bridge.ts | 83 + .../ipc-events/content-creation-ipc-bridge.ts | 133 + .../ipc-events/direct-ssh-bridge-runtime.ts | 199 + .../ipc-events/direct-ssh-state-ipc-bridge.ts | 296 ++ .../ipc-events/mobile-driver-ipc-bridge.ts | 139 + .../mobile-terminal-close-ipc-bridge.ts | 116 + .../hooks/ipc-events/new-workspace-command.ts | 36 + .../normalize-agent-status-event.ts | 25 + .../ipc-events/project-catalog-ipc-bridge.ts | 101 + .../hooks/ipc-events/rate-limit-ipc-bridge.ts | 30 + .../ipc-events/remote-workspace-ipc-bridge.ts | 50 + .../ipc-events/runtime-client-ipc-bridge.ts | 199 + ...time-environment-subscription-selection.ts | 203 + .../ipc-events/session-tab-ipc-bridge.ts | 108 + .../ipc-events/settings-sidebar-ipc-bridge.ts | 190 + .../ipc-events/tab-lifecycle-ipc-bridge.ts | 180 + .../ipc-events/terminal-command-state.ts | 132 + .../terminal-presentation-ipc-bridge.ts | 272 + .../ipc-events/terminal-request-ipc-bridge.ts | 152 + .../terminal-ui-routing-ipc-bridge.ts | 95 + .../updater-status-ipc-bridge.test.ts | 62 + .../ipc-events/updater-status-ipc-bridge.ts | 21 + .../workspace-shortcut-ipc-bridge.ts | 136 + .../ipc-events/worktree-event-runtime.ts | 165 + .../src/hooks/ipc-events/zoom-ipc-bridge.ts | 48 + .../src/hooks/remote-workspace-target-sync.ts | 1 - ...IpcEvents-agent-dashboard-shortcut.test.ts | 2 +- .../useIpcEvents-browser-navigation.test.ts | 2 +- .../src/hooks/useIpcEvents-lifecycle.test.ts | 502 ++ ...seIpcEvents-new-workspace-shortcut.test.ts | 5 +- ...ents-runtime-environment-selectors.test.ts | 4 +- .../hooks/useIpcEvents-zoom-routing.test.ts | 12 +- src/renderer/src/hooks/useIpcEvents.ts | 4455 +---------------- .../workspace-activation-path-gate.test.ts | 5 +- 48 files changed, 5372 insertions(+), 4463 deletions(-) create mode 100644 src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-bridge-types.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-listeners.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-routing.ts create mode 100644 src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-session-tab-target.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/content-creation-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts create mode 100644 src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/mobile-driver-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/new-workspace-command.ts create mode 100644 src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts create mode 100644 src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/rate-limit-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts create mode 100644 src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/terminal-command-state.ts create mode 100644 src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts create mode 100644 src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts create mode 100644 src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 67f8212e06d..43aa99cf788 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -124,7 +124,6 @@ inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts inline src/renderer/src/hooks/useAutomationDispatchEvents.ts inline src/renderer/src/hooks/useComposerState.ts inline src/renderer/src/hooks/useEditorExternalWatch.ts -inline src/renderer/src/hooks/useIpcEvents.ts inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index aed0685dbb7..5047feac381 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import { translate } from '@/i18n/i18n' import { useAppStore } from '../store' -import { isRemoteWorkspaceSnapshotApplyInProgress } from '../hooks/useIpcEvents' +import { isDirectSshRemoteWorkspaceApplyInProgress } from '../hooks/remote-workspace-snapshot-apply' import { createSessionWriteSubscriber } from '../lib/session-write-subscriber' import { buildActiveViewUnloadPatch } from '../lib/active-view-persist' import { @@ -76,7 +76,7 @@ export function useAppSessionPersistence(): void { useEffect(() => { return createSessionWriteSubscriber({ store: useAppStore, - shouldSchedulePersist: () => !isRemoteWorkspaceSnapshotApplyInProgress(), + shouldSchedulePersist: () => !isDirectSshRemoteWorkspaceApplyInProgress(), persist: ({ patch }) => { const state = useAppStore.getState() // Why: route each host's worktree-scoped slice to its own partition; return the local write so the remote-workspace upload chain below keeps its ordering. diff --git a/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts b/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts new file mode 100644 index 00000000000..32812f74d71 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts @@ -0,0 +1,30 @@ +import type { AppState } from '../../store/types' + +export function toggleAgentDashboardFromShortcut( + state: Pick< + AppState, + | 'activeView' + | 'settings' + | 'agentDashboardDrawerOpen' + | 'setSidebarOpen' + | 'setAgentDashboardDrawerOpen' + >, + openPopout: () => void +): void { + if ( + state.activeView === 'settings' || + state.settings?.experimentalAgentDashboardPopout !== true + ) { + return + } + if (state.settings.experimentalAgentDashboardMode === 'popout') { + openPopout() + return + } + const nextOpen = !state.agentDashboardDrawerOpen + // The drawer self-closes with the sidebar: reveal only when opening, never while closing. + if (nextOpen) { + state.setSidebarOpen(true) + } + state.setAgentDashboardDrawerOpen(nextOpen) +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-bridge-types.ts b/src/renderer/src/hooks/ipc-events/agent-status-bridge-types.ts new file mode 100644 index 00000000000..c5d827eea73 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-bridge-types.ts @@ -0,0 +1,36 @@ +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import type { AgentStatusBatchTransaction } from '@/store/slices/agent-status' +import type { AgentStatusPaneRoutingIndex } from './agent-status-pane-routing-index' + +export type PendingAgentStatusEvent = { + data: AgentStatusIpcPayload + firstSeenAt: number + replay: boolean +} + +export type AgentStatusApplyResult = 'applied' | 'pending' | 'dropped' + +type ProjectedAgentTabTitles = { + title: string | undefined + identityTitle: string | undefined +} + +export type AgentStatusBatchContext = { + transaction: AgentStatusBatchTransaction + routingIndex: AgentStatusPaneRoutingIndex + projectedTitlesByTabId: Map + tabTitlesByTabId: Map + notificationEffects: (() => void)[] +} + +export type AgentStatusBatchEvent = { + data: AgentStatusIpcPayload + replay?: boolean + retry?: boolean +} + +export type AgentStatusApplyOptions = { + replay?: boolean + retry?: boolean + batch?: AgentStatusBatchContext +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts new file mode 100644 index 00000000000..4e24bf97168 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts @@ -0,0 +1,295 @@ +import { isWslHookRelayConnectionId } from '../../../../shared/wsl-hook-relay-contract' +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import { + resolveAgentStatusIdentity, + shouldSuppressInheritedTerminalStatus +} from '../../../../shared/agent-status-identity' +import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { shouldSuppressCodexAutoApprovalStatus } from '@/components/terminal-pane/codex-auto-approval-notification-suppression' +import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' +import { track } from '@/lib/telemetry' +import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' +import type { AgentStatusBatchUpdate, AgentStatusUpdate } from '@/store/slices/agent-status' +import { observeAgentHookCompletionForNotification } from '../agent-hook-completion-notifications' +import { useAppStore } from '../../store' +import { + applyResolvedAgentTerminalTitleToTab, + hasRuntimeBackedWorktreeAttribution, + isAgentStatusForRecentlyClosedTab, + resolveHookPayloadAgentType, + resolvePaneKey, + resolveWorktreeConnection, + shouldApplyResolvedAgentTerminalTitleToTab +} from './agent-status-routing' +import { + resolvePaneKeyFromRoutingIndex, + resolveWorktreeConnectionFromRoutingIndex +} from './agent-status-pane-routing-index' +import type { + AgentStatusApplyOptions, + AgentStatusApplyResult, + PendingAgentStatusEvent +} from './agent-status-bridge-types' +import { normalizeAgentStatusEvent } from './normalize-agent-status-event' + +export function createAgentStatusEventApplicator(args: { + pendingAgentStatusEvents: PendingAgentStatusEvent[] + transientClearWatermarkByConnectionId: Map + enqueuePendingAgentStatus: (data: AgentStatusIpcPayload, options?: { replay?: boolean }) => void +}): (data: AgentStatusIpcPayload, options?: AgentStatusApplyOptions) => AgentStatusApplyResult { + const { + pendingAgentStatusEvents, + transientClearWatermarkByConnectionId, + enqueuePendingAgentStatus + } = args + const applyAgentStatus = ( + data: AgentStatusIpcPayload, + options?: AgentStatusApplyOptions + ): AgentStatusApplyResult => { + const store = options?.batch?.transaction.getState() ?? useAppStore.getState() + if (!store.workspaceSessionReady) { + return 'dropped' + } + if (isAgentStatusForRecentlyClosedTab(store, data.paneKey)) { + return 'dropped' + } + const paneKey = resolveAgentPaneAuthorityKey(data.paneKey) + const ownerTabId = parsePaneKey(paneKey)?.tabId ?? data.tabId + const payload = normalizeAgentStatusEvent(data) + if (!payload) { + return 'dropped' + } + let { + exists, + title, + identityTitle, + repoConnectionId, + repoConnectionResolved, + owningWorktreeId, + titleUsesTabTitle + } = options?.batch + ? resolvePaneKeyFromRoutingIndex(options.batch.routingIndex, paneKey) + : resolvePaneKey(store, paneKey) + const projectedTitles = + titleUsesTabTitle && ownerTabId + ? options?.batch?.projectedTitlesByTabId.get(ownerTabId) + : undefined + if (projectedTitles) { + title = projectedTitles.title + identityTitle = projectedTitles.identityTitle + } + if (!exists && data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) { + const fallbackOwnership = options?.batch + ? resolveWorktreeConnectionFromRoutingIndex(options.batch.routingIndex, data.worktreeId) + : resolveWorktreeConnection(store, data.worktreeId) + if (fallbackOwnership.worktreeExists) { + owningWorktreeId = data.worktreeId + repoConnectionId = fallbackOwnership.repoConnectionId + repoConnectionResolved = fallbackOwnership.repoConnectionResolved + exists = true + } + } + if (!exists) { + if (options?.replay === true) { + if (data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) { + if (options?.retry !== true) { + enqueuePendingAgentStatus(data, { replay: true }) + } + return 'pending' + } + return 'dropped' + } + if (options?.retry !== true) { + track('agent_hook_unattributed', { reason: 'unknown_tab_id' }) + enqueuePendingAgentStatus(data) + } + return 'pending' + } + if (options?.replay !== true && options?.retry !== true) { + for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { + if (pendingAgentStatusEvents[index].data.paneKey === data.paneKey) { + pendingAgentStatusEvents.splice(index, 1) + } + } + } + const ownershipConnectionId = isWslHookRelayConnectionId(data.connectionId) + ? null + : data.connectionId + const transientClearWatermark = + typeof data.connectionId === 'string' + ? transientClearWatermarkByConnectionId.get(data.connectionId) + : undefined + if (transientClearWatermark !== undefined && data.receivedAt <= transientClearWatermark) { + return 'dropped' + } + const canAcceptPendingRemoteOwnership = + ownershipConnectionId !== undefined && + ownershipConnectionId !== null && + !repoConnectionResolved && + data.worktreeId !== undefined && + data.worktreeId === owningWorktreeId + if ( + ownershipConnectionId !== undefined && + ownershipConnectionId !== repoConnectionId && + !canAcceptPendingRemoteOwnership + ) { + return 'dropped' + } + const existingStatus = store.agentStatusByPaneKey[paneKey] + if (existingStatus && data.receivedAt < existingStatus.updatedAt) { + return 'dropped' + } + if (data.providerSessionOnly) { + if (!data.providerSession || data.agentType !== 'pi') { + return 'dropped' + } + const providerSessionUpdate: AgentStatusBatchUpdate = { + kind: 'providerSession', + paneKey, + agent: 'pi', + providerSession: data.providerSession, + timing: { updatedAt: data.receivedAt }, + routing: { + tabId: ownerTabId, + worktreeId: data.worktreeId ?? owningWorktreeId, + ...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {}) + }, + metadata: data.launchToken ? { launchToken: data.launchToken } : undefined + } + if (options?.batch) { + return options.batch.transaction.apply(providerSessionUpdate) ? 'applied' : 'dropped' + } + store.recordAgentProviderSession( + providerSessionUpdate.paneKey, + providerSessionUpdate.agent, + providerSessionUpdate.providerSession, + providerSessionUpdate.timing, + providerSessionUpdate.routing, + providerSessionUpdate.metadata + ) + return 'applied' + } + const resolvedPayload = resolveHookPayloadAgentType(payload, identityTitle ?? title) + const statusPayload = data.orchestration + ? { ...resolvedPayload, orchestration: data.orchestration } + : resolvedPayload + const statusPayloadWithTurnBoundary = data.promptInteractionKey + ? { ...statusPayload, promptInteractionKey: data.promptInteractionKey } + : statusPayload + const statusPayloadWithProvenance = + data.restoredUnconfirmed === true + ? { ...statusPayloadWithTurnBoundary, restoredUnconfirmed: true } + : statusPayloadWithTurnBoundary + const statusPayloadWithObservation = data.observation + ? { ...statusPayloadWithProvenance, observation: data.observation } + : statusPayloadWithProvenance + const identity = resolveAgentStatusIdentity({ + existing: existingStatus + ? { + agentType: existingStatus.agentType, + state: existingStatus.state, + updatedAt: existingStatus.updatedAt, + restoredUnconfirmed: existingStatus.restoredUnconfirmed + } + : undefined, + incoming: statusPayload.agentType, + now: data.receivedAt + }) + if ( + existingStatus && + shouldSuppressInheritedTerminalStatus({ + inheritedFromActivePane: identity.inheritedFromActivePane, + incomingState: statusPayload.state + }) + ) { + return 'dropped' + } + if ( + shouldSuppressCodexAutoApprovalStatus(statusPayload, { + paneKey, + tabId: ownerTabId, + terminalHandle: data.terminalHandle, + launchToken: data.launchToken, + providerSession: data.providerSession, + existingProviderSession: existingStatus?.providerSession + }) + ) { + return 'dropped' + } + const terminalTitle = resolveAgentStatusTerminalTitle(statusPayload, title) + const statusWorktreeId = data.worktreeId ?? owningWorktreeId + const update: AgentStatusUpdate = { + paneKey, + payload: statusPayloadWithObservation, + terminalTitle, + timing: { + updatedAt: data.receivedAt, + stateStartedAt: data.stateStartedAt + }, + routing: { + tabId: ownerTabId, + worktreeId: statusWorktreeId, + terminalHandle: data.terminalHandle, + ...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {}) + }, + metadata: + data.providerSession || data.launchToken + ? { + ...(data.providerSession ? { providerSession: data.providerSession } : {}), + ...(data.launchToken ? { launchToken: data.launchToken } : {}) + } + : undefined + } + const applyPostCommitNotification = (): void => { + if (statusWorktreeId && (options?.replay !== true || resolvedPayload.state === 'working')) { + const notificationPayload = + typeof data.stateStartedAt === 'number' + ? { ...resolvedPayload, stateStartedAt: data.stateStartedAt } + : resolvedPayload + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: statusWorktreeId, + payload: notificationPayload, + ...(options?.replay === true ? { seedOnly: true } : {}) + }) + } + } + if (options?.batch) { + if (!options.batch.transaction.apply(update)) { + return 'dropped' + } + options.batch.notificationEffects.push(applyPostCommitNotification) + if ( + terminalTitle && + shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, title, terminalTitle) + ) { + const tabId = parsePaneKey(paneKey)?.tabId + if (tabId) { + options.batch.tabTitlesByTabId.set(tabId, terminalTitle) + if (titleUsesTabTitle) { + const titleChanges = !title || !isDecorativeAgentTitleFrameChange(title, terminalTitle) + options.batch.projectedTitlesByTabId.set(tabId, { + title: titleChanges ? terminalTitle : title, + identityTitle: titleChanges ? terminalTitle : identityTitle + }) + } + } + } + } else { + store.setAgentStatus( + update.paneKey, + update.payload, + update.terminalTitle, + update.timing, + update.routing, + update.metadata + ) + applyResolvedAgentTerminalTitleToTab(useAppStore.getState(), paneKey, title, terminalTitle) + applyPostCommitNotification() + } + return 'applied' + } + + return applyAgentStatus +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/agent-status-ipc-bridge.ts new file mode 100644 index 00000000000..3a99527b1d3 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-ipc-bridge.ts @@ -0,0 +1,284 @@ +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import { syncAgentHookCompletionNotificationsForStoreUpdate } from '../agent-hook-completion-notifications' +import { registerAgentStatusListeners } from './agent-status-listeners' +import { useAppStore } from '../../store' +import { + createAgentStatusPaneRoutingIndex, + resolvePaneKeyFromRoutingIndex +} from './agent-status-pane-routing-index' +import { createAgentStatusEventApplicator } from './agent-status-event-applicator' +import type { + AgentStatusApplyResult, + AgentStatusBatchContext, + AgentStatusBatchEvent, + PendingAgentStatusEvent +} from './agent-status-bridge-types' + +const PENDING_AGENT_STATUS_RETRY_MS = 100 +const PENDING_AGENT_STATUS_TTL_MS = 15_000 +const MAX_PENDING_AGENT_STATUS_EVENTS = 100 +const LIVE_AGENT_STATUS_BURST_WINDOW_MS = 33 + +export type AgentStatusIpcBridge = { + disposeAsyncState: () => void + unsubscribeStore: () => void +} + +export function registerAgentStatusIpcBridge(unsubs: (() => void)[]): AgentStatusIpcBridge { + const pendingAgentStatusEvents: PendingAgentStatusEvent[] = [] + const transientClearWatermarkByConnectionId = new Map() + let disposed = false + let pendingAgentStatusRetryTimer: ReturnType | null = null + let isFlushingAgentStatuses = false + const liveAgentStatusBurstQueue: AgentStatusIpcPayload[] = [] + let liveAgentStatusBurstTimer: ReturnType | null = null + let lastLiveAgentStatusApplyAt = 0 + function schedulePendingAgentStatusFlush(): void { + if (pendingAgentStatusRetryTimer !== null || pendingAgentStatusEvents.length === 0) { + return + } + pendingAgentStatusRetryTimer = globalThis.setTimeout(() => { + pendingAgentStatusRetryTimer = null + flushPendingAgentStatuses() + }, PENDING_AGENT_STATUS_RETRY_MS) + } + + function enqueuePendingAgentStatus( + data: AgentStatusIpcPayload, + options?: { replay?: boolean } + ): void { + pendingAgentStatusEvents.push({ + data, + firstSeenAt: Date.now(), + replay: options?.replay === true + }) + while (pendingAgentStatusEvents.length > MAX_PENDING_AGENT_STATUS_EVENTS) { + pendingAgentStatusEvents.shift() + } + schedulePendingAgentStatusFlush() + } + + function flushPendingAgentStatuses(): void { + // Why: guard re-entrancy — a subscriber firing mid-loop must not reprocess queued events the outer flush already owns. + if (isFlushingAgentStatuses) { + return + } + if (pendingAgentStatusEvents.length === 0) { + return + } + isFlushingAgentStatuses = true + try { + const now = Date.now() + const candidates = pendingAgentStatusEvents + .splice(0) + .filter((event) => now - event.firstSeenAt <= PENDING_AGENT_STATUS_TTL_MS) + let results: AgentStatusApplyResult[] + try { + results = applyAgentStatusBatch( + candidates.map((event) => ({ data: event.data, replay: event.replay, retry: true })) + ) + } catch (err) { + // Why: the queue was already spliced, so a throwing fold would drop the whole + // burst and strand every pane in it. Requeue ahead of newer arrivals and retry. + pendingAgentStatusEvents.unshift(...candidates) + throw err + } + for (let index = 0; index < candidates.length; index += 1) { + if (results[index] === 'pending') { + pendingAgentStatusEvents.push(candidates[index]) + } + } + if (pendingAgentStatusEvents.length === 0 && pendingAgentStatusRetryTimer !== null) { + globalThis.clearTimeout(pendingAgentStatusRetryTimer) + pendingAgentStatusRetryTimer = null + } + } finally { + isFlushingAgentStatuses = false + } + schedulePendingAgentStatusFlush() + } + + const applyAgentStatus = createAgentStatusEventApplicator({ + pendingAgentStatusEvents, + transientClearWatermarkByConnectionId, + enqueuePendingAgentStatus + }) + let snapshotRequestedForReadyWindow = false + let snapshotRequestId = 0 + const requestAgentStatusSnapshotIfReady = (): void => { + const store = useAppStore.getState() + if (!store.workspaceSessionReady) { + snapshotRequestedForReadyWindow = false + return + } + if (snapshotRequestedForReadyWindow) { + return + } + const getSnapshot = window.api.agentStatus.getSnapshot + if (typeof getSnapshot !== 'function') { + return + } + snapshotRequestedForReadyWindow = true + const requestId = ++snapshotRequestId + void getSnapshot() + .then((entries) => { + if (disposed || requestId !== snapshotRequestId) { + return + } + const current = useAppStore.getState() + if (!current.workspaceSessionReady) { + return + } + applyAgentStatusBatch(entries.map((data) => ({ data, replay: true }))) + const getMigrationUnsupportedSnapshot = + window.api.agentStatus.getMigrationUnsupportedSnapshot + if (typeof getMigrationUnsupportedSnapshot !== 'function') { + return + } + void getMigrationUnsupportedSnapshot().then((unsupportedEntries) => { + if (disposed || requestId !== snapshotRequestId) { + return + } + const unsupportedStore = useAppStore.getState() + if (!unsupportedStore.workspaceSessionReady) { + return + } + const unsupportedRoutingIndex = createAgentStatusPaneRoutingIndex(unsupportedStore) + for (const entry of unsupportedEntries) { + if ( + entry.paneKey && + resolvePaneKeyFromRoutingIndex(unsupportedRoutingIndex, entry.paneKey).exists + ) { + unsupportedStore.setMigrationUnsupportedPty(entry) + } + } + }) + }) + .catch((err) => { + // Why: stay latched on failure; the store subscriber fires on every update, so resetting here would turn a persistent IPC failure into a retry storm (flag clears on workspaceSessionReady toggle). + console.warn('[agent-status] failed to load startup snapshot:', err) + }) + } + + function applyAgentStatusBatch( + events: readonly AgentStatusBatchEvent[] + ): AgentStatusApplyResult[] { + if (events.length === 0) { + return [] + } + return useAppStore.getState().transactAgentStatuses((transaction) => { + const batch: AgentStatusBatchContext = { + transaction, + routingIndex: createAgentStatusPaneRoutingIndex(transaction.getState()), + projectedTitlesByTabId: new Map(), + tabTitlesByTabId: new Map(), + notificationEffects: [] + } + const results = events.map(({ data, replay, retry }) => + applyAgentStatus(data, { batch, replay, retry }) + ) + if (batch.tabTitlesByTabId.size > 0) { + transaction.afterCommit(() => { + useAppStore + .getState() + .updateTabTitles( + [...batch.tabTitlesByTabId].map(([tabId, title]) => ({ tabId, title })) + ) + }) + } + for (const effect of batch.notificationEffects) { + transaction.afterCommit(effect) + } + return results + }) + } + + function applyLiveAgentStatusBatch(batch: readonly AgentStatusIpcPayload[]): boolean { + return applyAgentStatusBatch(batch.map((data) => ({ data }))).some( + (result) => result === 'applied' + ) + } + + function flushLiveAgentStatusBurst(): void { + liveAgentStatusBurstTimer = null + lastLiveAgentStatusApplyAt = Date.now() + // Why: splice before publishing — synchronous Zustand subscribers can enqueue the next burst. + const batch = liveAgentStatusBurstQueue.splice(0) + if (!applyLiveAgentStatusBatch(batch)) { + lastLiveAgentStatusApplyAt = 0 + } + } + + function drainQueuedLiveAgentStatusesForPane(paneKey: string): void { + const queuedForPane: AgentStatusIpcPayload[] = [] + const remaining: AgentStatusIpcPayload[] = [] + for (const queued of liveAgentStatusBurstQueue) { + if (queued.paneKey === paneKey) { + queuedForPane.push(queued) + } else { + remaining.push(queued) + } + } + liveAgentStatusBurstQueue.length = 0 + liveAgentStatusBurstQueue.push(...remaining) + applyLiveAgentStatusBatch(queuedForPane) + } + + function enqueueLiveAgentStatus(data: AgentStatusIpcPayload): void { + const now = Date.now() + if ( + liveAgentStatusBurstTimer === null && + now - lastLiveAgentStatusApplyAt >= LIVE_AGENT_STATUS_BURST_WINDOW_MS + ) { + lastLiveAgentStatusApplyAt = now + // Why: only an applied event commits state and costs a render pass — + // a dropped/pending leading edge must not make its successor pay + // burst latency (startup replay and unmounted panes stay immediate). + if (applyAgentStatus(data) !== 'applied') { + lastLiveAgentStatusApplyAt = 0 + } + return + } + liveAgentStatusBurstQueue.push(data) + if (liveAgentStatusBurstTimer === null) { + liveAgentStatusBurstTimer = globalThis.setTimeout( + flushLiveAgentStatusBurst, + LIVE_AGENT_STATUS_BURST_WINDOW_MS + ) + } + } + + registerAgentStatusListeners({ + unsubs, + enqueueLiveAgentStatus, + drainQueuedLiveAgentStatusesForPane, + pendingAgentStatusEvents, + transientClearWatermarkByConnectionId, + liveAgentStatusBurstQueue + }) + + // Why: main hook server is the durable source of truth; pull the snapshot only after tabs are ready so early startup pushes can be ignored, not buffered. + requestAgentStatusSnapshotIfReady() + const unsubscribeAgentStatusStore = useAppStore.subscribe((state, previousState) => { + requestAgentStatusSnapshotIfReady() + flushPendingAgentStatuses() + syncAgentHookCompletionNotificationsForStoreUpdate(state, previousState) + }) + + return { + disposeAsyncState: () => { + disposed = true + snapshotRequestId += 1 + if (pendingAgentStatusRetryTimer !== null) { + globalThis.clearTimeout(pendingAgentStatusRetryTimer) + } + pendingAgentStatusEvents.length = 0 + if (liveAgentStatusBurstTimer !== null) { + globalThis.clearTimeout(liveAgentStatusBurstTimer) + liveAgentStatusBurstTimer = null + } + liveAgentStatusBurstQueue.length = 0 + }, + unsubscribeStore: unsubscribeAgentStatusStore + } +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts new file mode 100644 index 00000000000..70b13e22a41 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts @@ -0,0 +1,129 @@ +import { CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' +import type { + AgentStatusClearIpcPayload, + AgentStatusIpcPayload +} from '../../../../shared/agent-status-types' +import { + resolveLegacyWorkerTerminalRecoveryAction, + rollbackLegacyWorkerTerminalSurfaceInStore +} from '../legacy-worker-terminal-recovery-event' +import { useAppStore } from '../../store' +import { resolvePaneKey } from './agent-status-routing' +import type { PendingAgentStatusEvent } from './agent-status-bridge-types' + +export function registerAgentStatusListeners(args: { + unsubs: (() => void)[] + enqueueLiveAgentStatus: (data: AgentStatusIpcPayload) => void + drainQueuedLiveAgentStatusesForPane: (paneKey: string) => void + pendingAgentStatusEvents: PendingAgentStatusEvent[] + transientClearWatermarkByConnectionId: Map + liveAgentStatusBurstQueue: AgentStatusIpcPayload[] +}): void { + const { + unsubs, + enqueueLiveAgentStatus, + drainQueuedLiveAgentStatusesForPane, + pendingAgentStatusEvents, + transientClearWatermarkByConnectionId, + liveAgentStatusBurstQueue + } = args + unsubs.push( + window.api.agentStatus.onSet((data) => { + enqueueLiveAgentStatus(data) + }) + ) + const unsubscribeAgentStatusClear = window.api.agentStatus.onClear?.( + (data: AgentStatusClearIpcPayload) => { + if (typeof data !== 'object' || data === null) { + return + } + if ('transient' in data && data.transient === true) { + if ( + typeof data.connectionId !== 'string' || + data.connectionId.length === 0 || + !Number.isFinite(data.clearedAt) + ) { + return + } + const previousWatermark = transientClearWatermarkByConnectionId.get(data.connectionId) ?? -1 + const effectiveWatermark = Math.max(previousWatermark, data.clearedAt) + transientClearWatermarkByConnectionId.set(data.connectionId, effectiveWatermark) + for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { + const pending = pendingAgentStatusEvents[index].data + if ( + pending.connectionId === data.connectionId && + pending.receivedAt <= effectiveWatermark + ) { + pendingAgentStatusEvents.splice(index, 1) + } + } + for (let index = liveAgentStatusBurstQueue.length - 1; index >= 0; index -= 1) { + const queued = liveAgentStatusBurstQueue[index] + if ( + queued.connectionId === data.connectionId && + queued.receivedAt <= effectiveWatermark + ) { + liveAgentStatusBurstQueue.splice(index, 1) + } + } + useAppStore.getState().clearTransientAgentStatuses(data.connectionId, effectiveWatermark) + return + } + if (!('paneKey' in data) || typeof data.paneKey !== 'string') { + return + } + // Why: preserve set→clear FIFO so a queued completion still survives pane teardown. + if (liveAgentStatusBurstQueue.some((queued) => queued.paneKey === data.paneKey)) { + drainQueuedLiveAgentStatusesForPane(data.paneKey) + } + for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { + if (pendingAgentStatusEvents[index].data.paneKey === data.paneKey) { + pendingAgentStatusEvents.splice(index, 1) + } + } + const store = useAppStore.getState() + if (store.agentStatusByPaneKey[data.paneKey]?.state === 'done') { + return + } + store.removeAgentStatus(data.paneKey) + } + ) + if (unsubscribeAgentStatusClear) { + unsubs.push(unsubscribeAgentStatusClear) + } + const unsubscribeMigrationUnsupported = window.api.agentStatus.onMigrationUnsupported?.( + (entry) => { + const store = useAppStore.getState() + if (!store.workspaceSessionReady) { + return + } + if (entry.paneKey && resolvePaneKey(store, entry.paneKey).exists) { + store.setMigrationUnsupportedPty(entry) + } + } + ) + if (unsubscribeMigrationUnsupported) { + unsubs.push(unsubscribeMigrationUnsupported) + } + const unsubscribeMigrationUnsupportedClear = window.api.agentStatus.onMigrationUnsupportedClear?.( + ({ ptyId }) => { + useAppStore.getState().clearMigrationUnsupportedPty(ptyId) + } + ) + if (unsubscribeMigrationUnsupportedClear) { + unsubs.push(unsubscribeMigrationUnsupportedClear) + } + const unsubscribeLegacyWorkerTerminalRecovery = + window.api.agentStatus.onLegacyWorkerTerminalRecovery?.((event) => { + const action = resolveLegacyWorkerTerminalRecoveryAction(event) + if (action.kind === 'rollback-surface') { + window.dispatchEvent(new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail: action.detail })) + rollbackLegacyWorkerTerminalSurfaceInStore(useAppStore.getState(), action.detail) + } else if (action.kind === 'clear-sleeping') { + useAppStore.getState().clearSleepingAgentSession(action.paneKey) + } + }) + if (unsubscribeLegacyWorkerTerminalRecovery) { + unsubs.push(unsubscribeLegacyWorkerTerminalRecovery) + } +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts b/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts new file mode 100644 index 00000000000..b7f9467bfd2 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts @@ -0,0 +1,153 @@ +import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' +import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { AppState } from '../../store/types' + +type AgentStatusPaneResolution = { + exists: boolean + title: string | undefined + identityTitle: string | undefined + repoConnectionId: string | null + repoConnectionResolved: boolean + owningWorktreeId: string | undefined + titleUsesTabTitle: boolean +} + +type AgentStatusWorktreeConnectionResolution = { + worktreeExists: boolean + repoConnectionId: string | null + repoConnectionResolved: boolean +} + +type IndexedAgentStatusTab = { + title: string | undefined + unifiedLabel: string | undefined + owningWorktreeId: string +} + +export type AgentStatusPaneRoutingIndex = { + tabsById: Map + layoutsByTabId: AppState['terminalLayoutsByTabId'] + leafIdsByRoot: WeakMap> + worktreesById: ReturnType + reposById: ReturnType +} + +function createUnifiedTerminalLabelIndex( + entries: AppState['unifiedTabsByWorktree'][string] | undefined +): Map { + const labelsByTabId = new Map() + for (const entry of entries ?? []) { + if (entry.contentType !== 'terminal' || labelsByTabId.has(entry.entityId)) { + continue + } + const rawLabel = entry.label?.trim() + labelsByTabId.set(entry.entityId, rawLabel && rawLabel.length > 0 ? rawLabel : undefined) + } + return labelsByTabId +} + +export function createAgentStatusPaneRoutingIndex(store: AppState): AgentStatusPaneRoutingIndex { + const tabsById = new Map() + for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree)) { + const unifiedLabelsByTabId = createUnifiedTerminalLabelIndex( + store.unifiedTabsByWorktree?.[worktreeId] + ) + for (const tab of tabs) { + const tabId = tab.id + if (!tabsById.has(tabId)) { + tabsById.set(tabId, { + title: tab.title, + unifiedLabel: unifiedLabelsByTabId.get(tabId), + owningWorktreeId: worktreeId + }) + } + } + } + return { + tabsById, + layoutsByTabId: store.terminalLayoutsByTabId, + leafIdsByRoot: new WeakMap(), + worktreesById: getWorktreeMapFromState(store), + reposById: getRepoMapFromState(store) + } +} + +export function resolveWorktreeConnectionFromRoutingIndex( + index: AgentStatusPaneRoutingIndex, + worktreeId: string +): AgentStatusWorktreeConnectionResolution { + const worktree = index.worktreesById.get(worktreeId) + if (!worktree) { + return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false } + } + const repo = index.reposById.get(worktree.repoId) + return { + worktreeExists: true, + repoConnectionId: repo?.connectionId ?? null, + repoConnectionResolved: repo !== undefined + } +} + +export function resolvePaneKeyFromRoutingIndex( + index: AgentStatusPaneRoutingIndex, + paneKey: string +): AgentStatusPaneResolution { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId: null, + repoConnectionResolved: false, + owningWorktreeId: undefined, + titleUsesTabTitle: false + } + } + const { tabId, leafId } = parsed + const tab = index.tabsById.get(tabId) + if (!tab) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId: null, + repoConnectionResolved: false, + owningWorktreeId: undefined, + titleUsesTabTitle: false + } + } + const connection = resolveWorktreeConnectionFromRoutingIndex(index, tab.owningWorktreeId) + const layout = index.layoutsByTabId?.[tabId] + if (layout?.root) { + let leafIds = index.leafIdsByRoot.get(layout.root) + if (!leafIds) { + leafIds = new Set(collectLeafIdsInOrder(layout.root)) + index.leafIdsByRoot.set(layout.root, leafIds) + } + if (!leafIds.has(leafId)) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId: connection.repoConnectionId, + repoConnectionResolved: connection.repoConnectionResolved, + owningWorktreeId: tab.owningWorktreeId, + titleUsesTabTitle: false + } + } + } + const rawPaneTitle = layout?.titlesByLeafId?.[leafId] + const paneTitle = rawPaneTitle && rawPaneTitle.length > 0 ? rawPaneTitle : undefined + return { + exists: true, + title: paneTitle ?? tab.title, + identityTitle: paneTitle ?? tab.unifiedLabel ?? tab.title, + repoConnectionId: connection.repoConnectionId, + repoConnectionResolved: connection.repoConnectionResolved, + owningWorktreeId: tab.owningWorktreeId, + titleUsesTabTitle: paneTitle === undefined + } +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-routing.ts b/src/renderer/src/hooks/ipc-events/agent-status-routing.ts new file mode 100644 index 00000000000..1aa80a282ef --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-routing.ts @@ -0,0 +1,217 @@ +import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' +import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' +import type { AppState } from '../../store/types' +import { titleHasAgentName } from '../../../../shared/agent-detection' +import type { + AgentStatusIpcPayload, + ParsedAgentStatusPayload +} from '../../../../shared/agent-status-types' +import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' +import type { useAppStore } from '../../store' + +export function isAgentStatusForRecentlyClosedTab( + store: Pick, + paneKey: string +): boolean { + const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) + if (store.recentlyRetiredAgentStatusPaneKeys?.[ownerPaneKey] === true) { + return true + } + const tabId = parsePaneKey(ownerPaneKey)?.tabId + return tabId ? store.recentlyClosedAgentStatusTabIds[tabId] === true : false +} + +export function hasRuntimeBackedWorktreeAttribution(data: AgentStatusIpcPayload): boolean { + return ( + (typeof data.terminalHandle === 'string' && data.terminalHandle.length > 0) || + data.orchestration !== undefined + ) +} + +export function tryMakePaneKey(tabId: string, leafId: string): string | null { + try { + return makePaneKey(tabId, leafId) + } catch { + return null + } +} + +export function applyResolvedAgentTerminalTitleToTab( + store: ReturnType, + paneKey: string, + previousTitle: string | undefined, + nextTitle: string | undefined +): void { + if ( + !nextTitle || + !shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, previousTitle, nextTitle) + ) { + return + } + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return + } + // Why: hook completion can arrive while the pane transport is unmounted; keep the tab label synced to the resolved state title. + store.updateTabTitle(parsed.tabId, nextTitle) +} + +export function shouldApplyResolvedAgentTerminalTitleToTab( + store: ReturnType, + paneKey: string, + previousTitle: string | undefined, + nextTitle: string | undefined +): boolean { + if (!nextTitle || nextTitle === previousTitle) { + return false + } + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return false + } + const layout = store.terminalLayoutsByTabId?.[parsed.tabId] + if (layout?.root && layout.activeLeafId && layout.activeLeafId !== parsed.leafId) { + return false + } + return true +} + +/** Resolve a paneKey (tabId:leafId) to liveness, current title, owning worktree, + * and the owning repo's connectionId. Used for agent-type inference and to drop + * status updates for torn-down tabs or dead connections (an SSH reconnect retires the + * old connectionId, so events still in flight under it must not land). */ +export function resolvePaneKey( + store: ReturnType, + paneKey: string +): { + exists: boolean + title: string | undefined + identityTitle: string | undefined + repoConnectionId: string | null + repoConnectionResolved: boolean + owningWorktreeId: string | undefined + titleUsesTabTitle: boolean +} { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId: null, + repoConnectionResolved: false, + owningWorktreeId: undefined, + titleUsesTabTitle: false + } + } + const { tabId, leafId } = parsed + const layout = store.terminalLayoutsByTabId?.[tabId] + let exists = false + let tabTitle: string | undefined + let unifiedTabLabel: string | undefined + let owningWorktreeId: string | undefined + for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree)) { + for (const tab of tabs) { + if (tab.id === tabId) { + exists = true + tabTitle = tab.title + owningWorktreeId = worktreeId + const visibleTab = (store.unifiedTabsByWorktree?.[worktreeId] ?? []).find( + (entry) => entry.contentType === 'terminal' && entry.entityId === tabId + ) + const rawVisibleLabel = visibleTab?.label?.trim() + unifiedTabLabel = + rawVisibleLabel && rawVisibleLabel.length > 0 ? rawVisibleLabel : undefined + break + } + } + if (exists) { + break + } + } + // Why: keep "resolved to a local repo" distinct from "not hydrated yet" so callers filter strictly post-hydration but still accept SSH snapshots during the startup ownership gap. + let repoConnectionId: string | null = null + let repoConnectionResolved = false + if (owningWorktreeId !== undefined) { + const worktree = getWorktreeMapFromState(store).get(owningWorktreeId) + if (worktree) { + const repo = getRepoMapFromState(store).get(worktree.repoId) + repoConnectionResolved = repo !== undefined + repoConnectionId = repo?.connectionId ?? null + } + } + if (!exists) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId, + repoConnectionResolved, + owningWorktreeId, + titleUsesTabTitle: false + } + } + // Why: an empty layout snapshot from a worktree switch (tab/PTY still live) counts as missing metadata; a non-empty layout lacking the leaf still means closed. + const leafExists = layout?.root ? collectLeafIdsInOrder(layout.root).includes(leafId) : true + if (!leafExists) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId, + repoConnectionResolved, + owningWorktreeId, + titleUsesTabTitle: false + } + } + // Why: inactive worktrees can have a durable tab and live PTY while the layout is unmounted; hook state must still land there. + const rawPaneTitle = layout?.titlesByLeafId?.[leafId] + // Why: treat empty-string paneTitle as "no title" so the tab-level fallback fires; nullish-coalescing on '' would short-circuit and erase cached terminalTitle. + const paneTitle = rawPaneTitle && rawPaneTitle.length > 0 ? rawPaneTitle : undefined + return { + exists, + title: paneTitle ?? tabTitle, + // Why: some agents (OpenClaude) keep the terminal title generic while the tab label carries the agent identity; use only the non-custom label for attribution. + identityTitle: paneTitle ?? unifiedTabLabel ?? tabTitle, + repoConnectionId, + repoConnectionResolved, + owningWorktreeId, + titleUsesTabTitle: paneTitle === undefined + } +} + +export function resolveWorktreeConnection( + store: ReturnType, + worktreeId: string +): { + worktreeExists: boolean + repoConnectionId: string | null + repoConnectionResolved: boolean +} { + const worktree = getWorktreeMapFromState(store).get(worktreeId) + if (!worktree) { + return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false } + } + const repo = getRepoMapFromState(store).get(worktree.repoId) + return { + worktreeExists: true, + repoConnectionId: repo?.connectionId ?? null, + repoConnectionResolved: repo !== undefined + } +} + +export function resolveHookPayloadAgentType( + payload: ParsedAgentStatusPayload, + terminalTitle: string | undefined +): ParsedAgentStatusPayload { + if ( + payload.agentType !== 'claude' || + !terminalTitle || + !titleHasAgentName(terminalTitle, 'openclaude') + ) { + return payload + } + // Why: OpenClaude emits Claude-compatible hooks; the title is the last renderer signal to keep it out of Claude-only status paths. + return { ...payload, agentType: 'openclaude' } +} diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts new file mode 100644 index 00000000000..f4bc7c6c007 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -0,0 +1,122 @@ +import { getTabIdsAwaitingHostHydrationRemount } from '@/lib/parked-terminal-host-hydration' +import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping-agents-in-background' +import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' +import { resetAgentHookCompletionNotificationCoordinators } from '../agent-hook-completion-notifications' +import { useAppStore } from '../../store' +import { registerAgentStatusIpcBridge } from './agent-status-ipc-bridge' +import { registerBrowserRequestIpcBridge } from './browser-request-ipc-bridge' +import { registerBrowserStateIpcBridge } from './browser-state-ipc-bridge' +import { registerContentCreationIpcBridge } from './content-creation-ipc-bridge' +import { createDirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' +import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' +import { registerMobileAndTerminalCloseIpcBridge } from './mobile-terminal-close-ipc-bridge' +import { registerMobileDriverIpcBridge } from './mobile-driver-ipc-bridge' +import { registerProjectCatalogIpcBridge } from './project-catalog-ipc-bridge' +import { registerRateLimitIpcBridge } from './rate-limit-ipc-bridge' +import { registerRemoteWorkspaceIpcBridge } from './remote-workspace-ipc-bridge' +import { registerRuntimeClientIpcBridge } from './runtime-client-ipc-bridge' +import { registerSessionTabIpcBridge } from './session-tab-ipc-bridge' +import { registerSettingsAndSidebarIpcBridge } from './settings-sidebar-ipc-bridge' +import { registerTabLifecycleIpcBridge } from './tab-lifecycle-ipc-bridge' +import { registerTerminalPresentationIpcBridge } from './terminal-presentation-ipc-bridge' +import { registerTerminalRequestIpcBridge } from './terminal-request-ipc-bridge' +import { registerTerminalUiRoutingIpcBridge } from './terminal-ui-routing-ipc-bridge' +import { registerUpdaterStatusIpcBridge } from './updater-status-ipc-bridge' +import { createWorktreeEventRuntime } from './worktree-event-runtime' +import { registerWorkspaceShortcutIpcBridge } from './workspace-shortcut-ipc-bridge' +import { registerZoomIpcBridge } from './zoom-ipc-bridge' + +function isRuntimeEnvironmentActive(): boolean { + return Boolean(useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim()) +} + +function remountTerminalTabsAwaitingHostHydration(): void { + const store = useAppStore.getState() + for (const tabId of getTabIdsAwaitingHostHydrationRemount(store)) { + store.remountTerminalTabForRecovery(tabId) + } +} + +export type IpcEventsCleanupPhase = + | 'agent.disposeAsyncState' + | 'mobile.disposeHydration' + | 'runtimeStore.unsubscribe' + | 'agentStore.unsubscribe' + | 'ipc.dispose' + | 'directSsh.stop' + | 'notifications.reset' + +export function installAppLifetimeIpcEvents( + onCleanupPhase?: (phase: IpcEventsCleanupPhase) => void +): () => void { + const unsubs: (() => void)[] = [] + const directSshRuntime = createDirectSshBridgeRuntime() + const backgroundWakeDispatcher = createBackgroundSleepingAgentWakeDispatcher() + unsubs.push(backgroundWakeDispatcher.dispose) + unsubs.push(attachMobileMarkdownBridge()) + + const worktreeRuntime = createWorktreeEventRuntime(unsubs, isRuntimeEnvironmentActive) + const unsubscribeRuntimeEnvironmentStore = registerRuntimeClientIpcBridge(unsubs, worktreeRuntime) + registerProjectCatalogIpcBridge( + unsubs, + worktreeRuntime.worktreeChangeRefreshQueue, + isRuntimeEnvironmentActive, + remountTerminalTabsAwaitingHostHydration + ) + registerSettingsAndSidebarIpcBridge(unsubs) + registerWorkspaceShortcutIpcBridge(unsubs) + unsubs.push( + window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup, defaultTabs }) => { + void worktreeRuntime + .activateNotifiedWorktree( + { + type: 'activateWorktree', + repoId, + worktreeId, + ...(setup ? { setup } : {}), + ...(startup ? { startup } : {}), + ...(defaultTabs ? { defaultTabs } : {}) + }, + { allowRuntimeEnvironment: false } + ) + .catch((error) => console.error('Failed to activate CLI-created worktree:', error)) + }) + ) + + registerTerminalPresentationIpcBridge(unsubs) + registerTerminalRequestIpcBridge(unsubs) + registerTerminalUiRoutingIpcBridge(unsubs) + registerSessionTabIpcBridge(unsubs) + registerMobileAndTerminalCloseIpcBridge(unsubs, backgroundWakeDispatcher.request) + registerUpdaterStatusIpcBridge(unsubs) + registerBrowserStateIpcBridge(unsubs, isRuntimeEnvironmentActive) + registerContentCreationIpcBridge(unsubs, isRuntimeEnvironmentActive) + registerBrowserRequestIpcBridge(unsubs, isRuntimeEnvironmentActive) + registerTabLifecycleIpcBridge(unsubs) + registerRateLimitIpcBridge(unsubs) + registerDirectSshStateIpcBridge(unsubs, directSshRuntime) + registerRemoteWorkspaceIpcBridge(unsubs, directSshRuntime) + registerZoomIpcBridge(unsubs) + const agentStatusBridge = registerAgentStatusIpcBridge(unsubs) + const disposeMobileDriverHydration = registerMobileDriverIpcBridge( + unsubs, + isRuntimeEnvironmentActive + ) + + return () => { + agentStatusBridge.disposeAsyncState() + onCleanupPhase?.('agent.disposeAsyncState') + disposeMobileDriverHydration() + onCleanupPhase?.('mobile.disposeHydration') + unsubscribeRuntimeEnvironmentStore() + onCleanupPhase?.('runtimeStore.unsubscribe') + agentStatusBridge.unsubscribeStore() + onCleanupPhase?.('agentStore.unsubscribe') + unsubs.forEach((unsubscribe) => unsubscribe()) + onCleanupPhase?.('ipc.dispose') + directSshRuntime.stop() + onCleanupPhase?.('directSsh.stop') + resetAgentHookCompletionNotificationCoordinators() + onCleanupPhase?.('notifications.reset') + } +} diff --git a/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts b/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts new file mode 100644 index 00000000000..5bf85bca8bb --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { acquireBrowserAutomationBootstrapLease } from './browser-automation-bootstrap-lease' + +const mocks = vi.hoisted(() => ({ + acquireVisibility: vi.fn(), + releaseVisibility: vi.fn(), + requestBackgroundMount: vi.fn() +})) + +vi.mock('@/components/browser-pane/host-guest/browser-automation-visibility', () => ({ + acquireBrowserAutomationVisibility: mocks.acquireVisibility, + releaseBrowserAutomationVisibility: mocks.releaseVisibility +})) +vi.mock('@/components/terminal/background-terminal-worktree-mount', () => ({ + requestBackgroundTerminalWorktreeMount: mocks.requestBackgroundMount +})) +vi.mock('../../store', () => ({ + useAppStore: { + getState: () => ({ + activeWorktreeId: 'wt-active', + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + activeBrowserTabIdByWorktree: {} + }) + } +})) + +describe('browser automation bootstrap lease ownership', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + mocks.acquireVisibility + .mockReturnValueOnce('lease-1') + .mockReturnValueOnce('lease-2') + .mockReturnValueOnce('lease-3') + }) + + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) + + it('replaces a same-page lease and expires different page leases independently after 10s', () => { + acquireBrowserAutomationBootstrapLease('wt-1', 'page-1') + acquireBrowserAutomationBootstrapLease('wt-1', 'page-1') + acquireBrowserAutomationBootstrapLease('wt-2', 'page-2') + + expect(mocks.releaseVisibility).toHaveBeenCalledTimes(1) + expect(mocks.releaseVisibility).toHaveBeenLastCalledWith('lease-1') + expect(mocks.requestBackgroundMount.mock.calls).toEqual([ + [{ worktreeId: 'wt-1' }], + [{ worktreeId: 'wt-1' }], + [{ worktreeId: 'wt-2' }] + ]) + + vi.advanceTimersByTime(9_999) + expect(mocks.releaseVisibility).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(1) + expect(mocks.releaseVisibility.mock.calls).toEqual([['lease-1'], ['lease-2'], ['lease-3']]) + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.ts b/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.ts new file mode 100644 index 00000000000..2bf48de78e8 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.ts @@ -0,0 +1,75 @@ +import { + acquireBrowserAutomationVisibility, + releaseBrowserAutomationVisibility +} from '@/components/browser-pane/host-guest/browser-automation-visibility' +import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' +import { useAppStore } from '../../store' +import type { AppState } from '../../store/types' + +const BROWSER_AUTOMATION_BOOTSTRAP_LEASE_MS = 10_000 +const browserAutomationBootstrapLeaseByPageId = new Map() + +function releaseBrowserAutomationBootstrapLease(browserPageId: string): void { + const existing = browserAutomationBootstrapLeaseByPageId.get(browserPageId) + if (!existing) { + return + } + window.clearTimeout(existing.timer) + releaseBrowserAutomationVisibility(existing.token) + browserAutomationBootstrapLeaseByPageId.delete(browserPageId) +} + +function findBrowserPageWorktreeId(store: AppState, browserPageId: string): string | null { + for (const [worktreeId, browserTabs] of Object.entries(store.browserTabsByWorktree)) { + for (const workspace of browserTabs) { + if ( + workspace.id === browserPageId || + workspace.activePageId === browserPageId || + workspace.pageIds?.includes(browserPageId) + ) { + return worktreeId + } + } + } + for (const pages of Object.values(store.browserPagesByWorkspace)) { + const page = pages.find((candidate) => candidate.id === browserPageId) + if (page) { + return page.worktreeId + } + } + return null +} + +export function acquireBrowserAutomationBootstrapLease( + worktreeId: string | null | undefined, + browserPageId?: string | null +): void { + const store = useAppStore.getState() + const targetWorktreeId = + worktreeId ?? + (browserPageId ? findBrowserPageWorktreeId(store, browserPageId) : null) ?? + store.activeWorktreeId + if (!targetWorktreeId) { + return + } + requestBackgroundTerminalWorktreeMount({ worktreeId: targetWorktreeId }) + let targetBrowserPageId = browserPageId ?? null + if (!targetBrowserPageId) { + const browserTabs = store.browserTabsByWorktree[targetWorktreeId] ?? [] + const activeWorkspaceId = store.activeBrowserTabIdByWorktree[targetWorktreeId] ?? null + const workspace = + browserTabs.find((tab) => tab.id === activeWorkspaceId) ?? browserTabs[0] ?? null + targetBrowserPageId = + workspace?.activePageId ?? workspace?.pageIds?.[0] ?? workspace?.id ?? null + } + if (!targetBrowserPageId) { + return + } + + releaseBrowserAutomationBootstrapLease(targetBrowserPageId) + const token = acquireBrowserAutomationVisibility(targetBrowserPageId) + const timer = window.setTimeout(() => { + releaseBrowserAutomationBootstrapLease(targetBrowserPageId) + }, BROWSER_AUTOMATION_BOOTSTRAP_LEASE_MS) + browserAutomationBootstrapLeaseByPageId.set(targetBrowserPageId, { token, timer }) +} diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts new file mode 100644 index 00000000000..056b1857021 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts @@ -0,0 +1,242 @@ +import { destroyPersistentWebview } from '@/components/browser-pane/host-guest/webview-registry' +import { + guardPinnedTabClose, + isUnifiedTabPinned, + resolvePinnedTabLabel +} from '../../store/pinned-tab-close-guard' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' +import { acquireBrowserAutomationBootstrapLease } from './browser-automation-bootstrap-lease' + +export function registerBrowserRequestIpcBridge( + unsubs: (() => void)[], + isRuntimeEnvironmentActive: () => boolean +): void { + unsubs.push( + window.api.ui.onRequestTabCreate((data) => { + try { + if (isRuntimeEnvironmentActive()) { + // Why: browser automation targets client-local Electron webviews that runtime agents can't see or control. + window.api.ui.replyTabCreate({ + requestId: data.requestId, + error: translate( + 'auto.hooks.useIpcEvents.291c8ed902', + 'Browser tabs are unavailable while a remote runtime is active' + ) + }) + return + } + const store = useAppStore.getState() + const worktreeId = data.worktreeId ?? store.activeWorktreeId + if (!worktreeId) { + window.api.ui.replyTabCreate({ + requestId: data.requestId, + error: translate('auto.hooks.useIpcEvents.f000b2ff76', 'No active worktree') + }) + return + } + // Why: CLI-created tabs should land in the active browser tab's group, not the terminal's UI-active group. + const activeBrowserTabId = store.activeBrowserTabIdByWorktree[worktreeId] + const activeBrowserUnifiedTab = activeBrowserTabId + ? (store.unifiedTabsByWorktree[worktreeId] ?? []).find( + (t) => t.contentType === 'browser' && t.entityId === activeBrowserTabId + ) + : undefined + + // Why: a user-initiated open (data.activate, e.g. mobile tapping an HTML path) foregrounds the tab so it lands in active-group order and publishes to mobile. + // Agent/automation opens stay in the background (activate:false) in the active browser group. + const workspace = store.createBrowserTab(worktreeId, data.url, { + title: data.url, + browserPageId: data.browserPageId, + targetGroupId: data.activate ? undefined : activeBrowserUnifiedTab?.groupId, + sessionProfileId: data.sessionProfileId, + sessionPartition: data.sessionPartition, + activate: data.activate === true + }) + // Why: registerGuest fires with the page ID, not the workspace ID; return it so waitForTabRegistration can correlate. + const pages = useAppStore.getState().browserPagesByWorkspace[workspace.id] ?? [] + const browserPageId = pages[0]?.id ?? workspace.id + acquireBrowserAutomationBootstrapLease(worktreeId, browserPageId) + window.api.ui.replyTabCreate({ requestId: data.requestId, browserPageId }) + } catch (err) { + window.api.ui.replyTabCreate({ + requestId: data.requestId, + error: err instanceof Error ? err.message : 'Tab creation failed' + }) + } + }) + ) + + unsubs.push( + window.api.ui.onRequestTabSetProfile((data) => { + try { + if (isRuntimeEnvironmentActive()) { + window.api.ui.replyTabSetProfile({ + requestId: data.requestId, + error: translate( + 'auto.hooks.useIpcEvents.f45fa2b03c', + 'Browser profiles are unavailable while a remote runtime is active' + ) + }) + return + } + const store = useAppStore.getState() + const owningWorkspace = Object.values(store.browserTabsByWorktree) + .flat() + .find((workspace) => { + if (workspace.id === data.browserPageId) { + return true + } + const pages = store.browserPagesByWorkspace[workspace.id] ?? [] + return pages.some((page) => page.id === data.browserPageId) + }) + if (!owningWorkspace) { + window.api.ui.replyTabSetProfile({ + requestId: data.requestId, + error: translate( + 'auto.hooks.useIpcEvents.0e3cf53060', + 'Browser tab {{value0}} not found', + { value0: data.browserPageId } + ) + }) + return + } + // Why: a workspace may host several browser pages; profile switch must tear down all sibling webviews, not just the IPC's. + const workspacePages = store.browserPagesByWorkspace[owningWorkspace.id] ?? [] + if (workspacePages.length > 0) { + for (const page of workspacePages) { + destroyPersistentWebview(page.id) + } + } else { + destroyPersistentWebview(data.browserPageId) + } + store.switchBrowserTabProfile(owningWorkspace.id, data.profileId, data.sessionPartition) + window.api.ui.replyTabSetProfile({ requestId: data.requestId }) + } catch (err) { + window.api.ui.replyTabSetProfile({ + requestId: data.requestId, + error: err instanceof Error ? err.message : 'Tab profile update failed' + }) + } + }) + ) + + unsubs.push( + window.api.ui.onRequestTabClose((data) => { + try { + if (isRuntimeEnvironmentActive()) { + window.api.ui.replyTabClose({ + requestId: data.requestId, + error: translate( + 'auto.hooks.useIpcEvents.291c8ed902', + 'Browser tabs are unavailable while a remote runtime is active' + ) + }) + return + } + const store = useAppStore.getState() + const explicitTargetId = data.tabId ?? null + const replyBrowserTabNotFound = (tabId: string): void => { + window.api.ui.replyTabClose({ + requestId: data.requestId, + code: 'browser_tab_not_found', + error: translate( + 'auto.hooks.useIpcEvents.0e3cf53060', + 'Browser tab {{value0}} not found', + { value0: tabId } + ) + }) + } + const replyPinnedBrowserCloseCanceled = (tabId: string): void => { + window.api.ui.replyTabClose({ + requestId: data.requestId, + error: translate( + 'auto.hooks.useIpcEvents.2f6637fe6c', + 'Browser tab {{value0}} is pinned', + { value0: tabId } + ) + }) + } + const closeBrowserWorkspaceWithReply = (worktreeId: string, workspaceId: string): void => { + const currentStore = useAppStore.getState() + guardPinnedTabClose({ + isPinned: isUnifiedTabPinned(currentStore, worktreeId, workspaceId), + tabLabel: resolvePinnedTabLabel(currentStore, worktreeId, workspaceId), + onClose: () => { + useAppStore.getState().closeBrowserTab(workspaceId) + window.api.ui.replyTabClose({ requestId: data.requestId }) + }, + onCancel: () => replyPinnedBrowserCloseCanceled(workspaceId) + }) + } + const tabToClose = + explicitTargetId ?? + (data.worktreeId + ? (store.activeBrowserTabIdByWorktree?.[data.worktreeId] ?? null) + : store.activeBrowserTabId) + if (!tabToClose) { + window.api.ui.replyTabClose({ + requestId: data.requestId, + error: translate('auto.hooks.useIpcEvents.a8d2bf8e9e', 'No active browser tab to close') + }) + return + } + // Why: the bridge keys tabs by browserPageId, but closeBrowserTab expects a workspace id. + // Per the CLI's `tab close --page` contract, close only that page unless it is the last in its workspace. + const isWorkspaceId = Object.values(store.browserTabsByWorktree) + .flat() + .some((ws) => ws.id === tabToClose) + if (!isWorkspaceId) { + const owningWorkspace = Object.entries(store.browserPagesByWorkspace).find(([, pages]) => + pages.some((p) => p.id === tabToClose) + ) + if (owningWorkspace) { + const [workspaceId, pages] = owningWorkspace + const owningWorktreeId = + Object.entries(store.browserTabsByWorktree).find(([, tabs]) => + tabs.some((tab) => tab.id === workspaceId) + )?.[0] ?? null + if (data.worktreeId && owningWorktreeId !== data.worktreeId) { + replyBrowserTabNotFound(tabToClose) + return + } + if (pages.length <= 1) { + if (owningWorktreeId) { + closeBrowserWorkspaceWithReply(owningWorktreeId, workspaceId) + return + } + store.closeBrowserTab(workspaceId) + } else { + store.closeBrowserPage(tabToClose) + } + window.api.ui.replyTabClose({ requestId: data.requestId }) + return + } + } + const owningWorktreeId = + Object.entries(store.browserTabsByWorktree).find(([, tabs]) => + tabs.some((tab) => tab.id === tabToClose) + )?.[0] ?? null + if (owningWorktreeId) { + if (data.worktreeId && owningWorktreeId !== data.worktreeId) { + replyBrowserTabNotFound(tabToClose) + return + } + closeBrowserWorkspaceWithReply(owningWorktreeId, tabToClose) + return + } + if (explicitTargetId) { + replyBrowserTabNotFound(explicitTargetId) + return + } + store.closeBrowserTab(tabToClose) + window.api.ui.replyTabClose({ requestId: data.requestId }) + } catch (err) { + window.api.ui.replyTabClose({ + requestId: data.requestId, + error: err instanceof Error ? err.message : 'Tab close failed' + }) + } + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/browser-session-tab-target.ts b/src/renderer/src/hooks/ipc-events/browser-session-tab-target.ts new file mode 100644 index 00000000000..193b0e22d92 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-session-tab-target.ts @@ -0,0 +1,25 @@ +import type { AppState } from '../../store/types' + +export type BrowserSessionTabTarget = + | { kind: 'unified-browser'; unifiedTabId: string; workspaceId: string; groupId: string } + | { kind: 'fallback-browser'; workspaceId: string } + +export function resolveBrowserSessionTabTarget( + state: Pick, + worktreeId: string, + tabId: string +): BrowserSessionTabTarget | null { + const tab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find((item) => item.id === tabId) + if (tab?.contentType === 'browser') { + return { + kind: 'unified-browser', + unifiedTabId: tab.id, + workspaceId: tab.entityId, + groupId: tab.groupId + } + } + const fallbackBrowser = (state.browserTabsByWorktree[worktreeId] ?? []).find( + (workspace) => workspace.id === tabId + ) + return fallbackBrowser ? { kind: 'fallback-browser', workspaceId: fallbackBrowser.id } : null +} diff --git a/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts new file mode 100644 index 00000000000..0920bf9ecec --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts @@ -0,0 +1,83 @@ +import { rememberLiveBrowserUrl } from '@/components/browser-pane/describe-page/live-browser-url-registry' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { redactKagiSessionToken } from '../../../../shared/browser-url' +import { useAppStore } from '../../store' +import { acquireBrowserAutomationBootstrapLease } from './browser-automation-bootstrap-lease' + +export function registerBrowserStateIpcBridge( + unsubs: (() => void)[], + isRuntimeEnvironmentActive: () => boolean +): void { + unsubs.push( + window.api.ui.onFullscreenChanged((isFullScreen) => { + useAppStore.getState().setIsFullScreen(isFullScreen) + }) + ) + unsubs.push( + window.api.browser.onGuestLoadFailed(({ browserPageId, loadError }) => { + if (isRuntimeEnvironmentActive()) { + return + } + useAppStore.getState().updateBrowserPageState(browserPageId, { + loading: false, + loadError, + canGoBack: false, + canGoForward: false + }) + }) + ) + const unsubscribeCertificateFailure = window.api.browser.onCertificateFailureChanged?.( + ({ browserPageId, failure }) => { + if (isRuntimeEnvironmentActive()) { + return + } + useAppStore.getState().setBrowserPageCertificateFailure(browserPageId, failure) + } + ) + if (unsubscribeCertificateFailure) { + unsubs.push(unsubscribeCertificateFailure) + } + unsubs.push( + window.api.browser.onNavigationUpdate(({ browserPageId, url, title }) => { + if (isRuntimeEnvironmentActive()) { + return + } + const store = useAppStore.getState() + // The redacted live registry must precede the raw persisted store update. + rememberLiveBrowserUrl(browserPageId, redactKagiSessionToken(url)) + store.setBrowserPageUrl(browserPageId, url) + store.updateBrowserPageState(browserPageId, { title, loading: false }) + }) + ) + unsubs.push( + window.api.browser.onActivateView(({ worktreeId, browserPageId }) => { + if (!isRuntimeEnvironmentActive()) { + acquireBrowserAutomationBootstrapLease(worktreeId, browserPageId) + } + }) + ) + unsubs.push( + window.api.browser.onPaneFocus(({ worktreeId, browserPageId }) => { + if (isRuntimeEnvironmentActive()) { + return + } + const store = useAppStore.getState() + const targetWorktreeId = worktreeId ?? store.activeWorktreeId + if (targetWorktreeId) { + store.focusBrowserTabInWorktree(targetWorktreeId, browserPageId) + } + }) + ) + unsubs.push( + window.api.browser.onOpenLinkInOrcaTab(({ browserPageId, url }) => { + const store = useAppStore.getState() + const sourcePage = Object.values(store.browserPagesByWorkspace) + .flat() + .find((page) => page.id === browserPageId) + if (!sourcePage || getRuntimeEnvironmentIdForWorktree(store, sourcePage.worktreeId)) { + return + } + store.createBrowserTab(sourcePage.worktreeId, url, { title: url }) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/content-creation-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/content-creation-ipc-bridge.ts new file mode 100644 index 00000000000..973a87b08ef --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/content-creation-ipc-bridge.ts @@ -0,0 +1,133 @@ +import { ensureSimulatorTab } from '@/lib/ensure-simulator-tab' +import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab' +import { + isManualSimulatorLaunchPending, + rememberPrelaunchedSimulatorSession +} from '@/lib/simulator-launch-coordination' +import { + createFloatingWorkspaceBrowserTab, + createFloatingWorkspaceMarkdownTab, + isFloatingWorkspacePanelFocused +} from '@/lib/floating-workspace-terminal-actions' +import { translate } from '@/i18n/i18n' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { toast } from 'sonner' +import { useAppStore } from '../../store' + +export function registerContentCreationIpcBridge( + unsubs: (() => void)[], + isRuntimeEnvironmentActive: () => boolean +): void { + unsubs.push( + window.api.ui.onNewBrowserTab(() => { + const store = useAppStore.getState() + if (isFloatingWorkspacePanelFocused()) { + void createFloatingWorkspaceBrowserTab(store).catch((error) => { + toast.error(error instanceof Error ? error.message : String(error)) + }) + return + } + const worktreeId = store.activeWorktreeId + if (!worktreeId) { + return + } + const targetGroupId = + store.activeGroupIdByWorktree[worktreeId] ?? store.groupsByWorktree[worktreeId]?.[0]?.id + if (!targetGroupId) { + return + } + void store.openNewBrowserTabInActiveWorkspace(targetGroupId).catch((error) => { + toast.error(error instanceof Error ? error.message : String(error)) + }) + }) + ) + + unsubs.push( + window.api.ui.onNewMarkdownTab(() => { + const store = useAppStore.getState() + if (isFloatingWorkspacePanelFocused()) { + void createFloatingWorkspaceMarkdownTab(store).catch((err) => { + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.hooks.useIpcEvents.56d3ec4203', + 'Failed to create untitled markdown file.' + ) + ) + }) + return + } + const worktreeId = store.activeWorktreeId + if (!worktreeId) { + return + } + const targetGroupId = + store.activeGroupIdByWorktree[worktreeId] ?? store.groupsByWorktree[worktreeId]?.[0]?.id + if (targetGroupId) { + void store.openNewMarkdownInActiveWorkspace(targetGroupId) + } + }) + ) + + // Why: emulator IPC is additive; guard so older clients or partial preload mocks don't crash the hook when it's absent. + const unsubscribeNewSimulatorTab = window.api.ui.onNewSimulatorTab?.(() => { + if (isRuntimeEnvironmentActive()) { + return + } + const store = useAppStore.getState() + const worktreeId = store.activeWorktreeId + if (!worktreeId) { + return + } + void openMobileEmulatorTab(worktreeId, { placement: 'rightSplit' }).catch((error) => { + toast.error(error instanceof Error ? error.message : String(error)) + }) + }) + if (unsubscribeNewSimulatorTab) { + unsubs.push(unsubscribeNewSimulatorTab) + } + + const unsubscribeEmulatorAutoAttach = window.api.emulator?.onAutoAttach( + ({ worktreeId, info }) => { + if (isRuntimeEnvironmentActive()) { + return + } + if (isManualSimulatorLaunchPending(worktreeId)) { + // Why: manual launches pre-attach so the ready pane opens in the right split, not as a hidden tab in this group. + rememberPrelaunchedSimulatorSession(worktreeId, info) + return + } + ensureSimulatorTab(worktreeId, { + surfacePane: false, + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + // Why: watcher may detect a helper while a simulator tab is already mounted; push stream info so the pane updates without re-attach. + window.setTimeout(() => { + window.dispatchEvent( + new CustomEvent('orca:emulator-auto-attach', { + detail: { worktreeId, info } + }) + ) + }, 0) + } + ) + if (unsubscribeEmulatorAutoAttach) { + unsubs.push(unsubscribeEmulatorAutoAttach) + } + + const unsubscribeEmulatorPaneFocus = window.api.emulator?.onPaneFocus(({ worktreeId }) => { + if (isRuntimeEnvironmentActive()) { + return + } + ensureSimulatorTab(worktreeId, { + surfacePane: true, + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + }) + if (unsubscribeEmulatorPaneFocus) { + unsubs.push(unsubscribeEmulatorPaneFocus) + } + + // Why: reply with the page ID so main can await registerGuest before returning to the CLI. +} diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts new file mode 100644 index 00000000000..3844d32ec0c --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts @@ -0,0 +1,199 @@ +import { createDirectSshReconnectProductTelemetryAdapter } from '@/lib/direct-ssh-reconnect-product-telemetry' +import { acquireDirectSshDetectedWorktreeRefresh } from '@/store/slices/worktrees' +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import { useAppStore } from '../../store' +import { + createDirectSshHostHydration, + type DirectSshHostHydration +} from '../direct-ssh-host-hydration' +import { + createDirectSshReconnectCoordinator, + type DirectSshPreparationInput, + type DirectSshPreparationReason, + type DirectSshReconnectCoordinator +} from '../direct-ssh-reconnect-coordinator' +import { directSshAuthoritiesEqual } from '../direct-ssh-reconnect-tokens' +import { createDirectSshWorktreeRefreshScheduler } from '../direct-ssh-worktree-refresh-scheduler' +import { + createRemoteWorkspaceTargetSync, + type RemoteWorkspaceTargetSync +} from '../remote-workspace-target-sync' +import type { AppState } from '../../store/types' + +type DirectSshTerminalActions = Partial< + Pick +> +type AuthorityDeadline = { timer: ReturnType; settle: () => void } + +export type DirectSshBridgeRuntime = { + reconnectAuthorityByTarget: Map + reconnectCoordinator: DirectSshReconnectCoordinator + hostHydration: DirectSshHostHydration + remoteWorkspaceTargetSync: RemoteWorkspaceTargetSync | null + currentAuthority: (targetId: string) => DirectSshAuthority | null + terminalActions: () => DirectSshTerminalActions + prepareAndSync: ( + authority: DirectSshAuthority, + reason: DirectSshPreparationReason, + options?: { authorityAlreadyReplaced?: boolean } + ) => Promise + isStopped: () => boolean + addDeadline: (deadline: AuthorityDeadline) => void + removeDeadline: (deadline: AuthorityDeadline) => void + stop: () => void +} + +export function createDirectSshBridgeRuntime(): DirectSshBridgeRuntime { + const reconnectAuthorityByTarget = new Map() + const deadlines = new Set() + let stopped = false + const currentAuthority = (targetId: string): DirectSshAuthority | null => { + const state = useAppStore.getState().sshConnectionStates?.get(targetId) + if ( + state?.status !== 'connected' || + state.targetId !== targetId || + !state.providerEpoch || + state.connectionGeneration === undefined + ) { + return null + } + return { + targetId, + providerEpoch: state.providerEpoch, + connectionGeneration: state.connectionGeneration + } + } + const scheduler = createDirectSshWorktreeRefreshScheduler({ + startAttempt: (key) => { + const acquired = acquireDirectSshDetectedWorktreeRefresh(useAppStore, { + repoId: key.repoId, + executionHostId: key.executionHostId, + authority: { + targetId: key.targetId, + providerEpoch: key.providerEpoch, + connectionGeneration: key.connectionGeneration + }, + requireAuthoritative: key.authorityRequirement === 'required' + }) + return { + providerRequestId: acquired.providerRequestId, + result: acquired.result.then((result) => acquired.merge(result)), + cancel: acquired.release + } + } + }) + const hostHydration = createDirectSshHostHydration({ + store: useAppStore, + isCurrentAuthority: (authority) => + directSshAuthoritiesEqual(currentAuthority(authority.targetId), authority), + listRepos: (authority) => { + const executionHostId = toSshExecutionHostId(authority.targetId) + return ( + window.api.repos.listForExecutionHost?.({ + executionHostId, + expectedAuthority: authority + }) ?? + Promise.resolve({ authoritative: false, executionHostId, reason: 'unavailable' as const }) + ) + }, + listLineage: (authority) => { + const executionHostId = toSshExecutionHostId(authority.targetId) + return ( + window.api.worktrees.listLineageForHost?.({ + executionHostId, + expectedAuthority: authority + }) ?? + Promise.resolve({ authoritative: false, executionHostId, reason: 'unavailable' as const }) + ) + } + }) + const terminalActions = (): DirectSshTerminalActions => + useAppStore.getState() as DirectSshTerminalActions + let remoteWorkspaceTargetSync: RemoteWorkspaceTargetSync | null = null + const reconnectCoordinator = createDirectSshReconnectCoordinator({ + scheduler, + isCurrentConnectedAuthority: (authority) => + directSshAuthoritiesEqual(currentAuthority(authority.targetId), authority), + capturePreparationInput: hostHydration.capturePreparationInput, + readHostScopedLineage: hostHydration.readHostScopedLineage, + invalidateStaleTerminalBindings: (authority) => + terminalActions().invalidateStaleDirectSshTargetPtyBindings?.(authority) ?? 0, + retryTargetPanes: (authority) => terminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, + finalizeHydratedTerminalPanes: (authority) => + terminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, + correctUnboundTerminalPanes: (authority) => + terminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, + syncRemoteWorkspaceAfterConnect: (token) => remoteWorkspaceTargetSync?.syncAfterConnect(token), + onTelemetry: createDirectSshReconnectProductTelemetryAdapter() + }) + const remoteWorkspaceApi = window.api.remoteWorkspace + if (remoteWorkspaceApi) { + remoteWorkspaceTargetSync = createRemoteWorkspaceTargetSync({ + store: useAppStore, + remoteWorkspace: remoteWorkspaceApi, + getCurrentAuthority: currentAuthority, + isPreparationTokenCurrent: hostHydration.isPreparationTokenCurrent, + capturePreparationInput: (authority, reason, revision) => + hostHydration.capturePreparationInput(authority, reason, revision), + prepareOnly: reconnectCoordinator.prepareOnly, + finalizeHydratedTerminals: (authority) => + directSshAuthoritiesEqual(reconnectAuthorityByTarget.get(authority.targetId), authority) + ? reconnectCoordinator.finalizeHydratedTerminals(authority) + : 0 + }) + } + const prepareAndSync: DirectSshBridgeRuntime['prepareAndSync'] = async ( + authority, + reason, + options + ) => { + try { + if (!options?.authorityAlreadyReplaced) { + reconnectCoordinator.replaceAuthority(authority) + } + const input: DirectSshPreparationInput | null = await hostHydration.capturePreparationInput( + authority, + reason + ) + if (!input) { + return + } + const prepared = await reconnectCoordinator.prepareOnly(input) + if (prepared.token && hostHydration.isPreparationTokenCurrent(prepared.token)) { + await remoteWorkspaceTargetSync?.syncAfterConnect(prepared.token) + } + } catch (error) { + if (directSshAuthoritiesEqual(currentAuthority(authority.targetId), authority)) { + useAppStore.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'error', + message: error instanceof Error ? error.message : 'Workspace sync failed' + }) + } + } + } + return { + reconnectAuthorityByTarget, + reconnectCoordinator, + hostHydration, + remoteWorkspaceTargetSync, + currentAuthority, + terminalActions, + prepareAndSync, + isStopped: () => stopped, + addDeadline: (deadline) => deadlines.add(deadline), + removeDeadline: (deadline) => deadlines.delete(deadline), + stop: () => { + stopped = true + for (const deadline of deadlines) { + clearTimeout(deadline.timer) + deadline.settle() + } + deadlines.clear() + remoteWorkspaceTargetSync?.stop() + hostHydration.stop() + reconnectCoordinator.stop() + reconnectAuthorityByTarget.clear() + } + } +} diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts new file mode 100644 index 00000000000..c29cb8ec19e --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts @@ -0,0 +1,296 @@ +import { canConnectSshStatus } from '@/ssh/ssh-connection-recoverability' +import type { DirectSshAuthority, SshConnectionState } from '../../../../shared/ssh-types' +import { useAppStore } from '../../store' +import { isDirectSshReconnectCoordinatorRoutingEnabled } from '../direct-ssh-reconnect-rollout' +import { directSshAuthoritiesEqual } from '../direct-ssh-reconnect-tokens' +import { + registerDirectSshWakeRouting, + routeDirectSshConnectedState, + type DirectSshConnectedStateOrigin +} from '../direct-ssh-state-routing' +import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' +export function registerDirectSshStateIpcBridge( + unsubs: (() => void)[], + runtime: DirectSshBridgeRuntime +): void { + const { + reconnectAuthorityByTarget, + reconnectCoordinator, + currentAuthority, + terminalActions, + prepareAndSync + } = runtime + const sshStateWatermarkByTargetId = new Map() + const pendingPortHydrationByTargetId = new Map< + string, + { receivedForwardPush: boolean; receivedDetectedPush: boolean } + >() + const hydrateSshPorts = (targetId: string, authority: DirectSshAuthority): void => { + const pendingPortHydration = { + receivedForwardPush: false, + receivedDetectedPush: false + } + pendingPortHydrationByTargetId.set(targetId, pendingPortHydration) + const isHydrationAuthorityCurrent = (): boolean => + !runtime.isStopped() && directSshAuthoritiesEqual(currentAuthority(targetId), authority) + const forwardHydration = window.api.ssh.listPortForwards({ targetId }).then((forwards) => { + if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedForwardPush) { + useAppStore.getState().setPortForwards(targetId, forwards) + } + }) + const detectedHydration = window.api.ssh.listDetectedPorts({ targetId }).then((detected) => { + if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedDetectedPush) { + useAppStore.getState().setDetectedPorts(targetId, detected) + } + }) + void Promise.allSettled([forwardHydration, detectedHydration]).then(() => { + if (pendingPortHydrationByTargetId.get(targetId) === pendingPortHydration) { + pendingPortHydrationByTargetId.delete(targetId) + } + }) + } + let applySshConnectionStateChange!: ( + targetId: string, + state: SshConnectionState, + origin: DirectSshConnectedStateOrigin + ) => void + void (async () => { + try { + const targets = await window.api.ssh.listTargets() + if (runtime.isStopped()) { + return + } + useAppStore.getState().setSshTargetsMetadata(targets) + try { + const removedLabels = await window.api.ssh.listRemovedTargetLabels() + if (runtime.isStopped()) { + return + } + useAppStore.getState().setRemovedSshTargetLabels(removedLabels) + } catch {} + for (const target of targets) { + const hydrationWatermark = sshStateWatermarkByTargetId.get(target.id) ?? 0 + const state = await window.api.ssh.getState({ targetId: target.id }) + if ( + !runtime.isStopped() && + state && + (sshStateWatermarkByTargetId.get(target.id) ?? 0) === hydrationWatermark + ) { + applySshConnectionStateChange(target.id, state as SshConnectionState, 'initial-hydration') + } + } + } catch {} + })() + unsubs.push( + window.api.ssh.onCredentialRequest((data) => { + useAppStore.getState().enqueueSshCredentialRequest(data) + }) + ) + unsubs.push( + window.api.ssh.onCredentialResolved(({ requestId }) => { + useAppStore.getState().removeSshCredentialRequest(requestId) + }) + ) + + unsubs.push( + window.api.ssh.onPortForwardsChanged(({ targetId, forwards }) => { + const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId) + if (pendingPortHydration) { + pendingPortHydration.receivedForwardPush = true + } + useAppStore.getState().setPortForwards(targetId, forwards) + }) + ) + + unsubs.push( + window.api.ssh.onDetectedPortsChanged(({ targetId, ports }) => { + const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId) + if (pendingPortHydration) { + pendingPortHydration.receivedDetectedPush = true + } + useAppStore.getState().setDetectedPorts(targetId, ports) + }) + ) + + const reconcileSshAuthority = ( + targetId: string, + initiatingState: SshConnectionState, + origin: DirectSshConnectedStateOrigin, + watermark: number + ): void => { + let pendingDeadline: { timer: ReturnType; settle: () => void } | undefined + const deadline = new Promise((resolve) => { + const settle = (): void => resolve(null) + const timer = setTimeout(settle, 5_000) + pendingDeadline = { timer, settle } + runtime.addDeadline(pendingDeadline) + }) + void Promise.race([window.api.ssh.getState({ targetId }).catch(() => null), deadline]) + .then((latest) => { + if ( + runtime.isStopped() || + latest?.targetId !== targetId || + !latest?.providerEpoch || + latest.connectionGeneration === undefined || + (sshStateWatermarkByTargetId.get(targetId) ?? 0) !== watermark + ) { + return + } + const current = useAppStore.getState().sshConnectionStates?.get(targetId) + if ( + current?.status !== initiatingState.status || + latest.status !== initiatingState.status || + current.providerEpoch !== initiatingState.providerEpoch || + current.connectionGeneration !== initiatingState.connectionGeneration || + (current.providerEpoch !== undefined && + current.providerEpoch !== null && + current.providerEpoch !== latest.providerEpoch) || + (current.connectionGeneration !== undefined && + current.connectionGeneration !== latest.connectionGeneration) + ) { + return + } + applySshConnectionStateChange( + targetId, + { + ...current, + providerEpoch: latest.providerEpoch, + connectionGeneration: latest.connectionGeneration + }, + origin + ) + }) + .catch(() => undefined) + .finally(() => { + if (pendingDeadline) { + clearTimeout(pendingDeadline.timer) + runtime.removeDeadline(pendingDeadline) + } + }) + } + + applySshConnectionStateChange = ( + targetId: string, + state: SshConnectionState, + origin: DirectSshConnectedStateOrigin + ): void => { + const store = useAppStore.getState() + const previous = store.sshConnectionStates?.get(targetId) + store.setSshConnectionState(targetId, state) + + if (canConnectSshStatus(state.status)) { + reconnectAuthorityByTarget.delete(targetId) + reconnectCoordinator.invalidate(targetId) + store.clearRemoteDetectedAgents(targetId) + + store.clearPortForwards(targetId) + store.setDetectedPorts(targetId, []) + + store.clearDirectSshTargetPtyBindings(targetId) + return + } + + if (state.status !== 'connected') { + return + } + const authority = currentAuthority(targetId) + if (!authority) { + reconcileSshAuthority(targetId, state, origin, sshStateWatermarkByTargetId.get(targetId) ?? 0) + return + } + const previousAuthority = + previous?.status === 'connected' && + previous.providerEpoch && + previous.connectionGeneration !== undefined + ? { + targetId, + providerEpoch: previous.providerEpoch, + connectionGeneration: previous.connectionGeneration + } + : null + routeDirectSshConnectedState( + { + coordinator: reconnectCoordinator, + coordinatorRoutingEnabled: isDirectSshReconnectCoordinatorRoutingEnabled(), + invalidateStaleTerminalBindings: (nextAuthority) => + terminalActions().invalidateStaleDirectSshTargetPtyBindings?.(nextAuthority) ?? 0, + retryTargetPanes: (nextAuthority) => + terminalActions().retryDirectSshTargetPanes?.(nextAuthority) ?? 0, + prepareAndSync: prepareAndSync, + rememberReconnectAuthority: (nextAuthority) => { + if (nextAuthority) { + reconnectAuthorityByTarget.set(targetId, nextAuthority) + } else { + reconnectAuthorityByTarget.delete(targetId) + } + } + }, + { authority, previousAuthority, origin } + ) + if (origin === 'initial-hydration') { + hydrateSshPorts(targetId, authority) + } + } + + let sshTargetStateEventId = 0 + const latestSshTargetStateEventByTargetId = new Map() + + const handleSshStateChangedEvent = (data: { targetId: string; state: unknown }): void => { + const store = useAppStore.getState() + const state = data.state as SshConnectionState + const stateEventId = ++sshTargetStateEventId + sshStateWatermarkByTargetId.set( + data.targetId, + (sshStateWatermarkByTargetId.get(data.targetId) ?? 0) + 1 + ) + latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId) + if (!store.sshTargetLabels.has(data.targetId)) { + window.api.ssh + .listTargets() + .catch(() => window.api.ssh.listTargets()) + .then((targets) => { + if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) { + return + } + latestSshTargetStateEventByTargetId.delete(data.targetId) + if (runtime.isStopped()) { + return + } + const latestStore = useAppStore.getState() + if (!targets.some((target) => target.id === data.targetId)) { + latestStore.clearRemovedSshTargetState(data.targetId) + return + } + latestStore.setSshTargetsMetadata(targets) + applySshConnectionStateChange(data.targetId, state, 'push') + }) + .catch(() => { + if ( + !runtime.isStopped() && + latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId + ) { + latestSshTargetStateEventByTargetId.delete(data.targetId) + applySshConnectionStateChange(data.targetId, state, 'push') + } + }) + return + } + + latestSshTargetStateEventByTargetId.delete(data.targetId) + applySshConnectionStateChange(data.targetId, state, 'push') + } + + unsubs.push(window.api.ssh.onStateChanged(handleSshStateChangedEvent)) + unsubs.push( + registerDirectSshWakeRouting({ + getConnectionStates: () => useAppStore.getState().sshConnectionStates ?? [], + wakeAuthority: (authority) => { + reconnectCoordinator.correctUnboundTerminals(authority, 'wake-refresh') + void prepareAndSync(authority, 'wake-refresh') + }, + ...(typeof window.api.ui.onSystemResumed === 'function' + ? { onSystemResumed: (callback: () => void) => window.api.ui.onSystemResumed(callback) } + : {}) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/mobile-driver-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/mobile-driver-ipc-bridge.ts new file mode 100644 index 00000000000..1796afa8a89 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/mobile-driver-ipc-bridge.ts @@ -0,0 +1,139 @@ +import { + hydrateBrowserDrivers, + setDriverForBrowserPage +} from '@/lib/pane-manager/browser-mobile-driver-state' +import { setDriverForPty, hydrateDrivers } from '@/lib/pane-manager/mobile-driver-state' +import { setFitOverride, hydrateOverrides } from '@/lib/pane-manager/mobile-fit-overrides' +import { applyNativeChatLaunchDraftResolved } from '@/runtime/native-chat-launch-draft-runtime-resolution' +import type { + RuntimeBrowserDriverState, + RuntimeTerminalDriverState +} from '../../../../shared/runtime-types' +import { useAppStore } from '../../store' + +const MAX_PENDING_MOBILE_STATE_EVENTS = 300 + +type PendingMobileStateEvent = + | { + kind: 'fit' + event: { + ptyId: string + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' + cols: number + rows: number + } + } + | { kind: 'driver'; event: { ptyId: string; driver: RuntimeTerminalDriverState } } + | { + kind: 'browser-driver' + event: { browserPageId: string; driver: RuntimeBrowserDriverState } + } + +export function registerMobileDriverIpcBridge( + unsubs: (() => void)[], + isRuntimeEnvironmentActive: () => boolean +): () => void { + let mobileStateHydrated = isRuntimeEnvironmentActive() + const pendingMobileStateEvents: PendingMobileStateEvent[] = [] + let disposed = false + + const applyPendingMobileStateEvents = (): void => { + for (const pending of pendingMobileStateEvents) { + if (pending.kind === 'fit') { + const { ptyId, mode, cols, rows } = pending.event + setFitOverride(ptyId, mode, cols, rows) + } else if (pending.kind === 'driver') { + setDriverForPty(pending.event.ptyId, pending.event.driver) + } else { + setDriverForBrowserPage(pending.event.browserPageId, pending.event.driver) + } + } + pendingMobileStateEvents.length = 0 + } + const enqueue = (event: PendingMobileStateEvent): void => { + pendingMobileStateEvents.push(event) + while (pendingMobileStateEvents.length > MAX_PENDING_MOBILE_STATE_EVENTS) { + pendingMobileStateEvents.shift() + } + } + + unsubs.push( + window.api.runtime.onTerminalFitOverrideChanged((event) => { + if (isRuntimeEnvironmentActive()) { + return + } + if (!mobileStateHydrated) { + enqueue({ kind: 'fit', event }) + return + } + setFitOverride(event.ptyId, event.mode, event.cols, event.rows) + }) + ) + unsubs.push( + window.api.runtime.onTerminalDriverChanged((event) => { + if (isRuntimeEnvironmentActive()) { + return + } + if (!mobileStateHydrated) { + enqueue({ kind: 'driver', event }) + return + } + setDriverForPty(event.ptyId, event.driver) + }) + ) + const unsubscribeLaunchDraftResolution = window.api.runtime.onNativeChatLaunchDraftResolved?.( + (event) => { + applyNativeChatLaunchDraftResolved(useAppStore.getState(), { + type: 'nativeChatLaunchDraftResolved', + ...event + }) + } + ) + if (unsubscribeLaunchDraftResolution) { + unsubs.push(unsubscribeLaunchDraftResolution) + } + unsubs.push( + window.api.runtime.onBrowserDriverChanged((event) => { + if (isRuntimeEnvironmentActive()) { + return + } + if (!mobileStateHydrated) { + enqueue({ kind: 'browser-driver', event }) + return + } + setDriverForBrowserPage(event.browserPageId, event.driver) + }) + ) + + // Subscribe before snapshots; queued pushes replay in arrival order after all three hydrate. + if (!isRuntimeEnvironmentActive()) { + void Promise.all([ + window.api.runtime.getTerminalFitOverrides(), + window.api.runtime.getTerminalDrivers(), + window.api.runtime.getBrowserDrivers() + ]) + .then(([overrides, drivers, browserDrivers]) => { + if (disposed) { + return + } + hydrateOverrides(overrides) + hydrateDrivers(drivers) + hydrateBrowserDrivers(browserDrivers) + mobileStateHydrated = true + applyPendingMobileStateEvents() + }) + .catch((error: unknown) => { + if (disposed) { + return + } + console.error('Failed to hydrate mobile terminal state:', error) + mobileStateHydrated = true + applyPendingMobileStateEvents() + }) + } + + return () => { + disposed = true + pendingMobileStateEvents.length = 0 + } +} diff --git a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts new file mode 100644 index 00000000000..8d58bcad3ea --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts @@ -0,0 +1,116 @@ +import { CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' +import type { CloseTerminalPaneDetail } from '@/constants/terminal' +import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' +import { detectLanguage } from '@/lib/language-detect' +import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence' +import { useAppStore } from '../../store' + +export function registerMobileAndTerminalCloseIpcBridge( + unsubs: (() => void)[], + requestSleepingAgentWake: (worktreeId: string) => void +): void { + unsubs.push( + window.api.ui.onOpenFileFromMobile( + ({ worktreeId, filePath, relativePath, runtimeEnvironmentId }) => { + const store = useAppStore.getState() + const basename = relativePath.split(/[\\/]/).pop() || relativePath + store.setActiveWorktree(worktreeId) + store.markWorktreeVisited(worktreeId) + store.setActiveView('terminal') + // Why: renderer owns tab creation so grouped order and markdown bridges share the desktop File Explorer's store path. + store.openFile({ + filePath, + relativePath, + worktreeId, + language: detectLanguage(basename), + runtimeEnvironmentId, + mode: 'edit' + }) + store.setActiveTabType('editor') + store.revealWorktreeInSidebar(worktreeId) + } + ) + ) + + unsubs.push( + window.api.ui.onOpenDiffFromMobile( + ({ worktreeId, filePath, relativePath, staged, runtimeEnvironmentId }) => { + const store = useAppStore.getState() + const language = detectLanguage(relativePath) + store.setActiveWorktree(worktreeId) + store.markWorktreeVisited(worktreeId) + store.setActiveView('terminal') + // Why: mobile renders diffs from metadata; the editor-local Changes shortcut would send plain markdown back to mobile. + store.openDiff(worktreeId, filePath, relativePath, language, staged, { + runtimeEnvironmentId + }) + store.setActiveTabType('editor') + store.revealWorktreeInSidebar(worktreeId) + } + ) + ) + + unsubs.push( + window.api.ui.onCloseTerminal(({ tabId, paneRuntimeId }) => { + if (paneRuntimeId != null) { + // Why: route pane closes via the lifecycle hook for sibling promotion (falls through to closeTab on the last pane). + const detail: CloseTerminalPaneDetail = { tabId, paneRuntimeId } + window.dispatchEvent(new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail })) + } else { + // Why: the CLI/RPC caller is answered immediately, so it cannot wait on a modal. + closeTerminalTab(tabId, { skipRunningProcessConfirm: true }) + } + }) + ) + + // Why: during an in-place renderer reload an older preload can linger; keep this listener additive at that seam. + if (window.api.ui.onTerminalTabCloseRequest) { + unsubs.push( + window.api.ui.onTerminalTabCloseRequest( + ({ requestId, tabId, localPtyTeardownOwnedExternally }) => { + let responded = false + const respond = (error?: string): void => { + if (responded) { + return + } + responded = true + window.api.ui.respondTerminalTabClose({ requestId, ...(error ? { error } : {}) }) + } + closeTerminalTab(tabId, { + rejectPinned: true, + ...(localPtyTeardownOwnedExternally ? { localPtyTeardownOwnedExternally: true } : {}), + onCancel: () => respond('terminal_tab_pinned'), + onClosed: () => { + void (async () => { + const state = useAppStore.getState() + await persistWorkspaceSessionByHost( + window.api.session, + buildWorkspaceSessionPayload(state), + state + ) + respond() + })().catch((error: unknown) => { + respond(error instanceof Error ? error.message : 'terminal_tab_close_failed') + }) + } + }) + } + ) + ) + } + + unsubs.push( + window.api.ui.onSleepWorktree(({ worktreeId }) => { + void runSleepWorktree(worktreeId) + }) + ) + + unsubs.push( + window.api.ui.onResumeSleepingAgents(({ worktreeId }) => { + // Why: a phone opened this worktree; wake its slept agents without changing the desktop's worktree/tab/view. + requestSleepingAgentWake(worktreeId) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/new-workspace-command.ts b/src/renderer/src/hooks/ipc-events/new-workspace-command.ts new file mode 100644 index 00000000000..98dd2b045c2 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/new-workspace-command.ts @@ -0,0 +1,36 @@ +import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' +import type { LinkedWorkItemSummary } from '@/lib/new-workspace' +import { getLinearIssueWorkspaceName } from '../../../../shared/workspace-name' +import type { AppState } from '../../store/types' + +type NewWorkspaceShortcutModalData = { + telemetrySource: 'shortcut' + prefilledName?: string + linkedWorkItem?: LinkedWorkItemSummary +} + +export function buildNewWorkspaceShortcutModalData( + state: Pick +): NewWorkspaceShortcutModalData { + const linearIssue = + state.activeView === 'tasks' ? (state.taskPageData.openLinearIssue ?? null) : null + if (!linearIssue) { + return { telemetrySource: 'shortcut' } + } + + return { + telemetrySource: 'shortcut', + prefilledName: getLinearIssueWorkspaceName(linearIssue), + // Cmd+N from a Linear issue mirrors its Start-workspace action with source context. + linkedWorkItem: buildLinearIssueLinkedWorkItem(linearIssue) + } +} + +export function openNewWorkspaceFromShortcut( + state: Pick +): void { + if (state.activeModal === 'new-workspace-composer') { + return + } + state.openModal('new-workspace-composer', buildNewWorkspaceShortcutModalData(state)) +} diff --git a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts new file mode 100644 index 00000000000..d9c92610bbd --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts @@ -0,0 +1,25 @@ +import { + normalizeAgentStatusPayload, + type AgentStatusIpcPayload, + type ParsedAgentStatusPayload +} from '../../../../shared/agent-status-types' + +export function normalizeAgentStatusEvent( + data: AgentStatusIpcPayload +): ParsedAgentStatusPayload | null { + return normalizeAgentStatusPayload({ + state: data.state, + workingMode: data.workingMode, + prompt: data.prompt, + agentType: data.agentType, + model: data.model, + toolName: data.toolName, + toolInput: data.toolInput, + interactivePrompt: data.interactivePrompt, + lastAssistantMessage: data.lastAssistantMessage, + interrupted: data.interrupted, + sessionBoundary: data.sessionBoundary, + turnCompletedAt: data.turnCompletedAt, + subagents: data.subagents + }) +} diff --git a/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts new file mode 100644 index 00000000000..6c75e473725 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts @@ -0,0 +1,101 @@ +import { applyWorktreeHeadIdentities } from '../worktree-head-identity-apply' +import type { WorktreeChangeRefreshQueue } from '../worktree-change-refresh-queue' +import { useAppStore } from '../../store' + +export function registerProjectCatalogIpcBridge( + unsubs: (() => void)[], + worktreeChangeRefreshQueue: WorktreeChangeRefreshQueue, + isRuntimeEnvironmentActive: () => boolean, + remountTerminalTabsAwaitingHostHydration: () => void +): void { + unsubs.push( + window.api.repos.onChanged(() => { + const state = useAppStore.getState() + if (isRuntimeEnvironmentActive()) { + // Why: the all-host sidebar shows local repos even under a runtime; refresh the local slice, keep runtime slices. + void (async () => { + const localOwner = { runtimeEnvironmentId: null } + await state.fetchRepos(localOwner) + await state.fetchProjectGroups(localOwner) + await state.fetchFolderWorkspaces(localOwner) + remountTerminalTabsAwaitingHostHydration() + })() + return + } + void state.fetchProjectGroups() + void state.fetchFolderWorkspaces() + void state.fetchRepos().then(remountTerminalTabsAwaitingHostHydration) + }) + ) + + unsubs.push( + window.api.worktrees.onChanged( + async (data: { + repoId: string + renamed?: { oldWorktreeId: string; newWorktreeId: string } + }) => { + // Why: preserve this event's local origin across queue delays and runtime + // focus changes; otherwise an unbound repo can refresh from the wrong host. + // A folder rename changes the worktree id; handleWorktreesChanged re-keys + // state and shields it from the deletion diff. + worktreeChangeRefreshQueue.enqueue({ + ...data, + forceLocalOwner: true + }) + } + ) + ) + + if (window.api.worktrees.onHeadIdentitiesChanged) { + unsubs.push( + window.api.worktrees.onHeadIdentitiesChanged((data) => { + if (isRuntimeEnvironmentActive()) { + // Why: local worktree events carry local repo ids; the local-pinned list + // refresh (onChanged) covers local rows while a runtime is active. + return + } + const state = useAppStore.getState() + applyWorktreeHeadIdentities(data, { + getWorktreesForRepo: (repoId) => state.worktreesByRepo[repoId], + updateWorktreeGitIdentity: state.updateWorktreeGitIdentity + }) + }) + ) + } + + unsubs.push( + window.api.worktrees.onBaseStatus((event) => { + if (isRuntimeEnvironmentActive()) { + return + } + useAppStore.getState().updateWorktreeBaseStatus(event) + }) + ) + + unsubs.push( + window.api.worktrees.onRemoteBranchConflict((event) => { + if (isRuntimeEnvironmentActive()) { + return + } + useAppStore.getState().updateWorktreeRemoteBranchConflict(event) + }) + ) + + // Why: route main's two-phase creation progress to each pending entry by correlation id (?. guards stale preload). + unsubs.push( + window.api.worktrees.onCreateProgress?.((data) => { + if (!data.creationId) { + return + } + useAppStore.getState().updatePendingWorktreeCreation(data.creationId, { phase: data.phase }) + }) ?? (() => {}) + ) + + if (window.api.gh?.onPRRefreshEvent) { + unsubs.push( + window.api.gh.onPRRefreshEvent((event) => { + useAppStore.getState().applyGitHubPRRefreshEvent(event) + }) + ) + } +} diff --git a/src/renderer/src/hooks/ipc-events/rate-limit-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/rate-limit-ipc-bridge.ts new file mode 100644 index 00000000000..48fb94c8304 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/rate-limit-ipc-bridge.ts @@ -0,0 +1,30 @@ +import type { RateLimitState } from '../../../../shared/rate-limit-types' +import { useAppStore } from '../../store' + +export function registerRateLimitIpcBridge(unsubs: (() => void)[]): void { + let initialSnapshotPending = true + let receivedPushBeforeInitialSnapshot = false + unsubs.push( + window.api.rateLimits.onUpdate((state) => { + if (initialSnapshotPending) { + receivedPushBeforeInitialSnapshot = true + } + useAppStore.getState().setRateLimitsFromPush(state as RateLimitState) + }) + ) + // The startup get is a fallback: a push before resolution permanently wins. + window.api.rateLimits.get().then((state) => { + initialSnapshotPending = false + if (receivedPushBeforeInitialSnapshot) { + return + } + useAppStore.getState().setRateLimitsFromPush(state as RateLimitState) + }) + + const unsubscribeWorkspaceSpaceProgress = window.api.workspaceSpace?.onProgress?.((progress) => { + useAppStore.getState().applyWorkspaceSpaceProgress(progress) + }) + if (unsubscribeWorkspaceSpaceProgress) { + unsubs.push(unsubscribeWorkspaceSpaceProgress) + } +} diff --git a/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts new file mode 100644 index 00000000000..161d6eaf576 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts @@ -0,0 +1,50 @@ +import { useAppStore } from '../../store' +import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' + +export function registerRemoteWorkspaceIpcBridge( + unsubs: (() => void)[], + runtime: DirectSshBridgeRuntime +): void { + let clientId: string | null = null + let clientIdPromise: Promise | null = null + const getClientId = (): Promise => { + const remoteWorkspace = window.api.remoteWorkspace + if (!remoteWorkspace) { + return Promise.resolve(null) + } + if (clientId) { + return Promise.resolve(clientId) + } + clientIdPromise ??= remoteWorkspace + .clientId() + .then((id) => { + clientId = id + return id + }) + .catch(() => null) + return clientIdPromise + } + if (!window.api.remoteWorkspace) { + return + } + void getClientId() + unsubs.push( + window.api.remoteWorkspace.onChanged((event) => { + void (async () => { + const currentClientId = await getClientId() + if (event.sourceClientId && currentClientId && event.sourceClientId === currentClientId) { + return + } + await runtime.remoteWorkspaceTargetSync + ?.applyUnsolicitedSnapshot(event.targetId, event.snapshot) + .catch((error) => { + useAppStore.getState().setRemoteWorkspaceSyncStatus(event.targetId, { + phase: 'error', + revision: event.snapshot.revision, + message: error instanceof Error ? error.message : 'Failed to apply remote workspace' + }) + }) + })() + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts new file mode 100644 index 00000000000..a4218c65763 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts @@ -0,0 +1,199 @@ +import { applyHostWorktreeTerminalSleepState } from '@/components/terminal-pane/pty-shutdown-exit-deferral' +import { dispatchTerminalSideEffectBatch } from '@/components/terminal-pane/terminal-side-effect-facts-handler' +import { applyNativeChatLaunchDraftResolved } from '@/runtime/native-chat-launch-draft-runtime-resolution' +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import { + applyRuntimeEnvironmentSshStateChanged, + hydrateRuntimeEnvironmentSshState, + refreshRuntimeEnvironmentSshTargetMetadata +} from '@/runtime/runtime-environment-ssh-state' +import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' +import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' +import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' +import { useAppStore } from '../../store' +import { createRuntimeClientEventsSync } from '../runtime-client-events-sync' +import { + createRuntimeProjectRefreshScheduler, + refreshRuntimeProjectWorktreesAndLineage +} from '../runtime-project-refresh-scheduler' +import { + buildRuntimeClientEventEnvironmentKey, + createRuntimeEnvironmentStoreSyncSubscriber, + getReachableRuntimeEnvironmentIds, + getRuntimeClientEventEnvironmentIds, + invalidateRuntimeClientEventReplay +} from './runtime-environment-subscription-selection' +import type { WorktreeEventRuntime } from './worktree-event-runtime' + +export function registerRuntimeClientIpcBridge( + unsubs: (() => void)[], + worktreeRuntime: WorktreeEventRuntime +): () => void { + const { worktreeChangeRefreshQueue, activateNotifiedWorktree } = worktreeRuntime + const ensureRuntimeEventRepoKnown = async ( + environmentId: string, + repoId: string + ): Promise => { + if ((useAppStore.getState().repos ?? []).some((repo) => repo.id === repoId)) { + return + } + await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId) + } + + const runtimeProjectRefreshScheduler = createRuntimeProjectRefreshScheduler({ + refresh: async (environmentId) => { + // Why: project events can reveal target CRUD, but known target states already arrive by push. + void refreshRuntimeEnvironmentSshTargetMetadata(environmentId).catch(() => {}) + const repos = await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId) + // Why: the host emits one reposChanged for group/folder-workspace edits too, so those + // catalogs go stale without this; groups first because folder workspaces resolve owners from them. + const runtimeOwner = { runtimeEnvironmentId: environmentId } + // Why: catalogs and worktrees are independent; serializing them put two 15s RPC + // timeouts ahead of worktree/lineage convergence on a wedged host. + await Promise.all([ + (async () => { + await useAppStore.getState().fetchProjectGroups(runtimeOwner) + await useAppStore.getState().fetchFolderWorkspaces(runtimeOwner) + })(), + refreshRuntimeProjectWorktreesAndLineage( + environmentId, + repos, + (repoId, options) => useAppStore.getState().fetchWorktrees(repoId, options), + (options) => useAppStore.getState().fetchWorktreeLineage(options) + ) + ]) + }, + onError: (error) => { + console.error('Failed to refresh runtime projects:', error) + } + }) + + const handleRuntimeClientEvent = ( + environmentId: string, + event: RuntimeClientEvent, + generation = getEnvironmentSshStateGeneration(environmentId) + ): void => { + if (event.type === 'worktreeTerminalSleepState') { + applyHostWorktreeTerminalSleepState(environmentId, event) + return + } + if (event.type === 'terminalSideEffects') { + dispatchTerminalSideEffectBatch({ + ...event.batch, + ptyId: toRemoteRuntimePtyId(event.batch.ptyId, environmentId) + }) + return + } + if (event.type === 'nativeChatLaunchDraftResolved') { + applyNativeChatLaunchDraftResolved(useAppStore.getState(), event) + return + } + if (event.type === 'reposChanged') { + runtimeProjectRefreshScheduler.request(environmentId) + return + } + if (event.type === 'sshStateChanged') { + applyRuntimeEnvironmentSshStateChanged(environmentId, event.targetId, event.state, generation) + return + } + if (event.type === 'worktreesChanged') { + void ensureRuntimeEventRepoKnown(environmentId, event.repoId).then(() => + worktreeChangeRefreshQueue.enqueue({ + repoId: event.repoId, + executionHostId: toRuntimeExecutionHostId(environmentId) + }) + ) + return + } + if (event.type === 'linearLinkedIssueUpdated') { + void useAppStore + .getState() + .refreshLinearIssue(event.identifier, event.workspaceId) + .catch((error) => { + console.error('Failed to refresh updated Linear issue:', error) + }) + return + } + void ensureRuntimeEventRepoKnown(environmentId, event.repoId) + .then(() => activateNotifiedWorktree(event, { allowRuntimeEnvironment: true })) + .catch((error) => { + console.error('Failed to activate runtime-created worktree:', error) + }) + } + + const runtimeClientEventsSync = createRuntimeClientEventsSync({ + getDesiredEnvironmentIds: () => getRuntimeClientEventEnvironmentIds(useAppStore.getState()), + getSubscriptionKey: (environmentId) => buildRuntimeClientEventEnvironmentKey([environmentId]), + subscribe: (environmentId, onEvent, onError) => { + const sshGeneration = getEnvironmentSshStateGeneration(environmentId) + const runtimeGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) + const runtimeRevision = getRuntimeEnvironmentRevision(environmentId) + return subscribeRuntimeClientEvents( + environmentId, + (event) => { + if ( + sshGeneration === getEnvironmentSshStateGeneration(environmentId) && + runtimeGeneration === getRuntimeEnvironmentConnectionGeneration(environmentId) && + runtimeRevision === getRuntimeEnvironmentRevision(environmentId) + ) { + onEvent(event) + } + }, + onError, + () => { + invalidateRuntimeClientEventReplay({ + getSshStateReference: () => useAppStore.getState().sshStateByEnvironment, + requestProjectRefresh: () => runtimeProjectRefreshScheduler.request(environmentId), + markEnvironmentSshStateStale: () => + useAppStore.getState().markEnvironmentSshStateStale(environmentId), + hydrateEnvironmentSshState: () => + hydrateRuntimeEnvironmentSshState(environmentId, { force: true }), + sync: runtimeClientEventsSync.sync + }) + } + ) + }, + onEvent: handleRuntimeClientEvent + }) + + // Why: no on-connect repo fetch (PR #2); seed discovery for connected runtimes or remote projects hide until Add-Project. + const initialRuntimeEnvironmentState = useAppStore.getState() + const runtimeClientEventEnvironmentIds = getRuntimeClientEventEnvironmentIds( + initialRuntimeEnvironmentState + ) + for (const environmentId of runtimeClientEventEnvironmentIds) { + runtimeProjectRefreshScheduler.request(environmentId) + } + const reachableRuntimeEnvironmentIds = getReachableRuntimeEnvironmentIds( + initialRuntimeEnvironmentState + ) + const handleRuntimeEnvironmentStoreWrite = createRuntimeEnvironmentStoreSyncSubscriber({ + initialDesiredEnvironmentIds: runtimeClientEventEnvironmentIds, + initialReachableEnvironmentIds: reachableRuntimeEnvironmentIds, + buildEnvironmentKey: buildRuntimeClientEventEnvironmentKey, + getDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds, + getReachableEnvironmentIds: getReachableRuntimeEnvironmentIds, + requestProjectRefresh: (environmentId) => + // The scheduler coalesces bursts per environment. + runtimeProjectRefreshScheduler.request(environmentId), + markEnvironmentSshStateStale: (environmentId) => { + // No-op when the environment has no SSH bucket (e.g. web client). + useAppStore.getState().markEnvironmentSshStateStale(environmentId) + }, + sync: runtimeClientEventsSync.sync + }) + const unsubscribeRuntimeEnvironmentStore = useAppStore.subscribe( + handleRuntimeEnvironmentStoreWrite + ) + // Subscribe before the first runtime stream starts: replay invalidation may + // synchronously publish a tracked SSH bucket and relies on this listener to + // replace that subscription exactly once. + runtimeClientEventsSync.sync() + unsubs.push(runtimeClientEventsSync.stop) + unsubs.push(runtimeProjectRefreshScheduler.stop) + + return unsubscribeRuntimeEnvironmentStore +} diff --git a/src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts b/src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts new file mode 100644 index 00000000000..b2fee2d646e --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts @@ -0,0 +1,203 @@ +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' +import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' +import type { AppState } from '../../store/types' + +export type RuntimeEnvironmentStoreSyncState = Pick< + AppState, + 'runtimeEnvironments' | 'runtimeStatusByEnvironmentId' | 'settings' | 'sshStateByEnvironment' +> + +function getActiveRuntimeEnvironmentId(state: RuntimeEnvironmentStoreSyncState): string | null { + return state.settings?.activeRuntimeEnvironmentId?.trim() || null +} + +export function getRuntimeClientEventEnvironmentIds( + state: RuntimeEnvironmentStoreSyncState +): string[] { + const ids = new Set() + const activeEnvironmentId = getActiveRuntimeEnvironmentId(state) + if (activeEnvironmentId) { + ids.add(activeEnvironmentId) + } + for (const environment of state.runtimeEnvironments ?? []) { + if (state.runtimeStatusByEnvironmentId?.get(environment.id)?.status) { + ids.add(environment.id) + } + } + return [...ids] +} + +export function getReachableRuntimeEnvironmentIds( + state: RuntimeEnvironmentStoreSyncState +): string[] { + const ids: string[] = [] + for (const [environmentId, status] of state.runtimeStatusByEnvironmentId ?? []) { + if (status?.status) { + ids.push(environmentId) + } + } + return ids +} + +export function canSkipRuntimeEnvironmentStoreSync( + state: RuntimeEnvironmentStoreSyncState, + previousState: RuntimeEnvironmentStoreSyncState +): boolean { + return ( + state.runtimeEnvironments === previousState.runtimeEnvironments && + state.runtimeStatusByEnvironmentId === previousState.runtimeStatusByEnvironmentId && + state.sshStateByEnvironment === previousState.sshStateByEnvironment && + getActiveRuntimeEnvironmentId(state) === getActiveRuntimeEnvironmentId(previousState) + ) +} + +export function buildRuntimeClientEventEnvironmentKey(environmentIds: string[]): string { + return [...new Set(environmentIds)] + .sort() + .map( + (environmentId) => + `${environmentId}:${getRuntimeEnvironmentConnectionGeneration(environmentId)}:${getEnvironmentSshStateGeneration(environmentId)}:${getRuntimeEnvironmentRevision(environmentId) ?? 'unknown'}` + ) + .join('\u0000') +} + +/** Ids in `next` not in `previous` — environments that just became connected. */ +export function getNewlyConnectedRuntimeEnvironmentIds( + previous: readonly string[], + next: readonly string[] +): string[] { + const known = new Set(previous) + return [...new Set(next)].filter((environmentId) => !known.has(environmentId)) +} + +/** Ids in `previous` not in `next` — environments whose transport was just observed down. */ +export function getNewlyDisconnectedRuntimeEnvironmentIds( + previous: readonly string[], + next: readonly string[] +): string[] { + return getNewlyConnectedRuntimeEnvironmentIds(next, previous) +} + +export function getRuntimeProjectRefreshEnvironmentIds(args: { + previousDesired: readonly string[] + nextDesired: readonly string[] + previousReachable: readonly string[] + nextReachable: readonly string[] +}): string[] { + return [ + ...new Set([ + ...getNewlyConnectedRuntimeEnvironmentIds(args.previousDesired, args.nextDesired), + ...getNewlyConnectedRuntimeEnvironmentIds(args.previousReachable, args.nextReachable) + ]) + ] +} + +type RuntimeEnvironmentStoreSyncSubscriberDeps = { + initialDesiredEnvironmentIds: string[] + initialReachableEnvironmentIds: string[] + buildEnvironmentKey: (environmentIds: string[]) => string + getDesiredEnvironmentIds: (state: RuntimeEnvironmentStoreSyncState) => string[] + getReachableEnvironmentIds: (state: RuntimeEnvironmentStoreSyncState) => string[] + requestProjectRefresh: (environmentId: string) => void + markEnvironmentSshStateStale: (environmentId: string) => void + sync: () => void +} + +export type RuntimeEnvironmentStoreSyncSubscriber = ( + state: RuntimeEnvironmentStoreSyncState, + previousState: RuntimeEnvironmentStoreSyncState +) => void + +/** + * Builds the one renderer-wide runtime subscriber. The reference gate runs + * before either host collection is enumerated; key generation remains the + * second gate for relevant-reference writes whose effective subscription set + * did not change. + */ +export function createRuntimeEnvironmentStoreSyncSubscriber( + deps: RuntimeEnvironmentStoreSyncSubscriberDeps +): RuntimeEnvironmentStoreSyncSubscriber { + let desiredEnvironmentIds = deps.initialDesiredEnvironmentIds + let desiredEnvironmentKey = deps.buildEnvironmentKey(desiredEnvironmentIds) + let reachableEnvironmentIds = deps.initialReachableEnvironmentIds + let reachableEnvironmentKey = deps.buildEnvironmentKey(reachableEnvironmentIds) + let handlingStoreWrite = false + + return (state, previousState) => { + // markEnvironmentSshStateStale can synchronously publish its nested SSH + // bucket. The outer pass incorporates that generation before syncing, so a + // re-entrant pass would only enumerate and sync the same transition twice. + if (handlingStoreWrite || canSkipRuntimeEnvironmentStoreSync(state, previousState)) { + return + } + + handlingStoreWrite = true + try { + const nextDesiredEnvironmentIds = deps.getDesiredEnvironmentIds(state) + const nextReachableEnvironmentIds = deps.getReachableEnvironmentIds(state) + const refreshEnvironmentIds = getRuntimeProjectRefreshEnvironmentIds({ + previousDesired: desiredEnvironmentIds, + nextDesired: nextDesiredEnvironmentIds, + previousReachable: reachableEnvironmentIds, + nextReachable: nextReachableEnvironmentIds + }) + const disconnectedEnvironmentIds = getNewlyDisconnectedRuntimeEnvironmentIds( + reachableEnvironmentIds, + nextReachableEnvironmentIds + ) + + desiredEnvironmentIds = nextDesiredEnvironmentIds + reachableEnvironmentIds = nextReachableEnvironmentIds + for (const environmentId of refreshEnvironmentIds) { + deps.requestProjectRefresh(environmentId) + } + for (const environmentId of disconnectedEnvironmentIds) { + deps.markEnvironmentSshStateStale(environmentId) + } + + // Build after disconnect invalidation: marking a mirrored SSH bucket stale + // advances its generation, and the replacement subscription must capture + // that final generation in this same (single) sync. + const nextDesiredEnvironmentKey = deps.buildEnvironmentKey(desiredEnvironmentIds) + const nextReachableEnvironmentKey = deps.buildEnvironmentKey(reachableEnvironmentIds) + if ( + nextDesiredEnvironmentKey === desiredEnvironmentKey && + nextReachableEnvironmentKey === reachableEnvironmentKey + ) { + return + } + desiredEnvironmentKey = nextDesiredEnvironmentKey + reachableEnvironmentKey = nextReachableEnvironmentKey + deps.sync() + } finally { + handlingStoreWrite = false + } + } +} + +type RuntimeClientEventReplayInvalidationDeps = { + getSshStateReference: () => RuntimeEnvironmentStoreSyncState['sshStateByEnvironment'] + requestProjectRefresh: () => void + markEnvironmentSshStateStale: () => void + hydrateEnvironmentSshState: () => Promise + sync: () => void +} + +/** + * Invalidates a replay after the runtime event stream reports a transport gap. + * A tracked SSH bucket publishes synchronously and lets the store subscriber + * sync it; an empty/already-stale bucket has no reference publication, so this + * path must explicitly sync the advanced module-level SSH generation. + */ +export function invalidateRuntimeClientEventReplay( + deps: RuntimeClientEventReplayInvalidationDeps +): void { + deps.requestProjectRefresh() + const previousSshStateReference = deps.getSshStateReference() + deps.markEnvironmentSshStateStale() + if (deps.getSshStateReference() === previousSshStateReference) { + deps.sync() + } + void deps.hydrateEnvironmentSshState().catch(() => {}) +} diff --git a/src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts new file mode 100644 index 00000000000..b0b284fbd79 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts @@ -0,0 +1,108 @@ +import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' +import { + SESSION_TAB_CLOSE_CANCELED_ERROR, + SESSION_TAB_CLOSE_FAILED_ERROR, + SESSION_TAB_NOT_FOUND_ERROR, + SESSION_TAB_CLOSE_TIMEOUT_ERROR +} from '../../../../shared/session-tab-close' +import { + guardPinnedTabClose, + isUnifiedTabPinned, + resolvePinnedTabLabel +} from '../../store/pinned-tab-close-guard' +import { useAppStore } from '../../store' +import { resolveBrowserSessionTabTarget } from './browser-session-tab-target' + +export function registerSessionTabIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onCloseSessionTab(({ tabId, worktreeId }) => { + const store = useAppStore.getState() + const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId) + if (browserTarget) { + guardPinnedTabClose({ + isPinned: isUnifiedTabPinned(store, worktreeId, browserTarget.workspaceId), + tabLabel: resolvePinnedTabLabel(store, worktreeId, browserTarget.workspaceId), + onClose: () => useAppStore.getState().closeBrowserTab(browserTarget.workspaceId) + }) + return + } + guardPinnedTabClose({ + isPinned: isUnifiedTabPinned(store, worktreeId, tabId), + tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId), + onClose: () => { + const currentStore = useAppStore.getState() + closeMobileSessionTabInStore(currentStore, worktreeId, tabId) + } + }) + }) + ) + + unsubs.push( + window.api.ui.onSessionTabCloseRequest(({ requestId, tabId, worktreeId, expiresAt }) => { + const store = useAppStore.getState() + const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId) + let cancelConfirmation: (() => void) | undefined + let timeout: ReturnType | undefined + let settled = false + const respond = (error?: string): void => { + if (settled) { + return + } + settled = true + if (timeout !== undefined) { + clearTimeout(timeout) + } + window.api.ui.respondSessionTabClose({ requestId, ...(error ? { error } : {}) }) + } + if (expiresAt !== undefined) { + timeout = setTimeout( + () => { + cancelConfirmation?.() + respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR) + }, + Math.max(0, expiresAt - Date.now()) + ) + } + const closeAndRespond = (): void => { + if (expiresAt !== undefined && Date.now() >= expiresAt) { + respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR) + return + } + try { + if (browserTarget) { + useAppStore.getState().closeBrowserTab(browserTarget.workspaceId) + respond() + return + } + const closed = closeMobileSessionTabInStore(useAppStore.getState(), worktreeId, tabId) + respond(closed ? undefined : SESSION_TAB_NOT_FOUND_ERROR) + } catch (error) { + respond(error instanceof Error ? error.message : SESSION_TAB_CLOSE_FAILED_ERROR) + } + } + const visibleId = browserTarget?.workspaceId ?? tabId + cancelConfirmation = guardPinnedTabClose({ + isPinned: isUnifiedTabPinned(store, worktreeId, visibleId), + tabLabel: resolvePinnedTabLabel(store, worktreeId, visibleId), + onClose: closeAndRespond, + onCancel: () => respond(SESSION_TAB_CLOSE_CANCELED_ERROR) + }) + }) + ) + + unsubs.push( + window.api.ui.onMoveSessionTab((move) => { + const { tabId, targetGroupId } = move + const store = useAppStore.getState() + if (move.kind === 'reorder') { + store.reorderUnifiedTabs(targetGroupId, move.tabOrder) + return + } + store.dropUnifiedTab(tabId, { + groupId: targetGroupId, + ...(move.kind === 'move-to-group' ? { index: move.index } : {}), + ...(move.kind === 'split' ? { splitDirection: move.splitDirection } : {}) + }) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts new file mode 100644 index 00000000000..bf3d74dd3a9 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts @@ -0,0 +1,190 @@ +import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility' +import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' +import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' +import { subscribeToUnpairedDeviceAuthNotification } from '../unpaired-device-auth-notification' +import { translate } from '@/i18n/i18n' +import { toast } from 'sonner' +import { useAppStore } from '../../store' + +function getShortcutPlatform(): NodeJS.Platform { + if (navigator.userAgent.includes('Mac')) { + return 'darwin' + } + if (navigator.userAgent.includes('Windows')) { + return 'win32' + } + return 'linux' +} + +export function registerSettingsAndSidebarIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onOpenSettings(() => { + useAppStore.getState().openSettingsPage() + }) + ) + + const unsubscribeOpenSkillShare = window.api.ui.onOpenSkillShare?.((shareId) => { + useAppStore.getState().openSkillShare(shareId) + }) + if (unsubscribeOpenSkillShare) { + unsubs.push(unsubscribeOpenSkillShare) + } + + // Why: a tray "Settings…" click can fire before this attaches; consume any queued intent (?. guards stale preload). + void window.api.ui + .consumePendingOpenSettings?.() + .then((open) => { + if (open) { + useAppStore.getState().openSettingsPage() + } + }) + .catch(() => {}) + + const pendingSkillShare = window.api.ui.consumePendingSkillShare?.() + if (pendingSkillShare && typeof pendingSkillShare.then === 'function') { + void pendingSkillShare + .then((shareId) => { + if (shareId) { + useAppStore.getState().openSkillShare(shareId) + } + }) + .catch(() => {}) + } + + unsubs.push( + window.api.ui.onOpenSetupGuide?.(() => { + useAppStore.getState().openModal('setup-guide', { telemetrySource: 'help_menu' }) + }) ?? (() => {}) + ) + + // Why: a phone stuck in a silent 4001 auth loop (lost device registry) reads as + // "phone won't connect" with no clue on either end; main throttles to once per session. + unsubs.push( + subscribeToUnpairedDeviceAuthNotification(window.api.mobile, () => { + toast.warning( + translate( + 'auto.hooks.useIpcEvents.ef223fbb6b', + 'A device tried to connect but is not paired' + ), + { + id: 'unpaired-device-auth-failure', + description: translate( + 'auto.hooks.useIpcEvents.11992d0337', + 'If this was your phone or another Orca client, re-pair it from Settings → Mobile.' + ), + // Why: main emits this recovery path once per session, so it must remain visible until acted on or dismissed. + duration: Infinity, + action: { + label: translate('auto.hooks.useIpcEvents.6573cfe955', 'Open Mobile Settings'), + onClick: () => { + const store = useAppStore.getState() + store.openSettingsTarget({ pane: 'mobile', repoId: null }) + store.openSettingsPage() + } + } + } + ) + }) + ) + + unsubs.push( + window.api.ui.onOpenFeatureTour(() => { + useAppStore.getState().openModal('feature-wall', { source: 'help_menu' }) + }) + ) + + // Why: View > Appearance toggles settings in main and broadcasts; merge into the store for an immediate re-render. + unsubs.push( + window.api.settings.onChanged((updates) => { + const store = useAppStore.getState() + if (!store.settings) { + return + } + const { worktreeVisibilityDefaults, ...activeOwnerUpdates } = updates + const settingsUpdates = store.settings.activeRuntimeEnvironmentId + ? activeOwnerUpdates + : updates + useAppStore.setState({ + settings: { + ...store.settings, + ...settingsUpdates, + notifications: { + ...store.settings.notifications, + ...updates.notifications + } + }, + ...(worktreeVisibilityDefaults + ? { + worktreeVisibilityDefaultsByHost: { + ...store.worktreeVisibilityDefaultsByHost, + local: worktreeVisibilityDefaults + } + } + : {}) + }) + if ('worktreeVisibilityDefaults' in updates) { + void store.fetchAllWorktrees({ visibilityOwnerHostId: 'local' }) + } + }) + ) + + // Why: UI view-state is shared with mobile via ui.set; re-hydrate so mobile changes reflect live in the desktop sidebar. + unsubs.push( + window.api.ui.onStateChanged((ui) => { + useAppStore.getState().hydratePersistedUI(ui, 'sync') + }) + ) + + if (window.api.keybindings) { + unsubs.push( + window.api.keybindings.onChanged((snapshot) => { + useAppStore.getState().setKeybindingSnapshot(snapshot) + }) + ) + } + + unsubs.push( + window.api.ui.onToggleLeftSidebar(() => { + useAppStore.getState().toggleSidebar() + }) + ) + + unsubs.push( + window.api.ui.onToggleRightSidebar(() => { + const store = useAppStore.getState() + if (!canShowRightSidebarForView(store.activeView)) { + return + } + store.toggleRightSidebar() + }) + ) + + unsubs.push( + window.api.ui.onToggleWorktreePalette(() => { + const store = useAppStore.getState() + if (store.activeModal === 'worktree-palette') { + store.closeModal() + return + } + store.openModal('worktree-palette') + }) + ) + + unsubs.push( + window.api.ui.onToggleFloatingTerminal(() => { + window.dispatchEvent(new CustomEvent(TOGGLE_FLOATING_TERMINAL_EVENT)) + }) + ) + + if (window.api.ui.onTerminalShortcutCaptured) { + unsubs.push( + window.api.ui.onTerminalShortcutCaptured(({ actionId }) => { + showTerminalShortcutCaptureNotification({ + actionId, + platform: getShortcutPlatform(), + keybindings: useAppStore.getState().keybindings + }) + }) + ) + } +} diff --git a/src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts new file mode 100644 index 00000000000..d585e721ff6 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts @@ -0,0 +1,180 @@ +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + closeWebRuntimeSessionTab, + createWebRuntimeSessionTerminal, + isWebRuntimeSessionActive +} from '@/runtime/web-runtime-session' +import { + guardPinnedTabClose, + isUnifiedTabPinned, + resolvePinnedTabLabel +} from '../../store/pinned-tab-close-guard' +import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' +import { + createFloatingWorkspaceTerminalTab, + isEmptyFloatingWorkspacePanelVisible, + isFloatingWorkspacePanelFocused, + resolveFloatingWorkspaceBrowserWorkspaceId, + switchFloatingWorkspaceTab +} from '@/lib/floating-workspace-terminal-actions' +import { + dispatchFloatingWorkspaceGuestClose, + dispatchFloatingWorkspaceGuestSelectIndex +} from '@/lib/floating-workspace-guest-bridge' + +import { useAppStore } from '../../store' +import { + handleSwitchRecentTab, + handleSwitchTab, + handleSwitchTabAcrossAllTypes, + handleSwitchTerminalTab +} from '../ipc-tab-switch' +function getWorktreeRuntimeEnvironmentId(worktreeId: string | null | undefined): string | null { + return getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) +} + +export function registerTabLifecycleIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onNewTerminalTab(() => { + const store = useAppStore.getState() + if (isFloatingWorkspacePanelFocused()) { + void createFloatingWorkspaceTerminalTab(store) + return + } + const worktreeId = store.activeWorktreeId + if (!worktreeId) { + return + } + void (async () => { + const environmentId = getWorktreeRuntimeEnvironmentId(worktreeId) + const outcome = await createWebRuntimeSessionTerminal({ + worktreeId, + environmentId, + activate: true + }) + if (outcome.status === 'created' || isWebRuntimeSessionActive(environmentId)) { + return + } + const newTab = store.createTab(worktreeId) + store.setActiveTabType('terminal') + // Why: mirror Terminal.tsx handleNewTab so a new tab appends at the end, not index 0, when tabBarOrder is unset. + const freshStore = useAppStore.getState() + const currentTerminals = freshStore.tabsByWorktree[worktreeId] ?? [] + const currentEditors = freshStore.openFiles.filter((f) => f.worktreeId === worktreeId) + const currentBrowsers = freshStore.browserTabsByWorktree[worktreeId] ?? [] + const stored = freshStore.tabBarOrderByWorktree[worktreeId] + const termIds = currentTerminals.map((t) => t.id) + const editorIds = currentEditors.map((f) => f.id) + const browserIds = currentBrowsers.map((tab) => tab.id) + const validIds = new Set([...termIds, ...editorIds, ...browserIds]) + const base = (stored ?? []).filter((id) => validIds.has(id)) + const inBase = new Set(base) + for (const id of [...termIds, ...editorIds, ...browserIds]) { + if (!inBase.has(id)) { + base.push(id) + inBase.add(id) + } + } + const order = base.filter((id) => id !== newTab.id) + order.push(newTab.id) + freshStore.setTabBarOrder(worktreeId, order) + focusTerminalTabSurface(newTab.id) + })() + }) + ) + + unsubs.push( + window.api.ui.onCloseActiveTab(() => { + if (isEmptyFloatingWorkspacePanelVisible()) { + window.dispatchEvent(new Event(TOGGLE_FLOATING_TERMINAL_EVENT)) + return + } + const store = useAppStore.getState() + if (store.activeTabType === 'browser' && store.activeBrowserTabId) { + const tabId = store.activeBrowserTabId + const worktreeId = store.activeWorktreeId + const closeActiveBrowserTab = (): void => { + const currentStore = useAppStore.getState() + const environmentId = getWorktreeRuntimeEnvironmentId(worktreeId) + if (environmentId && worktreeId) { + if (!isWebRuntimeSessionActive(environmentId)) { + currentStore.closeBrowserTab(tabId) + return + } + void closeWebRuntimeSessionTab({ + worktreeId, + tabId, + environmentId, + reason: 'user' + }) + return + } + currentStore.closeBrowserTab(tabId) + } + if (worktreeId && isUnifiedTabPinned(store, worktreeId, tabId)) { + guardPinnedTabClose({ + isPinned: true, + tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId), + onClose: closeActiveBrowserTab + }) + return + } + closeActiveBrowserTab() + } + }) + ) + + unsubs.push( + window.api.ui.onCloseFloatingItem(({ sourceId }) => { + // Main forwards the guest's browser *page* id; resolve it to the owning live floating + // browser workspace (the id space the panel closes by), then hand off to the mounted + // panel's own close closure (pin guard + reclaim intent). Stale id = no-op. + const workspaceId = resolveFloatingWorkspaceBrowserWorkspaceId( + useAppStore.getState(), + sourceId + ) + if (!workspaceId) { + return + } + dispatchFloatingWorkspaceGuestClose({ sourceId: workspaceId }) + }) + ) + unsubs.push( + window.api.ui.onSelectFloatingIndex(({ index }) => { + dispatchFloatingWorkspaceGuestSelectIndex({ index }) + }) + ) + + unsubs.push( + window.api.ui.onSwitchTab((direction) => { + const store = useAppStore.getState() + if (isFloatingWorkspacePanelFocused()) { + switchFloatingWorkspaceTab(store, direction, 'same-type') + return + } + handleSwitchTab(direction) + }) + ) + unsubs.push( + window.api.ui.onSwitchTabAcrossAllTypes((direction) => { + const store = useAppStore.getState() + if (isFloatingWorkspacePanelFocused()) { + switchFloatingWorkspaceTab(store, direction, 'all-types') + return + } + handleSwitchTabAcrossAllTypes(direction) + }) + ) + unsubs.push(window.api.ui.onSwitchRecentTab(handleSwitchRecentTab)) + unsubs.push( + window.api.ui.onSwitchTerminalTab((direction) => { + const store = useAppStore.getState() + if (isFloatingWorkspacePanelFocused()) { + switchFloatingWorkspaceTab(store, direction, 'terminal') + return + } + handleSwitchTerminalTab(direction) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/terminal-command-state.ts b/src/renderer/src/hooks/ipc-events/terminal-command-state.ts new file mode 100644 index 00000000000..3e6c61b506f --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/terminal-command-state.ts @@ -0,0 +1,132 @@ +import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { focusRuntimeTerminalSurface } from '@/runtime/sync-runtime-graph' +import type { RuntimeTerminalPresentation } from '../../../../shared/runtime-types' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode +} from '../../../../shared/terminal-tab-types' +import type { AppState } from '../../store/types' + +export function resolveTerminalPresentation(data: { + presentation?: RuntimeTerminalPresentation + activate?: boolean + focus?: boolean +}): RuntimeTerminalPresentation | undefined { + if (data.presentation) { + return data.presentation + } + if (data.focus !== undefined) { + return data.focus ? 'focused' : 'background' + } + if (data.activate === true) { + return 'focused' + } + return undefined +} + +export function focusTerminalInitiatedTab(tabId: string, leafId?: string | null): void { + if (!focusRuntimeTerminalSurface(tabId, leafId)) { + focusTerminalTabSurface(tabId, leafId) + } +} + +export function activateTerminalInitiatedWorktree(store: AppState, worktreeId: string): void { + store.setActiveView('terminal') + store.setActiveWorktree(worktreeId) + store.markWorktreeVisited(worktreeId) + if (!store.isNavigatingHistory) { + store.recordWorktreeVisit(worktreeId) + } +} + +type TerminalSplitDirection = 'horizontal' | 'vertical' + +function insertLeafAfterSource( + node: TerminalPaneLayoutNode, + sourceLeafId: string, + newLeafId: string, + direction: TerminalSplitDirection +): { node: TerminalPaneLayoutNode; inserted: boolean } { + if (node.type === 'leaf') { + if (node.leafId !== sourceLeafId) { + return { node, inserted: false } + } + return { + node: { + type: 'split', + direction, + first: node, + second: { type: 'leaf', leafId: newLeafId }, + ratio: 0.5 + }, + inserted: true + } + } + const first = insertLeafAfterSource(node.first, sourceLeafId, newLeafId, direction) + if (first.inserted) { + return { node: { ...node, first: first.node }, inserted: true } + } + const second = insertLeafAfterSource(node.second, sourceLeafId, newLeafId, direction) + return second.inserted + ? { node: { ...node, second: second.node }, inserted: true } + : { node, inserted: false } +} + +export function addSplitLeafToLayout( + layout: TerminalLayoutSnapshot | null | undefined, + sourceLeafId: string, + newLeafId: string, + ptyId: string, + direction: TerminalSplitDirection, + title?: string | null, + activateNewLeaf = true +): TerminalLayoutSnapshot { + const root = layout?.root ?? { type: 'leaf', leafId: sourceLeafId } + const existingLeafIds = collectLeafIdsInOrder(root) + const nextActiveLeafId = + activateNewLeaf || !layout?.activeLeafId || !existingLeafIds.includes(layout.activeLeafId) + ? newLeafId + : layout.activeLeafId + const nextRoot = existingLeafIds.includes(newLeafId) + ? root + : (() => { + const inserted = insertLeafAfterSource(root, sourceLeafId, newLeafId, direction) + if (inserted.inserted) { + return inserted.node + } + return { + type: 'split' as const, + direction, + first: root, + second: { type: 'leaf' as const, leafId: newLeafId }, + ratio: 0.5 + } + })() + return { + ...(layout ?? { root: null, activeLeafId: null, expandedLeafId: null }), + root: nextRoot, + activeLeafId: nextActiveLeafId, + expandedLeafId: null, + ptyIdsByLeafId: { ...layout?.ptyIdsByLeafId, [newLeafId]: ptyId }, + ...(title ? { titlesByLeafId: { ...layout?.titlesByLeafId, [newLeafId]: title } } : {}) + } +} + +export function activateExistingLeafInLayout( + layout: TerminalLayoutSnapshot | null | undefined, + leafId: string, + ptyId: string, + title?: string | null +): TerminalLayoutSnapshot | null { + if (!layout?.root || !collectLeafIdsInOrder(layout.root).includes(leafId)) { + return null + } + return { + ...layout, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { ...layout.ptyIdsByLeafId, [leafId]: ptyId }, + ...(title ? { titlesByLeafId: { ...layout.titlesByLeafId, [leafId]: title } } : {}) + } +} diff --git a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts new file mode 100644 index 00000000000..0be417cf3e3 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts @@ -0,0 +1,272 @@ +import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' +import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph' +import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' +import { resolveTerminalTabPtyOwnership } from '@/lib/terminal-tab-for-pty-id' +import { SPLIT_TERMINAL_PANE_EVENT } from '@/constants/terminal' +import type { SplitTerminalPaneDetail } from '@/constants/terminal' +import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' +import { verifyTerminalRevealIdentity } from '@/lib/terminal-reveal-identity' +import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { tryMakePaneKey } from './agent-status-routing' +import { useAppStore } from '../../store' +import { + activateExistingLeafInLayout, + activateTerminalInitiatedWorktree, + addSplitLeafToLayout, + focusTerminalInitiatedTab, + resolveTerminalPresentation +} from './terminal-command-state' + +export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onCreateTerminal( + ({ + requestId, + worktreeId, + command, + cwd, + env, + launchConfig, + resumeProviderSession, + launchToken, + launchAgent, + viewMode, + title, + ptyId, + activate, + focus, + presentation, + surfaceOwner, + tabId, + leafId, + splitFromLeafId, + splitDirection, + splitTelemetrySource + }) => { + try { + const store = useAppStore.getState() + const terminalPresentation = resolveTerminalPresentation({ + presentation, + activate, + focus + }) + const shouldActivate = terminalPresentation === 'focused' + const shouldSurfaceOwner = terminalPresentation !== 'background' && surfaceOwner !== false + if (shouldActivate) { + activateTerminalInitiatedWorktree(store, worktreeId) + } + const worktreeTabs = store.tabsByWorktree[worktreeId] ?? [] + // Why: a split pane revealed from mobile is only bound in the persisted + // layout until its pane mounts; missing it minted a duplicate tab (#10486). + const ownership = ptyId + ? resolveTerminalTabPtyOwnership( + store, + worktreeId, + ptyId, + tabId !== undefined ? { preferTabId: tabId } : {} + ) + : { kind: 'none' as const } + const existingTab = + ownership.kind === 'owned' + ? worktreeTabs.find((candidate) => candidate.id === ownership.tabId) + : undefined + const isSplitReveal = Boolean(ptyId && tabId && leafId && splitFromLeafId) + const splitTargetTab = isSplitReveal + ? worktreeTabs.find((candidate) => candidate.id === tabId) + : undefined + if (isSplitReveal && !splitTargetTab) { + throw new Error(`Terminal tab ${tabId} not found`) + } + const reusedTab = existingTab ?? splitTargetTab + const tab = + reusedTab ?? + (ptyId + ? store.createTab(worktreeId, undefined, undefined, { + initialPtyId: ptyId, + activate: shouldActivate, + ...(launchAgent + ? { + launchAgent, + // Why: a paired client resolved explicit mode before PTY materialization; only omitted mode uses host defaults. + ...(viewMode + ? { viewMode } + : initialAgentTabViewModeProps(store.settings, { + agent: launchAgent, + nativeChatTranscriptIsLocalReadable: + isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) + })) + } + : {}), + ...(cwd ? { startupCwd: cwd } : {}), + // Why: CLI-spawned PTYs bake the pane key into env; adopt the same tab id so hook-event attribution keeps working. + ...(tabId !== undefined ? { id: tabId } : {}) + }) + : store.createTab( + worktreeId, + undefined, + undefined, + shouldActivate + ? cwd + ? { startupCwd: cwd } + : undefined + : { + activate: false, + recordInteraction: false, + ...(cwd ? { startupCwd: cwd } : {}) + } + )) + // Why: a reused tab whose id differs from the hint breaks the PTY's baked-in paneKey attribution; warn during dev. + if (tabId !== undefined && tab.id !== tabId) { + console.warn( + `[onCreateTerminal] tabId hint ${tabId} ignored for ptyId ${ptyId}; existing tab ${tab.id} adopted instead (hook attribution will degrade for this terminal)` + ) + } + if (shouldActivate) { + store.setActiveTabType('terminal') + store.setActiveTab(tab.id) + } + if (shouldSurfaceOwner) { + store.revealWorktreeInSidebar(worktreeId) + focusTerminalInitiatedTab(tab.id, leafId) + } + // Why: only stamp the runtime title on fresh tabs; reused tabs may have a user customTitle it would overwrite on focus. + if (title && !reusedTab) { + store.setTabCustomTitle(tab.id, title, { recordInteraction: false }) + } + if (leafId && ptyId) { + const launchPaneKey = tryMakePaneKey(tab.id, leafId) + if (launchConfig) { + if (launchPaneKey) { + store.registerAgentLaunchConfig(launchPaneKey, launchConfig, { + ...(launchAgent ? { agentType: launchAgent } : {}), + ...(launchToken ? { launchToken } : {}), + tabId: tab.id, + leafId + }) + } + } else if (!splitFromLeafId && launchPaneKey) { + store.clearAgentLaunchConfig(launchPaneKey) + } + if (splitFromLeafId) { + // Why: runtime split PTYs already carry the parent tab's paneKey, so reuse the tab instead of minting a collision tab. + store.updateTabPtyId(tab.id, ptyId) + const existingLayout = store.terminalLayoutsByTabId?.[tab.id] + const sourcePtyId = existingLayout?.ptyIdsByLeafId?.[splitFromLeafId] + store.setTabLayout( + tab.id, + addSplitLeafToLayout( + existingLayout, + splitFromLeafId, + leafId, + ptyId, + splitDirection ?? 'horizontal', + title, + shouldActivate + ) + ) + window.dispatchEvent( + new CustomEvent(SPLIT_TERMINAL_PANE_EVENT, { + detail: { + tabId: tab.id, + paneRuntimeId: -1, + direction: splitDirection ?? 'horizontal', + sourceLeafId: splitFromLeafId, + sourcePtyId, + telemetrySource: splitTelemetrySource, + newLeafId: leafId, + ptyId + } + }) + ) + } else { + // Why: CLI/runtime PTYs emit hook events before the tab mounts, so the leaf must exist in layout for paneKey validation. + const existingLayout = reusedTab + ? activateExistingLeafInLayout( + store.terminalLayoutsByTabId?.[tab.id], + leafId, + ptyId, + title + ) + : null + if (existingLayout) { + store.updateTabPtyId(tab.id, ptyId) + store.setTabLayout(tab.id, existingLayout) + } else { + store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId, title)) + } + } + } + if (command) { + store.queueTabStartupCommand(tab.id, { + command, + ...(env ? { env } : {}), + ...(launchConfig ? { launchConfig } : {}), + ...(resumeProviderSession ? { resumeProviderSession } : {}), + ...(launchToken ? { launchToken } : {}), + ...(launchAgent ? { launchAgent } : {}) + }) + } + if (ptyId && terminalPresentation === 'background') { + requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] }) + } + if (requestId) { + // Why: attest the actual binding; recovery callers compare it with their expected identity. + const identity = + ptyId && tabId && leafId + ? verifyTerminalRevealIdentity(useAppStore.getState(), { + worktreeId, + tabId: tab.id, + leafId, + ptyId + }) + : undefined + window.api.ui.replyTerminalCreate({ + requestId, + tabId: tab.id, + title: title ?? tab.title, + ...(identity ? { identity } : {}) + }) + } + } catch (err) { + if (!requestId) { + throw err + } + window.api.ui.replyTerminalCreate({ + requestId, + error: err instanceof Error ? err.message : 'Terminal reveal failed' + }) + } + } + ) + ) + + // Why: background-mount a mobile-subscribed tab's PTY without navigating the desktop (STA-1840). + unsubs.push( + window.api.ui.onRequestTerminalTabMount(({ worktreeId, tabId, ptyId }) => { + if (!worktreeId) { + return + } + // Why: synthetic pty handles need persisted-tab resolution; a miss must not mount every saved tab in a hidden worktree. + const mount = planMobileTerminalTabMount( + useAppStore.getState(), + { + worktreeId, + ...(tabId ? { tabId } : {}), + ...(ptyId ? { ptyId } : {}) + }, + { + isTabMounted: hasRegisteredRuntimeTerminalTab + } + ) + if (mount) { + requestBackgroundTerminalWorktreeMount(mount) + } + }) + ) + + // Why: CLI-driven terminal creation waits for the tabId reply so it can hand the caller a usable handle immediately. +} diff --git a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts new file mode 100644 index 00000000000..ef8540550cd --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts @@ -0,0 +1,152 @@ +import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' +import { + activateTerminalInitiatedWorktree, + focusTerminalInitiatedTab, + resolveTerminalPresentation +} from './terminal-command-state' + +export function registerTerminalRequestIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onRequestTerminalCreate((data) => { + try { + const store = useAppStore.getState() + const worktreeId = data.worktreeId ?? store.activeWorktreeId + if (!worktreeId) { + window.api.ui.replyTerminalCreate({ + requestId: data.requestId, + error: translate('auto.hooks.useIpcEvents.f000b2ff76', 'No active worktree') + }) + return + } + const worktreeRoute = resolveTerminalWorktreeRoute(store, worktreeId) + if (!worktreeRoute) { + window.api.ui.replyTerminalCreate({ + requestId: data.requestId, + error: translate( + 'auto.hooks.useIpcEvents.unresolvedTerminalWorktreeOwner', + 'Terminal creation is unavailable because the worktree owner could not be resolved' + ) + }) + return + } + // Why: runtime-session requests are host-owned tabs materialized by this renderer, not ordinary local creates. + if (worktreeRoute.runtimeEnvironmentId && data.source !== 'runtime-session') { + window.api.ui.replyTerminalCreate({ + requestId: data.requestId, + error: translate( + 'auto.hooks.useIpcEvents.7a64b31991', + 'Local terminal creation is unavailable while a remote runtime is active' + ) + }) + return + } + const terminalPresentation = resolveTerminalPresentation(data) + const shouldActivate = terminalPresentation === 'focused' + const shouldSurfaceOwner = + terminalPresentation !== 'background' && data.surfaceOwner !== false + if (shouldActivate) { + activateTerminalInitiatedWorktree(store, worktreeId) + } + // Why: the paired launch client already resolved the mode, so its choice wins over the host renderer's local default. + const tabOptions = data.launchAgent + ? { + ...(shouldActivate ? {} : { activate: false, recordInteraction: false }), + launchAgent: data.launchAgent, + ...(data.viewMode + ? { viewMode: data.viewMode } + : initialAgentTabViewModeProps(store.settings, { + agent: data.launchAgent, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) + })), + ...(data.cwd ? { startupCwd: data.cwd } : {}) + } + : shouldActivate + ? data.cwd + ? { startupCwd: data.cwd } + : undefined + : { + activate: false, + recordInteraction: false, + ...(data.cwd ? { startupCwd: data.cwd } : {}) + } + const tab = store.createTab(worktreeId, data.targetGroupId, undefined, tabOptions) + if (!shouldActivate) { + // Why: renderer-backed Codex startup must mount its new TerminalPane without switching UI or connecting every saved tab. + requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] }) + } + if (data.afterTabId) { + const createdUnifiedTab = useAppStore + .getState() + .unifiedTabsByWorktree[worktreeId]?.find((item) => item.entityId === tab.id) + const anchorUnifiedTab = useAppStore + .getState() + .unifiedTabsByWorktree[worktreeId]?.find((item) => item.id === data.afterTabId) + if ( + createdUnifiedTab && + anchorUnifiedTab && + createdUnifiedTab.groupId === anchorUnifiedTab.groupId + ) { + const group = useAppStore + .getState() + .groupsByWorktree[worktreeId]?.find((item) => item.id === createdUnifiedTab.groupId) + const order = (group?.tabOrder ?? []).filter((id) => id !== createdUnifiedTab.id) + const anchorIndex = order.indexOf(anchorUnifiedTab.id) + order.splice( + anchorIndex === -1 ? order.length : anchorIndex + 1, + 0, + createdUnifiedTab.id + ) + useAppStore.getState().reorderUnifiedTabs(createdUnifiedTab.groupId, order, { + recordInteraction: false + }) + } + } + if (shouldActivate) { + store.setActiveTabType('terminal') + store.setActiveTab(tab.id) + } + if (shouldSurfaceOwner) { + store.revealWorktreeInSidebar(worktreeId) + focusTerminalInitiatedTab(tab.id) + } + if (data.title) { + store.setTabCustomTitle(tab.id, data.title, { recordInteraction: false }) + } + if (data.command) { + store.queueTabStartupCommand(tab.id, { + command: data.command, + ...(data.env ? { env: data.env } : {}), + ...(data.envToDelete ? { envToDelete: data.envToDelete } : {}), + ...(data.launchConfig ? { launchConfig: data.launchConfig } : {}), + ...(data.resumeProviderSession + ? { resumeProviderSession: data.resumeProviderSession } + : {}), + ...(data.launchToken ? { launchToken: data.launchToken } : {}), + ...(data.launchAgent ? { launchAgent: data.launchAgent } : {}), + ...(data.startupCommandDelivery + ? { startupCommandDelivery: data.startupCommandDelivery } + : {}) + }) + } + window.api.ui.replyTerminalCreate({ + requestId: data.requestId, + tabId: tab.id, + title: data.title ?? tab.title + }) + } catch (err) { + window.api.ui.replyTerminalCreate({ + requestId: data.requestId, + error: err instanceof Error ? err.message : 'Terminal creation failed' + }) + } + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts new file mode 100644 index 00000000000..4a7269fe2e7 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts @@ -0,0 +1,95 @@ +import { SPLIT_TERMINAL_PANE_EVENT } from '@/constants/terminal' +import type { SplitTerminalPaneDetail } from '@/constants/terminal' +import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' +import { useAppStore } from '../../store' +import { resolveBrowserSessionTabTarget } from './browser-session-tab-target' +import { + activateTerminalInitiatedWorktree, + focusTerminalInitiatedTab +} from './terminal-command-state' + +export function registerTerminalUiRoutingIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onSplitTerminal( + ({ tabId, paneRuntimeId, direction, command, telemetrySource }) => { + const detail: SplitTerminalPaneDetail = { + tabId, + paneRuntimeId, + direction, + command, + telemetrySource + } + window.dispatchEvent(new CustomEvent(SPLIT_TERMINAL_PANE_EVENT, { detail })) + } + ) + ) + + unsubs.push( + window.api.ui.onRenameTerminal(({ tabId, title }) => { + useAppStore.getState().setTabCustomTitle(tabId, title) + }) + ) + + unsubs.push( + window.api.ui.onFocusTerminal( + ({ + tabId, + worktreeId, + leafId, + ackPaneKeyOnSuccess, + flashFocusedPane, + scrollToBottomIfOutputSinceLastView + }) => { + const store = useAppStore.getState() + activateTerminalInitiatedWorktree(store, worktreeId) + store.setActiveTab(tabId) + store.revealWorktreeInSidebar(worktreeId) + if (ackPaneKeyOnSuccess || flashFocusedPane || scrollToBottomIfOutputSinceLastView) { + activateTabAndFocusPane(tabId, leafId ?? null, { + ...(ackPaneKeyOnSuccess ? { ackPaneKeyOnSuccess } : {}), + ...(flashFocusedPane ? { flashFocusedPane: true } : {}), + ...(scrollToBottomIfOutputSinceLastView + ? { scrollToBottomIfOutputSinceLastView: true } + : {}) + }) + return + } + focusTerminalInitiatedTab(tabId, leafId) + } + ) + ) + + unsubs.push( + window.api.ui.onFocusEditorTab(({ tabId, worktreeId }) => { + const store = useAppStore.getState() + const tab = (store.unifiedTabsByWorktree[worktreeId] ?? []).find((item) => item.id === tabId) + const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId) + if (!tab) { + if (browserTarget) { + // Why: older/mobile fallback snapshots identify browser tabs by workspace id when no unified tab wrapper exists. + store.setActiveWorktree(worktreeId) + store.markWorktreeVisited(worktreeId) + store.setActiveView('terminal') + store.setActiveBrowserTab(browserTarget.workspaceId) + store.setActiveTabType('browser') + store.revealWorktreeInSidebar(worktreeId) + } + return + } + store.setActiveWorktree(worktreeId) + store.markWorktreeVisited(worktreeId) + store.setActiveView('terminal') + store.focusGroup(worktreeId, tab.groupId) + store.activateTab(tab.id) + if (browserTarget) { + // Why: browser tabs need their own active-page state, not the editor file activation path. + store.setActiveBrowserTab(browserTarget.workspaceId) + store.setActiveTabType('browser') + } else { + store.setActiveFile(tab.entityId) + store.setActiveTabType('editor') + } + store.revealWorktreeInSidebar(worktreeId) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts b/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts new file mode 100644 index 00000000000..761ad4ca6db --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { registerUpdaterStatusIpcBridge } from './updater-status-ipc-bridge' + +const mocks = vi.hoisted(() => ({ setUpdateStatus: vi.fn() })) + +vi.mock('../../store', () => ({ + useAppStore: { + getState: () => ({ + setUpdateStatus: mocks.setUpdateStatus, + clearDismissedUpdateVersion: vi.fn() + }) + } +})) + +describe('registerUpdaterStatusIpcBridge', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.unstubAllGlobals() + }) + + it('registers after requesting the snapshot and preserves the current late snapshot overwrite', async () => { + const order: string[] = [] + let resolveSnapshot: ((status: { state: string }) => void) | undefined + let statusListener: ((status: { state: string }) => void) | undefined + const statusCleanup = vi.fn() + const dismissalCleanup = vi.fn() + vi.stubGlobal('window', { + api: { + updater: { + getStatus: () => { + order.push('snapshot') + return new Promise<{ state: string }>((resolve) => { + resolveSnapshot = resolve + }) + }, + onStatus: (listener: (status: { state: string }) => void) => { + order.push('listener') + statusListener = listener + return statusCleanup + }, + onClearDismissal: () => dismissalCleanup + } + } + }) + + const unsubs: (() => void)[] = [] + registerUpdaterStatusIpcBridge(unsubs) + + expect(order).toEqual(['snapshot', 'listener']) + statusListener?.({ state: 'available' }) + resolveSnapshot?.({ state: 'idle' }) + await Promise.resolve() + expect(mocks.setUpdateStatus.mock.calls).toEqual([ + [{ state: 'available' }], + [{ state: 'idle' }] + ]) + + unsubs.forEach((unsubscribe) => unsubscribe()) + expect(statusCleanup).toHaveBeenCalledOnce() + expect(dismissalCleanup).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.ts new file mode 100644 index 00000000000..6cbd6809123 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.ts @@ -0,0 +1,21 @@ +import type { UpdateStatus } from '../../../../shared/update-status-types' +import { useAppStore } from '../../store' + +/** Installs updater listeners in their historical snapshot-before-push order. */ +export function registerUpdaterStatusIpcBridge(unsubs: (() => void)[]): void { + // Current behavior intentionally permits the initial snapshot to overwrite an earlier push. + window.api.updater.getStatus().then((status) => { + useAppStore.getState().setUpdateStatus(status as UpdateStatus) + }) + + unsubs.push( + window.api.updater.onStatus((raw) => { + useAppStore.getState().setUpdateStatus(raw as UpdateStatus) + }) + ) + unsubs.push( + window.api.updater.onClearDismissal(() => { + useAppStore.getState().clearDismissedUpdateVersion() + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts new file mode 100644 index 00000000000..9e78916fef3 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts @@ -0,0 +1,136 @@ +import { TOGGLE_QUICK_COMMANDS_MENU_EVENT } from '@/lib/quick-commands-menu-events' +import { TOGGLE_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel' +import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' +import { emitCmdJRowIndexJump } from '@/lib/cmd-j-row-index-jump' +import { getVisibleWorktreeShortcutTargets } from '@/components/sidebar/visible-worktrees' +import { activateAndRevealWorkspace } from '@/lib/worktree-activation' +import { deleteHoveredWorkspaceImmediately } from '@/components/sidebar/hovered-workspace-delete' +import { isFloatingWorkspacePanelFocused } from '@/lib/floating-workspace-terminal-actions' +import { isGitRepoKind } from '../../../../shared/repo-kind' +import { useAppStore } from '../../store' +import { toggleAgentDashboardFromShortcut } from './agent-dashboard-command' +import { openNewWorkspaceFromShortcut } from './new-workspace-command' + +export function registerWorkspaceShortcutIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onOpenQuickOpen(() => { + const store = useAppStore.getState() + if (store.activeView === 'terminal' && store.activeWorktreeId !== null) { + store.openModal('quick-open') + } + }) + ) + + unsubs.push( + window.api.ui.onToggleQuickCommandsMenu(() => { + window.dispatchEvent(new CustomEvent(TOGGLE_QUICK_COMMANDS_MENU_EVENT)) + }) + ) + + unsubs.push( + window.api.ui.onOpenNewWorkspace(() => { + const store = useAppStore.getState() + openNewWorkspaceFromShortcut(store) + }) + ) + + if (window.api.ui.onDeleteCurrentWorkspace) { + unsubs.push( + window.api.ui.onDeleteCurrentWorkspace(() => { + if (isFloatingWorkspacePanelFocused()) { + return + } + deleteHoveredWorkspaceImmediately(useAppStore.getState()) + }) + ) + } + + if (window.api.ui.onOpenWorkspaceBoard) { + unsubs.push( + window.api.ui.onOpenWorkspaceBoard(() => { + const store = useAppStore.getState() + if (store.activeView === 'settings') { + return + } + store.setSidebarOpen(true) + window.dispatchEvent(new CustomEvent(TOGGLE_WORKSPACE_BOARD_EVENT)) + }) + ) + } + + if (window.api.ui.onToggleAgentDashboard) { + unsubs.push( + window.api.ui.onToggleAgentDashboard(() => { + toggleAgentDashboardFromShortcut(useAppStore.getState(), () => { + void window.api.dashboard.openPopout() + }) + }) + ) + } + + unsubs.push( + window.api.ui.onOpenTasks(() => { + const store = useAppStore.getState() + if (store.activeView === 'settings' || !store.repos.some((repo) => isGitRepoKind(repo))) { + return + } + store.openTaskPage() + }) + ) + + unsubs.push( + window.api.ui.onJumpToWorktreeIndex((index) => { + const store = useAppStore.getState() + // Why: while Cmd+J is open the digit chord means "activate recent row N" — main already + // preventDefault'd it, so routing it here keeps digits out of the palette's search input. + if (store.activeModal === 'worktree-palette') { + emitCmdJRowIndexJump(index) + return + } + if (store.activeView !== 'terminal') { + return + } + const visibleTargets = getVisibleWorktreeShortcutTargets() + const target = visibleTargets[index] + if (target) { + if (target.executionHostId) { + activateAndRevealWorkspace(target.id, { executionHostId: target.executionHostId }) + } else { + activateAndRevealWorkspace(target.id) + } + } + }) + ) + + unsubs.push( + window.api.ui.onJumpToTabIndex((index) => { + // Why: dropped while Cmd+J is open — never switch tabs behind the overlay. + if (useAppStore.getState().activeModal === 'worktree-palette') { + return + } + activateTabNumberShortcut(index) + }) + ) + + unsubs.push( + window.api.ui.onWorktreeHistoryNavigate((direction) => { + const store = useAppStore.getState() + // Why: mirror button visibility — worktree history nav is only meaningful in the terminal view, so no-op elsewhere. + if (store.activeView !== 'terminal') { + return + } + if (direction === 'back') { + store.goBackWorktree() + } else { + store.goForwardWorktree() + } + }) + ) + + unsubs.push( + window.api.ui.onToggleStatusBar(() => { + const store = useAppStore.getState() + store.setStatusBarVisible(!store.statusBarVisible) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts new file mode 100644 index 00000000000..d5846c28f69 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts @@ -0,0 +1,165 @@ +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' +import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' +import type { AppState } from '../../store/types' +import { useAppStore } from '../../store' +import { + createWorktreeChangeRefreshQueue, + type WorktreeChangeRefreshQueue +} from '../worktree-change-refresh-queue' + +const WORKTREE_RENAME_PURGE_GRACE_MS = 20_000 +const recentlyRenamedWorktreeIdExpiry = new Map() + +function getAuthoritativeDetectedWorktreeIds(state: AppState, repoId: string): Set | null { + const detected = state.detectedWorktreesByRepo[repoId] + return detected?.authoritative === true + ? new Set(detected.worktrees.map((worktree) => worktree.id)) + : null +} +function getVisibleWorktreeIdsForRepo(state: AppState, repoId: string): Set { + return new Set((state.worktreesByRepo[repoId] ?? []).map((worktree) => worktree.id)) +} + +export type WorktreeEventRuntime = { + worktreeChangeRefreshQueue: WorktreeChangeRefreshQueue + activateNotifiedWorktree: ( + event: Extract, + options: { allowRuntimeEnvironment: boolean } + ) => Promise +} + +export function createWorktreeEventRuntime( + unsubs: (() => void)[], + isRuntimeEnvironmentActive: () => boolean +): WorktreeEventRuntime { + const handleWorktreesChanged = async ( + repoId: string, + renamed?: { oldWorktreeId: string; newWorktreeId: string }, + options?: { forceLocalOwner?: boolean; executionHostId?: ExecutionHostId } + ): Promise => { + const localRefreshStartedWithRuntime = + options?.forceLocalOwner === true && isRuntimeEnvironmentActive() + // Why: capture active-ness before migration moves the pointer; re-key maps before the diff so a rename isn't a deletion. + const renamedWasActive = + renamed != null && useAppStore.getState().activeWorktreeId === renamed.oldWorktreeId + if (renamed) { + // Shield both ids from the deletion diff across the rename's event burst — the worktree list lags the on-disk move. + const expiry = Date.now() + WORKTREE_RENAME_PURGE_GRACE_MS + recentlyRenamedWorktreeIdExpiry.set(renamed.oldWorktreeId, expiry) + recentlyRenamedWorktreeIdExpiry.set(renamed.newWorktreeId, expiry) + useAppStore.getState().migrateWorktreeIdentity(renamed.oldWorktreeId, renamed.newWorktreeId) + } + // Why: diff before/after fetch to catch out-of-band deletions and purge worktree state, else zombie ptyId entries leak (design §2c, §4.4). + const state = useAppStore.getState() + const before = + getAuthoritativeDetectedWorktreeIds(state, repoId) ?? + getVisibleWorktreeIdsForRepo(state, repoId) + await state.fetchWorktrees( + repoId, + options?.forceLocalOwner + ? { forceLocalOwner: true } + : options?.executionHostId + ? { + executionHostId: options.executionHostId, + suppressRemoteLineageRefresh: true + } + : undefined + ) + await useAppStore + .getState() + .fetchWorktreeLineage( + options?.forceLocalOwner + ? { forceLocalOwner: true } + : options?.executionHostId + ? { executionHostId: options.executionHostId } + : undefined + ) + // Why: an id change unmounts the active pane; re-activate so the tab reconciles, else it vanishes until re-select. + if (renamedWasActive && renamed) { + useAppStore.getState().setActiveWorktree(renamed.newWorktreeId) + } + // Sweep expired rename-grace entries before any early return, else forced-local + // (or non-authoritative) events let the map grow for the session. + const now = Date.now() + for (const [id, expiry] of recentlyRenamedWorktreeIdExpiry) { + if (expiry <= now) { + recentlyRenamedWorktreeIdExpiry.delete(id) + } + } + // Why: the deletion diff below is repo-wide, but a forced-local scan overlapping + // a runtime cannot prove remote absence (legacy runtime rows may lack hostId). + // fetchWorktrees still purges removed local rows host-scoped; accepted gap: the + // workspace-space entry survives until the next local-only rescan. + if ( + options?.forceLocalOwner && + (localRefreshStartedWithRuntime || isRuntimeEnvironmentActive()) + ) { + return + } + const afterState = useAppStore.getState() + const after = getAuthoritativeDetectedWorktreeIds(afterState, repoId) + if (!after) { + return + } + const removed: string[] = [] + for (const id of before) { + if (after.has(id)) { + continue + } + // A recently renamed worktree's old/new id isn't a deletion — its state moved to the new id; the list just lags. + const graceExpiry = recentlyRenamedWorktreeIdExpiry.get(id) + if (graceExpiry != null && graceExpiry > now) { + continue + } + removed.push(id) + } + if (removed.length > 0) { + console.warn( + `[worktree-purge] diff-based purge removing state for ${removed.length} worktree(s):`, + removed + ) + const purgeHostId = + options?.executionHostId ?? + // A forced-local refresh owns only the local host's panes. + (options?.forceLocalOwner ? LOCAL_EXECUTION_HOST_ID : undefined) + afterState.purgeWorktreeTerminalState( + purgeHostId ? removed.map((id) => ({ id, hostId: purgeHostId })) : removed + ) + afterState.removeWorkspaceSpaceWorktrees(removed) + } + } + const worktreeChangeRefreshQueue = createWorktreeChangeRefreshQueue(handleWorktreesChanged) + unsubs.push(worktreeChangeRefreshQueue.dispose) + + const activateNotifiedWorktree = async ( + { + repoId, + worktreeId, + setup, + startup, + defaultTabs + }: Extract, + options: { allowRuntimeEnvironment: boolean } + ): Promise => { + if (!options.allowRuntimeEnvironment && isRuntimeEnvironmentActive()) { + // Why: local CLI worktree events carry local ids; runtime activation comes via the remote stream, allowed separately. + return + } + const existedBeforeFetch = Boolean(useAppStore.getState().getKnownWorktreeById(worktreeId)) + // Why: fetch first so activation can resolve the CLI-created worktree; it arrived from main, not yet in renderer state. + await useAppStore.getState().fetchWorktrees(repoId) + const existsAfterFetch = Boolean(useAppStore.getState().getKnownWorktreeById(worktreeId)) + // Why: use the canonical activation path so the CLI switch records a back/forward visit, or the nav buttons ignore it. + activateAndRevealWorktree(worktreeId, { + ...(setup ? { setup } : {}), + ...(startup ? { startup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), + ...(!existedBeforeFetch && existsAfterFetch ? { sidebarRevealBehavior: 'auto' } : {}), + // Why: this activation came from the host runtime stream; echoing it back can create a selection loop. + notifyHostRuntime: false + }) + } + + return { worktreeChangeRefreshQueue, activateNotifiedWorktree } +} diff --git a/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts new file mode 100644 index 00000000000..584045b8c12 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts @@ -0,0 +1,48 @@ +import { applyUIZoom } from '@/lib/ui-zoom' +import { computeEditorFontSize, nextEditorFontZoomLevel } from '@/lib/editor-font-zoom' +import { zoomLevelToPercent } from '@/components/settings/SettingsConstants' +import { dispatchZoomLevelChanged } from '@/lib/zoom-events' +import { stepUIZoomLevel } from '../../../../shared/ui-zoom-level' +import { useAppStore } from '../../store' +import { resolveZoomTarget } from '../resolve-zoom-target' + +export function registerZoomIpcBridge(unsubs: (() => void)[]): void { + // Zoom handling for menu accelerators and keyboard fallback paths. + unsubs.push( + window.api.ui.onTerminalZoom((direction) => { + const store = useAppStore.getState() + const { activeView, activeTabType, editorFontZoomLevel, setEditorFontZoomLevel, settings } = + store + const target = resolveZoomTarget({ + activeView, + activeTabType, + activeElement: document.activeElement + }) + if (target === 'terminal') { + return + } + if (target === 'editor') { + const next = nextEditorFontZoomLevel(editorFontZoomLevel, direction) + setEditorFontZoomLevel(next) + void window.api.ui.set({ editorFontZoomLevel: next }) + + // Why: mirror the editor's base font (terminalFontSize) + clamping so the overlay percent matches the rendered size. + const baseFontSize = settings?.terminalFontSize ?? 13 + const actual = computeEditorFontSize(baseFontSize, next) + const percent = Math.round((actual / baseFontSize) * 100) + dispatchZoomLevelChanged('editor', percent) + return + } + + const current = window.api.ui.getZoomLevel() + const next = stepUIZoomLevel(current, direction) + + applyUIZoom(next) + void window.api.ui.set({ uiZoomLevel: next }) + + dispatchZoomLevelChanged('ui', zoomLevelToPercent(next)) + }) + ) + + // Why: re-parse main-process agent status here so the renderer applies the same normalization regardless of hook vs OSC source. +} diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.ts b/src/renderer/src/hooks/remote-workspace-target-sync.ts index 836fd763fc6..b60c26b2ddc 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.ts @@ -16,7 +16,6 @@ import type { import { buildDirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator' import { resolveDirectSshTargetScope } from '../lib/direct-ssh-target-scope' import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' -export { isDirectSshRemoteWorkspaceApplyInProgress } from './remote-workspace-snapshot-apply' const WORKSPACE_HYDRATION_TIMEOUT_MS = 10_000 diff --git a/src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts index 56d9823d652..11c95866ed2 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { toggleAgentDashboardFromShortcut } from './useIpcEvents' +import { toggleAgentDashboardFromShortcut } from './ipc-events/agent-dashboard-command' function makeState( overrides: { diff --git a/src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts b/src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts index f4d274a3838..6854f93161f 100644 --- a/src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { resolveBrowserSessionTabTarget } from './useIpcEvents' +import { resolveBrowserSessionTabTarget } from './ipc-events/browser-session-tab-target' import { createHarnessStoreState, loadIpcEventsHarness } from './ipc-events-test-harness' describe('browser navigation updates', () => { diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts new file mode 100644 index 00000000000..bd80c6aa726 --- /dev/null +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -0,0 +1,502 @@ +import type * as ReactModule from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Mock } from 'vitest' +import { createHarnessStoreState } from './ipc-events-test-harness' +const EXPECTED_DIRECT_CALLBACK_METHODS = [ + 'agentStatus.onClear', + 'agentStatus.onLegacyWorkerTerminalRecovery', + 'agentStatus.onMigrationUnsupported', + 'agentStatus.onMigrationUnsupportedClear', + 'agentStatus.onSet', + 'browser.onActivateView', + 'browser.onCertificateFailureChanged', + 'browser.onGuestLoadFailed', + 'browser.onNavigationUpdate', + 'browser.onOpenLinkInOrcaTab', + 'browser.onPaneFocus', + 'emulator.onAutoAttach', + 'emulator.onPaneFocus', + 'gh.onPRRefreshEvent', + 'keybindings.onChanged', + 'rateLimits.onUpdate', + 'remoteWorkspace.onChanged', + 'repos.onChanged', + 'runtime.onBrowserDriverChanged', + 'runtime.onNativeChatLaunchDraftResolved', + 'runtime.onTerminalDriverChanged', + 'runtime.onTerminalFitOverrideChanged', + 'settings.onChanged', + 'ssh.onCredentialRequest', + 'ssh.onCredentialResolved', + 'ssh.onDetectedPortsChanged', + 'ssh.onPortForwardsChanged', + 'ssh.onStateChanged', + 'ui.onActivateWorktree', + 'ui.onCloseActiveTab', + 'ui.onCloseFloatingItem', + 'ui.onCloseSessionTab', + 'ui.onCloseTerminal', + 'ui.onCreateTerminal', + 'ui.onDeleteCurrentWorkspace', + 'ui.onFocusEditorTab', + 'ui.onFocusTerminal', + 'ui.onFullscreenChanged', + 'ui.onJumpToTabIndex', + 'ui.onJumpToWorktreeIndex', + 'ui.onMoveSessionTab', + 'ui.onNewBrowserTab', + 'ui.onNewMarkdownTab', + 'ui.onNewSimulatorTab', + 'ui.onNewTerminalTab', + 'ui.onOpenDiffFromMobile', + 'ui.onOpenFeatureTour', + 'ui.onOpenFileFromMobile', + 'ui.onOpenNewWorkspace', + 'ui.onOpenQuickOpen', + 'ui.onOpenSettings', + 'ui.onOpenSetupGuide', + 'ui.onOpenSkillShare', + 'ui.onOpenTasks', + 'ui.onOpenWorkspaceBoard', + 'ui.onRenameTerminal', + 'ui.onRequestTabClose', + 'ui.onRequestTabCreate', + 'ui.onRequestTabSetProfile', + 'ui.onRequestTerminalCreate', + 'ui.onRequestTerminalTabMount', + 'ui.onResumeSleepingAgents', + 'ui.onSelectFloatingIndex', + 'ui.onSessionTabCloseRequest', + 'ui.onSleepWorktree', + 'ui.onSplitTerminal', + 'ui.onStateChanged', + 'ui.onSwitchRecentTab', + 'ui.onSwitchTab', + 'ui.onSwitchTabAcrossAllTypes', + 'ui.onSwitchTerminalTab', + 'ui.onSystemResumed', + 'ui.onTerminalShortcutCaptured', + 'ui.onTerminalTabCloseRequest', + 'ui.onTerminalZoom', + 'ui.onToggleAgentDashboard', + 'ui.onToggleFloatingTerminal', + 'ui.onToggleLeftSidebar', + 'ui.onToggleQuickCommandsMenu', + 'ui.onToggleRightSidebar', + 'ui.onToggleStatusBar', + 'ui.onToggleWorktreePalette', + 'ui.onWorktreeHistoryNavigate', + 'updater.onClearDismissal', + 'updater.onStatus', + 'workspaceSpace.onProgress', + 'worktrees.onBaseStatus', + 'worktrees.onChanged', + 'worktrees.onCreateProgress', + 'worktrees.onHeadIdentitiesChanged', + 'worktrees.onRemoteBranchConflict' +] as const + +const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ + 'ui.onMobileMarkdownRequest', + 'repos.onChanged', + 'worktrees.onChanged', + 'worktrees.onHeadIdentitiesChanged', + 'worktrees.onBaseStatus', + 'worktrees.onRemoteBranchConflict', + 'worktrees.onCreateProgress', + 'gh.onPRRefreshEvent', + 'ui.onOpenSettings', + 'ui.onOpenSkillShare', + 'ui.onOpenSetupGuide', + 'mobile.onUnpairedDeviceAuthFailure', + 'ui.onOpenFeatureTour', + 'settings.onChanged', + 'ui.onStateChanged', + 'keybindings.onChanged', + 'ui.onToggleLeftSidebar', + 'ui.onToggleRightSidebar', + 'ui.onToggleWorktreePalette', + 'ui.onToggleFloatingTerminal', + 'ui.onTerminalShortcutCaptured', + 'ui.onOpenQuickOpen', + 'ui.onToggleQuickCommandsMenu', + 'ui.onOpenNewWorkspace', + 'ui.onDeleteCurrentWorkspace', + 'ui.onOpenWorkspaceBoard', + 'ui.onToggleAgentDashboard', + 'ui.onOpenTasks', + 'ui.onJumpToWorktreeIndex', + 'ui.onJumpToTabIndex', + 'ui.onWorktreeHistoryNavigate', + 'ui.onToggleStatusBar', + 'ui.onActivateWorktree', + 'ui.onCreateTerminal', + 'ui.onRequestTerminalTabMount', + 'ui.onRequestTerminalCreate', + 'ui.onSplitTerminal', + 'ui.onRenameTerminal', + 'ui.onFocusTerminal', + 'ui.onFocusEditorTab', + 'ui.onCloseSessionTab', + 'ui.onSessionTabCloseRequest', + 'ui.onMoveSessionTab', + 'ui.onOpenFileFromMobile', + 'ui.onOpenDiffFromMobile', + 'ui.onCloseTerminal', + 'ui.onTerminalTabCloseRequest', + 'ui.onSleepWorktree', + 'ui.onResumeSleepingAgents', + 'updater.onStatus', + 'updater.onClearDismissal', + 'ui.onFullscreenChanged', + 'browser.onGuestLoadFailed', + 'browser.onCertificateFailureChanged', + 'browser.onNavigationUpdate', + 'browser.onActivateView', + 'browser.onPaneFocus', + 'browser.onOpenLinkInOrcaTab', + 'ui.onNewBrowserTab', + 'ui.onNewMarkdownTab', + 'ui.onNewSimulatorTab', + 'emulator.onAutoAttach', + 'emulator.onPaneFocus', + 'ui.onRequestTabCreate', + 'ui.onRequestTabSetProfile', + 'ui.onRequestTabClose', + 'ui.onNewTerminalTab', + 'ui.onCloseActiveTab', + 'ui.onCloseFloatingItem', + 'ui.onSelectFloatingIndex', + 'ui.onSwitchTab', + 'ui.onSwitchTabAcrossAllTypes', + 'ui.onSwitchRecentTab', + 'ui.onSwitchTerminalTab', + 'rateLimits.onUpdate', + 'workspaceSpace.onProgress', + 'ssh.onCredentialRequest', + 'ssh.onCredentialResolved', + 'ssh.onPortForwardsChanged', + 'ssh.onDetectedPortsChanged', + 'ssh.onStateChanged', + 'ui.onSystemResumed', + 'remoteWorkspace.onChanged', + 'ui.onTerminalZoom', + 'agentStatus.onSet', + 'agentStatus.onClear', + 'agentStatus.onMigrationUnsupported', + 'agentStatus.onMigrationUnsupportedClear', + 'agentStatus.onLegacyWorkerTerminalRecovery', + 'runtime.onTerminalFitOverrideChanged', + 'runtime.onTerminalDriverChanged', + 'runtime.onNativeChatLaunchDraftResolved', + 'runtime.onBrowserDriverChanged' +] as const + +type ListenerRecord = { + callback: (...args: unknown[]) => void + active: boolean + cleanup: Mock +} + +describe('useIpcEvents App-lifetime lifecycle', () => { + beforeEach(() => { + vi.resetModules() + vi.unstubAllGlobals() + vi.doUnmock('./ipc-events/app-lifetime-ipc-bridge') + }) + + it('owns one empty-dependency React effect', async () => { + let dependencies: readonly unknown[] | undefined + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useEffect: (_effect: () => void | (() => void), nextDependencies?: readonly unknown[]) => { + dependencies = nextDependencies + } + } + }) + vi.doMock('./ipc-events/app-lifetime-ipc-bridge', () => ({ + installAppLifetimeIpcEvents: vi.fn(() => vi.fn()) + })) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + + expect(dependencies).toEqual([]) + }) + + it('leaves exactly one listener per channel across a StrictMode cleanup-remount cycle', async () => { + const registrationOrder: string[] = [] + const cleanupOrder: string[] = [] + const listeners = new Map() + const storeSubscriptions: { active: boolean; cleanup: Mock }[] = [] + const setUpdateStatus = vi.fn() + const storeState = new Proxy( + createHarnessStoreState({ + tabsByWorktree: { 'wt-1': [] }, + setUpdateStatus, + workspaceSessionReady: true, + runtimeEnvironments: [{ id: 'runtime-1' }], + runtimeStatusByEnvironmentId: new Map([['runtime-1', { status: 'connected' }]]) + }), + { + get: (target, property: string) => + property in target ? target[property] : property.startsWith('set') ? vi.fn() : undefined + } + ) + + vi.doMock('../store', () => ({ + useAppStore: { + getState: () => storeState, + subscribe: vi.fn(() => { + const record = { active: true, cleanup: vi.fn() } + const subscriptionIndex = storeSubscriptions.length + storeSubscriptions.push(record) + return () => { + cleanupOrder.push(`store.unsubscribe.${subscriptionIndex}`) + record.active = false + record.cleanup() + } + }) + } + })) + + const namespace = (name: string): Record => + new Proxy( + {}, + { + get: (_target, property: string) => { + if (name === 'runtimeEnvironments' && property === 'subscribe') { + return async () => { + registrationOrder.push('runtimeEnvironments.subscribe') + return { + unsubscribe: () => cleanupOrder.push('runtimeEnvironment.unsubscribe') + } + } + } + if (property.startsWith('on')) { + return (callback: (...args: unknown[]) => void) => { + registrationOrder.push(`${name}.${property}`) + const record: ListenerRecord = { callback, active: true, cleanup: vi.fn() } + const records = listeners.get(`${name}.${property}`) ?? [] + records.push(record) + listeners.set(`${name}.${property}`, records) + return () => { + cleanupOrder.push(`ipc.${name}.${property}`) + record.active = false + record.cleanup() + } + } + } + if (property === 'getStatus') { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve({ state: 'idle' }) + } + } + if (property === 'get') { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve({ limits: {}, lastUpdatedAt: 0 }) + } + } + if (property === 'getState') { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve(null) + } + } + if (property === 'clientId') { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve(null) + } + } + if (property.startsWith('get') || property.startsWith('list')) { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve([]) + } + } + if (property.startsWith('consumePending')) { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve(null) + } + } + return vi.fn() + } + } + ) + const api = new Proxy( + {}, + { get: (_target, property: string) => namespace(property) } + ) as unknown + vi.stubGlobal('window', { + api, + dispatchEvent: vi.fn(), + setTimeout, + clearTimeout + }) + + const { installAppLifetimeIpcEvents } = await import('./ipc-events/app-lifetime-ipc-bridge') + const recordCleanupPhase = (phase: string): void => { + cleanupOrder.push(phase) + } + const firstCleanup = installAppLifetimeIpcEvents(recordCleanupPhase) + await Promise.resolve() + await Promise.resolve() + const directCallbackMethods = [...listeners.keys()] + .filter( + (method) => + method !== 'mobile.onUnpairedDeviceAuthFailure' && method !== 'ui.onMobileMarkdownRequest' + ) + .sort() + expect(directCallbackMethods).toEqual(EXPECTED_DIRECT_CALLBACK_METHODS) + expect([...listeners.keys()].sort()).toEqual( + [...EXPECTED_CALLBACK_REGISTRATION_SEQUENCE].sort() + ) + expect( + registrationOrder.filter( + (entry) => + entry === 'runtimeEnvironments.subscribe' || + EXPECTED_CALLBACK_REGISTRATION_SEQUENCE.includes( + entry as (typeof EXPECTED_CALLBACK_REGISTRATION_SEQUENCE)[number] + ) + ) + ).toEqual([ + 'ui.onMobileMarkdownRequest', + 'runtimeEnvironments.subscribe', + ...EXPECTED_CALLBACK_REGISTRATION_SEQUENCE.slice(1) + ]) + const groupOrder = (names: readonly string[]): string[] => + registrationOrder.filter((entry) => names.includes(entry)) + expect( + groupOrder([ + 'ui.onOpenSettings', + 'ui.onOpenSkillShare', + 'ui.consumePendingOpenSettings', + 'ui.consumePendingSkillShare' + ]) + ).toEqual([ + 'ui.onOpenSettings', + 'ui.onOpenSkillShare', + 'ui.consumePendingOpenSettings', + 'ui.consumePendingSkillShare' + ]) + expect( + groupOrder([ + 'updater.getStatus', + 'updater.onStatus', + 'updater.onClearDismissal', + 'rateLimits.onUpdate', + 'rateLimits.get' + ]) + ).toEqual([ + 'updater.getStatus', + 'updater.onStatus', + 'updater.onClearDismissal', + 'rateLimits.onUpdate', + 'rateLimits.get' + ]) + expect( + groupOrder([ + 'agentStatus.onSet', + 'agentStatus.onClear', + 'agentStatus.onMigrationUnsupported', + 'agentStatus.onMigrationUnsupportedClear', + 'agentStatus.onLegacyWorkerTerminalRecovery', + 'agentStatus.getSnapshot' + ]) + ).toEqual([ + 'agentStatus.onSet', + 'agentStatus.onClear', + 'agentStatus.onMigrationUnsupported', + 'agentStatus.onMigrationUnsupportedClear', + 'agentStatus.onLegacyWorkerTerminalRecovery', + 'agentStatus.getSnapshot' + ]) + expect( + groupOrder([ + 'runtime.onTerminalFitOverrideChanged', + 'runtime.onTerminalDriverChanged', + 'runtime.onNativeChatLaunchDraftResolved', + 'runtime.onBrowserDriverChanged', + 'runtime.getTerminalFitOverrides', + 'runtime.getTerminalDrivers', + 'runtime.getBrowserDrivers' + ]) + ).toEqual([ + 'runtime.onTerminalFitOverrideChanged', + 'runtime.onTerminalDriverChanged', + 'runtime.onNativeChatLaunchDraftResolved', + 'runtime.onBrowserDriverChanged', + 'runtime.getTerminalFitOverrides', + 'runtime.getTerminalDrivers', + 'runtime.getBrowserDrivers' + ]) + expect( + [...listeners.values()].every((records) => records.filter((item) => item.active).length === 1) + ).toBe(true) + expect(storeSubscriptions.filter((item) => item.active)).toHaveLength(2) + + firstCleanup() + const ipcCleanupOrder = cleanupOrder + .filter((entry) => entry.startsWith('ipc.') && entry !== 'ipc.dispose') + .map((entry) => entry.slice('ipc.'.length)) + expect(ipcCleanupOrder).toEqual(EXPECTED_CALLBACK_REGISTRATION_SEQUENCE) + expect(cleanupOrder.slice(0, 6)).toEqual([ + 'agent.disposeAsyncState', + 'mobile.disposeHydration', + 'store.unsubscribe.0', + 'runtimeStore.unsubscribe', + 'store.unsubscribe.1', + 'agentStore.unsubscribe' + ]) + expect(cleanupOrder.indexOf('runtimeEnvironment.unsubscribe')).toBeGreaterThan( + cleanupOrder.indexOf('ipc.ui.onMobileMarkdownRequest') + ) + expect(cleanupOrder.indexOf('runtimeEnvironment.unsubscribe')).toBeLessThan( + cleanupOrder.indexOf('ipc.repos.onChanged') + ) + expect(cleanupOrder.indexOf('directSsh.stop')).toBeGreaterThan( + cleanupOrder.lastIndexOf('ipc.runtime.onBrowserDriverChanged') + ) + expect(cleanupOrder.at(-1)).toBe('notifications.reset') + expect([...listeners.values()].every((records) => records.every((item) => !item.active))).toBe( + true + ) + expect(storeSubscriptions.filter((item) => item.active)).toHaveLength(0) + expect( + [...listeners.values()].every((records) => + records.every((item) => item.cleanup.mock.calls.length === 1) + ) + ).toBe(true) + + const statusWritesBeforePostUnmountEvent = setUpdateStatus.mock.calls.length + for (const record of listeners.get('updater.onStatus') ?? []) { + if (record.active) { + record.callback({ state: 'available' }) + } + } + expect(setUpdateStatus).toHaveBeenCalledTimes(statusWritesBeforePostUnmountEvent) + + const secondCleanup = installAppLifetimeIpcEvents(recordCleanupPhase) + expect( + [...listeners.values()].every((records) => records.filter((item) => item.active).length === 1) + ).toBe(true) + expect(storeSubscriptions.filter((item) => item.active)).toHaveLength(2) + + secondCleanup() + expect([...listeners.values()].every((records) => records.every((item) => !item.active))).toBe( + true + ) + expect(storeSubscriptions.filter((item) => item.active)).toHaveLength(0) + expect( + [...listeners.values()].every((records) => + records.every((item) => item.cleanup.mock.calls.length === 1) + ) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts b/src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts index 7ce571b73b9..a72216cc9b6 100644 --- a/src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -import { buildNewWorkspaceShortcutModalData, openNewWorkspaceFromShortcut } from './useIpcEvents' +import { + buildNewWorkspaceShortcutModalData, + openNewWorkspaceFromShortcut +} from './ipc-events/new-workspace-command' describe('buildNewWorkspaceShortcutModalData', () => { it('carries the active Linear issue into the Cmd+N composer', () => { diff --git a/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts b/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts index ad1d318f332..c47487dd57e 100644 --- a/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts @@ -8,11 +8,11 @@ import { getRuntimeClientEventEnvironmentIds, getRuntimeProjectRefreshEnvironmentIds, invalidateRuntimeClientEventReplay -} from './useIpcEvents' +} from './ipc-events/runtime-environment-subscription-selection' import type { RuntimeEnvironmentStoreSyncState, RuntimeEnvironmentStoreSyncSubscriber -} from './useIpcEvents' +} from './ipc-events/runtime-environment-subscription-selection' describe('buildRuntimeClientEventEnvironmentKey', () => { it('treats runtime environment ids as a stable set', () => { diff --git a/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts b/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts index 9a9ec54974f..be11678c1fd 100644 --- a/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts @@ -1,6 +1,6 @@ import type * as ReactModule from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { resolveZoomTarget } from './useIpcEvents' +import { resolveZoomTarget } from './resolve-zoom-target' function makeTarget(args: { hasXtermClass?: boolean; editorClosest?: boolean }): { classList: { contains: (token: string) => boolean } @@ -91,6 +91,16 @@ describe('useIpcEvents zoom routing', () => { beforeEach(() => { vi.resetModules() vi.unstubAllGlobals() + // Zoom routing never renders toast UI; keep Sonner's DOM style injector out of this synthetic-document harness. + vi.doMock('sonner', () => ({ + toast: { + dismiss: vi.fn(), + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn() + } + })) }) it('applies app zoom for an active browser tab', async () => { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 95c8278fc52..e9e6ce4929e 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1,4456 +1,7 @@ -/* oxlint-disable max-lines -- Why: this App-level IPC bridge intentionally keeps the renderer's main-process event contract in one place so shortcut, runtime, updater, and agent-status wiring do not drift across files. */ import { useEffect } from 'react' -import { toast } from 'sonner' -import { useAppStore } from '../store' -import { getTabIdsAwaitingHostHydrationRemount } from '@/lib/parked-terminal-host-hydration' -import { applyWorktreeHeadIdentities } from './worktree-head-identity-apply' -import { getWorktreeMapFromState, getRepoMapFromState } from '@/store/selectors' -import { applyUIZoom } from '@/lib/ui-zoom' -import { activateAndRevealWorktree, activateAndRevealWorkspace } from '@/lib/worktree-activation' -import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' -import { deleteHoveredWorkspaceImmediately } from '@/components/sidebar/hovered-workspace-delete' -import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow' -import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping-agents-in-background' -import { TOGGLE_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel' -import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' -import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' -import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' -import { resolveTerminalTabPtyOwnership } from '@/lib/terminal-tab-for-pty-id' -import { - hasRegisteredRuntimeTerminalTab, - focusRuntimeTerminalSurface -} from '@/runtime/sync-runtime-graph' -import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal' -import { getVisibleWorktreeShortcutTargets } from '@/components/sidebar/visible-worktrees' -import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' -import { emitCmdJRowIndexJump } from '@/lib/cmd-j-row-index-jump' -import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-font-zoom' -import { canConnectSshStatus } from '@/ssh/ssh-connection-recoverability' -import type { - TerminalLayoutSnapshot, - TerminalPaneLayoutNode -} from '../../../shared/terminal-tab-types' -import type { UpdateStatus } from '../../../shared/update-status-types' -import type { RateLimitState } from '../../../shared/rate-limit-types' -import type { DirectSshAuthority, SshConnectionState } from '../../../shared/ssh-types' -import { - LOCAL_EXECUTION_HOST_ID, - toRuntimeExecutionHostId, - toSshExecutionHostId, - type ExecutionHostId -} from '../../../shared/execution-host' -import { isWslHookRelayConnectionId } from '../../../shared/wsl-hook-relay-contract' -import type { - RuntimeBrowserDriverState, - RuntimeTerminalPresentation, - RuntimeTerminalDriverState -} from '../../../shared/runtime-types' -import { zoomLevelToPercent } from '@/components/settings/SettingsConstants' -import { stepUIZoomLevel } from '../../../shared/ui-zoom-level' -import { dispatchZoomLevelChanged } from '@/lib/zoom-events' -import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility' -import { resolveZoomTarget } from './resolve-zoom-target' -import { - handleSwitchRecentTab, - handleSwitchTab, - handleSwitchTabAcrossAllTypes, - handleSwitchTerminalTab -} from './ipc-tab-switch' -import { ensureSimulatorTab } from '@/lib/ensure-simulator-tab' -import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab' -import { - isManualSimulatorLaunchPending, - rememberPrelaunchedSimulatorSession -} from '@/lib/simulator-launch-coordination' -import { - normalizeAgentStatusPayload, - type AgentStatusClearIpcPayload, - type AgentStatusIpcPayload, - type ParsedAgentStatusPayload -} from '../../../shared/agent-status-types' -import { - resolveAgentStatusIdentity, - shouldSuppressInheritedTerminalStatus -} from '../../../shared/agent-status-identity' -import { isGitRepoKind } from '../../../shared/repo-kind' -import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' -import { TOGGLE_QUICK_COMMANDS_MENU_EVENT } from '@/lib/quick-commands-menu-events' -import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' -import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' -import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' -import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' -import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' -import { setFitOverride, hydrateOverrides } from '@/lib/pane-manager/mobile-fit-overrides' -import { setDriverForPty, hydrateDrivers } from '@/lib/pane-manager/mobile-driver-state' -import { - hydrateBrowserDrivers, - setDriverForBrowserPage -} from '@/lib/pane-manager/browser-mobile-driver-state' -import { destroyPersistentWebview } from '@/components/browser-pane/host-guest/webview-registry' -import { rememberLiveBrowserUrl } from '@/components/browser-pane/describe-page/live-browser-url-registry' -import { - acquireBrowserAutomationVisibility, - releaseBrowserAutomationVisibility -} from '@/components/browser-pane/host-guest/browser-automation-visibility' -import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' -import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' -import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue' -import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' -import { applyNativeChatLaunchDraftResolved } from '@/runtime/native-chat-launch-draft-runtime-resolution' -import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' -import { dispatchTerminalSideEffectBatch } from '@/components/terminal-pane/terminal-side-effect-facts-handler' -import { subscribeToUnpairedDeviceAuthNotification } from './unpaired-device-auth-notification' -import { - applyRuntimeEnvironmentSshStateChanged, - hydrateRuntimeEnvironmentSshState, - refreshRuntimeEnvironmentSshTargetMetadata -} from '@/runtime/runtime-environment-ssh-state' -import { - createRuntimeProjectRefreshScheduler, - refreshRuntimeProjectWorktreesAndLineage -} from './runtime-project-refresh-scheduler' -import { createRuntimeClientEventsSync } from './runtime-client-events-sync' -import { detectLanguage } from '@/lib/language-detect' -import { makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' -import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' -import { track } from '@/lib/telemetry' -import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' -import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' -import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence' -import { verifyTerminalRevealIdentity } from '@/lib/terminal-reveal-identity' -import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' -import type { RuntimeClientEvent } from '../../../shared/runtime-client-events' -import { applyHostWorktreeTerminalSleepState } from '@/components/terminal-pane/pty-shutdown-exit-deferral' -import { - resolveLegacyWorkerTerminalRecoveryAction, - rollbackLegacyWorkerTerminalSurfaceInStore -} from './legacy-worker-terminal-recovery-event' -import type { AppState } from '../store/types' -import { - guardPinnedTabClose, - isUnifiedTabPinned, - resolvePinnedTabLabel -} from '../store/pinned-tab-close-guard' -import { - closeWebRuntimeSessionTab, - createWebRuntimeSessionTerminal, - isWebRuntimeSessionActive -} from '@/runtime/web-runtime-session' -import { - createFloatingWorkspaceBrowserTab, - createFloatingWorkspaceMarkdownTab, - createFloatingWorkspaceTerminalTab, - isEmptyFloatingWorkspacePanelVisible, - isFloatingWorkspacePanelFocused, - resolveFloatingWorkspaceBrowserWorkspaceId, - switchFloatingWorkspaceTab -} from '@/lib/floating-workspace-terminal-actions' -import { - dispatchFloatingWorkspaceGuestClose, - dispatchFloatingWorkspaceGuestSelectIndex -} from '@/lib/floating-workspace-guest-bridge' -import { - observeAgentHookCompletionForNotification, - resetAgentHookCompletionNotificationCoordinators, - syncAgentHookCompletionNotificationsForStoreUpdate -} from './agent-hook-completion-notifications' -import { shouldSuppressCodexAutoApprovalStatus } from '@/components/terminal-pane/codex-auto-approval-notification-suppression' -import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' -import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' -import { titleHasAgentName } from '../../../shared/agent-detection' -import { isDecorativeAgentTitleFrameChange } from '../../../shared/agent-decorative-title-signature' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' -import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' -import type { - AgentStatusBatchTransaction, - AgentStatusBatchUpdate, - AgentStatusUpdate -} from '@/store/slices/agent-status' -import { translate } from '@/i18n/i18n' -import { redactKagiSessionToken } from '../../../shared/browser-url' -import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' -import { - SESSION_TAB_CLOSE_CANCELED_ERROR, - SESSION_TAB_CLOSE_FAILED_ERROR, - SESSION_TAB_NOT_FOUND_ERROR, - SESSION_TAB_CLOSE_TIMEOUT_ERROR -} from '../../../shared/session-tab-close' -import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' -import { getConnectionIdFromState } from '@/lib/connection-context' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' -import { acquireDirectSshDetectedWorktreeRefresh } from '@/store/slices/worktrees' -import { createDirectSshWorktreeRefreshScheduler } from './direct-ssh-worktree-refresh-scheduler' -import { - createDirectSshReconnectCoordinator, - type DirectSshPreparationInput, - type DirectSshPreparationReason -} from './direct-ssh-reconnect-coordinator' -import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' -import { createDirectSshHostHydration } from './direct-ssh-host-hydration' -import { createDirectSshReconnectProductTelemetryAdapter } from '@/lib/direct-ssh-reconnect-product-telemetry' -import { - createRemoteWorkspaceTargetSync, - isDirectSshRemoteWorkspaceApplyInProgress, - type RemoteWorkspaceTargetSync -} from './remote-workspace-target-sync' -import { - registerDirectSshWakeRouting, - routeDirectSshConnectedState, - type DirectSshConnectedStateOrigin -} from './direct-ssh-state-routing' -import { isDirectSshReconnectCoordinatorRoutingEnabled } from './direct-ssh-reconnect-rollout' - -function getShortcutPlatform(): NodeJS.Platform { - if (navigator.userAgent.includes('Mac')) { - return 'darwin' - } - if (navigator.userAgent.includes('Windows')) { - return 'win32' - } - return 'linux' -} - -const BROWSER_AUTOMATION_BOOTSTRAP_LEASE_MS = 10_000 -const browserAutomationBootstrapLeaseByPageId = new Map() - -function resolveTerminalPresentation(data: { - presentation?: RuntimeTerminalPresentation - activate?: boolean - focus?: boolean -}): RuntimeTerminalPresentation | undefined { - if (data.presentation) { - return data.presentation - } - if (data.focus !== undefined) { - return data.focus ? 'focused' : 'background' - } - if (data.activate === true) { - return 'focused' - } - return undefined -} - -function releaseBrowserAutomationBootstrapLease(browserPageId: string): void { - const existing = browserAutomationBootstrapLeaseByPageId.get(browserPageId) - if (!existing) { - return - } - window.clearTimeout(existing.timer) - releaseBrowserAutomationVisibility(existing.token) - browserAutomationBootstrapLeaseByPageId.delete(browserPageId) -} - -function findBrowserPageWorktreeId(store: AppState, browserPageId: string): string | null { - for (const [worktreeId, browserTabs] of Object.entries(store.browserTabsByWorktree)) { - for (const workspace of browserTabs) { - if ( - workspace.id === browserPageId || - workspace.activePageId === browserPageId || - workspace.pageIds?.includes(browserPageId) - ) { - return worktreeId - } - } - } - - for (const pages of Object.values(store.browserPagesByWorkspace)) { - const page = pages.find((candidate) => candidate.id === browserPageId) - if (page) { - return page.worktreeId - } - } - - return null -} - -function acquireBrowserAutomationBootstrapLease( - worktreeId: string | null | undefined, - browserPageId?: string | null -): void { - const store = useAppStore.getState() - const targetWorktreeId = - worktreeId ?? - (browserPageId ? findBrowserPageWorktreeId(store, browserPageId) : null) ?? - store.activeWorktreeId - if (!targetWorktreeId) { - return - } - requestBackgroundTerminalWorktreeMount({ worktreeId: targetWorktreeId }) - let targetBrowserPageId = browserPageId ?? null - if (!targetBrowserPageId) { - const browserTabs = store.browserTabsByWorktree[targetWorktreeId] ?? [] - const activeWorkspaceId = store.activeBrowserTabIdByWorktree[targetWorktreeId] ?? null - const workspace = - browserTabs.find((tab) => tab.id === activeWorkspaceId) ?? browserTabs[0] ?? null - targetBrowserPageId = - workspace?.activePageId ?? workspace?.pageIds?.[0] ?? workspace?.id ?? null - } - if (!targetBrowserPageId) { - return - } - - releaseBrowserAutomationBootstrapLease(targetBrowserPageId) - const token = acquireBrowserAutomationVisibility(targetBrowserPageId) - const timer = window.setTimeout(() => { - releaseBrowserAutomationBootstrapLease(targetBrowserPageId) - }, BROWSER_AUTOMATION_BOOTSTRAP_LEASE_MS) - browserAutomationBootstrapLeaseByPageId.set(targetBrowserPageId, { token, timer }) -} - -export { resolveZoomTarget } from './resolve-zoom-target' - -const PENDING_AGENT_STATUS_RETRY_MS = 100 -const PENDING_AGENT_STATUS_TTL_MS = 15_000 -const MAX_PENDING_AGENT_STATUS_EVENTS = 100 -// Why: each live status event is its own IPC task, so a multi-agent burst pays -// one full render pass per event; same-task commits batch to ONE pass (React -// flushes external-store updates at the microtask boundary). One frame of -// buffering collapses a burst; the leading event still applies immediately. -const LIVE_AGENT_STATUS_BURST_WINDOW_MS = 33 -// Why: mobile driver hydration is async; cap replay so a stuck IPC snapshot can't retain an unbounded startup buffer. -const MAX_PENDING_MOBILE_STATE_EVENTS = 300 -// Why: a rename's event burst lags the on-disk move; shield both ids from the deletion diff for a grace window. -const WORKTREE_RENAME_PURGE_GRACE_MS = 20_000 -const recentlyRenamedWorktreeIdExpiry = new Map() - -function isAgentStatusForRecentlyClosedTab( - store: Pick, - paneKey: string -): boolean { - const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) - if (store.recentlyRetiredAgentStatusPaneKeys?.[ownerPaneKey] === true) { - return true - } - const tabId = parsePaneKey(ownerPaneKey)?.tabId - if (!tabId) { - return false - } - return store.recentlyClosedAgentStatusTabIds[tabId] === true -} - -function getAuthoritativeDetectedWorktreeIds(state: AppState, repoId: string): Set | null { - const detected = state.detectedWorktreesByRepo[repoId] - if (detected?.authoritative !== true) { - return null - } - return new Set(detected.worktrees.map((worktree) => worktree.id)) -} - -function getVisibleWorktreeIdsForRepo(state: AppState, repoId: string): Set { - return new Set((state.worktreesByRepo[repoId] ?? []).map((worktree) => worktree.id)) -} - -function focusTerminalInitiatedTab(tabId: string, leafId?: string | null): void { - if (!focusRuntimeTerminalSurface(tabId, leafId)) { - focusTerminalTabSurface(tabId, leafId) - } -} - -function activateTerminalInitiatedWorktree(store: AppState, worktreeId: string): void { - store.setActiveView('terminal') - store.setActiveWorktree(worktreeId) - // Why: CLI/runtime terminal focus is user-visible navigation, so feed both Cmd+J recency and the back/forward stack. - store.markWorktreeVisited(worktreeId) - if (!store.isNavigatingHistory) { - store.recordWorktreeVisit(worktreeId) - } -} - -type TerminalSplitDirection = 'horizontal' | 'vertical' - -function insertLeafAfterSource( - node: TerminalPaneLayoutNode, - sourceLeafId: string, - newLeafId: string, - direction: TerminalSplitDirection -): { node: TerminalPaneLayoutNode; inserted: boolean } { - if (node.type === 'leaf') { - if (node.leafId !== sourceLeafId) { - return { node, inserted: false } - } - return { - node: { - type: 'split', - direction, - first: node, - second: { type: 'leaf', leafId: newLeafId }, - ratio: 0.5 - }, - inserted: true - } - } - - const first = insertLeafAfterSource(node.first, sourceLeafId, newLeafId, direction) - if (first.inserted) { - return { node: { ...node, first: first.node }, inserted: true } - } - const second = insertLeafAfterSource(node.second, sourceLeafId, newLeafId, direction) - if (second.inserted) { - return { node: { ...node, second: second.node }, inserted: true } - } - return { node, inserted: false } -} - -function addSplitLeafToLayout( - layout: TerminalLayoutSnapshot | null | undefined, - sourceLeafId: string, - newLeafId: string, - ptyId: string, - direction: TerminalSplitDirection, - title?: string | null, - activateNewLeaf = true -): TerminalLayoutSnapshot { - const root = layout?.root ?? { type: 'leaf', leafId: sourceLeafId } - const existingLeafIds = collectLeafIdsInOrder(root) - const nextActiveLeafId = - activateNewLeaf || !layout?.activeLeafId || !existingLeafIds.includes(layout.activeLeafId) - ? newLeafId - : layout.activeLeafId - const nextRoot = existingLeafIds.includes(newLeafId) - ? root - : (() => { - const inserted = insertLeafAfterSource(root, sourceLeafId, newLeafId, direction) - if (inserted.inserted) { - return inserted.node - } - return { - type: 'split' as const, - direction, - first: root, - second: { type: 'leaf' as const, leafId: newLeafId }, - ratio: 0.5 - } - })() - return { - ...(layout ?? { root: null, activeLeafId: null, expandedLeafId: null }), - root: nextRoot, - activeLeafId: nextActiveLeafId, - expandedLeafId: null, - ptyIdsByLeafId: { - ...layout?.ptyIdsByLeafId, - [newLeafId]: ptyId - }, - ...(title - ? { - titlesByLeafId: { - ...layout?.titlesByLeafId, - [newLeafId]: title - } - } - : {}) - } -} - -function activateExistingLeafInLayout( - layout: TerminalLayoutSnapshot | null | undefined, - leafId: string, - ptyId: string, - title?: string | null -): TerminalLayoutSnapshot | null { - if (!layout?.root || !collectLeafIdsInOrder(layout.root).includes(leafId)) { - return null - } - return { - ...layout, - activeLeafId: leafId, - expandedLeafId: null, - ptyIdsByLeafId: { - ...layout.ptyIdsByLeafId, - [leafId]: ptyId - }, - ...(title - ? { - titlesByLeafId: { - ...layout.titlesByLeafId, - [leafId]: title - } - } - : {}) - } -} - -export function isRemoteWorkspaceSnapshotApplyInProgress(): boolean { - return isDirectSshRemoteWorkspaceApplyInProgress() -} - -type BrowserSessionTabTarget = - | { kind: 'unified-browser'; unifiedTabId: string; workspaceId: string; groupId: string } - | { kind: 'fallback-browser'; workspaceId: string } - -type NewWorkspaceShortcutModalData = { - telemetrySource: 'shortcut' - prefilledName?: string - linkedWorkItem?: ReturnType -} - -export function buildNewWorkspaceShortcutModalData( - state: Pick -): NewWorkspaceShortcutModalData { - const linearIssue = - state.activeView === 'tasks' ? (state.taskPageData.openLinearIssue ?? null) : null - if (!linearIssue) { - return { telemetrySource: 'shortcut' } - } - - return { - telemetrySource: 'shortcut', - prefilledName: getLinearIssueWorkspaceName(linearIssue), - // Why: Cmd+N from a Linear issue mirrors its Start-workspace action, else the agent launches without source context. - linkedWorkItem: buildLinearIssueLinkedWorkItem(linearIssue) - } -} - -export function openNewWorkspaceFromShortcut( - state: Pick -): void { - if (state.activeModal === 'new-workspace-composer') { - return - } - state.openModal('new-workspace-composer', buildNewWorkspaceShortcutModalData(state)) -} - -export function toggleAgentDashboardFromShortcut( - state: Pick< - AppState, - | 'activeView' - | 'settings' - | 'agentDashboardDrawerOpen' - | 'setSidebarOpen' - | 'setAgentDashboardDrawerOpen' - >, - openPopout: () => void -): void { - // Why: mirror the sidebar entry's gate — the chord must stay inert while the - // experiment is off, so a stale binding cannot open a hidden surface. - if ( - state.activeView === 'settings' || - state.settings?.experimentalAgentDashboardPopout !== true - ) { - return - } - if (state.settings.experimentalAgentDashboardMode === 'popout') { - openPopout() - return - } - const nextOpen = !state.agentDashboardDrawerOpen - // Why: the drawer lives beside the sidebar and self-closes when the sidebar - // collapses, so opening it has to reveal the sidebar first. Closing must not, - // or dismissing the drawer would force the sidebar back open. - if (nextOpen) { - state.setSidebarOpen(true) - } - state.setAgentDashboardDrawerOpen(nextOpen) -} - -export function resolveBrowserSessionTabTarget( - state: Pick, - worktreeId: string, - tabId: string -): BrowserSessionTabTarget | null { - const tab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find((item) => item.id === tabId) - if (tab?.contentType === 'browser') { - return { - kind: 'unified-browser', - unifiedTabId: tab.id, - workspaceId: tab.entityId, - groupId: tab.groupId - } - } - const fallbackBrowser = (state.browserTabsByWorktree[worktreeId] ?? []).find( - (workspace) => workspace.id === tabId - ) - return fallbackBrowser ? { kind: 'fallback-browser', workspaceId: fallbackBrowser.id } : null -} - -function isRuntimeEnvironmentActive(): boolean { - return Boolean(useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim()) -} - -/** Remount panes that parked with no PTY while their owning host was unknown. */ -function remountTerminalTabsAwaitingHostHydration(): void { - const store = useAppStore.getState() - for (const tabId of getTabIdsAwaitingHostHydrationRemount(store)) { - store.remountTerminalTabForRecovery(tabId) - } -} - -export type RuntimeEnvironmentStoreSyncState = Pick< - AppState, - 'runtimeEnvironments' | 'runtimeStatusByEnvironmentId' | 'settings' | 'sshStateByEnvironment' -> - -function getActiveRuntimeEnvironmentId(state: RuntimeEnvironmentStoreSyncState): string | null { - return state.settings?.activeRuntimeEnvironmentId?.trim() || null -} - -export function getRuntimeClientEventEnvironmentIds( - state: RuntimeEnvironmentStoreSyncState -): string[] { - const ids = new Set() - const activeEnvironmentId = getActiveRuntimeEnvironmentId(state) - if (activeEnvironmentId) { - ids.add(activeEnvironmentId) - } - for (const environment of state.runtimeEnvironments ?? []) { - const status = state.runtimeStatusByEnvironmentId?.get(environment.id) - if (status?.status) { - ids.add(environment.id) - } - } - return [...ids] -} - -export function getReachableRuntimeEnvironmentIds( - state: RuntimeEnvironmentStoreSyncState -): string[] { - const ids: string[] = [] - for (const [environmentId, status] of state.runtimeStatusByEnvironmentId ?? []) { - if (status?.status) { - ids.push(environmentId) - } - } - return ids -} - -export function canSkipRuntimeEnvironmentStoreSync( - state: RuntimeEnvironmentStoreSyncState, - previousState: RuntimeEnvironmentStoreSyncState -): boolean { - return ( - state.runtimeEnvironments === previousState.runtimeEnvironments && - state.runtimeStatusByEnvironmentId === previousState.runtimeStatusByEnvironmentId && - state.sshStateByEnvironment === previousState.sshStateByEnvironment && - getActiveRuntimeEnvironmentId(state) === getActiveRuntimeEnvironmentId(previousState) - ) -} - -export function buildRuntimeClientEventEnvironmentKey(environmentIds: string[]): string { - return [...new Set(environmentIds)] - .sort() - .map( - (environmentId) => - `${environmentId}:${getRuntimeEnvironmentConnectionGeneration(environmentId)}:${getEnvironmentSshStateGeneration(environmentId)}:${getRuntimeEnvironmentRevision(environmentId) ?? 'unknown'}` - ) - .join('\u0000') -} - -/** Ids in `next` not in `previous` — environments that just became connected (exported to unit-test on-connect discovery). */ -export function getNewlyConnectedRuntimeEnvironmentIds( - previous: readonly string[], - next: readonly string[] -): string[] { - const known = new Set(previous) - return [...new Set(next)].filter((environmentId) => !known.has(environmentId)) -} - -/** Ids in `previous` not in `next` — environments whose transport was just observed down. */ -export function getNewlyDisconnectedRuntimeEnvironmentIds( - previous: readonly string[], - next: readonly string[] -): string[] { - return getNewlyConnectedRuntimeEnvironmentIds(next, previous) -} - -export function getRuntimeProjectRefreshEnvironmentIds(args: { - previousDesired: readonly string[] - nextDesired: readonly string[] - previousReachable: readonly string[] - nextReachable: readonly string[] -}): string[] { - return [ - ...new Set([ - ...getNewlyConnectedRuntimeEnvironmentIds(args.previousDesired, args.nextDesired), - ...getNewlyConnectedRuntimeEnvironmentIds(args.previousReachable, args.nextReachable) - ]) - ] -} - -type RuntimeEnvironmentStoreSyncSubscriberDeps = { - initialDesiredEnvironmentIds: string[] - initialReachableEnvironmentIds: string[] - buildEnvironmentKey: (environmentIds: string[]) => string - getDesiredEnvironmentIds: (state: RuntimeEnvironmentStoreSyncState) => string[] - getReachableEnvironmentIds: (state: RuntimeEnvironmentStoreSyncState) => string[] - requestProjectRefresh: (environmentId: string) => void - markEnvironmentSshStateStale: (environmentId: string) => void - sync: () => void -} - -export type RuntimeEnvironmentStoreSyncSubscriber = ( - state: RuntimeEnvironmentStoreSyncState, - previousState: RuntimeEnvironmentStoreSyncState -) => void - -/** - * Builds the one renderer-wide runtime subscriber. The reference gate runs - * before either host collection is enumerated; key generation remains the - * second gate for relevant-reference writes whose effective subscription set - * did not change. - */ -export function createRuntimeEnvironmentStoreSyncSubscriber( - deps: RuntimeEnvironmentStoreSyncSubscriberDeps -): RuntimeEnvironmentStoreSyncSubscriber { - let desiredEnvironmentIds = deps.initialDesiredEnvironmentIds - let desiredEnvironmentKey = deps.buildEnvironmentKey(desiredEnvironmentIds) - let reachableEnvironmentIds = deps.initialReachableEnvironmentIds - let reachableEnvironmentKey = deps.buildEnvironmentKey(reachableEnvironmentIds) - let handlingStoreWrite = false - - return (state, previousState) => { - // markEnvironmentSshStateStale can synchronously publish its nested SSH - // bucket. The outer pass incorporates that generation before syncing, so a - // re-entrant pass would only enumerate and sync the same transition twice. - if (handlingStoreWrite || canSkipRuntimeEnvironmentStoreSync(state, previousState)) { - return - } - - handlingStoreWrite = true - try { - const nextDesiredEnvironmentIds = deps.getDesiredEnvironmentIds(state) - const nextReachableEnvironmentIds = deps.getReachableEnvironmentIds(state) - const refreshEnvironmentIds = getRuntimeProjectRefreshEnvironmentIds({ - previousDesired: desiredEnvironmentIds, - nextDesired: nextDesiredEnvironmentIds, - previousReachable: reachableEnvironmentIds, - nextReachable: nextReachableEnvironmentIds - }) - const disconnectedEnvironmentIds = getNewlyDisconnectedRuntimeEnvironmentIds( - reachableEnvironmentIds, - nextReachableEnvironmentIds - ) - - desiredEnvironmentIds = nextDesiredEnvironmentIds - reachableEnvironmentIds = nextReachableEnvironmentIds - for (const environmentId of refreshEnvironmentIds) { - deps.requestProjectRefresh(environmentId) - } - for (const environmentId of disconnectedEnvironmentIds) { - deps.markEnvironmentSshStateStale(environmentId) - } - - // Build after disconnect invalidation: marking a mirrored SSH bucket stale - // advances its generation, and the replacement subscription must capture - // that final generation in this same (single) sync. - const nextDesiredEnvironmentKey = deps.buildEnvironmentKey(desiredEnvironmentIds) - const nextReachableEnvironmentKey = deps.buildEnvironmentKey(reachableEnvironmentIds) - if ( - nextDesiredEnvironmentKey === desiredEnvironmentKey && - nextReachableEnvironmentKey === reachableEnvironmentKey - ) { - return - } - desiredEnvironmentKey = nextDesiredEnvironmentKey - reachableEnvironmentKey = nextReachableEnvironmentKey - deps.sync() - } finally { - handlingStoreWrite = false - } - } -} - -type RuntimeClientEventReplayInvalidationDeps = { - getSshStateReference: () => RuntimeEnvironmentStoreSyncState['sshStateByEnvironment'] - requestProjectRefresh: () => void - markEnvironmentSshStateStale: () => void - hydrateEnvironmentSshState: () => Promise - sync: () => void -} - -/** - * Invalidates a replay after the runtime event stream reports a transport gap. - * A tracked SSH bucket publishes synchronously and lets the store subscriber - * sync it; an empty/already-stale bucket has no reference publication, so this - * path must explicitly sync the advanced module-level SSH generation. - */ -export function invalidateRuntimeClientEventReplay( - deps: RuntimeClientEventReplayInvalidationDeps -): void { - deps.requestProjectRefresh() - const previousSshStateReference = deps.getSshStateReference() - deps.markEnvironmentSshStateStale() - if (deps.getSshStateReference() === previousSshStateReference) { - deps.sync() - } - void deps.hydrateEnvironmentSshState().catch(() => {}) -} - -function getWorktreeRuntimeEnvironmentId(worktreeId: string | null | undefined): string | null { - return getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) -} +import { installAppLifetimeIpcEvents } from './ipc-events/app-lifetime-ipc-bridge' +/** Installs the renderer IPC bridge once for the App lifetime. */ export function useIpcEvents(): void { - useEffect(() => { - const unsubs: (() => void)[] = [] - const reconnectAuthorityByTarget = new Map() - const authorityReconciliationDeadlines = new Set<{ - timer: ReturnType - settle: () => void - }>() - let directSshEffectStopped = false - const currentDirectSshAuthority = (targetId: string): DirectSshAuthority | null => { - const state = useAppStore.getState().sshConnectionStates?.get(targetId) - if ( - state?.status !== 'connected' || - state.targetId !== targetId || - !state.providerEpoch || - state.connectionGeneration === undefined - ) { - return null - } - return { - targetId, - providerEpoch: state.providerEpoch, - connectionGeneration: state.connectionGeneration - } - } - const scheduler = createDirectSshWorktreeRefreshScheduler({ - startAttempt: (key) => { - const acquired = acquireDirectSshDetectedWorktreeRefresh(useAppStore, { - repoId: key.repoId, - executionHostId: key.executionHostId, - authority: { - targetId: key.targetId, - providerEpoch: key.providerEpoch, - connectionGeneration: key.connectionGeneration - }, - requireAuthoritative: key.authorityRequirement === 'required' - }) - return { - providerRequestId: acquired.providerRequestId, - result: acquired.result.then((result) => acquired.merge(result)), - cancel: acquired.release - } - } - }) - const hostHydration = createDirectSshHostHydration({ - store: useAppStore, - isCurrentAuthority: (authority) => - directSshAuthoritiesEqual(currentDirectSshAuthority(authority.targetId), authority), - listRepos: (authority) => { - const executionHostId = toSshExecutionHostId(authority.targetId) - return ( - window.api.repos.listForExecutionHost?.({ - executionHostId, - expectedAuthority: authority - }) ?? - Promise.resolve({ - authoritative: false, - executionHostId, - reason: 'unavailable' as const - }) - ) - }, - listLineage: (authority) => { - const executionHostId = toSshExecutionHostId(authority.targetId) - return ( - window.api.worktrees.listLineageForHost?.({ - executionHostId, - expectedAuthority: authority - }) ?? - Promise.resolve({ - authoritative: false, - executionHostId, - reason: 'unavailable' as const - }) - ) - } - }) - type DirectSshTerminalActions = Partial< - Pick - > - const directSshTerminalActions = (): DirectSshTerminalActions => - useAppStore.getState() as DirectSshTerminalActions - let remoteWorkspaceTargetSync: RemoteWorkspaceTargetSync | null = null - const reconnectCoordinator = createDirectSshReconnectCoordinator({ - scheduler, - isCurrentConnectedAuthority: (authority) => - directSshAuthoritiesEqual(currentDirectSshAuthority(authority.targetId), authority), - capturePreparationInput: hostHydration.capturePreparationInput, - readHostScopedLineage: hostHydration.readHostScopedLineage, - invalidateStaleTerminalBindings: (authority) => - directSshTerminalActions().invalidateStaleDirectSshTargetPtyBindings?.(authority) ?? 0, - retryTargetPanes: (authority) => - directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, - finalizeHydratedTerminalPanes: (authority) => - directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, - correctUnboundTerminalPanes: (authority) => - directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, - syncRemoteWorkspaceAfterConnect: (token) => - remoteWorkspaceTargetSync?.syncAfterConnect(token), - onTelemetry: createDirectSshReconnectProductTelemetryAdapter() - }) - const remoteWorkspaceApi = window.api.remoteWorkspace - if (remoteWorkspaceApi) { - remoteWorkspaceTargetSync = createRemoteWorkspaceTargetSync({ - store: useAppStore, - remoteWorkspace: remoteWorkspaceApi, - getCurrentAuthority: currentDirectSshAuthority, - isPreparationTokenCurrent: hostHydration.isPreparationTokenCurrent, - capturePreparationInput: (authority, reason, snapshotRevision) => - hostHydration.capturePreparationInput(authority, reason, snapshotRevision), - prepareOnly: reconnectCoordinator.prepareOnly, - finalizeHydratedTerminals: (authority) => - directSshAuthoritiesEqual(reconnectAuthorityByTarget.get(authority.targetId), authority) - ? reconnectCoordinator.finalizeHydratedTerminals(authority) - : 0 - }) - } - const prepareAndSyncDirectSshTarget = async ( - authority: DirectSshAuthority, - reason: DirectSshPreparationReason, - options?: { authorityAlreadyReplaced?: boolean } - ): Promise => { - try { - if (!options?.authorityAlreadyReplaced) { - reconnectCoordinator.replaceAuthority(authority) - } - const input: DirectSshPreparationInput | null = await hostHydration.capturePreparationInput( - authority, - reason - ) - if (!input) { - return - } - const prepared = await reconnectCoordinator.prepareOnly(input) - if (prepared.token && hostHydration.isPreparationTokenCurrent(prepared.token)) { - await remoteWorkspaceTargetSync?.syncAfterConnect(prepared.token) - } - } catch (error) { - if (directSshAuthoritiesEqual(currentDirectSshAuthority(authority.targetId), authority)) { - useAppStore.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { - phase: 'error', - message: error instanceof Error ? error.message : 'Workspace sync failed' - }) - } - } - } - const backgroundSleepingAgentWakeDispatcher = createBackgroundSleepingAgentWakeDispatcher() - unsubs.push(backgroundSleepingAgentWakeDispatcher.dispose) - type PendingAgentStatusEvent = { - data: AgentStatusIpcPayload - firstSeenAt: number - replay: boolean - } - type AgentStatusApplyResult = 'applied' | 'pending' | 'dropped' - type ProjectedAgentTabTitles = { - title: string | undefined - identityTitle: string | undefined - } - type AgentStatusBatchContext = { - transaction: AgentStatusBatchTransaction - routingIndex: AgentStatusPaneRoutingIndex - projectedTitlesByTabId: Map - tabTitlesByTabId: Map - notificationEffects: (() => void)[] - } - type AgentStatusBatchEvent = { - data: AgentStatusIpcPayload - replay?: boolean - retry?: boolean - } - type AgentStatusApplyOptions = { - replay?: boolean - retry?: boolean - batch?: AgentStatusBatchContext - } - const pendingAgentStatusEvents: PendingAgentStatusEvent[] = [] - const transientClearWatermarkByConnectionId = new Map() - let agentStatusEffectDisposed = false - let pendingAgentStatusRetryTimer: ReturnType | null = null - // Why: setAgentStatus notifies synchronously and re-enters this flush mid-drain; guard re-entrancy (crash 9fc89529). - let isFlushingAgentStatuses = false - const liveAgentStatusBurstQueue: AgentStatusIpcPayload[] = [] - let liveAgentStatusBurstTimer: ReturnType | null = null - let lastLiveAgentStatusApplyAt = 0 - - unsubs.push(attachMobileMarkdownBridge()) - - const handleWorktreesChanged = async ( - repoId: string, - renamed?: { oldWorktreeId: string; newWorktreeId: string }, - options?: { forceLocalOwner?: boolean; executionHostId?: ExecutionHostId } - ): Promise => { - const localRefreshStartedWithRuntime = - options?.forceLocalOwner === true && isRuntimeEnvironmentActive() - // Why: capture active-ness before migration moves the pointer; re-key maps before the diff so a rename isn't a deletion. - const renamedWasActive = - renamed != null && useAppStore.getState().activeWorktreeId === renamed.oldWorktreeId - if (renamed) { - // Shield both ids from the deletion diff across the rename's event burst — the worktree list lags the on-disk move. - const expiry = Date.now() + WORKTREE_RENAME_PURGE_GRACE_MS - recentlyRenamedWorktreeIdExpiry.set(renamed.oldWorktreeId, expiry) - recentlyRenamedWorktreeIdExpiry.set(renamed.newWorktreeId, expiry) - useAppStore.getState().migrateWorktreeIdentity(renamed.oldWorktreeId, renamed.newWorktreeId) - } - // Why: diff before/after fetch to catch out-of-band deletions and purge worktree state, else zombie ptyId entries leak (design §2c, §4.4). - const state = useAppStore.getState() - const before = - getAuthoritativeDetectedWorktreeIds(state, repoId) ?? - getVisibleWorktreeIdsForRepo(state, repoId) - await state.fetchWorktrees( - repoId, - options?.forceLocalOwner - ? { forceLocalOwner: true } - : options?.executionHostId - ? { - executionHostId: options.executionHostId, - suppressRemoteLineageRefresh: true - } - : undefined - ) - await useAppStore - .getState() - .fetchWorktreeLineage( - options?.forceLocalOwner - ? { forceLocalOwner: true } - : options?.executionHostId - ? { executionHostId: options.executionHostId } - : undefined - ) - // Why: an id change unmounts the active pane; re-activate so the tab reconciles, else it vanishes until re-select. - if (renamedWasActive && renamed) { - useAppStore.getState().setActiveWorktree(renamed.newWorktreeId) - } - // Sweep expired rename-grace entries before any early return, else forced-local - // (or non-authoritative) events let the map grow for the session. - const now = Date.now() - for (const [id, expiry] of recentlyRenamedWorktreeIdExpiry) { - if (expiry <= now) { - recentlyRenamedWorktreeIdExpiry.delete(id) - } - } - // Why: the deletion diff below is repo-wide, but a forced-local scan overlapping - // a runtime cannot prove remote absence (legacy runtime rows may lack hostId). - // fetchWorktrees still purges removed local rows host-scoped; accepted gap: the - // workspace-space entry survives until the next local-only rescan. - if ( - options?.forceLocalOwner && - (localRefreshStartedWithRuntime || isRuntimeEnvironmentActive()) - ) { - return - } - const afterState = useAppStore.getState() - const after = getAuthoritativeDetectedWorktreeIds(afterState, repoId) - if (!after) { - return - } - const removed: string[] = [] - for (const id of before) { - if (after.has(id)) { - continue - } - // A recently renamed worktree's old/new id isn't a deletion — its state moved to the new id; the list just lags. - const graceExpiry = recentlyRenamedWorktreeIdExpiry.get(id) - if (graceExpiry != null && graceExpiry > now) { - continue - } - removed.push(id) - } - if (removed.length > 0) { - console.warn( - `[worktree-purge] diff-based purge removing state for ${removed.length} worktree(s):`, - removed - ) - const purgeHostId = - options?.executionHostId ?? - (options?.forceLocalOwner ? LOCAL_EXECUTION_HOST_ID : undefined) - afterState.purgeWorktreeTerminalState( - purgeHostId ? removed.map((id) => ({ id, hostId: purgeHostId })) : removed - ) - afterState.removeWorkspaceSpaceWorktrees(removed) - } - } - const worktreeChangeRefreshQueue = createWorktreeChangeRefreshQueue(handleWorktreesChanged) - unsubs.push(worktreeChangeRefreshQueue.dispose) - - const activateNotifiedWorktree = async ( - { - repoId, - worktreeId, - setup, - startup, - defaultTabs - }: Extract, - options: { allowRuntimeEnvironment: boolean } - ): Promise => { - if (!options.allowRuntimeEnvironment && isRuntimeEnvironmentActive()) { - // Why: local CLI worktree events carry local ids; runtime activation comes via the remote stream, allowed separately. - return - } - const existedBeforeFetch = Boolean(useAppStore.getState().getKnownWorktreeById(worktreeId)) - // Why: fetch first so activation can resolve the CLI-created worktree; it arrived from main, not yet in renderer state. - await useAppStore.getState().fetchWorktrees(repoId) - const existsAfterFetch = Boolean(useAppStore.getState().getKnownWorktreeById(worktreeId)) - // Why: use the canonical activation path so the CLI switch records a back/forward visit, or the nav buttons ignore it. - activateAndRevealWorktree(worktreeId, { - ...(setup ? { setup } : {}), - ...(startup ? { startup } : {}), - ...(defaultTabs ? { defaultTabs } : {}), - ...(!existedBeforeFetch && existsAfterFetch ? { sidebarRevealBehavior: 'auto' } : {}), - // Why: this activation came from the host runtime stream; echoing it back can create a selection loop. - notifyHostRuntime: false - }) - } - - const ensureRuntimeEventRepoKnown = async ( - environmentId: string, - repoId: string - ): Promise => { - if ((useAppStore.getState().repos ?? []).some((repo) => repo.id === repoId)) { - return - } - await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId) - } - - const runtimeProjectRefreshScheduler = createRuntimeProjectRefreshScheduler({ - refresh: async (environmentId) => { - // Why: project events can reveal target CRUD, but known target states already arrive by push. - void refreshRuntimeEnvironmentSshTargetMetadata(environmentId).catch(() => {}) - const repos = await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId) - // Why: the host emits one reposChanged for group/folder-workspace edits too, so those - // catalogs go stale without this; groups first because folder workspaces resolve owners from them. - const runtimeOwner = { runtimeEnvironmentId: environmentId } - // Why: catalogs and worktrees are independent; serializing them put two 15s RPC - // timeouts ahead of worktree/lineage convergence on a wedged host. - await Promise.all([ - (async () => { - await useAppStore.getState().fetchProjectGroups(runtimeOwner) - await useAppStore.getState().fetchFolderWorkspaces(runtimeOwner) - })(), - refreshRuntimeProjectWorktreesAndLineage( - environmentId, - repos, - (repoId, options) => useAppStore.getState().fetchWorktrees(repoId, options), - (options) => useAppStore.getState().fetchWorktreeLineage(options) - ) - ]) - }, - onError: (error) => { - console.error('Failed to refresh runtime projects:', error) - } - }) - - // Assigned later (by the ssh.onStateChanged wiring); safe because subscriptions attach asynchronously. - let handleSshStateChangedEvent: ((data: { targetId: string; state: unknown }) => void) | null = - null - - const handleRuntimeClientEvent = ( - environmentId: string, - event: RuntimeClientEvent, - generation = getEnvironmentSshStateGeneration(environmentId) - ): void => { - if (event.type === 'worktreeTerminalSleepState') { - applyHostWorktreeTerminalSleepState(environmentId, event) - return - } - if (event.type === 'terminalSideEffects') { - dispatchTerminalSideEffectBatch({ - ...event.batch, - ptyId: toRemoteRuntimePtyId(event.batch.ptyId, environmentId) - }) - return - } - if (event.type === 'nativeChatLaunchDraftResolved') { - applyNativeChatLaunchDraftResolved(useAppStore.getState(), event) - return - } - if (event.type === 'reposChanged') { - runtimeProjectRefreshScheduler.request(environmentId) - return - } - if (event.type === 'sshStateChanged') { - applyRuntimeEnvironmentSshStateChanged( - environmentId, - event.targetId, - event.state, - generation - ) - return - } - if (event.type === 'worktreesChanged') { - void ensureRuntimeEventRepoKnown(environmentId, event.repoId).then(() => - worktreeChangeRefreshQueue.enqueue({ - repoId: event.repoId, - executionHostId: toRuntimeExecutionHostId(environmentId) - }) - ) - return - } - if (event.type === 'linearLinkedIssueUpdated') { - void useAppStore - .getState() - .refreshLinearIssue(event.identifier, event.workspaceId) - .catch((error) => { - console.error('Failed to refresh updated Linear issue:', error) - }) - return - } - void ensureRuntimeEventRepoKnown(environmentId, event.repoId) - .then(() => activateNotifiedWorktree(event, { allowRuntimeEnvironment: true })) - .catch((error) => { - console.error('Failed to activate runtime-created worktree:', error) - }) - } - - const runtimeClientEventsSync = createRuntimeClientEventsSync({ - getDesiredEnvironmentIds: () => getRuntimeClientEventEnvironmentIds(useAppStore.getState()), - getSubscriptionKey: (environmentId) => buildRuntimeClientEventEnvironmentKey([environmentId]), - subscribe: (environmentId, onEvent, onError) => { - const sshGeneration = getEnvironmentSshStateGeneration(environmentId) - const runtimeGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) - const runtimeRevision = getRuntimeEnvironmentRevision(environmentId) - return subscribeRuntimeClientEvents( - environmentId, - (event) => { - if ( - sshGeneration === getEnvironmentSshStateGeneration(environmentId) && - runtimeGeneration === getRuntimeEnvironmentConnectionGeneration(environmentId) && - runtimeRevision === getRuntimeEnvironmentRevision(environmentId) - ) { - onEvent(event) - } - }, - onError, - () => { - invalidateRuntimeClientEventReplay({ - getSshStateReference: () => useAppStore.getState().sshStateByEnvironment, - requestProjectRefresh: () => runtimeProjectRefreshScheduler.request(environmentId), - markEnvironmentSshStateStale: () => - useAppStore.getState().markEnvironmentSshStateStale(environmentId), - hydrateEnvironmentSshState: () => - hydrateRuntimeEnvironmentSshState(environmentId, { force: true }), - sync: runtimeClientEventsSync.sync - }) - } - ) - }, - onEvent: handleRuntimeClientEvent - }) - - // Why: no on-connect repo fetch (PR #2); seed discovery for connected runtimes or remote projects hide until Add-Project. - const initialRuntimeEnvironmentState = useAppStore.getState() - const runtimeClientEventEnvironmentIds = getRuntimeClientEventEnvironmentIds( - initialRuntimeEnvironmentState - ) - for (const environmentId of runtimeClientEventEnvironmentIds) { - runtimeProjectRefreshScheduler.request(environmentId) - } - const reachableRuntimeEnvironmentIds = getReachableRuntimeEnvironmentIds( - initialRuntimeEnvironmentState - ) - const handleRuntimeEnvironmentStoreWrite = createRuntimeEnvironmentStoreSyncSubscriber({ - initialDesiredEnvironmentIds: runtimeClientEventEnvironmentIds, - initialReachableEnvironmentIds: reachableRuntimeEnvironmentIds, - buildEnvironmentKey: buildRuntimeClientEventEnvironmentKey, - getDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds, - getReachableEnvironmentIds: getReachableRuntimeEnvironmentIds, - requestProjectRefresh: (environmentId) => - runtimeProjectRefreshScheduler.request(environmentId), - markEnvironmentSshStateStale: (environmentId) => { - // No-op when the environment has no SSH bucket (e.g. web client). - useAppStore.getState().markEnvironmentSshStateStale(environmentId) - }, - sync: runtimeClientEventsSync.sync - }) - const unsubscribeRuntimeEnvironmentStore = useAppStore.subscribe( - handleRuntimeEnvironmentStoreWrite - ) - // Subscribe before the first runtime stream starts: replay invalidation may - // synchronously publish a tracked SSH bucket and relies on this listener to - // replace that subscription exactly once. - runtimeClientEventsSync.sync() - unsubs.push(runtimeClientEventsSync.stop) - unsubs.push(runtimeProjectRefreshScheduler.stop) - - unsubs.push( - window.api.repos.onChanged(() => { - const state = useAppStore.getState() - if (isRuntimeEnvironmentActive()) { - // Why: the all-host sidebar shows local repos even under a runtime; refresh the local slice, keep runtime slices. - void (async () => { - const localOwner = { runtimeEnvironmentId: null } - await state.fetchRepos(localOwner) - await state.fetchProjectGroups(localOwner) - await state.fetchFolderWorkspaces(localOwner) - remountTerminalTabsAwaitingHostHydration() - })() - return - } - void state.fetchProjectGroups() - void state.fetchFolderWorkspaces() - void state.fetchRepos().then(remountTerminalTabsAwaitingHostHydration) - }) - ) - - unsubs.push( - window.api.worktrees.onChanged( - async (data: { - repoId: string - renamed?: { oldWorktreeId: string; newWorktreeId: string } - }) => { - // Why: preserve this event's local origin across queue delays and runtime - // focus changes; otherwise an unbound repo can refresh from the wrong host. - // A folder rename changes the worktree id; handleWorktreesChanged re-keys - // state and shields it from the deletion diff. - worktreeChangeRefreshQueue.enqueue({ - ...data, - forceLocalOwner: true - }) - } - ) - ) - - if (window.api.worktrees.onHeadIdentitiesChanged) { - unsubs.push( - window.api.worktrees.onHeadIdentitiesChanged((data) => { - if (isRuntimeEnvironmentActive()) { - // Why: local worktree events carry local repo ids; the local-pinned list - // refresh (onChanged) covers local rows while a runtime is active. - return - } - const state = useAppStore.getState() - applyWorktreeHeadIdentities(data, { - getWorktreesForRepo: (repoId) => state.worktreesByRepo[repoId], - updateWorktreeGitIdentity: state.updateWorktreeGitIdentity - }) - }) - ) - } - - unsubs.push( - window.api.worktrees.onBaseStatus((event) => { - if (isRuntimeEnvironmentActive()) { - return - } - useAppStore.getState().updateWorktreeBaseStatus(event) - }) - ) - - unsubs.push( - window.api.worktrees.onRemoteBranchConflict((event) => { - if (isRuntimeEnvironmentActive()) { - return - } - useAppStore.getState().updateWorktreeRemoteBranchConflict(event) - }) - ) - - // Why: route main's two-phase creation progress to each pending entry by correlation id (?. guards stale preload). - unsubs.push( - window.api.worktrees.onCreateProgress?.((data) => { - if (!data.creationId) { - return - } - useAppStore.getState().updatePendingWorktreeCreation(data.creationId, { phase: data.phase }) - }) ?? (() => {}) - ) - - if (window.api.gh?.onPRRefreshEvent) { - unsubs.push( - window.api.gh.onPRRefreshEvent((event) => { - useAppStore.getState().applyGitHubPRRefreshEvent(event) - }) - ) - } - - unsubs.push( - window.api.ui.onOpenSettings(() => { - useAppStore.getState().openSettingsPage() - }) - ) - - const unsubscribeOpenSkillShare = window.api.ui.onOpenSkillShare?.((shareId) => { - useAppStore.getState().openSkillShare(shareId) - }) - if (unsubscribeOpenSkillShare) { - unsubs.push(unsubscribeOpenSkillShare) - } - - // Why: a tray "Settings…" click can fire before this attaches; consume any queued intent (?. guards stale preload). - void window.api.ui - .consumePendingOpenSettings?.() - .then((open) => { - if (open) { - useAppStore.getState().openSettingsPage() - } - }) - .catch(() => {}) - - const pendingSkillShare = window.api.ui.consumePendingSkillShare?.() - if (pendingSkillShare && typeof pendingSkillShare.then === 'function') { - void pendingSkillShare - .then((shareId) => { - if (shareId) { - useAppStore.getState().openSkillShare(shareId) - } - }) - .catch(() => {}) - } - - unsubs.push( - window.api.ui.onOpenSetupGuide?.(() => { - useAppStore.getState().openModal('setup-guide', { telemetrySource: 'help_menu' }) - }) ?? (() => {}) - ) - - // Why: a phone stuck in a silent 4001 auth loop (lost device registry) reads as - // "phone won't connect" with no clue on either end; main throttles to once per session. - unsubs.push( - subscribeToUnpairedDeviceAuthNotification(window.api.mobile, () => { - toast.warning( - translate( - 'auto.hooks.useIpcEvents.ef223fbb6b', - 'A device tried to connect but is not paired' - ), - { - id: 'unpaired-device-auth-failure', - description: translate( - 'auto.hooks.useIpcEvents.11992d0337', - 'If this was your phone or another Orca client, re-pair it from Settings → Mobile.' - ), - // Why: main emits this recovery path once per session, so it must remain visible until acted on or dismissed. - duration: Infinity, - action: { - label: translate('auto.hooks.useIpcEvents.6573cfe955', 'Open Mobile Settings'), - onClick: () => { - const store = useAppStore.getState() - store.openSettingsTarget({ pane: 'mobile', repoId: null }) - store.openSettingsPage() - } - } - } - ) - }) - ) - - unsubs.push( - window.api.ui.onOpenFeatureTour(() => { - useAppStore.getState().openModal('feature-wall', { source: 'help_menu' }) - }) - ) - - // Why: View > Appearance toggles settings in main and broadcasts; merge into the store for an immediate re-render. - unsubs.push( - window.api.settings.onChanged((updates) => { - const store = useAppStore.getState() - if (!store.settings) { - return - } - const { worktreeVisibilityDefaults, ...activeOwnerUpdates } = updates - const settingsUpdates = store.settings.activeRuntimeEnvironmentId - ? activeOwnerUpdates - : updates - useAppStore.setState({ - settings: { - ...store.settings, - ...settingsUpdates, - notifications: { - ...store.settings.notifications, - ...updates.notifications - } - }, - ...(worktreeVisibilityDefaults - ? { - worktreeVisibilityDefaultsByHost: { - ...store.worktreeVisibilityDefaultsByHost, - local: worktreeVisibilityDefaults - } - } - : {}) - }) - if ('worktreeVisibilityDefaults' in updates) { - void store.fetchAllWorktrees({ visibilityOwnerHostId: 'local' }) - } - }) - ) - - // Why: UI view-state is shared with mobile via ui.set; re-hydrate so mobile changes reflect live in the desktop sidebar. - unsubs.push( - window.api.ui.onStateChanged((ui) => { - useAppStore.getState().hydratePersistedUI(ui, 'sync') - }) - ) - - if (window.api.keybindings) { - unsubs.push( - window.api.keybindings.onChanged((snapshot) => { - useAppStore.getState().setKeybindingSnapshot(snapshot) - }) - ) - } - - unsubs.push( - window.api.ui.onToggleLeftSidebar(() => { - useAppStore.getState().toggleSidebar() - }) - ) - - unsubs.push( - window.api.ui.onToggleRightSidebar(() => { - const store = useAppStore.getState() - if (!canShowRightSidebarForView(store.activeView)) { - return - } - store.toggleRightSidebar() - }) - ) - - unsubs.push( - window.api.ui.onToggleWorktreePalette(() => { - const store = useAppStore.getState() - if (store.activeModal === 'worktree-palette') { - store.closeModal() - return - } - store.openModal('worktree-palette') - }) - ) - - unsubs.push( - window.api.ui.onToggleFloatingTerminal(() => { - window.dispatchEvent(new CustomEvent(TOGGLE_FLOATING_TERMINAL_EVENT)) - }) - ) - - if (window.api.ui.onTerminalShortcutCaptured) { - unsubs.push( - window.api.ui.onTerminalShortcutCaptured(({ actionId }) => { - showTerminalShortcutCaptureNotification({ - actionId, - platform: getShortcutPlatform(), - keybindings: useAppStore.getState().keybindings - }) - }) - ) - } - - unsubs.push( - window.api.ui.onOpenQuickOpen(() => { - const store = useAppStore.getState() - if (store.activeView === 'terminal' && store.activeWorktreeId !== null) { - store.openModal('quick-open') - } - }) - ) - - unsubs.push( - window.api.ui.onToggleQuickCommandsMenu(() => { - window.dispatchEvent(new CustomEvent(TOGGLE_QUICK_COMMANDS_MENU_EVENT)) - }) - ) - - unsubs.push( - window.api.ui.onOpenNewWorkspace(() => { - const store = useAppStore.getState() - openNewWorkspaceFromShortcut(store) - }) - ) - - if (window.api.ui.onDeleteCurrentWorkspace) { - unsubs.push( - window.api.ui.onDeleteCurrentWorkspace(() => { - if (isFloatingWorkspacePanelFocused()) { - return - } - deleteHoveredWorkspaceImmediately(useAppStore.getState()) - }) - ) - } - - if (window.api.ui.onOpenWorkspaceBoard) { - unsubs.push( - window.api.ui.onOpenWorkspaceBoard(() => { - const store = useAppStore.getState() - if (store.activeView === 'settings') { - return - } - store.setSidebarOpen(true) - window.dispatchEvent(new CustomEvent(TOGGLE_WORKSPACE_BOARD_EVENT)) - }) - ) - } - - if (window.api.ui.onToggleAgentDashboard) { - unsubs.push( - window.api.ui.onToggleAgentDashboard(() => { - toggleAgentDashboardFromShortcut(useAppStore.getState(), () => { - void window.api.dashboard.openPopout() - }) - }) - ) - } - - unsubs.push( - window.api.ui.onOpenTasks(() => { - const store = useAppStore.getState() - if (store.activeView === 'settings' || !store.repos.some((repo) => isGitRepoKind(repo))) { - return - } - store.openTaskPage() - }) - ) - - unsubs.push( - window.api.ui.onJumpToWorktreeIndex((index) => { - const store = useAppStore.getState() - // Why: while Cmd+J is open the digit chord means "activate recent row N" — main already - // preventDefault'd it, so routing it here keeps digits out of the palette's search input. - if (store.activeModal === 'worktree-palette') { - emitCmdJRowIndexJump(index) - return - } - if (store.activeView !== 'terminal') { - return - } - const visibleTargets = getVisibleWorktreeShortcutTargets() - const target = visibleTargets[index] - if (target) { - if (target.executionHostId) { - activateAndRevealWorkspace(target.id, { executionHostId: target.executionHostId }) - } else { - activateAndRevealWorkspace(target.id) - } - } - }) - ) - - unsubs.push( - window.api.ui.onJumpToTabIndex((index) => { - // Why: dropped while Cmd+J is open — never switch tabs behind the overlay. - if (useAppStore.getState().activeModal === 'worktree-palette') { - return - } - activateTabNumberShortcut(index) - }) - ) - - unsubs.push( - window.api.ui.onWorktreeHistoryNavigate((direction) => { - const store = useAppStore.getState() - // Why: mirror button visibility — worktree history nav is only meaningful in the terminal view, so no-op elsewhere. - if (store.activeView !== 'terminal') { - return - } - if (direction === 'back') { - store.goBackWorktree() - } else { - store.goForwardWorktree() - } - }) - ) - - unsubs.push( - window.api.ui.onToggleStatusBar(() => { - const store = useAppStore.getState() - store.setStatusBarVisible(!store.statusBarVisible) - }) - ) - - unsubs.push( - window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup, defaultTabs }) => { - void activateNotifiedWorktree( - { - type: 'activateWorktree', - repoId, - worktreeId, - ...(setup ? { setup } : {}), - ...(startup ? { startup } : {}), - ...(defaultTabs ? { defaultTabs } : {}) - }, - { allowRuntimeEnvironment: false } - ).catch((error) => { - console.error('Failed to activate CLI-created worktree:', error) - }) - }) - ) - - unsubs.push( - window.api.ui.onCreateTerminal( - ({ - requestId, - worktreeId, - command, - cwd, - env, - launchConfig, - resumeProviderSession, - launchToken, - launchAgent, - viewMode, - title, - ptyId, - activate, - focus, - presentation, - surfaceOwner, - tabId, - leafId, - splitFromLeafId, - splitDirection, - splitTelemetrySource - }) => { - try { - const store = useAppStore.getState() - const terminalPresentation = resolveTerminalPresentation({ - presentation, - activate, - focus - }) - const shouldActivate = terminalPresentation === 'focused' - const shouldSurfaceOwner = - terminalPresentation !== 'background' && surfaceOwner !== false - if (shouldActivate) { - activateTerminalInitiatedWorktree(store, worktreeId) - } - const worktreeTabs = store.tabsByWorktree[worktreeId] ?? [] - // Why: a split pane revealed from mobile is only bound in the persisted - // layout until its pane mounts; missing it minted a duplicate tab (#10486). - const ownership = ptyId - ? resolveTerminalTabPtyOwnership( - store, - worktreeId, - ptyId, - tabId !== undefined ? { preferTabId: tabId } : {} - ) - : { kind: 'none' as const } - const existingTab = - ownership.kind === 'owned' - ? worktreeTabs.find((candidate) => candidate.id === ownership.tabId) - : undefined - const isSplitReveal = Boolean(ptyId && tabId && leafId && splitFromLeafId) - const splitTargetTab = isSplitReveal - ? worktreeTabs.find((candidate) => candidate.id === tabId) - : undefined - if (isSplitReveal && !splitTargetTab) { - throw new Error(`Terminal tab ${tabId} not found`) - } - const reusedTab = existingTab ?? splitTargetTab - const tab = - reusedTab ?? - (ptyId - ? store.createTab(worktreeId, undefined, undefined, { - initialPtyId: ptyId, - activate: shouldActivate, - ...(launchAgent - ? { - launchAgent, - // Why: a paired client resolved explicit mode before PTY materialization; only omitted mode uses host defaults. - ...(viewMode - ? { viewMode } - : initialAgentTabViewModeProps(store.settings, { - agent: launchAgent, - nativeChatTranscriptIsLocalReadable: - isNativeChatTranscriptLocalReadable( - getConnectionIdFromState(store, worktreeId) - ) - })) - } - : {}), - ...(cwd ? { startupCwd: cwd } : {}), - // Why: CLI-spawned PTYs bake the pane key into env; adopt the same tab id so hook-event attribution keeps working. - ...(tabId !== undefined ? { id: tabId } : {}) - }) - : store.createTab( - worktreeId, - undefined, - undefined, - shouldActivate - ? cwd - ? { startupCwd: cwd } - : undefined - : { - activate: false, - recordInteraction: false, - ...(cwd ? { startupCwd: cwd } : {}) - } - )) - // Why: a reused tab whose id differs from the hint breaks the PTY's baked-in paneKey attribution; warn during dev. - if (tabId !== undefined && tab.id !== tabId) { - console.warn( - `[onCreateTerminal] tabId hint ${tabId} ignored for ptyId ${ptyId}; existing tab ${tab.id} adopted instead (hook attribution will degrade for this terminal)` - ) - } - if (shouldActivate) { - store.setActiveTabType('terminal') - store.setActiveTab(tab.id) - } - if (shouldSurfaceOwner) { - store.revealWorktreeInSidebar(worktreeId) - focusTerminalInitiatedTab(tab.id, leafId) - } - // Why: only stamp the runtime title on fresh tabs; reused tabs may have a user customTitle it would overwrite on focus. - if (title && !reusedTab) { - store.setTabCustomTitle(tab.id, title, { recordInteraction: false }) - } - if (leafId && ptyId) { - const launchPaneKey = tryMakePaneKey(tab.id, leafId) - if (launchConfig) { - if (launchPaneKey) { - store.registerAgentLaunchConfig(launchPaneKey, launchConfig, { - ...(launchAgent ? { agentType: launchAgent } : {}), - ...(launchToken ? { launchToken } : {}), - tabId: tab.id, - leafId - }) - } - } else if (!splitFromLeafId && launchPaneKey) { - store.clearAgentLaunchConfig(launchPaneKey) - } - if (splitFromLeafId) { - // Why: runtime split PTYs already carry the parent tab's paneKey, so reuse the tab instead of minting a collision tab. - store.updateTabPtyId(tab.id, ptyId) - const existingLayout = store.terminalLayoutsByTabId?.[tab.id] - const sourcePtyId = existingLayout?.ptyIdsByLeafId?.[splitFromLeafId] - store.setTabLayout( - tab.id, - addSplitLeafToLayout( - existingLayout, - splitFromLeafId, - leafId, - ptyId, - splitDirection ?? 'horizontal', - title, - shouldActivate - ) - ) - window.dispatchEvent( - new CustomEvent(SPLIT_TERMINAL_PANE_EVENT, { - detail: { - tabId: tab.id, - paneRuntimeId: -1, - direction: splitDirection ?? 'horizontal', - sourceLeafId: splitFromLeafId, - sourcePtyId, - telemetrySource: splitTelemetrySource, - newLeafId: leafId, - ptyId - } - }) - ) - } else { - // Why: CLI/runtime PTYs emit hook events before the tab mounts, so the leaf must exist in layout for paneKey validation. - const existingLayout = reusedTab - ? activateExistingLeafInLayout( - store.terminalLayoutsByTabId?.[tab.id], - leafId, - ptyId, - title - ) - : null - if (existingLayout) { - store.updateTabPtyId(tab.id, ptyId) - store.setTabLayout(tab.id, existingLayout) - } else { - store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId, title)) - } - } - } - if (command) { - store.queueTabStartupCommand(tab.id, { - command, - ...(env ? { env } : {}), - ...(launchConfig ? { launchConfig } : {}), - ...(resumeProviderSession ? { resumeProviderSession } : {}), - ...(launchToken ? { launchToken } : {}), - ...(launchAgent ? { launchAgent } : {}) - }) - } - if (ptyId && terminalPresentation === 'background') { - requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] }) - } - if (requestId) { - // Why: attest the actual binding; recovery callers compare it with their expected identity. - const identity = - ptyId && tabId && leafId - ? verifyTerminalRevealIdentity(useAppStore.getState(), { - worktreeId, - tabId: tab.id, - leafId, - ptyId - }) - : undefined - window.api.ui.replyTerminalCreate({ - requestId, - tabId: tab.id, - title: title ?? tab.title, - ...(identity ? { identity } : {}) - }) - } - } catch (err) { - if (!requestId) { - throw err - } - window.api.ui.replyTerminalCreate({ - requestId, - error: err instanceof Error ? err.message : 'Terminal reveal failed' - }) - } - } - ) - ) - - // Why: background-mount a mobile-subscribed tab's PTY without navigating the desktop (STA-1840). - unsubs.push( - window.api.ui.onRequestTerminalTabMount(({ worktreeId, tabId, ptyId }) => { - if (!worktreeId) { - return - } - // Why: synthetic pty handles need persisted-tab resolution; a miss must not mount every saved tab in a hidden worktree. - const mount = planMobileTerminalTabMount( - useAppStore.getState(), - { - worktreeId, - ...(tabId ? { tabId } : {}), - ...(ptyId ? { ptyId } : {}) - }, - { - isTabMounted: hasRegisteredRuntimeTerminalTab - } - ) - if (mount) { - requestBackgroundTerminalWorktreeMount(mount) - } - }) - ) - - // Why: CLI-driven terminal creation waits for the tabId reply so it can hand the caller a usable handle immediately. - unsubs.push( - window.api.ui.onRequestTerminalCreate((data) => { - try { - const store = useAppStore.getState() - const worktreeId = data.worktreeId ?? store.activeWorktreeId - if (!worktreeId) { - window.api.ui.replyTerminalCreate({ - requestId: data.requestId, - error: translate('auto.hooks.useIpcEvents.f000b2ff76', 'No active worktree') - }) - return - } - const worktreeRoute = resolveTerminalWorktreeRoute(store, worktreeId) - if (!worktreeRoute) { - window.api.ui.replyTerminalCreate({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.unresolvedTerminalWorktreeOwner', - 'Terminal creation is unavailable because the worktree owner could not be resolved' - ) - }) - return - } - // Why: runtime-session requests are host-owned tabs materialized by this renderer, not ordinary local creates. - if (worktreeRoute.runtimeEnvironmentId && data.source !== 'runtime-session') { - window.api.ui.replyTerminalCreate({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.7a64b31991', - 'Local terminal creation is unavailable while a remote runtime is active' - ) - }) - return - } - const terminalPresentation = resolveTerminalPresentation(data) - const shouldActivate = terminalPresentation === 'focused' - const shouldSurfaceOwner = - terminalPresentation !== 'background' && data.surfaceOwner !== false - if (shouldActivate) { - activateTerminalInitiatedWorktree(store, worktreeId) - } - // Why: the paired launch client already resolved the mode, so its choice wins over the host renderer's local default. - const tabOptions = data.launchAgent - ? { - ...(shouldActivate ? {} : { activate: false, recordInteraction: false }), - launchAgent: data.launchAgent, - ...(data.viewMode - ? { viewMode: data.viewMode } - : initialAgentTabViewModeProps(store.settings, { - agent: data.launchAgent, - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - getConnectionIdFromState(store, worktreeId) - ) - })), - ...(data.cwd ? { startupCwd: data.cwd } : {}) - } - : shouldActivate - ? data.cwd - ? { startupCwd: data.cwd } - : undefined - : { - activate: false, - recordInteraction: false, - ...(data.cwd ? { startupCwd: data.cwd } : {}) - } - const tab = store.createTab(worktreeId, data.targetGroupId, undefined, tabOptions) - if (!shouldActivate) { - // Why: renderer-backed Codex startup must mount its new TerminalPane without switching UI or connecting every saved tab. - requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] }) - } - if (data.afterTabId) { - const createdUnifiedTab = useAppStore - .getState() - .unifiedTabsByWorktree[worktreeId]?.find((item) => item.entityId === tab.id) - const anchorUnifiedTab = useAppStore - .getState() - .unifiedTabsByWorktree[worktreeId]?.find((item) => item.id === data.afterTabId) - if ( - createdUnifiedTab && - anchorUnifiedTab && - createdUnifiedTab.groupId === anchorUnifiedTab.groupId - ) { - const group = useAppStore - .getState() - .groupsByWorktree[worktreeId]?.find((item) => item.id === createdUnifiedTab.groupId) - const order = (group?.tabOrder ?? []).filter((id) => id !== createdUnifiedTab.id) - const anchorIndex = order.indexOf(anchorUnifiedTab.id) - order.splice( - anchorIndex === -1 ? order.length : anchorIndex + 1, - 0, - createdUnifiedTab.id - ) - useAppStore.getState().reorderUnifiedTabs(createdUnifiedTab.groupId, order, { - recordInteraction: false - }) - } - } - if (shouldActivate) { - store.setActiveTabType('terminal') - store.setActiveTab(tab.id) - } - if (shouldSurfaceOwner) { - store.revealWorktreeInSidebar(worktreeId) - focusTerminalInitiatedTab(tab.id) - } - if (data.title) { - store.setTabCustomTitle(tab.id, data.title, { recordInteraction: false }) - } - if (data.command) { - store.queueTabStartupCommand(tab.id, { - command: data.command, - ...(data.env ? { env: data.env } : {}), - ...(data.envToDelete ? { envToDelete: data.envToDelete } : {}), - ...(data.launchConfig ? { launchConfig: data.launchConfig } : {}), - ...(data.resumeProviderSession - ? { resumeProviderSession: data.resumeProviderSession } - : {}), - ...(data.launchToken ? { launchToken: data.launchToken } : {}), - ...(data.launchAgent ? { launchAgent: data.launchAgent } : {}), - ...(data.startupCommandDelivery - ? { startupCommandDelivery: data.startupCommandDelivery } - : {}) - }) - } - window.api.ui.replyTerminalCreate({ - requestId: data.requestId, - tabId: tab.id, - title: data.title ?? tab.title - }) - } catch (err) { - window.api.ui.replyTerminalCreate({ - requestId: data.requestId, - error: err instanceof Error ? err.message : 'Terminal creation failed' - }) - } - }) - ) - - unsubs.push( - window.api.ui.onSplitTerminal( - ({ tabId, paneRuntimeId, direction, command, telemetrySource }) => { - const detail: SplitTerminalPaneDetail = { - tabId, - paneRuntimeId, - direction, - command, - telemetrySource - } - window.dispatchEvent(new CustomEvent(SPLIT_TERMINAL_PANE_EVENT, { detail })) - } - ) - ) - - unsubs.push( - window.api.ui.onRenameTerminal(({ tabId, title }) => { - useAppStore.getState().setTabCustomTitle(tabId, title) - }) - ) - - unsubs.push( - window.api.ui.onFocusTerminal( - ({ - tabId, - worktreeId, - leafId, - ackPaneKeyOnSuccess, - flashFocusedPane, - scrollToBottomIfOutputSinceLastView - }) => { - const store = useAppStore.getState() - activateTerminalInitiatedWorktree(store, worktreeId) - store.setActiveTab(tabId) - store.revealWorktreeInSidebar(worktreeId) - if (ackPaneKeyOnSuccess || flashFocusedPane || scrollToBottomIfOutputSinceLastView) { - activateTabAndFocusPane(tabId, leafId ?? null, { - ...(ackPaneKeyOnSuccess ? { ackPaneKeyOnSuccess } : {}), - ...(flashFocusedPane ? { flashFocusedPane: true } : {}), - ...(scrollToBottomIfOutputSinceLastView - ? { scrollToBottomIfOutputSinceLastView: true } - : {}) - }) - return - } - focusTerminalInitiatedTab(tabId, leafId) - } - ) - ) - - unsubs.push( - window.api.ui.onFocusEditorTab(({ tabId, worktreeId }) => { - const store = useAppStore.getState() - const tab = (store.unifiedTabsByWorktree[worktreeId] ?? []).find( - (item) => item.id === tabId - ) - const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId) - if (!tab) { - if (browserTarget) { - // Why: older/mobile fallback snapshots identify browser tabs by workspace id when no unified tab wrapper exists. - store.setActiveWorktree(worktreeId) - store.markWorktreeVisited(worktreeId) - store.setActiveView('terminal') - store.setActiveBrowserTab(browserTarget.workspaceId) - store.setActiveTabType('browser') - store.revealWorktreeInSidebar(worktreeId) - } - return - } - store.setActiveWorktree(worktreeId) - store.markWorktreeVisited(worktreeId) - store.setActiveView('terminal') - store.focusGroup(worktreeId, tab.groupId) - store.activateTab(tab.id) - if (browserTarget) { - // Why: browser tabs need their own active-page state, not the editor file activation path. - store.setActiveBrowserTab(browserTarget.workspaceId) - store.setActiveTabType('browser') - } else { - store.setActiveFile(tab.entityId) - store.setActiveTabType('editor') - } - store.revealWorktreeInSidebar(worktreeId) - }) - ) - - unsubs.push( - window.api.ui.onCloseSessionTab(({ tabId, worktreeId }) => { - const store = useAppStore.getState() - const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId) - if (browserTarget) { - guardPinnedTabClose({ - isPinned: isUnifiedTabPinned(store, worktreeId, browserTarget.workspaceId), - tabLabel: resolvePinnedTabLabel(store, worktreeId, browserTarget.workspaceId), - onClose: () => useAppStore.getState().closeBrowserTab(browserTarget.workspaceId) - }) - return - } - guardPinnedTabClose({ - isPinned: isUnifiedTabPinned(store, worktreeId, tabId), - tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId), - onClose: () => { - const currentStore = useAppStore.getState() - closeMobileSessionTabInStore(currentStore, worktreeId, tabId) - } - }) - }) - ) - - unsubs.push( - window.api.ui.onSessionTabCloseRequest(({ requestId, tabId, worktreeId, expiresAt }) => { - const store = useAppStore.getState() - const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId) - let cancelConfirmation: (() => void) | undefined - let timeout: ReturnType | undefined - let settled = false - const respond = (error?: string): void => { - if (settled) { - return - } - settled = true - if (timeout !== undefined) { - clearTimeout(timeout) - } - window.api.ui.respondSessionTabClose({ requestId, ...(error ? { error } : {}) }) - } - if (expiresAt !== undefined) { - timeout = setTimeout( - () => { - cancelConfirmation?.() - respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR) - }, - Math.max(0, expiresAt - Date.now()) - ) - } - const closeAndRespond = (): void => { - if (expiresAt !== undefined && Date.now() >= expiresAt) { - respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR) - return - } - try { - if (browserTarget) { - useAppStore.getState().closeBrowserTab(browserTarget.workspaceId) - respond() - return - } - const closed = closeMobileSessionTabInStore(useAppStore.getState(), worktreeId, tabId) - respond(closed ? undefined : SESSION_TAB_NOT_FOUND_ERROR) - } catch (error) { - respond(error instanceof Error ? error.message : SESSION_TAB_CLOSE_FAILED_ERROR) - } - } - const visibleId = browserTarget?.workspaceId ?? tabId - cancelConfirmation = guardPinnedTabClose({ - isPinned: isUnifiedTabPinned(store, worktreeId, visibleId), - tabLabel: resolvePinnedTabLabel(store, worktreeId, visibleId), - onClose: closeAndRespond, - onCancel: () => respond(SESSION_TAB_CLOSE_CANCELED_ERROR) - }) - }) - ) - - unsubs.push( - window.api.ui.onMoveSessionTab((move) => { - const { tabId, targetGroupId } = move - const store = useAppStore.getState() - if (move.kind === 'reorder') { - store.reorderUnifiedTabs(targetGroupId, move.tabOrder) - return - } - store.dropUnifiedTab(tabId, { - groupId: targetGroupId, - ...(move.kind === 'move-to-group' ? { index: move.index } : {}), - ...(move.kind === 'split' ? { splitDirection: move.splitDirection } : {}) - }) - }) - ) - - unsubs.push( - window.api.ui.onOpenFileFromMobile( - ({ worktreeId, filePath, relativePath, runtimeEnvironmentId }) => { - const store = useAppStore.getState() - const basename = relativePath.split(/[\\/]/).pop() || relativePath - store.setActiveWorktree(worktreeId) - store.markWorktreeVisited(worktreeId) - store.setActiveView('terminal') - // Why: renderer owns tab creation so grouped order and markdown bridges share the desktop File Explorer's store path. - store.openFile({ - filePath, - relativePath, - worktreeId, - language: detectLanguage(basename), - runtimeEnvironmentId, - mode: 'edit' - }) - store.setActiveTabType('editor') - store.revealWorktreeInSidebar(worktreeId) - } - ) - ) - - unsubs.push( - window.api.ui.onOpenDiffFromMobile( - ({ worktreeId, filePath, relativePath, staged, runtimeEnvironmentId }) => { - const store = useAppStore.getState() - const language = detectLanguage(relativePath) - store.setActiveWorktree(worktreeId) - store.markWorktreeVisited(worktreeId) - store.setActiveView('terminal') - // Why: mobile renders diffs from metadata; the editor-local Changes shortcut would send plain markdown back to mobile. - store.openDiff(worktreeId, filePath, relativePath, language, staged, { - runtimeEnvironmentId - }) - store.setActiveTabType('editor') - store.revealWorktreeInSidebar(worktreeId) - } - ) - ) - - unsubs.push( - window.api.ui.onCloseTerminal(({ tabId, paneRuntimeId }) => { - if (paneRuntimeId != null) { - // Why: route pane closes via the lifecycle hook for sibling promotion (falls through to closeTab on the last pane). - const detail: CloseTerminalPaneDetail = { tabId, paneRuntimeId } - window.dispatchEvent(new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail })) - } else { - // Why: the CLI/RPC caller is answered immediately, so it cannot wait on a modal. - closeTerminalTab(tabId, { skipRunningProcessConfirm: true }) - } - }) - ) - - // Why: during an in-place renderer reload an older preload can linger; keep this listener additive at that seam. - if (window.api.ui.onTerminalTabCloseRequest) { - unsubs.push( - window.api.ui.onTerminalTabCloseRequest( - ({ requestId, tabId, localPtyTeardownOwnedExternally }) => { - let responded = false - const respond = (error?: string): void => { - if (responded) { - return - } - responded = true - window.api.ui.respondTerminalTabClose({ requestId, ...(error ? { error } : {}) }) - } - closeTerminalTab(tabId, { - rejectPinned: true, - ...(localPtyTeardownOwnedExternally ? { localPtyTeardownOwnedExternally: true } : {}), - onCancel: () => respond('terminal_tab_pinned'), - onClosed: () => { - void (async () => { - const state = useAppStore.getState() - await persistWorkspaceSessionByHost( - window.api.session, - buildWorkspaceSessionPayload(state), - state - ) - respond() - })().catch((error: unknown) => { - respond(error instanceof Error ? error.message : 'terminal_tab_close_failed') - }) - } - }) - } - ) - ) - } - - unsubs.push( - window.api.ui.onSleepWorktree(({ worktreeId }) => { - void runSleepWorktree(worktreeId) - }) - ) - - unsubs.push( - window.api.ui.onResumeSleepingAgents(({ worktreeId }) => { - // Why: a phone opened this worktree; wake its slept agents without changing the desktop's worktree/tab/view. - backgroundSleepingAgentWakeDispatcher.request(worktreeId) - }) - ) - - // Hydrate initial update status then subscribe to changes - window.api.updater.getStatus().then((status) => { - useAppStore.getState().setUpdateStatus(status as UpdateStatus) - }) - - unsubs.push( - window.api.updater.onStatus((raw) => { - const status = raw as UpdateStatus - useAppStore.getState().setUpdateStatus(status) - }) - ) - - unsubs.push( - window.api.updater.onClearDismissal(() => { - useAppStore.getState().clearDismissedUpdateVersion() - }) - ) - - unsubs.push( - window.api.ui.onFullscreenChanged((isFullScreen) => { - useAppStore.getState().setIsFullScreen(isFullScreen) - }) - ) - - unsubs.push( - window.api.browser.onGuestLoadFailed(({ browserPageId, loadError }) => { - if (isRuntimeEnvironmentActive()) { - return - } - useAppStore.getState().updateBrowserPageState(browserPageId, { - loading: false, - loadError, - canGoBack: false, - canGoForward: false - }) - }) - ) - - const unsubscribeCertificateFailure = window.api.browser.onCertificateFailureChanged?.( - ({ browserPageId, failure }) => { - if (isRuntimeEnvironmentActive()) { - return - } - useAppStore.getState().setBrowserPageCertificateFailure(browserPageId, failure) - } - ) - if (unsubscribeCertificateFailure) { - unsubs.push(unsubscribeCertificateFailure) - } - - // Why: agent-browser navigates via CDP so did-navigate never fires; this IPC pushes live URL/title to the stale store. - unsubs.push( - window.api.browser.onNavigationUpdate(({ browserPageId, url, title }) => { - if (isRuntimeEnvironmentActive()) { - return - } - const store = useAppStore.getState() - rememberLiveBrowserUrl(browserPageId, redactKagiSessionToken(url)) - store.setBrowserPageUrl(browserPageId, url) - store.updateBrowserPageState(browserPageId, { title, loading: false }) - }) - ) - - // Why: webviews start their guest only when shown; sent pre-automation so hidden tabs mount without moving the active pane. - unsubs.push( - window.api.browser.onActivateView(({ worktreeId, browserPageId }) => { - if (isRuntimeEnvironmentActive()) { - return - } - acquireBrowserAutomationBootstrapLease(worktreeId, browserPageId) - }) - ) - - // Why: `orca tab switch --focus` must NOT call setActiveWorktree — a global focus from one agent's parallel-worktree switch would steal the user's view. - // focusBrowserTabInWorktree updates per-worktree state in place; globals flip only when the user is already on the targeted worktree. - unsubs.push( - window.api.browser.onPaneFocus(({ worktreeId, browserPageId }) => { - if (isRuntimeEnvironmentActive()) { - return - } - const store = useAppStore.getState() - // Why: worktreeId is null if the tab closed mid-switch; the activeWorktreeId fallback makes the focus call a safe no-op for a stale page id. - const targetWt = worktreeId ?? store.activeWorktreeId - if (!targetWt) { - return - } - store.focusBrowserTabInWorktree(targetWt, browserPageId) - }) - ) - - unsubs.push( - window.api.browser.onOpenLinkInOrcaTab(({ browserPageId, url }) => { - const store = useAppStore.getState() - const sourcePage = Object.values(store.browserPagesByWorkspace) - .flat() - .find((page) => page.id === browserPageId) - if (!sourcePage) { - return - } - if (getRuntimeEnvironmentIdForWorktree(store, sourcePage.worktreeId)) { - return - } - // Why: only the renderer owns Orca's tab model, so main delegates link-open here. - store.createBrowserTab(sourcePage.worktreeId, url, { title: url }) - }) - ) - - // Why: embedded browser guests capture keyboard focus and bypass window-level keydown, so shortcuts are forwarded via IPC. - unsubs.push( - window.api.ui.onNewBrowserTab(() => { - const store = useAppStore.getState() - if (isFloatingWorkspacePanelFocused()) { - void createFloatingWorkspaceBrowserTab(store).catch((error) => { - toast.error(error instanceof Error ? error.message : String(error)) - }) - return - } - const worktreeId = store.activeWorktreeId - if (!worktreeId) { - return - } - const targetGroupId = - store.activeGroupIdByWorktree[worktreeId] ?? store.groupsByWorktree[worktreeId]?.[0]?.id - if (!targetGroupId) { - return - } - void store.openNewBrowserTabInActiveWorkspace(targetGroupId).catch((error) => { - toast.error(error instanceof Error ? error.message : String(error)) - }) - }) - ) - - unsubs.push( - window.api.ui.onNewMarkdownTab(() => { - const store = useAppStore.getState() - if (isFloatingWorkspacePanelFocused()) { - void createFloatingWorkspaceMarkdownTab(store).catch((err) => { - toast.error( - err instanceof Error - ? err.message - : translate( - 'auto.hooks.useIpcEvents.56d3ec4203', - 'Failed to create untitled markdown file.' - ) - ) - }) - return - } - const worktreeId = store.activeWorktreeId - if (!worktreeId) { - return - } - const targetGroupId = - store.activeGroupIdByWorktree[worktreeId] ?? store.groupsByWorktree[worktreeId]?.[0]?.id - if (targetGroupId) { - void store.openNewMarkdownInActiveWorkspace(targetGroupId) - } - }) - ) - - // Why: emulator IPC is additive; guard so older clients or partial preload mocks don't crash the hook when it's absent. - const unsubscribeNewSimulatorTab = window.api.ui.onNewSimulatorTab?.(() => { - if (isRuntimeEnvironmentActive()) { - return - } - const store = useAppStore.getState() - const worktreeId = store.activeWorktreeId - if (!worktreeId) { - return - } - void openMobileEmulatorTab(worktreeId, { placement: 'rightSplit' }).catch((error) => { - toast.error(error instanceof Error ? error.message : String(error)) - }) - }) - if (unsubscribeNewSimulatorTab) { - unsubs.push(unsubscribeNewSimulatorTab) - } - - const unsubscribeEmulatorAutoAttach = window.api.emulator?.onAutoAttach( - ({ worktreeId, info }) => { - if (isRuntimeEnvironmentActive()) { - return - } - if (isManualSimulatorLaunchPending(worktreeId)) { - // Why: manual launches pre-attach so the ready pane opens in the right split, not as a hidden tab in this group. - rememberPrelaunchedSimulatorSession(worktreeId, info) - return - } - ensureSimulatorTab(worktreeId, { - surfacePane: false, - executionHostId: LOCAL_EXECUTION_HOST_ID - }) - // Why: watcher may detect a helper while a simulator tab is already mounted; push stream info so the pane updates without re-attach. - window.setTimeout(() => { - window.dispatchEvent( - new CustomEvent('orca:emulator-auto-attach', { - detail: { worktreeId, info } - }) - ) - }, 0) - } - ) - if (unsubscribeEmulatorAutoAttach) { - unsubs.push(unsubscribeEmulatorAutoAttach) - } - - const unsubscribeEmulatorPaneFocus = window.api.emulator?.onPaneFocus(({ worktreeId }) => { - if (isRuntimeEnvironmentActive()) { - return - } - ensureSimulatorTab(worktreeId, { - surfacePane: true, - executionHostId: LOCAL_EXECUTION_HOST_ID - }) - }) - if (unsubscribeEmulatorPaneFocus) { - unsubs.push(unsubscribeEmulatorPaneFocus) - } - - // Why: reply with the page ID so main can await registerGuest before returning to the CLI. - unsubs.push( - window.api.ui.onRequestTabCreate((data) => { - try { - if (isRuntimeEnvironmentActive()) { - // Why: browser automation targets client-local Electron webviews that runtime agents can't see or control. - window.api.ui.replyTabCreate({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.291c8ed902', - 'Browser tabs are unavailable while a remote runtime is active' - ) - }) - return - } - const store = useAppStore.getState() - const worktreeId = data.worktreeId ?? store.activeWorktreeId - if (!worktreeId) { - window.api.ui.replyTabCreate({ - requestId: data.requestId, - error: translate('auto.hooks.useIpcEvents.f000b2ff76', 'No active worktree') - }) - return - } - // Why: CLI-created tabs should land in the active browser tab's group, not the terminal's UI-active group. - const activeBrowserTabId = store.activeBrowserTabIdByWorktree[worktreeId] - const activeBrowserUnifiedTab = activeBrowserTabId - ? (store.unifiedTabsByWorktree[worktreeId] ?? []).find( - (t) => t.contentType === 'browser' && t.entityId === activeBrowserTabId - ) - : undefined - - // Why: a user-initiated open (data.activate, e.g. mobile tapping an HTML path) foregrounds the tab so it lands in active-group order and publishes to mobile. - // Agent/automation opens stay in the background (activate:false) in the active browser group. - const workspace = store.createBrowserTab(worktreeId, data.url, { - title: data.url, - browserPageId: data.browserPageId, - targetGroupId: data.activate ? undefined : activeBrowserUnifiedTab?.groupId, - sessionProfileId: data.sessionProfileId, - sessionPartition: data.sessionPartition, - activate: data.activate === true - }) - // Why: registerGuest fires with the page ID, not the workspace ID; return it so waitForTabRegistration can correlate. - const pages = useAppStore.getState().browserPagesByWorkspace[workspace.id] ?? [] - const browserPageId = pages[0]?.id ?? workspace.id - acquireBrowserAutomationBootstrapLease(worktreeId, browserPageId) - window.api.ui.replyTabCreate({ requestId: data.requestId, browserPageId }) - } catch (err) { - window.api.ui.replyTabCreate({ - requestId: data.requestId, - error: err instanceof Error ? err.message : 'Tab creation failed' - }) - } - }) - ) - - unsubs.push( - window.api.ui.onRequestTabSetProfile((data) => { - try { - if (isRuntimeEnvironmentActive()) { - window.api.ui.replyTabSetProfile({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.f45fa2b03c', - 'Browser profiles are unavailable while a remote runtime is active' - ) - }) - return - } - const store = useAppStore.getState() - const owningWorkspace = Object.values(store.browserTabsByWorktree) - .flat() - .find((workspace) => { - if (workspace.id === data.browserPageId) { - return true - } - const pages = store.browserPagesByWorkspace[workspace.id] ?? [] - return pages.some((page) => page.id === data.browserPageId) - }) - if (!owningWorkspace) { - window.api.ui.replyTabSetProfile({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.0e3cf53060', - 'Browser tab {{value0}} not found', - { value0: data.browserPageId } - ) - }) - return - } - // Why: a workspace may host several browser pages; profile switch must tear down all sibling webviews, not just the IPC's. - const workspacePages = store.browserPagesByWorkspace[owningWorkspace.id] ?? [] - if (workspacePages.length > 0) { - for (const page of workspacePages) { - destroyPersistentWebview(page.id) - } - } else { - destroyPersistentWebview(data.browserPageId) - } - store.switchBrowserTabProfile(owningWorkspace.id, data.profileId, data.sessionPartition) - window.api.ui.replyTabSetProfile({ requestId: data.requestId }) - } catch (err) { - window.api.ui.replyTabSetProfile({ - requestId: data.requestId, - error: err instanceof Error ? err.message : 'Tab profile update failed' - }) - } - }) - ) - - unsubs.push( - window.api.ui.onRequestTabClose((data) => { - try { - if (isRuntimeEnvironmentActive()) { - window.api.ui.replyTabClose({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.291c8ed902', - 'Browser tabs are unavailable while a remote runtime is active' - ) - }) - return - } - const store = useAppStore.getState() - const explicitTargetId = data.tabId ?? null - const replyBrowserTabNotFound = (tabId: string): void => { - window.api.ui.replyTabClose({ - requestId: data.requestId, - code: 'browser_tab_not_found', - error: translate( - 'auto.hooks.useIpcEvents.0e3cf53060', - 'Browser tab {{value0}} not found', - { value0: tabId } - ) - }) - } - const replyPinnedBrowserCloseCanceled = (tabId: string): void => { - window.api.ui.replyTabClose({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.2f6637fe6c', - 'Browser tab {{value0}} is pinned', - { value0: tabId } - ) - }) - } - const closeBrowserWorkspaceWithReply = ( - worktreeId: string, - workspaceId: string - ): void => { - const currentStore = useAppStore.getState() - guardPinnedTabClose({ - isPinned: isUnifiedTabPinned(currentStore, worktreeId, workspaceId), - tabLabel: resolvePinnedTabLabel(currentStore, worktreeId, workspaceId), - onClose: () => { - useAppStore.getState().closeBrowserTab(workspaceId) - window.api.ui.replyTabClose({ requestId: data.requestId }) - }, - onCancel: () => replyPinnedBrowserCloseCanceled(workspaceId) - }) - } - const tabToClose = - explicitTargetId ?? - (data.worktreeId - ? (store.activeBrowserTabIdByWorktree?.[data.worktreeId] ?? null) - : store.activeBrowserTabId) - if (!tabToClose) { - window.api.ui.replyTabClose({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.a8d2bf8e9e', - 'No active browser tab to close' - ) - }) - return - } - // Why: the bridge keys tabs by browserPageId, but closeBrowserTab expects a workspace id. - // Per the CLI's `tab close --page` contract, close only that page unless it is the last in its workspace. - const isWorkspaceId = Object.values(store.browserTabsByWorktree) - .flat() - .some((ws) => ws.id === tabToClose) - if (!isWorkspaceId) { - const owningWorkspace = Object.entries(store.browserPagesByWorkspace).find( - ([, pages]) => pages.some((p) => p.id === tabToClose) - ) - if (owningWorkspace) { - const [workspaceId, pages] = owningWorkspace - const owningWorktreeId = - Object.entries(store.browserTabsByWorktree).find(([, tabs]) => - tabs.some((tab) => tab.id === workspaceId) - )?.[0] ?? null - if (data.worktreeId && owningWorktreeId !== data.worktreeId) { - replyBrowserTabNotFound(tabToClose) - return - } - if (pages.length <= 1) { - if (owningWorktreeId) { - closeBrowserWorkspaceWithReply(owningWorktreeId, workspaceId) - return - } - store.closeBrowserTab(workspaceId) - } else { - store.closeBrowserPage(tabToClose) - } - window.api.ui.replyTabClose({ requestId: data.requestId }) - return - } - } - const owningWorktreeId = - Object.entries(store.browserTabsByWorktree).find(([, tabs]) => - tabs.some((tab) => tab.id === tabToClose) - )?.[0] ?? null - if (owningWorktreeId) { - if (data.worktreeId && owningWorktreeId !== data.worktreeId) { - replyBrowserTabNotFound(tabToClose) - return - } - closeBrowserWorkspaceWithReply(owningWorktreeId, tabToClose) - return - } - if (explicitTargetId) { - replyBrowserTabNotFound(explicitTargetId) - return - } - store.closeBrowserTab(tabToClose) - window.api.ui.replyTabClose({ requestId: data.requestId }) - } catch (err) { - window.api.ui.replyTabClose({ - requestId: data.requestId, - error: err instanceof Error ? err.message : 'Tab close failed' - }) - } - }) - ) - - unsubs.push( - window.api.ui.onNewTerminalTab(() => { - const store = useAppStore.getState() - if (isFloatingWorkspacePanelFocused()) { - void createFloatingWorkspaceTerminalTab(store) - return - } - const worktreeId = store.activeWorktreeId - if (!worktreeId) { - return - } - void (async () => { - const environmentId = getWorktreeRuntimeEnvironmentId(worktreeId) - const outcome = await createWebRuntimeSessionTerminal({ - worktreeId, - environmentId, - activate: true - }) - if (outcome.status === 'created' || isWebRuntimeSessionActive(environmentId)) { - return - } - const newTab = store.createTab(worktreeId) - store.setActiveTabType('terminal') - // Why: mirror Terminal.tsx handleNewTab so a new tab appends at the end, not index 0, when tabBarOrder is unset. - const freshStore = useAppStore.getState() - const currentTerminals = freshStore.tabsByWorktree[worktreeId] ?? [] - const currentEditors = freshStore.openFiles.filter((f) => f.worktreeId === worktreeId) - const currentBrowsers = freshStore.browserTabsByWorktree[worktreeId] ?? [] - const stored = freshStore.tabBarOrderByWorktree[worktreeId] - const termIds = currentTerminals.map((t) => t.id) - const editorIds = currentEditors.map((f) => f.id) - const browserIds = currentBrowsers.map((tab) => tab.id) - const validIds = new Set([...termIds, ...editorIds, ...browserIds]) - const base = (stored ?? []).filter((id) => validIds.has(id)) - const inBase = new Set(base) - for (const id of [...termIds, ...editorIds, ...browserIds]) { - if (!inBase.has(id)) { - base.push(id) - inBase.add(id) - } - } - const order = base.filter((id) => id !== newTab.id) - order.push(newTab.id) - freshStore.setTabBarOrder(worktreeId, order) - focusTerminalTabSurface(newTab.id) - })() - }) - ) - - unsubs.push( - window.api.ui.onCloseActiveTab(() => { - if (isEmptyFloatingWorkspacePanelVisible()) { - window.dispatchEvent(new Event(TOGGLE_FLOATING_TERMINAL_EVENT)) - return - } - const store = useAppStore.getState() - if (store.activeTabType === 'browser' && store.activeBrowserTabId) { - const tabId = store.activeBrowserTabId - const worktreeId = store.activeWorktreeId - const closeActiveBrowserTab = (): void => { - const currentStore = useAppStore.getState() - const environmentId = getWorktreeRuntimeEnvironmentId(worktreeId) - if (environmentId && worktreeId) { - if (!isWebRuntimeSessionActive(environmentId)) { - currentStore.closeBrowserTab(tabId) - return - } - void closeWebRuntimeSessionTab({ - worktreeId, - tabId, - environmentId, - reason: 'user' - }) - return - } - currentStore.closeBrowserTab(tabId) - } - if (worktreeId && isUnifiedTabPinned(store, worktreeId, tabId)) { - guardPinnedTabClose({ - isPinned: true, - tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId), - onClose: closeActiveBrowserTab - }) - return - } - closeActiveBrowserTab() - } - }) - ) - - unsubs.push( - window.api.ui.onCloseFloatingItem(({ sourceId }) => { - // Main forwards the guest's browser *page* id; resolve it to the owning live floating - // browser workspace (the id space the panel closes by), then hand off to the mounted - // panel's own close closure (pin guard + reclaim intent). Stale id = no-op. - const workspaceId = resolveFloatingWorkspaceBrowserWorkspaceId( - useAppStore.getState(), - sourceId - ) - if (!workspaceId) { - return - } - dispatchFloatingWorkspaceGuestClose({ sourceId: workspaceId }) - }) - ) - unsubs.push( - window.api.ui.onSelectFloatingIndex(({ index }) => { - dispatchFloatingWorkspaceGuestSelectIndex({ index }) - }) - ) - - unsubs.push( - window.api.ui.onSwitchTab((direction) => { - const store = useAppStore.getState() - if (isFloatingWorkspacePanelFocused()) { - switchFloatingWorkspaceTab(store, direction, 'same-type') - return - } - handleSwitchTab(direction) - }) - ) - unsubs.push( - window.api.ui.onSwitchTabAcrossAllTypes((direction) => { - const store = useAppStore.getState() - if (isFloatingWorkspacePanelFocused()) { - switchFloatingWorkspaceTab(store, direction, 'all-types') - return - } - handleSwitchTabAcrossAllTypes(direction) - }) - ) - unsubs.push(window.api.ui.onSwitchRecentTab(handleSwitchRecentTab)) - unsubs.push( - window.api.ui.onSwitchTerminalTab((direction) => { - const store = useAppStore.getState() - if (isFloatingWorkspacePanelFocused()) { - switchFloatingWorkspaceTab(store, direction, 'terminal') - return - } - handleSwitchTerminalTab(direction) - }) - ) - - let initialRateLimitsSnapshotPending = true - let receivedRateLimitsPushBeforeInitialSnapshot = false - unsubs.push( - window.api.rateLimits.onUpdate((state) => { - if (initialRateLimitsSnapshotPending) { - receivedRateLimitsPushBeforeInitialSnapshot = true - } - useAppStore.getState().setRateLimitsFromPush(state as RateLimitState) - }) - ) - // Why: the startup get is a fallback; a live push may already include account snapshots the get result lacks. - window.api.rateLimits.get().then((state) => { - initialRateLimitsSnapshotPending = false - if (receivedRateLimitsPushBeforeInitialSnapshot) { - return - } - useAppStore.getState().setRateLimitsFromPush(state as RateLimitState) - }) - - const unsubscribeWorkspaceSpaceProgress = window.api.workspaceSpace?.onProgress?.( - (progress) => { - useAppStore.getState().applyWorkspaceSpaceProgress(progress) - } - ) - if (unsubscribeWorkspaceSpaceProgress) { - unsubs.push(unsubscribeWorkspaceSpaceProgress) - } - - const sshStateWatermarkByTargetId = new Map() - const pendingPortHydrationByTargetId = new Map< - string, - { receivedForwardPush: boolean; receivedDetectedPush: boolean } - >() - const hydrateSshPorts = (targetId: string, authority: DirectSshAuthority): void => { - const pendingPortHydration = { - receivedForwardPush: false, - receivedDetectedPush: false - } - pendingPortHydrationByTargetId.set(targetId, pendingPortHydration) - const isHydrationAuthorityCurrent = (): boolean => - !directSshEffectStopped && - directSshAuthoritiesEqual(currentDirectSshAuthority(targetId), authority) - const forwardHydration = window.api.ssh.listPortForwards({ targetId }).then((forwards) => { - // Why: if the session disconnected while awaiting the snapshot, applying it would resurrect a dead session's ports. - if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedForwardPush) { - useAppStore.getState().setPortForwards(targetId, forwards) - } - }) - const detectedHydration = window.api.ssh.listDetectedPorts({ targetId }).then((detected) => { - if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedDetectedPush) { - useAppStore.getState().setDetectedPorts(targetId, detected) - } - }) - // Why: one failed or stalled port stream must not block the other stream or later targets. - void Promise.allSettled([forwardHydration, detectedHydration]).then(() => { - if (pendingPortHydrationByTargetId.get(targetId) === pendingPortHydration) { - pendingPortHydrationByTargetId.delete(targetId) - } - }) - } - let applySshConnectionStateChange!: ( - targetId: string, - state: SshConnectionState, - origin: DirectSshConnectedStateOrigin - ) => void - - // Why: hydrate initial SSH state for all targets so worktree cards show correct connect state on launch. - void (async () => { - try { - const targets = await window.api.ssh.listTargets() - if (directSshEffectStopped) { - return - } - useAppStore.getState().setSshTargetsMetadata(targets) - // Why: ghost-host UI (removed target still referenced by a workspace) shows a tombstone name instead of the raw id. - try { - const removedLabels = await window.api.ssh.listRemovedTargetLabels() - if (directSshEffectStopped) { - return - } - useAppStore.getState().setRemovedSshTargetLabels(removedLabels) - } catch { - // Best-effort — a missing map just falls back to the raw target id. - } - for (const target of targets) { - const hydrationWatermark = sshStateWatermarkByTargetId.get(target.id) ?? 0 - const state = await window.api.ssh.getState({ targetId: target.id }) - if ( - !directSshEffectStopped && - state && - (sshStateWatermarkByTargetId.get(target.id) ?? 0) === hydrationWatermark - ) { - applySshConnectionStateChange( - target.id, - state as SshConnectionState, - 'initial-hydration' - ) - } - } - } catch { - // SSH may not be configured - } - })() - - unsubs.push( - window.api.ssh.onCredentialRequest((data) => { - useAppStore.getState().enqueueSshCredentialRequest(data) - }) - ) - - unsubs.push( - window.api.ssh.onCredentialResolved(({ requestId }) => { - useAppStore.getState().removeSshCredentialRequest(requestId) - }) - ) - - unsubs.push( - window.api.ssh.onPortForwardsChanged(({ targetId, forwards }) => { - const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId) - if (pendingPortHydration) { - pendingPortHydration.receivedForwardPush = true - } - useAppStore.getState().setPortForwards(targetId, forwards) - }) - ) - - unsubs.push( - window.api.ssh.onDetectedPortsChanged(({ targetId, ports }) => { - const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId) - if (pendingPortHydration) { - pendingPortHydration.receivedDetectedPush = true - } - useAppStore.getState().setDetectedPorts(targetId, ports) - }) - ) - - const reconcileSshAuthority = ( - targetId: string, - initiatingState: SshConnectionState, - origin: DirectSshConnectedStateOrigin, - watermark: number - ): void => { - let pendingDeadline: { timer: ReturnType; settle: () => void } | undefined - const deadline = new Promise((resolve) => { - const settle = (): void => resolve(null) - const timer = setTimeout(settle, 5_000) - pendingDeadline = { timer, settle } - authorityReconciliationDeadlines.add(pendingDeadline) - }) - void Promise.race([window.api.ssh.getState({ targetId }).catch(() => null), deadline]) - .then((latest) => { - if ( - directSshEffectStopped || - latest?.targetId !== targetId || - !latest?.providerEpoch || - latest.connectionGeneration === undefined || - (sshStateWatermarkByTargetId.get(targetId) ?? 0) !== watermark - ) { - return - } - const current = useAppStore.getState().sshConnectionStates?.get(targetId) - if ( - current?.status !== initiatingState.status || - latest.status !== initiatingState.status || - current.providerEpoch !== initiatingState.providerEpoch || - current.connectionGeneration !== initiatingState.connectionGeneration || - (current.providerEpoch !== undefined && - current.providerEpoch !== null && - current.providerEpoch !== latest.providerEpoch) || - (current.connectionGeneration !== undefined && - current.connectionGeneration !== latest.connectionGeneration) - ) { - return - } - applySshConnectionStateChange( - targetId, - { - ...current, - providerEpoch: latest.providerEpoch, - connectionGeneration: latest.connectionGeneration - }, - origin - ) - }) - .catch(() => undefined) - .finally(() => { - if (pendingDeadline) { - clearTimeout(pendingDeadline.timer) - authorityReconciliationDeadlines.delete(pendingDeadline) - } - }) - } - - applySshConnectionStateChange = ( - targetId: string, - state: SshConnectionState, - origin: DirectSshConnectedStateOrigin - ): void => { - const store = useAppStore.getState() - const previous = store.sshConnectionStates?.get(targetId) - store.setSshConnectionState(targetId, state) - - if (canConnectSshStatus(state.status)) { - reconnectAuthorityByTarget.delete(targetId) - reconnectCoordinator.invalidate(targetId) - // Why: remote agent list is tied to a live relay; clear on disconnect so reconnect re-detects against the new relay. - store.clearRemoteDetectedAgents(targetId) - - // Why: defensive — clear port state in case the removeAllForwards broadcast races this state change. - store.clearPortForwards(targetId) - store.setDetectedPorts(targetId, []) - - // SSH teardown has no per-PTY exits; clear only exact-target bindings in one store publication. - store.clearDirectSshTargetPtyBindings(targetId) - return - } - - if (state.status !== 'connected') { - return - } - const authority = currentDirectSshAuthority(targetId) - if (!authority) { - reconcileSshAuthority( - targetId, - state, - origin, - sshStateWatermarkByTargetId.get(targetId) ?? 0 - ) - return - } - const previousAuthority = - previous?.status === 'connected' && - previous.providerEpoch && - previous.connectionGeneration !== undefined - ? { - targetId, - providerEpoch: previous.providerEpoch, - connectionGeneration: previous.connectionGeneration - } - : null - routeDirectSshConnectedState( - { - coordinator: reconnectCoordinator, - coordinatorRoutingEnabled: isDirectSshReconnectCoordinatorRoutingEnabled(), - invalidateStaleTerminalBindings: (nextAuthority) => - directSshTerminalActions().invalidateStaleDirectSshTargetPtyBindings?.(nextAuthority) ?? - 0, - retryTargetPanes: (nextAuthority) => - directSshTerminalActions().retryDirectSshTargetPanes?.(nextAuthority) ?? 0, - prepareAndSync: prepareAndSyncDirectSshTarget, - rememberReconnectAuthority: (nextAuthority) => { - if (nextAuthority) { - reconnectAuthorityByTarget.set(targetId, nextAuthority) - } else { - reconnectAuthorityByTarget.delete(targetId) - } - } - }, - { authority, previousAuthority, origin } - ) - // Why: initial connected state can be partial; hydrate only after reconciliation yields a complete authority. - if (origin === 'initial-hydration') { - hydrateSshPorts(targetId, authority) - } - } - - let sshTargetStateEventId = 0 - const latestSshTargetStateEventByTargetId = new Map() - - handleSshStateChangedEvent = (data: { targetId: string; state: unknown }): void => { - const store = useAppStore.getState() - const state = data.state as SshConnectionState - const stateEventId = ++sshTargetStateEventId - sshStateWatermarkByTargetId.set( - data.targetId, - (sshStateWatermarkByTargetId.get(data.targetId) ?? 0) + 1 - ) - latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId) - if (!store.sshTargetLabels.has(data.targetId)) { - // Why: unknown target id could be a post-boot add or a removed target racing disconnect; confirm with main first. - window.api.ssh - .listTargets() - // Why: refresh doubles as a deletion guard; retry once so a transient IPC failure doesn't drop a real added-target event. - .catch(() => window.api.ssh.listTargets()) - .then((targets) => { - if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) { - return - } - latestSshTargetStateEventByTargetId.delete(data.targetId) - if (directSshEffectStopped) { - return - } - const latestStore = useAppStore.getState() - if (!targets.some((target) => target.id === data.targetId)) { - // Why: state events can race after target removal; absence from main's target list means deletion, not a new target. - latestStore.clearRemovedSshTargetState(data.targetId) - return - } - latestStore.setSshTargetsMetadata(targets) - applySshConnectionStateChange(data.targetId, state, 'push') - }) - .catch(() => { - if ( - !directSshEffectStopped && - latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId - ) { - latestSshTargetStateEventByTargetId.delete(data.targetId) - applySshConnectionStateChange(data.targetId, state, 'push') - } - }) - return - } - - latestSshTargetStateEventByTargetId.delete(data.targetId) - applySshConnectionStateChange(data.targetId, state, 'push') - } - - unsubs.push(window.api.ssh.onStateChanged(handleSshStateChangedEvent)) - unsubs.push( - registerDirectSshWakeRouting({ - getConnectionStates: () => useAppStore.getState().sshConnectionStates ?? [], - wakeAuthority: (authority) => { - reconnectCoordinator.correctUnboundTerminals(authority, 'wake-refresh') - void prepareAndSyncDirectSshTarget(authority, 'wake-refresh') - }, - ...(typeof window.api.ui.onSystemResumed === 'function' - ? { onSystemResumed: (callback: () => void) => window.api.ui.onSystemResumed(callback) } - : {}) - }) - ) - - let remoteWorkspaceClientId: string | null = null - let remoteWorkspaceClientIdPromise: Promise | null = null - const getRemoteWorkspaceClientId = (): Promise => { - const remoteWorkspace = window.api.remoteWorkspace - if (!remoteWorkspace) { - return Promise.resolve(null) - } - if (remoteWorkspaceClientId) { - return Promise.resolve(remoteWorkspaceClientId) - } - remoteWorkspaceClientIdPromise ??= remoteWorkspace - .clientId() - .then((id) => { - remoteWorkspaceClientId = id - return id - }) - .catch(() => null) - return remoteWorkspaceClientIdPromise - } - if (window.api.remoteWorkspace) { - void getRemoteWorkspaceClientId() - unsubs.push( - window.api.remoteWorkspace.onChanged((event) => { - void (async () => { - // Why: relay notifications can race the client-id IPC; self-originated writes must never bounce back into restore. - const clientId = await getRemoteWorkspaceClientId() - if (event.sourceClientId && clientId && event.sourceClientId === clientId) { - return - } - await remoteWorkspaceTargetSync - ?.applyUnsolicitedSnapshot(event.targetId, event.snapshot) - .catch((err) => { - useAppStore.getState().setRemoteWorkspaceSyncStatus(event.targetId, { - phase: 'error', - revision: event.snapshot.revision, - message: err instanceof Error ? err.message : 'Failed to apply remote workspace' - }) - }) - })() - }) - ) - } - - // Zoom handling for menu accelerators and keyboard fallback paths. - unsubs.push( - window.api.ui.onTerminalZoom((direction) => { - const store = useAppStore.getState() - const { activeView, activeTabType, editorFontZoomLevel, setEditorFontZoomLevel, settings } = - store - const target = resolveZoomTarget({ - activeView, - activeTabType, - activeElement: document.activeElement - }) - if (target === 'terminal') { - return - } - if (target === 'editor') { - const next = nextEditorFontZoomLevel(editorFontZoomLevel, direction) - setEditorFontZoomLevel(next) - void window.api.ui.set({ editorFontZoomLevel: next }) - - // Why: mirror the editor's base font (terminalFontSize) + clamping so the overlay percent matches the rendered size. - const baseFontSize = settings?.terminalFontSize ?? 13 - const actual = computeEditorFontSize(baseFontSize, next) - const percent = Math.round((actual / baseFontSize) * 100) - dispatchZoomLevelChanged('editor', percent) - return - } - - const current = window.api.ui.getZoomLevel() - const next = stepUIZoomLevel(current, direction) - - applyUIZoom(next) - void window.api.ui.set({ uiZoomLevel: next }) - - dispatchZoomLevelChanged('ui', zoomLevelToPercent(next)) - }) - ) - - // Why: re-parse main-process agent status here so the renderer applies the same normalization regardless of hook vs OSC source. - // Startup pushes are ignored until workspace session hydration finishes; the snapshot pull below replays main's cache once tab identity exists. - function schedulePendingAgentStatusFlush(): void { - if (pendingAgentStatusRetryTimer !== null || pendingAgentStatusEvents.length === 0) { - return - } - pendingAgentStatusRetryTimer = globalThis.setTimeout(() => { - pendingAgentStatusRetryTimer = null - flushPendingAgentStatuses() - }, PENDING_AGENT_STATUS_RETRY_MS) - } - - function enqueuePendingAgentStatus( - data: AgentStatusIpcPayload, - options?: { replay?: boolean } - ): void { - pendingAgentStatusEvents.push({ - data, - firstSeenAt: Date.now(), - replay: options?.replay === true - }) - while (pendingAgentStatusEvents.length > MAX_PENDING_AGENT_STATUS_EVENTS) { - pendingAgentStatusEvents.shift() - } - schedulePendingAgentStatusFlush() - } - - function flushPendingAgentStatuses(): void { - // Why: guard re-entrancy — a subscriber firing mid-loop must not reprocess queued events the outer flush already owns. - if (isFlushingAgentStatuses) { - return - } - if (pendingAgentStatusEvents.length === 0) { - return - } - isFlushingAgentStatuses = true - try { - const now = Date.now() - const candidates = pendingAgentStatusEvents - .splice(0) - .filter((event) => now - event.firstSeenAt <= PENDING_AGENT_STATUS_TTL_MS) - let results: AgentStatusApplyResult[] - try { - results = applyAgentStatusBatch( - candidates.map((event) => ({ data: event.data, replay: event.replay, retry: true })) - ) - } catch (err) { - // Why: the queue was already spliced, so a throwing fold would drop the whole - // burst and strand every pane in it. Requeue ahead of newer arrivals and retry. - pendingAgentStatusEvents.unshift(...candidates) - throw err - } - for (let index = 0; index < candidates.length; index += 1) { - if (results[index] === 'pending') { - pendingAgentStatusEvents.push(candidates[index]) - } - } - if (pendingAgentStatusEvents.length === 0 && pendingAgentStatusRetryTimer !== null) { - globalThis.clearTimeout(pendingAgentStatusRetryTimer) - pendingAgentStatusRetryTimer = null - } - } finally { - isFlushingAgentStatuses = false - } - schedulePendingAgentStatusFlush() - } - - const applyAgentStatus = ( - data: AgentStatusIpcPayload, - options?: AgentStatusApplyOptions - ): AgentStatusApplyResult => { - const store = options?.batch?.transaction.getState() ?? useAppStore.getState() - if (!store.workspaceSessionReady) { - return 'dropped' - } - if (isAgentStatusForRecentlyClosedTab(store, data.paneKey)) { - return 'dropped' - } - const paneKey = resolveAgentPaneAuthorityKey(data.paneKey) - 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, - toolName: data.toolName, - toolInput: data.toolInput, - // Why: the live AskUserQuestion prompt rides this field; omitting it drops the native question card on web/mobile. - interactivePrompt: data.interactivePrompt, - lastAssistantMessage: data.lastAssistantMessage, - interrupted: data.interrupted, - sessionBoundary: data.sessionBoundary, - turnCompletedAt: data.turnCompletedAt, - // Why: same trap as interactivePrompt — this rebuild is a field whitelist, so subagent child rows vanish if omitted. - subagents: data.subagents - }) - if (!payload) { - return 'dropped' - } - let { - exists, - title, - identityTitle, - repoConnectionId, - repoConnectionResolved, - owningWorktreeId, - titleUsesTabTitle - } = options?.batch - ? resolvePaneKeyFromRoutingIndex(options.batch.routingIndex, paneKey) - : resolvePaneKey(store, paneKey) - const projectedTitles = - titleUsesTabTitle && ownerTabId - ? options?.batch?.projectedTitlesByTabId.get(ownerTabId) - : undefined - if (projectedTitles) { - title = projectedTitles.title - identityTitle = projectedTitles.identityTitle - } - if (!exists && data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) { - // Why: orchestration worker hooks may carry worktree attribution before this renderer has a tab for the pane. - // Require runtime identity too — worktreeId-only snapshots can be stale rows from closed/remounted panes. - const fallbackOwnership = options?.batch - ? resolveWorktreeConnectionFromRoutingIndex(options.batch.routingIndex, data.worktreeId) - : resolveWorktreeConnection(store, data.worktreeId) - if (fallbackOwnership.worktreeExists) { - owningWorktreeId = data.worktreeId - repoConnectionId = fallbackOwnership.repoConnectionId - repoConnectionResolved = fallbackOwnership.repoConnectionResolved - exists = true - } - } - if (!exists) { - // Why: startup snapshot replay can beat tab/layout hydration too. - // Reuse the same bounded retry queue when the row still carries - // runtime-backed worktree provenance so the cached status can adopt - // once the pane becomes visible. - if (options?.replay === true) { - if (data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) { - if (options?.retry !== true) { - enqueuePendingAgentStatus(data, { replay: true }) - } - return 'pending' - } - return 'dropped' - } - if (options?.retry !== true) { - // Why: empty paneKeys are dropped in main before IPC fanout. Reaching - // this branch means a non-empty paneKey escaped without a matching - // renderer tab, so track the adoption/routing failure separately. - track('agent_hook_unattributed', { reason: 'unknown_tab_id' }) - enqueuePendingAgentStatus(data) - } - return 'pending' - } - if (options?.replay !== true && options?.retry !== true) { - for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { - if (pendingAgentStatusEvents[index].data.paneKey === data.paneKey) { - pendingAgentStatusEvents.splice(index, 1) - } - } - } - // Why: drop in-flight events stamped with a dead connection's id after SSH disconnect/reconnect. - // Why: startup snapshot replay can beat SSH repo hydration; accept when worktreeId matches the tab until repo ownership resolves. - // Why: WSL relay stamps a `wsl:` connectionId but the pane is a local repo (ownership null); normalize so the strict check below doesn't drop it. - const ownershipConnectionId = isWslHookRelayConnectionId(data.connectionId) - ? null - : data.connectionId - const transientClearWatermark = - typeof data.connectionId === 'string' - ? transientClearWatermarkByConnectionId.get(data.connectionId) - : undefined - // Why: delayed snapshots/queued relay events must not resurrect a status cleared by a newer disconnect on this connection. - if (transientClearWatermark !== undefined && data.receivedAt <= transientClearWatermark) { - return 'dropped' - } - const canAcceptPendingRemoteOwnership = - ownershipConnectionId !== undefined && - ownershipConnectionId !== null && - !repoConnectionResolved && - data.worktreeId !== undefined && - data.worktreeId === owningWorktreeId - if ( - ownershipConnectionId !== undefined && - ownershipConnectionId !== repoConnectionId && - !canAcceptPendingRemoteOwnership - ) { - return 'dropped' - } - const existingStatus = store.agentStatusByPaneKey[paneKey] - if (existingStatus && data.receivedAt < existingStatus.updatedAt) { - // Why: the store rejects out-of-order status rows; keep metadata-only session identity on the same event boundary. - return 'dropped' - } - if (data.providerSessionOnly) { - if (!data.providerSession || data.agentType !== 'pi') { - return 'dropped' - } - const providerSessionUpdate: AgentStatusBatchUpdate = { - kind: 'providerSession', - paneKey, - agent: 'pi', - providerSession: data.providerSession, - timing: { updatedAt: data.receivedAt }, - routing: { - tabId: ownerTabId, - worktreeId: data.worktreeId ?? owningWorktreeId, - // Why: persist the WSL-normalized ownership id, not raw relay provenance; a `wsl:*` connectionId would misroute later resumes. - ...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {}) - }, - metadata: data.launchToken ? { launchToken: data.launchToken } : undefined - } - if (options?.batch) { - return options.batch.transaction.apply(providerSessionUpdate) ? 'applied' : 'dropped' - } - store.recordAgentProviderSession( - providerSessionUpdate.paneKey, - providerSessionUpdate.agent, - providerSessionUpdate.providerSession, - providerSessionUpdate.timing, - providerSessionUpdate.routing, - providerSessionUpdate.metadata - ) - return 'applied' - } - const resolvedPayload = resolveHookPayloadAgentType(payload, identityTitle ?? title) - const statusPayload = data.orchestration - ? { ...resolvedPayload, orchestration: data.orchestration } - : resolvedPayload - const statusPayloadWithTurnBoundary = data.promptInteractionKey - ? { ...statusPayload, promptInteractionKey: data.promptInteractionKey } - : statusPayload - // Why: hydrated-unconfirmed provenance is envelope data the payload whitelist above drops; re-thread it or freshness gates confirm restored rows. - const statusPayloadWithProvenance = - data.restoredUnconfirmed === true - ? { ...statusPayloadWithTurnBoundary, restoredUnconfirmed: true } - : statusPayloadWithTurnBoundary - // Why: main sequenced this row as the pane authority; carry its stamp rather than - // minting a renderer one, which would claim a second authority for the same observation. - const statusPayloadWithObservation = data.observation - ? { ...statusPayloadWithProvenance, observation: data.observation } - : statusPayloadWithProvenance - const identity = resolveAgentStatusIdentity({ - existing: existingStatus - ? { - agentType: existingStatus.agentType, - state: existingStatus.state, - updatedAt: existingStatus.updatedAt, - restoredUnconfirmed: existingStatus.restoredUnconfirmed - } - : undefined, - incoming: statusPayload.agentType, - now: data.receivedAt - }) - if ( - existingStatus && - shouldSuppressInheritedTerminalStatus({ - inheritedFromActivePane: identity.inheritedFromActivePane, - incomingState: statusPayload.state - }) - ) { - // Why: guards against a stale main-process child completion resurrecting terminal status. - return 'dropped' - } - if ( - shouldSuppressCodexAutoApprovalStatus(statusPayload, { - paneKey, - tabId: ownerTabId, - terminalHandle: data.terminalHandle, - launchToken: data.launchToken, - providerSession: data.providerSession, - existingProviderSession: existingStatus?.providerSession - }) - ) { - // Why: Codex yolo permission hooks are not user-actionable; they must not drive status, titles, badges, or notifications. - return 'dropped' - } - const terminalTitle = resolveAgentStatusTerminalTitle(statusPayload, title) - const statusWorktreeId = data.worktreeId ?? owningWorktreeId - const update: AgentStatusUpdate = { - paneKey, - payload: statusPayloadWithObservation, - terminalTitle, - timing: { - updatedAt: data.receivedAt, - stateStartedAt: data.stateStartedAt - }, - routing: { - tabId: ownerTabId, - worktreeId: statusWorktreeId, - terminalHandle: data.terminalHandle, - ...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {}) - }, - metadata: - data.providerSession || data.launchToken - ? { - ...(data.providerSession ? { providerSession: data.providerSession } : {}), - ...(data.launchToken ? { launchToken: data.launchToken } : {}) - } - : undefined - } - const applyPostCommitNotification = (): void => { - if (statusWorktreeId && (options?.replay !== true || resolvedPayload.state === 'working')) { - // Why: local Codex/Claude hooks arrive via this main-process IPC path, not the PTY OSC fallback, so task-complete notifications must observe accepted hook state here too. - const notificationPayload = - typeof data.stateStartedAt === 'number' - ? { ...resolvedPayload, stateStartedAt: data.stateStartedAt } - : resolvedPayload - observeAgentHookCompletionForNotification({ - paneKey, - worktreeId: statusWorktreeId, - payload: notificationPayload, - ...(options?.replay === true ? { seedOnly: true } : {}) - }) - } - } - if (options?.batch) { - if (!options.batch.transaction.apply(update)) { - return 'dropped' - } - options.batch.notificationEffects.push(applyPostCommitNotification) - if ( - terminalTitle && - shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, title, terminalTitle) - ) { - const tabId = parsePaneKey(paneKey)?.tabId - if (tabId) { - options.batch.tabTitlesByTabId.set(tabId, terminalTitle) - if (titleUsesTabTitle) { - const titleChanges = - !title || !isDecorativeAgentTitleFrameChange(title, terminalTitle) - options.batch.projectedTitlesByTabId.set(tabId, { - title: titleChanges ? terminalTitle : title, - identityTitle: titleChanges ? terminalTitle : identityTitle - }) - } - } - } - } else { - store.setAgentStatus( - update.paneKey, - update.payload, - update.terminalTitle, - update.timing, - update.routing, - update.metadata - ) - applyResolvedAgentTerminalTitleToTab(useAppStore.getState(), paneKey, title, terminalTitle) - applyPostCommitNotification() - } - return 'applied' - } - - let snapshotRequestedForReadyWindow = false - let snapshotRequestId = 0 - const requestAgentStatusSnapshotIfReady = (): void => { - const store = useAppStore.getState() - if (!store.workspaceSessionReady) { - snapshotRequestedForReadyWindow = false - return - } - if (snapshotRequestedForReadyWindow) { - return - } - const getSnapshot = window.api.agentStatus.getSnapshot - if (typeof getSnapshot !== 'function') { - return - } - snapshotRequestedForReadyWindow = true - const requestId = ++snapshotRequestId - void getSnapshot() - .then((entries) => { - if (agentStatusEffectDisposed || requestId !== snapshotRequestId) { - return - } - const current = useAppStore.getState() - if (!current.workspaceSessionReady) { - return - } - applyAgentStatusBatch(entries.map((data) => ({ data, replay: true }))) - const getMigrationUnsupportedSnapshot = - window.api.agentStatus.getMigrationUnsupportedSnapshot - if (typeof getMigrationUnsupportedSnapshot !== 'function') { - return - } - void getMigrationUnsupportedSnapshot().then((unsupportedEntries) => { - if (agentStatusEffectDisposed || requestId !== snapshotRequestId) { - return - } - const unsupportedStore = useAppStore.getState() - if (!unsupportedStore.workspaceSessionReady) { - return - } - const unsupportedRoutingIndex = createAgentStatusPaneRoutingIndex(unsupportedStore) - for (const entry of unsupportedEntries) { - if ( - entry.paneKey && - resolvePaneKeyFromRoutingIndex(unsupportedRoutingIndex, entry.paneKey).exists - ) { - unsupportedStore.setMigrationUnsupportedPty(entry) - } - } - }) - }) - .catch((err) => { - // Why: stay latched on failure; the store subscriber fires on every update, so resetting here would turn a persistent IPC failure into a retry storm (flag clears on workspaceSessionReady toggle). - console.warn('[agent-status] failed to load startup snapshot:', err) - }) - } - - function applyAgentStatusBatch( - events: readonly AgentStatusBatchEvent[] - ): AgentStatusApplyResult[] { - if (events.length === 0) { - return [] - } - return useAppStore.getState().transactAgentStatuses((transaction) => { - const batch: AgentStatusBatchContext = { - transaction, - routingIndex: createAgentStatusPaneRoutingIndex(transaction.getState()), - projectedTitlesByTabId: new Map(), - tabTitlesByTabId: new Map(), - notificationEffects: [] - } - const results = events.map(({ data, replay, retry }) => - applyAgentStatus(data, { batch, replay, retry }) - ) - if (batch.tabTitlesByTabId.size > 0) { - transaction.afterCommit(() => { - useAppStore - .getState() - .updateTabTitles( - [...batch.tabTitlesByTabId].map(([tabId, title]) => ({ tabId, title })) - ) - }) - } - for (const effect of batch.notificationEffects) { - transaction.afterCommit(effect) - } - return results - }) - } - - function applyLiveAgentStatusBatch(batch: readonly AgentStatusIpcPayload[]): boolean { - return applyAgentStatusBatch(batch.map((data) => ({ data }))).some( - (result) => result === 'applied' - ) - } - - function flushLiveAgentStatusBurst(): void { - liveAgentStatusBurstTimer = null - lastLiveAgentStatusApplyAt = Date.now() - // Why: splice before publishing — synchronous Zustand subscribers can enqueue the next burst. - const batch = liveAgentStatusBurstQueue.splice(0) - if (!applyLiveAgentStatusBatch(batch)) { - lastLiveAgentStatusApplyAt = 0 - } - } - - function drainQueuedLiveAgentStatusesForPane(paneKey: string): void { - const queuedForPane: AgentStatusIpcPayload[] = [] - const remaining: AgentStatusIpcPayload[] = [] - for (const queued of liveAgentStatusBurstQueue) { - if (queued.paneKey === paneKey) { - queuedForPane.push(queued) - } else { - remaining.push(queued) - } - } - liveAgentStatusBurstQueue.length = 0 - liveAgentStatusBurstQueue.push(...remaining) - applyLiveAgentStatusBatch(queuedForPane) - } - - function enqueueLiveAgentStatus(data: AgentStatusIpcPayload): void { - const now = Date.now() - if ( - liveAgentStatusBurstTimer === null && - now - lastLiveAgentStatusApplyAt >= LIVE_AGENT_STATUS_BURST_WINDOW_MS - ) { - lastLiveAgentStatusApplyAt = now - // Why: only an applied event commits state and costs a render pass — - // a dropped/pending leading edge must not make its successor pay - // burst latency (startup replay and unmounted panes stay immediate). - if (applyAgentStatus(data) !== 'applied') { - lastLiveAgentStatusApplyAt = 0 - } - return - } - liveAgentStatusBurstQueue.push(data) - if (liveAgentStatusBurstTimer === null) { - liveAgentStatusBurstTimer = globalThis.setTimeout( - flushLiveAgentStatusBurst, - LIVE_AGENT_STATUS_BURST_WINDOW_MS - ) - } - } - - unsubs.push( - window.api.agentStatus.onSet((data) => { - enqueueLiveAgentStatus(data) - }) - ) - const unsubscribeAgentStatusClear = window.api.agentStatus.onClear?.( - (data: AgentStatusClearIpcPayload) => { - if (typeof data !== 'object' || data === null) { - return - } - if ('transient' in data && data.transient === true) { - if ( - typeof data.connectionId !== 'string' || - data.connectionId.length === 0 || - !Number.isFinite(data.clearedAt) - ) { - return - } - const previousWatermark = - transientClearWatermarkByConnectionId.get(data.connectionId) ?? -1 - const effectiveWatermark = Math.max(previousWatermark, data.clearedAt) - transientClearWatermarkByConnectionId.set(data.connectionId, effectiveWatermark) - for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { - const pending = pendingAgentStatusEvents[index].data - if ( - pending.connectionId === data.connectionId && - pending.receivedAt <= effectiveWatermark - ) { - pendingAgentStatusEvents.splice(index, 1) - } - } - for (let index = liveAgentStatusBurstQueue.length - 1; index >= 0; index -= 1) { - const queued = liveAgentStatusBurstQueue[index] - if ( - queued.connectionId === data.connectionId && - queued.receivedAt <= effectiveWatermark - ) { - liveAgentStatusBurstQueue.splice(index, 1) - } - } - useAppStore.getState().clearTransientAgentStatuses(data.connectionId, effectiveWatermark) - return - } - if (!('paneKey' in data) || typeof data.paneKey !== 'string') { - return - } - // Why: preserve set→clear FIFO so a queued completion still survives pane teardown. - if (liveAgentStatusBurstQueue.some((queued) => queued.paneKey === data.paneKey)) { - drainQueuedLiveAgentStatusesForPane(data.paneKey) - } - for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { - if (pendingAgentStatusEvents[index].data.paneKey === data.paneKey) { - pendingAgentStatusEvents.splice(index, 1) - } - } - const store = useAppStore.getState() - if (store.agentStatusByPaneKey[data.paneKey]?.state === 'done') { - return - } - store.removeAgentStatus(data.paneKey) - } - ) - if (unsubscribeAgentStatusClear) { - unsubs.push(unsubscribeAgentStatusClear) - } - const unsubscribeMigrationUnsupported = window.api.agentStatus.onMigrationUnsupported?.( - (entry) => { - const store = useAppStore.getState() - if (!store.workspaceSessionReady) { - return - } - if (entry.paneKey && resolvePaneKey(store, entry.paneKey).exists) { - store.setMigrationUnsupportedPty(entry) - } - } - ) - if (unsubscribeMigrationUnsupported) { - unsubs.push(unsubscribeMigrationUnsupported) - } - const unsubscribeMigrationUnsupportedClear = - window.api.agentStatus.onMigrationUnsupportedClear?.(({ ptyId }) => { - useAppStore.getState().clearMigrationUnsupportedPty(ptyId) - }) - if (unsubscribeMigrationUnsupportedClear) { - unsubs.push(unsubscribeMigrationUnsupportedClear) - } - const unsubscribeLegacyWorkerTerminalRecovery = - window.api.agentStatus.onLegacyWorkerTerminalRecovery?.((event) => { - const action = resolveLegacyWorkerTerminalRecoveryAction(event) - if (action.kind === 'rollback-surface') { - window.dispatchEvent( - new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail: action.detail }) - ) - rollbackLegacyWorkerTerminalSurfaceInStore(useAppStore.getState(), action.detail) - } else if (action.kind === 'clear-sleeping') { - useAppStore.getState().clearSleepingAgentSession(action.paneKey) - } - }) - if (unsubscribeLegacyWorkerTerminalRecovery) { - unsubs.push(unsubscribeLegacyWorkerTerminalRecovery) - } - - // Why: main hook server is the durable source of truth; pull the snapshot only after tabs are ready so early startup pushes can be ignored, not buffered. - requestAgentStatusSnapshotIfReady() - const unsubscribeAgentStatusStore = useAppStore.subscribe((state, previousState) => { - requestAgentStatusSnapshotIfReady() - flushPendingAgentStatuses() - syncAgentHookCompletionNotificationsForStoreUpdate(state, previousState) - }) - - let mobileStateHydrated = isRuntimeEnvironmentActive() - type PendingMobileStateEvent = - | { - kind: 'fit' - event: { - ptyId: string - mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' - cols: number - rows: number - } - } - | { - kind: 'driver' - event: { - ptyId: string - driver: RuntimeTerminalDriverState - } - } - | { - kind: 'browser-driver' - event: { - browserPageId: string - driver: RuntimeBrowserDriverState - } - } - const pendingMobileStateEvents: PendingMobileStateEvent[] = [] - let mobileStateHydrationDisposed = false - - const applyPendingMobileStateEvents = (): void => { - for (const pending of pendingMobileStateEvents) { - if (pending.kind === 'fit') { - const { ptyId, mode, cols, rows } = pending.event - setFitOverride(ptyId, mode, cols, rows) - } else if (pending.kind === 'driver') { - setDriverForPty(pending.event.ptyId, pending.event.driver) - } else { - setDriverForBrowserPage(pending.event.browserPageId, pending.event.driver) - } - } - pendingMobileStateEvents.length = 0 - } - - const enqueuePendingMobileStateEvent = (event: PendingMobileStateEvent): void => { - pendingMobileStateEvents.push(event) - while (pendingMobileStateEvents.length > MAX_PENDING_MOBILE_STATE_EVENTS) { - pendingMobileStateEvents.shift() - } - } - - unsubs.push( - window.api.runtime.onTerminalFitOverrideChanged((event) => { - if (isRuntimeEnvironmentActive()) { - return - } - if (!mobileStateHydrated) { - enqueuePendingMobileStateEvent({ kind: 'fit', event }) - return - } - setFitOverride(event.ptyId, event.mode, event.cols, event.rows) - }) - ) - - unsubs.push( - // Why: mirror presence-lock driver state so TerminalPane / pty-connection guards know which PTYs are mobile-driven. See docs/mobile-presence-lock.md. - window.api.runtime.onTerminalDriverChanged((event) => { - if (isRuntimeEnvironmentActive()) { - return - } - if (!mobileStateHydrated) { - enqueuePendingMobileStateEvent({ kind: 'driver', event }) - return - } - setDriverForPty(event.ptyId, event.driver) - }) - ) - - const unsubscribeLaunchDraftResolution = window.api.runtime.onNativeChatLaunchDraftResolved?.( - (event) => { - applyNativeChatLaunchDraftResolved(useAppStore.getState(), { - type: 'nativeChatLaunchDraftResolved', - ...event - }) - } - ) - if (unsubscribeLaunchDraftResolution) { - unsubs.push(unsubscribeLaunchDraftResolution) - } - - unsubs.push( - window.api.runtime.onBrowserDriverChanged((event) => { - if (isRuntimeEnvironmentActive()) { - return - } - if (!mobileStateHydrated) { - enqueuePendingMobileStateEvent({ kind: 'browser-driver', event }) - return - } - setDriverForBrowserPage(event.browserPageId, event.driver) - }) - ) - - // Why: subscribe before the snapshot round trip and buffer live events; otherwise an older snapshot could overwrite a newer live lock and hide the overlay. - if (!isRuntimeEnvironmentActive()) { - void Promise.all([ - window.api.runtime.getTerminalFitOverrides(), - window.api.runtime.getTerminalDrivers(), - window.api.runtime.getBrowserDrivers() - ]) - .then(([overrides, drivers, browserDrivers]) => { - if (mobileStateHydrationDisposed) { - return - } - hydrateOverrides(overrides) - hydrateDrivers(drivers) - hydrateBrowserDrivers(browserDrivers) - mobileStateHydrated = true - applyPendingMobileStateEvents() - }) - .catch((error: unknown) => { - if (mobileStateHydrationDisposed) { - return - } - console.error('Failed to hydrate mobile terminal state:', error) - mobileStateHydrated = true - applyPendingMobileStateEvents() - }) - } - - return () => { - // Why: React remount can leave an older snapshot promise in flight; it must not write through after the replacement effect processes a clear. - agentStatusEffectDisposed = true - snapshotRequestId += 1 - if (pendingAgentStatusRetryTimer !== null) { - globalThis.clearTimeout(pendingAgentStatusRetryTimer) - } - pendingAgentStatusEvents.length = 0 - if (liveAgentStatusBurstTimer !== null) { - globalThis.clearTimeout(liveAgentStatusBurstTimer) - liveAgentStatusBurstTimer = null - } - liveAgentStatusBurstQueue.length = 0 - mobileStateHydrationDisposed = true - pendingMobileStateEvents.length = 0 - unsubscribeRuntimeEnvironmentStore() - unsubscribeAgentStatusStore() - unsubs.forEach((fn) => fn()) - directSshEffectStopped = true - for (const deadline of authorityReconciliationDeadlines) { - clearTimeout(deadline.timer) - deadline.settle() - } - authorityReconciliationDeadlines.clear() - remoteWorkspaceTargetSync?.stop() - hostHydration.stop() - reconnectCoordinator.stop() - reconnectAuthorityByTarget.clear() - resetAgentHookCompletionNotificationCoordinators() - } - }, []) -} - -function hasRuntimeBackedWorktreeAttribution(data: AgentStatusIpcPayload): boolean { - return ( - (typeof data.terminalHandle === 'string' && data.terminalHandle.length > 0) || - data.orchestration !== undefined - ) -} - -function tryMakePaneKey(tabId: string, leafId: string): string | null { - try { - return makePaneKey(tabId, leafId) - } catch { - return null - } -} - -function applyResolvedAgentTerminalTitleToTab( - store: ReturnType, - paneKey: string, - previousTitle: string | undefined, - nextTitle: string | undefined -): void { - if ( - !nextTitle || - !shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, previousTitle, nextTitle) - ) { - return - } - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return - } - // Why: hook completion can arrive while the pane transport is unmounted; keep the tab label synced to the resolved state title. - store.updateTabTitle(parsed.tabId, nextTitle) -} - -function shouldApplyResolvedAgentTerminalTitleToTab( - store: ReturnType, - paneKey: string, - previousTitle: string | undefined, - nextTitle: string | undefined -): boolean { - if (!nextTitle || nextTitle === previousTitle) { - return false - } - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return false - } - const layout = store.terminalLayoutsByTabId?.[parsed.tabId] - if (layout?.root && layout.activeLeafId && layout.activeLeafId !== parsed.leafId) { - return false - } - return true -} - -type AgentStatusPaneResolution = { - exists: boolean - title: string | undefined - identityTitle: string | undefined - repoConnectionId: string | null - repoConnectionResolved: boolean - owningWorktreeId: string | undefined - titleUsesTabTitle: boolean -} - -type AgentStatusWorktreeConnectionResolution = { - worktreeExists: boolean - repoConnectionId: string | null - repoConnectionResolved: boolean -} - -type IndexedAgentStatusTab = { - title: string | undefined - unifiedLabel: string | undefined - owningWorktreeId: string -} - -type AgentStatusPaneRoutingIndex = { - tabsById: Map - layoutsByTabId: AppState['terminalLayoutsByTabId'] - leafIdsByRoot: WeakMap> - worktreesById: ReturnType - reposById: ReturnType -} - -function createUnifiedTerminalLabelIndex( - entries: AppState['unifiedTabsByWorktree'][string] | undefined -): Map { - const labelsByTabId = new Map() - for (const entry of entries ?? []) { - if (entry.contentType !== 'terminal' || labelsByTabId.has(entry.entityId)) { - continue - } - const rawLabel = entry.label?.trim() - labelsByTabId.set(entry.entityId, rawLabel && rawLabel.length > 0 ? rawLabel : undefined) - } - return labelsByTabId -} - -function createAgentStatusPaneRoutingIndex( - store: ReturnType -): AgentStatusPaneRoutingIndex { - const tabsById = new Map() - for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree)) { - const unifiedLabelsByTabId = createUnifiedTerminalLabelIndex( - store.unifiedTabsByWorktree?.[worktreeId] - ) - for (const tab of tabs) { - const tabId = tab.id - if (!tabsById.has(tabId)) { - tabsById.set(tabId, { - title: tab.title, - unifiedLabel: unifiedLabelsByTabId.get(tabId), - owningWorktreeId: worktreeId - }) - } - } - } - return { - tabsById, - layoutsByTabId: store.terminalLayoutsByTabId, - leafIdsByRoot: new WeakMap(), - worktreesById: getWorktreeMapFromState(store), - reposById: getRepoMapFromState(store) - } -} - -function resolveWorktreeConnectionFromRoutingIndex( - index: AgentStatusPaneRoutingIndex, - worktreeId: string -): AgentStatusWorktreeConnectionResolution { - const worktree = index.worktreesById.get(worktreeId) - if (!worktree) { - return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false } - } - const repo = index.reposById.get(worktree.repoId) - return { - worktreeExists: true, - repoConnectionId: repo?.connectionId ?? null, - repoConnectionResolved: repo !== undefined - } -} - -function resolvePaneKeyFromRoutingIndex( - index: AgentStatusPaneRoutingIndex, - paneKey: string -): AgentStatusPaneResolution { - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId: null, - repoConnectionResolved: false, - owningWorktreeId: undefined, - titleUsesTabTitle: false - } - } - const { tabId, leafId } = parsed - const tab = index.tabsById.get(tabId) - if (!tab) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId: null, - repoConnectionResolved: false, - owningWorktreeId: undefined, - titleUsesTabTitle: false - } - } - const connection = resolveWorktreeConnectionFromRoutingIndex(index, tab.owningWorktreeId) - const layout = index.layoutsByTabId?.[tabId] - if (layout?.root) { - let leafIds = index.leafIdsByRoot.get(layout.root) - if (!leafIds) { - leafIds = new Set(collectLeafIdsInOrder(layout.root)) - index.leafIdsByRoot.set(layout.root, leafIds) - } - if (!leafIds.has(leafId)) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId: connection.repoConnectionId, - repoConnectionResolved: connection.repoConnectionResolved, - owningWorktreeId: tab.owningWorktreeId, - titleUsesTabTitle: false - } - } - } - const rawPaneTitle = layout?.titlesByLeafId?.[leafId] - const paneTitle = rawPaneTitle && rawPaneTitle.length > 0 ? rawPaneTitle : undefined - return { - exists: true, - title: paneTitle ?? tab.title, - identityTitle: paneTitle ?? tab.unifiedLabel ?? tab.title, - repoConnectionId: connection.repoConnectionId, - repoConnectionResolved: connection.repoConnectionResolved, - owningWorktreeId: tab.owningWorktreeId, - titleUsesTabTitle: paneTitle === undefined - } -} - -/** Resolve a paneKey (tabId:leafId) to liveness, current title, owning worktree, - * and the owning repo's connectionId. Used for agent-type inference and to drop - * status updates for torn-down tabs or dead connections (an SSH reconnect retires the - * old connectionId, so events still in flight under it must not land). */ -function resolvePaneKey( - store: ReturnType, - paneKey: string -): { - exists: boolean - title: string | undefined - identityTitle: string | undefined - repoConnectionId: string | null - repoConnectionResolved: boolean - owningWorktreeId: string | undefined - titleUsesTabTitle: boolean -} { - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId: null, - repoConnectionResolved: false, - owningWorktreeId: undefined, - titleUsesTabTitle: false - } - } - const { tabId, leafId } = parsed - const layout = store.terminalLayoutsByTabId?.[tabId] - let exists = false - let tabTitle: string | undefined - let unifiedTabLabel: string | undefined - let owningWorktreeId: string | undefined - for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree)) { - for (const tab of tabs) { - if (tab.id === tabId) { - exists = true - tabTitle = tab.title - owningWorktreeId = worktreeId - const visibleTab = (store.unifiedTabsByWorktree?.[worktreeId] ?? []).find( - (entry) => entry.contentType === 'terminal' && entry.entityId === tabId - ) - const rawVisibleLabel = visibleTab?.label?.trim() - unifiedTabLabel = - rawVisibleLabel && rawVisibleLabel.length > 0 ? rawVisibleLabel : undefined - break - } - } - if (exists) { - break - } - } - // Why: keep "resolved to a local repo" distinct from "not hydrated yet" so callers filter strictly post-hydration but still accept SSH snapshots during the startup ownership gap. - let repoConnectionId: string | null = null - let repoConnectionResolved = false - if (owningWorktreeId !== undefined) { - const worktree = getWorktreeMapFromState(store).get(owningWorktreeId) - if (worktree) { - const repo = getRepoMapFromState(store).get(worktree.repoId) - repoConnectionResolved = repo !== undefined - repoConnectionId = repo?.connectionId ?? null - } - } - if (!exists) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId, - repoConnectionResolved, - owningWorktreeId, - titleUsesTabTitle: false - } - } - // Why: an empty layout snapshot from a worktree switch (tab/PTY still live) counts as missing metadata; a non-empty layout lacking the leaf still means closed. - const leafExists = layout?.root ? collectLeafIdsInOrder(layout.root).includes(leafId) : true - if (!leafExists) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId, - repoConnectionResolved, - owningWorktreeId, - titleUsesTabTitle: false - } - } - // Why: inactive worktrees can have a durable tab and live PTY while the layout is unmounted; hook state must still land there. - const rawPaneTitle = layout?.titlesByLeafId?.[leafId] - // Why: treat empty-string paneTitle as "no title" so the tab-level fallback fires; nullish-coalescing on '' would short-circuit and erase cached terminalTitle. - const paneTitle = rawPaneTitle && rawPaneTitle.length > 0 ? rawPaneTitle : undefined - return { - exists, - title: paneTitle ?? tabTitle, - // Why: some agents (OpenClaude) keep the terminal title generic while the tab label carries the agent identity; use only the non-custom label for attribution. - identityTitle: paneTitle ?? unifiedTabLabel ?? tabTitle, - repoConnectionId, - repoConnectionResolved, - owningWorktreeId, - titleUsesTabTitle: paneTitle === undefined - } -} - -function resolveWorktreeConnection( - store: ReturnType, - worktreeId: string -): { - worktreeExists: boolean - repoConnectionId: string | null - repoConnectionResolved: boolean -} { - const worktree = getWorktreeMapFromState(store).get(worktreeId) - if (!worktree) { - return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false } - } - const repo = getRepoMapFromState(store).get(worktree.repoId) - return { - worktreeExists: true, - repoConnectionId: repo?.connectionId ?? null, - repoConnectionResolved: repo !== undefined - } -} - -function resolveHookPayloadAgentType( - payload: ParsedAgentStatusPayload, - terminalTitle: string | undefined -): ParsedAgentStatusPayload { - if ( - payload.agentType !== 'claude' || - !terminalTitle || - !titleHasAgentName(terminalTitle, 'openclaude') - ) { - return payload - } - // Why: OpenClaude emits Claude-compatible hooks; the title is the last renderer signal to keep it out of Claude-only status paths. - return { ...payload, agentType: 'openclaude' } + useEffect(() => installAppLifetimeIpcEvents(), []) } diff --git a/src/renderer/src/lib/workspace-activation-path-gate.test.ts b/src/renderer/src/lib/workspace-activation-path-gate.test.ts index 430e9a31571..b4b8e2de049 100644 --- a/src/renderer/src/lib/workspace-activation-path-gate.test.ts +++ b/src/renderer/src/lib/workspace-activation-path-gate.test.ts @@ -135,7 +135,10 @@ describe('Cmd/Ctrl+1-9 folder-workspace path gate (#10716)', () => { // Why: the guard only helps if the IPC handler actually calls it. Pin the source // so re-pointing the handler back at the unguarded activateAndRevealWorktree fails. it('wires onJumpToWorktreeIndex to the guarded workspace activator', async () => { - const source = await readFile(new URL('../hooks/useIpcEvents.ts', import.meta.url), 'utf8') + const source = await readFile( + new URL('../hooks/ipc-events/workspace-shortcut-ipc-bridge.ts', import.meta.url), + 'utf8' + ) const handler = source.slice( source.indexOf('onJumpToWorktreeIndex('), source.indexOf('onJumpToTabIndex(') From b516300b8c53a663ec23017c9d62baa8c4197145 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:45:38 -0700 Subject: [PATCH 33/65] refactor agent hook listener modules (#16187) --- config/max-lines-baseline.txt | 1 - ...k-status-session-tabs-invalidation.test.ts | 2 +- .../hook-status-session-tabs-invalidation.ts | 2 +- .../manual-compact-hook-stream.test.ts | 2 +- .../server-authority-evidence.test.ts | 2 +- .../agent-hooks/server-grok-discovery.test.ts | 2 +- .../server-hook-http-ingest.test.ts | 94 + .../server-relay-listener-replay.test.ts | 4 +- src/main/agent-hooks/server.ts | 60 +- .../compact-status-registration.test.ts | 8 +- src/relay/agent-hook-envelope-build.ts | 2 +- .../agent-hook-result-retry-scheduler.ts | 12 +- src/relay/agent-hook-server.test.ts | 99 +- src/relay/agent-hook-server.ts | 26 +- src/relay/subprocess.test.ts | 2 +- ...on-coordinator-monitoring-turn-end.test.ts | 6 +- ...tion-background-turn-notifications.test.ts | 3 +- ...gent-hook-completion-notifications.test.ts | 3 +- .../agent-hook-listener-antigravity.test.ts | 6 +- ...listener-claude-compatible-vendors.test.ts | 4 +- ...ent-hook-listener-claude-subagents.test.ts | 10 +- ...nt-hook-listener-claude-turn-state.test.ts | 4 +- ...k-listener-command-code-transcript.test.ts | 4 +- ...stener-extraction-characterization.test.ts | 242 + src/shared/agent-hook-listener-grok.test.ts | 10 +- ...t-hook-listener-hermes-codex-droid.test.ts | 4 +- ...-hook-listener-interactive-prompts.test.ts | 4 +- .../agent-hook-listener-pi-compatible.test.ts | 4 +- ...ent-hook-listener-relay-dependency.test.ts | 135 + ...ent-hook-listener-roster-retention.test.ts | 4 +- ...-hook-listener-session-replacement.test.ts | 4 +- ...listener-startless-child-lifecycle.test.ts | 6 +- .../agent-hook-listener-test-harness.ts | 3 +- .../agent-hook-listener-transport.test.ts | 70 +- src/shared/agent-hook-listener.ts | 4845 +---------------- .../command-code-transcript.ts | 190 + .../endpoint-publication.ts | 82 + .../grok-result-discovery.ts | 221 + .../agent-hook-listener/hook-envelope.ts | 79 + .../agent-hook-listener/interactive-tool.ts | 124 + .../agent-hook-listener/listener-event.ts | 55 + .../agent-hook-listener/listener-limits.ts | 63 + .../agent-hook-listener/listener-state.ts | 216 + .../agent-hook-listener/prompt-fields.ts | 153 + .../agent-hook-listener/provider-dispatch.ts | 154 + .../provider-event-names.ts | 16 + .../provider-event-routing.ts | 174 + .../providers/amp-events.ts | 168 + .../providers/amp-tool-fields.ts | 44 + .../providers/antigravity-event-rules.ts | 5 + .../providers/antigravity-events.ts | 84 + .../providers/antigravity-tool-fields.ts | 54 + .../providers/claude-events.ts | 315 ++ .../providers/claude-lifecycle-events.ts | 144 + .../providers/claude-roster-state.ts | 277 + .../providers/claude-status-build.ts | 53 + .../providers/claude-tool-fields.ts | 62 + .../providers/codex-events.ts | 178 + .../providers/codex-state.ts | 146 + .../providers/codex-tool-fields.ts | 41 + .../providers/command-code-events.ts | 44 + .../providers/command-code-tool-fields.ts | 51 + .../providers/copilot-events.ts | 73 + .../providers/copilot-tool-fields.ts | 203 + .../providers/cursor-events.ts | 66 + .../providers/cursor-tool-fields.ts | 76 + .../providers/devin-events.ts | 61 + .../providers/droid-events.ts | 79 + .../providers/droid-tool-fields.ts | 98 + .../providers/gemini-events.ts | 50 + .../providers/gemini-tool-fields.ts | 36 + .../providers/grok-events.ts | 103 + .../providers/grok-tool-fields.ts | 128 + .../providers/hermes-events.ts | 54 + .../providers/hermes-tool-fields.ts | 70 + .../providers/kimi-events.ts | 73 + .../providers/opencode-family-events.ts | 56 + .../providers/opencode-family-tool-fields.ts | 75 + .../providers/pi-family-events.ts | 67 + .../providers/pi-family-tool-fields.ts | 35 + .../agent-hook-listener/request-body.ts | 125 + .../agent-hook-listener/source-routing.ts | 28 + .../agent-hook-listener/tool-input-preview.ts | 169 + .../transcript-entry-text.ts | 56 + .../agent-hook-listener/transcript-lines.ts | 51 + .../agent-hook-listener/transcript-reader.ts | 107 + .../agent-hook-request-body-memory.test.ts | 2 +- src/shared/agent-hook-status-cache.test.ts | 3 +- src/shared/agent-hook-status-cache.ts | 7 +- .../claude-background-task-status.test.ts | 10 +- .../claude-subagent-row-lifecycle.test.ts | 4 +- .../codex-subagent-rollout-lifecycle.test.ts | 3 +- src/shared/opencode-permission-status.test.ts | 3 +- 93 files changed, 5967 insertions(+), 4886 deletions(-) create mode 100644 src/shared/agent-hook-listener-extraction-characterization.test.ts create mode 100644 src/shared/agent-hook-listener-relay-dependency.test.ts create mode 100644 src/shared/agent-hook-listener/command-code-transcript.ts create mode 100644 src/shared/agent-hook-listener/endpoint-publication.ts create mode 100644 src/shared/agent-hook-listener/grok-result-discovery.ts create mode 100644 src/shared/agent-hook-listener/hook-envelope.ts create mode 100644 src/shared/agent-hook-listener/interactive-tool.ts create mode 100644 src/shared/agent-hook-listener/listener-event.ts create mode 100644 src/shared/agent-hook-listener/listener-limits.ts create mode 100644 src/shared/agent-hook-listener/listener-state.ts create mode 100644 src/shared/agent-hook-listener/prompt-fields.ts create mode 100644 src/shared/agent-hook-listener/provider-dispatch.ts create mode 100644 src/shared/agent-hook-listener/provider-event-names.ts create mode 100644 src/shared/agent-hook-listener/provider-event-routing.ts create mode 100644 src/shared/agent-hook-listener/providers/amp-events.ts create mode 100644 src/shared/agent-hook-listener/providers/amp-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/antigravity-event-rules.ts create mode 100644 src/shared/agent-hook-listener/providers/antigravity-events.ts create mode 100644 src/shared/agent-hook-listener/providers/antigravity-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/claude-events.ts create mode 100644 src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts create mode 100644 src/shared/agent-hook-listener/providers/claude-roster-state.ts create mode 100644 src/shared/agent-hook-listener/providers/claude-status-build.ts create mode 100644 src/shared/agent-hook-listener/providers/claude-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/codex-events.ts create mode 100644 src/shared/agent-hook-listener/providers/codex-state.ts create mode 100644 src/shared/agent-hook-listener/providers/codex-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/command-code-events.ts create mode 100644 src/shared/agent-hook-listener/providers/command-code-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/copilot-events.ts create mode 100644 src/shared/agent-hook-listener/providers/copilot-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/cursor-events.ts create mode 100644 src/shared/agent-hook-listener/providers/cursor-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/devin-events.ts create mode 100644 src/shared/agent-hook-listener/providers/droid-events.ts create mode 100644 src/shared/agent-hook-listener/providers/droid-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/gemini-events.ts create mode 100644 src/shared/agent-hook-listener/providers/gemini-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/grok-events.ts create mode 100644 src/shared/agent-hook-listener/providers/grok-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/hermes-events.ts create mode 100644 src/shared/agent-hook-listener/providers/hermes-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/kimi-events.ts create mode 100644 src/shared/agent-hook-listener/providers/opencode-family-events.ts create mode 100644 src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/pi-family-events.ts create mode 100644 src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/request-body.ts create mode 100644 src/shared/agent-hook-listener/source-routing.ts create mode 100644 src/shared/agent-hook-listener/tool-input-preview.ts create mode 100644 src/shared/agent-hook-listener/transcript-entry-text.ts create mode 100644 src/shared/agent-hook-listener/transcript-lines.ts create mode 100644 src/shared/agent-hook-listener/transcript-reader.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 43aa99cf788..2c9a7b1b5af 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -145,7 +145,6 @@ inline src/renderer/src/store/slices/tabs.ts inline src/renderer/src/store/slices/ui.ts inline src/renderer/src/store/slices/workspace-cleanup.ts inline src/renderer/src/web/web-runtime-client.ts -inline src/shared/agent-hook-listener.ts inline src/shared/automation-schedules.ts inline src/shared/commit-message-agent-spec.ts inline src/shared/constants.ts 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 5f28a40c039..fc2482cbdb0 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 @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' import { createHookStatusSessionTabsInvalidator } from './hook-status-session-tabs-invalidation' 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 0a36eef0f56..04902579855 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts @@ -1,4 +1,4 @@ -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' type KnownStatus = { diff --git a/src/main/agent-hooks/manual-compact-hook-stream.test.ts b/src/main/agent-hooks/manual-compact-hook-stream.test.ts index 1a122349ca2..c44a28f13d7 100644 --- a/src/main/agent-hooks/manual-compact-hook-stream.test.ts +++ b/src/main/agent-hooks/manual-compact-hook-stream.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { RelayAgentHookServer } from '../../relay/agent-hook-server' -import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener' +import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener/providers/claude-roster-state' import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay' import { makePaneKey } from '../../shared/stable-pane-id' import { AgentHookServer } from './server' diff --git a/src/main/agent-hooks/server-authority-evidence.test.ts b/src/main/agent-hooks/server-authority-evidence.test.ts index 182eddada02..d6acf1886da 100644 --- a/src/main/agent-hooks/server-authority-evidence.test.ts +++ b/src/main/agent-hooks/server-authority-evidence.test.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { afterEach, describe, expect, it } from 'vitest' -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import { makePaneKey } from '../../shared/stable-pane-id' import { AgentHookServer } from './server' diff --git a/src/main/agent-hooks/server-grok-discovery.test.ts b/src/main/agent-hooks/server-grok-discovery.test.ts index cea007c3d2f..f3ea95bc595 100644 --- a/src/main/agent-hooks/server-grok-discovery.test.ts +++ b/src/main/agent-hooks/server-grok-discovery.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import * as agentHookListener from '../../shared/agent-hook-listener' +import * as agentHookListener from '../../shared/agent-hook-listener/grok-result-discovery' import { makePaneKey } from '../../shared/stable-pane-id' import { AgentHookServer } from './server' diff --git a/src/main/agent-hooks/server-hook-http-ingest.test.ts b/src/main/agent-hooks/server-hook-http-ingest.test.ts index 622fc7d428b..55f2c6e1007 100644 --- a/src/main/agent-hooks/server-hook-http-ingest.test.ts +++ b/src/main/agent-hooks/server-hook-http-ingest.test.ts @@ -28,7 +28,101 @@ afterEach(() => { vi.restoreAllMocks() }) +async function postClaudeHook( + server: AgentHookServer, + payload: Record +): Promise { + const env = server.buildPtyEnv() + return 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)) + }) +} + describe('AgentHookServer listener replay', () => { + it('caches and notifies status/main/plugin before retry scheduling and HTTP response', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + const order: string[] = [] + const internal = server as unknown as { + scheduleAssistantMessageRetry: (...args: unknown[]) => void + scheduleCodexSubagentPoll: (...args: unknown[]) => void + } + const originalAssistantRetry = internal.scheduleAssistantMessageRetry.bind(server) + const originalCodexRetry = internal.scheduleCodexSubagentPoll.bind(server) + const assistantRetry = vi + .spyOn(internal, 'scheduleAssistantMessageRetry') + .mockImplementation((...args) => { + order.push('assistant-retry') + originalAssistantRetry(...args) + }) + const codexRetry = vi + .spyOn(internal, 'scheduleCodexSubagentPoll') + .mockImplementation((...args) => { + order.push('codex-retry') + originalCodexRetry(...args) + }) + const unsubscribeStatus = server.subscribeStatusChanges(() => order.push('status-change')) + server.setListener(() => { + expect(server.getStatusSnapshotForPane(PANE)).toHaveLength(1) + order.push('main-listener') + }) + const unsubscribePlugin = server.subscribeEnrichedStatus(() => order.push('plugin-listener')) + try { + const response = await postClaudeHook(server, { + hook_event_name: 'UserPromptSubmit', + prompt: 'ordered' + }) + order.push('response') + expect(response.status).toBe(204) + expect(order).toEqual([ + 'status-change', + 'main-listener', + 'plugin-listener', + 'assistant-retry', + 'codex-retry', + 'response' + ]) + } finally { + unsubscribeStatus() + unsubscribePlugin() + assistantRetry.mockRestore() + codexRetry.mockRestore() + server.stop() + } + }) + + it('fails open after a throwing callback with cache retained and retries skipped', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + const internal = server as unknown as { + scheduleAssistantMessageRetry: (...args: unknown[]) => void + scheduleCodexSubagentPoll: (...args: unknown[]) => void + } + const assistantRetry = vi.spyOn(internal, 'scheduleAssistantMessageRetry') + const codexRetry = vi.spyOn(internal, 'scheduleCodexSubagentPoll') + server.setListener(() => { + throw new Error('listener failed') + }) + try { + const response = await postClaudeHook(server, { + hook_event_name: 'UserPromptSubmit', + prompt: 'cached before callback' + }) + expect(response.status).toBe(204) + expect(server.getStatusSnapshotForPane(PANE)).toHaveLength(1) + expect(assistantRetry).not.toHaveBeenCalled() + expect(codexRetry).not.toHaveBeenCalled() + } finally { + assistantRetry.mockRestore() + codexRetry.mockRestore() + 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' }) diff --git a/src/main/agent-hooks/server-relay-listener-replay.test.ts b/src/main/agent-hooks/server-relay-listener-replay.test.ts index c7795fb8069..6c96d9c3bb8 100644 --- a/src/main/agent-hooks/server-relay-listener-replay.test.ts +++ b/src/main/agent-hooks/server-relay-listener-replay.test.ts @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer, _internals } from './server' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from '../../shared/agent-hook-listener' +} from '../../shared/agent-hook-listener/listener-state' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' import { createShedSubagentsField } from '../../shared/agent-hook-relay' import { buildBody, PANE } from './server.test-fixtures' diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index f69ffea2e4c..e447be40262 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -13,33 +13,45 @@ import { clearAllListenerCaches, clearPaneCacheState, paneHasStateClaims, - clearClaudeAnsweredQuestionWait, createHookListenerState, - getEndpointFileName, - hasCodexTranscriptSubagents, - hasPendingAgentResultText, - HOOK_REQUEST_SLOWLORIS_MS, - isNewTurnEvent, - markClaudeLeadTurnInterrupted, - markCodexLeadTurnInterrupted, - MAX_PANE_KEY_LEN, movePaneCacheState, - normalizeClaudePromptId, - normalizeHookPayload, - parseFormEncodedBody, - readRequestBody, - reapRestoredClaudeSubagentsForDeadPane, - reconcileRemoteCodexState, - resolveHookSource, - preparePendingGrokResultDiscovery, - seedClaudeLeadTurnFromPersistedStatus, - seedClaudeSubagentRosterFromSnapshots, - seedCodexStateFromSnapshot, - warnOnHookEnvOrVersionMismatch, - writeEndpointFile, - type AgentHookEventPayload, type HookListenerState -} from '../../shared/agent-hook-listener' +} from '../../shared/agent-hook-listener/listener-state' +import { + clearClaudeAnsweredQuestionWait, + markClaudeLeadTurnInterrupted, + reapRestoredClaudeSubagentsForDeadPane, + seedClaudeLeadTurnFromPersistedStatus, + seedClaudeSubagentRosterFromSnapshots +} from '../../shared/agent-hook-listener/providers/claude-roster-state' +import { + getEndpointFileName, + writeEndpointFile +} from '../../shared/agent-hook-listener/endpoint-publication' +import { + hasCodexTranscriptSubagents, + markCodexLeadTurnInterrupted, + reconcileRemoteCodexState, + seedCodexStateFromSnapshot +} from '../../shared/agent-hook-listener/providers/codex-state' +import { + hasPendingAgentResultText, + preparePendingGrokResultDiscovery +} from '../../shared/agent-hook-listener/grok-result-discovery' +import { + HOOK_REQUEST_SLOWLORIS_MS, + MAX_PANE_KEY_LEN, + normalizeClaudePromptId, + warnOnHookEnvOrVersionMismatch +} from '../../shared/agent-hook-listener/listener-limits' +import { isNewTurnEvent } from '../../shared/agent-hook-listener/provider-event-routing' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' +import { + parseFormEncodedBody, + readRequestBody +} from '../../shared/agent-hook-listener/request-body' +import { resolveHookSource } from '../../shared/agent-hook-listener/source-routing' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import { canAcceptClaudeCompactCompletion, isClaudeCompactCompletionConsumed, diff --git a/src/main/claude/compact-status-registration.test.ts b/src/main/claude/compact-status-registration.test.ts index f17e14630e5..24399161d48 100644 --- a/src/main/claude/compact-status-registration.test.ts +++ b/src/main/claude/compact-status-registration.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' import { clearAllListenerCaches, clearPaneCacheState, createHookListenerState, movePaneCacheState, - normalizeHookPayload, - seedClaudeSubagentRosterFromSnapshots, - type AgentHookEventPayload, type HookListenerState -} from '../../shared/agent-hook-listener' +} from '../../shared/agent-hook-listener/listener-state' +import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener/providers/claude-roster-state' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import { makePaneKey } from '../../shared/stable-pane-id' import { applyManagedHooks, CLAUDE_EVENTS } from './hook-settings' diff --git a/src/relay/agent-hook-envelope-build.ts b/src/relay/agent-hook-envelope-build.ts index 34b4cbbdec6..14a7430f997 100644 --- a/src/relay/agent-hook-envelope-build.ts +++ b/src/relay/agent-hook-envelope-build.ts @@ -1,7 +1,7 @@ // Pure mapping from a parsed hook event plus its raw body onto the `agent.hook` envelope that goes // over the SSH channel. Sits beside agent-hook-envelope-publication.ts, which owns shedding and // redelivery of the same envelope. -import type { AgentHookEventPayload } from '../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay' // Why: cap metadata to prevent a misbehaving CLI growing the cache unboundedly. diff --git a/src/relay/agent-hook-result-retry-scheduler.ts b/src/relay/agent-hook-result-retry-scheduler.ts index b69abcb49a8..bd6d2796163 100644 --- a/src/relay/agent-hook-result-retry-scheduler.ts +++ b/src/relay/agent-hook-result-retry-scheduler.ts @@ -2,14 +2,14 @@ // when the hook fired, so re-read the same body on a timer and re-apply only if it changed. Both // timer families live in one owner so pane teardown and server stop tear both down in one ordered // place before the listener caches are cleared. +import { hasCodexTranscriptSubagents } from '../shared/agent-hook-listener/providers/codex-state' import { - hasCodexTranscriptSubagents, hasPendingAgentResultText, - normalizeHookPayload, - preparePendingGrokResultDiscovery, - type AgentHookEventPayload, - type HookListenerState -} from '../shared/agent-hook-listener' + preparePendingGrokResultDiscovery +} from '../shared/agent-hook-listener/grok-result-discovery' +import { normalizeHookPayload } from '../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' +import type { HookListenerState } from '../shared/agent-hook-listener/listener-state' import type { AgentHookSource } from '../shared/agent-hook-relay' const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index f80de335e43..0e3771aff55 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -3,14 +3,20 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { RelayAgentHookServer } from './agent-hook-server' +import type { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler' import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' import { makePaneKey } from '../shared/stable-pane-id' -import * as agentHookListener from '../shared/agent-hook-listener' +import * as agentHookListener from '../shared/agent-hook-listener/grok-result-discovery' const LEAF_ID = '11111111-1111-4111-8111-111111111111' const PANE_KEY = makePaneKey('tab-1', LEAF_ID) +type RelayServerInternals = { + state: { lastStatusByPaneKey: Map } + retryScheduler: AgentHookResultRetryScheduler +} + describe('RelayAgentHookServer', () => { let dir: string beforeEach(() => { @@ -76,6 +82,97 @@ describe('RelayAgentHookServer', () => { } }) + it('caches before forwarding, schedules retries after forwarding, and responds last', async () => { + const order: string[] = [] + let server!: RelayAgentHookServer + let internals!: RelayServerInternals + server = new RelayAgentHookServer({ + endpointDir: dir, + forward: () => { + expect(internals.state.lastStatusByPaneKey.has(PANE_KEY)).toBe(true) + order.push('forward') + } + }) + // Characterization deliberately observes the server-owned scheduler without widening production API. + internals = server as unknown as RelayServerInternals + const retryScheduler = internals.retryScheduler + const originalAssistantRetry = retryScheduler.scheduleAssistantMessageRetry.bind(retryScheduler) + const originalCodexRetry = retryScheduler.scheduleCodexSubagentPoll.bind(retryScheduler) + const assistantRetry = vi + .spyOn(retryScheduler, 'scheduleAssistantMessageRetry') + .mockImplementation((...args) => { + order.push('assistant-retry') + originalAssistantRetry(...args) + }) + const codexRetry = vi + .spyOn(retryScheduler, 'scheduleCodexSubagentPoll') + .mockImplementation((...args) => { + order.push('codex-retry') + originalCodexRetry(...args) + }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + hook_event_name: 'UserPromptSubmit', + payload: { prompt: 'ordered' } + }) + }) + order.push('response') + expect(res.status).toBe(204) + expect(order).toEqual(['forward', 'assistant-retry', 'codex-retry', 'response']) + } finally { + server.stop() + assistantRetry.mockRestore() + codexRetry.mockRestore() + } + }) + + it('fails open after a throwing forward while retaining the already-cached event', async () => { + const server = new RelayAgentHookServer({ + endpointDir: dir, + forward: () => { + throw new Error('forward failed') + } + }) + // Characterization deliberately observes cache/scheduler order without widening production API. + const internals = server as unknown as RelayServerInternals + const retryScheduler = internals.retryScheduler + const assistantRetry = vi.spyOn(retryScheduler, 'scheduleAssistantMessageRetry') + const codexRetry = vi.spyOn(retryScheduler, 'scheduleCodexSubagentPoll') + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + hook_event_name: 'UserPromptSubmit', + payload: { prompt: 'cached before throw' } + }) + }) + expect(res.status).toBe(204) + expect(internals.state.lastStatusByPaneKey.has(PANE_KEY)).toBe(true) + expect(assistantRetry).not.toHaveBeenCalled() + expect(codexRetry).not.toHaveBeenCalled() + } finally { + server.stop() + assistantRetry.mockRestore() + codexRetry.mockRestore() + } + }) + it('forwards Claude background monitoring until its authoritative inventory drains', async () => { const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index fb37dc64e3d..84474cbab00 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -1,8 +1,3 @@ -// Relay-side adapter for the shared agent-hook listener: hosts a loopback HTTP server and -// forwards each parsed payload via a callback so `relay.ts` re-emits it as an `agent.hook` -// JSON-RPC notification over the SSH channel. Replay cache is bounded one-entry-per-paneKey: a -// reattaching Orca only needs each pane's current status, never its history, and the bound keeps a -// long-lived relay from growing with every event. import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' import { join } from 'node:path' @@ -12,15 +7,17 @@ import { clearAllListenerCaches, clearPaneCacheState, createHookListenerState, - getEndpointFileName, - HOOK_REQUEST_SLOWLORIS_MS, - normalizeHookPayload, - readRequestBody, - resolveHookSource, - writeEndpointFile, - type AgentHookEventPayload, type HookListenerState -} from '../shared/agent-hook-listener' +} from '../shared/agent-hook-listener/listener-state' +import { + getEndpointFileName, + writeEndpointFile +} from '../shared/agent-hook-listener/endpoint-publication' +import { HOOK_REQUEST_SLOWLORIS_MS } from '../shared/agent-hook-listener/listener-limits' +import { normalizeHookPayload } from '../shared/agent-hook-listener' +import { readRequestBody } from '../shared/agent-hook-listener/request-body' +import { resolveHookSource } from '../shared/agent-hook-listener/source-routing' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' import { createHookTransportInterferenceTracker, describeHookTransportInterference, @@ -37,7 +34,6 @@ import { AgentHookResultRetryScheduler } from './agent-hook-result-retry-schedul export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void -// Why: WSL lacks per-pane teardown, so cap replay-cache recency. const MAX_CACHED_PANES = 256 export type RelayHookServerOptions = { @@ -49,7 +45,6 @@ export type RelayHookServerOptions = { token?: string /** Preferred bind port. WSL relay passes the Windows listener's port so env-sourced client coords stay truthful; falls back to :0 if occupied. Defaults to :0. */ preferredPort?: number - /** Called once per parsed payload; the relay wires this to `dispatcher.notify('agent.hook', envelope)`. */ forward: RelayHookForward } @@ -121,7 +116,6 @@ export class RelayAgentHookServer { } } - /** True when the preferred port was occupied and the server fell back to an ephemeral bind. */ get usedPortFallback(): boolean { return this.portFallbackApplied } diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index 57b3466707d..18d837bb4ef 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -14,7 +14,7 @@ import { tmpdir } from 'node:os' import { execFileSync, spawn as spawnChild } from 'node:child_process' import { build } from 'esbuild' import { spawnRelay, type RelayProcess } from './subprocess-test-utils' -import { getEndpointFileName } from '../shared/agent-hook-listener' +import { getEndpointFileName } from '../shared/agent-hook-listener/endpoint-publication' import { relayTestSocketPath } from './relay-test-socket-path' const RELAY_TS_ENTRY = path.resolve(__dirname, 'relay.ts') diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts index f0b2aab1520..64162c8520d 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { createAgentCompletionCoordinator } from './agent-completion-coordinator' import { useAgentCompletionCoordinatorLifecycle } from './agent-completion-coordinator-test-harness' -import { - createHookListenerState, - normalizeHookPayload -} from '../../../../shared/agent-hook-listener' +import { normalizeHookPayload } from '../../../../shared/agent-hook-listener' +import { createHookListenerState } from '../../../../shared/agent-hook-listener/listener-state' import { makePaneKey } from '../../../../shared/stable-pane-id' // Why: STA-4119's second complaint is the missing completion notification. This drives the REAL diff --git a/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts index 8583c3bb0de..3898479808a 100644 --- a/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' -import { createHookListenerState, normalizeHookPayload } from '../../../shared/agent-hook-listener' +import { createHookListenerState } from '../../../shared/agent-hook-listener/listener-state' +import { normalizeHookPayload } from '../../../shared/agent-hook-listener' import { makePaneKey } from '../../../shared/stable-pane-id' const dispatchTerminalNotification = vi.fn() diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index 63c33c7f26e..71233c7f697 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import { YOLO_TUI_AGENT_ARGS } from '../../../shared/tui-agent-permissions' -import { createHookListenerState, normalizeHookPayload } from '../../../shared/agent-hook-listener' +import { createHookListenerState } from '../../../shared/agent-hook-listener/listener-state' +import { normalizeHookPayload } from '../../../shared/agent-hook-listener' const dispatchTerminalNotification = vi.fn() const dispatchAgentHookTerminalLifecycle = vi.fn() diff --git a/src/shared/agent-hook-listener-antigravity.test.ts b/src/shared/agent-hook-listener-antigravity.test.ts index 9eac0d75593..df6ec253c85 100644 --- a/src/shared/agent-hook-listener-antigravity.test.ts +++ b/src/shared/agent-hook-listener-antigravity.test.ts @@ -4,10 +4,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { createHookListenerState, - hasPendingAgentResultText, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { hasPendingAgentResultText } from './agent-hook-listener/grok-result-discovery' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { PANE_KEY } from './agent-hook-listener-test-harness' diff --git a/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts b/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts index 64390067fcf..601b8aea828 100644 --- a/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts +++ b/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { normalizeAndAccept, PANE_KEY } from './agent-hook-listener-test-harness' diff --git a/src/shared/agent-hook-listener-claude-subagents.test.ts b/src/shared/agent-hook-listener-claude-subagents.test.ts index 5278247cf8d..c0d646837ee 100644 --- a/src/shared/agent-hook-listener-claude-subagents.test.ts +++ b/src/shared/agent-hook-listener-claude-subagents.test.ts @@ -1,13 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { clearClaudeAnsweredQuestionWait, + markClaudeLeadTurnInterrupted, + seedClaudeSubagentRosterFromSnapshots +} from './agent-hook-listener/providers/claude-roster-state' +import { clearPaneCacheState, createHookListenerState, - markClaudeLeadTurnInterrupted, - normalizeHookPayload, - seedClaudeSubagentRosterFromSnapshots, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' import { makePaneKey } from './stable-pane-id' diff --git a/src/shared/agent-hook-listener-claude-turn-state.test.ts b/src/shared/agent-hook-listener-claude-turn-state.test.ts index 15f1973187d..f7e40a52461 100644 --- a/src/shared/agent-hook-listener-claude-turn-state.test.ts +++ b/src/shared/agent-hook-listener-claude-turn-state.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { CLAUDE_PREVIOUS_PROMPT_ID, diff --git a/src/shared/agent-hook-listener-command-code-transcript.test.ts b/src/shared/agent-hook-listener-command-code-transcript.test.ts index 458eb918935..6e8159bf72e 100644 --- a/src/shared/agent-hook-listener-command-code-transcript.test.ts +++ b/src/shared/agent-hook-listener-command-code-transcript.test.ts @@ -4,9 +4,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { PANE_KEY } from './agent-hook-listener-test-harness' diff --git a/src/shared/agent-hook-listener-extraction-characterization.test.ts b/src/shared/agent-hook-listener-extraction-characterization.test.ts new file mode 100644 index 00000000000..0d4089d4537 --- /dev/null +++ b/src/shared/agent-hook-listener-extraction-characterization.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it, vi } from 'vitest' + +import { normalizeHookPayload } from './agent-hook-listener' +import { + clearAllListenerCaches, + clearPaneCacheState, + createHookListenerState, + movePaneCacheState +} from './agent-hook-listener/listener-state' +import { warnOnHookEnvOrVersionMismatch } from './agent-hook-listener/listener-limits' +import { resolveHookSource } from './agent-hook-listener/source-routing' +import { makePaneKey } from './stable-pane-id' + +const PANE = makePaneKey('tab-hooks', '11111111-1111-4111-8112-111111111111') +const MOVED_PANE = makePaneKey('tab-hooks', '22222222-2222-4222-8222-222222222222') +const ROUTES = { + '/hook/claude': 'claude', + '/hook/codex': 'codex', + '/hook/gemini': 'gemini', + '/hook/antigravity': 'antigravity', + '/hook/amp': 'amp', + '/hook/opencode': 'opencode', + '/hook/mimo-code': 'mimo-code', + '/hook/cursor': 'cursor', + '/hook/pi': 'pi', + '/hook/omp': 'omp', + '/hook/prime-agent': 'prime-agent', + '/hook/droid': 'droid', + '/hook/command-code': 'command-code', + '/hook/grok': 'grok', + '/hook/copilot': 'copilot', + '/hook/hermes': 'hermes', + '/hook/devin': 'devin', + '/hook/kimi': 'kimi' +} as const +function normalizeProviderState( + source: (typeof ROUTES)[keyof typeof ROUTES], + eventName: string, + payload: Record = {} +) { + return ( + normalizeHookPayload( + createHookListenerState(), + source, + { paneKey: PANE, hook_event_name: eventName, payload }, + 'production' + )?.payload ?? null + ) +} +describe('agent hook extraction boundaries', () => { + it('routes exactly the complete provider vocabulary', () => { + for (const [pathname, source] of Object.entries(ROUTES)) { + expect(resolveHookSource(pathname)).toBe(source) + expect(resolveHookSource(`${pathname}/`)).toBeNull() + expect(resolveHookSource(`${pathname}?v=1`)).toBeNull() + expect(resolveHookSource(pathname.toUpperCase())).toBeNull() + } + expect(resolveHookSource('/hook/unknown')).toBeNull() + }) + + it('preserves thin provider lifecycle branches and source attribution', () => { + const cases = [ + ['devin', 'UserPromptSubmit', 'working'], + ['devin', 'PermissionRequest', 'waiting'], + ['devin', 'Stop', 'done'], + ['mimo-code', 'MessagePart', 'working'], + ['mimo-code', 'AskUserQuestion', 'waiting'], + ['prime-agent', 'before_agent_start', 'working'], + ['prime-agent', 'agent_end', 'done'], + ['kimi', 'PermissionRequest', 'waiting'], + ['kimi', 'StopFailure', 'done'], + ['hermes', 'on_session_finalize', 'done'], + ['hermes', 'on_session_reset', 'done'] + ] as const + + for (const [source, eventName, expectedState] of cases) { + expect(normalizeProviderState(source, eventName)).toMatchObject({ + state: expectedState, + agentType: source + }) + } + for (const source of ['devin', 'mimo-code', 'prime-agent', 'kimi', 'hermes'] as const) { + expect(normalizeProviderState(source, 'UnknownEvent')).toBeNull() + } + }) + + it('warns before tab rejection and caps version and environment warning keys independently', () => { + const orderState = createHookListenerState() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const rejected = normalizeHookPayload( + orderState, + 'devin', + { + paneKey: PANE, + tabId: 'wrong-tab', + version: 'outdated', + payload: { hook_event_name: 'UnknownEvent' } + }, + 'production' + ) + expect(rejected).toBeNull() + expect(warn).toHaveBeenCalledTimes(1) + + const cappedState = createHookListenerState() + for (let index = 0; index < 40; index++) { + warnOnHookEnvOrVersionMismatch(cappedState, { + version: `version-${index}`, + env: `environment-${index}`, + expectedEnv: 'production' + }) + } + expect(cappedState.warnedVersions.size).toBe(32) + expect(cappedState.warnedEnvs.size).toBe(32) + warn.mockRestore() + }) + + it('moves every pane-owned collection and preserves NUL suffixes', () => { + const state = createHookListenerState() + const scoped = `${PANE}\0child` + const movedScoped = `${MOVED_PANE}\0child` + const sibling = `${PANE}-sibling` + const paneMaps = [ + state.lastPromptByPaneKey, + state.lastToolByPaneKey, + state.lastStatusByPaneKey, + state.antigravityCompletedTranscriptByPaneKey, + state.claudeSubagentRosterByPaneKey, + state.claudeLeadStateByPaneKey, + state.codexSubagentRosterByPaneKey, + state.codexSubagentTranscriptByPaneKey, + state.codexLeadStateByPaneKey + ] + for (const map of paneMaps) { + const cache = map as Map + cache.set(PANE, 'exact') + cache.set(scoped, 'scoped') + cache.set(sibling, 'sibling') + } + const paneSets = [ + state.ampCompletedCacheKeys, + state.claudeUnconfirmedRestoredStatusPaneKeys, + state.claudeRunningNonAgentTaskPaneKeys, + state.claudeActiveSessionCronPaneKeys + ] + for (const set of paneSets) { + set.add(PANE) + set.add(scoped) + set.add(sibling) + } + + movePaneCacheState(state, PANE, MOVED_PANE) + + for (const map of paneMaps) { + const cache = map as Map + expect(cache.get(MOVED_PANE)).toBe('exact') + expect(cache.get(movedScoped)).toBe('scoped') + expect(cache.get(sibling)).toBe('sibling') + expect(cache.has(PANE)).toBe(false) + } + for (const set of paneSets) { + expect(set.has(MOVED_PANE)).toBe(true) + expect(set.has(movedScoped)).toBe(true) + expect(set.has(sibling)).toBe(true) + expect(set.has(PANE)).toBe(false) + } + + movePaneCacheState(state, MOVED_PANE, MOVED_PANE) + expect((state.lastPromptByPaneKey as Map).get(MOVED_PANE)).toBe('exact') + }) + + it('clears exact and NUL-scoped cache keys but not sibling prefixes', () => { + const state = createHookListenerState() + const scoped = `${PANE}\0thread` + const sibling = `${PANE}-sibling` + const paneMaps = [ + state.lastPromptByPaneKey, + state.lastToolByPaneKey, + state.lastStatusByPaneKey, + state.antigravityCompletedTranscriptByPaneKey + ] + for (const map of paneMaps) { + const cache = map as Map + cache.set(PANE, 'exact') + cache.set(scoped, 'scoped') + cache.set(sibling, 'sibling') + } + state.ampCompletedCacheKeys.add(PANE) + state.ampCompletedCacheKeys.add(scoped) + state.ampCompletedCacheKeys.add(sibling) + state.claudeLeadStateByPaneKey.set(PANE, { state: 'working' }) + state.codexLeadStateByPaneKey.set(PANE, { state: 'working' }) + + clearPaneCacheState(state, PANE) + + for (const map of paneMaps) { + const cache = map as Map + expect(cache.has(PANE)).toBe(false) + expect(cache.has(scoped)).toBe(false) + expect(cache.get(sibling)).toBe('sibling') + } + expect(state.ampCompletedCacheKeys.has(scoped)).toBe(false) + expect(state.ampCompletedCacheKeys.has(sibling)).toBe(true) + expect(state.claudeLeadStateByPaneKey.has(PANE)).toBe(false) + expect(state.codexLeadStateByPaneKey.has(PANE)).toBe(false) + }) + + it('preserves cache mutation from a provider reset that emits no row', () => { + const state = createHookListenerState() + state.lastPromptByPaneKey.set(PANE, 'old prompt') + state.lastToolByPaneKey.set(PANE, { toolName: 'old tool' }) + state.lastStatusByPaneKey.set(PANE, {} as never) + + const event = normalizeHookPayload( + state, + 'droid', + { paneKey: PANE, payload: { hook_event_name: 'SessionStart' } }, + 'production' + ) + + expect(event).toBeNull() + expect(state.lastPromptByPaneKey.has(PANE)).toBe(false) + expect(state.lastToolByPaneKey.has(PANE)).toBe(false) + expect(state.lastStatusByPaneKey.has(PANE)).toBe(true) + }) + + it('clears warning, provider, and lifecycle caches together', () => { + const state = createHookListenerState() + state.warnedVersions.add('old-version') + state.warnedEnvs.add('development->production') + state.lastPromptByPaneKey.set(PANE, 'prompt') + state.claudeRunningNonAgentTaskPaneKeys.add(PANE) + state.codexLeadStateByPaneKey.set(PANE, { state: 'working' }) + + clearAllListenerCaches(state) + + expect(state.warnedVersions.size).toBe(0) + expect(state.warnedEnvs.size).toBe(0) + expect(state.lastPromptByPaneKey.size).toBe(0) + expect(state.claudeRunningNonAgentTaskPaneKeys.size).toBe(0) + expect(state.codexLeadStateByPaneKey.size).toBe(0) + }) +}) diff --git a/src/shared/agent-hook-listener-grok.test.ts b/src/shared/agent-hook-listener-grok.test.ts index 0ec1d6447ab..9fa6f844114 100644 --- a/src/shared/agent-hook-listener-grok.test.ts +++ b/src/shared/agent-hook-listener-grok.test.ts @@ -4,11 +4,13 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { createHookListenerState, - hasPendingAgentResultText, - normalizeHookPayload, - preparePendingGrokResultDiscovery, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { + hasPendingAgentResultText, + preparePendingGrokResultDiscovery +} from './agent-hook-listener/grok-result-discovery' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests, findGrokChatHistoryBySessionId diff --git a/src/shared/agent-hook-listener-hermes-codex-droid.test.ts b/src/shared/agent-hook-listener-hermes-codex-droid.test.ts index cfd7e1903a4..9fddb88957f 100644 --- a/src/shared/agent-hook-listener-hermes-codex-droid.test.ts +++ b/src/shared/agent-hook-listener-hermes-codex-droid.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { PANE_KEY } from './agent-hook-listener-test-harness' diff --git a/src/shared/agent-hook-listener-interactive-prompts.test.ts b/src/shared/agent-hook-listener-interactive-prompts.test.ts index e7531f651f8..214035ae575 100644 --- a/src/shared/agent-hook-listener-interactive-prompts.test.ts +++ b/src/shared/agent-hook-listener-interactive-prompts.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { PANE_KEY } from './agent-hook-listener-test-harness' diff --git a/src/shared/agent-hook-listener-pi-compatible.test.ts b/src/shared/agent-hook-listener-pi-compatible.test.ts index b0519378b18..8e835906f05 100644 --- a/src/shared/agent-hook-listener-pi-compatible.test.ts +++ b/src/shared/agent-hook-listener-pi-compatible.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { PANE_KEY } from './agent-hook-listener-test-harness' diff --git a/src/shared/agent-hook-listener-relay-dependency.test.ts b/src/shared/agent-hook-listener-relay-dependency.test.ts new file mode 100644 index 00000000000..e05ef801f7f --- /dev/null +++ b/src/shared/agent-hook-listener-relay-dependency.test.ts @@ -0,0 +1,135 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +function readRuntimeSpecifiers(source: string): string[] { + const specifiers: string[] = [] + const lines = source.split('\n') + for (let index = 0; index < lines.length; index += 1) { + const firstLine = lines[index].trimStart() + const isRuntimeImport = + firstLine.startsWith('import ') && + !firstLine.startsWith('import type ') && + !/^import\s+[\w$]+\s*=\s*require\s*\(/.test(firstLine) + const isRuntimeReexport = + (firstLine.startsWith('export {') || firstLine.startsWith('export *')) && + !firstLine.startsWith('export type ') + if (!isRuntimeImport && !isRuntimeReexport) { + continue + } + let statement = firstLine + while (!/(?:^import\s*['"]|\bfrom\s*['"])/.test(statement) && index + 1 < lines.length) { + index += 1 + statement += `\n${lines[index]}` + } + const match = + statement.match(/^import\s*['"]([^'"]+)['"]/) ?? statement.match(/\bfrom\s*['"]([^'"]+)['"]/) + if (match) { + specifiers.push(match[1]) + } + } + for (const match of source.matchAll(/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) { + specifiers.push(match[1]) + } + for (const match of source.matchAll(/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) { + specifiers.push(match[1]) + } + return specifiers +} + +function resolveTypeScriptImport(importer: string, specifier: string): string | null { + const candidate = resolve(dirname(importer), specifier) + for (const path of [`${candidate}.ts`, `${candidate}.tsx`]) { + if (existsSync(path)) { + return path + } + } + return null +} + +describe('agent hook listener relay dependency boundary', () => { + it('detects every supported runtime dependency syntax while excluding type imports', () => { + const source = [ + "import type { TypeOnly } from './type-only'", + 'import {', + ' runtime', + "} from './static-runtime'", + "import './side-effect'", + "const dynamic = import('./dynamic-runtime')", + "export { runtimeExport } from './runtime-export'", + "export * as runtimeNamespace from './namespace-runtime'", + "import imported = require('./ts-import-equals')", + "const required = require('./required-runtime')" + ].join('\n') + + expect(readRuntimeSpecifiers(source)).toEqual([ + './static-runtime', + './side-effect', + './runtime-export', + './namespace-runtime', + './dynamic-runtime', + './ts-import-equals', + './required-runtime' + ]) + }) + + it('keeps the transitive runtime graph inside Node builtins and shared modules', () => { + const sharedRoot = resolve(__dirname) + const listenerPathPrefix = resolve(sharedRoot, 'agent-hook-listener') + const relayConsumers = [ + resolve(sharedRoot, '../relay/agent-hook-server.ts'), + resolve(sharedRoot, '../relay/agent-hook-result-retry-scheduler.ts') + ] + const pending = relayConsumers.flatMap((consumer) => + readRuntimeSpecifiers(readFileSync(consumer, 'utf8')) + .map((specifier) => resolveTypeScriptImport(consumer, specifier)) + .filter( + (dependency): dependency is string => + dependency === `${listenerPathPrefix}.ts` || + dependency?.startsWith(`${listenerPathPrefix}/`) === true + ) + ) + const seeded = new Set(pending) + const visited = new Set() + const forbidden: string[] = [] + + while (pending.length > 0) { + const file = pending.pop()! + if (visited.has(file)) { + continue + } + visited.add(file) + const source = readFileSync(file, 'utf8') + for (const specifier of readRuntimeSpecifiers(source)) { + if (specifier.startsWith('node:')) { + continue + } + if (!specifier.startsWith('.')) { + forbidden.push(`${file}: ${specifier}`) + continue + } + const dependency = resolveTypeScriptImport(file, specifier) + if (!dependency || !dependency.startsWith(sharedRoot)) { + forbidden.push(`${file}: ${specifier}`) + continue + } + pending.push(dependency) + } + } + + expect(forbidden).toEqual([]) + expect([...seeded].map((file) => file.slice(sharedRoot.length + 1)).sort()).toEqual([ + 'agent-hook-listener.ts', + 'agent-hook-listener/endpoint-publication.ts', + 'agent-hook-listener/grok-result-discovery.ts', + 'agent-hook-listener/listener-limits.ts', + 'agent-hook-listener/listener-state.ts', + 'agent-hook-listener/providers/codex-state.ts', + 'agent-hook-listener/request-body.ts', + 'agent-hook-listener/source-routing.ts' + ]) + expect( + [...visited].some((file) => file.endsWith('/agent-hook-listener/provider-dispatch.ts')) + ).toBe(true) + }) +}) diff --git a/src/shared/agent-hook-listener-roster-retention.test.ts b/src/shared/agent-hook-listener-roster-retention.test.ts index f7a9332efb2..dbe5b311f6c 100644 --- a/src/shared/agent-hook-listener-roster-retention.test.ts +++ b/src/shared/agent-hook-listener-roster-retention.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { MAX_AGENT_HOOK_STATUS_CACHE_PANES } from './agent-hook-status-cache' import { makePaneKey } from './stable-pane-id' diff --git a/src/shared/agent-hook-listener-session-replacement.test.ts b/src/shared/agent-hook-listener-session-replacement.test.ts index 918840aac57..da179e68414 100644 --- a/src/shared/agent-hook-listener-session-replacement.test.ts +++ b/src/shared/agent-hook-listener-session-replacement.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' +import { normalizeHookPayload } from './agent-hook-listener' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' import { makePaneKey } from './stable-pane-id' const LEAF_ID = '44444444-4444-4444-8444-444444444444' diff --git a/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts b/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts index aebade35d6c..4046eb3e645 100644 --- a/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts +++ b/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import { createHookListenerState, - normalizeHookPayload, - seedClaudeSubagentRosterFromSnapshots, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' +import { seedClaudeSubagentRosterFromSnapshots } from './agent-hook-listener/providers/claude-roster-state' import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' import { makePaneKey } from './stable-pane-id' diff --git a/src/shared/agent-hook-listener-test-harness.ts b/src/shared/agent-hook-listener-test-harness.ts index d4b4eb50348..470039265d9 100644 --- a/src/shared/agent-hook-listener-test-harness.ts +++ b/src/shared/agent-hook-listener-test-harness.ts @@ -1,4 +1,5 @@ -import { normalizeHookPayload, type HookListenerState } from './agent-hook-listener' +import { normalizeHookPayload } from './agent-hook-listener' +import type { HookListenerState } from './agent-hook-listener/listener-state' import { makePaneKey } from './stable-pane-id' const LEAF_ID = '11111111-1111-4111-8111-111111111111' diff --git a/src/shared/agent-hook-listener-transport.test.ts b/src/shared/agent-hook-listener-transport.test.ts index a4ed6998fbd..344be39eca3 100644 --- a/src/shared/agent-hook-listener-transport.test.ts +++ b/src/shared/agent-hook-listener-transport.test.ts @@ -6,13 +6,15 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { getEndpointFileName, - HOOK_REQUEST_MAX_BYTES, isShellSafeEndpointValue, - parseFormEncodedBody, - readRequestBody, - resolveHookSource, writeEndpointFile -} from './agent-hook-listener' +} from './agent-hook-listener/endpoint-publication' +import { + HOOK_REQUEST_MAX_BYTES, + parseFormEncodedBody, + readRequestBody +} from './agent-hook-listener/request-body' +import { resolveHookSource } from './agent-hook-listener/source-routing' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' type FakeIncomingMessage = EventEmitter & { @@ -69,6 +71,64 @@ describe('shared agent-hook-listener', () => { expectRequestParserListenersReleased(req) }) + it('accepts a JSON body at exactly the byte limit', async () => { + const req = createReadableRequest({ 'content-type': 'application/json' }) + const text = JSON.stringify({ x: 'a'.repeat(HOOK_REQUEST_MAX_BYTES - 8) }) + expect(Buffer.byteLength(text)).toBe(HOOK_REQUEST_MAX_BYTES) + const body = readRequestBody(req as unknown as IncomingMessage) + + req.emit('data', Buffer.from(text)) + req.emit('end') + + await expect(body).resolves.toEqual({ x: 'a'.repeat(HOOK_REQUEST_MAX_BYTES - 8) }) + expectRequestParserListenersReleased(req) + }) + + it('strips exactly one outer JSON BOM', async () => { + const req = createReadableRequest({ 'content-type': 'application/json' }) + const body = readRequestBody(req as unknown as IncomingMessage) + req.emit('data', Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('{"ok":true}')])) + req.emit('end') + await expect(body).resolves.toEqual({ ok: true }) + }) + + it('rejects JSON beyond the nesting-depth limit', async () => { + const req = createReadableRequest({ 'content-type': 'application/json' }) + const body = readRequestBody(req as unknown as IncomingMessage) + req.emit('data', Buffer.from(`${'['.repeat(65)}0${']'.repeat(65)}`)) + req.emit('end') + await expect(body).rejects.toThrow('JSON nesting exceeds 64 levels') + expectRequestParserListenersReleased(req) + }) + + it('rejects JSON beyond the 128K structural-token limit', async () => { + const req = createReadableRequest({ 'content-type': 'application/json' }) + const body = readRequestBody(req as unknown as IncomingMessage) + req.emit('data', Buffer.from(`[${'0,'.repeat(128 * 1024)}0]`)) + req.emit('end') + await expect(body).rejects.toThrow('JSON structure exceeds 131072 tokens') + expectRequestParserListenersReleased(req) + }) + + it('classifies a premature request error before its original error', async () => { + const req = createReadableRequest({ 'content-length': '10' }) + const body = readRequestBody(req as unknown as IncomingMessage) + req.emit('data', Buffer.from('ab')) + req.emit('error', new Error('socket reset')) + await expect(body).rejects.toThrow('hook request truncated after 2 of 10 bytes') + expectRequestParserListenersReleased(req) + }) + + it('settles a premature close once and ignores late request events', async () => { + const req = createReadableRequest({ 'content-length': '10' }) + const body = readRequestBody(req as unknown as IncomingMessage) + req.emit('data', Buffer.from('ab')) + req.emit('close') + req.emit('end') + await expect(body).rejects.toThrow('hook request truncated after 2 of 10 bytes') + expectRequestParserListenersReleased(req) + }) + it('routes pathnames to a known source or null', () => { expect(resolveHookSource('/hook/claude')).toBe('claude') expect(resolveHookSource('/hook/cursor')).toBe('cursor') diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 05702db4fcf..f1a05ad1412 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -1,4448 +1,22 @@ -/* eslint-disable max-lines -- Why: canonical transport-agnostic listener; parser, normalizer, per-CLI extractors, and endpoint writer share invariants that must not drift between Orca's main process and the relay. */ - -// Why: extracted from src/main/agent-hooks/server.ts so the relay can host the same pipeline without Electron — this file must stay on Node builtins and other shared/ modules, or the relay bundle breaks. -import type { IncomingMessage } from 'node:http' -import { createHash, randomUUID } from 'node:crypto' -import { homedir } from 'node:os' -import { - chmodSync, - closeSync, - mkdirSync, - openSync, - readSync, - renameSync, - statSync, - unlinkSync, - writeFileSync -} from 'node:fs' -import { isAbsolute, join } from 'node:path' - -import { - AGENT_MODEL_MAX_LENGTH, - normalizeAgentStatusPayload, - type AgentStatusState, - type AgentSubagentSnapshot, - type AgentWorkingMode, - type ParsedAgentStatusPayload -} from './agent-status-types' -import { normalizeOptionalField } from './agent-status-field-normalization' -import { isAskUserQuestionTool } from './agent-question-answered-intent' -import { - claudeRosterHasRestoredSnapshotSubagent, - claudeRosterHasRuntimeWorkingSubagent, - claudeRosterHasWorkingSubagent, - claudeRosterToSnapshots, - claudeTeammateIdMatchesName, - foldClaudeBackgroundTasksIntoRoster, - idleClaudeTeammateByName, - reapUnconfirmedRestoredClaudeSubagents, - stopClaudeSubagent, - upsertWorkingClaudeSubagent, - type ClaudeSubagentRoster -} from './claude-subagent-roster' -import { readClaudeBackgroundAgentTasks } from './claude-background-task-inventory' -import { - codexRosterEffectiveState, - codexRosterToSnapshots, - finishCodexSubagent, - seedCodexSubagentRoster, - upsertCodexSubagent, - type CodexSubagentRoster -} from './codex-subagent-roster' -import { - createCodexSubagentTranscriptState, - hasTrackedCodexTranscriptSubagents, - reconcileCodexSubagentTranscript, - type CodexSubagentTranscriptState -} from './codex-subagent-transcript' -import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types' -import { REMOTE_AGENT_HOOK_ENV, type AgentHookSource } from './agent-hook-relay' -import { - extractAgentProviderSession, - type AgentProviderSessionMetadata -} from './agent-session-resume' -import { parsePaneKey } from './stable-pane-id' -import { - isCompactContinuationUserTurnText, - isKnownHarnessInjectedUserTurnText -} from './harness-injected-user-turns' -import { - buildGrokChatHistoryPathCandidates, - findGrokChatHistoryBySessionId, - getCachedGrokChatHistoryBySessionId, - GROK_SESSION_ID_MAX_LENGTH, - isSafeGrokSessionId, - resolveGrokChatHistoryPathSync, - resolveGrokSessionsDir -} from './grok-session-paths' +import { normalizeAgentStatusPayload } from './agent-status-types' +import type { AgentHookSource } from './agent-hook-relay' +import { extractAgentProviderSession } from './agent-session-resume' import { canAcceptClaudeCompactCompletion, isClaudeCompactCompletionConsumed, markClaudeCompactCompletionConsumed } from './claude-compact-completion' -import { sweepStaleAgentHookEndpointTemps } from './agent-hook-endpoint-temp-cleanup' -import { classifyTruncatedHookRequest } from './agent-hook-transport-interference' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' - -/** Maximum request body size accepted by the listener (1 MB). */ -export const HOOK_REQUEST_MAX_BYTES = 1_000_000 -const HOOK_REQUEST_INITIAL_BUFFER_BYTES = 4 * 1024 -const AGENT_HOOK_JSON_STRUCTURE_LIMITS = { - structuralTokens: 128 * 1024, - nestingDepth: 64 -} as const - -function parseAgentHookJson(content: string): unknown { - // Why: Cursor on Windows writes UTF-8-with-BOM to the hook's stdin and `JSON.parse` rejects U+FEFF, - // so the whole event was dropped. Strip exactly one leading BOM — not a trim — to keep every other - // malformed payload rejected as before. - const normalizedContent = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content - assertJsonTextStructureWithinLimits(normalizedContent, AGENT_HOOK_JSON_STRUCTURE_LIMITS) - return JSON.parse(normalizedContent) as unknown -} - -/** Bound the warn-once Sets so a client varying `version`/`env` per request can't grow them unbounded. */ -const MAX_WARNED_KEYS = 32 - -/** Slowloris cap: drop requests that have not finished sending after 5 s. */ -export const HOOK_REQUEST_SLOWLORIS_MS = 5_000 - -/** Why: old OpenCode plugin builds re-post the full accumulated reply on every streamed part (O(n²) bytes/turn); cap at ingest to bound per-event cost. */ -export const OPENCODE_HOOK_TEXT_MAX_CHARS = 8_000 - -function capOpenCodeHookText(text: string): string { - return text.length > OPENCODE_HOOK_TEXT_MAX_CHARS - ? text.slice(0, OPENCODE_HOOK_TEXT_MAX_CHARS) - : text -} - -/** Bound paneKey size (real keys are well under 200); caps per-pane caches against pathological input. Exported so non-HTTP ingest (`ingestRemote`) applies the same cap as defense-in-depth. */ -export const MAX_PANE_KEY_LEN = 200 -const CLAUDE_PROMPT_ID_RE = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i - -export function normalizeClaudePromptId(value: unknown): string | undefined { - if (typeof value !== 'string') { - return undefined - } - const normalized = value.trim().toLowerCase() - return CLAUDE_PROMPT_ID_RE.test(normalized) ? normalized : undefined -} - -/** Per-listener-instance caches needing per-PTY teardown; Orca's main process and the relay each get their own, never shared. */ -export type HookListenerState = { - warnedVersions: Set - warnedEnvs: Set - lastPromptByPaneKey: Map - lastToolByPaneKey: Map - lastStatusByPaneKey: Map - antigravityCompletedTranscriptByPaneKey: Map - ampCompletedCacheKeys: Set - /** Live subagents/teammates per Claude pane; survives turn boundaries since background children outlive the lead turn. */ - claudeSubagentRosterByPaneKey: Map - /** Last state from the LEAD session's own events (subagent events carry agent_id, excluded), so a SubagentStop can re-emit pane status; `interrupted` persists so the eventual done still carries it. */ - claudeLeadStateByPaneKey: Map - /** One-normalization provenance marker for a status backed only by restored child state. */ - claudeUnconfirmedRestoredStatusPaneKeys: Set - /** Panes whose latest authoritative Claude task inventory still has running non-agent work. */ - claudeRunningNonAgentTaskPaneKeys: Set - /** Panes whose latest authoritative Claude cron inventory still has a scheduled job. */ - claudeActiveSessionCronPaneKeys: Set - /** Compact whose completion each pane already applied, so relay duplicates can't refresh the row. */ - claudeConsumedCompactPromptIdByPaneKey: Map - /** Claude `session_id` that last reported on the pane from a LEAD event. A different id means the - * conversation was replaced (/clear, relaunch, resume), so claims the old session owned are void - * even when no SessionStart arrives — the backstop for the exits that emit no terminating hook. */ - claudeSessionOwnerByPaneKey: Map - /** Live thread-spawn children per Codex pane. */ - codexSubagentRosterByPaneKey: Map - /** Incremental parent/child rollout cursors for Codex collaboration v2. */ - codexSubagentTranscriptByPaneKey: Map - /** Root Codex state/model, kept separate from child hook traffic. */ - codexLeadStateByPaneKey: Map -} - -export type ClaudeLeadTurnState = { - state: AgentStatusState - interrupted?: true - /** Subagent that induced the wait; only its next tool activity may clear it, so other children's churn can't dismiss a pending human-input card. */ - waitingAgentId?: string - /** Tool call that owns the wait; late completions from parallel sibling tools must not dismiss its card. */ - waitingToolUseId?: string - /** End time of the lead turn closed while background inventory kept the pane `working`. Repeated on the later all-clear `done`. */ - turnCompletedAt?: number - /** Lead state a child-induced wait displaced, restored when the wait clears; can't invent 'working' since the done-gate only downgrades done→working, never back. */ - stateBeforeWait?: Pick -} - -type CodexLeadTurnState = { - state: 'working' | 'waiting' | 'done' - model?: string -} - -export function createHookListenerState(): HookListenerState { - return { - warnedVersions: new Set(), - warnedEnvs: new Set(), - lastPromptByPaneKey: new Map(), - lastToolByPaneKey: new Map(), - lastStatusByPaneKey: new Map(), - antigravityCompletedTranscriptByPaneKey: new Map(), - ampCompletedCacheKeys: new Set(), - claudeSubagentRosterByPaneKey: new Map(), - claudeLeadStateByPaneKey: new Map(), - claudeUnconfirmedRestoredStatusPaneKeys: new Set(), - claudeRunningNonAgentTaskPaneKeys: new Set(), - claudeActiveSessionCronPaneKeys: new Set(), - claudeConsumedCompactPromptIdByPaneKey: new Map(), - claudeSessionOwnerByPaneKey: new Map(), - codexSubagentRosterByPaneKey: new Map(), - codexSubagentTranscriptByPaneKey: new Map(), - codexLeadStateByPaneKey: new Map() - } -} - -export function clearPaneCacheState(state: HookListenerState, paneKey: string): void { - deletePaneScopedCacheEntry(state.lastPromptByPaneKey, paneKey) - deletePaneScopedCacheEntry(state.lastToolByPaneKey, paneKey) - deletePaneScopedCacheEntry(state.lastStatusByPaneKey, paneKey) - deletePaneScopedCacheEntry(state.antigravityCompletedTranscriptByPaneKey, paneKey) - deletePaneScopedSetEntry(state.ampCompletedCacheKeys, paneKey) - deletePaneScopedCacheEntry(state.claudeConsumedCompactPromptIdByPaneKey, paneKey) - state.claudeSubagentRosterByPaneKey.delete(paneKey) - state.claudeLeadStateByPaneKey.delete(paneKey) - state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey) - state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - state.claudeActiveSessionCronPaneKeys.delete(paneKey) - state.claudeSessionOwnerByPaneKey.delete(paneKey) - state.codexSubagentRosterByPaneKey.delete(paneKey) - state.codexSubagentTranscriptByPaneKey.delete(paneKey) - state.codexLeadStateByPaneKey.delete(paneKey) -} - -/** Does this pane still hold anything that can ASSERT a state — a stored row, or a Claude latch that - * `resolveClaudePaneState` would re-gate `working` from on the pane's next event? - * - * Deliberately lives next to `clearPaneCacheState` above and enumerates the claim-bearing subset of - * what that function deletes: the two must be edited together, and keeping them three lines apart in - * one file is what makes that obvious. Prompt/tool/transcript caches are excluded — they render a - * row, they never create one. */ -export function paneHasStateClaims(state: HookListenerState, paneKey: string): boolean { - return ( - state.lastStatusByPaneKey.has(paneKey) || - state.claudeSubagentRosterByPaneKey.has(paneKey) || - state.claudeLeadStateByPaneKey.has(paneKey) || - state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - state.claudeActiveSessionCronPaneKeys.has(paneKey) || - state.claudeSessionOwnerByPaneKey.has(paneKey) || - state.codexSubagentRosterByPaneKey.has(paneKey) || - state.codexLeadStateByPaneKey.has(paneKey) - ) -} - -function movePaneScopedMapEntries( - map: Map, - fromPaneKey: string, - toPaneKey: string -): void { - for (const [key, value] of Array.from(map.entries())) { - if (key !== fromPaneKey && !key.startsWith(`${fromPaneKey}\0`)) { - continue - } - map.delete(key) - map.set(`${toPaneKey}${key.slice(fromPaneKey.length)}`, value) - } -} - -function movePaneScopedSetEntries(set: Set, fromPaneKey: string, toPaneKey: string): void { - for (const key of Array.from(set)) { - if (key !== fromPaneKey && !key.startsWith(`${fromPaneKey}\0`)) { - continue - } - set.delete(key) - set.add(`${toPaneKey}${key.slice(fromPaneKey.length)}`) - } -} - -export function movePaneCacheState( - state: HookListenerState, - fromPaneKey: string, - toPaneKey: string -): void { - if (fromPaneKey === toPaneKey) { - return - } - movePaneScopedMapEntries(state.lastPromptByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.lastToolByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.lastStatusByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.antigravityCompletedTranscriptByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedSetEntries(state.ampCompletedCacheKeys, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.claudeConsumedCompactPromptIdByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.claudeSubagentRosterByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.claudeLeadStateByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedSetEntries(state.claudeUnconfirmedRestoredStatusPaneKeys, fromPaneKey, toPaneKey) - movePaneScopedSetEntries(state.claudeRunningNonAgentTaskPaneKeys, fromPaneKey, toPaneKey) - movePaneScopedSetEntries(state.claudeActiveSessionCronPaneKeys, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.claudeSessionOwnerByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.codexSubagentRosterByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) -} - -function clearPaneTurnCacheState(state: HookListenerState, paneKey: string): void { - state.lastPromptByPaneKey.delete(paneKey) - state.lastToolByPaneKey.delete(paneKey) - state.antigravityCompletedTranscriptByPaneKey.delete(paneKey) - state.ampCompletedCacheKeys.delete(paneKey) -} - -function deletePaneScopedCacheEntry(map: Map, paneKey: string): void { - map.delete(paneKey) - const scopedPrefix = `${paneKey}\0` - for (const key of map.keys()) { - if (key.startsWith(scopedPrefix)) { - map.delete(key) - } - } -} - -function deletePaneScopedSetEntry(set: Set, paneKey: string): void { - set.delete(paneKey) - const scopedPrefix = `${paneKey}\0` - for (const key of set) { - if (key.startsWith(scopedPrefix)) { - set.delete(key) - } - } -} - -export function clearAllListenerCaches(state: HookListenerState): void { - state.lastPromptByPaneKey.clear() - state.lastToolByPaneKey.clear() - state.lastStatusByPaneKey.clear() - state.antigravityCompletedTranscriptByPaneKey.clear() - state.ampCompletedCacheKeys.clear() - state.claudeConsumedCompactPromptIdByPaneKey.clear() - state.warnedVersions.clear() - state.warnedEnvs.clear() - state.claudeSubagentRosterByPaneKey.clear() - state.claudeLeadStateByPaneKey.clear() - state.claudeUnconfirmedRestoredStatusPaneKeys.clear() - state.claudeRunningNonAgentTaskPaneKeys.clear() - state.claudeActiveSessionCronPaneKeys.clear() - state.claudeSessionOwnerByPaneKey.clear() - state.codexSubagentRosterByPaneKey.clear() - state.codexSubagentTranscriptByPaneKey.clear() - state.codexLeadStateByPaneKey.clear() -} - -/** Warn-once on cross-build (`version`) and dev-vs-prod (`env`) mismatches; the relay's "remote" env marker is a location tag, not a build env, so it must not warn as a stale local hook. */ -export function warnOnHookEnvOrVersionMismatch( - state: HookListenerState, - fields: { version?: string; env?: string; expectedEnv: string } -): void { - const { version, env, expectedEnv } = fields - if ( - version && - version !== ORCA_HOOK_PROTOCOL_VERSION && - !state.warnedVersions.has(version) && - state.warnedVersions.size < MAX_WARNED_KEYS - ) { - state.warnedVersions.add(version) - console.warn( - `[agent-hooks] received hook v${version}; server expects v${ORCA_HOOK_PROTOCOL_VERSION}. ` + - 'Reinstall agent hooks from Settings to upgrade the managed script.' - ) - } - if (env && env !== REMOTE_AGENT_HOOK_ENV && env !== expectedEnv) { - const key = `${env}->${expectedEnv}` - if (!state.warnedEnvs.has(key) && state.warnedEnvs.size < MAX_WARNED_KEYS) { - state.warnedEnvs.add(key) - console.warn( - `[agent-hooks] received ${env} hook on ${expectedEnv} server. ` + - 'Likely a stale terminal from another Orca install.' - ) - } - } -} - -export type AgentHookEventPayload = { - paneKey: string - /** Authenticated hook route that produced this event. */ - source?: AgentHookSource - /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ - launchToken?: string - tabId?: string - worktreeId?: string - /** SSH connection the event arrived on, or null for local. Only `ingestRemote` can stamp it — the loopback HTTP path has no mux identity — and receivers key off it to drop - * in-flight events from a superseded connection after an SSH reconnect. */ - connectionId: string | null - /** True when the event carried prompt text directly, not the listener's cached prompt from an earlier event in the pane. */ - hasExplicitPrompt?: boolean - /** The emitted nonterminal state is backed only by child state restored from disk. */ - restoredUnconfirmed?: true - /** Stable per-turn key to distinguish duplicate hook delivery from a same-text prompt rerun (when the source exposes enough context). */ - promptInteractionKey?: string - /** Raw agent hook event name, used by main-process transition guards. */ - hookEventName?: string - /** Claude's provider-owned user-prompt UUID. */ - providerPromptId?: string - /** Active Claude compact generation, keyed by provider prompt identity. */ - compactTrigger?: 'manual' | 'auto' - /** Claude tool-use identifier when the hook source exposes one. */ - toolUseId?: string - /** Claude agent/subagent identifier when the hook source exposes one. */ - toolAgentId?: string - /** Claude teammate name carried by TeammateIdle. */ - teammateName?: string - /** Agent/subagent type from the source hook payload, when present. */ - toolAgentType?: string - /** Provider-owned conversation/session id needed to resume a sleeping agent. */ - providerSession?: AgentProviderSessionMetadata - /** Session identity update with no turn-state transition; refreshes durable resume metadata without a fake status row. */ - providerSessionOnly?: boolean - /** True when this event is a relay cache replay rather than a live hook. */ - isReplay?: boolean - /** Transport-only Claude background-work evidence used to reject false input-based interrupts. */ - claudeRunningNonAgentTask?: boolean - payload: ParsedAgentStatusPayload -} - -// ─── Body parsing ─────────────────────────────────────────────────── - -export function parseFormEncodedBody(body: string): Record { - const params = new URLSearchParams(body) - const parsed: Record = {} - for (const [key, value] of params.entries()) { - parsed[key] = value - } - return parsed -} - -export function readRequestBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let retained = Buffer.alloc(0) - let byteLength = 0 - let settled = false - const cleanup = (): void => { - req.off('data', onData) - req.off('end', onEnd) - req.off('error', onError) - req.off('close', onClose) - // Why: keep a neutral error sink so a late IncomingMessage error after cleanup can't become unhandled. - req.on('error', ignoreSettledRequestError) - } - const settleResolve = (value: unknown): void => { - if (settled) { - return - } - settled = true - cleanup() - resolve(value) - } - const settleReject = (error: unknown): void => { - if (settled) { - return - } - settled = true - cleanup() - reject(error) - } - const onData = (chunk: Buffer): void => { - // Why: bound by bytes (not UTF-16 units) and stop accumulating after rejection so a client can't push memory past the cap. - const nextByteLength = byteLength + chunk.length - if (nextByteLength > HOOK_REQUEST_MAX_BYTES) { - settleReject(new Error('payload too large')) - req.destroy() - return - } - if (retained.length < nextByteLength) { - const nextCapacity = Math.min( - HOOK_REQUEST_MAX_BYTES, - Math.max(HOOK_REQUEST_INITIAL_BUFFER_BYTES, retained.length * 2, nextByteLength) - ) - const next = Buffer.allocUnsafe(nextCapacity) - retained.copy(next, 0, 0, byteLength) - retained = next - } - chunk.copy(retained, byteLength) - byteLength = nextByteLength - } - const onEnd = (): void => { - try { - const body = retained.toString('utf8', 0, byteLength) - const contentType = req.headers['content-type'] ?? '' - if (typeof contentType === 'string' && contentType.includes('application/json')) { - settleResolve(body ? parseAgentHookJson(body) : {}) - return - } - if ( - typeof contentType === 'string' && - contentType.includes('application/x-www-form-urlencoded') - ) { - settleResolve(parseFormEncodedBody(body)) - return - } - // Why: managed scripts POST JSON, updated POSIX scripts form-encoded; default to JSON for unknown content types. - settleResolve(body ? parseAgentHookJson(body) : {}) - } catch (error) { - settleReject(error) - } - } - // Why (#11217): a body cut short of its own Content-Length is the fingerprint of an IDS - // resetting the connection mid-inspection. Classify on every path that ends the request without - // 'end' — a peer RST surfaces as 'error' (ECONNRESET) and only a local destroy reaches 'close' first. - const settleUnfinished = (fallback: Error): void => { - settleReject( - classifyTruncatedHookRequest(req.headers['content-length'], byteLength) ?? fallback - ) - } - const onError = (err: Error): void => { - settleUnfinished(err) - } - // Why: req.destroy() (slowloris timer) emits 'close' but not 'end'/'error'; without this the promise never settles and buffers leak. - const onClose = (): void => { - settleUnfinished(new Error('aborted')) - } - req.on('data', onData) - req.on('end', onEnd) - req.on('error', onError) - req.on('close', onClose) - }) -} - -function ignoreSettledRequestError(): void {} - -// ─── Per-pane field caches + extractors ───────────────────────────── - -type ExtractedPromptText = { - text: string - source: - | 'prompt' - | 'user_prompt' - | 'userPrompt' - | 'initial_prompt' - | 'initialPrompt' - | 'user_message' - | 'message' - | 'role_user_text' - | null -} - -// Joins text of an Anthropic-style content-block array; returns '' when nothing textual so callers fall through to the next prompt source. -function contentBlockArrayText(value: unknown[]): string { - const parts: string[] = [] - for (const item of value) { - if (typeof item === 'string') { - parts.push(item) - continue - } - if (item && typeof item === 'object') { - const text = (item as Record).text - if (typeof text === 'string') { - parts.push(text) - } - } - } - return parts.join(' ').replace(/\s+/g, ' ').trim() -} - -function extractPromptText(hookPayload: Record): ExtractedPromptText { - const candidateKeys = [ - 'prompt', - 'user_prompt', - 'userPrompt', - 'initial_prompt', - 'initialPrompt', - 'user_message', - 'message' - ] - for (const key of candidateKeys) { - const value = hookPayload[key] - if (typeof value === 'string' && value.trim().length > 0) { - // Why: trim so prompts match readStringField output — whitespace would otherwise leak into UI and caches. - return { text: value.trim(), source: key as Exclude } - } - // Why: Kimi sends `prompt` as a content-block array, not a string; extract it for real prompt keys but skip `message` (ambiguous status field). - if (key !== 'message' && Array.isArray(value)) { - const text = contentBlockArrayText(value) - if (text.length > 0) { - return { text, source: key as Exclude } - } - } - } - // Why: OpenCode sends MessagePart { role, text } with no UserPromptSubmit; when role === 'user' the text is the prompt. - if (hookPayload.role === 'user' && typeof hookPayload.text === 'string') { - const trimmed = capOpenCodeHookText(hookPayload.text.trim()) - if (trimmed.length > 0) { - return { text: trimmed, source: 'role_user_text' } - } - } - return { text: '', source: null } -} - -function stripGrokUserQueryWrapper(promptText: string): string { - const opener = '' - if (!promptText.startsWith(opener)) { - return promptText - } - const closer = '' - const wrappedText = promptText.slice(opener.length) - const text = wrappedText.endsWith(closer) ? wrappedText.slice(0, -closer.length) : wrappedText - // Why: Grok wraps the submitted prompt in a `` envelope; the status cache should hold the plain user text. - return text.trim() -} - -// Why: the post-compact continuation prompt has no matching Stop and would resurrect working. -function shouldIgnoreCompactContinuationUserPromptSubmit( - eventName: unknown, - promptText: string -): boolean { - return eventName === 'UserPromptSubmit' && isCompactContinuationUserTurnText(promptText) -} - -function resolvePrompt( - state: HookListenerState, - paneKey: string, - promptText: string, - options?: { resetOnNewTurn?: boolean } -): string { - // Why: harness-injected turns fire UserPromptSubmit but aren't the user's ask — keep cached prompt; match only known tags so real still reset the turn. - if (isKnownHarnessInjectedUserTurnText(promptText)) { - return state.lastPromptByPaneKey.get(paneKey) ?? '' - } - if (options?.resetOnNewTurn) { - state.lastPromptByPaneKey.delete(paneKey) - } - if (promptText) { - state.lastPromptByPaneKey.set(paneKey, promptText) - return promptText - } - return state.lastPromptByPaneKey.get(paneKey) ?? '' -} - -export type ToolSnapshot = { - toolName?: string - toolInput?: string - /** Full JSON of an AskUserQuestion tool input; set only on its own event and NOT inherited (resolveToolState) so no stale prompt lingers. */ - interactivePrompt?: string - hasToolUpdate?: boolean - hasToolInputField?: boolean - lastAssistantMessage?: string - clearLastAssistantMessage?: boolean -} - -function resolveToolState( - state: HookListenerState, - paneKey: string, - update: ToolSnapshot, - options: { resetOnNewTurn: boolean } -): ToolSnapshot { - if (options.resetOnNewTurn) { - state.lastToolByPaneKey.delete(paneKey) - } - const previous = state.lastToolByPaneKey.get(paneKey) ?? {} - // Why: undefined means either "no update" or "input not previewable"; extractor metadata decides whether to inherit stale input. - const clearsUnpreviewableInput = - update.hasToolInputField === true && update.toolInput === undefined - const clearsUnidentifiedTool = - update.hasToolUpdate === true && - update.toolName === undefined && - update.hasToolInputField === true - const toolName = clearsUnidentifiedTool ? undefined : (update.toolName ?? previous.toolName) - const toolInput = - clearsUnpreviewableInput || - (update.toolName !== undefined && - update.toolName !== previous.toolName && - update.toolInput === undefined) - ? undefined - : (update.toolInput ?? previous.toolInput) - const merged: ToolSnapshot = { - toolName, - toolInput, - // Why: don't inherit previous.interactivePrompt — valid only for its one AskUserQuestion event; carrying it forward leaves a stale live card. - interactivePrompt: update.interactivePrompt, - lastAssistantMessage: update.clearLastAssistantMessage - ? undefined - : (update.lastAssistantMessage ?? previous.lastAssistantMessage) - } - state.lastToolByPaneKey.set(paneKey, merged) - return merged -} - -const TOOL_INPUT_KEYS_BY_TOOL: Record = { - Read: ['file_path', 'filePath', 'path'], - Write: ['file_path', 'filePath', 'path'], - Create: ['file_path', 'filePath', 'path'], - Edit: ['file_path', 'filePath', 'path'], - Execute: ['command'], - MultiEdit: ['file_path', 'filePath', 'path'], - NotebookEdit: ['file_path', 'filePath', 'path'], - Bash: ['command'], - Glob: ['pattern'], - Grep: ['pattern'], - WebFetch: ['url'], - WebSearch: ['query'], - FetchUrl: ['url'], - read_file: ['file_path', 'path'], - write_file: ['file_path', 'path'], - read_many_files: ['file_path', 'paths', 'path'], - edit_file: ['file_path', 'path'], - replace: ['file_path', 'path'], - run_shell_command: ['command'], - run_command: ['CommandLine', 'command', 'cmd'], - glob: ['pattern'], - search_file_content: ['pattern'], - web_fetch: ['url'], - google_web_search: ['query'], - exec_command: ['cmd', 'command'], - shell_command: ['cmd', 'command'], - run_terminal_cmd: ['command'], - // Why: Grok maps Bash/Edit/Write to snake_case tool names; without these keys the status row shows blank toolInput for most Grok turns. - run_terminal_command: ['command'], - search_replace: ['file_path', 'path', 'filePath'], - write_to_file: ['TargetFile', 'path', 'file_path'], - execute_code: ['code', 'command', 'cmd'], - apply_patch: ['path', 'file_path'], - view_image: ['path', 'file_path'], - AskUser: ['question', 'prompt', 'message'], - ask_user: ['question', 'prompt', 'message'], - AskUserQuestion: ['questions', 'question', 'prompt', 'message'], - ask_user_question: ['questions', 'question', 'prompt', 'message'], - bash: ['command'], - powershell: ['command'], - create: ['path', 'file_path'], - read: ['path', 'file_path'], - write: ['path', 'file_path'], - edit: ['path', 'file_path'], - view: ['path', 'file_path'], - grep: ['pattern'], - web_search: ['query'], - fetch_content: ['url'], - terminal: ['command'], - patch: ['path', 'file_path'], - search_files: ['query', 'pattern', 'path'], - browser_navigate: ['url'], - browser_click: ['target', 'selector', 'text'], - browser_type: ['text', 'target', 'selector'], - session_search: ['query'], - skill_manage: ['action', 'name', 'file_path'], - delegate_task: ['task', 'prompt', 'description'], - view_file: ['AbsolutePath', 'path', 'file_path'], - replace_file_content: ['TargetFile', 'path', 'file_path'], - multi_replace_file_content: ['TargetFile', 'path', 'file_path'], - list_dir: ['DirectoryPath', 'path'], - find_by_name: ['SearchDirectory', 'Pattern', 'query'], - grep_search: ['SearchPath', 'Query', 'query', 'pattern'], - search_web: ['query'], - read_url_content: ['Url', 'url'], - manage_task: ['TaskId', 'Action'], - schedule: ['Prompt', 'DurationSeconds', 'CronExpression'], - ask_question: ['question', 'questions'], - ask_permission: ['Action', 'Target', 'Reason'], - spawn_subagent: ['prompt', 'description', 'subagent_type'], - open_page: ['url'] -} - -const FALLBACK_TOOL_INPUT_KEYS = [ - 'command', - 'cmd', - 'code', - 'query', - 'pattern', - 'url', - 'path', - 'file_path', - 'filePath', - 'target', - 'selector', - 'text', - 'action', - 'name', - 'description', - 'CommandLine', - 'AbsolutePath', - 'TargetFile', - 'DirectoryPath', - 'SearchPath', - 'Query', - 'Url', - 'Prompt' -] as const - -function deriveToolInputPreview( - toolName: string | undefined, - toolInput: unknown -): string | undefined { - if (typeof toolInput === 'string') { - return toolInput - } - if (typeof toolInput !== 'object' || toolInput === null) { - return undefined - } - if (!toolName) { - return undefined - } - const keys = TOOL_INPUT_KEYS_BY_TOOL[toolName] - if (!keys) { - return undefined - } - const record = toolInput as Record - for (const key of keys) { - const value = record[key] - if (typeof value === 'string' && value.trim().length > 0) { - return value - } - } - return undefined -} - -function deriveFallbackToolInputPreview(toolInput: unknown): string | undefined { - if (typeof toolInput === 'string') { - return toolInput - } - if (typeof toolInput !== 'object' || toolInput === null) { - return undefined - } - const record = toolInput as Record - for (const key of FALLBACK_TOOL_INPUT_KEYS) { - const value = record[key] - if (typeof value === 'string' && value.trim().length > 0) { - return value - } - } - return undefined -} - -function readString(record: Record, key: string): string | undefined { - const value = record[key] - return typeof value === 'string' && value.length > 0 ? value : undefined -} - -function hasOwnField(record: Record, key: string): boolean { - return Object.hasOwn(record, key) -} - -function hasAnyOwnField(record: Record, keys: readonly string[]): boolean { - return keys.some((key) => hasOwnField(record, key)) -} - -function toolUpdate( - fields: Pick, - options?: { hasToolInputField?: boolean } -): ToolSnapshot { - return { - ...fields, - hasToolUpdate: true, - hasToolInputField: options?.hasToolInputField === true - } -} - -/** Clear active-tool metadata so a failed tool stops looking in-flight (else the compact sidebar hides the error behind the tool name). */ -function clearActiveToolFieldsUpdate(): ToolSnapshot { - return toolUpdate( - { toolName: undefined, toolInput: undefined, interactivePrompt: undefined }, - { hasToolInputField: true } - ) -} - -/** Drop the hook envelope keys a plugin merges into event properties so the serialized prompt holds only the question structure. */ -function stripHookEnvelopeKeys(record: Record): Record { - const { hook_event_name: _h, hookEventName: _he, ...rest } = record - return rest -} - -/** One-line description of a tool call for an approval card (Bash command, file path, else clipped JSON). */ -function summarizeApprovalInput(toolInput: unknown): string { - if (toolInput && typeof toolInput === 'object') { - const obj = toolInput as Record - const direct = obj.command ?? obj.file_path ?? obj.path ?? obj.url ?? obj.pattern - if (typeof direct === 'string' && direct.length > 0) { - return direct.length > 200 ? `${direct.slice(0, 200)}…` : direct - } - } - try { - const json = JSON.stringify(toolInput) ?? '' - return json.length > 200 ? `${json.slice(0, 200)}…` : json - } catch { - return '' - } -} - -/** Normalized JSON envelope for a pending prompt: AskUserQuestion → `{ questions }` (shape kept stable for back-compat); other tool on PermissionRequest → `{ approval }`; else undefined. */ -function deriveInteractivePrompt( - toolName: string | undefined, - toolInput: unknown, - eventName?: unknown -): string | undefined { - // Why: providers vary casing; any post-tool event means the question is no longer pending — don't recreate its answered card. - const normalizedEventName = normalizeHookEventName(eventName) - const isPostToolEvent = - normalizedEventName === 'post_tool_use' || normalizedEventName === 'post_tool_use_failure' - if ( - isAskUserQuestionTool(toolName) && - !isPostToolEvent && - toolInput !== undefined && - toolInput !== null - ) { - try { - return JSON.stringify(toolInput) - } catch { - // Why: circular/unserializable input from a buggy agent — a missing live card beats throwing in the hook hot path. - return undefined - } - } - if (eventName === 'PermissionRequest' && typeof toolName === 'string' && toolName.length > 0) { - try { - return JSON.stringify({ - approval: { tool: toolName, summary: summarizeApprovalInput(toolInput) } - }) - } catch { - return undefined - } - } - return undefined -} - -function readFirstString( - record: Record, - keys: readonly string[] -): string | undefined { - for (const key of keys) { - const value = readString(record, key) - if (value) { - return value - } - } - return undefined -} - -function parseJsonObjectString(value: unknown): Record | undefined { - if (typeof value !== 'string' || value.trim().length === 0) { - return undefined - } - try { - const parsed = parseAgentHookJson(value) - return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) - ? (parsed as Record) - : undefined - } catch { - return undefined - } -} - -function extractToolResponseText(toolResponse: unknown): string | undefined { - if (typeof toolResponse === 'string' && toolResponse.length > 0) { - return toolResponse - } - if (typeof toolResponse !== 'object' || toolResponse === null) { - return undefined - } - const record = toolResponse as Record - const directText = readFirstString(record, ['text_result_for_llm', 'textResultForLlm', 'text']) - if (directText) { - return directText - } - const content = record.content - if (Array.isArray(content)) { - for (const part of content) { - if (typeof part === 'object' && part !== null) { - const text = (part as Record).text - if (typeof text === 'string' && text.trim().length > 0) { - return text - } - } - } - } - return undefined -} - -const TRANSCRIPT_CHUNK_BYTES = 64 * 1024 -const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024 -const EMPTY_TRANSCRIPT_REGION = Buffer.alloc(0) -const AMP_THREAD_ID_MAX_LENGTH = 256 -const AMP_MAX_SCOPED_THREAD_CACHE_KEYS = 32 -const GROK_SESSION_CWD_MAX_LENGTH = 4096 -const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 - -function extractAssistantTextFromLine(line: string): string | undefined { - let entry: unknown - try { - entry = parseAgentHookJson(line) - } catch { - return undefined - } - if (typeof entry !== 'object' || entry === null) { - return undefined - } - const record = entry as Record - if (record.type === 'assistant.message') { - const data = record.data - if (typeof data === 'object' && data !== null) { - const text = extractAssistantContentText((data as Record).content) - if (text) { - return text - } - } - } - if ( - record.source === 'MODEL' && - record.type === 'PLANNER_RESPONSE' && - typeof record.content === 'string' && - record.content.trim().length > 0 - ) { - return record.content - } - const nestedMessage = record.message as Record | undefined - const role = - record.role ?? nestedMessage?.role ?? (record.type === 'assistant' ? 'assistant' : undefined) - if (role !== 'assistant') { - return undefined - } - const content = (nestedMessage ?? record).content - return extractAssistantContentText(content) -} - -function extractAssistantContentText(content: unknown): string | undefined { - if (typeof content === 'string' && content.trim().length > 0) { - return content - } - if (Array.isArray(content)) { - for (const part of content) { - if (typeof part === 'object' && part !== null) { - const text = (part as Record).text - if (typeof text === 'string' && text.trim().length > 0) { - return text - } - } - } - } - return undefined -} - -function extractAntigravityUserRequest(content: string): string | undefined { - const opener = '' - const startIndex = content.indexOf(opener) - const bodyStartIndex = startIndex === -1 ? -1 : startIndex + opener.length - const endIndex = bodyStartIndex === -1 ? -1 : content.indexOf('', bodyStartIndex) - const text = - bodyStartIndex === -1 || endIndex === -1 ? content : content.slice(bodyStartIndex, endIndex) - const trimmed = text.trim() - return trimmed.length > 0 ? trimmed : undefined -} - -function extractUserPromptTextFromLine(line: string): string | undefined { - let entry: unknown - try { - entry = parseAgentHookJson(line) - } catch { - return undefined - } - if (typeof entry !== 'object' || entry === null) { - return undefined - } - const record = entry as Record - if ( - (record.source === 'USER_EXPLICIT' || record.source === 'USER') && - (record.type === 'USER_INPUT' || record.type === 'REQUEST') && - typeof record.content === 'string' - ) { - return extractAntigravityUserRequest(record.content) - } - return undefined -} - -function readLastAssistantFromTranscript(transcriptPath: unknown): string | undefined { - if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { - return undefined - } - return readLastAssistantFromTranscriptOnce(transcriptPath) -} - -function readLastUserPromptFromTranscript(transcriptPath: unknown): string | undefined { - if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { - return undefined - } - return readLastTextFromTranscriptOnce(transcriptPath, extractUserPromptTextFromLine) -} - -function extractCommandCodeUserPromptFromLine(line: string): string | undefined { - let entry: unknown - try { - entry = parseAgentHookJson(line) - } catch { - return undefined - } - if (typeof entry !== 'object' || entry === null) { - return undefined - } - const record = entry as Record - return record.role === 'user' ? extractAssistantContentText(record.content) : undefined -} - -function hashInteractionKeyPart(value: string): string { - return createHash('sha256').update(value).digest('hex').slice(0, 12) -} - -// Why byte offsets: the caller's interactionKey embeds the prompt's absolute -// position, so the backward scan has to report the same offset the old -// read-everything-then-take-the-last-match pass produced. -function findLastCommandCodePromptInRegion( - region: Buffer -): { prompt: string; byteOffset: number } | undefined { - let lineEnd = region.length - for (let index = region.length - 1; index >= -1; index--) { - if (index >= 0 && region[index] !== 0x0a) { - continue - } - const lineStart = index + 1 - if (lineEnd > lineStart) { - const prompt = extractCommandCodeUserPromptFromLine( - region.subarray(lineStart, lineEnd).toString('utf8').trim() - ) - if (prompt !== undefined) { - return { prompt, byteOffset: lineStart } - } - } - lineEnd = index - } - return undefined -} - -export function readLastCommandCodeUserPromptEntryFromTranscript( - transcriptPath: unknown -): { text: string; interactionKey: string } | undefined { - if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { - return undefined - } - try { - const stats = statSync(transcriptPath) - const size = stats.size - if (size <= 0) { - return undefined - } - const fd = openSync(transcriptPath, 'r') - try { - // Why scan backward: the answer is the LAST user line, so walking up from - // EOF returns on the first hit instead of parsing every line of a - // multi-megabyte transcript on every hook event. - // Why a chunk list: carry holds a partial line, and re-concatenating it per - // block made one oversized line (a big tool result) cost O(line^2). - let carryChunks: Buffer[] = [] - let bytesRead = 0 - let scanEnd = size - while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { - const chunkSize = Math.min( - scanEnd, - TRANSCRIPT_CHUNK_BYTES, - TRANSCRIPT_MAX_SCAN_BYTES - bytesRead - ) - const position = scanEnd - chunkSize - const buffer = Buffer.alloc(chunkSize) - let filled = 0 - while (filled < chunkSize) { - const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled) - if (n === 0) { - break - } - filled += n - } - // Why bail on a short read: the file shrank under us, so the bytes above - // this block no longer line up and any stitched offset would be wrong. - if (filled < chunkSize) { - break - } - bytesRead += filled - scanEnd = position - // Why search only the new block: carry is always the run before a newline, - // so it holds none of its own. - const firstNewline = buffer.indexOf(0x0a) - // Why only at a true file start: a scan that stops on the size cap must - // discard its leading partial line, exactly as the capped read did. - const atStart = position === 0 - let completeRegion: Buffer - let regionPosition: number - if (atStart) { - completeRegion = - carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) - regionPosition = position - carryChunks = [] - } else if (firstNewline === -1) { - completeRegion = EMPTY_TRANSCRIPT_REGION - regionPosition = position - carryChunks.unshift(buffer) - } else { - const afterNewline = buffer.subarray(firstNewline + 1) - completeRegion = - carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks]) - regionPosition = position + firstNewline + 1 - carryChunks = [buffer.subarray(0, firstNewline)] - } - if (completeRegion.length > 0) { - const found = findLastCommandCodePromptInRegion(completeRegion) - if (found) { - return { - text: found.prompt, - interactionKey: [ - 'command-code-transcript', - hashInteractionKeyPart(transcriptPath), - String(regionPosition + found.byteOffset), - hashInteractionKeyPart(found.prompt) - ].join('-') - } - } - } - } - return undefined - } finally { - closeSync(fd) - } - } catch { - return undefined - } -} - -function extractCommandCodeAssistantTextFromLine(line: string): string | undefined { - let entry: unknown - try { - entry = parseAgentHookJson(line) - } catch { - return undefined - } - if (typeof entry !== 'object' || entry === null) { - return undefined - } - const record = entry as Record - if (record.role !== 'assistant') { - return undefined - } - const content = record.content - if (typeof content === 'string' && content.trim().length > 0) { - return content - } - if (Array.isArray(content)) { - const textPart = content.find( - (part) => - typeof part === 'object' && - part !== null && - (part as Record).type === 'text' && - typeof (part as Record).text === 'string' && - ((part as Record).text as string).trim().length > 0 - ) as Record | undefined - if (typeof textPart?.text === 'string') { - return textPart.text - } - } - return extractAssistantContentText(content) -} - -function readLastCommandCodeAssistantFromTranscript(transcriptPath: unknown): string | undefined { - if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { - return undefined - } - return readLastTextFromTranscriptOnce(transcriptPath, extractCommandCodeAssistantTextFromLine) -} - -function parseHookBodyPayloadRecord(body: unknown): Record | null { - if (typeof body !== 'object' || body === null) { - return null - } - const rawPayload = (body as Record).payload - const payload = - typeof rawPayload === 'string' - ? (() => { - try { - return parseAgentHookJson(rawPayload) - } catch { - return null - } - })() - : rawPayload - return typeof payload === 'object' && payload !== null - ? (payload as Record) - : null -} - -function readBoundedString( - record: Record, - keys: readonly string[], - maxLength: number -): string | undefined { - const value = readFirstString(record, keys) - return value && value.length <= maxLength ? value : undefined -} - -function readGrokHomeEnvelope(record: Record): string | undefined { - const value = readBoundedString(record, ['grokHome'], GROK_HOME_ENVELOPE_MAX_LENGTH) - if (!value || value !== value.trim() || !isAbsolute(value) || hasControlCharacter(value)) { - return undefined - } - return value -} - -function hasControlCharacter(value: string): boolean { - return Array.from(value).some((character) => { - const code = character.charCodeAt(0) - return code <= 0x1f || code === 0x7f - }) -} - -type GrokSessionMetadata = { - sessionId: string - cwd?: string - sessionsDir: string -} - -function readGrokSessionMetadata( - hookPayload: Record, - grokHome?: string -): GrokSessionMetadata | undefined { - const sessionId = readBoundedString( - hookPayload, - ['sessionId', 'session_id'], - GROK_SESSION_ID_MAX_LENGTH - ) - if (!sessionId || !isSafeGrokSessionId(sessionId)) { - return undefined - } - const cwd = readBoundedString( - hookPayload, - ['cwd', 'workspaceRoot', 'workspace_root'], - GROK_SESSION_CWD_MAX_LENGTH - ) - // Why: hook scripts report the effective per-PTY/remote Grok home; old scripts fall back to the runtime's for compatibility. - const sessionsDir = grokHome - ? join(grokHome, 'sessions') - : resolveGrokSessionsDir(process.env, homedir()) - return { sessionId, cwd, sessionsDir } -} - -function getGrokChatHistoryPath( - hookPayload: Record, - grokHome?: string -): string | undefined { - const metadata = readGrokSessionMetadata(hookPayload, grokHome) - if (!metadata) { - return undefined - } - const resolved = resolveGrokChatHistoryPathSync({ - sessionId: metadata.sessionId, - cwd: metadata.cwd ?? null, - sessionsDir: metadata.sessionsDir - }) - if (resolved) { - return resolved - } - const cached = getCachedGrokChatHistoryBySessionId(metadata.sessionsDir, metadata.sessionId) - if (cached) { - return cached - } - // Why: SessionEnd can race the last write; return a plausible on-disk candidate (short-cwd preferred) even if the file doesn't exist yet. - if (!metadata.cwd) { - return undefined - } - return ( - buildGrokChatHistoryPathCandidates({ - sessionId: metadata.sessionId, - cwd: metadata.cwd, - sessionsDir: metadata.sessionsDir - })[0] ?? undefined - ) -} - -function readLastAssistantFromGrokChatHistory( - hookPayload: Record, - grokHome?: string -): string | undefined { - const chatHistoryPath = getGrokChatHistoryPath(hookPayload, grokHome) - if (!chatHistoryPath) { - return undefined - } - return readLastAssistantFromTranscriptOnce(chatHistoryPath) -} - -export function hasPendingAgentResultText(source: AgentHookSource, body: unknown): boolean { - const envelope = - typeof body === 'object' && body !== null ? (body as Record) : null - const record = parseHookBodyPayloadRecord(body) - if (!record) { - return false - } - if (hasExplicitLastAssistantResult(record)) { - return false - } - if (source === 'copilot') { - // Why: Copilot Stop uses generic `message` as final assistant text; Grok/Antigravity use that field for status instead. - if (hasNonEmptyString(record.message)) { - return false - } - const transcriptPath = record.transcript_path ?? record.transcriptPath - return typeof transcriptPath === 'string' && transcriptPath.trim().length > 0 - } - const eventName = - envelope?.hook_event_name ?? - envelope?.hookEventName ?? - record.hook_event_name ?? - record.hookEventName - if (source === 'antigravity' && eventName === 'Stop') { - if (isAntigravityStopStillBusy(record)) { - return false - } - const transcriptPath = record.transcriptPath ?? record.transcript_path - return typeof transcriptPath === 'string' && transcriptPath.trim().length > 0 - } - const pendingGrokDiscovery = preparePendingGrokResultDiscovery(source, body) - if (pendingGrokDiscovery) { - void pendingGrokDiscovery - return true - } - return false -} - -function hasNonEmptyString(value: unknown): boolean { - return typeof value === 'string' && value.trim().length > 0 -} - -function hasExplicitLastAssistantResult(record: Record): boolean { - return ( - hasNonEmptyString(record.last_assistant_message) || - hasNonEmptyString(record.lastAssistantMessage) - ) -} - -/** Start bounded discovery only for a Grok completion that still needs result text. */ -export function preparePendingGrokResultDiscovery( - source: AgentHookSource, - body: unknown -): Promise | null { - if (source !== 'grok') { - return null - } - const envelope = - typeof body === 'object' && body !== null ? (body as Record) : null - const record = parseHookBodyPayloadRecord(body) - if (!record || hasExplicitLastAssistantResult(record)) { - return null - } - const eventName = - envelope?.hook_event_name ?? - envelope?.hookEventName ?? - record.hook_event_name ?? - record.hookEventName - if (!isGrokEvent(eventName, 'stop', 'session_end')) { - return null - } - const metadata = readGrokSessionMetadata( - record, - envelope ? readGrokHomeEnvelope(envelope) : undefined - ) - if (!metadata) { - return null - } - // Why: lets the server await discovery without moving filesystem I/O back into synchronous hook normalization. - return findGrokChatHistoryBySessionId(metadata.sessionsDir, metadata.sessionId).then( - () => undefined - ) -} - -function readLastAssistantFromTranscriptOnce(transcriptPath: string): string | undefined { - return readLastTextFromTranscriptOnce(transcriptPath, extractAssistantTextFromLine) -} - -function readLastTextFromTranscriptOnce( - transcriptPath: string, - extractLineText: (line: string) => string | undefined -): string | undefined { - try { - const stats = statSync(transcriptPath) - const size = stats.size - if (size <= 0) { - return undefined - } - const fd = openSync(transcriptPath, 'r') - try { - // Why a chunk list: carry holds a partial line, and re-joining it per block - // made one oversized line (a big tool result or pasted prompt) cost O(line^2). - let carryChunks: Buffer[] = [] - let bytesRead = 0 - let scanEnd = size - while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { - const chunkSize = Math.min(scanEnd, TRANSCRIPT_CHUNK_BYTES) - const position = scanEnd - chunkSize - const buffer = Buffer.alloc(chunkSize) - let filled = 0 - while (filled < chunkSize) { - const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled) - if (n === 0) { - break - } - filled += n - } - // Why bail on a short read: the file shrank under us, so the bytes above - // this block no longer line up with what the earlier ones assumed. - if (filled < chunkSize) { - break - } - bytesRead += filled - scanEnd = position - // Why search only the new block: carry is always the run before a newline, - // so it holds none of its own. - const firstNewline = buffer.indexOf(0x0a) - const atStart = position === 0 - let completeRegion: Buffer - if (atStart) { - completeRegion = - carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) - carryChunks = [] - } else if (firstNewline === -1) { - completeRegion = EMPTY_TRANSCRIPT_REGION - carryChunks.unshift(buffer) - } else { - const afterNewline = buffer.subarray(firstNewline + 1) - completeRegion = - carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks]) - carryChunks = [buffer.subarray(0, firstNewline)] - } - if (completeRegion.length > 0) { - const extracted = findLastExtractedTranscriptLineText( - completeRegion.toString('utf8'), - extractLineText - ) - if (extracted !== undefined) { - return extracted - } - } - } - return undefined - } finally { - closeSync(fd) - } - } catch { - return undefined - } -} - -function findLastExtractedTranscriptLineText( - text: string, - extractLineText: (line: string) => string | undefined -): string | undefined { - let lineEnd = text.length - - for (let index = text.length - 1; index >= -1; index--) { - if (index >= 0 && text.charCodeAt(index) !== 10) { - continue - } - - const line = text.slice(index + 1, lineEnd).trim() - if (line.length > 0) { - const extracted = extractLineText(line) - if (extracted !== undefined) { - return extracted - } - } - lineEnd = index - } - - return undefined -} - -function extractClaudeToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - const update: ToolSnapshot = {} - if (eventName === 'PostToolUseFailure') { - Object.assign(update, clearActiveToolFieldsUpdate()) - } else if ( - eventName === 'PreToolUse' || - eventName === 'PostToolUse' || - eventName === 'PermissionRequest' - ) { - const toolName = readString(hookPayload, 'tool_name') - Object.assign( - update, - toolUpdate( - { - toolName, - toolInput: deriveToolInputPreview(toolName, hookPayload.tool_input), - interactivePrompt: deriveInteractivePrompt(toolName, hookPayload.tool_input, eventName) - }, - { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } - ) - ) - } - if (eventName === 'PostToolUse') { - const responseText = extractToolResponseText(hookPayload.tool_response) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - if (eventName === 'PostToolUseFailure') { - const errorText = - extractToolResponseText(hookPayload.tool_response) ?? - readString(hookPayload, 'error') ?? - readString(hookPayload, 'message') - if (errorText) { - update.lastAssistantMessage = errorText - } - } - if (eventName === 'Stop') { - const direct = readString(hookPayload, 'last_assistant_message') - if (direct) { - update.lastAssistantMessage = direct - } else { - const lastFromTranscript = readLastAssistantFromTranscript(hookPayload.transcript_path) - if (lastFromTranscript) { - update.lastAssistantMessage = lastFromTranscript - } - } - } - return update -} - -function extractCodexToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if ( - eventName === 'PreToolUse' || - eventName === 'PermissionRequest' || - eventName === 'PostToolUse' - ) { - const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') - const rawInput = hookPayload.tool_input ?? hookPayload.input ?? hookPayload.arguments - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.input) ?? - deriveToolInputPreview(toolName, hookPayload.arguments) - return toolUpdate( - { - toolName, - toolInput, - interactivePrompt: deriveInteractivePrompt(toolName, rawInput, eventName) - }, - { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'input', 'arguments']) } - ) - } - if (eventName === 'Stop') { - const message = readString(hookPayload, 'last_assistant_message') - if (message) { - return { lastAssistantMessage: message } - } - } - return {} -} - -function extractGeminiToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if ( - eventName === 'BeforeTool' || - eventName === 'AfterTool' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ) { - const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.args) ?? - deriveToolInputPreview(toolName, hookPayload.input) - return toolUpdate( - { toolName, toolInput }, - { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'args', 'input']) } - ) - } - if (eventName === 'AfterAgent') { - const message = readString(hookPayload, 'prompt_response') - if (message) { - return { lastAssistantMessage: message } - } - } - return {} -} - -function readAntigravityToolCall(hookPayload: Record): { - toolName?: string - toolInputSource?: unknown -} { - const toolCall = hookPayload.toolCall - if (typeof toolCall !== 'object' || toolCall === null) { - return {} - } - const record = toolCall as Record - return { - toolName: readFirstString(record, ['name', 'toolName', 'tool_name']), - toolInputSource: record.args - } -} - -function extractAntigravityToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (eventName === 'PreToolUse' || eventName === 'PostToolUse') { - const toolCall = readAntigravityToolCall(hookPayload) - const toolName = toolCall.toolName - const toolInput = - deriveToolInputPreview(toolName, toolCall.toolInputSource) ?? - deriveFallbackToolInputPreview(toolCall.toolInputSource) - return toolUpdate( - { toolName, toolInput }, - { hasToolInputField: toolCall.toolInputSource !== undefined } - ) - } - if (eventName === 'Stop') { - if (isAntigravityStopStillBusy(hookPayload)) { - return {} - } - const message = - readString(hookPayload, 'last_assistant_message') ?? - readLastAssistantFromTranscript(hookPayload.transcriptPath ?? hookPayload.transcript_path) - if (message) { - return { lastAssistantMessage: message } - } - } - return {} -} - -function extractAmpToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (eventName === 'tool.call' || eventName === 'tool.result') { - const toolName = - readString(hookPayload, 'tool') ?? - readString(hookPayload, 'toolName') ?? - readString(hookPayload, 'name') - const toolInput = - deriveToolInputPreview(toolName, hookPayload.input) ?? - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.arguments) ?? - // Why: Amp plugin tools can have arbitrary names; fall back to obvious arg fields instead of an empty tool preview. - deriveFallbackToolInputPreview(hookPayload.input) ?? - deriveFallbackToolInputPreview(hookPayload.tool_input) ?? - deriveFallbackToolInputPreview(hookPayload.arguments) - const update: ToolSnapshot = toolUpdate( - { toolName, toolInput }, - { hasToolInputField: hasAnyOwnField(hookPayload, ['input', 'tool_input', 'arguments']) } - ) - if (eventName === 'tool.result') { - const responseText = - readFirstString(hookPayload, ['error', 'output', 'result', 'message']) ?? - extractToolResponseText(hookPayload.output) ?? - extractToolResponseText(hookPayload.result) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - return update - } - return {} -} - -/** - * Retires any cached tool fields. PermissionRequest is the only OpenCode-family event that - * carries them, and the only isNewTurnEvent boundary this family has is opencode's - * SessionStart — which a resumed session never re-emits — so nothing else resets the cache - * mid-session. Without an explicit retire, resolveToolState inherits one answered permission - * onto every later frame in the pane and the row reads a resolved command as the live tool. - */ -const OPENCODE_TOOL_FIELDS_RETIRED: ToolSnapshot = { - hasToolUpdate: true, - hasToolInputField: true -} - -function extractOpenCodeToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (eventName === 'MessagePart' && hookPayload.role === 'assistant') { - const text = readString(hookPayload, 'text') - if (text) { - return { ...OPENCODE_TOOL_FIELDS_RETIRED, lastAssistantMessage: capOpenCodeHookText(text) } - } - } - if (eventName === 'AskUserQuestion') { - // Why: OpenCode's payload is question.asked's event.properties (hook_event_name merged in); strip envelope or use tool_input, capture JSON for the card. - const toolInputSource = hasOwnField(hookPayload, 'tool_input') - ? hookPayload.tool_input - : stripHookEnvelopeKeys(hookPayload) - return { - ...OPENCODE_TOOL_FIELDS_RETIRED, - interactivePrompt: deriveInteractivePrompt('AskUserQuestion', toolInputSource) - } - } - if (eventName === 'PermissionRequest') { - // Why: the payload is permission.asked's event.properties — `permission` names what is - // being requested and `metadata`/`patterns` say which command or path it covers. Without - // them the row is a bare 'waiting' that cannot tell the user what to approve. - // The SDK types `metadata` as Record, so these keys come from the tools - // themselves: bash sends `command`, edit sends `filepath`, webfetch sends `url` (verified - // against opencode 1.18.18). `file_path`/`path` cover tools that spell it the common way. - // `diff` is deliberately absent — edit ships the whole patch and it would swamp the row. - const metadata = hookPayload.metadata - const metadataInput = - metadata !== null && typeof metadata === 'object' && !Array.isArray(metadata) - ? readFirstString(metadata as Record, [ - 'command', - 'filepath', - 'file_path', - 'path', - 'url' - ]) - : undefined - const patterns = Array.isArray(hookPayload.patterns) - ? hookPayload.patterns.filter( - (pattern): pattern is string => typeof pattern === 'string' && pattern.length > 0 - ) - : [] - return toolUpdate( - { - toolName: readString(hookPayload, 'permission'), - toolInput: metadataInput ?? (patterns.length > 0 ? patterns.join(', ') : undefined) - }, - { hasToolInputField: hasAnyOwnField(hookPayload, ['metadata', 'patterns']) } - ) - } - return OPENCODE_TOOL_FIELDS_RETIRED -} - -function extractCursorToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if ( - eventName === 'preToolUse' || - eventName === 'postToolUse' || - eventName === 'postToolUseFailure' - ) { - const update: ToolSnapshot = {} - if (eventName === 'postToolUseFailure') { - Object.assign(update, clearActiveToolFieldsUpdate()) - } else { - const toolName = readString(hookPayload, 'tool_name') - const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) - Object.assign( - update, - toolUpdate( - { toolName, toolInput }, - { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } - ) - ) - } - if (eventName === 'postToolUse') { - const responseText = extractToolResponseText(hookPayload.tool_output) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - if (eventName === 'postToolUseFailure') { - const errorText = - extractToolResponseText(hookPayload.tool_output) ?? - readString(hookPayload, 'error_message') ?? - readString(hookPayload, 'error') - if (errorText) { - update.lastAssistantMessage = errorText - } - } - return update - } - if (eventName === 'beforeShellExecution') { - const command = readString(hookPayload, 'command') - return toolUpdate( - { toolName: 'Shell', toolInput: command }, - { hasToolInputField: hasOwnField(hookPayload, 'command') } - ) - } - if (eventName === 'beforeMCPExecution') { - const toolName = readString(hookPayload, 'tool_name') ?? 'MCP' - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - readString(hookPayload, 'command') ?? - readString(hookPayload, 'url') - return toolUpdate( - { toolName, toolInput }, - { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'command', 'url']) } - ) - } - if (eventName === 'afterAgentResponse') { - const text = readString(hookPayload, 'text') - if (text) { - return { lastAssistantMessage: text } - } - } - return {} -} - -function normalizeCopilotEventName(eventName: unknown): unknown { - if (typeof eventName !== 'string') { - return eventName - } - const eventMap: Record = { - sessionStart: 'SessionStart', - sessionEnd: 'SessionEnd', - userPromptSubmitted: 'UserPromptSubmit', - userPromptSubmit: 'UserPromptSubmit', - preToolUse: 'PreToolUse', - postToolUse: 'PostToolUse', - postToolUseFailure: 'PostToolUseFailure', - subagentStart: 'SubagentStart', - subagentStop: 'SubagentStop', - preCompact: 'PreCompact', - agentStop: 'Stop', - stop: 'Stop', - errorOccurred: 'ErrorOccurred', - permissionRequest: 'PermissionRequest', - notification: 'Notification' - } - return eventMap[eventName] ?? eventName -} - -function resolveCopilotEventName( - eventName: unknown, - hookPayload: Record -): unknown { - const explicit = - eventName ?? - readFirstString(hookPayload, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) - if (explicit) { - return explicit - } - if (readFirstString(hookPayload, ['initial_prompt', 'initialPrompt'])) { - return 'SessionStart' - } - if (readString(hookPayload, 'prompt')) { - return 'UserPromptSubmit' - } - if (readFirstString(hookPayload, ['notification_type', 'notificationType'])) { - return 'Notification' - } - if ( - readFirstString(hookPayload, ['transcript_path', 'transcriptPath', 'stop_reason', 'stopReason']) - ) { - return 'Stop' - } - if (hookPayload.error || readFirstString(hookPayload, ['error_context', 'errorContext'])) { - return 'ErrorOccurred' - } - if ( - Array.isArray(hookPayload.toolCalls) || - readFirstString(hookPayload, ['tool_name', 'toolName', 'name']) - ) { - if ( - hookPayload.tool_result || - hookPayload.toolResult || - hookPayload.tool_response || - hookPayload.toolResponse - ) { - return 'PostToolUse' - } - return 'PreToolUse' - } - return eventName -} - -function readCopilotToolCall(hookPayload: Record): { - toolName?: string - toolInputSource?: unknown -} { - const toolCalls = hookPayload.toolCalls - if (!Array.isArray(toolCalls) || toolCalls.length === 0) { - return {} - } - const first = toolCalls[0] - if (typeof first !== 'object' || first === null) { - return {} - } - const record = first as Record - return { - toolName: readFirstString(record, ['name', 'toolName', 'tool_name']), - toolInputSource: - parseJsonObjectString(record.args) ?? - record.args ?? - parseJsonObjectString(record.arguments) ?? - record.arguments - } -} - -function isAskUserTool(toolName: string | undefined): boolean { - return toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuser' -} - -function extractCopilotToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - const update: ToolSnapshot = {} - if (eventName === 'PostToolUseFailure' || eventName === 'ErrorOccurred') { - Object.assign(update, clearActiveToolFieldsUpdate()) - } else if ( - eventName === 'PreToolUse' || - eventName === 'PostToolUse' || - eventName === 'PermissionRequest' - ) { - const copilotToolCall = readCopilotToolCall(hookPayload) - const toolName = - readFirstString(hookPayload, ['tool_name', 'toolName', 'name']) ?? copilotToolCall.toolName - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.toolInput) ?? - deriveToolInputPreview(toolName, hookPayload.toolArgs) ?? - deriveToolInputPreview(toolName, hookPayload.input) ?? - deriveToolInputPreview(toolName, hookPayload.arguments) ?? - deriveToolInputPreview(toolName, copilotToolCall.toolInputSource) - Object.assign( - update, - toolUpdate( - { toolName, toolInput }, - { - hasToolInputField: - hasAnyOwnField(hookPayload, [ - 'tool_input', - 'toolInput', - 'toolArgs', - 'input', - 'arguments' - ]) || copilotToolCall.toolInputSource !== undefined - } - ) - ) - if (isAskUserTool(toolName) && toolInput) { - update.lastAssistantMessage = toolInput - } - } - if (eventName === 'PostToolUse') { - const responseText = - extractToolResponseText(hookPayload.tool_result) ?? - extractToolResponseText(hookPayload.toolResult) ?? - extractToolResponseText(hookPayload.tool_response) ?? - extractToolResponseText(hookPayload.toolResponse) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - if (eventName === 'PostToolUseFailure' || eventName === 'ErrorOccurred') { - const errorText = - extractToolResponseText(hookPayload.tool_result) ?? - extractToolResponseText(hookPayload.toolResult) ?? - extractToolResponseText(hookPayload.tool_response) ?? - extractToolResponseText(hookPayload.toolResponse) ?? - readFirstString(hookPayload, ['error_message', 'errorMessage', 'error', 'message']) - if (errorText) { - update.lastAssistantMessage = errorText - } - } - if (eventName === 'Notification') { - const notificationType = readFirstString(hookPayload, ['notification_type', 'notificationType']) - if (notificationType === 'permission_prompt' || notificationType === 'elicitation_dialog') { - const message = readFirstString(hookPayload, ['message', 'body', 'text', 'title']) - if (message) { - update.lastAssistantMessage = message - } - } - } - if (eventName === 'Stop') { - const direct = readFirstString(hookPayload, [ - 'last_assistant_message', - 'lastAssistantMessage', - 'message' - ]) - if (direct) { - update.lastAssistantMessage = direct - } else { - const lastFromTranscript = readLastAssistantFromTranscript( - hookPayload.transcript_path ?? hookPayload.transcriptPath - ) - if (lastFromTranscript) { - update.lastAssistantMessage = lastFromTranscript - } else { - update.clearLastAssistantMessage = true - } - } - } - return update -} - -function extractPiToolFields( - eventName: unknown, - hookPayload: Record, - agentKind: 'pi' | 'omp' | 'prime-agent' -): ToolSnapshot { - if ( - eventName === 'tool_call' || - eventName === 'tool_execution_start' || - eventName === 'tool_execution_end' - ) { - const toolName = readString(hookPayload, 'tool_name') - const rawToolInput = hookPayload.tool_input - const toolInput = deriveToolInputPreview(toolName, rawToolInput) - // Why: OMP shares this extractor; only derive interactivePrompt for Pi so OMP ask_user_question metadata stays unchanged. - const interactivePrompt = - agentKind === 'pi' && (eventName === 'tool_call' || eventName === 'tool_execution_start') - ? deriveInteractivePrompt(toolName, rawToolInput, eventName) - : undefined - return toolUpdate( - { toolName, toolInput, interactivePrompt }, - { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } - ) - } - if (eventName === 'message_end' && hookPayload.role === 'assistant') { - const text = readString(hookPayload, 'text') - if (text) { - return { lastAssistantMessage: text } - } - } - return {} -} - -function isDroidPermissionNotification(message: string | undefined): boolean { - if (!message) { - return false - } - const lower = message.toLowerCase() - // Why: 'confirm' is excluded — it false-positives on benign messages like "task confirmed" that aren't permission prompts. - return lower.includes('permission') || lower.includes('approve') || lower.includes('approval') -} - -function isDroidIdleNotification(message: string | undefined): boolean { - if (!message) { - return false - } - const lower = message.toLowerCase() - return lower.includes('waiting for your input') || lower.includes('waiting for input') -} - -function isDroidAskUserTool(toolName: string | undefined): boolean { - if (!toolName) { - return false - } - return toolName.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuser' -} - -function readDroidToolRiskLevel(hookPayload: Record): string | undefined { - const directRisk = readString(hookPayload, 'riskLevel') ?? readString(hookPayload, 'risk_level') - if (directRisk) { - return directRisk - } - - for (const key of ['tool_input', 'input', 'arguments'] as const) { - const value = hookPayload[key] - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - continue - } - const record = value as Record - const nestedRisk = readString(record, 'riskLevel') ?? readString(record, 'risk_level') - if (nestedRisk) { - return nestedRisk - } - } - return undefined -} - -function isDroidHighRiskToolUse(hookPayload: Record): boolean { - return readDroidToolRiskLevel(hookPayload)?.trim().toLowerCase() === 'high' -} - -function extractDroidToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if ( - eventName === 'PreToolUse' || - eventName === 'PostToolUse' || - eventName === 'PermissionRequest' - ) { - const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.input) ?? - deriveToolInputPreview(toolName, hookPayload.arguments) - const update: ToolSnapshot = toolUpdate( - { toolName, toolInput }, - { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'input', 'arguments']) } - ) - if (eventName === 'PostToolUse') { - const responseText = - extractToolResponseText(hookPayload.tool_response) ?? - extractToolResponseText(hookPayload.tool_output) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - return update - } - if (eventName === 'Stop') { - const direct = readString(hookPayload, 'last_assistant_message') - if (direct) { - return { lastAssistantMessage: direct } - } - const fromTranscript = readLastAssistantFromTranscript(hookPayload.transcript_path) - if (fromTranscript) { - return { lastAssistantMessage: fromTranscript } - } - } - return {} -} - -function extractCommandCodeToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (eventName === 'PreToolUse' || eventName === 'PostToolUse') { - const toolName = - readString(hookPayload, 'tool_name') ?? - readString(hookPayload, 'toolName') ?? - readString(hookPayload, 'tool_display_name') - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveFallbackToolInputPreview(hookPayload.tool_input) - const update: ToolSnapshot = toolUpdate( - { toolName, toolInput }, - { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } - ) - if (eventName === 'PostToolUse') { - const responseText = - extractToolResponseText(hookPayload.tool_response) ?? - extractToolResponseText(hookPayload.tool_output) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - return update - } - if (eventName === 'Stop') { - const direct = readString(hookPayload, 'last_assistant_message') - if (direct) { - return { lastAssistantMessage: direct } - } - const fromTranscript = readLastCommandCodeAssistantFromTranscript( - hookPayload.transcript_path ?? hookPayload.transcriptPath - ) - if (fromTranscript) { - return { lastAssistantMessage: fromTranscript } - } - } - return {} -} - -function normalizeHookEventName(value: unknown): string { - if (typeof value !== 'string') { - return '' - } - return value - .trim() - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .replace(/[-\s]+/g, '_') - .toLowerCase() -} - -function isGrokEvent(eventName: unknown, ...expected: readonly string[]): boolean { - const normalized = normalizeHookEventName(eventName) - return expected.includes(normalized) -} - -function extractGrokToolFields( - eventName: unknown, - hookPayload: Record, - grokHome?: string -): ToolSnapshot { - if (isGrokEvent(eventName, 'pre_tool_use', 'post_tool_use', 'post_tool_use_failure')) { - const update: ToolSnapshot = {} - if (isGrokEvent(eventName, 'post_tool_use_failure')) { - Object.assign(update, clearActiveToolFieldsUpdate()) - } else { - const toolName = - readString(hookPayload, 'toolName') ?? - readString(hookPayload, 'tool_name') ?? - readString(hookPayload, 'name') - const rawInput = - hookPayload.toolInput ?? - hookPayload.tool_input ?? - hookPayload.input ?? - hookPayload.arguments - const toolInput = - deriveToolInputPreview(toolName, rawInput) ?? deriveFallbackToolInputPreview(rawInput) - // Why: Grok's ask_user_question is auto-allowed via PreToolUse, not PermissionRequest; capture full payload for the live card. - const interactivePrompt = deriveInteractivePrompt(toolName, rawInput, eventName) - Object.assign( - update, - toolUpdate( - { toolName, toolInput, interactivePrompt }, - { - hasToolInputField: hasAnyOwnField(hookPayload, [ - 'toolInput', - 'tool_input', - 'input', - 'arguments' - ]) - } - ) - ) - } - if (isGrokEvent(eventName, 'post_tool_use', 'post_tool_use_failure')) { - const responseText = - extractToolResponseText(hookPayload.toolResponse) ?? - extractToolResponseText(hookPayload.tool_response) ?? - extractToolResponseText(hookPayload.toolOutput) ?? - extractToolResponseText(hookPayload.tool_output) ?? - readString(hookPayload, 'error') ?? - readString(hookPayload, 'message') - if (responseText) { - update.lastAssistantMessage = responseText - } - } - return update - } - if (isGrokEvent(eventName, 'stop', 'session_end', 'stop_failure')) { - const direct = - readString(hookPayload, 'lastAssistantMessage') ?? - readString(hookPayload, 'last_assistant_message') - if (direct) { - return { lastAssistantMessage: direct } - } - const fromTranscript = readLastAssistantFromTranscript( - hookPayload.transcriptPath ?? hookPayload.transcript_path - ) - if (fromTranscript) { - return { lastAssistantMessage: fromTranscript } - } - const fromChatHistory = readLastAssistantFromGrokChatHistory(hookPayload, grokHome) - if (fromChatHistory) { - return { lastAssistantMessage: fromChatHistory } - } - } - return {} -} - -function extractHermesToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if ( - eventName === 'pre_tool_call' || - eventName === 'post_tool_call' || - eventName === 'pre_approval_request' || - eventName === 'post_approval_response' - ) { - const toolName = - readString(hookPayload, 'tool_name') ?? - readString(hookPayload, 'name') ?? - (eventName === 'pre_approval_request' || eventName === 'post_approval_response' - ? 'approval' - : undefined) - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.args) ?? - deriveToolInputPreview(toolName, hookPayload.input) ?? - // Why: Hermes has many tool names; fall back to obvious arg fields so a new name still shows a value, not a blank row. - deriveFallbackToolInputPreview(hookPayload.tool_input) ?? - deriveFallbackToolInputPreview(hookPayload.args) ?? - deriveFallbackToolInputPreview(hookPayload.input) ?? - readString(hookPayload, 'command') ?? - readString(hookPayload, 'description') - const update: ToolSnapshot = toolUpdate( - { toolName, toolInput }, - { - hasToolInputField: hasAnyOwnField(hookPayload, [ - 'tool_input', - 'args', - 'input', - 'command', - 'description' - ]) - } - ) - if (eventName === 'post_tool_call') { - const responseText = - extractToolResponseText(hookPayload.result) ?? - extractToolResponseText(hookPayload.tool_response) ?? - extractToolResponseText(hookPayload.output) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - return update - } - if (eventName === 'post_llm_call') { - const message = - readString(hookPayload, 'last_assistant_message') ?? - readString(hookPayload, 'assistant_response') ?? - readString(hookPayload, 'response_text') - if (message) { - return { lastAssistantMessage: message } - } - } - return {} -} - -function isGrokPermissionNotification(message: string | undefined): boolean { - if (!message) { - return false - } - const lower = message.toLowerCase() - return ( - lower.includes('permission') || - lower.includes('approval') || - lower.includes('approve') || - lower.includes('allow') || - lower.includes('confirm') || - lower.includes('needs your') || - lower.includes('requires your') || - lower.includes('feedback') || - lower.includes('clarify') || - lower.includes('question') - ) -} - -function getGrokNotificationType(hookPayload: Record): string | undefined { - return ( - readString(hookPayload, 'notificationType') ?? - readString(hookPayload, 'notification_type') ?? - readString(hookPayload, 'type') - ) -} - -function isGrokRoutinePermissionPromptNotification( - notificationType: string | undefined, - message: string | undefined, - level: string | undefined -): boolean { - // Why: Grok emits this before each tool even under bypassPermissions; PreToolUse already covers progress. - return ( - isGrokEvent(notificationType, 'permission_prompt') && - message?.trim().toLowerCase() === 'tool permission requested' && - (!level || level.trim().toLowerCase() === 'info') - ) -} - -function isGrokIdleNotification(message: string | undefined): boolean { - if (!message) { - return false - } - const lower = message.toLowerCase() - return ( - lower.includes('type your message') || - lower.includes('enter send') || - lower.includes('shift-tab normal') || - lower.includes('ask a side question') - ) -} - -/** The per-provider answer to "is this event a user-initiated new turn?". Exported so the - * observation stamp reuses it instead of minting a second list of event-name literals. */ -export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { - // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of falling through to false. - switch (source) { - case 'claude': - // Why: SessionStart lands an idle row (STA-3386) and must also drop stale - // tool/prompt caches left by the pane's previous session. - return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' - case 'kimi': - // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. - return eventName === 'UserPromptSubmit' - case 'codex': - return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' - case 'gemini': - return eventName === 'BeforeAgent' - case 'antigravity': - return eventName === 'PreInvocation' - case 'amp': - return eventName === 'agent.start' - case 'opencode': - return eventName === 'SessionStart' - case 'mimo-code': - return false - case 'cursor': - return eventName === 'beforeSubmitPrompt' || eventName === 'sessionStart' - case 'pi': - case 'omp': - case 'prime-agent': - return eventName === 'before_agent_start' - case 'droid': - return eventName === 'UserPromptSubmit' - case 'command-code': - return false - case 'grok': - return isGrokEvent(eventName, 'user_prompt_submit') - case 'copilot': { - const normalizedEventName = normalizeCopilotEventName(eventName) - return normalizedEventName === 'SessionStart' || normalizedEventName === 'UserPromptSubmit' - } - case 'hermes': - return eventName === 'pre_llm_call' || eventName === 'on_session_start' - case 'devin': - // Why: SessionStart is handled by an early return in normalizeDevinEvent, so UserPromptSubmit is Devin's real new-turn boundary here. - return eventName === 'UserPromptSubmit' - } -} - -function hasExplicitUserPrompt( - source: AgentHookSource, - eventName: unknown, - extractedPrompt: ExtractedPromptText, - resolvedPromptText: string, - hasTranscriptPromptEvidence = false -): boolean { - if ( - source === 'command-code' && - (eventName === 'PreToolUse' || eventName === 'Stop') && - (extractedPrompt.source !== 'message' || hasTranscriptPromptEvidence) && - resolvedPromptText.trim().length > 0 - ) { - // Why: Command Code exposes the submitted prompt via its transcript, not direct hook fields; treat the transcript-backed prompt as explicit so telemetry covers real turns. - return true - } - if ( - source === 'antigravity' && - isNewTurnEvent(source, eventName) && - resolvedPromptText.trim().length > 0 - ) { - return true - } - if (extractedPrompt.source === 'role_user_text') { - return (source === 'opencode' || source === 'mimo-code') && eventName === 'MessagePart' - } - if (extractedPrompt.text.length === 0) { - return false - } - // Why: harness-injected turns aren't a user submit (no prompt-sent telemetry or permission stickiness); match only KNOWN tags so a real `` prompt still counts and survives interrupt recovery. - if (isKnownHarnessInjectedUserTurnText(extractedPrompt.text)) { - return false - } - // Why: bare `message` fields often carry permission/status copy — may update visible status prompts but aren't proof of a user submit. - if (extractedPrompt.source === 'message') { - return false - } - if ( - extractedPrompt.source === 'user_prompt' || - extractedPrompt.source === 'userPrompt' || - extractedPrompt.source === 'user_message' - ) { - return isNewTurnEvent(source, eventName) - } - return isNewTurnEvent(source, eventName) -} - -function extractToolFields( - source: AgentHookSource, - eventName: unknown, - hookPayload: Record, - options?: { grokHome?: string } -): ToolSnapshot { - // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of silently routing through OpenCode's extractor. - switch (source) { - case 'claude': - // Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim. - // falls through - case 'kimi': - return extractClaudeToolFields(eventName, hookPayload) - case 'codex': - return extractCodexToolFields(eventName, hookPayload) - case 'gemini': - return extractGeminiToolFields(eventName, hookPayload) - case 'antigravity': - return extractAntigravityToolFields(eventName, hookPayload) - case 'amp': - return extractAmpToolFields(eventName, hookPayload) - case 'opencode': - case 'mimo-code': - return extractOpenCodeToolFields(eventName, hookPayload) - case 'cursor': - return extractCursorToolFields(eventName, hookPayload) - case 'pi': - case 'omp': - case 'prime-agent': - return extractPiToolFields(eventName, hookPayload, source) - case 'droid': - return extractDroidToolFields(eventName, hookPayload) - case 'command-code': - return extractCommandCodeToolFields(eventName, hookPayload) - case 'grok': - return extractGrokToolFields(eventName, hookPayload, options?.grokHome) - case 'copilot': - return extractCopilotToolFields(normalizeCopilotEventName(eventName), hookPayload) - case 'hermes': - return extractHermesToolFields(eventName, hookPayload) - case 'devin': - return extractClaudeToolFields(eventName, hookPayload) - } -} - -function getOrCreateClaudeSubagentRoster( - state: HookListenerState, - paneKey: string -): ClaudeSubagentRoster { - let roster = state.claudeSubagentRosterByPaneKey.get(paneKey) - if (!roster) { - roster = new Map() - state.claudeSubagentRosterByPaneKey.set(paneKey, roster) - } - return roster -} - -function updateClaudeRunningNonAgentTask( - state: HookListenerState, - paneKey: string, - hasRunningNonAgentTask: boolean, - /** Lead-turn property. Pass `false` from any non-lead fold: an interrupt clears the gate even when - * the inventory positively reports a running shell, which is a live-shell judgement no new call - * site may inherit by copying this signature. */ - interrupted: boolean -): void { - if (hasRunningNonAgentTask && !interrupted) { - state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) - } else { - state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - } -} - -type ClaudePaneStatusResolution = { - stateName: AgentStatusState - workingMode?: AgentWorkingMode -} - -function resolveClaudePaneStatus( - state: HookListenerState, - paneKey: string, - lead: Pick -): ClaudePaneStatusResolution { - if (lead.state !== 'done') { - return { stateName: lead.state } - } - const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) - 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. */ -function normalizeClaudeSubagentLifecycleEvent( - state: HookListenerState, - eventName: 'SubagentStart' | 'SubagentStop' | 'TeammateIdle', - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const lifecycleField = eventName === 'TeammateIdle' ? 'teammate_name' : 'agent_id' - const lifecycleId = readString(hookPayload, lifecycleField) - if (!lifecycleId) { - return null - } - const cachedLead = state.claudeLeadStateByPaneKey.get(paneKey) - const ownsUnbackedWait = - cachedLead?.state === 'waiting' && - cachedLead.stateBeforeWait === undefined && - cachedLead.waitingAgentId !== undefined && - (eventName === 'TeammateIdle' - ? claudeTeammateIdMatchesName(cachedLead.waitingAgentId, lifecycleId) - : cachedLead.waitingAgentId === lifecycleId) - const hasCachedLeadEvidence = cachedLead !== undefined && !ownsUnbackedWait - let roster = state.claudeSubagentRosterByPaneKey.get(paneKey) - let endedChildWork = false - let endedRuntimeChildWork = false - if (eventName === 'TeammateIdle') { - const teammateName = lifecycleId - // Why: on claude 2.1.21x teammates are turn-based — TeammateIdle means "turn over, awaiting mail", not finished. The row parks as idle (confirmed teammate) instead of leaving, so the sidebar keeps showing resumable children. - if (roster) { - let wasWorking = false - for (const [id, tracked] of roster) { - if (tracked.state === 'working' && claudeTeammateIdMatchesName(id, teammateName)) { - wasWorking = true - endedRuntimeChildWork ||= tracked.restoredFromSnapshot !== true - } - } - idleClaudeTeammateByName(roster, teammateName) - endedChildWork = wasWorking - } - clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => - claudeTeammateIdMatchesName(waitingAgentId, teammateName) - ) - } else { - const agentId = lifecycleId - if (eventName === 'SubagentStart') { - roster = getOrCreateClaudeSubagentRoster(state, paneKey) - upsertWorkingClaudeSubagent( - roster, - agentId, - { agentType: readString(hookPayload, 'agent_type') }, - Date.now() - ) - } else { - if (roster) { - const tracked = roster.get(agentId) - const wasWorking = tracked?.state === 'working' - endedRuntimeChildWork = wasWorking && tracked.restoredFromSnapshot !== true - // Why: one-shot stops are true finishes (row removed); teammate-shaped stops are turn ends on 2.1.21x — the row parks idle and a later SubagentStart revives it. - stopClaudeSubagent(roster, agentId) - endedChildWork = wasWorking && roster.get(agentId)?.state !== 'working' - } - // Why: a blocked child that dies without another tool event would pin its permission/question wait on the pane forever — nothing else references that agent again. - clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => waitingAgentId === agentId) - } - } - const workingChildEvidence = claudeRosterHasRuntimeWorkingSubagent(roster) - const hasUnconfirmedChild = claudeRosterHasRestoredSnapshotSubagent(roster) - const hasConfirmedDoneGate = - cachedLead?.state === 'done' && - cachedLead.interrupted !== true && - (state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - state.claudeActiveSessionCronPaneKeys.has(paneKey)) - const restoredOnlyDoneGate = - cachedLead?.state === 'done' && !hasConfirmedDoneGate && hasUnconfirmedChild - if (roster?.size === 0) { - state.claudeSubagentRosterByPaneKey.delete(paneKey) - } - if ( - !workingChildEvidence && - (restoredOnlyDoneGate || - (endedChildWork && !hasCachedLeadEvidence && (hasUnconfirmedChild || !endedRuntimeChildWork))) - ) { - // Why: a restored-only ending proves no lead boundary, and an unmatched restored sibling proves no current liveness; persist the roster transition without publishing fresh work or completion. - state.claudeUnconfirmedRestoredStatusPaneKeys.add(paneKey) - } - return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload, { - workingChildEvidence, - endedChildWork, - endedRuntimeChildWork - }) -} - -/** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C with a missed Stop); else a later child lifecycle event resurrects the cancelled pane. */ -export function markClaudeLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { - state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done', interrupted: true }) - state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - state.claudeActiveSessionCronPaneKeys.delete(paneKey) -} - -/** Rebuild a pane's working roster from a persisted snapshot; live activity confirms a seed, a complete task inventory may reap an unconfirmed one whose finish hook arrived while Orca was offline. */ -export function seedClaudeSubagentRosterFromSnapshots( - state: HookListenerState, - paneKey: string, - snapshots: readonly AgentSubagentSnapshot[] -): void { - if (snapshots.length === 0 || state.claudeSubagentRosterByPaneKey.has(paneKey)) { - return - } - const roster = getOrCreateClaudeSubagentRoster(state, paneKey) - for (const snapshot of snapshots) { - // Why: idle-teammate liveness can't be proven across a restart (its TeammateIdle confirmation is gone); only working seeds restore, and a live teammate re-earns its row via SubagentStart. - if (snapshot.state !== 'working') { - continue - } - roster.set(snapshot.id, { - state: 'working', - startedAt: snapshot.startedAt, - agentType: snapshot.agentType, - description: snapshot.description, - // Why: the seed can be a phantom (child finished while Orca was down, SubagentStop lost); let a PRESENT background_tasks list omitting the id remove it, not gate the pane 'working' forever. - backgroundTasksAuthoritative: true, - // Why: an idle parent never emits that list, so the inventory reap alone can strand the seed; mark it for the liveness reap below. - restoredFromSnapshot: true - }) - } -} - -export function seedClaudeLeadTurnFromPersistedStatus( - state: HookListenerState, - paneKey: string, - status: Pick, - options: { childOnlyBoundary: boolean } -): void { - if (options.childOnlyBoundary && status.payload.agentType === 'claude') { - state.claudeLeadStateByPaneKey.set(paneKey, { - state: 'done', - ...(status.payload.interrupted === true ? { interrupted: true } : {}), - ...(status.payload.turnCompletedAt !== undefined - ? { turnCompletedAt: status.payload.turnCompletedAt } - : {}) - }) - if (status.payload.prompt) { - state.lastPromptByPaneKey.set(paneKey, status.payload.prompt) - } - if (status.payload.lastAssistantMessage) { - state.lastToolByPaneKey.set(paneKey, { - lastAssistantMessage: status.payload.lastAssistantMessage - }) - } - } -} - -/** Reap this pane's unconfirmed restored seeds because no live agent process backs - * the pane any more (its PTY died while Orca was down, so no finish hook could - * arrive). Callers must have proven the pane is LOCAL-launched — a remote/SSH - * agent runs on the far host and can never appear in a local process index. - * Returns whether the roster changed. */ -export function reapRestoredClaudeSubagentsForDeadPane( - state: HookListenerState, - paneKey: string -): boolean { - const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) - if (!roster || !reapUnconfirmedRestoredClaudeSubagents(roster)) { - return false - } - if (roster.size === 0) { - state.claudeSubagentRosterByPaneKey.delete(paneKey) - } - return true -} - -/** Drop a child-owned waiting state when the child stops/idles, restoring the displaced lead state. */ -function clearClaudePendingWaitForAgent( - state: HookListenerState, - paneKey: string, - ownsWait: (waitingAgentId: string) => boolean -): void { - const lead = state.claudeLeadStateByPaneKey.get(paneKey) - if (lead?.state !== 'waiting' || !lead.waitingAgentId || !ownsWait(lead.waitingAgentId)) { - return - } - state.claudeLeadStateByPaneKey.set(paneKey, lead.stateBeforeWait ?? { state: 'working' }) - const previousTool = state.lastToolByPaneKey.get(paneKey) - state.lastToolByPaneKey.set( - paneKey, - previousTool?.lastAssistantMessage - ? { lastAssistantMessage: previousTool.lastAssistantMessage } - : {} - ) -} - -/** Clear an AskUserQuestion wait after the answer is typed (answering emits no hook event; the caller infers it from the submit keystroke). Restores the stashed pre-wait lead state or 'working', drops the cached card, and returns the pane state to emit (gated up to 'working' while children run). */ -export function clearClaudeAnsweredQuestionWait( - state: HookListenerState, - paneKey: string -): Pick & { - workingMode?: AgentWorkingMode -} { - const lead = state.claudeLeadStateByPaneKey.get(paneKey) - const restored = - lead?.state === 'waiting' - ? (lead.stateBeforeWait ?? { state: 'working' as const }) - : { state: 'working' as const } - state.claudeLeadStateByPaneKey.set(paneKey, { ...restored }) - const previousTool = state.lastToolByPaneKey.get(paneKey) - state.lastToolByPaneKey.set( - paneKey, - previousTool?.lastAssistantMessage - ? { lastAssistantMessage: previousTool.lastAssistantMessage } - : {} - ) - const resolved = resolveClaudePaneStatus(state, paneKey, restored) - return resolved.stateName === restored.state && resolved.workingMode === undefined - ? restored - : { - state: resolved.stateName, - ...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}), - ...(restored.interrupted ? { interrupted: true as const } : {}), - ...(restored.turnCompletedAt !== undefined - ? { turnCompletedAt: restored.turnCompletedAt } - : {}) - } -} - -/** Re-emit the cached lead state without touching its tool/prompt caches; child churn and parallel completions must not dismiss live cards. */ -function buildClaudeCachedLeadStatusPayload( - state: HookListenerState, - eventName: unknown, - paneKey: string, - hookPayload: Record, - evidence: { - workingChildEvidence?: boolean - endedChildWork?: boolean - endedRuntimeChildWork?: boolean - } = {} -): ParsedAgentStatusPayload | null { - const lead = state.claudeLeadStateByPaneKey.get(paneKey) - let leadState = lead?.state - if (!leadState) { - if (evidence.workingChildEvidence || evidence.endedRuntimeChildWork) { - // Why: ending a current-runtime child wakes its parent; only a cached lead boundary can prove the whole pane completed. - leadState = 'working' - } else if (evidence.endedChildWork) { - // Why: a restored child ending proves only that child ended; persist its roster transition as unconfirmed working, never as lead completion. - leadState = 'working' - } else { - return null - } - } - return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, { - ...resolveClaudePaneStatus(state, paneKey, { - state: leadState, - interrupted: lead?.interrupted - }), - updateToolSnapshot: false, - interrupted: lead?.interrupted, - // Why: draining the last background child is this turn's all-clear; the stamp lets a consumer pair it with the announcement already sent. - turnCompletedAt: lead?.turnCompletedAt - }) -} - -/** Lead events that may re-anchor a pane's owning session. Allow-list, not a deny-list: a payload we - * can't attribute (unknown name, child event missing its agent_id) must void nothing. */ -const CLAUDE_SESSION_OWNER_EVENTS: ReadonlySet = new Set([ - 'SessionStart', - 'UserPromptSubmit', - 'Stop', - 'StopFailure', - 'PreToolUse', - 'PostToolUse', - 'PostToolUseFailure', - 'PermissionRequest' -]) - -/** A pane whose `session_id` changed is running a different conversation, so claims the previous one - * owned are void — the hook-independent backstop for /clear, relaunch and resume, which emit no - * terminating hook (SessionEnd covers about a third of exit paths). - * - * Voids only what the replaced session provably owned. Deliberately NOT voided: - * - `claudeRunningNonAgentTaskPaneKeys`: a background shell is an OS process that survives /clear, - * and the previous inventory is positive evidence it was running. Only a fresh inventory or a - * certified process death may retire it. - * - `confirmedTeammate` roster rows: persistent in-process teammates a lead replacement can't end. - * - `claudeLeadStateByPaneKey`: the caller's own fold overwrites it anyway. */ -function voidClaimsOfReplacedClaudeSession( - state: HookListenerState, - eventName: unknown, - eventAgentId: string | undefined, - paneKey: string, - hookPayload: Record -): void { - if ( - eventAgentId !== undefined || - typeof eventName !== 'string' || - !CLAUDE_SESSION_OWNER_EVENTS.has(eventName) - ) { - return - } - // Compact/unknown SessionStart events are intentionally ignored by the status fold; do not let - // them advance the owner anchor or the next real lead event will miss the replacement. - if ( - eventName === 'SessionStart' && - hookPayload['source'] !== 'startup' && - hookPayload['source'] !== 'resume' && - hookPayload['source'] !== 'clear' - ) { - return - } - const sessionId = readString(hookPayload, 'session_id') - if (!sessionId) { - return - } - const previousOwner = state.claudeSessionOwnerByPaneKey.get(paneKey) - state.claudeSessionOwnerByPaneKey.set(paneKey, sessionId) - if (previousOwner === undefined || previousOwner === sessionId) { - return - } - // Why: a compact restart mints a SessionStart mid-turn under the same conversation; the existing - // handler already fails closed on non-idle sources, and this must not undercut it. No other - // allow-listed event carries a compact `trigger`, so SessionStart is the whole guard — if a - // compact event is ever added to CLAUDE_SESSION_OWNER_EVENTS, re-derive one for it deliberately. - if (eventName === 'SessionStart') { - return - } - state.claudeActiveSessionCronPaneKeys.delete(paneKey) - const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) - if (!roster) { - return - } - for (const [id, tracked] of roster) { - if (tracked.confirmedTeammate !== true) { - roster.delete(id) - } - } - if (roster.size === 0) { - state.claudeSubagentRosterByPaneKey.delete(paneKey) - } -} - -function normalizeClaudeEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const eventAgentId = readString(hookPayload, 'agent_id') - if ( - eventName === 'SubagentStart' || - eventName === 'SubagentStop' || - eventName === 'TeammateIdle' - ) { - return normalizeClaudeSubagentLifecycleEvent(state, eventName, paneKey, hookPayload) - } - voidClaimsOfReplacedClaudeSession(state, eventName, eventAgentId, paneKey, hookPayload) - if (eventName === 'SessionStart') { - // Why: SessionStart is the only signal a resumed session emits before its first prompt - // (STA-3386). Land it as a session-boundary 'done' row: 'working' would show a phantom - // spinner on an idle TUI (why Devin/Pi/Grok drop the event), and the sessionBoundary - // flag keeps completion-reactive consumers (notifications, automation runs) out of it. - const sessionStartSource = hookPayload['source'] - if ( - eventAgentId !== undefined || - (sessionStartSource !== 'startup' && - sessionStartSource !== 'resume' && - sessionStartSource !== 'clear') - ) { - // Why: allowlist idle boundaries and fail closed — a compact restart (or any unknown - // source) fires mid-turn, and a child-attributed SessionStart must not flip the lead's - // live turn to an idle row. - return null - } - // Why: a new process owns the pane; stale children/tasks/crons must not gate the - // fresh session's idle row back up to 'working' (same reset Codex does on SessionStart). - state.claudeSubagentRosterByPaneKey.delete(paneKey) - state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - state.claudeActiveSessionCronPaneKeys.delete(paneKey) - state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done' }) - return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { - stateName: 'done', - updateToolSnapshot: true, - sessionBoundary: true - }) - } - const previousLead = state.claudeLeadStateByPaneKey.get(paneKey) - // Why: only a turn boundary may declare an interrupt or carry a prior one forward; any other event starts a fresh turn and drops it. - const isTurnBoundary = eventName === 'Stop' || eventName === 'StopFailure' - const interrupted = - isTurnBoundary && - ((eventAgentId === undefined && hookPayload['is_interrupt'] === true) || - previousLead?.interrupted === true) - ? true - : undefined - const backgroundTasks = readClaudeBackgroundAgentTasks(hookPayload) - const sessionCrons = hookPayload['session_crons'] - const sessionCronInventoryPresent = Array.isArray(sessionCrons) - const hasActiveSessionCron = sessionCronInventoryPresent && sessionCrons.length > 0 - - if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { - return null - } - - // Why: Claude normally emits PreToolUse while AskUserQuestion is blocked; newer builds can also report it as PermissionRequest. - // Treat the PreToolUse as waiting so the sidebar shows amber attention, not a spinner that decays to grey. Mirrors normalizeKimiEvent. - const eventToolName = readString(hookPayload, 'tool_name') - const isAskUserQuestionWait = - (eventName === 'PreToolUse' || eventName === 'PermissionRequest') && - isAskUserQuestionTool(eventToolName) - const isAskUserQuestion = eventName === 'PreToolUse' && isAskUserQuestionWait - // Why: a manual /compact swallows the turn boundary — it ends at an idle prompt and emits no - // Stop, so PostCompact is the pane's only clearing signal (STA-2915). An auto compact runs INSIDE - // a turn that resumes and emits its own Stop, so it must claim nothing. PreCompact fires before - // the compact is validated (an aborted compact emits it alone), so it is neither registered nor - // mapped — see claude-compact-completion.ts. - const isManualCompactCompletion = eventName === 'PostCompact' && hookPayload.trigger === 'manual' - const reportedStateName = - eventName === 'UserPromptSubmit' || - eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure' || - (eventName === 'PreToolUse' && !isAskUserQuestion) - ? 'working' - : eventName === 'PermissionRequest' || isAskUserQuestion - ? 'waiting' - : isTurnBoundary || isManualCompactCompletion - ? 'done' - : null - - if (!reportedStateName) { - return null - } - if (backgroundTasks.present && eventAgentId === undefined) { - updateClaudeRunningNonAgentTask( - state, - paneKey, - backgroundTasks.hasRunningNonAgentTask, - interrupted === true - ) - } - if (sessionCronInventoryPresent && eventAgentId === undefined) { - if (hasActiveSessionCron && interrupted !== true) { - state.claudeActiveSessionCronPaneKeys.add(paneKey) - } else { - state.claudeActiveSessionCronPaneKeys.delete(paneKey) - } - } else if (eventAgentId === undefined && isTurnBoundary && backgroundTasks.present) { - // Why: current Claude may omit an empty cron inventory while still emitting background_tasks. - state.claudeActiveSessionCronPaneKeys.delete(paneKey) - } - - const eventToolUseId = readFirstString(hookPayload, ['tool_use_id', 'toolUseId']) - const previousTool = state.lastToolByPaneKey.get(paneKey) - const isParallelSiblingCompletionDuringQuestion = - eventAgentId === undefined && - previousLead?.state === 'waiting' && - isAskUserQuestionTool(previousTool?.toolName) && - (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && - previousLead.waitingToolUseId !== undefined && - eventToolUseId !== undefined && - eventToolUseId !== previousLead.waitingToolUseId - if (isParallelSiblingCompletionDuringQuestion) { - return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) - } - - // Why: subagent/teammate events carry `agent_id` (lead's don't); child tool activity keeps its row live but must not become the lead's state or overwrite its tool/prompt caches (a live card would vanish). - // Two exceptions take the full path below: waiting-inducing events (a child needs human attention on this pane) and the blocked child's own next tool event (approval granted — clear the wait as for the lead). - const isWaitingInducing = reportedStateName === 'waiting' - const subagentOriginId = - !isWaitingInducing && - (eventName === 'PreToolUse' || - eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure') - ? eventAgentId - : undefined - if (eventAgentId && (subagentOriginId || isWaitingInducing)) { - upsertWorkingClaudeSubagent( - getOrCreateClaudeSubagentRoster(state, paneKey), - eventAgentId, - { agentType: readString(hookPayload, 'agent_type') }, - Date.now() - ) - } - if (subagentOriginId) { - const lead = state.claudeLeadStateByPaneKey.get(paneKey) - if (lead?.state !== 'waiting' || lead.waitingAgentId !== subagentOriginId) { - return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload, { - workingChildEvidence: true - }) - } - const isParallelSiblingCompletionDuringChildQuestion = - (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && - lead.waitingToolUseId !== undefined && - eventToolUseId !== undefined && - eventToolUseId !== lead.waitingToolUseId - if (isParallelSiblingCompletionDuringChildQuestion) { - return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload, { - workingChildEvidence: true - }) - } - // Why: approval granted — update the tool snapshot (drop the pending card) as the lead's own next tool event would. - // Restore the stashed lead state, not this child's 'working': the lead may already be done, and the done-gate never upgrades working back to done once the roster drains. - const restored = lead.stateBeforeWait ?? { state: 'working' as const } - state.claudeLeadStateByPaneKey.set(paneKey, restored) - return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { - ...resolveClaudePaneStatus(state, paneKey, restored), - updateToolSnapshot: true, - interrupted: restored.interrupted, - turnCompletedAt: restored.turnCompletedAt - }) - } - - // Why: lead events never carry agent_id; even a child missed by lifecycle tracking cannot own the lead turn or its background-work evidence. - if (eventAgentId && !isWaitingInducing) { - return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload, { - workingChildEvidence: claudeRosterHasRuntimeWorkingSubagent( - state.claudeSubagentRosterByPaneKey.get(paneKey) - ) - }) - } - - if (isTurnBoundary && eventAgentId === undefined) { - // Why: background_tasks is trusted only where unambiguous (see foldClaudeBackgroundTasksIntoRoster) — teammates report "running" here even while idle. - // Older Claude builds without the field keep the incrementally tracked roster. - if (backgroundTasks.present) { - foldClaudeBackgroundTasksIntoRoster( - getOrCreateClaudeSubagentRoster(state, paneKey), - backgroundTasks.tasks, - Date.now(), - { inventoryComplete: !backgroundTasks.truncated } - ) - } - } - // Why: a child-induced wait displaces the lead state; stash it so clearing restores reality (lead may be done). A 2nd child wait carries the ORIGINAL stash, not the intermediate waiting state. - const stateBeforeWait = - isWaitingInducing && eventAgentId && previousLead - ? previousLead.state === 'waiting' - ? previousLead.stateBeforeWait - : { - state: previousLead.state, - ...(previousLead.interrupted ? { interrupted: true as const } : {}), - // Why: a child's permission pause displaces an already-finished lead; keep the end time so the later drain is still that turn's tail. - ...(previousLead.turnCompletedAt !== undefined - ? { turnCompletedAt: previousLead.turnCompletedAt } - : {}) - } - : undefined - const waitingToolUseId = eventToolUseId ?? previousLead?.waitingToolUseId - - if (interrupted && eventAgentId === undefined) { - state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - state.claudeActiveSessionCronPaneKeys.delete(paneKey) - } - - if (isManualCompactCompletion) { - // Why: a manual /compact only ever completes at an idle prompt, so a child that exists ONLY as - // a disk snapshot has nothing live behind it and must not keep the pane spinning — that - // restored child is what holds the stuck row STA-2915 actually reports. Everything else the - // done-gate consults is live evidence (a child observed in this runtime, an unclassifiable - // running background task, a registered session cron) and still holds the pane. - const restoredRoster = state.claudeSubagentRosterByPaneKey.get(paneKey) - if ( - restoredRoster && - reapUnconfirmedRestoredClaudeSubagents(restoredRoster) && - restoredRoster.size === 0 - ) { - state.claudeSubagentRosterByPaneKey.delete(paneKey) - } - } - - const resolvedStatus = resolveClaudePaneStatus(state, paneKey, { - state: reportedStateName, - interrupted - }) - // Why: #15202's compact-completion guard reads the resolved state; this branch replaced the - // resolver with one that also reports workingMode, so bridge rather than resolve twice. - const effectiveState = resolvedStatus.stateName - // Why: the lead already ended — the pane stays `working` only because background inventory is still registered. `stateStartedAt` is pinned for that whole run, so this end time is the per-turn identity and the later all-clear's pair key. - const turnCompletedAt = - eventAgentId === undefined && - isTurnBoundary && - reportedStateName === 'done' && - resolvedStatus.stateName === 'working' && - interrupted !== true - ? Date.now() - : undefined - - state.claudeLeadStateByPaneKey.set(paneKey, { - state: reportedStateName, - ...(interrupted ? { interrupted } : {}), - ...(isWaitingInducing && eventAgentId ? { waitingAgentId: eventAgentId } : {}), - ...(isAskUserQuestionWait && waitingToolUseId !== undefined ? { waitingToolUseId } : {}), - ...(stateBeforeWait ? { stateBeforeWait } : {}), - ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) - }) - - const effectiveRoster = state.claudeSubagentRosterByPaneKey.get(paneKey) - if ( - isTurnBoundary && - eventAgentId === undefined && - resolvedStatus.stateName === 'working' && - claudeRosterHasRestoredSnapshotSubagent(effectiveRoster) && - !claudeRosterHasRuntimeWorkingSubagent(effectiveRoster) && - !state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) && - !state.claudeActiveSessionCronPaneKeys.has(paneKey) - ) { - // Why: a legacy or partial Stop confirms the lead boundary, not a child restored from disk; keep the child-only gate eligible for reconciliation. - state.claudeUnconfirmedRestoredStatusPaneKeys.add(paneKey) - } - - if (isManualCompactCompletion && effectiveState !== 'done') { - // Why: a compact completion CLEARS a row or says nothing at all. Live evidence still holds this - // pane, so restating it would only strip `restoredUnconfirmed` off a hydrated row and restart - // the staleness clock for work the compact never observed — strictly worse than the silence - // this event replaced. The lead record above still marks the turn ended, so whichever event - // retires that evidence can clear the pane. - return null - } - - return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { - ...resolvedStatus, - updateToolSnapshot: true, - interrupted, - // Why: a finished compact is a session-shaped boundary, not a completed turn. Without this the - // clearing `done` would fire completion notifications, unread counts and automation-run - // completion evidence for work nobody did. - sessionBoundary: isManualCompactCompletion ? true : undefined, - turnCompletedAt - }) -} - -function buildClaudeStatusPayload( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record, - options: { - stateName: AgentStatusState - workingMode?: AgentWorkingMode - updateToolSnapshot: boolean - interrupted?: boolean - sessionBoundary?: boolean - turnCompletedAt?: number - } -): ParsedAgentStatusPayload | null { - // Why: child-driven refreshes are roster bookkeeping, not lead tool activity; read the cached snapshot without merging so they can't clear a live AskUserQuestion card or clobber the tool preview. - const snapshot = options.updateToolSnapshot - ? resolveToolState(state, paneKey, extractToolFields('claude', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('claude', eventName) - }) - : (state.lastToolByPaneKey.get(paneKey) ?? {}) - - // Why: validate directly — the JSON stringify/parse round trip other normalizers use is pure overhead on this hot per-hook path. - // 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) - }), - agentType: 'claude', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage, - interrupted: options.interrupted, - sessionBoundary: options.sessionBoundary, - turnCompletedAt: options.turnCompletedAt, - subagents: claudeRosterToSnapshots(state.claudeSubagentRosterByPaneKey.get(paneKey)) - }) -} - -// Why: Devin uses Claude-compatible payloads but its own lifecycle event set; normalize those event names while keeping Devin attribution. -function normalizeDevinEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - if (eventName === 'SessionStart') { - // Why: Devin emits SessionStart on idle TUI open/resume; mapping it to 'working' showed a spinner before the user typed, so only UserPromptSubmit/tool activity may create a row. - clearPaneTurnCacheState(state, paneKey) - return null - } - - const stateName = - eventName === 'UserPromptSubmit' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' || - eventName === 'PostCompaction' - ? 'working' - : eventName === 'PermissionRequest' - ? 'waiting' - : eventName === 'Stop' || eventName === 'SessionEnd' - ? 'done' - : null - - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('devin', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('devin', eventName) } - ) - - const interrupted = - eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('devin', eventName) - }), - agentType: 'devin', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage, - interrupted - }) -} - -// Why: Kimi's auto-allowed AskUserQuestion emits PreToolUse (not PermissionRequest) while awaiting an answer; treat as waiting so the UI shows the attention icon, not a spinner. -function isKimiUserInputTool(toolName: string | undefined): boolean { - return toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuserquestion' -} - -// Why: Kimi Code emits Claude-compatible payloads/event names; normalize but attribute to Kimi so the sidebar shows Kimi's icon/label, not Claude's. -function normalizeKimiEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { - return null - } - - const toolName = readString(hookPayload, 'tool_name') - const isUserInputTool = isKimiUserInputTool(toolName) - - let stateName: 'working' | 'waiting' | 'done' | null = null - if ( - eventName === 'UserPromptSubmit' || - eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure' || - (eventName === 'PreToolUse' && !isUserInputTool) - ) { - stateName = 'working' - } else if (eventName === 'PermissionRequest' || (eventName === 'PreToolUse' && isUserInputTool)) { - stateName = 'waiting' - } else if (eventName === 'Stop' || eventName === 'StopFailure') { - stateName = 'done' - } - - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('kimi', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('kimi', eventName) } - ) - - const interrupted = - eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('kimi', eventName) - }), - agentType: 'kimi', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage, - interrupted - }) -} - -function normalizeGeminiEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - // Why: Gemini CLI's native pre-tool event is BeforeTool; PreToolUse/PostToolUse still accepted for legacy Antigravity-compatible payloads. - const stateName = - eventName === 'BeforeAgent' || - eventName === 'BeforeTool' || - eventName === 'AfterTool' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ? 'working' - : eventName === 'AfterAgent' - ? 'done' - : null - - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('gemini', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('gemini', eventName) } - ) - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('gemini', eventName) - }), - agentType: 'gemini', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage - }) -} - -function isAntigravityFeedbackTool(toolName: string | undefined): boolean { - return toolName === 'ask_question' || toolName === 'ask_permission' -} - -function isAntigravityStopStillBusy(hookPayload: Record): boolean { - return hookPayload.fullyIdle === false || hookPayload.fully_idle === false -} - -function normalizeAntigravityEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const transcriptPath = readFirstString(hookPayload, ['transcriptPath', 'transcript_path']) - if (eventName === 'PreInvocation') { - state.antigravityCompletedTranscriptByPaneKey.delete(paneKey) - } else if ( - transcriptPath && - eventName !== 'Stop' && - state.antigravityCompletedTranscriptByPaneKey.get(paneKey) === transcriptPath - ) { - // Why: agy can emit a bookkeeping PostToolUse after Stop; ignore it so a finished row doesn't turn back into a yellow spinner. - return null - } - - const toolName = readAntigravityToolCall(hookPayload).toolName - const stopStillBusy = eventName === 'Stop' && isAntigravityStopStillBusy(hookPayload) - const stateName = - eventName === 'PreToolUse' && isAntigravityFeedbackTool(toolName) - ? 'waiting' - : eventName === 'Stop' - ? stopStillBusy - ? 'working' - : 'done' - : eventName === 'PreInvocation' || - eventName === 'PostInvocation' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ? 'working' - : null - - if (!stateName) { - return null - } - - const resetsTurn = isNewTurnEvent('antigravity', eventName) - // Why: once the prompt is cached for this pane, avoid rescanning the (potentially large) Antigravity transcript per hook. - const cachedPrompt = resetsTurn ? undefined : state.lastPromptByPaneKey.get(paneKey) - const effectivePrompt = - promptText || cachedPrompt || readLastUserPromptFromTranscript(transcriptPath) || '' - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('antigravity', eventName, hookPayload), - { resetOnNewTurn: resetsTurn } - ) - - const payload = normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, effectivePrompt, { - resetOnNewTurn: resetsTurn - }), - agentType: 'antigravity', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage - }) - // Why: Antigravity can emit Stop with fullyIdle=false between tool steps; only a fully idle Stop is terminal, else the sidebar bounces done -> working and ignores later tool updates. - if (eventName === 'Stop' && !stopStillBusy && transcriptPath) { - state.antigravityCompletedTranscriptByPaneKey.set(paneKey, transcriptPath) - } - return payload -} - -function normalizeAmpEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const ampCacheKey = getAmpCacheKey(paneKey, hookPayload) - if (eventName === 'session.start') { - clearPaneTurnCacheState(state, ampCacheKey) - if (ampCacheKey !== paneKey) { - clearPaneTurnCacheState(state, paneKey) - } - return null - } - - const stateName = - eventName === 'agent.start' || eventName === 'tool.call' || eventName === 'tool.result' - ? 'working' - : eventName === 'agent.end' - ? 'done' - : null - - if (!stateName) { - return null - } - if (eventName === 'agent.start') { - state.ampCompletedCacheKeys.delete(ampCacheKey) - } else if ( - (eventName === 'tool.call' || eventName === 'tool.result') && - state.ampCompletedCacheKeys.has(ampCacheKey) - ) { - // Why: Amp status posts are fire-and-forget, so drop stale tool events that arrive after the thread ended. - return null - } - - const snapshot = resolveToolState( - state, - ampCacheKey, - extractToolFields('amp', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('amp', eventName) } - ) - - const interrupted = - eventName === 'agent.end' && hookPayload.status === 'cancelled' ? true : undefined - const explicitPrompt = readFirstString(hookPayload, [ - 'prompt', - 'user_prompt', - 'userPrompt', - 'initial_prompt', - 'initialPrompt', - 'user_message' - ]) - const canUseMessageAsPrompt = - eventName === 'agent.start' || - (eventName === 'agent.end' && !state.lastPromptByPaneKey.has(ampCacheKey)) - const ampPromptText = explicitPrompt ?? (canUseMessageAsPrompt ? promptText : '') - - const normalized = normalizeAgentStatusPayload({ - state: stateName, - // Why: Amp tool/result events may use `message` for tool output; only lifecycle events may treat it as the turn prompt. - prompt: resolvePrompt(state, ampCacheKey, ampPromptText, { - resetOnNewTurn: isNewTurnEvent('amp', eventName) - }), - agentType: 'amp', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage, - interrupted - }) - if (normalized && eventName === 'agent.end') { - state.ampCompletedCacheKeys.add(ampCacheKey) - } - if (normalized) { - pruneAmpThreadCacheKeys(state, paneKey, ampCacheKey) - } - return normalized -} - -function getAmpCacheKey(paneKey: string, hookPayload: Record): string { - const threadId = readBoundedString( - hookPayload, - ['threadId', 'threadID', 'thread_id'], - AMP_THREAD_ID_MAX_LENGTH - ) - // Why: Amp emits events for multiple threads per pane; cache by thread internally while keeping the visible paneKey stable. - return threadId ? `${paneKey}\0amp:${threadId}` : paneKey -} - -function pruneAmpThreadCacheKeys( - state: HookListenerState, - paneKey: string, - currentCacheKey: string -): void { - const scopedPrefix = `${paneKey}\0amp:` - if (!currentCacheKey.startsWith(scopedPrefix)) { - return - } - - const scopedKeys = new Set() - for (const key of state.lastPromptByPaneKey.keys()) { - if (key.startsWith(scopedPrefix)) { - scopedKeys.add(key) - } - } - for (const key of state.lastToolByPaneKey.keys()) { - if (key.startsWith(scopedPrefix)) { - scopedKeys.add(key) - } - } - for (const key of state.ampCompletedCacheKeys) { - if (key.startsWith(scopedPrefix)) { - scopedKeys.add(key) - } - } - - let overflow = scopedKeys.size - AMP_MAX_SCOPED_THREAD_CACHE_KEYS - if (overflow <= 0) { - return - } - - // Why: Amp multiplexes many thread IDs through one pane; keep the current thread plus the most recent entries instead of retaining every completed thread until teardown. - for (const key of scopedKeys) { - if (overflow <= 0) { - break - } - if (key === currentCacheKey) { - continue - } - state.lastPromptByPaneKey.delete(key) - state.lastToolByPaneKey.delete(key) - state.ampCompletedCacheKeys.delete(key) - overflow-- - } -} - -function hasExplicitPromptForSource( - source: AgentHookSource, - eventName: unknown, - promptText: string, - hookPayload: Record -): boolean { - if (source !== 'amp') { - return promptText.length > 0 - } - if ( - readFirstString(hookPayload, [ - 'prompt', - 'user_prompt', - 'userPrompt', - 'initial_prompt', - 'initialPrompt', - 'user_message' - ]) - ) { - return true - } - // Why: Amp tool/result `message` is output text, not a user prompt. - return eventName === 'agent.start' && promptText.length > 0 -} - -function getOrCreateCodexSubagentRoster( - state: HookListenerState, - paneKey: string -): CodexSubagentRoster { - let roster = state.codexSubagentRosterByPaneKey.get(paneKey) - if (!roster) { - roster = new Map() - state.codexSubagentRosterByPaneKey.set(paneKey, roster) - } - return roster -} - -function getOrCreateCodexSubagentTranscriptState( - state: HookListenerState, - paneKey: string -): CodexSubagentTranscriptState { - let transcriptState = state.codexSubagentTranscriptByPaneKey.get(paneKey) - if (!transcriptState) { - transcriptState = createCodexSubagentTranscriptState() - state.codexSubagentTranscriptByPaneKey.set(paneKey, transcriptState) - } - return transcriptState -} - -export function hasCodexTranscriptSubagents(state: HookListenerState, paneKey: string): boolean { - return hasTrackedCodexTranscriptSubagents(state.codexSubagentTranscriptByPaneKey.get(paneKey)) -} - -export function seedCodexStateFromSnapshot( - state: HookListenerState, - paneKey: string, - payload: Pick -): void { - const snapshots = payload.subagents ?? [] - if (snapshots.length > 0 && !state.codexSubagentRosterByPaneKey.has(paneKey)) { - seedCodexSubagentRoster(getOrCreateCodexSubagentRoster(state, paneKey), snapshots) - } - if (!state.codexLeadStateByPaneKey.has(paneKey)) { - // Why: child hooks after restart omit the root model; seed it from durable status before they can overwrite the cache. - state.codexLeadStateByPaneKey.set(paneKey, { - // Why: a child wait drives the aggregate waiting state, so it is not evidence that the root itself was waiting. - state: - payload.state === 'done' - ? 'done' - : payload.state === 'waiting' && - !snapshots.some((snapshot) => snapshot.state === 'waiting') - ? 'waiting' - : 'working', - model: payload.model - }) - } -} - -/** Sync the Codex lead record when the server infers an interrupt, so delayed child events cannot restore stale working state. */ -export function markCodexLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { - const lead = state.codexLeadStateByPaneKey.get(paneKey) - state.codexLeadStateByPaneKey.set(paneKey, { state: 'done', model: lead?.model }) -} - -function codexLeadStateForHookEvent( - eventName: string | undefined -): CodexLeadTurnState['state'] | undefined { - if (eventName === 'Stop') { - return 'done' - } - if (eventName === 'PermissionRequest') { - return 'waiting' - } - if ( - eventName === 'SessionStart' || - eventName === 'UserPromptSubmit' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ) { - return 'working' - } - return undefined -} - -/** Why: relay restarts lose lead/roster state; merge child events into main's longer-lived cache. */ -export function reconcileRemoteCodexState( - state: HookListenerState, - paneKey: string, - eventName: string | undefined, - agentId: string | undefined, - payload: ParsedAgentStatusPayload, - previous: ParsedAgentStatusPayload | undefined -): ParsedAgentStatusPayload { - if (previous?.agentType === 'codex') { - seedCodexStateFromSnapshot(state, paneKey, previous) - } else { - seedCodexStateFromSnapshot(state, paneKey, payload) - } - - // Why: older relays send child identity without roster snapshots; keep their already-normalized aggregate authoritative. - if (agentId && !payload.subagents && !state.codexSubagentRosterByPaneKey.has(paneKey)) { - return payload - } - const roster = getOrCreateCodexSubagentRoster(state, paneKey) - if (payload.subagents) { - seedCodexSubagentRoster(roster, payload.subagents) - } - if (agentId) { - if (eventName === 'SubagentStop') { - finishCodexSubagent(roster, agentId) - } - } else { - const leadState = codexLeadStateForHookEvent(eventName) - if (eventName === 'SessionStart' || (eventName === 'Stop' && !payload.subagents)) { - roster.clear() - } - if (leadState) { - const previousLead = state.codexLeadStateByPaneKey.get(paneKey) - state.codexLeadStateByPaneKey.set(paneKey, { - state: leadState, - model: payload.model ?? previousLead?.model - }) - } - } - - const lead = state.codexLeadStateByPaneKey.get(paneKey) - if (!lead) { - return payload - } - return { - ...payload, - state: codexRosterEffectiveState(roster, lead.state), - model: lead.model ?? payload.model, - subagents: codexRosterToSnapshots(roster) - } -} - -function buildCodexStatusPayload( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record, - options: { stateName: 'working' | 'waiting' | 'done'; updateLead: boolean } -): ParsedAgentStatusPayload | null { - const snapshot = options.updateLead - ? resolveToolState(state, paneKey, extractToolFields('codex', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('codex', eventName) - }) - : (state.lastToolByPaneKey.get(paneKey) ?? {}) - const lead = state.codexLeadStateByPaneKey.get(paneKey) - - return normalizeAgentStatusPayload({ - state: options.stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: options.updateLead && isNewTurnEvent('codex', eventName) - }), - agentType: 'codex', - model: lead?.model, - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage, - subagents: codexRosterToSnapshots(state.codexSubagentRosterByPaneKey.get(paneKey)) - }) -} - -function buildCodexChildDrivenStatusPayload( - state: HookListenerState, - eventName: unknown, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const leadState = state.codexLeadStateByPaneKey.get(paneKey)?.state ?? 'working' - const stateName = codexRosterEffectiveState( - state.codexSubagentRosterByPaneKey.get(paneKey), - leadState - ) - return buildCodexStatusPayload(state, eventName, '', paneKey, hookPayload, { - stateName, - updateLead: false - }) -} - -function normalizeCodexSubagentLifecycleEvent( - state: HookListenerState, - eventName: 'SubagentStart' | 'SubagentStop', - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const agentId = readString(hookPayload, 'agent_id') - if (!agentId) { - return null - } - const roster = getOrCreateCodexSubagentRoster(state, paneKey) - if (eventName === 'SubagentStart') { - upsertCodexSubagent( - roster, - agentId, - { - agentType: readString(hookPayload, 'agent_type'), - model: readString(hookPayload, 'model'), - state: 'working' - }, - Date.now() - ) - } else { - finishCodexSubagent(roster, agentId) - } - return buildCodexChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) -} - -function normalizeCodexEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - if (eventName === 'SubagentStart' || eventName === 'SubagentStop') { - return normalizeCodexSubagentLifecycleEvent(state, eventName, paneKey, hookPayload) - } - - // Why: Codex's request_user_input (0.145+) is auto-allowed, so it fires PreToolUse while blocked on a human answer; map to waiting like grok's ask_user_question. - const isUserInputPreTool = - eventName === 'PreToolUse' && - isAskUserQuestionTool(readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name')) - const stateName = - eventName === 'SessionStart' || - eventName === 'UserPromptSubmit' || - (eventName === 'PreToolUse' && !isUserInputPreTool) || - eventName === 'PostToolUse' - ? 'working' - : eventName === 'PermissionRequest' || isUserInputPreTool - ? 'waiting' - : eventName === 'Stop' - ? 'done' - : null - if (!stateName) { - return null - } - - const agentId = readString(hookPayload, 'agent_id') - if (agentId) { - upsertCodexSubagent( - getOrCreateCodexSubagentRoster(state, paneKey), - agentId, - { - agentType: readString(hookPayload, 'agent_type'), - model: readString(hookPayload, 'model'), - state: stateName === 'waiting' ? 'waiting' : 'working' - }, - Date.now() - ) - return buildCodexChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) - } - - if (eventName === 'SessionStart') { - // Why: a pane can host a new Codex process after the old one exited without child Stop hooks. - state.codexSubagentRosterByPaneKey.delete(paneKey) - state.codexSubagentTranscriptByPaneKey.delete(paneKey) - } - const transcriptPath = readFirstString(hookPayload, ['transcript_path', 'transcriptPath']) - if (transcriptPath) { - reconcileCodexSubagentTranscript( - getOrCreateCodexSubagentTranscriptState(state, paneKey), - getOrCreateCodexSubagentRoster(state, paneKey), - transcriptPath - ) - } - if (eventName === 'Stop' && !hasCodexTranscriptSubagents(state, paneKey)) { - // Why: Codex CLI 0.144 can omit child Stop hooks; later child activity safely recreates any agent still running. - state.codexSubagentRosterByPaneKey.delete(paneKey) - } - const previousLead = state.codexLeadStateByPaneKey.get(paneKey) - state.codexLeadStateByPaneKey.set(paneKey, { - state: stateName, - model: - normalizeOptionalField(hookPayload['model'], AGENT_MODEL_MAX_LENGTH) ?? - (eventName === 'SessionStart' ? undefined : previousLead?.model) - }) - const effectiveState = codexRosterEffectiveState( - state.codexSubagentRosterByPaneKey.get(paneKey), - stateName - ) - return buildCodexStatusPayload(state, eventName, promptText, paneKey, hookPayload, { - stateName: effectiveState, - updateLead: true - }) -} - -function normalizeOpenCodeFamilyEvent( - source: 'opencode' | 'mimo-code', - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const resetsTurn = - isNewTurnEvent(source, eventName) || - (eventName === 'MessagePart' && hookPayload.role === 'user') - const stateName = - eventName === 'SessionBusy' || eventName === 'MessagePart' - ? 'working' - : eventName === 'SessionIdle' - ? 'done' - : source === 'opencode' && eventName === 'SessionStart' - ? 'done' - : eventName === 'PermissionRequest' || eventName === 'AskUserQuestion' - ? 'waiting' - : null - - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields(source, eventName, hookPayload), - { resetOnNewTurn: resetsTurn } - ) - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: resetsTurn - }), - agentType: source, - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage, - sessionBoundary: source === 'opencode' && eventName === 'SessionStart' ? true : undefined - }) -} - -function normalizeCursorEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - // Why: Cursor can emit final response text after `stop`; enrich the completed row, don't resurrect the agent as working. - const previousStatus = state.lastStatusByPaneKey.get(paneKey)?.payload - const stateName = - eventName === 'beforeSubmitPrompt' || - eventName === 'sessionStart' || - eventName === 'preToolUse' || - eventName === 'postToolUse' || - eventName === 'postToolUseFailure' || - // Why: these fire on every shell/MCP invocation (pre-execution gates, not just approval); treat as working to avoid waiting-notification spam. - eventName === 'beforeShellExecution' || - eventName === 'beforeMCPExecution' - ? 'working' - : eventName === 'afterAgentResponse' - ? previousStatus?.state === 'done' && previousStatus.agentType === 'cursor' - ? 'done' - : 'working' - : eventName === 'stop' || eventName === 'sessionEnd' - ? 'done' - : null - - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('cursor', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('cursor', eventName) } - ) - - const interrupted = - eventName === 'stop' && - typeof hookPayload.status === 'string' && - hookPayload.status !== 'completed' - ? true - : undefined - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('cursor', eventName) - }), - agentType: 'cursor', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage, - interrupted - }) -} - -// Why: Copilot PermissionRequest fires before allow/ask/deny (stays working); ask_user and notification prompts are the real blocked signals. -function normalizeCopilotEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const normalizedEventName = normalizeCopilotEventName( - resolveCopilotEventName(eventName, hookPayload) - ) - const notificationType = readFirstString(hookPayload, ['notification_type', 'notificationType']) - const isBlockingNotification = - normalizedEventName === 'Notification' && - (notificationType === 'permission_prompt' || notificationType === 'elicitation_dialog') - const toolSnapshot = extractToolFields('copilot', normalizedEventName, hookPayload) - const isAskUserPrompt = - (normalizedEventName === 'PreToolUse' || normalizedEventName === 'PermissionRequest') && - isAskUserTool(toolSnapshot.toolName) - const stateName = - normalizedEventName === 'SessionStart' || - normalizedEventName === 'UserPromptSubmit' || - normalizedEventName === 'PostToolUse' || - normalizedEventName === 'PostToolUseFailure' - ? 'working' - : isBlockingNotification || isAskUserPrompt - ? 'blocked' - : normalizedEventName === 'PreToolUse' || normalizedEventName === 'PermissionRequest' - ? 'working' - : normalizedEventName === 'Stop' || normalizedEventName === 'SessionEnd' - ? 'done' - : normalizedEventName === 'ErrorOccurred' - ? hookPayload.recoverable === true - ? 'working' - : 'done' - : null - - if (!stateName) { - return null - } - - const snapshot = resolveToolState(state, paneKey, toolSnapshot, { - resetOnNewTurn: isNewTurnEvent('copilot', normalizedEventName) - }) - - const effectivePrompt = normalizedEventName === 'Notification' ? '' : promptText - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, effectivePrompt, { - resetOnNewTurn: isNewTurnEvent('copilot', normalizedEventName) - }), - agentType: 'copilot', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage - }) -} - -function normalizePiCompatibleEvent( - state: HookListenerState, - agentType: 'pi' | 'omp' | 'prime-agent', - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - if (agentType !== 'omp' && eventName === 'session_start') { - // Why: Pi's session_start fires on TUI open/resume; discard stale turn details, no working row before user activity. - clearPaneTurnCacheState(state, paneKey) - return null - } - - // Why: gate on the event's own tool_name so a stale cached question can't re-enter blocked. - const toolName = readString(hookPayload, 'tool_name') - const isPiCompatibleAsk = - ((agentType === 'pi' && isAskUserQuestionTool(toolName)) || - (agentType === 'omp' && toolName === 'ask')) && - (eventName === 'tool_call' || eventName === 'tool_execution_start') - - const stateName = isPiCompatibleAsk - ? 'blocked' - : eventName === 'before_agent_start' || - eventName === 'agent_start' || - eventName === 'tool_call' || - eventName === 'tool_execution_start' || - eventName === 'tool_execution_end' || - eventName === 'message_end' - ? 'working' - : eventName === 'agent_end' - ? 'done' - : null - - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields(agentType, eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent(agentType, eventName) } - ) - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent(agentType, eventName) - }), - agentType, - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage - }) -} - -function normalizeDroidEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - if (eventName === 'SessionStart') { - // Why: Droid's SessionStart fires while idle (TUI open/resume); wait for real activity before a working row. - clearPaneTurnCacheState(state, paneKey) - return null - } - - const notificationMessage = readString(hookPayload, 'message') - const droidToolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') - let stateName: 'working' | 'waiting' | 'done' | null = null - if ( - eventName === 'PreToolUse' && - (isDroidAskUserTool(droidToolName) || isDroidHighRiskToolUse(hookPayload)) - ) { - // Why: Droid surfaces AskUser and high-risk approvals as PreToolUse; the approval path emits no Notification hook. - stateName = 'waiting' - } else if ( - eventName === 'UserPromptSubmit' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ) { - stateName = 'working' - } else if (eventName === 'Stop') { - stateName = 'done' - } else if (eventName === 'PermissionRequest') { - stateName = 'waiting' - } else if (eventName === 'Notification' && isDroidPermissionNotification(notificationMessage)) { - stateName = 'waiting' - } else if (eventName === 'Notification' && isDroidIdleNotification(notificationMessage)) { - // Why: Droid emits no Stop on user-interrupt, only an idle notification when ready again. - stateName = 'done' - } - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('droid', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('droid', eventName) } - ) - - // Why: Droid Notification.message is status text, not the prompt; '' keeps resolvePrompt's cached UserPromptSubmit value. - const effectivePrompt = eventName === 'Notification' ? '' : promptText - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, effectivePrompt, { - resetOnNewTurn: isNewTurnEvent('droid', eventName) - }), - agentType: 'droid', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage - }) -} - -function normalizeCommandCodeEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const stateName = - eventName === 'PreToolUse' || eventName === 'PostToolUse' - ? 'working' - : eventName === 'Stop' - ? 'done' - : null - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('command-code', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('command-code', eventName) } - ) - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('command-code', eventName) - }), - agentType: 'command-code', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage - }) -} - -function normalizeGrokEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record, - grokHome?: string -): ParsedAgentStatusPayload | null { - if (isGrokEvent(eventName, 'session_start')) { - // Why: SessionStart resets stale per-turn state but must not create a working row before any prompt/tool event. - clearPaneTurnCacheState(state, paneKey) - return null - } - - const notificationMessage = readString(hookPayload, 'message') - const notificationType = getGrokNotificationType(hookPayload) - const notificationLevel = readString(hookPayload, 'level') - const preToolName = - readString(hookPayload, 'toolName') ?? - readString(hookPayload, 'tool_name') ?? - readString(hookPayload, 'name') - // Why: Grok's ask_user_question is auto-allowed, so it fires PreToolUse while blocked on a human answer; map to waiting. - const isUserInputPreTool = - isGrokEvent(eventName, 'pre_tool_use') && isAskUserQuestionTool(preToolName) - - let stateName: 'working' | 'waiting' | 'done' | null = null - if ( - isGrokEvent(eventName, 'user_prompt_submit', 'post_tool_use', 'post_tool_use_failure') || - (isGrokEvent(eventName, 'pre_tool_use') && !isUserInputPreTool) - ) { - stateName = 'working' - } else if (isUserInputPreTool) { - stateName = 'waiting' - } else if (isGrokEvent(eventName, 'stop', 'session_end', 'stop_failure')) { - stateName = 'done' - } else if ( - isGrokEvent(eventName, 'notification') && - isGrokRoutinePermissionPromptNotification( - notificationType, - notificationMessage, - notificationLevel - ) - ) { - return null - } else if ( - isGrokEvent(eventName, 'notification') && - isGrokPermissionNotification(notificationMessage) - ) { - stateName = 'waiting' - } else if ( - isGrokEvent(eventName, 'notification') && - isGrokIdleNotification(notificationMessage) - ) { - stateName = 'done' - } - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('grok', eventName, hookPayload, { grokHome }), - { resetOnNewTurn: isNewTurnEvent('grok', eventName) } - ) - - // Why: Grok Notification.message is status UI text, not the prompt; '' preserves the cached UserPromptSubmit. - const effectivePrompt = isGrokEvent(eventName, 'notification') - ? '' - : stripGrokUserQueryWrapper(promptText) - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, effectivePrompt, { - resetOnNewTurn: isNewTurnEvent('grok', eventName) - }), - agentType: 'grok', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage - }) -} - -function normalizeHermesEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const stateName = - eventName === 'pre_approval_request' - ? 'waiting' - : eventName === 'post_llm_call' || - eventName === 'on_session_end' || - eventName === 'on_session_finalize' || - eventName === 'on_session_reset' - ? 'done' - : eventName === 'on_session_start' || - eventName === 'pre_llm_call' || - eventName === 'pre_tool_call' || - eventName === 'post_tool_call' || - eventName === 'post_approval_response' - ? 'working' - : null - - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('hermes', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('hermes', eventName) } - ) - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('hermes', eventName) - }), - agentType: 'hermes', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage - }) -} - -function readStringField(record: Record, key: string): string | undefined { - const value = record[key] - if (typeof value !== 'string') { - return undefined - } - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : undefined -} - +import { parseHookEnvelope } from './agent-hook-listener/hook-envelope' +import { readFirstString } from './agent-hook-listener/interactive-tool' +import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' +import { normalizeClaudePromptId } from './agent-hook-listener/listener-limits' +import type { HookListenerState } from './agent-hook-listener/listener-state' +import { extractPromptText } from './agent-hook-listener/prompt-fields' +import { normalizeProviderEvent } from './agent-hook-listener/provider-dispatch' +import { hasExplicitUserPrompt } from './agent-hook-listener/provider-event-routing' +import { hasExplicitAmpPrompt } from './agent-hook-listener/providers/amp-events' +import { readString } from './agent-hook-listener/tool-input-preview' +/** Canonical transport-agnostic normalization entry shared by main and relay listeners. */ export function normalizeHookPayload( state: HookListenerState, source: AgentHookSource, @@ -4450,65 +24,19 @@ export function normalizeHookPayload( expectedEnv: string, options: { deferCompactOwnershipToClient?: boolean } = {} ): AgentHookEventPayload | null { - if (typeof body !== 'object' || body === null) { + const envelope = parseHookEnvelope(state, source, body, expectedEnv) + if (!envelope) { return null } - - const record = body as Record - const paneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' - const parsedPaneKey = parsePaneKey(paneKey) - const rawPayload = record.payload - // Why (#15117): some Antigravity events fire with no stdin at all, yet still carry a status - // transition in `hook_event_name`. The POSIX script substitutes `{}` before posting; on - // Windows curl omits the form field entirely when stdin is empty, so the event arrives with - // no `payload` key at all. Accept both shapes rather than dropping the transition. - const antigravityPayloadAbsent = - source === 'antigravity' && - (rawPayload === undefined || (typeof rawPayload === 'string' && rawPayload.trim() === '')) - const hookPayload = antigravityPayloadAbsent - ? {} - : typeof rawPayload === 'string' - ? (() => { - try { - return parseAgentHookJson(rawPayload) - } catch { - return null - } - })() - : rawPayload - if ( - !paneKey || - paneKey.length > MAX_PANE_KEY_LEN || - !parsedPaneKey || - typeof hookPayload !== 'object' || - hookPayload === null - ) { - return null - } - - warnOnHookEnvOrVersionMismatch(state, { - version: readStringField(record, 'version'), - env: readStringField(record, 'env'), - expectedEnv - }) - - const tabId = readStringField(record, 'tabId') - if (tabId && tabId !== parsedPaneKey.tabId) { - return null - } - const worktreeId = readStringField(record, 'worktreeId') - const launchToken = readStringField(record, 'launchToken') - - const hookPayloadRecord = hookPayload as Record + const { record, paneKey, hookPayloadRecord, tabId, worktreeId, launchToken } = envelope if (source === 'claude') { state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey) } - let promptInteractionKey: string | undefined const eventName = readFirstString(record, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) ?? hookPayloadRecord.hook_event_name ?? hookPayloadRecord.hookEventName - // Why: Codex child hooks expose the child's session_id on the parent's pane. + // Codex child hooks expose the child's session_id on the parent's pane. const providerSession = source === 'codex' && readString(hookPayloadRecord, 'agent_id') ? null @@ -4570,295 +98,76 @@ export function normalizeHookPayload( state.lastPromptByPaneKey.set(paneKey, previousStatus.payload.prompt) } } - const extractedPrompt = extractPromptText(hookPayload as Record) - const promptText = extractedPrompt.text - let resolvedPromptText = promptText - let hasTranscriptPromptEvidence = false - // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of silently misrouting. - let payload: ParsedAgentStatusPayload | null - switch (source) { - case 'claude': - payload = normalizeClaudeEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'codex': - payload = normalizeCodexEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'gemini': - payload = normalizeGeminiEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'antigravity': - if (isNewTurnEvent('antigravity', eventName)) { - resolvedPromptText = - promptText || - readLastUserPromptFromTranscript( - readFirstString(hookPayloadRecord, ['transcriptPath', 'transcript_path']) - ) || - '' - } - payload = normalizeAntigravityEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'amp': - payload = normalizeAmpEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'opencode': - case 'mimo-code': - if (extractedPrompt.source === 'role_user_text') { - const messageId = readFirstString(hookPayloadRecord, [ - 'messageID', - 'messageId', - 'message_id' - ]) - const prefix = source === 'mimo-code' ? 'mimo-code-message' : 'opencode-message' - promptInteractionKey = messageId ? `${prefix}-${messageId}` : undefined - } - payload = normalizeOpenCodeFamilyEvent( - source, - state, - eventName, - promptText, - paneKey, - hookPayloadRecord - ) - break - case 'cursor': - payload = normalizeCursorEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'pi': - payload = normalizePiCompatibleEvent( - state, - 'pi', - eventName, - promptText, - paneKey, - hookPayloadRecord - ) - break - case 'omp': - payload = normalizePiCompatibleEvent( - state, - 'omp', - eventName, - promptText, - paneKey, - hookPayloadRecord - ) - break - case 'prime-agent': - payload = normalizePiCompatibleEvent( - state, - 'prime-agent', - eventName, - promptText, - paneKey, - hookPayloadRecord - ) - break - case 'droid': - payload = normalizeDroidEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'command-code': - { - const transcriptPrompt = readLastCommandCodeUserPromptEntryFromTranscript( - hookPayloadRecord.transcript_path ?? hookPayloadRecord.transcriptPath - ) - hasTranscriptPromptEvidence = transcriptPrompt !== undefined - promptInteractionKey = transcriptPrompt?.interactionKey - resolvedPromptText = transcriptPrompt?.text ?? '' - if (promptText && extractedPrompt.source !== 'message') { - resolvedPromptText = promptText - } - } - payload = normalizeCommandCodeEvent( - state, - eventName, - resolvedPromptText, - paneKey, - hookPayloadRecord - ) - break - case 'grok': - payload = normalizeGrokEvent( - state, - eventName, - promptText, - paneKey, - hookPayloadRecord, - readGrokHomeEnvelope(record) - ) - break - case 'copilot': - payload = normalizeCopilotEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'hermes': - payload = normalizeHermesEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'devin': - payload = normalizeDevinEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'kimi': - payload = normalizeKimiEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - } + const extractedPrompt = extractPromptText(hookPayloadRecord) + const promptText = extractedPrompt.text + const dispatched = normalizeProviderEvent({ + state, + source, + eventName, + promptText, + paneKey, + hookPayload: hookPayloadRecord, + envelope: record, + extractedPrompt + }) const providerSessionOnly = (source === 'pi' || source === 'prime-agent') && eventName === 'session_start' && providerSession !== null - // Why: transcript session_start carries resume identity while idle; receivers discard the placeholder row. + // A transcript session_start carries resume identity while idle; receivers discard the placeholder row. const transportPayload = - payload ?? + dispatched.payload ?? (providerSessionOnly ? normalizeAgentStatusPayload({ state: 'done', prompt: '', agentType: source }) : null) const restoredUnconfirmed = source === 'claude' && state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey) - return transportPayload - ? { - paneKey, - source, - launchToken, - tabId, - worktreeId, - // Why: normalization is transport-agnostic — only ingestRemote knows the mux identity to stamp here. - connectionId: null, - ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), - hasExplicitPrompt: - source === 'amp' - ? hasExplicitPromptForSource(source, eventName, promptText, hookPayloadRecord) - ? true - : undefined - : hasExplicitUserPrompt( - source, - eventName, - extractedPrompt, - resolvedPromptText, - hasTranscriptPromptEvidence - ), - promptInteractionKey, - hookEventName: typeof eventName === 'string' ? eventName : undefined, - providerPromptId, - compactTrigger, - toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']), - toolAgentId: readFirstString(hookPayloadRecord, ['agent_id', 'agentId']), - teammateName: - source === 'claude' && eventName === 'TeammateIdle' - ? readString(hookPayloadRecord, 'teammate_name') - : undefined, - toolAgentType: readString(hookPayloadRecord, 'agent_type'), - ...(source === 'claude' - ? { - claudeRunningNonAgentTask: - state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - state.claudeActiveSessionCronPaneKeys.has(paneKey) - } - : {}), - ...(providerSession ? { providerSession } : {}), - ...(providerSessionOnly ? { providerSessionOnly: true } : {}), - payload: transportPayload - } - : null -} - -// ─── URL routing ──────────────────────────────────────────────────── - -export const HOOK_SOURCE_BY_PATHNAME: Readonly> = Object.freeze({ - '/hook/claude': 'claude', - '/hook/codex': 'codex', - '/hook/gemini': 'gemini', - '/hook/antigravity': 'antigravity', - '/hook/amp': 'amp', - '/hook/opencode': 'opencode', - '/hook/mimo-code': 'mimo-code', - '/hook/cursor': 'cursor', - '/hook/pi': 'pi', - '/hook/omp': 'omp', - '/hook/prime-agent': 'prime-agent', - '/hook/droid': 'droid', - '/hook/command-code': 'command-code', - '/hook/grok': 'grok', - '/hook/copilot': 'copilot', - '/hook/hermes': 'hermes', - '/hook/devin': 'devin', - '/hook/kimi': 'kimi' -}) - -export function resolveHookSource(pathname: string): AgentHookSource | null { - return HOOK_SOURCE_BY_PATHNAME[pathname] ?? null -} - -// ─── Endpoint-file writing ────────────────────────────────────────── - -export function getEndpointFileName(): string { - // Why: per-platform extension lets hook scripts source the file natively (POSIX `. "$file"` / Windows `call "%file%"`); the OpenCode plugin regex accepts both shapes. - return process.platform === 'win32' ? 'endpoint.cmd' : 'endpoint.env' -} - -export function isShellSafeEndpointValue(value: string): boolean { - // Why: values are shell-sourced; the + rejects empty strings so a sourced `KEY=` can't clear the env var. - return /^[A-Za-z0-9._:/-]+$/.test(value) -} - -export type EndpointFileFields = { - port: number - token: string - env: string - version: string -} - -/** Atomically write the endpoint file at `endpointDir/`. - * Returns true on success, false on error (caller may fall back to PTY env). - * Kept in sync with `AgentHookServer.writeEndpointFile`. */ -export function writeEndpointFile( - endpointDir: string, - finalPath: string, - fields: EndpointFileFields -): boolean { - const tmpPath = join(endpointDir, `.endpoint-${process.pid}-${randomUUID()}.tmp`) - const prefix = process.platform === 'win32' ? 'set ' : '' - const valuesToWrite: [string, string][] = [ - ['ORCA_AGENT_HOOK_PORT', String(fields.port)], - ['ORCA_AGENT_HOOK_TOKEN', fields.token], - ['ORCA_AGENT_HOOK_ENV', fields.env], - ['ORCA_AGENT_HOOK_VERSION', fields.version] - ] - for (const [key, value] of valuesToWrite) { - if (!isShellSafeEndpointValue(value)) { - console.error( - `[agent-hooks] refusing to write endpoint file: ${key} contains ` + - 'characters unsafe for shell sourcing. Falling back to PTY env.' - ) - return false - } + if (!transportPayload) { + return null } - const lines = [...valuesToWrite.map(([key, value]) => `${prefix}${key}=${value}`), ''] - let tmpWritten = false - try { - // Why: 0o700 owner-only so the dir doesn't leak this install's existence to other local users. - mkdirSync(endpointDir, { recursive: true, mode: 0o700 }) - if (process.platform !== 'win32') { - // Why: mkdirSync mode only applies on creation; chmod fixes perms on a pre-existing dir (POSIX-only). - try { - chmodSync(endpointDir, 0o700) - } catch { - // best-effort - } - } - // Why: crash-orphan cleanup must not materialize a tampered, enormous directory. - sweepStaleAgentHookEndpointTemps(endpointDir) - const separator = process.platform === 'win32' ? '\r\n' : '\n' - writeFileSync(tmpPath, lines.join(separator), { mode: 0o600 }) - tmpWritten = true - renameSync(tmpPath, finalPath) - return true - } catch (err) { - console.error('[agent-hooks] failed to write endpoint file:', err) - if (tmpWritten) { - try { - unlinkSync(tmpPath) - } catch { - // tmp may already be gone - } - } - return false + + return { + paneKey, + source, + launchToken, + tabId, + worktreeId, + // Normalization is transport-agnostic; only ingestRemote knows the mux identity to stamp. + connectionId: null, + ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + hasExplicitPrompt: + source === 'amp' + ? hasExplicitAmpPrompt(eventName, promptText, hookPayloadRecord) + ? true + : undefined + : hasExplicitUserPrompt( + source, + eventName, + extractedPrompt, + dispatched.resolvedPromptText, + dispatched.hasTranscriptPromptEvidence + ), + promptInteractionKey: dispatched.promptInteractionKey, + hookEventName: typeof eventName === 'string' ? eventName : undefined, + providerPromptId, + compactTrigger, + toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']), + toolAgentId: readFirstString(hookPayloadRecord, ['agent_id', 'agentId']), + teammateName: + source === 'claude' && eventName === 'TeammateIdle' + ? readString(hookPayloadRecord, 'teammate_name') + : undefined, + toolAgentType: readString(hookPayloadRecord, 'agent_type'), + ...(source === 'claude' + ? { + claudeRunningNonAgentTask: + state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + state.claudeActiveSessionCronPaneKeys.has(paneKey) + } + : {}), + ...(providerSession ? { providerSession } : {}), + ...(providerSessionOnly ? { providerSessionOnly: true } : {}), + payload: transportPayload } } diff --git a/src/shared/agent-hook-listener/command-code-transcript.ts b/src/shared/agent-hook-listener/command-code-transcript.ts new file mode 100644 index 00000000000..b3ec2a1d497 --- /dev/null +++ b/src/shared/agent-hook-listener/command-code-transcript.ts @@ -0,0 +1,190 @@ +import { createHash } from 'node:crypto' +import { closeSync, openSync, readSync, statSync } from 'node:fs' + +import { parseAgentHookJson } from './request-body' +import { extractAssistantContentText } from './transcript-entry-text' +import { + EMPTY_TRANSCRIPT_REGION, + readLastTextFromTranscriptOnce, + TRANSCRIPT_CHUNK_BYTES, + TRANSCRIPT_MAX_SCAN_BYTES +} from './transcript-reader' + +export function extractCommandCodeUserPromptFromLine(line: string): string | undefined { + let entry: unknown + try { + entry = parseAgentHookJson(line) + } catch { + return undefined + } + if (typeof entry !== 'object' || entry === null) { + return undefined + } + const record = entry as Record + return record.role === 'user' ? extractAssistantContentText(record.content) : undefined +} + +export function hashInteractionKeyPart(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 12) +} + +// Why byte offsets: the caller's interactionKey embeds the prompt's absolute +// position, so the backward scan has to report the same offset the old +// read-everything-then-take-the-last-match pass produced. +export function findLastCommandCodePromptInRegion( + region: Buffer +): { prompt: string; byteOffset: number } | undefined { + let lineEnd = region.length + for (let index = region.length - 1; index >= -1; index--) { + if (index >= 0 && region[index] !== 0x0a) { + continue + } + const lineStart = index + 1 + if (lineEnd > lineStart) { + const prompt = extractCommandCodeUserPromptFromLine( + region.subarray(lineStart, lineEnd).toString('utf8').trim() + ) + if (prompt !== undefined) { + return { prompt, byteOffset: lineStart } + } + } + lineEnd = index + } + return undefined +} + +export function readLastCommandCodeUserPromptEntryFromTranscript( + transcriptPath: unknown +): { text: string; interactionKey: string } | undefined { + if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { + return undefined + } + try { + const stats = statSync(transcriptPath) + const size = stats.size + if (size <= 0) { + return undefined + } + const fd = openSync(transcriptPath, 'r') + try { + // Why scan backward: the answer is the LAST user line, so walking up from + // EOF returns on the first hit instead of parsing every line of a + // multi-megabyte transcript on every hook event. + // Why a chunk list: carry holds a partial line, and re-concatenating it per + // block made one oversized line (a big tool result) cost O(line^2). + let carryChunks: Buffer[] = [] + let bytesRead = 0 + let scanEnd = size + while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { + const chunkSize = Math.min( + scanEnd, + TRANSCRIPT_CHUNK_BYTES, + TRANSCRIPT_MAX_SCAN_BYTES - bytesRead + ) + const position = scanEnd - chunkSize + const buffer = Buffer.alloc(chunkSize) + let filled = 0 + while (filled < chunkSize) { + const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled) + if (n === 0) { + break + } + filled += n + } + // Why bail on a short read: the file shrank under us, so the bytes above + // this block no longer line up and any stitched offset would be wrong. + if (filled < chunkSize) { + break + } + bytesRead += filled + scanEnd = position + // Why search only the new block: carry is always the run before a newline, + // so it holds none of its own. + const firstNewline = buffer.indexOf(0x0a) + // Why only at a true file start: a scan that stops on the size cap must + // discard its leading partial line, exactly as the capped read did. + const atStart = position === 0 + let completeRegion: Buffer + let regionPosition: number + if (atStart) { + completeRegion = + carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) + regionPosition = position + carryChunks = [] + } else if (firstNewline === -1) { + completeRegion = EMPTY_TRANSCRIPT_REGION + regionPosition = position + carryChunks.unshift(buffer) + } else { + const afterNewline = buffer.subarray(firstNewline + 1) + completeRegion = + carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks]) + regionPosition = position + firstNewline + 1 + carryChunks = [buffer.subarray(0, firstNewline)] + } + if (completeRegion.length > 0) { + const found = findLastCommandCodePromptInRegion(completeRegion) + if (found) { + return { + text: found.prompt, + interactionKey: [ + 'command-code-transcript', + hashInteractionKeyPart(transcriptPath), + String(regionPosition + found.byteOffset), + hashInteractionKeyPart(found.prompt) + ].join('-') + } + } + } + } + return undefined + } finally { + closeSync(fd) + } + } catch { + return undefined + } +} + +export function extractCommandCodeAssistantTextFromLine(line: string): string | undefined { + let entry: unknown + try { + entry = parseAgentHookJson(line) + } catch { + return undefined + } + if (typeof entry !== 'object' || entry === null) { + return undefined + } + const record = entry as Record + if (record.role !== 'assistant') { + return undefined + } + const content = record.content + if (typeof content === 'string' && content.trim().length > 0) { + return content + } + if (Array.isArray(content)) { + const textPart = content.find( + (part) => + typeof part === 'object' && + part !== null && + (part as Record).type === 'text' && + typeof (part as Record).text === 'string' && + ((part as Record).text as string).trim().length > 0 + ) as Record | undefined + if (typeof textPart?.text === 'string') { + return textPart.text + } + } + return extractAssistantContentText(content) +} + +export function readLastCommandCodeAssistantFromTranscript( + transcriptPath: unknown +): string | undefined { + if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { + return undefined + } + return readLastTextFromTranscriptOnce(transcriptPath, extractCommandCodeAssistantTextFromLine) +} diff --git a/src/shared/agent-hook-listener/endpoint-publication.ts b/src/shared/agent-hook-listener/endpoint-publication.ts new file mode 100644 index 00000000000..cb09f204a01 --- /dev/null +++ b/src/shared/agent-hook-listener/endpoint-publication.ts @@ -0,0 +1,82 @@ +import { randomUUID } from 'node:crypto' +import { chmodSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { sweepStaleAgentHookEndpointTemps } from '../agent-hook-endpoint-temp-cleanup' + +// ─── Endpoint-file writing ────────────────────────────────────────── + +export function getEndpointFileName(): string { + // Why: per-platform extension lets hook scripts source the file natively (POSIX `. "$file"` / Windows `call "%file%"`); the OpenCode plugin regex accepts both shapes. + return process.platform === 'win32' ? 'endpoint.cmd' : 'endpoint.env' +} + +export function isShellSafeEndpointValue(value: string): boolean { + // Why: values are shell-sourced; the + rejects empty strings so a sourced `KEY=` can't clear the env var. + return /^[A-Za-z0-9._:/-]+$/.test(value) +} + +export type EndpointFileFields = { + port: number + token: string + env: string + version: string +} + +/** Atomically write the endpoint file at `endpointDir/`. + * Returns true on success, false on error (caller may fall back to PTY env). + * Kept in sync with `AgentHookServer.writeEndpointFile`. */ +export function writeEndpointFile( + endpointDir: string, + finalPath: string, + fields: EndpointFileFields +): boolean { + const tmpPath = join(endpointDir, `.endpoint-${process.pid}-${randomUUID()}.tmp`) + const prefix = process.platform === 'win32' ? 'set ' : '' + const valuesToWrite: [string, string][] = [ + ['ORCA_AGENT_HOOK_PORT', String(fields.port)], + ['ORCA_AGENT_HOOK_TOKEN', fields.token], + ['ORCA_AGENT_HOOK_ENV', fields.env], + ['ORCA_AGENT_HOOK_VERSION', fields.version] + ] + for (const [key, value] of valuesToWrite) { + if (!isShellSafeEndpointValue(value)) { + console.error( + `[agent-hooks] refusing to write endpoint file: ${key} contains ` + + 'characters unsafe for shell sourcing. Falling back to PTY env.' + ) + return false + } + } + const lines = [...valuesToWrite.map(([key, value]) => `${prefix}${key}=${value}`), ''] + let tmpWritten = false + try { + // Why: 0o700 owner-only so the dir doesn't leak this install's existence to other local users. + mkdirSync(endpointDir, { recursive: true, mode: 0o700 }) + if (process.platform !== 'win32') { + // Why: mkdirSync mode only applies on creation; chmod fixes perms on a pre-existing dir (POSIX-only). + try { + chmodSync(endpointDir, 0o700) + } catch { + // best-effort + } + } + // Why: crash-orphan cleanup must not materialize a tampered, enormous directory. + sweepStaleAgentHookEndpointTemps(endpointDir) + const separator = process.platform === 'win32' ? '\r\n' : '\n' + writeFileSync(tmpPath, lines.join(separator), { mode: 0o600 }) + tmpWritten = true + renameSync(tmpPath, finalPath) + return true + } catch (err) { + console.error('[agent-hooks] failed to write endpoint file:', err) + if (tmpWritten) { + try { + unlinkSync(tmpPath) + } catch { + // tmp may already be gone + } + } + return false + } +} diff --git a/src/shared/agent-hook-listener/grok-result-discovery.ts b/src/shared/agent-hook-listener/grok-result-discovery.ts new file mode 100644 index 00000000000..7cdc306082e --- /dev/null +++ b/src/shared/agent-hook-listener/grok-result-discovery.ts @@ -0,0 +1,221 @@ +import { homedir } from 'node:os' +import { isAbsolute, join } from 'node:path' + +import type { AgentHookSource } from '../agent-hook-relay' +import { + buildGrokChatHistoryPathCandidates, + findGrokChatHistoryBySessionId, + getCachedGrokChatHistoryBySessionId, + GROK_SESSION_ID_MAX_LENGTH, + isSafeGrokSessionId, + resolveGrokChatHistoryPathSync, + resolveGrokSessionsDir +} from '../grok-session-paths' +import { readFirstString } from './interactive-tool' +import { parseAgentHookJson } from './request-body' +import { GROK_HOME_ENVELOPE_MAX_LENGTH, GROK_SESSION_CWD_MAX_LENGTH } from './listener-limits' +import { readLastAssistantFromTranscriptOnce } from './transcript-reader' +import { isGrokEvent } from './provider-event-names' +import { isAntigravityStopStillBusy } from './providers/antigravity-event-rules' + +export function parseHookBodyPayloadRecord(body: unknown): Record | null { + if (typeof body !== 'object' || body === null) { + return null + } + const rawPayload = (body as Record).payload + const payload = + typeof rawPayload === 'string' + ? (() => { + try { + return parseAgentHookJson(rawPayload) + } catch { + return null + } + })() + : rawPayload + return typeof payload === 'object' && payload !== null + ? (payload as Record) + : null +} + +export function readBoundedString( + record: Record, + keys: readonly string[], + maxLength: number +): string | undefined { + const value = readFirstString(record, keys) + return value && value.length <= maxLength ? value : undefined +} + +export function readGrokHomeEnvelope(record: Record): string | undefined { + const value = readBoundedString(record, ['grokHome'], GROK_HOME_ENVELOPE_MAX_LENGTH) + if (!value || value !== value.trim() || !isAbsolute(value) || hasControlCharacter(value)) { + return undefined + } + return value +} + +export function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const code = character.charCodeAt(0) + return code <= 0x1f || code === 0x7f + }) +} + +type GrokSessionMetadata = { + sessionId: string + cwd?: string + sessionsDir: string +} + +export function readGrokSessionMetadata( + hookPayload: Record, + grokHome?: string +): GrokSessionMetadata | undefined { + const sessionId = readBoundedString( + hookPayload, + ['sessionId', 'session_id'], + GROK_SESSION_ID_MAX_LENGTH + ) + if (!sessionId || !isSafeGrokSessionId(sessionId)) { + return undefined + } + const cwd = readBoundedString( + hookPayload, + ['cwd', 'workspaceRoot', 'workspace_root'], + GROK_SESSION_CWD_MAX_LENGTH + ) + // Why: hook scripts report the effective per-PTY/remote Grok home; old scripts fall back to the runtime's for compatibility. + const sessionsDir = grokHome + ? join(grokHome, 'sessions') + : resolveGrokSessionsDir(process.env, homedir()) + return { sessionId, cwd, sessionsDir } +} + +export function getGrokChatHistoryPath( + hookPayload: Record, + grokHome?: string +): string | undefined { + const metadata = readGrokSessionMetadata(hookPayload, grokHome) + if (!metadata) { + return undefined + } + const resolved = resolveGrokChatHistoryPathSync({ + sessionId: metadata.sessionId, + cwd: metadata.cwd ?? null, + sessionsDir: metadata.sessionsDir + }) + if (resolved) { + return resolved + } + const cached = getCachedGrokChatHistoryBySessionId(metadata.sessionsDir, metadata.sessionId) + if (cached) { + return cached + } + // Why: SessionEnd can race the last write; return a plausible on-disk candidate (short-cwd preferred) even if the file doesn't exist yet. + if (!metadata.cwd) { + return undefined + } + return ( + buildGrokChatHistoryPathCandidates({ + sessionId: metadata.sessionId, + cwd: metadata.cwd, + sessionsDir: metadata.sessionsDir + })[0] ?? undefined + ) +} + +export function readLastAssistantFromGrokChatHistory( + hookPayload: Record, + grokHome?: string +): string | undefined { + const chatHistoryPath = getGrokChatHistoryPath(hookPayload, grokHome) + if (!chatHistoryPath) { + return undefined + } + return readLastAssistantFromTranscriptOnce(chatHistoryPath) +} + +export function hasPendingAgentResultText(source: AgentHookSource, body: unknown): boolean { + const envelope = + typeof body === 'object' && body !== null ? (body as Record) : null + const record = parseHookBodyPayloadRecord(body) + if (!record) { + return false + } + if (hasExplicitLastAssistantResult(record)) { + return false + } + if (source === 'copilot') { + // Why: Copilot Stop uses generic `message` as final assistant text; Grok/Antigravity use that field for status instead. + if (hasNonEmptyString(record.message)) { + return false + } + const transcriptPath = record.transcript_path ?? record.transcriptPath + return typeof transcriptPath === 'string' && transcriptPath.trim().length > 0 + } + const eventName = + envelope?.hook_event_name ?? + envelope?.hookEventName ?? + record.hook_event_name ?? + record.hookEventName + if (source === 'antigravity' && eventName === 'Stop') { + if (isAntigravityStopStillBusy(record)) { + return false + } + const transcriptPath = record.transcriptPath ?? record.transcript_path + return typeof transcriptPath === 'string' && transcriptPath.trim().length > 0 + } + const pendingGrokDiscovery = preparePendingGrokResultDiscovery(source, body) + if (pendingGrokDiscovery) { + void pendingGrokDiscovery + return true + } + return false +} + +export function hasNonEmptyString(value: unknown): boolean { + return typeof value === 'string' && value.trim().length > 0 +} + +export function hasExplicitLastAssistantResult(record: Record): boolean { + return ( + hasNonEmptyString(record.last_assistant_message) || + hasNonEmptyString(record.lastAssistantMessage) + ) +} + +/** Start bounded discovery only for a Grok completion that still needs result text. */ +export function preparePendingGrokResultDiscovery( + source: AgentHookSource, + body: unknown +): Promise | null { + if (source !== 'grok') { + return null + } + const envelope = + typeof body === 'object' && body !== null ? (body as Record) : null + const record = parseHookBodyPayloadRecord(body) + if (!record || hasExplicitLastAssistantResult(record)) { + return null + } + const eventName = + envelope?.hook_event_name ?? + envelope?.hookEventName ?? + record.hook_event_name ?? + record.hookEventName + if (!isGrokEvent(eventName, 'stop', 'session_end')) { + return null + } + const metadata = readGrokSessionMetadata( + record, + envelope ? readGrokHomeEnvelope(envelope) : undefined + ) + if (!metadata) { + return null + } + // Why: lets the server await discovery without moving filesystem I/O back into synchronous hook normalization. + return findGrokChatHistoryBySessionId(metadata.sessionsDir, metadata.sessionId).then( + () => undefined + ) +} diff --git a/src/shared/agent-hook-listener/hook-envelope.ts b/src/shared/agent-hook-listener/hook-envelope.ts new file mode 100644 index 00000000000..5435f9fe912 --- /dev/null +++ b/src/shared/agent-hook-listener/hook-envelope.ts @@ -0,0 +1,79 @@ +import type { AgentHookSource } from '../agent-hook-relay' +import { parsePaneKey } from '../stable-pane-id' +import { MAX_PANE_KEY_LEN, warnOnHookEnvOrVersionMismatch } from './listener-limits' +import type { HookListenerState } from './listener-state' +import { parseAgentHookJson } from './request-body' +function readEnvelopeString(record: Record, key: string): string | undefined { + const value = record[key] + if (typeof value !== 'string') { + return undefined + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +export type ParsedHookEnvelope = { + record: Record + paneKey: string + hookPayloadRecord: Record + tabId?: string + worktreeId?: string + launchToken?: string +} + +/** Validates the transport envelope while preserving warning-before-tab-rejection order. */ +export function parseHookEnvelope( + state: HookListenerState, + source: AgentHookSource, + body: unknown, + expectedEnv: string +): ParsedHookEnvelope | null { + if (typeof body !== 'object' || body === null) { + return null + } + const record = body as Record + const paneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' + const parsedPaneKey = parsePaneKey(paneKey) + const rawPayload = record.payload + // Antigravity may carry a transition with absent stdin; every other provider requires payload. + const antigravityPayloadAbsent = + source === 'antigravity' && + (rawPayload === undefined || (typeof rawPayload === 'string' && rawPayload.trim() === '')) + const hookPayload = antigravityPayloadAbsent + ? {} + : typeof rawPayload === 'string' + ? (() => { + try { + return parseAgentHookJson(rawPayload) + } catch { + return null + } + })() + : rawPayload + if ( + !paneKey || + paneKey.length > MAX_PANE_KEY_LEN || + !parsedPaneKey || + typeof hookPayload !== 'object' || + hookPayload === null + ) { + return null + } + warnOnHookEnvOrVersionMismatch(state, { + version: readEnvelopeString(record, 'version'), + env: readEnvelopeString(record, 'env'), + expectedEnv + }) + const tabId = readEnvelopeString(record, 'tabId') + if (tabId && tabId !== parsedPaneKey.tabId) { + return null + } + return { + record, + paneKey, + hookPayloadRecord: hookPayload as Record, + tabId, + worktreeId: readEnvelopeString(record, 'worktreeId'), + launchToken: readEnvelopeString(record, 'launchToken') + } +} diff --git a/src/shared/agent-hook-listener/interactive-tool.ts b/src/shared/agent-hook-listener/interactive-tool.ts new file mode 100644 index 00000000000..bc3042cd5ca --- /dev/null +++ b/src/shared/agent-hook-listener/interactive-tool.ts @@ -0,0 +1,124 @@ +import { isAskUserQuestionTool } from '../agent-question-answered-intent' +import type { ToolSnapshot } from './listener-event' +import { parseAgentHookJson } from './request-body' +import { readString, toolUpdate } from './tool-input-preview' +import { normalizeHookEventName } from './provider-event-names' + +/** Clear active-tool metadata so a failed tool stops looking in-flight (else the compact sidebar hides the error behind the tool name). */ +export function clearActiveToolFieldsUpdate(): ToolSnapshot { + return toolUpdate( + { toolName: undefined, toolInput: undefined, interactivePrompt: undefined }, + { hasToolInputField: true } + ) +} + +/** Drop the hook envelope keys a plugin merges into event properties so the serialized prompt holds only the question structure. */ +export function stripHookEnvelopeKeys(record: Record): Record { + const { hook_event_name: _h, hookEventName: _he, ...rest } = record + return rest +} + +/** One-line description of a tool call for an approval card (Bash command, file path, else clipped JSON). */ +export function summarizeApprovalInput(toolInput: unknown): string { + if (toolInput && typeof toolInput === 'object') { + const obj = toolInput as Record + const direct = obj.command ?? obj.file_path ?? obj.path ?? obj.url ?? obj.pattern + if (typeof direct === 'string' && direct.length > 0) { + return direct.length > 200 ? `${direct.slice(0, 200)}…` : direct + } + } + try { + const json = JSON.stringify(toolInput) ?? '' + return json.length > 200 ? `${json.slice(0, 200)}…` : json + } catch { + return '' + } +} + +/** Normalized JSON envelope for a pending prompt: AskUserQuestion → `{ questions }` (shape kept stable for back-compat); other tool on PermissionRequest → `{ approval }`; else undefined. */ +export function deriveInteractivePrompt( + toolName: string | undefined, + toolInput: unknown, + eventName?: unknown +): string | undefined { + // Why: providers vary casing; any post-tool event means the question is no longer pending — don't recreate its answered card. + const normalizedEventName = normalizeHookEventName(eventName) + const isPostToolEvent = + normalizedEventName === 'post_tool_use' || normalizedEventName === 'post_tool_use_failure' + if ( + isAskUserQuestionTool(toolName) && + !isPostToolEvent && + toolInput !== undefined && + toolInput !== null + ) { + try { + return JSON.stringify(toolInput) + } catch { + // Why: circular/unserializable input from a buggy agent — a missing live card beats throwing in the hook hot path. + return undefined + } + } + if (eventName === 'PermissionRequest' && typeof toolName === 'string' && toolName.length > 0) { + try { + return JSON.stringify({ + approval: { tool: toolName, summary: summarizeApprovalInput(toolInput) } + }) + } catch { + return undefined + } + } + return undefined +} + +export function readFirstString( + record: Record, + keys: readonly string[] +): string | undefined { + for (const key of keys) { + const value = readString(record, key) + if (value) { + return value + } + } + return undefined +} + +export function parseJsonObjectString(value: unknown): Record | undefined { + if (typeof value !== 'string' || value.trim().length === 0) { + return undefined + } + try { + const parsed = parseAgentHookJson(value) + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : undefined + } catch { + return undefined + } +} + +export function extractToolResponseText(toolResponse: unknown): string | undefined { + if (typeof toolResponse === 'string' && toolResponse.length > 0) { + return toolResponse + } + if (typeof toolResponse !== 'object' || toolResponse === null) { + return undefined + } + const record = toolResponse as Record + const directText = readFirstString(record, ['text_result_for_llm', 'textResultForLlm', 'text']) + if (directText) { + return directText + } + const content = record.content + if (Array.isArray(content)) { + for (const part of content) { + if (typeof part === 'object' && part !== null) { + const text = (part as Record).text + if (typeof text === 'string' && text.trim().length > 0) { + return text + } + } + } + } + return undefined +} diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts new file mode 100644 index 00000000000..cfbe6394a71 --- /dev/null +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -0,0 +1,55 @@ +import type { ParsedAgentStatusPayload } from '../agent-status-types' +import type { AgentHookSource } from '../agent-hook-relay' +import type { AgentProviderSessionMetadata } from '../agent-session-resume' + +export type AgentHookEventPayload = { + paneKey: string + /** Authenticated hook route that produced this event. */ + source?: AgentHookSource + /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ + launchToken?: string + tabId?: string + worktreeId?: string + /** SSH connection the event arrived on, or null for local. Only `ingestRemote` can stamp it — the loopback HTTP path has no mux identity — and receivers key off it to drop + * in-flight events from a superseded connection after an SSH reconnect. */ + connectionId: string | null + /** True when the event carried prompt text directly, not the listener's cached prompt from an earlier event in the pane. */ + hasExplicitPrompt?: boolean + /** The emitted nonterminal state is backed only by child state restored from disk. */ + restoredUnconfirmed?: true + /** Stable per-turn key to distinguish duplicate hook delivery from a same-text prompt rerun (when the source exposes enough context). */ + promptInteractionKey?: string + /** Raw agent hook event name, used by main-process transition guards. */ + hookEventName?: string + /** Claude's provider-owned user-prompt UUID. */ + providerPromptId?: string + /** Active Claude compact generation, keyed by provider prompt identity. */ + compactTrigger?: 'manual' | 'auto' + /** Claude tool-use identifier when the hook source exposes one. */ + toolUseId?: string + /** Claude agent/subagent identifier when the hook source exposes one. */ + toolAgentId?: string + /** Claude teammate name carried by TeammateIdle. */ + teammateName?: string + /** Agent/subagent type from the source hook payload, when present. */ + toolAgentType?: string + /** Provider-owned conversation/session id needed to resume a sleeping agent. */ + providerSession?: AgentProviderSessionMetadata + /** Session identity update with no turn-state transition; refreshes durable resume metadata without a fake status row. */ + providerSessionOnly?: boolean + /** True when this event is a relay cache replay rather than a live hook. */ + isReplay?: boolean + /** Transport-only Claude background-work evidence used to reject false input-based interrupts. */ + claudeRunningNonAgentTask?: boolean + payload: ParsedAgentStatusPayload +} +export type ToolSnapshot = { + toolName?: string + toolInput?: string + /** Full JSON of an AskUserQuestion tool input; set only on its own event and NOT inherited (resolveToolState) so no stale prompt lingers. */ + interactivePrompt?: string + hasToolUpdate?: boolean + hasToolInputField?: boolean + lastAssistantMessage?: string + clearLastAssistantMessage?: boolean +} diff --git a/src/shared/agent-hook-listener/listener-limits.ts b/src/shared/agent-hook-listener/listener-limits.ts new file mode 100644 index 00000000000..872f0a617ba --- /dev/null +++ b/src/shared/agent-hook-listener/listener-limits.ts @@ -0,0 +1,63 @@ +import { ORCA_HOOK_PROTOCOL_VERSION } from '../agent-hook-types' +import { REMOTE_AGENT_HOOK_ENV } from '../agent-hook-relay' +import type { HookListenerState } from './listener-state' + +/** Bound the warn-once Sets so a client varying `version`/`env` per request can't grow them unbounded. */ +const MAX_WARNED_KEYS = 32 + +/** Slowloris cap: drop requests that have not finished sending after 5 s. */ +export const HOOK_REQUEST_SLOWLORIS_MS = 5_000 + +/** Why: old OpenCode plugin builds re-post the full accumulated reply on every streamed part (O(n²) bytes/turn); cap at ingest to bound per-event cost. */ +export const OPENCODE_HOOK_TEXT_MAX_CHARS = 8_000 + +export function capOpenCodeHookText(text: string): string { + return text.length > OPENCODE_HOOK_TEXT_MAX_CHARS + ? text.slice(0, OPENCODE_HOOK_TEXT_MAX_CHARS) + : text +} + +/** Bound paneKey size (real keys are well under 200); caps per-pane caches against pathological input. Exported so non-HTTP ingest (`ingestRemote`) applies the same cap as defense-in-depth. */ +export const MAX_PANE_KEY_LEN = 200 +export const AMP_THREAD_ID_MAX_LENGTH = 256 +export const AMP_MAX_SCOPED_THREAD_CACHE_KEYS = 32 +export const GROK_SESSION_CWD_MAX_LENGTH = 4096 +export const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 +const CLAUDE_PROMPT_ID_RE = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i + +export function normalizeClaudePromptId(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const normalized = value.trim().toLowerCase() + return CLAUDE_PROMPT_ID_RE.test(normalized) ? normalized : undefined +} +/** Warn-once on cross-build (`version`) and dev-vs-prod (`env`) mismatches; the relay's "remote" env marker is a location tag, not a build env, so it must not warn as a stale local hook. */ +export function warnOnHookEnvOrVersionMismatch( + state: HookListenerState, + fields: { version?: string; env?: string; expectedEnv: string } +): void { + const { version, env, expectedEnv } = fields + if ( + version && + version !== ORCA_HOOK_PROTOCOL_VERSION && + !state.warnedVersions.has(version) && + state.warnedVersions.size < MAX_WARNED_KEYS + ) { + state.warnedVersions.add(version) + console.warn( + `[agent-hooks] received hook v${version}; server expects v${ORCA_HOOK_PROTOCOL_VERSION}. ` + + 'Reinstall agent hooks from Settings to upgrade the managed script.' + ) + } + if (env && env !== REMOTE_AGENT_HOOK_ENV && env !== expectedEnv) { + const key = `${env}->${expectedEnv}` + if (!state.warnedEnvs.has(key) && state.warnedEnvs.size < MAX_WARNED_KEYS) { + state.warnedEnvs.add(key) + console.warn( + `[agent-hooks] received ${env} hook on ${expectedEnv} server. ` + + 'Likely a stale terminal from another Orca install.' + ) + } + } +} diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts new file mode 100644 index 00000000000..f6f9308dc62 --- /dev/null +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -0,0 +1,216 @@ +import type { AgentStatusState } from '../agent-status-types' +import type { ClaudeSubagentRoster } from '../claude-subagent-roster' +import type { CodexSubagentRoster } from '../codex-subagent-roster' +import type { CodexSubagentTranscriptState } from '../codex-subagent-transcript' +import type { AgentHookEventPayload, ToolSnapshot } from './listener-event' + +/** Per-listener-instance caches needing per-PTY teardown; Orca's main process and the relay each get their own, never shared. */ +export type HookListenerState = { + warnedVersions: Set + warnedEnvs: Set + lastPromptByPaneKey: Map + lastToolByPaneKey: Map + lastStatusByPaneKey: Map + antigravityCompletedTranscriptByPaneKey: Map + ampCompletedCacheKeys: Set + /** Live subagents/teammates per Claude pane; survives turn boundaries since background children outlive the lead turn. */ + claudeSubagentRosterByPaneKey: Map + /** Last state from the LEAD session's own events (subagent events carry agent_id, excluded), so a SubagentStop can re-emit pane status; `interrupted` persists so the eventual done still carries it. */ + claudeLeadStateByPaneKey: Map + /** One-normalization provenance marker for a status backed only by restored child state. */ + claudeUnconfirmedRestoredStatusPaneKeys: Set + /** Panes whose latest authoritative Claude task inventory still has running non-agent work. */ + claudeRunningNonAgentTaskPaneKeys: Set + /** Panes whose latest authoritative Claude cron inventory still has a scheduled job. */ + claudeActiveSessionCronPaneKeys: Set + /** Compact whose completion each pane already applied, so relay duplicates can't refresh the row. */ + claudeConsumedCompactPromptIdByPaneKey: Map + /** Claude `session_id` that last reported on the pane from a LEAD event. A different id means the + * conversation was replaced (/clear, relaunch, resume), so claims the old session owned are void + * even when no SessionStart arrives — the backstop for the exits that emit no terminating hook. */ + claudeSessionOwnerByPaneKey: Map + /** Live thread-spawn children per Codex pane. */ + codexSubagentRosterByPaneKey: Map + /** Incremental parent/child rollout cursors for Codex collaboration v2. */ + codexSubagentTranscriptByPaneKey: Map + /** Root Codex state/model, kept separate from child hook traffic. */ + codexLeadStateByPaneKey: Map +} + +export type ClaudeLeadTurnState = { + state: AgentStatusState + interrupted?: true + /** Subagent that induced the wait; only its next tool activity may clear it, so other children's churn can't dismiss a pending human-input card. */ + waitingAgentId?: string + /** Tool call that owns the wait; late completions from parallel sibling tools must not dismiss its card. */ + waitingToolUseId?: string + /** End time of the lead turn closed while background inventory kept the pane `working`. Repeated on the later all-clear `done`. */ + turnCompletedAt?: number + /** Lead state a child-induced wait displaced, restored when the wait clears; can't invent 'working' since the done-gate only downgrades done→working, never back. */ + stateBeforeWait?: Pick +} + +export type CodexLeadTurnState = { + state: 'working' | 'waiting' | 'done' + model?: string +} + +export function createHookListenerState(): HookListenerState { + return { + warnedVersions: new Set(), + warnedEnvs: new Set(), + lastPromptByPaneKey: new Map(), + lastToolByPaneKey: new Map(), + lastStatusByPaneKey: new Map(), + antigravityCompletedTranscriptByPaneKey: new Map(), + ampCompletedCacheKeys: new Set(), + claudeSubagentRosterByPaneKey: new Map(), + claudeLeadStateByPaneKey: new Map(), + claudeUnconfirmedRestoredStatusPaneKeys: new Set(), + claudeRunningNonAgentTaskPaneKeys: new Set(), + claudeActiveSessionCronPaneKeys: new Set(), + claudeConsumedCompactPromptIdByPaneKey: new Map(), + claudeSessionOwnerByPaneKey: new Map(), + codexSubagentRosterByPaneKey: new Map(), + codexSubagentTranscriptByPaneKey: new Map(), + codexLeadStateByPaneKey: new Map() + } +} + +export function clearPaneCacheState(state: HookListenerState, paneKey: string): void { + deletePaneScopedCacheEntry(state.lastPromptByPaneKey, paneKey) + deletePaneScopedCacheEntry(state.lastToolByPaneKey, paneKey) + deletePaneScopedCacheEntry(state.lastStatusByPaneKey, paneKey) + deletePaneScopedCacheEntry(state.antigravityCompletedTranscriptByPaneKey, paneKey) + deletePaneScopedSetEntry(state.ampCompletedCacheKeys, paneKey) + deletePaneScopedCacheEntry(state.claudeConsumedCompactPromptIdByPaneKey, paneKey) + state.claudeSubagentRosterByPaneKey.delete(paneKey) + state.claudeLeadStateByPaneKey.delete(paneKey) + state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey) + state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + state.claudeActiveSessionCronPaneKeys.delete(paneKey) + state.claudeSessionOwnerByPaneKey.delete(paneKey) + state.codexSubagentRosterByPaneKey.delete(paneKey) + state.codexSubagentTranscriptByPaneKey.delete(paneKey) + state.codexLeadStateByPaneKey.delete(paneKey) +} + +/** Does this pane still hold anything that can ASSERT a state — a stored row, or a Claude latch that + * `resolveClaudePaneStatus` would re-gate `working` from on the pane's next event? + * + * Deliberately lives next to `clearPaneCacheState` above and enumerates the claim-bearing subset of + * what that function deletes: the two must be edited together, and keeping them three lines apart in + * one file is what makes that obvious. Prompt/tool/transcript caches are excluded — they render a + * row, they never create one. */ +export function paneHasStateClaims(state: HookListenerState, paneKey: string): boolean { + return ( + state.lastStatusByPaneKey.has(paneKey) || + state.claudeSubagentRosterByPaneKey.has(paneKey) || + state.claudeLeadStateByPaneKey.has(paneKey) || + state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + state.claudeActiveSessionCronPaneKeys.has(paneKey) || + state.claudeSessionOwnerByPaneKey.has(paneKey) || + state.codexSubagentRosterByPaneKey.has(paneKey) || + state.codexLeadStateByPaneKey.has(paneKey) + ) +} + +export function movePaneScopedMapEntries( + map: Map, + fromPaneKey: string, + toPaneKey: string +): void { + for (const [key, value] of Array.from(map.entries())) { + if (key !== fromPaneKey && !key.startsWith(`${fromPaneKey}\0`)) { + continue + } + map.delete(key) + map.set(`${toPaneKey}${key.slice(fromPaneKey.length)}`, value) + } +} + +export function movePaneScopedSetEntries( + set: Set, + fromPaneKey: string, + toPaneKey: string +): void { + for (const key of Array.from(set)) { + if (key !== fromPaneKey && !key.startsWith(`${fromPaneKey}\0`)) { + continue + } + set.delete(key) + set.add(`${toPaneKey}${key.slice(fromPaneKey.length)}`) + } +} + +export function movePaneCacheState( + state: HookListenerState, + fromPaneKey: string, + toPaneKey: string +): void { + if (fromPaneKey === toPaneKey) { + return + } + movePaneScopedMapEntries(state.lastPromptByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.lastToolByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.lastStatusByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.antigravityCompletedTranscriptByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedSetEntries(state.ampCompletedCacheKeys, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.claudeConsumedCompactPromptIdByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.claudeSubagentRosterByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.claudeLeadStateByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedSetEntries(state.claudeUnconfirmedRestoredStatusPaneKeys, fromPaneKey, toPaneKey) + movePaneScopedSetEntries(state.claudeRunningNonAgentTaskPaneKeys, fromPaneKey, toPaneKey) + movePaneScopedSetEntries(state.claudeActiveSessionCronPaneKeys, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.claudeSessionOwnerByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.codexSubagentRosterByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) +} + +export function clearPaneTurnCacheState(state: HookListenerState, paneKey: string): void { + state.lastPromptByPaneKey.delete(paneKey) + state.lastToolByPaneKey.delete(paneKey) + state.antigravityCompletedTranscriptByPaneKey.delete(paneKey) + state.ampCompletedCacheKeys.delete(paneKey) +} + +export function deletePaneScopedCacheEntry(map: Map, paneKey: string): void { + map.delete(paneKey) + const scopedPrefix = `${paneKey}\0` + for (const key of map.keys()) { + if (key.startsWith(scopedPrefix)) { + map.delete(key) + } + } +} + +export function deletePaneScopedSetEntry(set: Set, paneKey: string): void { + set.delete(paneKey) + const scopedPrefix = `${paneKey}\0` + for (const key of set) { + if (key.startsWith(scopedPrefix)) { + set.delete(key) + } + } +} + +export function clearAllListenerCaches(state: HookListenerState): void { + state.lastPromptByPaneKey.clear() + state.lastToolByPaneKey.clear() + state.lastStatusByPaneKey.clear() + state.antigravityCompletedTranscriptByPaneKey.clear() + state.ampCompletedCacheKeys.clear() + state.claudeConsumedCompactPromptIdByPaneKey.clear() + state.warnedVersions.clear() + state.warnedEnvs.clear() + state.claudeSubagentRosterByPaneKey.clear() + state.claudeLeadStateByPaneKey.clear() + state.claudeUnconfirmedRestoredStatusPaneKeys.clear() + state.claudeRunningNonAgentTaskPaneKeys.clear() + state.claudeActiveSessionCronPaneKeys.clear() + state.claudeSessionOwnerByPaneKey.clear() + state.codexSubagentRosterByPaneKey.clear() + state.codexSubagentTranscriptByPaneKey.clear() + state.codexLeadStateByPaneKey.clear() +} diff --git a/src/shared/agent-hook-listener/prompt-fields.ts b/src/shared/agent-hook-listener/prompt-fields.ts new file mode 100644 index 00000000000..d76f67f06a8 --- /dev/null +++ b/src/shared/agent-hook-listener/prompt-fields.ts @@ -0,0 +1,153 @@ +import { + isCompactContinuationUserTurnText, + isKnownHarnessInjectedUserTurnText +} from '../harness-injected-user-turns' +import { capOpenCodeHookText } from './listener-limits' +import type { HookListenerState } from './listener-state' +import type { ToolSnapshot } from './listener-event' + +// ─── Per-pane field caches + extractors ───────────────────────────── + +export type ExtractedPromptText = { + text: string + source: + | 'prompt' + | 'user_prompt' + | 'userPrompt' + | 'initial_prompt' + | 'initialPrompt' + | 'user_message' + | 'message' + | 'role_user_text' + | null +} + +// Joins text of an Anthropic-style content-block array; returns '' when nothing textual so callers fall through to the next prompt source. +export function contentBlockArrayText(value: unknown[]): string { + const parts: string[] = [] + for (const item of value) { + if (typeof item === 'string') { + parts.push(item) + continue + } + if (item && typeof item === 'object') { + const text = (item as Record).text + if (typeof text === 'string') { + parts.push(text) + } + } + } + return parts.join(' ').replace(/\s+/g, ' ').trim() +} + +export function extractPromptText(hookPayload: Record): ExtractedPromptText { + const candidateKeys = [ + 'prompt', + 'user_prompt', + 'userPrompt', + 'initial_prompt', + 'initialPrompt', + 'user_message', + 'message' + ] + for (const key of candidateKeys) { + const value = hookPayload[key] + if (typeof value === 'string' && value.trim().length > 0) { + // Why: trim so prompts match readStringField output — whitespace would otherwise leak into UI and caches. + return { text: value.trim(), source: key as Exclude } + } + // Why: Kimi sends `prompt` as a content-block array, not a string; extract it for real prompt keys but skip `message` (ambiguous status field). + if (key !== 'message' && Array.isArray(value)) { + const text = contentBlockArrayText(value) + if (text.length > 0) { + return { text, source: key as Exclude } + } + } + } + // Why: OpenCode sends MessagePart { role, text } with no UserPromptSubmit; when role === 'user' the text is the prompt. + if (hookPayload.role === 'user' && typeof hookPayload.text === 'string') { + const trimmed = capOpenCodeHookText(hookPayload.text.trim()) + if (trimmed.length > 0) { + return { text: trimmed, source: 'role_user_text' } + } + } + return { text: '', source: null } +} + +export function stripGrokUserQueryWrapper(promptText: string): string { + const opener = '' + if (!promptText.startsWith(opener)) { + return promptText + } + const closer = '' + const wrappedText = promptText.slice(opener.length) + const text = wrappedText.endsWith(closer) ? wrappedText.slice(0, -closer.length) : wrappedText + // Why: Grok wraps the submitted prompt in a `` envelope; the status cache should hold the plain user text. + return text.trim() +} + +// Why: the post-compact continuation prompt has no matching Stop and would resurrect working. +export function shouldIgnoreCompactContinuationUserPromptSubmit( + eventName: unknown, + promptText: string +): boolean { + return eventName === 'UserPromptSubmit' && isCompactContinuationUserTurnText(promptText) +} + +export function resolvePrompt( + state: HookListenerState, + paneKey: string, + promptText: string, + options?: { resetOnNewTurn?: boolean } +): string { + // Why: harness-injected turns fire UserPromptSubmit but aren't the user's ask — keep cached prompt; match only known tags so real still reset the turn. + if (isKnownHarnessInjectedUserTurnText(promptText)) { + return state.lastPromptByPaneKey.get(paneKey) ?? '' + } + if (options?.resetOnNewTurn) { + state.lastPromptByPaneKey.delete(paneKey) + } + if (promptText) { + state.lastPromptByPaneKey.set(paneKey, promptText) + return promptText + } + return state.lastPromptByPaneKey.get(paneKey) ?? '' +} + +export function resolveToolState( + state: HookListenerState, + paneKey: string, + update: ToolSnapshot, + options: { resetOnNewTurn: boolean } +): ToolSnapshot { + if (options.resetOnNewTurn) { + state.lastToolByPaneKey.delete(paneKey) + } + const previous = state.lastToolByPaneKey.get(paneKey) ?? {} + // Why: undefined means either "no update" or "input not previewable"; extractor metadata decides whether to inherit stale input. + const clearsUnpreviewableInput = + update.hasToolInputField === true && update.toolInput === undefined + const clearsUnidentifiedTool = + update.hasToolUpdate === true && + update.toolName === undefined && + update.hasToolInputField === true + const toolName = clearsUnidentifiedTool ? undefined : (update.toolName ?? previous.toolName) + const toolInput = + clearsUnpreviewableInput || + (update.toolName !== undefined && + update.toolName !== previous.toolName && + update.toolInput === undefined) + ? undefined + : (update.toolInput ?? previous.toolInput) + const merged: ToolSnapshot = { + toolName, + toolInput, + // Why: don't inherit previous.interactivePrompt — valid only for its one AskUserQuestion event; carrying it forward leaves a stale live card. + interactivePrompt: update.interactivePrompt, + lastAssistantMessage: update.clearLastAssistantMessage + ? undefined + : (update.lastAssistantMessage ?? previous.lastAssistantMessage) + } + state.lastToolByPaneKey.set(paneKey, merged) + return merged +} diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts new file mode 100644 index 00000000000..473bb557d09 --- /dev/null +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -0,0 +1,154 @@ +import type { ParsedAgentStatusPayload } from '../agent-status-types' +import type { AgentHookSource } from '../agent-hook-relay' +import { readLastCommandCodeUserPromptEntryFromTranscript } from './command-code-transcript' +import { readGrokHomeEnvelope } from './grok-result-discovery' +import { readFirstString } from './interactive-tool' +import type { HookListenerState } from './listener-state' +import type { ExtractedPromptText } from './prompt-fields' +import { isNewTurnEvent } from './provider-event-routing' +import { readLastUserPromptFromTranscript } from './transcript-lines' +import { normalizeAntigravityEvent } from './providers/antigravity-events' +import { normalizeAmpEvent } from './providers/amp-events' +import { normalizeClaudeEvent } from './providers/claude-events' +import { normalizeCodexEvent } from './providers/codex-events' +import { normalizeGeminiEvent } from './providers/gemini-events' +import { normalizeOpenCodeFamilyEvent } from './providers/opencode-family-events' +import { normalizeCursorEvent } from './providers/cursor-events' +import { normalizePiCompatibleEvent } from './providers/pi-family-events' +import { normalizeDroidEvent } from './providers/droid-events' +import { normalizeCommandCodeEvent } from './providers/command-code-events' +import { normalizeGrokEvent } from './providers/grok-events' +import { normalizeCopilotEvent } from './providers/copilot-events' +import { normalizeHermesEvent } from './providers/hermes-events' +import { normalizeDevinEvent } from './providers/devin-events' +import { normalizeKimiEvent } from './providers/kimi-events' + +export type ProviderDispatchResult = { + payload: ParsedAgentStatusPayload | null + resolvedPromptText: string + promptInteractionKey?: string + hasTranscriptPromptEvidence: boolean +} + +/** Exhaustive provider routing with provider-specific transcript locality and attribution. */ +export function normalizeProviderEvent(input: { + state: HookListenerState + source: AgentHookSource + eventName: unknown + promptText: string + paneKey: string + hookPayload: Record + envelope: Record + extractedPrompt: ExtractedPromptText +}): ProviderDispatchResult { + const { state, source, eventName, promptText, paneKey, hookPayload, envelope, extractedPrompt } = + input + let resolvedPromptText = promptText + let promptInteractionKey: string | undefined + let hasTranscriptPromptEvidence = false + let payload: ParsedAgentStatusPayload | null + + switch (source) { + case 'claude': + payload = normalizeClaudeEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'codex': + payload = normalizeCodexEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'gemini': + payload = normalizeGeminiEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'antigravity': + if (isNewTurnEvent('antigravity', eventName)) { + resolvedPromptText = + promptText || + readLastUserPromptFromTranscript( + readFirstString(hookPayload, ['transcriptPath', 'transcript_path']) + ) || + '' + } + payload = normalizeAntigravityEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'amp': + payload = normalizeAmpEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'opencode': + case 'mimo-code': { + if (extractedPrompt.source === 'role_user_text') { + const messageId = readFirstString(hookPayload, ['messageID', 'messageId', 'message_id']) + const prefix = source === 'mimo-code' ? 'mimo-code-message' : 'opencode-message' + promptInteractionKey = messageId ? `${prefix}-${messageId}` : undefined + } + payload = normalizeOpenCodeFamilyEvent( + source, + state, + eventName, + promptText, + paneKey, + hookPayload + ) + break + } + case 'cursor': + payload = normalizeCursorEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'pi': + case 'omp': + case 'prime-agent': + payload = normalizePiCompatibleEvent( + state, + source, + eventName, + promptText, + paneKey, + hookPayload + ) + break + case 'droid': + payload = normalizeDroidEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'command-code': { + const transcriptPrompt = readLastCommandCodeUserPromptEntryFromTranscript( + hookPayload.transcript_path ?? hookPayload.transcriptPath + ) + hasTranscriptPromptEvidence = transcriptPrompt !== undefined + promptInteractionKey = transcriptPrompt?.interactionKey + resolvedPromptText = transcriptPrompt?.text ?? '' + if (promptText && extractedPrompt.source !== 'message') { + resolvedPromptText = promptText + } + payload = normalizeCommandCodeEvent( + state, + eventName, + resolvedPromptText, + paneKey, + hookPayload + ) + break + } + case 'grok': + payload = normalizeGrokEvent( + state, + eventName, + promptText, + paneKey, + hookPayload, + readGrokHomeEnvelope(envelope) + ) + break + case 'copilot': + payload = normalizeCopilotEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'hermes': + payload = normalizeHermesEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'devin': + payload = normalizeDevinEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'kimi': + payload = normalizeKimiEvent(state, eventName, promptText, paneKey, hookPayload) + break + } + + return { payload, resolvedPromptText, promptInteractionKey, hasTranscriptPromptEvidence } +} diff --git a/src/shared/agent-hook-listener/provider-event-names.ts b/src/shared/agent-hook-listener/provider-event-names.ts new file mode 100644 index 00000000000..f21d61ac368 --- /dev/null +++ b/src/shared/agent-hook-listener/provider-event-names.ts @@ -0,0 +1,16 @@ +// Provider event-name normalization shared by routing and provider modules. + +export function normalizeHookEventName(value: unknown): string { + if (typeof value !== 'string') { + return '' + } + return value + .trim() + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/[-\s]+/g, '_') + .toLowerCase() +} + +export function isGrokEvent(eventName: unknown, ...expected: readonly string[]): boolean { + return expected.includes(normalizeHookEventName(eventName)) +} diff --git a/src/shared/agent-hook-listener/provider-event-routing.ts b/src/shared/agent-hook-listener/provider-event-routing.ts new file mode 100644 index 00000000000..e74de1fdc44 --- /dev/null +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -0,0 +1,174 @@ +import type { AgentHookSource } from '../agent-hook-relay' +import { isKnownHarnessInjectedUserTurnText } from '../harness-injected-user-turns' +import type { ToolSnapshot } from './listener-event' +import type { ExtractedPromptText } from './prompt-fields' +import { extractClaudeToolFields } from './providers/claude-tool-fields' +import { extractCodexToolFields } from './providers/codex-tool-fields' +import { extractGeminiToolFields } from './providers/gemini-tool-fields' +import { extractAntigravityToolFields } from './providers/antigravity-tool-fields' +import { extractAmpToolFields } from './providers/amp-tool-fields' +import { extractOpenCodeToolFields } from './providers/opencode-family-tool-fields' +import { extractCursorToolFields } from './providers/cursor-tool-fields' +import { + extractCopilotToolFields, + normalizeCopilotEventName +} from './providers/copilot-tool-fields' +import { extractPiToolFields } from './providers/pi-family-tool-fields' +import { extractDroidToolFields } from './providers/droid-tool-fields' +import { extractCommandCodeToolFields } from './providers/command-code-tool-fields' +import { isGrokEvent } from './provider-event-names' +import { extractGrokToolFields } from './providers/grok-tool-fields' +import { extractHermesToolFields } from './providers/hermes-tool-fields' + +export function isGrokIdleNotification(message: string | undefined): boolean { + if (!message) { + return false + } + const lower = message.toLowerCase() + return ( + lower.includes('type your message') || + lower.includes('enter send') || + lower.includes('shift-tab normal') || + lower.includes('ask a side question') + ) +} + +/** The per-provider answer to "is this event a user-initiated new turn?". Exported so the + * observation stamp reuses it instead of minting a second list of event-name literals. */ +export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { + // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of falling through to false. + switch (source) { + case 'claude': + // Why: SessionStart lands an idle row (STA-3386) and must also drop stale + // tool/prompt caches left by the pane's previous session. + return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' + case 'kimi': + // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. + return eventName === 'UserPromptSubmit' + case 'codex': + return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' + case 'gemini': + return eventName === 'BeforeAgent' + case 'antigravity': + return eventName === 'PreInvocation' + case 'amp': + return eventName === 'agent.start' + case 'opencode': + return eventName === 'SessionStart' + case 'mimo-code': + return false + case 'cursor': + return eventName === 'beforeSubmitPrompt' || eventName === 'sessionStart' + case 'pi': + case 'omp': + case 'prime-agent': + return eventName === 'before_agent_start' + case 'droid': + return eventName === 'UserPromptSubmit' + case 'command-code': + return false + case 'grok': + return isGrokEvent(eventName, 'user_prompt_submit') + case 'copilot': { + const normalizedEventName = normalizeCopilotEventName(eventName) + return normalizedEventName === 'SessionStart' || normalizedEventName === 'UserPromptSubmit' + } + case 'hermes': + return eventName === 'pre_llm_call' || eventName === 'on_session_start' + case 'devin': + // Why: SessionStart is handled by an early return in normalizeDevinEvent, so UserPromptSubmit is Devin's real new-turn boundary here. + return eventName === 'UserPromptSubmit' + } +} + +export function hasExplicitUserPrompt( + source: AgentHookSource, + eventName: unknown, + extractedPrompt: ExtractedPromptText, + resolvedPromptText: string, + hasTranscriptPromptEvidence = false +): boolean { + if ( + source === 'command-code' && + (eventName === 'PreToolUse' || eventName === 'Stop') && + (extractedPrompt.source !== 'message' || hasTranscriptPromptEvidence) && + resolvedPromptText.trim().length > 0 + ) { + // Why: Command Code exposes the submitted prompt via its transcript, not direct hook fields; treat the transcript-backed prompt as explicit so telemetry covers real turns. + return true + } + if ( + source === 'antigravity' && + isNewTurnEvent(source, eventName) && + resolvedPromptText.trim().length > 0 + ) { + return true + } + if (extractedPrompt.source === 'role_user_text') { + return (source === 'opencode' || source === 'mimo-code') && eventName === 'MessagePart' + } + if (extractedPrompt.text.length === 0) { + return false + } + // Why: harness-injected turns aren't a user submit (no prompt-sent telemetry or permission stickiness); match only KNOWN tags so a real `` prompt still counts and survives interrupt recovery. + if (isKnownHarnessInjectedUserTurnText(extractedPrompt.text)) { + return false + } + // Why: bare `message` fields often carry permission/status copy — may update visible status prompts but aren't proof of a user submit. + if (extractedPrompt.source === 'message') { + return false + } + if ( + extractedPrompt.source === 'user_prompt' || + extractedPrompt.source === 'userPrompt' || + extractedPrompt.source === 'user_message' + ) { + return isNewTurnEvent(source, eventName) + } + return isNewTurnEvent(source, eventName) +} + +export function extractToolFields( + source: AgentHookSource, + eventName: unknown, + hookPayload: Record, + options?: { grokHome?: string } +): ToolSnapshot { + // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of silently routing through OpenCode's extractor. + switch (source) { + case 'claude': + // Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim. + // falls through + case 'kimi': + return extractClaudeToolFields(eventName, hookPayload) + case 'codex': + return extractCodexToolFields(eventName, hookPayload) + case 'gemini': + return extractGeminiToolFields(eventName, hookPayload) + case 'antigravity': + return extractAntigravityToolFields(eventName, hookPayload) + case 'amp': + return extractAmpToolFields(eventName, hookPayload) + case 'opencode': + case 'mimo-code': + return extractOpenCodeToolFields(eventName, hookPayload) + case 'cursor': + return extractCursorToolFields(eventName, hookPayload) + case 'pi': + case 'omp': + case 'prime-agent': + return extractPiToolFields(eventName, hookPayload, source) + case 'droid': + return extractDroidToolFields(eventName, hookPayload) + case 'command-code': + return extractCommandCodeToolFields(eventName, hookPayload) + case 'grok': + return extractGrokToolFields(eventName, hookPayload, options?.grokHome) + case 'copilot': + return extractCopilotToolFields(normalizeCopilotEventName(eventName), hookPayload) + case 'hermes': + return extractHermesToolFields(eventName, hookPayload) + case 'devin': + return extractClaudeToolFields(eventName, hookPayload) + } +} diff --git a/src/shared/agent-hook-listener/providers/amp-events.ts b/src/shared/agent-hook-listener/providers/amp-events.ts new file mode 100644 index 00000000000..cbca0e4d175 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/amp-events.ts @@ -0,0 +1,168 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { readFirstString } from '../interactive-tool' +import { readBoundedString } from '../grok-result-discovery' +import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { AMP_MAX_SCOPED_THREAD_CACHE_KEYS, AMP_THREAD_ID_MAX_LENGTH } from '../listener-limits' + +export function normalizeAmpEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const ampCacheKey = getAmpCacheKey(paneKey, hookPayload) + if (eventName === 'session.start') { + clearPaneTurnCacheState(state, ampCacheKey) + if (ampCacheKey !== paneKey) { + clearPaneTurnCacheState(state, paneKey) + } + return null + } + + const stateName = + eventName === 'agent.start' || eventName === 'tool.call' || eventName === 'tool.result' + ? 'working' + : eventName === 'agent.end' + ? 'done' + : null + + if (!stateName) { + return null + } + if (eventName === 'agent.start') { + state.ampCompletedCacheKeys.delete(ampCacheKey) + } else if ( + (eventName === 'tool.call' || eventName === 'tool.result') && + state.ampCompletedCacheKeys.has(ampCacheKey) + ) { + // Why: Amp status posts are fire-and-forget, so drop stale tool events that arrive after the thread ended. + return null + } + + const snapshot = resolveToolState( + state, + ampCacheKey, + extractToolFields('amp', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('amp', eventName) } + ) + + const interrupted = + eventName === 'agent.end' && hookPayload.status === 'cancelled' ? true : undefined + const explicitPrompt = readFirstString(hookPayload, [ + 'prompt', + 'user_prompt', + 'userPrompt', + 'initial_prompt', + 'initialPrompt', + 'user_message' + ]) + const canUseMessageAsPrompt = + eventName === 'agent.start' || + (eventName === 'agent.end' && !state.lastPromptByPaneKey.has(ampCacheKey)) + const ampPromptText = explicitPrompt ?? (canUseMessageAsPrompt ? promptText : '') + + const normalized = normalizeAgentStatusPayload({ + state: stateName, + // Why: Amp tool/result events may use `message` for tool output; only lifecycle events may treat it as the turn prompt. + prompt: resolvePrompt(state, ampCacheKey, ampPromptText, { + resetOnNewTurn: isNewTurnEvent('amp', eventName) + }), + agentType: 'amp', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted + }) + if (normalized && eventName === 'agent.end') { + state.ampCompletedCacheKeys.add(ampCacheKey) + } + if (normalized) { + pruneAmpThreadCacheKeys(state, paneKey, ampCacheKey) + } + return normalized +} + +export function getAmpCacheKey(paneKey: string, hookPayload: Record): string { + const threadId = readBoundedString( + hookPayload, + ['threadId', 'threadID', 'thread_id'], + AMP_THREAD_ID_MAX_LENGTH + ) + // Why: Amp emits events for multiple threads per pane; cache by thread internally while keeping the visible paneKey stable. + return threadId ? `${paneKey}\0amp:${threadId}` : paneKey +} + +export function pruneAmpThreadCacheKeys( + state: HookListenerState, + paneKey: string, + currentCacheKey: string +): void { + const scopedPrefix = `${paneKey}\0amp:` + if (!currentCacheKey.startsWith(scopedPrefix)) { + return + } + + const scopedKeys = new Set() + for (const key of state.lastPromptByPaneKey.keys()) { + if (key.startsWith(scopedPrefix)) { + scopedKeys.add(key) + } + } + for (const key of state.lastToolByPaneKey.keys()) { + if (key.startsWith(scopedPrefix)) { + scopedKeys.add(key) + } + } + for (const key of state.ampCompletedCacheKeys) { + if (key.startsWith(scopedPrefix)) { + scopedKeys.add(key) + } + } + + let overflow = scopedKeys.size - AMP_MAX_SCOPED_THREAD_CACHE_KEYS + if (overflow <= 0) { + return + } + + // Why: Amp multiplexes many thread IDs through one pane; keep the current thread plus the most recent entries instead of retaining every completed thread until teardown. + for (const key of scopedKeys) { + if (overflow <= 0) { + break + } + if (key === currentCacheKey) { + continue + } + state.lastPromptByPaneKey.delete(key) + state.lastToolByPaneKey.delete(key) + state.ampCompletedCacheKeys.delete(key) + overflow-- + } +} + +export function hasExplicitAmpPrompt( + eventName: unknown, + promptText: string, + hookPayload: Record +): boolean { + if ( + readFirstString(hookPayload, [ + 'prompt', + 'user_prompt', + 'userPrompt', + 'initial_prompt', + 'initialPrompt', + 'user_message' + ]) + ) { + return true + } + // Amp tool/result `message` is output text, not a user prompt. + return eventName === 'agent.start' && promptText.length > 0 +} diff --git a/src/shared/agent-hook-listener/providers/amp-tool-fields.ts b/src/shared/agent-hook-listener/providers/amp-tool-fields.ts new file mode 100644 index 00000000000..96d85304ee0 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/amp-tool-fields.ts @@ -0,0 +1,44 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveFallbackToolInputPreview, + deriveToolInputPreview, + hasAnyOwnField, + readString, + toolUpdate +} from '../tool-input-preview' +import { extractToolResponseText, readFirstString } from '../interactive-tool' + +export function extractAmpToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (eventName === 'tool.call' || eventName === 'tool.result') { + const toolName = + readString(hookPayload, 'tool') ?? + readString(hookPayload, 'toolName') ?? + readString(hookPayload, 'name') + const toolInput = + deriveToolInputPreview(toolName, hookPayload.input) ?? + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.arguments) ?? + // Why: Amp plugin tools can have arbitrary names; fall back to obvious arg fields instead of an empty tool preview. + deriveFallbackToolInputPreview(hookPayload.input) ?? + deriveFallbackToolInputPreview(hookPayload.tool_input) ?? + deriveFallbackToolInputPreview(hookPayload.arguments) + const update: ToolSnapshot = toolUpdate( + { toolName, toolInput }, + { hasToolInputField: hasAnyOwnField(hookPayload, ['input', 'tool_input', 'arguments']) } + ) + if (eventName === 'tool.result') { + const responseText = + readFirstString(hookPayload, ['error', 'output', 'result', 'message']) ?? + extractToolResponseText(hookPayload.output) ?? + extractToolResponseText(hookPayload.result) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + return update + } + return {} +} diff --git a/src/shared/agent-hook-listener/providers/antigravity-event-rules.ts b/src/shared/agent-hook-listener/providers/antigravity-event-rules.ts new file mode 100644 index 00000000000..aab23c615a4 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/antigravity-event-rules.ts @@ -0,0 +1,5 @@ +// Antigravity lifecycle predicates shared by normalization and result discovery. + +export function isAntigravityStopStillBusy(hookPayload: Record): boolean { + return hookPayload.fullyIdle === false || hookPayload.fully_idle === false +} diff --git a/src/shared/agent-hook-listener/providers/antigravity-events.ts b/src/shared/agent-hook-listener/providers/antigravity-events.ts new file mode 100644 index 00000000000..8465e9b56d0 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/antigravity-events.ts @@ -0,0 +1,84 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { readFirstString } from '../interactive-tool' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { readLastUserPromptFromTranscript } from '../transcript-lines' +import { readAntigravityToolCall } from './antigravity-tool-fields' +import { isAntigravityStopStillBusy } from './antigravity-event-rules' + +export function isAntigravityFeedbackTool(toolName: string | undefined): boolean { + return toolName === 'ask_question' || toolName === 'ask_permission' +} + +export function normalizeAntigravityEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const transcriptPath = readFirstString(hookPayload, ['transcriptPath', 'transcript_path']) + if (eventName === 'PreInvocation') { + state.antigravityCompletedTranscriptByPaneKey.delete(paneKey) + } else if ( + transcriptPath && + eventName !== 'Stop' && + state.antigravityCompletedTranscriptByPaneKey.get(paneKey) === transcriptPath + ) { + // Why: agy can emit a bookkeeping PostToolUse after Stop; ignore it so a finished row doesn't turn back into a yellow spinner. + return null + } + + const toolName = readAntigravityToolCall(hookPayload).toolName + const stopStillBusy = eventName === 'Stop' && isAntigravityStopStillBusy(hookPayload) + const stateName = + eventName === 'PreToolUse' && isAntigravityFeedbackTool(toolName) + ? 'waiting' + : eventName === 'Stop' + ? stopStillBusy + ? 'working' + : 'done' + : eventName === 'PreInvocation' || + eventName === 'PostInvocation' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ? 'working' + : null + + if (!stateName) { + return null + } + + const resetsTurn = isNewTurnEvent('antigravity', eventName) + // Why: once the prompt is cached for this pane, avoid rescanning the (potentially large) Antigravity transcript per hook. + const cachedPrompt = resetsTurn ? undefined : state.lastPromptByPaneKey.get(paneKey) + const effectivePrompt = + promptText || cachedPrompt || readLastUserPromptFromTranscript(transcriptPath) || '' + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('antigravity', eventName, hookPayload), + { resetOnNewTurn: resetsTurn } + ) + + const payload = normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, effectivePrompt, { + resetOnNewTurn: resetsTurn + }), + agentType: 'antigravity', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage + }) + // Why: Antigravity can emit Stop with fullyIdle=false between tool steps; only a fully idle Stop is terminal, else the sidebar bounces done -> working and ignores later tool updates. + if (eventName === 'Stop' && !stopStillBusy && transcriptPath) { + state.antigravityCompletedTranscriptByPaneKey.set(paneKey, transcriptPath) + } + return payload +} diff --git a/src/shared/agent-hook-listener/providers/antigravity-tool-fields.ts b/src/shared/agent-hook-listener/providers/antigravity-tool-fields.ts new file mode 100644 index 00000000000..ba57349f14b --- /dev/null +++ b/src/shared/agent-hook-listener/providers/antigravity-tool-fields.ts @@ -0,0 +1,54 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveFallbackToolInputPreview, + deriveToolInputPreview, + readString, + toolUpdate +} from '../tool-input-preview' +import { readFirstString } from '../interactive-tool' +import { readLastAssistantFromTranscript } from '../transcript-lines' +import { isAntigravityStopStillBusy } from './antigravity-event-rules' + +export function readAntigravityToolCall(hookPayload: Record): { + toolName?: string + toolInputSource?: unknown +} { + const toolCall = hookPayload.toolCall + if (typeof toolCall !== 'object' || toolCall === null) { + return {} + } + const record = toolCall as Record + return { + toolName: readFirstString(record, ['name', 'toolName', 'tool_name']), + toolInputSource: record.args + } +} + +export function extractAntigravityToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (eventName === 'PreToolUse' || eventName === 'PostToolUse') { + const toolCall = readAntigravityToolCall(hookPayload) + const toolName = toolCall.toolName + const toolInput = + deriveToolInputPreview(toolName, toolCall.toolInputSource) ?? + deriveFallbackToolInputPreview(toolCall.toolInputSource) + return toolUpdate( + { toolName, toolInput }, + { hasToolInputField: toolCall.toolInputSource !== undefined } + ) + } + if (eventName === 'Stop') { + if (isAntigravityStopStillBusy(hookPayload)) { + return {} + } + const message = + readString(hookPayload, 'last_assistant_message') ?? + readLastAssistantFromTranscript(hookPayload.transcriptPath ?? hookPayload.transcript_path) + if (message) { + return { lastAssistantMessage: message } + } + } + return {} +} diff --git a/src/shared/agent-hook-listener/providers/claude-events.ts b/src/shared/agent-hook-listener/providers/claude-events.ts new file mode 100644 index 00000000000..d094afd3367 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/claude-events.ts @@ -0,0 +1,315 @@ +import type { ParsedAgentStatusPayload } from '../../agent-status-types' +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { readClaudeBackgroundAgentTasks } from '../../claude-background-task-inventory' +import { + claudeRosterHasRestoredSnapshotSubagent, + claudeRosterHasRuntimeWorkingSubagent, + foldClaudeBackgroundTasksIntoRoster, + reapUnconfirmedRestoredClaudeSubagents, + upsertWorkingClaudeSubagent +} from '../../claude-subagent-roster' +import type { HookListenerState } from '../listener-state' +import { readFirstString } from '../interactive-tool' +import { shouldIgnoreCompactContinuationUserPromptSubmit } from '../prompt-fields' +import { readString } from '../tool-input-preview' +import { + buildClaudeCachedLeadStatusPayload, + normalizeClaudeSubagentLifecycleEvent +} from './claude-lifecycle-events' +import { + getOrCreateClaudeSubagentRoster, + resolveClaudePaneStatus, + updateClaudeRunningNonAgentTask, + voidClaimsOfReplacedClaudeSession +} from './claude-roster-state' +import { buildClaudeStatusPayload } from './claude-status-build' + +export function normalizeClaudeEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const eventAgentId = readString(hookPayload, 'agent_id') + if ( + eventName === 'SubagentStart' || + eventName === 'SubagentStop' || + eventName === 'TeammateIdle' + ) { + return normalizeClaudeSubagentLifecycleEvent(state, eventName, paneKey, hookPayload) + } + voidClaimsOfReplacedClaudeSession(state, eventName, eventAgentId, paneKey, hookPayload) + if (eventName === 'SessionStart') { + // Why: SessionStart is the only signal a resumed session emits before its first prompt + // (STA-3386). Land it as a session-boundary 'done' row: 'working' would show a phantom + // spinner on an idle TUI (why Devin/Pi/Grok drop the event), and the sessionBoundary + // flag keeps completion-reactive consumers (notifications, automation runs) out of it. + const sessionStartSource = hookPayload['source'] + if ( + eventAgentId !== undefined || + (sessionStartSource !== 'startup' && + sessionStartSource !== 'resume' && + sessionStartSource !== 'clear') + ) { + // Why: allowlist idle boundaries and fail closed — a compact restart (or any unknown + // source) fires mid-turn, and a child-attributed SessionStart must not flip the lead's + // live turn to an idle row. + return null + } + // Why: a new process owns the pane; stale children/tasks/crons must not gate the + // fresh session's idle row back up to 'working' (same reset Codex does on SessionStart). + state.claudeSubagentRosterByPaneKey.delete(paneKey) + state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + state.claudeActiveSessionCronPaneKeys.delete(paneKey) + state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done' }) + return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { + stateName: 'done', + updateToolSnapshot: true, + sessionBoundary: true + }) + } + const previousLead = state.claudeLeadStateByPaneKey.get(paneKey) + // Why: only a turn boundary may declare an interrupt or carry a prior one forward; any other event starts a fresh turn and drops it. + const isTurnBoundary = eventName === 'Stop' || eventName === 'StopFailure' + const interrupted = + isTurnBoundary && + ((eventAgentId === undefined && hookPayload['is_interrupt'] === true) || + previousLead?.interrupted === true) + ? true + : undefined + const backgroundTasks = readClaudeBackgroundAgentTasks(hookPayload) + const sessionCrons = hookPayload['session_crons'] + const sessionCronInventoryPresent = Array.isArray(sessionCrons) + const hasActiveSessionCron = sessionCronInventoryPresent && sessionCrons.length > 0 + + if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { + return null + } + + // Why: Claude normally emits PreToolUse while AskUserQuestion is blocked; newer builds can also report it as PermissionRequest. + // Treat the PreToolUse as waiting so the sidebar shows amber attention, not a spinner that decays to grey. Mirrors normalizeKimiEvent. + const eventToolName = readString(hookPayload, 'tool_name') + const isAskUserQuestionWait = + (eventName === 'PreToolUse' || eventName === 'PermissionRequest') && + isAskUserQuestionTool(eventToolName) + const isAskUserQuestion = eventName === 'PreToolUse' && isAskUserQuestionWait + // Why: a manual /compact swallows the turn boundary — it ends at an idle prompt and emits no + // Stop, so PostCompact is the pane's only clearing signal (STA-2915). An auto compact runs INSIDE + // a turn that resumes and emits its own Stop, so it must claim nothing. PreCompact fires before + // the compact is validated (an aborted compact emits it alone), so it is neither registered nor + // mapped — see claude-compact-completion.ts. + const isManualCompactCompletion = eventName === 'PostCompact' && hookPayload.trigger === 'manual' + const reportedStateName = + eventName === 'UserPromptSubmit' || + eventName === 'PostToolUse' || + eventName === 'PostToolUseFailure' || + (eventName === 'PreToolUse' && !isAskUserQuestion) + ? 'working' + : eventName === 'PermissionRequest' || isAskUserQuestion + ? 'waiting' + : isTurnBoundary || isManualCompactCompletion + ? 'done' + : null + + if (!reportedStateName) { + return null + } + if (backgroundTasks.present && eventAgentId === undefined) { + updateClaudeRunningNonAgentTask( + state, + paneKey, + backgroundTasks.hasRunningNonAgentTask, + interrupted === true + ) + } + if (sessionCronInventoryPresent && eventAgentId === undefined) { + if (hasActiveSessionCron && interrupted !== true) { + state.claudeActiveSessionCronPaneKeys.add(paneKey) + } else { + state.claudeActiveSessionCronPaneKeys.delete(paneKey) + } + } else if (eventAgentId === undefined && isTurnBoundary && backgroundTasks.present) { + // Why: current Claude may omit an empty cron inventory while still emitting background_tasks. + state.claudeActiveSessionCronPaneKeys.delete(paneKey) + } + + const eventToolUseId = readFirstString(hookPayload, ['tool_use_id', 'toolUseId']) + const previousTool = state.lastToolByPaneKey.get(paneKey) + const isParallelSiblingCompletionDuringQuestion = + eventAgentId === undefined && + previousLead?.state === 'waiting' && + isAskUserQuestionTool(previousTool?.toolName) && + (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && + previousLead.waitingToolUseId !== undefined && + eventToolUseId !== undefined && + eventToolUseId !== previousLead.waitingToolUseId + if (isParallelSiblingCompletionDuringQuestion) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) + } + + // Why: subagent/teammate events carry `agent_id` (lead's don't); child tool activity keeps its row live but must not become the lead's state or overwrite its tool/prompt caches (a live card would vanish). + // Two exceptions take the full path below: waiting-inducing events (a child needs human attention on this pane) and the blocked child's own next tool event (approval granted — clear the wait as for the lead). + const isWaitingInducing = reportedStateName === 'waiting' + const subagentOriginId = + !isWaitingInducing && + (eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PostToolUseFailure') + ? eventAgentId + : undefined + if (eventAgentId && (subagentOriginId || isWaitingInducing)) { + upsertWorkingClaudeSubagent( + getOrCreateClaudeSubagentRoster(state, paneKey), + eventAgentId, + { agentType: readString(hookPayload, 'agent_type') }, + Date.now() + ) + } + if (subagentOriginId) { + const lead = state.claudeLeadStateByPaneKey.get(paneKey) + if (lead?.state !== 'waiting' || lead.waitingAgentId !== subagentOriginId) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload, { + workingChildEvidence: true + }) + } + const isParallelSiblingCompletionDuringChildQuestion = + (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && + lead.waitingToolUseId !== undefined && + eventToolUseId !== undefined && + eventToolUseId !== lead.waitingToolUseId + if (isParallelSiblingCompletionDuringChildQuestion) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload, { + workingChildEvidence: true + }) + } + // Why: approval granted — update the tool snapshot (drop the pending card) as the lead's own next tool event would. + // Restore the stashed lead state, not this child's 'working': the lead may already be done, and the done-gate never upgrades working back to done once the roster drains. + const restored = lead.stateBeforeWait ?? { state: 'working' as const } + state.claudeLeadStateByPaneKey.set(paneKey, restored) + return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { + ...resolveClaudePaneStatus(state, paneKey, restored), + updateToolSnapshot: true, + interrupted: restored.interrupted, + turnCompletedAt: restored.turnCompletedAt + }) + } + + // Why: lead events never carry agent_id; even a child missed by lifecycle tracking cannot own the lead turn or its background-work evidence. + if (eventAgentId && !isWaitingInducing) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload, { + workingChildEvidence: claudeRosterHasRuntimeWorkingSubagent( + state.claudeSubagentRosterByPaneKey.get(paneKey) + ) + }) + } + + if (isTurnBoundary && eventAgentId === undefined) { + // Why: background_tasks is trusted only where unambiguous (see foldClaudeBackgroundTasksIntoRoster) — teammates report "running" here even while idle. + // Older Claude builds without the field keep the incrementally tracked roster. + if (backgroundTasks.present) { + foldClaudeBackgroundTasksIntoRoster( + getOrCreateClaudeSubagentRoster(state, paneKey), + backgroundTasks.tasks, + Date.now(), + { inventoryComplete: !backgroundTasks.truncated } + ) + } + } + // Why: a child-induced wait displaces the lead state; stash it so clearing restores reality (lead may be done). A 2nd child wait carries the ORIGINAL stash, not the intermediate waiting state. + const stateBeforeWait = + isWaitingInducing && eventAgentId && previousLead + ? previousLead.state === 'waiting' + ? previousLead.stateBeforeWait + : { + state: previousLead.state, + ...(previousLead.interrupted ? { interrupted: true as const } : {}), + // Why: a child's permission pause displaces an already-finished lead; keep the end time so the later drain is still that turn's tail. + ...(previousLead.turnCompletedAt !== undefined + ? { turnCompletedAt: previousLead.turnCompletedAt } + : {}) + } + : undefined + const waitingToolUseId = eventToolUseId ?? previousLead?.waitingToolUseId + + if (interrupted && eventAgentId === undefined) { + state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + state.claudeActiveSessionCronPaneKeys.delete(paneKey) + } + + if (isManualCompactCompletion) { + // Why: a manual /compact only ever completes at an idle prompt, so a child that exists ONLY as + // a disk snapshot has nothing live behind it and must not keep the pane spinning — that + // restored child is what holds the stuck row STA-2915 actually reports. Everything else the + // done-gate consults is live evidence (a child observed in this runtime, an unclassifiable + // running background task, a registered session cron) and still holds the pane. + const restoredRoster = state.claudeSubagentRosterByPaneKey.get(paneKey) + if ( + restoredRoster && + reapUnconfirmedRestoredClaudeSubagents(restoredRoster) && + restoredRoster.size === 0 + ) { + state.claudeSubagentRosterByPaneKey.delete(paneKey) + } + } + + const resolvedStatus = resolveClaudePaneStatus(state, paneKey, { + state: reportedStateName, + interrupted + }) + // Why: #15202's compact-completion guard reads the resolved state; this branch replaced the + // resolver with one that also reports workingMode, so bridge rather than resolve twice. + const effectiveState = resolvedStatus.stateName + // Why: the lead already ended — the pane stays `working` only because background inventory is still registered. `stateStartedAt` is pinned for that whole run, so this end time is the per-turn identity and the later all-clear's pair key. + const turnCompletedAt = + eventAgentId === undefined && + isTurnBoundary && + reportedStateName === 'done' && + resolvedStatus.stateName === 'working' && + interrupted !== true + ? Date.now() + : undefined + + state.claudeLeadStateByPaneKey.set(paneKey, { + state: reportedStateName, + ...(interrupted ? { interrupted } : {}), + ...(isWaitingInducing && eventAgentId ? { waitingAgentId: eventAgentId } : {}), + ...(isAskUserQuestionWait && waitingToolUseId !== undefined ? { waitingToolUseId } : {}), + ...(stateBeforeWait ? { stateBeforeWait } : {}), + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) + }) + + const effectiveRoster = state.claudeSubagentRosterByPaneKey.get(paneKey) + if ( + isTurnBoundary && + eventAgentId === undefined && + resolvedStatus.stateName === 'working' && + claudeRosterHasRestoredSnapshotSubagent(effectiveRoster) && + !claudeRosterHasRuntimeWorkingSubagent(effectiveRoster) && + !state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) && + !state.claudeActiveSessionCronPaneKeys.has(paneKey) + ) { + // Why: a legacy or partial Stop confirms the lead boundary, not a child restored from disk; keep the child-only gate eligible for reconciliation. + state.claudeUnconfirmedRestoredStatusPaneKeys.add(paneKey) + } + + if (isManualCompactCompletion && effectiveState !== 'done') { + // Why: a compact completion CLEARS a row or says nothing at all. Live evidence still holds this + // pane, so restating it would only strip `restoredUnconfirmed` off a hydrated row and restart + // the staleness clock for work the compact never observed — strictly worse than the silence + // this event replaced. The lead record above still marks the turn ended, so whichever event + // retires that evidence can clear the pane. + return null + } + + return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { + ...resolvedStatus, + updateToolSnapshot: true, + interrupted, + // Why: a finished compact is a session-shaped boundary, not a completed turn. Without this the + // clearing `done` would fire completion notifications, unread counts and automation-run + // completion evidence for work nobody did. + sessionBoundary: isManualCompactCompletion ? true : undefined, + turnCompletedAt + }) +} diff --git a/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts b/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts new file mode 100644 index 00000000000..2e616ddbb8b --- /dev/null +++ b/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts @@ -0,0 +1,144 @@ +import type { ParsedAgentStatusPayload } from '../../agent-status-types' +import { + claudeRosterHasRestoredSnapshotSubagent, + claudeRosterHasRuntimeWorkingSubagent, + claudeTeammateIdMatchesName, + idleClaudeTeammateByName, + stopClaudeSubagent, + upsertWorkingClaudeSubagent +} from '../../claude-subagent-roster' +import type { HookListenerState } from '../listener-state' +import { readString } from '../tool-input-preview' +import { + clearClaudePendingWaitForAgent, + getOrCreateClaudeSubagentRoster, + resolveClaudePaneStatus +} from './claude-roster-state' +import { buildClaudeStatusPayload } from './claude-status-build' + +/** 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. */ +export function normalizeClaudeSubagentLifecycleEvent( + state: HookListenerState, + eventName: 'SubagentStart' | 'SubagentStop' | 'TeammateIdle', + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const lifecycleField = eventName === 'TeammateIdle' ? 'teammate_name' : 'agent_id' + const lifecycleId = readString(hookPayload, lifecycleField) + if (!lifecycleId) { + return null + } + const cachedLead = state.claudeLeadStateByPaneKey.get(paneKey) + const ownsUnbackedWait = + cachedLead?.state === 'waiting' && + cachedLead.stateBeforeWait === undefined && + cachedLead.waitingAgentId !== undefined && + (eventName === 'TeammateIdle' + ? claudeTeammateIdMatchesName(cachedLead.waitingAgentId, lifecycleId) + : cachedLead.waitingAgentId === lifecycleId) + const hasCachedLeadEvidence = cachedLead !== undefined && !ownsUnbackedWait + let roster = state.claudeSubagentRosterByPaneKey.get(paneKey) + let endedChildWork = false + let endedRuntimeChildWork = false + if (eventName === 'TeammateIdle') { + const teammateName = lifecycleId + // Why: on claude 2.1.21x teammates are turn-based — TeammateIdle means "turn over, awaiting mail", not finished. The row parks as idle (confirmed teammate) instead of leaving, so the sidebar keeps showing resumable children. + if (roster) { + let wasWorking = false + for (const [id, tracked] of roster) { + if (tracked.state === 'working' && claudeTeammateIdMatchesName(id, teammateName)) { + wasWorking = true + endedRuntimeChildWork ||= tracked.restoredFromSnapshot !== true + } + } + idleClaudeTeammateByName(roster, teammateName) + endedChildWork = wasWorking + } + clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => + claudeTeammateIdMatchesName(waitingAgentId, teammateName) + ) + } else { + const agentId = lifecycleId + if (eventName === 'SubagentStart') { + roster = getOrCreateClaudeSubagentRoster(state, paneKey) + upsertWorkingClaudeSubagent( + roster, + agentId, + { agentType: readString(hookPayload, 'agent_type') }, + Date.now() + ) + } else { + if (roster) { + const tracked = roster.get(agentId) + const wasWorking = tracked?.state === 'working' + endedRuntimeChildWork = wasWorking && tracked.restoredFromSnapshot !== true + // Why: one-shot stops are true finishes (row removed); teammate-shaped stops are turn ends on 2.1.21x — the row parks idle and a later SubagentStart revives it. + stopClaudeSubagent(roster, agentId) + endedChildWork = wasWorking && roster.get(agentId)?.state !== 'working' + } + // Why: a blocked child that dies without another tool event would pin its permission/question wait on the pane forever — nothing else references that agent again. + clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => waitingAgentId === agentId) + } + } + const workingChildEvidence = claudeRosterHasRuntimeWorkingSubagent(roster) + const hasUnconfirmedChild = claudeRosterHasRestoredSnapshotSubagent(roster) + const hasConfirmedDoneGate = + cachedLead?.state === 'done' && + cachedLead.interrupted !== true && + (state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + state.claudeActiveSessionCronPaneKeys.has(paneKey)) + const restoredOnlyDoneGate = + cachedLead?.state === 'done' && !hasConfirmedDoneGate && hasUnconfirmedChild + if (roster?.size === 0) { + state.claudeSubagentRosterByPaneKey.delete(paneKey) + } + if ( + !workingChildEvidence && + (restoredOnlyDoneGate || + (endedChildWork && !hasCachedLeadEvidence && (hasUnconfirmedChild || !endedRuntimeChildWork))) + ) { + // Why: a restored-only ending proves no lead boundary, and an unmatched restored sibling proves no current liveness; persist the roster transition without publishing fresh work or completion. + state.claudeUnconfirmedRestoredStatusPaneKeys.add(paneKey) + } + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload, { + workingChildEvidence, + endedChildWork, + endedRuntimeChildWork + }) +} +/** Re-emit the cached lead state without touching its tool/prompt caches; child churn and parallel completions must not dismiss live cards. */ +export function buildClaudeCachedLeadStatusPayload( + state: HookListenerState, + eventName: unknown, + paneKey: string, + hookPayload: Record, + evidence: { + workingChildEvidence?: boolean + endedChildWork?: boolean + endedRuntimeChildWork?: boolean + } = {} +): ParsedAgentStatusPayload | null { + const lead = state.claudeLeadStateByPaneKey.get(paneKey) + let leadState = lead?.state + if (!leadState) { + if (evidence.workingChildEvidence || evidence.endedRuntimeChildWork) { + // Why: ending a current-runtime child wakes its parent; only a cached lead boundary can prove the whole pane completed. + leadState = 'working' + } else if (evidence.endedChildWork) { + // Why: a restored child ending proves only that child ended; persist its roster transition as unconfirmed working, never as lead completion. + leadState = 'working' + } else { + return null + } + } + return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, { + ...resolveClaudePaneStatus(state, paneKey, { + state: leadState, + interrupted: lead?.interrupted + }), + updateToolSnapshot: false, + interrupted: lead?.interrupted, + // Why: draining the last background child is this turn's all-clear; the stamp lets a consumer pair it with the announcement already sent. + turnCompletedAt: lead?.turnCompletedAt + }) +} diff --git a/src/shared/agent-hook-listener/providers/claude-roster-state.ts b/src/shared/agent-hook-listener/providers/claude-roster-state.ts new file mode 100644 index 00000000000..852dce162b2 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -0,0 +1,277 @@ +import type { + AgentStatusState, + AgentSubagentSnapshot, + AgentWorkingMode +} from '../../agent-status-types' +import { + claudeRosterHasWorkingSubagent, + reapUnconfirmedRestoredClaudeSubagents, + type ClaudeSubagentRoster +} from '../../claude-subagent-roster' +import type { AgentHookEventPayload } from '../listener-event' +import type { ClaudeLeadTurnState, HookListenerState } from '../listener-state' +import { readString } from '../tool-input-preview' + +/** Lead events that may re-anchor a pane's owning session. Allow-list, not a deny-list: a payload we + * can't attribute (unknown name, child event missing its agent_id) must void nothing. */ +const CLAUDE_SESSION_OWNER_EVENTS: ReadonlySet = new Set([ + 'SessionStart', + 'UserPromptSubmit', + 'Stop', + 'StopFailure', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest' +]) + +/** A pane whose `session_id` changed is running a different conversation, so claims the previous one + * owned are void — the hook-independent backstop for /clear, relaunch and resume, which emit no + * terminating hook (SessionEnd covers about a third of exit paths). + * + * Voids only what the replaced session provably owned. Deliberately NOT voided: + * - `claudeRunningNonAgentTaskPaneKeys`: a background shell is an OS process that survives /clear, + * and the previous inventory is positive evidence it was running. Only a fresh inventory or a + * certified process death may retire it. + * - `confirmedTeammate` roster rows: persistent in-process teammates a lead replacement can't end. + * - `claudeLeadStateByPaneKey`: the caller's own fold overwrites it anyway. */ +export function voidClaimsOfReplacedClaudeSession( + state: HookListenerState, + eventName: unknown, + eventAgentId: string | undefined, + paneKey: string, + hookPayload: Record +): void { + if ( + eventAgentId !== undefined || + typeof eventName !== 'string' || + !CLAUDE_SESSION_OWNER_EVENTS.has(eventName) + ) { + return + } + // Compact/unknown SessionStart events are intentionally ignored by the status fold; do not let + // them advance the owner anchor or the next real lead event will miss the replacement. + if ( + eventName === 'SessionStart' && + hookPayload['source'] !== 'startup' && + hookPayload['source'] !== 'resume' && + hookPayload['source'] !== 'clear' + ) { + return + } + const sessionId = readString(hookPayload, 'session_id') + if (!sessionId) { + return + } + const previousOwner = state.claudeSessionOwnerByPaneKey.get(paneKey) + state.claudeSessionOwnerByPaneKey.set(paneKey, sessionId) + if (previousOwner === undefined || previousOwner === sessionId) { + return + } + // Why: a compact restart mints a SessionStart mid-turn under the same conversation; the existing + // handler already fails closed on non-idle sources, and this must not undercut it. No other + // allow-listed event carries a compact `trigger`, so SessionStart is the whole guard — if a + // compact event is ever added to CLAUDE_SESSION_OWNER_EVENTS, re-derive one for it deliberately. + if (eventName === 'SessionStart') { + return + } + state.claudeActiveSessionCronPaneKeys.delete(paneKey) + const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) + if (!roster) { + return + } + for (const [id, tracked] of roster) { + if (tracked.confirmedTeammate !== true) { + roster.delete(id) + } + } + if (roster.size === 0) { + state.claudeSubagentRosterByPaneKey.delete(paneKey) + } +} + +export function getOrCreateClaudeSubagentRoster( + state: HookListenerState, + paneKey: string +): ClaudeSubagentRoster { + let roster = state.claudeSubagentRosterByPaneKey.get(paneKey) + if (!roster) { + roster = new Map() + state.claudeSubagentRosterByPaneKey.set(paneKey, roster) + } + return roster +} + +export function updateClaudeRunningNonAgentTask( + state: HookListenerState, + paneKey: string, + hasRunningNonAgentTask: boolean, + /** Lead-turn property. Pass `false` from any non-lead fold: an interrupt clears the gate even when + * the inventory positively reports a running shell, which is a live-shell judgement no new call + * site may inherit by copying this signature. */ + interrupted: boolean +): void { + if (hasRunningNonAgentTask && !interrupted) { + state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) + } else { + state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + } +} + +export type ClaudePaneStatusResolution = { + stateName: AgentStatusState + workingMode?: AgentWorkingMode +} + +export function resolveClaudePaneStatus( + state: HookListenerState, + paneKey: string, + lead: Pick +): ClaudePaneStatusResolution { + if (lead.state !== 'done') { + return { stateName: lead.state } + } + const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) + 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' } +} +/** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C with a missed Stop); else a later child lifecycle event resurrects the cancelled pane. */ +export function markClaudeLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { + state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done', interrupted: true }) + state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + state.claudeActiveSessionCronPaneKeys.delete(paneKey) +} + +/** Rebuild a pane's working roster from a persisted snapshot; live activity confirms a seed, a complete task inventory may reap an unconfirmed one whose finish hook arrived while Orca was offline. */ +export function seedClaudeSubagentRosterFromSnapshots( + state: HookListenerState, + paneKey: string, + snapshots: readonly AgentSubagentSnapshot[] +): void { + if (snapshots.length === 0 || state.claudeSubagentRosterByPaneKey.has(paneKey)) { + return + } + const roster = getOrCreateClaudeSubagentRoster(state, paneKey) + for (const snapshot of snapshots) { + // Why: idle-teammate liveness can't be proven across a restart (its TeammateIdle confirmation is gone); only working seeds restore, and a live teammate re-earns its row via SubagentStart. + if (snapshot.state !== 'working') { + continue + } + roster.set(snapshot.id, { + state: 'working', + startedAt: snapshot.startedAt, + agentType: snapshot.agentType, + description: snapshot.description, + // Why: the seed can be a phantom (child finished while Orca was down, SubagentStop lost); let a PRESENT background_tasks list omitting the id remove it, not gate the pane 'working' forever. + backgroundTasksAuthoritative: true, + // Why: an idle parent never emits that list, so the inventory reap alone can strand the seed; mark it for the liveness reap below. + restoredFromSnapshot: true + }) + } +} + +export function seedClaudeLeadTurnFromPersistedStatus( + state: HookListenerState, + paneKey: string, + status: Pick, + options: { childOnlyBoundary: boolean } +): void { + if (options.childOnlyBoundary && status.payload.agentType === 'claude') { + state.claudeLeadStateByPaneKey.set(paneKey, { + state: 'done', + ...(status.payload.interrupted === true ? { interrupted: true } : {}), + ...(status.payload.turnCompletedAt !== undefined + ? { turnCompletedAt: status.payload.turnCompletedAt } + : {}) + }) + if (status.payload.prompt) { + state.lastPromptByPaneKey.set(paneKey, status.payload.prompt) + } + if (status.payload.lastAssistantMessage) { + state.lastToolByPaneKey.set(paneKey, { + lastAssistantMessage: status.payload.lastAssistantMessage + }) + } + } +} + +/** Reap this pane's unconfirmed restored seeds because no live agent process backs + * the pane any more (its PTY died while Orca was down, so no finish hook could + * arrive). Callers must have proven the pane is LOCAL-launched — a remote/SSH + * agent runs on the far host and can never appear in a local process index. + * Returns whether the roster changed. */ +export function reapRestoredClaudeSubagentsForDeadPane( + state: HookListenerState, + paneKey: string +): boolean { + const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) + if (!roster || !reapUnconfirmedRestoredClaudeSubagents(roster)) { + return false + } + if (roster.size === 0) { + state.claudeSubagentRosterByPaneKey.delete(paneKey) + } + return true +} + +/** Drop a child-owned waiting state when the child stops/idles, restoring the displaced lead state. */ +export function clearClaudePendingWaitForAgent( + state: HookListenerState, + paneKey: string, + ownsWait: (waitingAgentId: string) => boolean +): void { + const lead = state.claudeLeadStateByPaneKey.get(paneKey) + if (lead?.state !== 'waiting' || !lead.waitingAgentId || !ownsWait(lead.waitingAgentId)) { + return + } + state.claudeLeadStateByPaneKey.set(paneKey, lead.stateBeforeWait ?? { state: 'working' }) + const previousTool = state.lastToolByPaneKey.get(paneKey) + state.lastToolByPaneKey.set( + paneKey, + previousTool?.lastAssistantMessage + ? { lastAssistantMessage: previousTool.lastAssistantMessage } + : {} + ) +} + +/** Clear an AskUserQuestion wait after the answer is typed (answering emits no hook event; the caller infers it from the submit keystroke). Restores the stashed pre-wait lead state or 'working', drops the cached card, and returns the pane state to emit (gated up to 'working' while children run). */ +export function clearClaudeAnsweredQuestionWait( + state: HookListenerState, + paneKey: string +): Pick & { + workingMode?: AgentWorkingMode +} { + const lead = state.claudeLeadStateByPaneKey.get(paneKey) + const restored = + lead?.state === 'waiting' + ? (lead.stateBeforeWait ?? { state: 'working' as const }) + : { state: 'working' as const } + state.claudeLeadStateByPaneKey.set(paneKey, { ...restored }) + const previousTool = state.lastToolByPaneKey.get(paneKey) + state.lastToolByPaneKey.set( + paneKey, + previousTool?.lastAssistantMessage + ? { lastAssistantMessage: previousTool.lastAssistantMessage } + : {} + ) + const resolved = resolveClaudePaneStatus(state, paneKey, restored) + return resolved.stateName === restored.state && resolved.workingMode === undefined + ? restored + : { + state: resolved.stateName, + ...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}), + ...(restored.interrupted ? { interrupted: true as const } : {}), + ...(restored.turnCompletedAt !== undefined + ? { turnCompletedAt: restored.turnCompletedAt } + : {}) + } +} diff --git a/src/shared/agent-hook-listener/providers/claude-status-build.ts b/src/shared/agent-hook-listener/providers/claude-status-build.ts new file mode 100644 index 00000000000..aa5d356d623 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/claude-status-build.ts @@ -0,0 +1,53 @@ +import { + normalizeAgentStatusPayload, + type AgentStatusState, + type AgentWorkingMode, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { claudeRosterToSnapshots } from '../../claude-subagent-roster' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import type { HookListenerState } from '../listener-state' + +export function buildClaudeStatusPayload( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record, + options: { + stateName: AgentStatusState + workingMode?: AgentWorkingMode + updateToolSnapshot: boolean + interrupted?: boolean + sessionBoundary?: boolean + turnCompletedAt?: number + } +): ParsedAgentStatusPayload | null { + // Why: child-driven refreshes are roster bookkeeping, not lead tool activity; read the cached snapshot without merging so they can't clear a live AskUserQuestion card or clobber the tool preview. + const snapshot = options.updateToolSnapshot + ? resolveToolState(state, paneKey, extractToolFields('claude', eventName, hookPayload), { + resetOnNewTurn: isNewTurnEvent('claude', eventName) + }) + : (state.lastToolByPaneKey.get(paneKey) ?? {}) + + // Why: validate directly — the JSON stringify/parse round trip other normalizers use is pure overhead on this hot per-hook path. + // 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) + }), + agentType: 'claude', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted: options.interrupted, + sessionBoundary: options.sessionBoundary, + turnCompletedAt: options.turnCompletedAt, + subagents: claudeRosterToSnapshots(state.claudeSubagentRosterByPaneKey.get(paneKey)) + }) +} diff --git a/src/shared/agent-hook-listener/providers/claude-tool-fields.ts b/src/shared/agent-hook-listener/providers/claude-tool-fields.ts new file mode 100644 index 00000000000..f980bbb34e7 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/claude-tool-fields.ts @@ -0,0 +1,62 @@ +import type { ToolSnapshot } from '../listener-event' +import { deriveToolInputPreview, hasOwnField, readString, toolUpdate } from '../tool-input-preview' +import { + clearActiveToolFieldsUpdate, + deriveInteractivePrompt, + extractToolResponseText +} from '../interactive-tool' +import { readLastAssistantFromTranscript } from '../transcript-lines' + +export function extractClaudeToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + const update: ToolSnapshot = {} + if (eventName === 'PostToolUseFailure') { + Object.assign(update, clearActiveToolFieldsUpdate()) + } else if ( + eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PermissionRequest' + ) { + const toolName = readString(hookPayload, 'tool_name') + Object.assign( + update, + toolUpdate( + { + toolName, + toolInput: deriveToolInputPreview(toolName, hookPayload.tool_input), + interactivePrompt: deriveInteractivePrompt(toolName, hookPayload.tool_input, eventName) + }, + { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } + ) + ) + } + if (eventName === 'PostToolUse') { + const responseText = extractToolResponseText(hookPayload.tool_response) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + if (eventName === 'PostToolUseFailure') { + const errorText = + extractToolResponseText(hookPayload.tool_response) ?? + readString(hookPayload, 'error') ?? + readString(hookPayload, 'message') + if (errorText) { + update.lastAssistantMessage = errorText + } + } + if (eventName === 'Stop') { + const direct = readString(hookPayload, 'last_assistant_message') + if (direct) { + update.lastAssistantMessage = direct + } else { + const lastFromTranscript = readLastAssistantFromTranscript(hookPayload.transcript_path) + if (lastFromTranscript) { + update.lastAssistantMessage = lastFromTranscript + } + } + } + return update +} diff --git a/src/shared/agent-hook-listener/providers/codex-events.ts b/src/shared/agent-hook-listener/providers/codex-events.ts new file mode 100644 index 00000000000..9df00a6ea1e --- /dev/null +++ b/src/shared/agent-hook-listener/providers/codex-events.ts @@ -0,0 +1,178 @@ +import { + AGENT_MODEL_MAX_LENGTH, + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { normalizeOptionalField } from '../../agent-status-field-normalization' +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { + codexRosterEffectiveState, + codexRosterToSnapshots, + finishCodexSubagent, + upsertCodexSubagent +} from '../../codex-subagent-roster' +import { reconcileCodexSubagentTranscript } from '../../codex-subagent-transcript' +import { readFirstString } from '../interactive-tool' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { readString } from '../tool-input-preview' +import { + getOrCreateCodexSubagentRoster, + getOrCreateCodexSubagentTranscriptState, + hasCodexTranscriptSubagents +} from './codex-state' + +export function buildCodexStatusPayload( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record, + options: { stateName: 'working' | 'waiting' | 'done'; updateLead: boolean } +): ParsedAgentStatusPayload | null { + const snapshot = options.updateLead + ? resolveToolState(state, paneKey, extractToolFields('codex', eventName, hookPayload), { + resetOnNewTurn: isNewTurnEvent('codex', eventName) + }) + : (state.lastToolByPaneKey.get(paneKey) ?? {}) + const lead = state.codexLeadStateByPaneKey.get(paneKey) + + return normalizeAgentStatusPayload({ + state: options.stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: options.updateLead && isNewTurnEvent('codex', eventName) + }), + agentType: 'codex', + model: lead?.model, + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + subagents: codexRosterToSnapshots(state.codexSubagentRosterByPaneKey.get(paneKey)) + }) +} + +export function buildCodexChildDrivenStatusPayload( + state: HookListenerState, + eventName: unknown, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const leadState = state.codexLeadStateByPaneKey.get(paneKey)?.state ?? 'working' + const stateName = codexRosterEffectiveState( + state.codexSubagentRosterByPaneKey.get(paneKey), + leadState + ) + return buildCodexStatusPayload(state, eventName, '', paneKey, hookPayload, { + stateName, + updateLead: false + }) +} + +export function normalizeCodexSubagentLifecycleEvent( + state: HookListenerState, + eventName: 'SubagentStart' | 'SubagentStop', + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const agentId = readString(hookPayload, 'agent_id') + if (!agentId) { + return null + } + const roster = getOrCreateCodexSubagentRoster(state, paneKey) + if (eventName === 'SubagentStart') { + upsertCodexSubagent( + roster, + agentId, + { + agentType: readString(hookPayload, 'agent_type'), + model: readString(hookPayload, 'model'), + state: 'working' + }, + Date.now() + ) + } else { + finishCodexSubagent(roster, agentId) + } + return buildCodexChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) +} + +export function normalizeCodexEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (eventName === 'SubagentStart' || eventName === 'SubagentStop') { + return normalizeCodexSubagentLifecycleEvent(state, eventName, paneKey, hookPayload) + } + + // Why: Codex's request_user_input (0.145+) is auto-allowed, so it fires PreToolUse while blocked on a human answer; map to waiting like grok's ask_user_question. + const isUserInputPreTool = + eventName === 'PreToolUse' && + isAskUserQuestionTool(readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name')) + const stateName = + eventName === 'SessionStart' || + eventName === 'UserPromptSubmit' || + (eventName === 'PreToolUse' && !isUserInputPreTool) || + eventName === 'PostToolUse' + ? 'working' + : eventName === 'PermissionRequest' || isUserInputPreTool + ? 'waiting' + : eventName === 'Stop' + ? 'done' + : null + if (!stateName) { + return null + } + + const agentId = readString(hookPayload, 'agent_id') + if (agentId) { + upsertCodexSubagent( + getOrCreateCodexSubagentRoster(state, paneKey), + agentId, + { + agentType: readString(hookPayload, 'agent_type'), + model: readString(hookPayload, 'model'), + state: stateName === 'waiting' ? 'waiting' : 'working' + }, + Date.now() + ) + return buildCodexChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + } + + if (eventName === 'SessionStart') { + // Why: a pane can host a new Codex process after the old one exited without child Stop hooks. + state.codexSubagentRosterByPaneKey.delete(paneKey) + state.codexSubagentTranscriptByPaneKey.delete(paneKey) + } + const transcriptPath = readFirstString(hookPayload, ['transcript_path', 'transcriptPath']) + if (transcriptPath) { + reconcileCodexSubagentTranscript( + getOrCreateCodexSubagentTranscriptState(state, paneKey), + getOrCreateCodexSubagentRoster(state, paneKey), + transcriptPath + ) + } + if (eventName === 'Stop' && !hasCodexTranscriptSubagents(state, paneKey)) { + // Why: Codex CLI 0.144 can omit child Stop hooks; later child activity safely recreates any agent still running. + state.codexSubagentRosterByPaneKey.delete(paneKey) + } + const previousLead = state.codexLeadStateByPaneKey.get(paneKey) + state.codexLeadStateByPaneKey.set(paneKey, { + state: stateName, + model: + normalizeOptionalField(hookPayload['model'], AGENT_MODEL_MAX_LENGTH) ?? + (eventName === 'SessionStart' ? undefined : previousLead?.model) + }) + const effectiveState = codexRosterEffectiveState( + state.codexSubagentRosterByPaneKey.get(paneKey), + stateName + ) + return buildCodexStatusPayload(state, eventName, promptText, paneKey, hookPayload, { + stateName: effectiveState, + updateLead: true + }) +} diff --git a/src/shared/agent-hook-listener/providers/codex-state.ts b/src/shared/agent-hook-listener/providers/codex-state.ts new file mode 100644 index 00000000000..c14c1d8a2b1 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/codex-state.ts @@ -0,0 +1,146 @@ +import type { ParsedAgentStatusPayload } from '../../agent-status-types' +import { + codexRosterEffectiveState, + codexRosterToSnapshots, + finishCodexSubagent, + seedCodexSubagentRoster, + type CodexSubagentRoster +} from '../../codex-subagent-roster' +import { + createCodexSubagentTranscriptState, + hasTrackedCodexTranscriptSubagents, + type CodexSubagentTranscriptState +} from '../../codex-subagent-transcript' +import type { CodexLeadTurnState, HookListenerState } from '../listener-state' + +export function getOrCreateCodexSubagentRoster( + state: HookListenerState, + paneKey: string +): CodexSubagentRoster { + let roster = state.codexSubagentRosterByPaneKey.get(paneKey) + if (!roster) { + roster = new Map() + state.codexSubagentRosterByPaneKey.set(paneKey, roster) + } + return roster +} + +export function getOrCreateCodexSubagentTranscriptState( + state: HookListenerState, + paneKey: string +): CodexSubagentTranscriptState { + let transcriptState = state.codexSubagentTranscriptByPaneKey.get(paneKey) + if (!transcriptState) { + transcriptState = createCodexSubagentTranscriptState() + state.codexSubagentTranscriptByPaneKey.set(paneKey, transcriptState) + } + return transcriptState +} + +export function hasCodexTranscriptSubagents(state: HookListenerState, paneKey: string): boolean { + return hasTrackedCodexTranscriptSubagents(state.codexSubagentTranscriptByPaneKey.get(paneKey)) +} + +export function seedCodexStateFromSnapshot( + state: HookListenerState, + paneKey: string, + payload: Pick +): void { + const snapshots = payload.subagents ?? [] + if (snapshots.length > 0 && !state.codexSubagentRosterByPaneKey.has(paneKey)) { + seedCodexSubagentRoster(getOrCreateCodexSubagentRoster(state, paneKey), snapshots) + } + if (!state.codexLeadStateByPaneKey.has(paneKey)) { + // Why: child hooks after restart omit the root model; seed it from durable status before they can overwrite the cache. + state.codexLeadStateByPaneKey.set(paneKey, { + // Why: a child wait drives the aggregate waiting state, so it is not evidence that the root itself was waiting. + state: + payload.state === 'done' + ? 'done' + : payload.state === 'waiting' && + !snapshots.some((snapshot) => snapshot.state === 'waiting') + ? 'waiting' + : 'working', + model: payload.model + }) + } +} + +/** Sync the Codex lead record when the server infers an interrupt, so delayed child events cannot restore stale working state. */ +export function markCodexLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { + const lead = state.codexLeadStateByPaneKey.get(paneKey) + state.codexLeadStateByPaneKey.set(paneKey, { state: 'done', model: lead?.model }) +} + +export function codexLeadStateForHookEvent( + eventName: string | undefined +): CodexLeadTurnState['state'] | undefined { + if (eventName === 'Stop') { + return 'done' + } + if (eventName === 'PermissionRequest') { + return 'waiting' + } + if ( + eventName === 'SessionStart' || + eventName === 'UserPromptSubmit' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ) { + return 'working' + } + return undefined +} + +/** Why: relay restarts lose lead/roster state; merge child events into main's longer-lived cache. */ +export function reconcileRemoteCodexState( + state: HookListenerState, + paneKey: string, + eventName: string | undefined, + agentId: string | undefined, + payload: ParsedAgentStatusPayload, + previous: ParsedAgentStatusPayload | undefined +): ParsedAgentStatusPayload { + if (previous?.agentType === 'codex') { + seedCodexStateFromSnapshot(state, paneKey, previous) + } else { + seedCodexStateFromSnapshot(state, paneKey, payload) + } + + // Why: older relays send child identity without roster snapshots; keep their already-normalized aggregate authoritative. + if (agentId && !payload.subagents && !state.codexSubagentRosterByPaneKey.has(paneKey)) { + return payload + } + const roster = getOrCreateCodexSubagentRoster(state, paneKey) + if (payload.subagents) { + seedCodexSubagentRoster(roster, payload.subagents) + } + if (agentId) { + if (eventName === 'SubagentStop') { + finishCodexSubagent(roster, agentId) + } + } else { + const leadState = codexLeadStateForHookEvent(eventName) + if (eventName === 'SessionStart' || (eventName === 'Stop' && !payload.subagents)) { + roster.clear() + } + if (leadState) { + const previousLead = state.codexLeadStateByPaneKey.get(paneKey) + state.codexLeadStateByPaneKey.set(paneKey, { + state: leadState, + model: payload.model ?? previousLead?.model + }) + } + } + + const lead = state.codexLeadStateByPaneKey.get(paneKey) + if (!lead) { + return payload + } + return { + ...payload, + state: codexRosterEffectiveState(roster, lead.state), + model: lead.model ?? payload.model, + subagents: codexRosterToSnapshots(roster) + } +} diff --git a/src/shared/agent-hook-listener/providers/codex-tool-fields.ts b/src/shared/agent-hook-listener/providers/codex-tool-fields.ts new file mode 100644 index 00000000000..09b87c794a5 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/codex-tool-fields.ts @@ -0,0 +1,41 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveToolInputPreview, + hasAnyOwnField, + readString, + toolUpdate +} from '../tool-input-preview' +import { deriveInteractivePrompt } from '../interactive-tool' + +export function extractCodexToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if ( + eventName === 'PreToolUse' || + eventName === 'PermissionRequest' || + eventName === 'PostToolUse' + ) { + const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') + const rawInput = hookPayload.tool_input ?? hookPayload.input ?? hookPayload.arguments + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.input) ?? + deriveToolInputPreview(toolName, hookPayload.arguments) + return toolUpdate( + { + toolName, + toolInput, + interactivePrompt: deriveInteractivePrompt(toolName, rawInput, eventName) + }, + { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'input', 'arguments']) } + ) + } + if (eventName === 'Stop') { + const message = readString(hookPayload, 'last_assistant_message') + if (message) { + return { lastAssistantMessage: message } + } + } + return {} +} diff --git a/src/shared/agent-hook-listener/providers/command-code-events.ts b/src/shared/agent-hook-listener/providers/command-code-events.ts new file mode 100644 index 00000000000..cfc503993ac --- /dev/null +++ b/src/shared/agent-hook-listener/providers/command-code-events.ts @@ -0,0 +1,44 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' + +export function normalizeCommandCodeEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'PreToolUse' || eventName === 'PostToolUse' + ? 'working' + : eventName === 'Stop' + ? 'done' + : null + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('command-code', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('command-code', eventName) } + ) + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('command-code', eventName) + }), + agentType: 'command-code', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage + }) +} diff --git a/src/shared/agent-hook-listener/providers/command-code-tool-fields.ts b/src/shared/agent-hook-listener/providers/command-code-tool-fields.ts new file mode 100644 index 00000000000..35788f7330f --- /dev/null +++ b/src/shared/agent-hook-listener/providers/command-code-tool-fields.ts @@ -0,0 +1,51 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveFallbackToolInputPreview, + deriveToolInputPreview, + hasOwnField, + readString, + toolUpdate +} from '../tool-input-preview' +import { extractToolResponseText } from '../interactive-tool' +import { readLastCommandCodeAssistantFromTranscript } from '../command-code-transcript' + +export function extractCommandCodeToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (eventName === 'PreToolUse' || eventName === 'PostToolUse') { + const toolName = + readString(hookPayload, 'tool_name') ?? + readString(hookPayload, 'toolName') ?? + readString(hookPayload, 'tool_display_name') + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveFallbackToolInputPreview(hookPayload.tool_input) + const update: ToolSnapshot = toolUpdate( + { toolName, toolInput }, + { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } + ) + if (eventName === 'PostToolUse') { + const responseText = + extractToolResponseText(hookPayload.tool_response) ?? + extractToolResponseText(hookPayload.tool_output) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + return update + } + if (eventName === 'Stop') { + const direct = readString(hookPayload, 'last_assistant_message') + if (direct) { + return { lastAssistantMessage: direct } + } + const fromTranscript = readLastCommandCodeAssistantFromTranscript( + hookPayload.transcript_path ?? hookPayload.transcriptPath + ) + if (fromTranscript) { + return { lastAssistantMessage: fromTranscript } + } + } + return {} +} diff --git a/src/shared/agent-hook-listener/providers/copilot-events.ts b/src/shared/agent-hook-listener/providers/copilot-events.ts new file mode 100644 index 00000000000..a9c599ef3d7 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/copilot-events.ts @@ -0,0 +1,73 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { readFirstString } from '../interactive-tool' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { + isAskUserTool, + normalizeCopilotEventName, + resolveCopilotEventName +} from './copilot-tool-fields' + +// Why: Copilot PermissionRequest fires before allow/ask/deny (stays working); ask_user and notification prompts are the real blocked signals. +export function normalizeCopilotEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const normalizedEventName = normalizeCopilotEventName( + resolveCopilotEventName(eventName, hookPayload) + ) + const notificationType = readFirstString(hookPayload, ['notification_type', 'notificationType']) + const isBlockingNotification = + normalizedEventName === 'Notification' && + (notificationType === 'permission_prompt' || notificationType === 'elicitation_dialog') + const toolSnapshot = extractToolFields('copilot', normalizedEventName, hookPayload) + const isAskUserPrompt = + (normalizedEventName === 'PreToolUse' || normalizedEventName === 'PermissionRequest') && + isAskUserTool(toolSnapshot.toolName) + const stateName = + normalizedEventName === 'SessionStart' || + normalizedEventName === 'UserPromptSubmit' || + normalizedEventName === 'PostToolUse' || + normalizedEventName === 'PostToolUseFailure' + ? 'working' + : isBlockingNotification || isAskUserPrompt + ? 'blocked' + : normalizedEventName === 'PreToolUse' || normalizedEventName === 'PermissionRequest' + ? 'working' + : normalizedEventName === 'Stop' || normalizedEventName === 'SessionEnd' + ? 'done' + : normalizedEventName === 'ErrorOccurred' + ? hookPayload.recoverable === true + ? 'working' + : 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState(state, paneKey, toolSnapshot, { + resetOnNewTurn: isNewTurnEvent('copilot', normalizedEventName) + }) + + const effectivePrompt = normalizedEventName === 'Notification' ? '' : promptText + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, effectivePrompt, { + resetOnNewTurn: isNewTurnEvent('copilot', normalizedEventName) + }), + agentType: 'copilot', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage + }) +} diff --git a/src/shared/agent-hook-listener/providers/copilot-tool-fields.ts b/src/shared/agent-hook-listener/providers/copilot-tool-fields.ts new file mode 100644 index 00000000000..d33fcd63677 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/copilot-tool-fields.ts @@ -0,0 +1,203 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveToolInputPreview, + hasAnyOwnField, + readString, + toolUpdate +} from '../tool-input-preview' +import { + clearActiveToolFieldsUpdate, + extractToolResponseText, + parseJsonObjectString, + readFirstString +} from '../interactive-tool' +import { readLastAssistantFromTranscript } from '../transcript-lines' + +export function normalizeCopilotEventName(eventName: unknown): unknown { + if (typeof eventName !== 'string') { + return eventName + } + const eventMap: Record = { + sessionStart: 'SessionStart', + sessionEnd: 'SessionEnd', + userPromptSubmitted: 'UserPromptSubmit', + userPromptSubmit: 'UserPromptSubmit', + preToolUse: 'PreToolUse', + postToolUse: 'PostToolUse', + postToolUseFailure: 'PostToolUseFailure', + subagentStart: 'SubagentStart', + subagentStop: 'SubagentStop', + preCompact: 'PreCompact', + agentStop: 'Stop', + stop: 'Stop', + errorOccurred: 'ErrorOccurred', + permissionRequest: 'PermissionRequest', + notification: 'Notification' + } + return eventMap[eventName] ?? eventName +} + +export function resolveCopilotEventName( + eventName: unknown, + hookPayload: Record +): unknown { + const explicit = + eventName ?? + readFirstString(hookPayload, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) + if (explicit) { + return explicit + } + if (readFirstString(hookPayload, ['initial_prompt', 'initialPrompt'])) { + return 'SessionStart' + } + if (readString(hookPayload, 'prompt')) { + return 'UserPromptSubmit' + } + if (readFirstString(hookPayload, ['notification_type', 'notificationType'])) { + return 'Notification' + } + if ( + readFirstString(hookPayload, ['transcript_path', 'transcriptPath', 'stop_reason', 'stopReason']) + ) { + return 'Stop' + } + if (hookPayload.error || readFirstString(hookPayload, ['error_context', 'errorContext'])) { + return 'ErrorOccurred' + } + if ( + Array.isArray(hookPayload.toolCalls) || + readFirstString(hookPayload, ['tool_name', 'toolName', 'name']) + ) { + if ( + hookPayload.tool_result || + hookPayload.toolResult || + hookPayload.tool_response || + hookPayload.toolResponse + ) { + return 'PostToolUse' + } + return 'PreToolUse' + } + return eventName +} + +export function readCopilotToolCall(hookPayload: Record): { + toolName?: string + toolInputSource?: unknown +} { + const toolCalls = hookPayload.toolCalls + if (!Array.isArray(toolCalls) || toolCalls.length === 0) { + return {} + } + const first = toolCalls[0] + if (typeof first !== 'object' || first === null) { + return {} + } + const record = first as Record + return { + toolName: readFirstString(record, ['name', 'toolName', 'tool_name']), + toolInputSource: + parseJsonObjectString(record.args) ?? + record.args ?? + parseJsonObjectString(record.arguments) ?? + record.arguments + } +} + +export function isAskUserTool(toolName: string | undefined): boolean { + return toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuser' +} + +export function extractCopilotToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + const update: ToolSnapshot = {} + if (eventName === 'PostToolUseFailure' || eventName === 'ErrorOccurred') { + Object.assign(update, clearActiveToolFieldsUpdate()) + } else if ( + eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PermissionRequest' + ) { + const copilotToolCall = readCopilotToolCall(hookPayload) + const toolName = + readFirstString(hookPayload, ['tool_name', 'toolName', 'name']) ?? copilotToolCall.toolName + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.toolInput) ?? + deriveToolInputPreview(toolName, hookPayload.toolArgs) ?? + deriveToolInputPreview(toolName, hookPayload.input) ?? + deriveToolInputPreview(toolName, hookPayload.arguments) ?? + deriveToolInputPreview(toolName, copilotToolCall.toolInputSource) + Object.assign( + update, + toolUpdate( + { toolName, toolInput }, + { + hasToolInputField: + hasAnyOwnField(hookPayload, [ + 'tool_input', + 'toolInput', + 'toolArgs', + 'input', + 'arguments' + ]) || copilotToolCall.toolInputSource !== undefined + } + ) + ) + if (isAskUserTool(toolName) && toolInput) { + update.lastAssistantMessage = toolInput + } + } + if (eventName === 'PostToolUse') { + const responseText = + extractToolResponseText(hookPayload.tool_result) ?? + extractToolResponseText(hookPayload.toolResult) ?? + extractToolResponseText(hookPayload.tool_response) ?? + extractToolResponseText(hookPayload.toolResponse) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + if (eventName === 'PostToolUseFailure' || eventName === 'ErrorOccurred') { + const errorText = + extractToolResponseText(hookPayload.tool_result) ?? + extractToolResponseText(hookPayload.toolResult) ?? + extractToolResponseText(hookPayload.tool_response) ?? + extractToolResponseText(hookPayload.toolResponse) ?? + readFirstString(hookPayload, ['error_message', 'errorMessage', 'error', 'message']) + if (errorText) { + update.lastAssistantMessage = errorText + } + } + if (eventName === 'Notification') { + const notificationType = readFirstString(hookPayload, ['notification_type', 'notificationType']) + if (notificationType === 'permission_prompt' || notificationType === 'elicitation_dialog') { + const message = readFirstString(hookPayload, ['message', 'body', 'text', 'title']) + if (message) { + update.lastAssistantMessage = message + } + } + } + if (eventName === 'Stop') { + const direct = readFirstString(hookPayload, [ + 'last_assistant_message', + 'lastAssistantMessage', + 'message' + ]) + if (direct) { + update.lastAssistantMessage = direct + } else { + const lastFromTranscript = readLastAssistantFromTranscript( + hookPayload.transcript_path ?? hookPayload.transcriptPath + ) + if (lastFromTranscript) { + update.lastAssistantMessage = lastFromTranscript + } else { + update.clearLastAssistantMessage = true + } + } + } + return update +} diff --git a/src/shared/agent-hook-listener/providers/cursor-events.ts b/src/shared/agent-hook-listener/providers/cursor-events.ts new file mode 100644 index 00000000000..1c933c96361 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/cursor-events.ts @@ -0,0 +1,66 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' + +export function normalizeCursorEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + // Why: Cursor can emit final response text after `stop`; enrich the completed row, don't resurrect the agent as working. + const previousStatus = state.lastStatusByPaneKey.get(paneKey)?.payload + const stateName = + eventName === 'beforeSubmitPrompt' || + eventName === 'sessionStart' || + eventName === 'preToolUse' || + eventName === 'postToolUse' || + eventName === 'postToolUseFailure' || + // Why: these fire on every shell/MCP invocation (pre-execution gates, not just approval); treat as working to avoid waiting-notification spam. + eventName === 'beforeShellExecution' || + eventName === 'beforeMCPExecution' + ? 'working' + : eventName === 'afterAgentResponse' + ? previousStatus?.state === 'done' && previousStatus.agentType === 'cursor' + ? 'done' + : 'working' + : eventName === 'stop' || eventName === 'sessionEnd' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('cursor', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('cursor', eventName) } + ) + + const interrupted = + eventName === 'stop' && + typeof hookPayload.status === 'string' && + hookPayload.status !== 'completed' + ? true + : undefined + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('cursor', eventName) + }), + agentType: 'cursor', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted + }) +} diff --git a/src/shared/agent-hook-listener/providers/cursor-tool-fields.ts b/src/shared/agent-hook-listener/providers/cursor-tool-fields.ts new file mode 100644 index 00000000000..648077a0f66 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/cursor-tool-fields.ts @@ -0,0 +1,76 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveToolInputPreview, + hasAnyOwnField, + hasOwnField, + readString, + toolUpdate +} from '../tool-input-preview' +import { clearActiveToolFieldsUpdate, extractToolResponseText } from '../interactive-tool' + +export function extractCursorToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if ( + eventName === 'preToolUse' || + eventName === 'postToolUse' || + eventName === 'postToolUseFailure' + ) { + const update: ToolSnapshot = {} + if (eventName === 'postToolUseFailure') { + Object.assign(update, clearActiveToolFieldsUpdate()) + } else { + const toolName = readString(hookPayload, 'tool_name') + const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) + Object.assign( + update, + toolUpdate( + { toolName, toolInput }, + { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } + ) + ) + } + if (eventName === 'postToolUse') { + const responseText = extractToolResponseText(hookPayload.tool_output) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + if (eventName === 'postToolUseFailure') { + const errorText = + extractToolResponseText(hookPayload.tool_output) ?? + readString(hookPayload, 'error_message') ?? + readString(hookPayload, 'error') + if (errorText) { + update.lastAssistantMessage = errorText + } + } + return update + } + if (eventName === 'beforeShellExecution') { + const command = readString(hookPayload, 'command') + return toolUpdate( + { toolName: 'Shell', toolInput: command }, + { hasToolInputField: hasOwnField(hookPayload, 'command') } + ) + } + if (eventName === 'beforeMCPExecution') { + const toolName = readString(hookPayload, 'tool_name') ?? 'MCP' + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + readString(hookPayload, 'command') ?? + readString(hookPayload, 'url') + return toolUpdate( + { toolName, toolInput }, + { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'command', 'url']) } + ) + } + if (eventName === 'afterAgentResponse') { + const text = readString(hookPayload, 'text') + if (text) { + return { lastAssistantMessage: text } + } + } + return {} +} diff --git a/src/shared/agent-hook-listener/providers/devin-events.ts b/src/shared/agent-hook-listener/providers/devin-events.ts new file mode 100644 index 00000000000..bcb77020a3b --- /dev/null +++ b/src/shared/agent-hook-listener/providers/devin-events.ts @@ -0,0 +1,61 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' + +// Why: Devin uses Claude-compatible payloads but its own lifecycle event set; normalize those event names while keeping Devin attribution. +export function normalizeDevinEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (eventName === 'SessionStart') { + // Why: Devin emits SessionStart on idle TUI open/resume; mapping it to 'working' showed a spinner before the user typed, so only UserPromptSubmit/tool activity may create a row. + clearPaneTurnCacheState(state, paneKey) + return null + } + + const stateName = + eventName === 'UserPromptSubmit' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PostCompaction' + ? 'working' + : eventName === 'PermissionRequest' + ? 'waiting' + : eventName === 'Stop' || eventName === 'SessionEnd' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('devin', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('devin', eventName) } + ) + + const interrupted = + eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('devin', eventName) + }), + agentType: 'devin', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted + }) +} diff --git a/src/shared/agent-hook-listener/providers/droid-events.ts b/src/shared/agent-hook-listener/providers/droid-events.ts new file mode 100644 index 00000000000..dbebc73cc13 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/droid-events.ts @@ -0,0 +1,79 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { readString } from '../tool-input-preview' +import { + isDroidAskUserTool, + isDroidHighRiskToolUse, + isDroidIdleNotification, + isDroidPermissionNotification +} from './droid-tool-fields' + +export function normalizeDroidEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (eventName === 'SessionStart') { + // Why: Droid's SessionStart fires while idle (TUI open/resume); wait for real activity before a working row. + clearPaneTurnCacheState(state, paneKey) + return null + } + + const notificationMessage = readString(hookPayload, 'message') + const droidToolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') + let stateName: 'working' | 'waiting' | 'done' | null = null + if ( + eventName === 'PreToolUse' && + (isDroidAskUserTool(droidToolName) || isDroidHighRiskToolUse(hookPayload)) + ) { + // Why: Droid surfaces AskUser and high-risk approvals as PreToolUse; the approval path emits no Notification hook. + stateName = 'waiting' + } else if ( + eventName === 'UserPromptSubmit' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ) { + stateName = 'working' + } else if (eventName === 'Stop') { + stateName = 'done' + } else if (eventName === 'PermissionRequest') { + stateName = 'waiting' + } else if (eventName === 'Notification' && isDroidPermissionNotification(notificationMessage)) { + stateName = 'waiting' + } else if (eventName === 'Notification' && isDroidIdleNotification(notificationMessage)) { + // Why: Droid emits no Stop on user-interrupt, only an idle notification when ready again. + stateName = 'done' + } + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('droid', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('droid', eventName) } + ) + + // Why: Droid Notification.message is status text, not the prompt; '' keeps resolvePrompt's cached UserPromptSubmit value. + const effectivePrompt = eventName === 'Notification' ? '' : promptText + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, effectivePrompt, { + resetOnNewTurn: isNewTurnEvent('droid', eventName) + }), + agentType: 'droid', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage + }) +} diff --git a/src/shared/agent-hook-listener/providers/droid-tool-fields.ts b/src/shared/agent-hook-listener/providers/droid-tool-fields.ts new file mode 100644 index 00000000000..4bcfe5e560f --- /dev/null +++ b/src/shared/agent-hook-listener/providers/droid-tool-fields.ts @@ -0,0 +1,98 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveToolInputPreview, + hasAnyOwnField, + readString, + toolUpdate +} from '../tool-input-preview' +import { extractToolResponseText } from '../interactive-tool' +import { readLastAssistantFromTranscript } from '../transcript-lines' + +export function isDroidPermissionNotification(message: string | undefined): boolean { + if (!message) { + return false + } + const lower = message.toLowerCase() + // Why: 'confirm' is excluded — it false-positives on benign messages like "task confirmed" that aren't permission prompts. + return lower.includes('permission') || lower.includes('approve') || lower.includes('approval') +} + +export function isDroidIdleNotification(message: string | undefined): boolean { + if (!message) { + return false + } + const lower = message.toLowerCase() + return lower.includes('waiting for your input') || lower.includes('waiting for input') +} + +export function isDroidAskUserTool(toolName: string | undefined): boolean { + if (!toolName) { + return false + } + return toolName.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuser' +} + +export function readDroidToolRiskLevel(hookPayload: Record): string | undefined { + const directRisk = readString(hookPayload, 'riskLevel') ?? readString(hookPayload, 'risk_level') + if (directRisk) { + return directRisk + } + + for (const key of ['tool_input', 'input', 'arguments'] as const) { + const value = hookPayload[key] + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + continue + } + const record = value as Record + const nestedRisk = readString(record, 'riskLevel') ?? readString(record, 'risk_level') + if (nestedRisk) { + return nestedRisk + } + } + return undefined +} + +export function isDroidHighRiskToolUse(hookPayload: Record): boolean { + return readDroidToolRiskLevel(hookPayload)?.trim().toLowerCase() === 'high' +} + +export function extractDroidToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if ( + eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PermissionRequest' + ) { + const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.input) ?? + deriveToolInputPreview(toolName, hookPayload.arguments) + const update: ToolSnapshot = toolUpdate( + { toolName, toolInput }, + { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'input', 'arguments']) } + ) + if (eventName === 'PostToolUse') { + const responseText = + extractToolResponseText(hookPayload.tool_response) ?? + extractToolResponseText(hookPayload.tool_output) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + return update + } + if (eventName === 'Stop') { + const direct = readString(hookPayload, 'last_assistant_message') + if (direct) { + return { lastAssistantMessage: direct } + } + const fromTranscript = readLastAssistantFromTranscript(hookPayload.transcript_path) + if (fromTranscript) { + return { lastAssistantMessage: fromTranscript } + } + } + return {} +} diff --git a/src/shared/agent-hook-listener/providers/gemini-events.ts b/src/shared/agent-hook-listener/providers/gemini-events.ts new file mode 100644 index 00000000000..0d7de6316db --- /dev/null +++ b/src/shared/agent-hook-listener/providers/gemini-events.ts @@ -0,0 +1,50 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' + +export function normalizeGeminiEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + // Why: Gemini CLI's native pre-tool event is BeforeTool; PreToolUse/PostToolUse still accepted for legacy Antigravity-compatible payloads. + const stateName = + eventName === 'BeforeAgent' || + eventName === 'BeforeTool' || + eventName === 'AfterTool' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ? 'working' + : eventName === 'AfterAgent' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('gemini', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('gemini', eventName) } + ) + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('gemini', eventName) + }), + agentType: 'gemini', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage + }) +} diff --git a/src/shared/agent-hook-listener/providers/gemini-tool-fields.ts b/src/shared/agent-hook-listener/providers/gemini-tool-fields.ts new file mode 100644 index 00000000000..9fadfea6e3c --- /dev/null +++ b/src/shared/agent-hook-listener/providers/gemini-tool-fields.ts @@ -0,0 +1,36 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveToolInputPreview, + hasAnyOwnField, + readString, + toolUpdate +} from '../tool-input-preview' + +export function extractGeminiToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if ( + eventName === 'BeforeTool' || + eventName === 'AfterTool' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ) { + const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.args) ?? + deriveToolInputPreview(toolName, hookPayload.input) + return toolUpdate( + { toolName, toolInput }, + { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'args', 'input']) } + ) + } + if (eventName === 'AfterAgent') { + const message = readString(hookPayload, 'prompt_response') + if (message) { + return { lastAssistantMessage: message } + } + } + return {} +} diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts new file mode 100644 index 00000000000..987c13b61a5 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -0,0 +1,103 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState, stripGrokUserQueryWrapper } from '../prompt-fields' +import { + extractToolFields, + isGrokIdleNotification, + isNewTurnEvent +} from '../provider-event-routing' +import { readString } from '../tool-input-preview' +import { isGrokEvent } from '../provider-event-names' +import { + getGrokNotificationType, + isGrokPermissionNotification, + isGrokRoutinePermissionPromptNotification +} from './grok-tool-fields' + +export function normalizeGrokEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record, + grokHome?: string +): ParsedAgentStatusPayload | null { + if (isGrokEvent(eventName, 'session_start')) { + // Why: SessionStart resets stale per-turn state but must not create a working row before any prompt/tool event. + clearPaneTurnCacheState(state, paneKey) + return null + } + + const notificationMessage = readString(hookPayload, 'message') + const notificationType = getGrokNotificationType(hookPayload) + const notificationLevel = readString(hookPayload, 'level') + const preToolName = + readString(hookPayload, 'toolName') ?? + readString(hookPayload, 'tool_name') ?? + readString(hookPayload, 'name') + // Why: Grok's ask_user_question is auto-allowed, so it fires PreToolUse while blocked on a human answer; map to waiting. + const isUserInputPreTool = + isGrokEvent(eventName, 'pre_tool_use') && isAskUserQuestionTool(preToolName) + + let stateName: 'working' | 'waiting' | 'done' | null = null + if ( + isGrokEvent(eventName, 'user_prompt_submit', 'post_tool_use', 'post_tool_use_failure') || + (isGrokEvent(eventName, 'pre_tool_use') && !isUserInputPreTool) + ) { + stateName = 'working' + } else if (isUserInputPreTool) { + stateName = 'waiting' + } else if (isGrokEvent(eventName, 'stop', 'session_end', 'stop_failure')) { + stateName = 'done' + } else if ( + isGrokEvent(eventName, 'notification') && + isGrokRoutinePermissionPromptNotification( + notificationType, + notificationMessage, + notificationLevel + ) + ) { + return null + } else if ( + isGrokEvent(eventName, 'notification') && + isGrokPermissionNotification(notificationMessage) + ) { + stateName = 'waiting' + } else if ( + isGrokEvent(eventName, 'notification') && + isGrokIdleNotification(notificationMessage) + ) { + stateName = 'done' + } + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('grok', eventName, hookPayload, { grokHome }), + { resetOnNewTurn: isNewTurnEvent('grok', eventName) } + ) + + // Why: Grok Notification.message is status UI text, not the prompt; '' preserves the cached UserPromptSubmit. + const effectivePrompt = isGrokEvent(eventName, 'notification') + ? '' + : stripGrokUserQueryWrapper(promptText) + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, effectivePrompt, { + resetOnNewTurn: isNewTurnEvent('grok', eventName) + }), + agentType: 'grok', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage + }) +} diff --git a/src/shared/agent-hook-listener/providers/grok-tool-fields.ts b/src/shared/agent-hook-listener/providers/grok-tool-fields.ts new file mode 100644 index 00000000000..32d12511a55 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/grok-tool-fields.ts @@ -0,0 +1,128 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveFallbackToolInputPreview, + deriveToolInputPreview, + hasAnyOwnField, + readString, + toolUpdate +} from '../tool-input-preview' +import { + clearActiveToolFieldsUpdate, + deriveInteractivePrompt, + extractToolResponseText +} from '../interactive-tool' +import { readLastAssistantFromTranscript } from '../transcript-lines' +import { readLastAssistantFromGrokChatHistory } from '../grok-result-discovery' +import { isGrokEvent } from '../provider-event-names' + +export function extractGrokToolFields( + eventName: unknown, + hookPayload: Record, + grokHome?: string +): ToolSnapshot { + if (isGrokEvent(eventName, 'pre_tool_use', 'post_tool_use', 'post_tool_use_failure')) { + const update: ToolSnapshot = {} + if (isGrokEvent(eventName, 'post_tool_use_failure')) { + Object.assign(update, clearActiveToolFieldsUpdate()) + } else { + const toolName = + readString(hookPayload, 'toolName') ?? + readString(hookPayload, 'tool_name') ?? + readString(hookPayload, 'name') + const rawInput = + hookPayload.toolInput ?? + hookPayload.tool_input ?? + hookPayload.input ?? + hookPayload.arguments + const toolInput = + deriveToolInputPreview(toolName, rawInput) ?? deriveFallbackToolInputPreview(rawInput) + // Why: Grok's ask_user_question is auto-allowed via PreToolUse, not PermissionRequest; capture full payload for the live card. + const interactivePrompt = deriveInteractivePrompt(toolName, rawInput, eventName) + Object.assign( + update, + toolUpdate( + { toolName, toolInput, interactivePrompt }, + { + hasToolInputField: hasAnyOwnField(hookPayload, [ + 'toolInput', + 'tool_input', + 'input', + 'arguments' + ]) + } + ) + ) + } + if (isGrokEvent(eventName, 'post_tool_use', 'post_tool_use_failure')) { + const responseText = + extractToolResponseText(hookPayload.toolResponse) ?? + extractToolResponseText(hookPayload.tool_response) ?? + extractToolResponseText(hookPayload.toolOutput) ?? + extractToolResponseText(hookPayload.tool_output) ?? + readString(hookPayload, 'error') ?? + readString(hookPayload, 'message') + if (responseText) { + update.lastAssistantMessage = responseText + } + } + return update + } + if (isGrokEvent(eventName, 'stop', 'session_end', 'stop_failure')) { + const direct = + readString(hookPayload, 'lastAssistantMessage') ?? + readString(hookPayload, 'last_assistant_message') + if (direct) { + return { lastAssistantMessage: direct } + } + const fromTranscript = readLastAssistantFromTranscript( + hookPayload.transcriptPath ?? hookPayload.transcript_path + ) + if (fromTranscript) { + return { lastAssistantMessage: fromTranscript } + } + const fromChatHistory = readLastAssistantFromGrokChatHistory(hookPayload, grokHome) + if (fromChatHistory) { + return { lastAssistantMessage: fromChatHistory } + } + } + return {} +} +export function isGrokPermissionNotification(message: string | undefined): boolean { + if (!message) { + return false + } + const lower = message.toLowerCase() + return ( + lower.includes('permission') || + lower.includes('approval') || + lower.includes('approve') || + lower.includes('allow') || + lower.includes('confirm') || + lower.includes('needs your') || + lower.includes('requires your') || + lower.includes('feedback') || + lower.includes('clarify') || + lower.includes('question') + ) +} + +export function getGrokNotificationType(hookPayload: Record): string | undefined { + return ( + readString(hookPayload, 'notificationType') ?? + readString(hookPayload, 'notification_type') ?? + readString(hookPayload, 'type') + ) +} + +export function isGrokRoutinePermissionPromptNotification( + notificationType: string | undefined, + message: string | undefined, + level: string | undefined +): boolean { + // Why: Grok emits this before each tool even under bypassPermissions; PreToolUse already covers progress. + return ( + isGrokEvent(notificationType, 'permission_prompt') && + message?.trim().toLowerCase() === 'tool permission requested' && + (!level || level.trim().toLowerCase() === 'info') + ) +} diff --git a/src/shared/agent-hook-listener/providers/hermes-events.ts b/src/shared/agent-hook-listener/providers/hermes-events.ts new file mode 100644 index 00000000000..3757e13430a --- /dev/null +++ b/src/shared/agent-hook-listener/providers/hermes-events.ts @@ -0,0 +1,54 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' + +export function normalizeHermesEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'pre_approval_request' + ? 'waiting' + : eventName === 'post_llm_call' || + eventName === 'on_session_end' || + eventName === 'on_session_finalize' || + eventName === 'on_session_reset' + ? 'done' + : eventName === 'on_session_start' || + eventName === 'pre_llm_call' || + eventName === 'pre_tool_call' || + eventName === 'post_tool_call' || + eventName === 'post_approval_response' + ? 'working' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('hermes', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('hermes', eventName) } + ) + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('hermes', eventName) + }), + agentType: 'hermes', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage + }) +} diff --git a/src/shared/agent-hook-listener/providers/hermes-tool-fields.ts b/src/shared/agent-hook-listener/providers/hermes-tool-fields.ts new file mode 100644 index 00000000000..93502b1c1d2 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/hermes-tool-fields.ts @@ -0,0 +1,70 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveFallbackToolInputPreview, + deriveToolInputPreview, + hasAnyOwnField, + readString, + toolUpdate +} from '../tool-input-preview' +import { extractToolResponseText } from '../interactive-tool' + +export function extractHermesToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if ( + eventName === 'pre_tool_call' || + eventName === 'post_tool_call' || + eventName === 'pre_approval_request' || + eventName === 'post_approval_response' + ) { + const toolName = + readString(hookPayload, 'tool_name') ?? + readString(hookPayload, 'name') ?? + (eventName === 'pre_approval_request' || eventName === 'post_approval_response' + ? 'approval' + : undefined) + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.args) ?? + deriveToolInputPreview(toolName, hookPayload.input) ?? + // Why: Hermes has many tool names; fall back to obvious arg fields so a new name still shows a value, not a blank row. + deriveFallbackToolInputPreview(hookPayload.tool_input) ?? + deriveFallbackToolInputPreview(hookPayload.args) ?? + deriveFallbackToolInputPreview(hookPayload.input) ?? + readString(hookPayload, 'command') ?? + readString(hookPayload, 'description') + const update: ToolSnapshot = toolUpdate( + { toolName, toolInput }, + { + hasToolInputField: hasAnyOwnField(hookPayload, [ + 'tool_input', + 'args', + 'input', + 'command', + 'description' + ]) + } + ) + if (eventName === 'post_tool_call') { + const responseText = + extractToolResponseText(hookPayload.result) ?? + extractToolResponseText(hookPayload.tool_response) ?? + extractToolResponseText(hookPayload.output) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + return update + } + if (eventName === 'post_llm_call') { + const message = + readString(hookPayload, 'last_assistant_message') ?? + readString(hookPayload, 'assistant_response') ?? + readString(hookPayload, 'response_text') + if (message) { + return { lastAssistantMessage: message } + } + } + return {} +} diff --git a/src/shared/agent-hook-listener/providers/kimi-events.ts b/src/shared/agent-hook-listener/providers/kimi-events.ts new file mode 100644 index 00000000000..c51a8be901a --- /dev/null +++ b/src/shared/agent-hook-listener/providers/kimi-events.ts @@ -0,0 +1,73 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { + resolvePrompt, + resolveToolState, + shouldIgnoreCompactContinuationUserPromptSubmit +} from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { readString } from '../tool-input-preview' + +// Why: Kimi's auto-allowed AskUserQuestion emits PreToolUse (not PermissionRequest) while awaiting an answer; treat as waiting so the UI shows the attention icon, not a spinner. +export function isKimiUserInputTool(toolName: string | undefined): boolean { + return toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuserquestion' +} + +// Why: Kimi Code emits Claude-compatible payloads/event names; normalize but attribute to Kimi so the sidebar shows Kimi's icon/label, not Claude's. +export function normalizeKimiEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { + return null + } + + const toolName = readString(hookPayload, 'tool_name') + const isUserInputTool = isKimiUserInputTool(toolName) + + let stateName: 'working' | 'waiting' | 'done' | null = null + if ( + eventName === 'UserPromptSubmit' || + eventName === 'PostToolUse' || + eventName === 'PostToolUseFailure' || + (eventName === 'PreToolUse' && !isUserInputTool) + ) { + stateName = 'working' + } else if (eventName === 'PermissionRequest' || (eventName === 'PreToolUse' && isUserInputTool)) { + stateName = 'waiting' + } else if (eventName === 'Stop' || eventName === 'StopFailure') { + stateName = 'done' + } + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('kimi', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('kimi', eventName) } + ) + + const interrupted = + eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('kimi', eventName) + }), + agentType: 'kimi', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted + }) +} diff --git a/src/shared/agent-hook-listener/providers/opencode-family-events.ts b/src/shared/agent-hook-listener/providers/opencode-family-events.ts new file mode 100644 index 00000000000..913bdbf4c03 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/opencode-family-events.ts @@ -0,0 +1,56 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' + +export function normalizeOpenCodeFamilyEvent( + source: 'opencode' | 'mimo-code', + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const resetsTurn = + isNewTurnEvent(source, eventName) || + (eventName === 'MessagePart' && hookPayload.role === 'user') + const stateName = + eventName === 'SessionBusy' || eventName === 'MessagePart' + ? 'working' + : eventName === 'SessionIdle' + ? 'done' + : source === 'opencode' && eventName === 'SessionStart' + ? 'done' + : eventName === 'PermissionRequest' || eventName === 'AskUserQuestion' + ? 'waiting' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields(source, eventName, hookPayload), + { + resetOnNewTurn: resetsTurn + } + ) + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: resetsTurn + }), + agentType: source, + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + sessionBoundary: source === 'opencode' && eventName === 'SessionStart' ? true : undefined + }) +} diff --git a/src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts b/src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts new file mode 100644 index 00000000000..4236cace716 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts @@ -0,0 +1,75 @@ +import { capOpenCodeHookText } from '../listener-limits' +import type { ToolSnapshot } from '../listener-event' +import { hasAnyOwnField, hasOwnField, readString, toolUpdate } from '../tool-input-preview' +import { + deriveInteractivePrompt, + readFirstString, + stripHookEnvelopeKeys +} from '../interactive-tool' + +/** + * Retires any cached tool fields. PermissionRequest is the only OpenCode-family event that + * carries them, and the only isNewTurnEvent boundary this family has is opencode's + * SessionStart — which a resumed session never re-emits — so nothing else resets the cache + * mid-session. Without an explicit retire, resolveToolState inherits one answered permission + * onto every later frame in the pane and the row reads a resolved command as the live tool. + */ +const OPENCODE_TOOL_FIELDS_RETIRED: ToolSnapshot = { + hasToolUpdate: true, + hasToolInputField: true +} + +export function extractOpenCodeToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (eventName === 'MessagePart' && hookPayload.role === 'assistant') { + const text = readString(hookPayload, 'text') + if (text) { + return { ...OPENCODE_TOOL_FIELDS_RETIRED, lastAssistantMessage: capOpenCodeHookText(text) } + } + } + if (eventName === 'AskUserQuestion') { + // Why: OpenCode's payload is question.asked's event.properties (hook_event_name merged in); strip envelope or use tool_input, capture JSON for the card. + const toolInputSource = hasOwnField(hookPayload, 'tool_input') + ? hookPayload.tool_input + : stripHookEnvelopeKeys(hookPayload) + return { + ...OPENCODE_TOOL_FIELDS_RETIRED, + interactivePrompt: deriveInteractivePrompt('AskUserQuestion', toolInputSource) + } + } + if (eventName === 'PermissionRequest') { + // Why: the payload is permission.asked's event.properties — `permission` names what is + // being requested and `metadata`/`patterns` say which command or path it covers. Without + // them the row is a bare 'waiting' that cannot tell the user what to approve. + // The SDK types `metadata` as Record, so these keys come from the tools + // themselves: bash sends `command`, edit sends `filepath`, webfetch sends `url` (verified + // against opencode 1.18.18). `file_path`/`path` cover tools that spell it the common way. + // `diff` is deliberately absent — edit ships the whole patch and it would swamp the row. + const metadata = hookPayload.metadata + const metadataInput = + metadata !== null && typeof metadata === 'object' && !Array.isArray(metadata) + ? readFirstString(metadata as Record, [ + 'command', + 'filepath', + 'file_path', + 'path', + 'url' + ]) + : undefined + const patterns = Array.isArray(hookPayload.patterns) + ? hookPayload.patterns.filter( + (pattern): pattern is string => typeof pattern === 'string' && pattern.length > 0 + ) + : [] + return toolUpdate( + { + toolName: readString(hookPayload, 'permission'), + toolInput: metadataInput ?? (patterns.length > 0 ? patterns.join(', ') : undefined) + }, + { hasToolInputField: hasAnyOwnField(hookPayload, ['metadata', 'patterns']) } + ) + } + return OPENCODE_TOOL_FIELDS_RETIRED +} diff --git a/src/shared/agent-hook-listener/providers/pi-family-events.ts b/src/shared/agent-hook-listener/providers/pi-family-events.ts new file mode 100644 index 00000000000..5f0aeaf42c5 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/pi-family-events.ts @@ -0,0 +1,67 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { readString } from '../tool-input-preview' + +export function normalizePiCompatibleEvent( + state: HookListenerState, + agentType: 'pi' | 'omp' | 'prime-agent', + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (agentType !== 'omp' && eventName === 'session_start') { + // Why: Pi's session_start fires on TUI open/resume; discard stale turn details, no working row before user activity. + clearPaneTurnCacheState(state, paneKey) + return null + } + + // Why: gate on the event's own tool_name so a stale cached question can't re-enter blocked. + const toolName = readString(hookPayload, 'tool_name') + const isPiCompatibleAsk = + ((agentType === 'pi' && isAskUserQuestionTool(toolName)) || + (agentType === 'omp' && toolName === 'ask')) && + (eventName === 'tool_call' || eventName === 'tool_execution_start') + + const stateName = isPiCompatibleAsk + ? 'blocked' + : eventName === 'before_agent_start' || + eventName === 'agent_start' || + eventName === 'tool_call' || + eventName === 'tool_execution_start' || + eventName === 'tool_execution_end' || + eventName === 'message_end' + ? 'working' + : eventName === 'agent_end' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields(agentType, eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent(agentType, eventName) } + ) + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent(agentType, eventName) + }), + agentType, + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage + }) +} diff --git a/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts b/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts new file mode 100644 index 00000000000..21648548036 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts @@ -0,0 +1,35 @@ +import type { ToolSnapshot } from '../listener-event' +import { deriveToolInputPreview, hasOwnField, readString, toolUpdate } from '../tool-input-preview' +import { deriveInteractivePrompt } from '../interactive-tool' + +export function extractPiToolFields( + eventName: unknown, + hookPayload: Record, + agentKind: 'pi' | 'omp' | 'prime-agent' +): ToolSnapshot { + if ( + eventName === 'tool_call' || + eventName === 'tool_execution_start' || + eventName === 'tool_execution_end' + ) { + const toolName = readString(hookPayload, 'tool_name') + const rawToolInput = hookPayload.tool_input + const toolInput = deriveToolInputPreview(toolName, rawToolInput) + // Why: OMP shares this extractor; only derive interactivePrompt for Pi so OMP ask_user_question metadata stays unchanged. + const interactivePrompt = + agentKind === 'pi' && (eventName === 'tool_call' || eventName === 'tool_execution_start') + ? deriveInteractivePrompt(toolName, rawToolInput, eventName) + : undefined + return toolUpdate( + { toolName, toolInput, interactivePrompt }, + { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } + ) + } + if (eventName === 'message_end' && hookPayload.role === 'assistant') { + const text = readString(hookPayload, 'text') + if (text) { + return { lastAssistantMessage: text } + } + } + return {} +} diff --git a/src/shared/agent-hook-listener/request-body.ts b/src/shared/agent-hook-listener/request-body.ts new file mode 100644 index 00000000000..b453c7c1f7e --- /dev/null +++ b/src/shared/agent-hook-listener/request-body.ts @@ -0,0 +1,125 @@ +import type { IncomingMessage } from 'node:http' + +import { classifyTruncatedHookRequest } from '../agent-hook-transport-interference' +import { assertJsonTextStructureWithinLimits } from '../json-text-structure-limit' + +/** Maximum request body size accepted by the listener (1 MB). */ +export const HOOK_REQUEST_MAX_BYTES = 1_000_000 +const HOOK_REQUEST_INITIAL_BUFFER_BYTES = 4 * 1024 +const AGENT_HOOK_JSON_STRUCTURE_LIMITS = { + structuralTokens: 128 * 1024, + nestingDepth: 64 +} as const + +export function parseAgentHookJson(content: string): unknown { + // Why: Cursor on Windows writes UTF-8-with-BOM to the hook's stdin and `JSON.parse` rejects U+FEFF, + // so the whole event was dropped. Strip exactly one leading BOM — not a trim — to keep every other + // malformed payload rejected as before. + const normalizedContent = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content + assertJsonTextStructureWithinLimits(normalizedContent, AGENT_HOOK_JSON_STRUCTURE_LIMITS) + return JSON.parse(normalizedContent) as unknown +} +// ─── Body parsing ─────────────────────────────────────────────────── + +export function parseFormEncodedBody(body: string): Record { + const params = new URLSearchParams(body) + const parsed: Record = {} + for (const [key, value] of params.entries()) { + parsed[key] = value + } + return parsed +} + +export function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let retained = Buffer.alloc(0) + let byteLength = 0 + let settled = false + const cleanup = (): void => { + req.off('data', onData) + req.off('end', onEnd) + req.off('error', onError) + req.off('close', onClose) + // Why: keep a neutral error sink so a late IncomingMessage error after cleanup can't become unhandled. + req.on('error', ignoreSettledRequestError) + } + const settleResolve = (value: unknown): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve(value) + } + const settleReject = (error: unknown): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + const onData = (chunk: Buffer): void => { + // Why: bound by bytes (not UTF-16 units) and stop accumulating after rejection so a client can't push memory past the cap. + const nextByteLength = byteLength + chunk.length + if (nextByteLength > HOOK_REQUEST_MAX_BYTES) { + settleReject(new Error('payload too large')) + req.destroy() + return + } + if (retained.length < nextByteLength) { + const nextCapacity = Math.min( + HOOK_REQUEST_MAX_BYTES, + Math.max(HOOK_REQUEST_INITIAL_BUFFER_BYTES, retained.length * 2, nextByteLength) + ) + const next = Buffer.allocUnsafe(nextCapacity) + retained.copy(next, 0, 0, byteLength) + retained = next + } + chunk.copy(retained, byteLength) + byteLength = nextByteLength + } + const onEnd = (): void => { + try { + const body = retained.toString('utf8', 0, byteLength) + const contentType = req.headers['content-type'] ?? '' + if (typeof contentType === 'string' && contentType.includes('application/json')) { + settleResolve(body ? parseAgentHookJson(body) : {}) + return + } + if ( + typeof contentType === 'string' && + contentType.includes('application/x-www-form-urlencoded') + ) { + settleResolve(parseFormEncodedBody(body)) + return + } + // Why: managed scripts POST JSON, updated POSIX scripts form-encoded; default to JSON for unknown content types. + settleResolve(body ? parseAgentHookJson(body) : {}) + } catch (error) { + settleReject(error) + } + } + // Why (#11217): a body cut short of its own Content-Length is the fingerprint of an IDS + // resetting the connection mid-inspection. Classify on every path that ends the request without + // 'end' — a peer RST surfaces as 'error' (ECONNRESET) and only a local destroy reaches 'close' first. + const settleUnfinished = (fallback: Error): void => { + settleReject( + classifyTruncatedHookRequest(req.headers['content-length'], byteLength) ?? fallback + ) + } + const onError = (err: Error): void => { + settleUnfinished(err) + } + // Why: req.destroy() (slowloris timer) emits 'close' but not 'end'/'error'; without this the promise never settles and buffers leak. + const onClose = (): void => { + settleUnfinished(new Error('aborted')) + } + req.on('data', onData) + req.on('end', onEnd) + req.on('error', onError) + req.on('close', onClose) + }) +} + +export function ignoreSettledRequestError(): void {} diff --git a/src/shared/agent-hook-listener/source-routing.ts b/src/shared/agent-hook-listener/source-routing.ts new file mode 100644 index 00000000000..470c1bf2410 --- /dev/null +++ b/src/shared/agent-hook-listener/source-routing.ts @@ -0,0 +1,28 @@ +import type { AgentHookSource } from '../agent-hook-relay' + +// ─── URL routing ──────────────────────────────────────────────────── + +export const HOOK_SOURCE_BY_PATHNAME: Readonly> = Object.freeze({ + '/hook/claude': 'claude', + '/hook/codex': 'codex', + '/hook/gemini': 'gemini', + '/hook/antigravity': 'antigravity', + '/hook/amp': 'amp', + '/hook/opencode': 'opencode', + '/hook/mimo-code': 'mimo-code', + '/hook/cursor': 'cursor', + '/hook/pi': 'pi', + '/hook/omp': 'omp', + '/hook/prime-agent': 'prime-agent', + '/hook/droid': 'droid', + '/hook/command-code': 'command-code', + '/hook/grok': 'grok', + '/hook/copilot': 'copilot', + '/hook/hermes': 'hermes', + '/hook/devin': 'devin', + '/hook/kimi': 'kimi' +}) + +export function resolveHookSource(pathname: string): AgentHookSource | null { + return HOOK_SOURCE_BY_PATHNAME[pathname] ?? null +} diff --git a/src/shared/agent-hook-listener/tool-input-preview.ts b/src/shared/agent-hook-listener/tool-input-preview.ts new file mode 100644 index 00000000000..7499763b63d --- /dev/null +++ b/src/shared/agent-hook-listener/tool-input-preview.ts @@ -0,0 +1,169 @@ +import type { ToolSnapshot } from './listener-event' + +const TOOL_INPUT_KEYS_BY_TOOL: Record = { + Read: ['file_path', 'filePath', 'path'], + Write: ['file_path', 'filePath', 'path'], + Create: ['file_path', 'filePath', 'path'], + Edit: ['file_path', 'filePath', 'path'], + Execute: ['command'], + MultiEdit: ['file_path', 'filePath', 'path'], + NotebookEdit: ['file_path', 'filePath', 'path'], + Bash: ['command'], + Glob: ['pattern'], + Grep: ['pattern'], + WebFetch: ['url'], + WebSearch: ['query'], + FetchUrl: ['url'], + read_file: ['file_path', 'path'], + write_file: ['file_path', 'path'], + read_many_files: ['file_path', 'paths', 'path'], + edit_file: ['file_path', 'path'], + replace: ['file_path', 'path'], + run_shell_command: ['command'], + run_command: ['CommandLine', 'command', 'cmd'], + glob: ['pattern'], + search_file_content: ['pattern'], + web_fetch: ['url'], + google_web_search: ['query'], + exec_command: ['cmd', 'command'], + shell_command: ['cmd', 'command'], + run_terminal_cmd: ['command'], + // Why: Grok maps Bash/Edit/Write to snake_case tool names; without these keys the status row shows blank toolInput for most Grok turns. + run_terminal_command: ['command'], + search_replace: ['file_path', 'path', 'filePath'], + write_to_file: ['TargetFile', 'path', 'file_path'], + execute_code: ['code', 'command', 'cmd'], + apply_patch: ['path', 'file_path'], + view_image: ['path', 'file_path'], + AskUser: ['question', 'prompt', 'message'], + ask_user: ['question', 'prompt', 'message'], + AskUserQuestion: ['questions', 'question', 'prompt', 'message'], + ask_user_question: ['questions', 'question', 'prompt', 'message'], + bash: ['command'], + powershell: ['command'], + create: ['path', 'file_path'], + read: ['path', 'file_path'], + write: ['path', 'file_path'], + edit: ['path', 'file_path'], + view: ['path', 'file_path'], + grep: ['pattern'], + web_search: ['query'], + fetch_content: ['url'], + terminal: ['command'], + patch: ['path', 'file_path'], + search_files: ['query', 'pattern', 'path'], + browser_navigate: ['url'], + browser_click: ['target', 'selector', 'text'], + browser_type: ['text', 'target', 'selector'], + session_search: ['query'], + skill_manage: ['action', 'name', 'file_path'], + delegate_task: ['task', 'prompt', 'description'], + view_file: ['AbsolutePath', 'path', 'file_path'], + replace_file_content: ['TargetFile', 'path', 'file_path'], + multi_replace_file_content: ['TargetFile', 'path', 'file_path'], + list_dir: ['DirectoryPath', 'path'], + find_by_name: ['SearchDirectory', 'Pattern', 'query'], + grep_search: ['SearchPath', 'Query', 'query', 'pattern'], + search_web: ['query'], + read_url_content: ['Url', 'url'], + manage_task: ['TaskId', 'Action'], + schedule: ['Prompt', 'DurationSeconds', 'CronExpression'], + ask_question: ['question', 'questions'], + ask_permission: ['Action', 'Target', 'Reason'], + spawn_subagent: ['prompt', 'description', 'subagent_type'], + open_page: ['url'] +} + +const FALLBACK_TOOL_INPUT_KEYS = [ + 'command', + 'cmd', + 'code', + 'query', + 'pattern', + 'url', + 'path', + 'file_path', + 'filePath', + 'target', + 'selector', + 'text', + 'action', + 'name', + 'description', + 'CommandLine', + 'AbsolutePath', + 'TargetFile', + 'DirectoryPath', + 'SearchPath', + 'Query', + 'Url', + 'Prompt' +] as const + +export function deriveToolInputPreview( + toolName: string | undefined, + toolInput: unknown +): string | undefined { + if (typeof toolInput === 'string') { + return toolInput + } + if (typeof toolInput !== 'object' || toolInput === null) { + return undefined + } + if (!toolName) { + return undefined + } + const keys = TOOL_INPUT_KEYS_BY_TOOL[toolName] + if (!keys) { + return undefined + } + const record = toolInput as Record + for (const key of keys) { + const value = record[key] + if (typeof value === 'string' && value.trim().length > 0) { + return value + } + } + return undefined +} + +export function deriveFallbackToolInputPreview(toolInput: unknown): string | undefined { + if (typeof toolInput === 'string') { + return toolInput + } + if (typeof toolInput !== 'object' || toolInput === null) { + return undefined + } + const record = toolInput as Record + for (const key of FALLBACK_TOOL_INPUT_KEYS) { + const value = record[key] + if (typeof value === 'string' && value.trim().length > 0) { + return value + } + } + return undefined +} + +export function readString(record: Record, key: string): string | undefined { + const value = record[key] + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +export function hasOwnField(record: Record, key: string): boolean { + return Object.hasOwn(record, key) +} + +export function hasAnyOwnField(record: Record, keys: readonly string[]): boolean { + return keys.some((key) => hasOwnField(record, key)) +} + +export function toolUpdate( + fields: Pick, + options?: { hasToolInputField?: boolean } +): ToolSnapshot { + return { + ...fields, + hasToolUpdate: true, + hasToolInputField: options?.hasToolInputField === true + } +} diff --git a/src/shared/agent-hook-listener/transcript-entry-text.ts b/src/shared/agent-hook-listener/transcript-entry-text.ts new file mode 100644 index 00000000000..3daa36b3840 --- /dev/null +++ b/src/shared/agent-hook-listener/transcript-entry-text.ts @@ -0,0 +1,56 @@ +import { parseAgentHookJson } from './request-body' + +export function extractAssistantTextFromLine(line: string): string | undefined { + let entry: unknown + try { + entry = parseAgentHookJson(line) + } catch { + return undefined + } + if (typeof entry !== 'object' || entry === null) { + return undefined + } + const record = entry as Record + if (record.type === 'assistant.message') { + const data = record.data + if (typeof data === 'object' && data !== null) { + const text = extractAssistantContentText((data as Record).content) + if (text) { + return text + } + } + } + if ( + record.source === 'MODEL' && + record.type === 'PLANNER_RESPONSE' && + typeof record.content === 'string' && + record.content.trim().length > 0 + ) { + return record.content + } + const nestedMessage = record.message as Record | undefined + const role = + record.role ?? nestedMessage?.role ?? (record.type === 'assistant' ? 'assistant' : undefined) + if (role !== 'assistant') { + return undefined + } + const content = (nestedMessage ?? record).content + return extractAssistantContentText(content) +} + +export function extractAssistantContentText(content: unknown): string | undefined { + if (typeof content === 'string' && content.trim().length > 0) { + return content + } + if (Array.isArray(content)) { + for (const part of content) { + if (typeof part === 'object' && part !== null) { + const text = (part as Record).text + if (typeof text === 'string' && text.trim().length > 0) { + return text + } + } + } + } + return undefined +} diff --git a/src/shared/agent-hook-listener/transcript-lines.ts b/src/shared/agent-hook-listener/transcript-lines.ts new file mode 100644 index 00000000000..0ea5a06f831 --- /dev/null +++ b/src/shared/agent-hook-listener/transcript-lines.ts @@ -0,0 +1,51 @@ +import { parseAgentHookJson } from './request-body' +import { + readLastAssistantFromTranscriptOnce, + readLastTextFromTranscriptOnce +} from './transcript-reader' + +export function extractAntigravityUserRequest(content: string): string | undefined { + const opener = '' + const startIndex = content.indexOf(opener) + const bodyStartIndex = startIndex === -1 ? -1 : startIndex + opener.length + const endIndex = bodyStartIndex === -1 ? -1 : content.indexOf('', bodyStartIndex) + const text = + bodyStartIndex === -1 || endIndex === -1 ? content : content.slice(bodyStartIndex, endIndex) + const trimmed = text.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +export function extractUserPromptTextFromLine(line: string): string | undefined { + let entry: unknown + try { + entry = parseAgentHookJson(line) + } catch { + return undefined + } + if (typeof entry !== 'object' || entry === null) { + return undefined + } + const record = entry as Record + if ( + (record.source === 'USER_EXPLICIT' || record.source === 'USER') && + (record.type === 'USER_INPUT' || record.type === 'REQUEST') && + typeof record.content === 'string' + ) { + return extractAntigravityUserRequest(record.content) + } + return undefined +} + +export function readLastAssistantFromTranscript(transcriptPath: unknown): string | undefined { + if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { + return undefined + } + return readLastAssistantFromTranscriptOnce(transcriptPath) +} + +export function readLastUserPromptFromTranscript(transcriptPath: unknown): string | undefined { + if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { + return undefined + } + return readLastTextFromTranscriptOnce(transcriptPath, extractUserPromptTextFromLine) +} diff --git a/src/shared/agent-hook-listener/transcript-reader.ts b/src/shared/agent-hook-listener/transcript-reader.ts new file mode 100644 index 00000000000..f9b1472384e --- /dev/null +++ b/src/shared/agent-hook-listener/transcript-reader.ts @@ -0,0 +1,107 @@ +import { closeSync, openSync, readSync, statSync } from 'node:fs' + +import { extractAssistantTextFromLine } from './transcript-entry-text' + +export const TRANSCRIPT_CHUNK_BYTES = 64 * 1024 +export const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024 +export const EMPTY_TRANSCRIPT_REGION = Buffer.alloc(0) +export function readLastAssistantFromTranscriptOnce(transcriptPath: string): string | undefined { + return readLastTextFromTranscriptOnce(transcriptPath, extractAssistantTextFromLine) +} + +export function readLastTextFromTranscriptOnce( + transcriptPath: string, + extractLineText: (line: string) => string | undefined +): string | undefined { + try { + const stats = statSync(transcriptPath) + const size = stats.size + if (size <= 0) { + return undefined + } + const fd = openSync(transcriptPath, 'r') + try { + // Why a chunk list: carry holds a partial line, and re-joining it per block + // made one oversized line (a big tool result or pasted prompt) cost O(line^2). + let carryChunks: Buffer[] = [] + let bytesRead = 0 + let scanEnd = size + while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { + const chunkSize = Math.min(scanEnd, TRANSCRIPT_CHUNK_BYTES) + const position = scanEnd - chunkSize + const buffer = Buffer.alloc(chunkSize) + let filled = 0 + while (filled < chunkSize) { + const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled) + if (n === 0) { + break + } + filled += n + } + // Why bail on a short read: the file shrank under us, so the bytes above + // this block no longer line up with what the earlier ones assumed. + if (filled < chunkSize) { + break + } + bytesRead += filled + scanEnd = position + // Why search only the new block: carry is always the run before a newline, + // so it holds none of its own. + const firstNewline = buffer.indexOf(0x0a) + const atStart = position === 0 + let completeRegion: Buffer + if (atStart) { + completeRegion = + carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) + carryChunks = [] + } else if (firstNewline === -1) { + completeRegion = EMPTY_TRANSCRIPT_REGION + carryChunks.unshift(buffer) + } else { + const afterNewline = buffer.subarray(firstNewline + 1) + completeRegion = + carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks]) + carryChunks = [buffer.subarray(0, firstNewline)] + } + if (completeRegion.length > 0) { + const extracted = findLastExtractedTranscriptLineText( + completeRegion.toString('utf8'), + extractLineText + ) + if (extracted !== undefined) { + return extracted + } + } + } + return undefined + } finally { + closeSync(fd) + } + } catch { + return undefined + } +} + +export function findLastExtractedTranscriptLineText( + text: string, + extractLineText: (line: string) => string | undefined +): string | undefined { + let lineEnd = text.length + + for (let index = text.length - 1; index >= -1; index--) { + if (index >= 0 && text.charCodeAt(index) !== 10) { + continue + } + + const line = text.slice(index + 1, lineEnd).trim() + if (line.length > 0) { + const extracted = extractLineText(line) + if (extracted !== undefined) { + return extracted + } + } + lineEnd = index + } + + return undefined +} diff --git a/src/shared/agent-hook-request-body-memory.test.ts b/src/shared/agent-hook-request-body-memory.test.ts index 0fae9ff87c4..ce0c8029dd0 100644 --- a/src/shared/agent-hook-request-body-memory.test.ts +++ b/src/shared/agent-hook-request-body-memory.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import type { IncomingHttpHeaders, IncomingMessage } from 'node:http' import { describe, expect, it, vi } from 'vitest' -import { readRequestBody } from './agent-hook-listener' +import { readRequestBody } from './agent-hook-listener/request-body' type FakeIncomingMessage = EventEmitter & { headers: IncomingHttpHeaders diff --git a/src/shared/agent-hook-status-cache.test.ts b/src/shared/agent-hook-status-cache.test.ts index fc7238cfbdc..ea9ff66b436 100644 --- a/src/shared/agent-hook-status-cache.test.ts +++ b/src/shared/agent-hook-status-cache.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { createHookListenerState, type AgentHookEventPayload } from './agent-hook-listener' +import { createHookListenerState } from './agent-hook-listener/listener-state' +import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' import { upsertBoundedAgentHookStatus } from './agent-hook-status-cache' import { AGENT_STATUS_STALE_AFTER_MS } from './agent-status-types' diff --git a/src/shared/agent-hook-status-cache.ts b/src/shared/agent-hook-status-cache.ts index 283e6d5589d..d46f574db37 100644 --- a/src/shared/agent-hook-status-cache.ts +++ b/src/shared/agent-hook-status-cache.ts @@ -1,8 +1,5 @@ -import { - clearPaneCacheState, - type AgentHookEventPayload, - type HookListenerState -} from './agent-hook-listener' +import { clearPaneCacheState, type HookListenerState } from './agent-hook-listener/listener-state' +import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' import { AGENT_STATUS_STALE_AFTER_MS } from './agent-status-types' export const MAX_AGENT_HOOK_STATUS_CACHE_PANES = 500 diff --git a/src/shared/claude-background-task-status.test.ts b/src/shared/claude-background-task-status.test.ts index 6722dded5d3..8f59ebdd7f5 100644 --- a/src/shared/claude-background-task-status.test.ts +++ b/src/shared/claude-background-task-status.test.ts @@ -3,12 +3,14 @@ import { clearAllListenerCaches, clearPaneCacheState, createHookListenerState, - markClaudeLeadTurnInterrupted, movePaneCacheState, - normalizeHookPayload, - seedClaudeSubagentRosterFromSnapshots, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { + markClaudeLeadTurnInterrupted, + seedClaudeSubagentRosterFromSnapshots +} from './agent-hook-listener/providers/claude-roster-state' +import { normalizeHookPayload } from './agent-hook-listener' import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' import { readClaudeBackgroundAgentTasks } from './claude-background-task-inventory' import { makePaneKey } from './stable-pane-id' diff --git a/src/shared/claude-subagent-row-lifecycle.test.ts b/src/shared/claude-subagent-row-lifecycle.test.ts index db3b71634e5..ff793c78384 100644 --- a/src/shared/claude-subagent-row-lifecycle.test.ts +++ b/src/shared/claude-subagent-row-lifecycle.test.ts @@ -30,9 +30,9 @@ import { beforeEach, describe, expect, it } from 'vitest' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { makePaneKey } from './stable-pane-id' const LEAF_ID = '11111111-1111-4111-8111-111111111111' diff --git a/src/shared/codex-subagent-rollout-lifecycle.test.ts b/src/shared/codex-subagent-rollout-lifecycle.test.ts index 9d476b85504..0f24197e5d9 100644 --- a/src/shared/codex-subagent-rollout-lifecycle.test.ts +++ b/src/shared/codex-subagent-rollout-lifecycle.test.ts @@ -3,7 +3,8 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { createHookListenerState, normalizeHookPayload } from './agent-hook-listener' +import { createHookListenerState } from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { makePaneKey } from './stable-pane-id' const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') diff --git a/src/shared/opencode-permission-status.test.ts b/src/shared/opencode-permission-status.test.ts index 6dfb86a7c4d..31316e30391 100644 --- a/src/shared/opencode-permission-status.test.ts +++ b/src/shared/opencode-permission-status.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { createHookListenerState, normalizeHookPayload } from './agent-hook-listener' +import { createHookListenerState } from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { makePaneKey } from './stable-pane-id' const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') From 9bcaf0986948b74df659ec6c66c4ea48a56905ad Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:45:53 -0700 Subject: [PATCH 34/65] refactor loading store into cohesive domains (#16192) --- config/max-lines-baseline.txt | 1 - ...rsistence-loading-store-extraction.test.ts | 199 + ...sistence-loading-store-write-risks.test.ts | 139 + src/main/persistence-test-harness.ts | 14 +- ...tence-workspace-session-scrollback.test.ts | 105 +- .../feature-interaction-recording.ts | 3 +- .../applying-settings/settings-update.ts | 4 +- .../applying-settings/ui-state-update.ts | 3 +- .../ssh-pty-binding-cleanup.ts | 4 +- .../ssh-pty-consumer-recovery.ts | 4 +- .../ssh-pty-lease-operations.ts | 3 +- .../ssh-target-reassignment.ts | 6 +- .../leasing-ssh-ptys/ssh-target-state.ts | 3 +- .../loading-store/automation-persistence.ts | 158 + .../loading-store/backup-recovery-rotation.ts | 147 + .../loading-store/loaded-cohort-migrations.ts | 66 + .../loading-store/loaded-state-adaptation.ts | 116 + .../loading-store/loaded-state-parsing.ts | 267 ++ .../metadata-lineage-operations.ts | 254 ++ .../mobile-tab-selection-persistence.ts | 42 + .../normalize-loaded-global-settings.ts | 138 + .../normalize-loaded-profile-state.ts | 91 + .../normalize-loaded-state-collections.ts | 66 + .../normalize-loaded-ui-state.ts | 206 + .../prepare-loaded-profile-settings.ts | 255 ++ .../prepare-loaded-terminal-settings.ts | 172 + .../loading-store/primary-state-writes.ts | 282 ++ .../loading-store/profile-preferences.ts | 194 + .../project-collection-operations.ts | 233 + .../loading-store/pty-binding-persistence.ts | 240 + .../repo-lifecycle-operations.ts | 264 ++ .../retired-name-registry-normalization.ts | 46 + .../retired-worktree-name-persistence.ts | 119 + .../loading-store/session-host-partitions.ts | 190 + .../session-snapshot-operations.ts | 102 + .../sparse-preset-persistence.ts | 55 + .../ssh-lease-recovery-operations.ts | 182 + .../loading-store/ssh-profile-operations.ts | 142 + .../state-serialization-secret-handling.ts | 125 + .../loading-store/store-domain-composition.ts | 187 + .../loading-store/store-owned-state.ts | 8 - .../loading-store/store-runtime-state.ts | 79 + src/main/persistence/loading-store/store.ts | 3949 +---------------- .../terminal-binding-recovery.ts | 104 + .../loading-store/terminal-session-cleanup.ts | 36 + .../workspace-session-partitions.ts | 56 + .../workspace-session-snapshot-publication.ts | 242 + .../loading-store/write-flush-barriers.ts | 276 ++ .../loading-store/write-scheduling.ts | 71 + .../folder-workspace-operations.ts | 3 +- .../automation-definition-operations.ts | 3 +- .../automation-run-operations.ts | 3 +- .../automation-schedule-operations.ts | 4 +- .../project-group-operations.ts | 3 +- .../tracking-repos/project-host-operations.ts | 3 +- .../project-host-setup-update.ts | 3 +- .../tracking-repos/repo-order-operations.ts | 3 +- .../tracking-repos/repo-update-operations.ts | 3 +- .../tracking-repos/repo-worktree-pruning.ts | 4 +- .../worktree-identity-migration.ts | 4 +- .../worktree-metadata-normalization.test.ts | 11 +- .../worktree-metadata-normalization.ts | 6 +- 62 files changed, 5759 insertions(+), 3942 deletions(-) create mode 100644 src/main/persistence-loading-store-extraction.test.ts create mode 100644 src/main/persistence-loading-store-write-risks.test.ts create mode 100644 src/main/persistence/loading-store/automation-persistence.ts create mode 100644 src/main/persistence/loading-store/backup-recovery-rotation.ts create mode 100644 src/main/persistence/loading-store/loaded-cohort-migrations.ts create mode 100644 src/main/persistence/loading-store/loaded-state-adaptation.ts create mode 100644 src/main/persistence/loading-store/loaded-state-parsing.ts create mode 100644 src/main/persistence/loading-store/metadata-lineage-operations.ts create mode 100644 src/main/persistence/loading-store/mobile-tab-selection-persistence.ts create mode 100644 src/main/persistence/loading-store/normalize-loaded-global-settings.ts create mode 100644 src/main/persistence/loading-store/normalize-loaded-profile-state.ts create mode 100644 src/main/persistence/loading-store/normalize-loaded-state-collections.ts create mode 100644 src/main/persistence/loading-store/normalize-loaded-ui-state.ts create mode 100644 src/main/persistence/loading-store/prepare-loaded-profile-settings.ts create mode 100644 src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts create mode 100644 src/main/persistence/loading-store/primary-state-writes.ts create mode 100644 src/main/persistence/loading-store/profile-preferences.ts create mode 100644 src/main/persistence/loading-store/project-collection-operations.ts create mode 100644 src/main/persistence/loading-store/pty-binding-persistence.ts create mode 100644 src/main/persistence/loading-store/repo-lifecycle-operations.ts create mode 100644 src/main/persistence/loading-store/retired-name-registry-normalization.ts create mode 100644 src/main/persistence/loading-store/retired-worktree-name-persistence.ts create mode 100644 src/main/persistence/loading-store/session-host-partitions.ts create mode 100644 src/main/persistence/loading-store/session-snapshot-operations.ts create mode 100644 src/main/persistence/loading-store/sparse-preset-persistence.ts create mode 100644 src/main/persistence/loading-store/ssh-lease-recovery-operations.ts create mode 100644 src/main/persistence/loading-store/ssh-profile-operations.ts create mode 100644 src/main/persistence/loading-store/state-serialization-secret-handling.ts create mode 100644 src/main/persistence/loading-store/store-domain-composition.ts delete mode 100644 src/main/persistence/loading-store/store-owned-state.ts create mode 100644 src/main/persistence/loading-store/store-runtime-state.ts create mode 100644 src/main/persistence/loading-store/terminal-binding-recovery.ts create mode 100644 src/main/persistence/loading-store/terminal-session-cleanup.ts create mode 100644 src/main/persistence/loading-store/workspace-session-partitions.ts create mode 100644 src/main/persistence/loading-store/workspace-session-snapshot-publication.ts create mode 100644 src/main/persistence/loading-store/write-flush-barriers.ts create mode 100644 src/main/persistence/loading-store/write-scheduling.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 2c9a7b1b5af..9e24dc1b39f 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -43,7 +43,6 @@ inline src/main/keybindings/keybinding-file.ts inline src/main/linear/issues.ts inline src/main/memory/collector.ts inline src/main/opencode/hook-service.ts -inline src/main/persistence/loading-store/store.ts inline src/main/ports/advertised-url-watcher.ts inline src/main/ports/local-workspace-port-scanner.ts inline src/main/project-groups/nested-repo-discovery.ts diff --git a/src/main/persistence-loading-store-extraction.test.ts b/src/main/persistence-loading-store-extraction.test.ts new file mode 100644 index 00000000000..dafb6deadee --- /dev/null +++ b/src/main/persistence-loading-store-extraction.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getDefaultWorkspaceSession } from '../shared/constants' +import type { PersistedState } from '../shared/persisted-state-types' +import type { Store as PersistenceStore } from './persistence/loading-store/store' +import { createStore, dataFile, makeRepo, testState } from './persistence-test-harness' + +const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ + trackMock: vi.fn(), + getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => { + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('encrypted:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('encrypted:'.length) + } + } +})) + +vi.mock('./telemetry/client', () => ({ track: trackMock })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => ({ hosts: [] })), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +describe('loading Store extraction seams', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-loading-store-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('accepts the first JSON-parseable backup even when an older backup has richer state', async () => { + mkdirSync(testState.dir, { recursive: true }) + writeFileSync(dataFile(), '{{corrupt-primary', 'utf-8') + writeFileSync(`${dataFile()}.bak.0`, '{}', 'utf-8') + writeFileSync( + `${dataFile()}.bak.1`, + JSON.stringify({ repos: [makeRepo({ id: 'older-complete-profile' })] }), + 'utf-8' + ) + + const store = await createStore() + + expect(store.getRepos()).toEqual([]) + expect(readFileSync(dataFile(), 'utf-8')).toBe('{}') + }) + + it('leaves backup bytes reusable when publishing recovery to the primary path fails', async () => { + mkdirSync(dataFile(), { recursive: true }) + writeFileSync( + `${dataFile()}.bak.0`, + JSON.stringify({ repos: [makeRepo({ id: 'recovery-survives-publish-failure' })] }), + 'utf-8' + ) + + const failedRecovery = await createStore() + expect(failedRecovery.getRepos()).toEqual([]) + failedRecovery.freezeWrites() + expect(existsSync(`${dataFile()}.bak.0`)).toBe(true) + + rmSync(dataFile(), { recursive: true, force: true }) + const recovered = await createStore() + expect(recovered.getRepos().map((repo) => repo.id)).toEqual([ + 'recovery-survives-publish-failure' + ]) + }) + + it('aliases blank host reads, writes, and patches to the local disk partition', async () => { + const store = await createStore() + store.setWorkspaceSession( + { ...getDefaultWorkspaceSession(), activeRepoId: 'from-blank-set' }, + ' ' + ) + store.patchWorkspaceSession({ activeRepoId: 'from-blank-patch' }, '') + store.flushOrThrow() + + expect(store.getWorkspaceSession(' ').activeRepoId).toBe('from-blank-patch') + expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) + + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + expect(persisted.workspaceSession?.activeRepoId).toBe('from-blank-patch') + expect(persisted.workspaceSessionsByHostId).toEqual({}) + }) + + it('keeps the last constructed Store as the global pane-migration listener owner', async () => { + const first = await createStore() + const { Store } = await import('./persistence/loading-store/store') + const { setMigrationUnsupportedPty } = + await import('./agent-hooks/migration-unsupported-pty-state') + const secondDataFile = join(testState.dir, 'second-profile', 'orca-data.json') + const second = new Store({ dataFile: secondDataFile }) + + setMigrationUnsupportedPty({ + ptyId: 'listener-owner-pty', + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 123 + }) + first.flushOrThrow() + second.flushOrThrow() + + const firstState = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + const secondState = JSON.parse(readFileSync(secondDataFile, 'utf-8')) as PersistedState + expect( + firstState.migrationUnsupportedPtyEntries?.some( + (entry) => entry.ptyId === 'listener-owner-pty' + ) + ).toBe(false) + expect( + secondState.migrationUnsupportedPtyEntries?.some( + (entry) => entry.ptyId === 'listener-owner-pty' + ) + ).toBe(true) + }) + + it('latches final flush bytes and ignores later in-memory mutations', async () => { + const store = await createStore() + store.updateUI({ sidebarWidth: 731 }) + const finalFlush = store.flushAsync() + await finalFlush + + store.updateUI({ sidebarWidth: 732 }) + expect(store.flushAsync()).toBe(finalFlush) + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + expect(persisted.ui.sidebarWidth).toBe(731) + expect(store.getUI().sidebarWidth).toBe(732) + }) + + it('includes a same-tick mutation made after final flush is invoked', async () => { + const store = await createStore() + store.updateUI({ sidebarWidth: 741 }) + + const finalFlush = store.flushAsync() + store.updateUI({ sidebarWidth: 742 }) + await finalFlush + + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + expect(persisted.ui.sidebarWidth).toBe(742) + }) + + it('keeps delegated operations on the Store prototype with native receiver and override semantics', async () => { + const store = await createStore() + const { Store } = await import('./persistence/loading-store/store') + const descriptor = Object.getOwnPropertyDescriptor(Store.prototype, 'getRepoCount') + + expect(descriptor).toMatchObject({ + configurable: true, + enumerable: false, + writable: true + }) + expect(Object.hasOwn(store, 'getRepoCount')).toBe(false) + const detached = store.getRepoCount + expect(() => detached()).toThrow(TypeError) + expect(detached.call(store)).toBe(0) + expect(new Proxy(store, {}).getRepoCount()).toBe(0) + expect([ + Store.prototype.getRepo.length, + Store.prototype.updateProject.length, + Store.prototype.createAutomationRun.length + ]).toEqual([1, 2, 2]) + + const prototypeSpy = vi.spyOn(Store.prototype, 'getRepoCount') + try { + expect(store.getRepoCount()).toBe(0) + expect(prototypeSpy).toHaveBeenCalledOnce() + } finally { + prototypeSpy.mockRestore() + } + + class StoreWithRepoCountOverride extends Store { + override getRepoCount(): number { + return 47 + } + } + const overridden = new StoreWithRepoCountOverride({ + dataFile: join(testState.dir, 'override-profile', 'orca-data.json') + }) + expect(overridden.getRepoCount()).toBe(47) + expectTypeOf().not.toHaveProperty('scheduleSave') + expectTypeOf().not.toHaveProperty('enqueueWrite') + expectTypeOf().not.toHaveProperty('getProjectHostOperations') + expectTypeOf().not.toHaveProperty('getAutomationDefinitionOperations') + expectTypeOf().not.toHaveProperty('getSshTargetStateOperations') + }) +}) diff --git a/src/main/persistence-loading-store-write-risks.test.ts b/src/main/persistence-loading-store-write-risks.test.ts new file mode 100644 index 00000000000..422573516f1 --- /dev/null +++ b/src/main/persistence-loading-store-write-risks.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { Store } from './persistence/loading-store/store' +import { initDataPath } from './persistence/loading-store/user-data-path' + +const testState = { dir: '' } + +const writeControl = vi.hoisted(() => { + let releaseRename: (() => void) | null = null + let markRenameStarted: (() => void) | null = null + return { + blockPrimaryRename: false, + failPrimaryOpen: false, + renameStarted: Promise.resolve(), + renameRelease: Promise.resolve(), + reset(): void { + this.blockPrimaryRename = false + this.failPrimaryOpen = false + this.renameStarted = new Promise((resolve) => { + markRenameStarted = resolve + }) + this.renameRelease = new Promise((resolve) => { + releaseRename = resolve + }) + }, + markRenameStarted(): void { + markRenameStarted?.() + }, + releaseRename(): void { + releaseRename?.() + } + } +}) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + open: async (...args: Parameters) => { + const target = String(args[0]) + if ( + writeControl.failPrimaryOpen && + target.includes('orca-data.json.') && + target.endsWith('.tmp') + ) { + throw Object.assign(new Error('profile mount rejected write'), { code: 'EIO' }) + } + return actual.open(...args) + }, + rename: async (...args: Parameters) => { + const target = String(args[1]) + if (writeControl.blockPrimaryRename && target.endsWith('orca-data.json')) { + writeControl.markRenameStarted() + await writeControl.renameRelease + } + return actual.rename(...args) + } + } +}) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +function createStore(): Store { + installFakeAppEnvironment({ getPath: () => testState.dir }) + initDataPath() + return new Store({ dataFile: join(testState.dir, 'orca-data.json') }) +} + +describe('loading Store write-risk characterization', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-write-risk-')) + writeControl.reset() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('allows an already-started primary rename to publish after writes are frozen', async () => { + const store = await createStore() + writeControl.blockPrimaryRename = true + store.updateUI({ sidebarWidth: 712 }) + vi.advanceTimersByTime(1_000) + await writeControl.renameStarted + + store.freezeWrites() + writeControl.releaseRename() + await store.waitForPendingWrite() + + const persisted = JSON.parse(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')) as { + ui: { sidebarWidth: number } + } + expect(persisted.ui.sidebarWidth).toBe(712) + }) + + it('keeps a rejected durable mutation in memory for a later unrelated flush', async () => { + const store = await createStore() + writeControl.failPrimaryOpen = true + await expect( + store.upsertSshPtyConsumerRecovery({ + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 3, + ownerGeneration: 5, + ownerLease: 'secret-owner-lease' + }) + ).rejects.toThrow('profile mount rejected write') + + writeControl.failPrimaryOpen = false + store.updateUI({ sidebarWidth: 713 }) + await store.flushPendingOrThrowAsync() + const persisted = JSON.parse(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')) as { + sshPtyConsumerRecoveries: { clientInstanceId: string }[] + } + expect(persisted.sshPtyConsumerRecoveries[0]?.clientInstanceId).toBe('client-1') + }) +}) diff --git a/src/main/persistence-test-harness.ts b/src/main/persistence-test-harness.ts index 89f5f03a88f..7b628b4b678 100644 --- a/src/main/persistence-test-harness.ts +++ b/src/main/persistence-test-harness.ts @@ -1,26 +1,22 @@ import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { vi } from 'vitest' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' import type { Project, ProjectHostSetup } from '../shared/project-types' import type { Repo } from '../shared/repo-types' import type { TerminalTab } from '../shared/terminal-tab-types' import type { WorkspaceLineage, WorktreeLineage } from '../shared/worktree/lineage-types' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' +import { Store } from './persistence/loading-store/store' +import { initDataPath } from './persistence/loading-store/user-data-path' // Shared mutable state so the electron mock can reference a per-test directory export const testState = { dir: '' } -/** Reset modules and dynamically import Store so the data-file path picks up the current testState.dir */ -export async function createStore() { - vi.resetModules() - // Why here and not a per-file vi.mock('electron'): the data-file path resolves through - // AppEnvironment now, and it must point at this test's dir rather than the global - // fake's shared one. Re-installed after resetModules so the fresh graph sees it. +/** Create a profile store without rebuilding its large module graph inside each test timeout. */ +export function createStore(): Store { installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store, initDataPath } = await import('./persistence') initDataPath() - return new Store() + return new Store({ dataFile: dataFile() }) } export async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { diff --git a/src/main/persistence-workspace-session-scrollback.test.ts b/src/main/persistence-workspace-session-scrollback.test.ts index f57a2a1b603..29c664be49b 100644 --- a/src/main/persistence-workspace-session-scrollback.test.ts +++ b/src/main/persistence-workspace-session-scrollback.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { writeFileSync, rmSync, mkdtempSync, mkdirSync, existsSync } from 'node:fs' +import { writeFileSync, readFileSync, rmSync, mkdtempSync, mkdirSync, existsSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' @@ -8,6 +9,7 @@ import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser import { testState, createStore, + dataFile, writeDataFile, makeRepo, makeTerminalTab @@ -19,6 +21,7 @@ import { makeSessionWithBrowserHistory } from './persistence-session-fixtures' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { Store, initDataPath } from './persistence' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -26,6 +29,29 @@ const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => sshConfigHostsToTargetsMock: vi.fn() })) +const asyncPrimaryWriteFailure = vi.hoisted(() => ({ + error: null as Error | null, + targetPrefix: '' +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + open: async (...args: Parameters) => { + const target = args[0] + if ( + asyncPrimaryWriteFailure.error && + typeof target === 'string' && + target.startsWith(asyncPrimaryWriteFailure.targetPrefix) + ) { + throw asyncPrimaryWriteFailure.error + } + return actual.open(...args) + } + } +}) + vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: loadUserSshConfigMock, sshConfigHostsToTargets: sshConfigHostsToTargetsMock @@ -66,10 +92,14 @@ describe('Store', () => { trackMock.mockReset() getCohortAtEmitMock.mockReset() getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) + asyncPrimaryWriteFailure.error = null + asyncPrimaryWriteFailure.targetPrefix = '' }) afterEach(() => { rmSync(testState.dir, { recursive: true, force: true }) + asyncPrimaryWriteFailure.error = null + asyncPrimaryWriteFailure.targetPrefix = '' }) // ── GitHub Cache ─────────────────────────────────────────────────── @@ -188,11 +218,9 @@ describe('Store', () => { const profileDataFile = join(profileDataDirectory, 'orca-data.json') mkdirSync(profileDataDirectory, { recursive: true }) - vi.resetModules() // Why: the legacy snapshot dir hangs off userData, which resolves through // AppEnvironment — without this it points at the global fake's shared dir. installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store, initDataPath } = await import('./persistence') initDataPath() const store = new Store({ dataFile: profileDataFile }) store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })) @@ -221,11 +249,9 @@ describe('Store', () => { mkdirSync(legacySnapshotDir, { recursive: true }) writeFileSync(join(legacySnapshotDir, `${ref}.bin`), 'legacy-scrollback', 'utf-8') - vi.resetModules() // Why: the legacy snapshot dir hangs off userData, which resolves through // AppEnvironment — without this it points at the global fake's shared dir. installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store, initDataPath } = await import('./persistence') initDataPath() const store = new Store({ dataFile: profileDataFile }) @@ -305,6 +331,7 @@ describe('Store', () => { throw new Error('expected scrollback snapshot ref') } expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(true) + store.flushOrThrow() store.setWorkspaceSession({ activeRepoId: null, @@ -315,6 +342,74 @@ describe('Store', () => { }) expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(false) + const stillPublished = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + workspaceSession: { + terminalLayoutsByTabId: Record }> + } + } + expect( + stillPublished.workspaceSession.terminalLayoutsByTabId['remote-tab'] + ?.scrollbackRefsByLeafId?.[TEST_LEAF_2] + ).toBe(ref) + }) + + it('leaves durable JSON pointing at deleted scrollback when the replacement write fails', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })) + const session = makeSessionWithTerminalBuffers() + store.setWorkspaceSession({ + ...session, + tabsByWorktree: { + 'remote-repo::/remote': session.tabsByWorktree['remote-repo::/remote'] + }, + terminalLayoutsByTabId: { + 'remote-tab': session.terminalLayoutsByTabId['remote-tab'] + } + }) + const ref = + store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[ + TEST_LEAF_2 + ] + if (!ref) { + throw new Error('expected scrollback snapshot ref') + } + const snapshotPath = join(testState.dir, 'terminal-scrollback', `${ref}.bin`) + store.flushOrThrow() + const durableBeforeRemoval = readFileSync(dataFile(), 'utf-8') + expect(existsSync(snapshotPath)).toBe(true) + + const writeError = Object.assign(new Error('profile mount rejected replacement write'), { + code: 'EIO' + }) + asyncPrimaryWriteFailure.error = writeError + asyncPrimaryWriteFailure.targetPrefix = `${dataFile()}.` + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + store.setWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + }) + + expect(existsSync(snapshotPath)).toBe(false) + expect(store.getWorkspaceSession().terminalLayoutsByTabId).toEqual({}) + await expect(store.flushPendingOrThrowAsync()).rejects.toBe(writeError) + } finally { + errors.mockRestore() + } + + expect(readFileSync(dataFile(), 'utf-8')).toBe(durableBeforeRemoval) + const stillPublished = JSON.parse(durableBeforeRemoval) as { + workspaceSession: { + terminalLayoutsByTabId: Record }> + } + } + expect( + stillPublished.workspaceSession.terminalLayoutsByTabId['remote-tab'] + ?.scrollbackRefsByLeafId?.[TEST_LEAF_2] + ).toBe(ref) }) it('reads only the replay tail from oversized terminal scrollback snapshots', async () => { diff --git a/src/main/persistence/applying-settings/feature-interaction-recording.ts b/src/main/persistence/applying-settings/feature-interaction-recording.ts index 935f11382b3..2a29bc64f66 100644 --- a/src/main/persistence/applying-settings/feature-interaction-recording.ts +++ b/src/main/persistence/applying-settings/feature-interaction-recording.ts @@ -1,5 +1,4 @@ import type { PersistedState } from '../../../shared/persisted-state-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { compareFeatureInteractionUsageBuckets, getFeatureInteractionCategory, @@ -12,7 +11,7 @@ import { track } from '../../telemetry/client' import { getCohortAtEmit } from '../../telemetry/cohort-classifier' export type FeatureInteractionOperations = { - state: StoreOwnedPersistedState + state: PersistedState scheduleSave: () => void notifyUIChanged: () => void getUI: () => PersistedState['ui'] diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index e6b18da0644..ff4c55b09a8 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -16,7 +16,7 @@ import { normalizeAppIconId } from '../../../shared/app-icon' import { normalizeUiLanguage } from '../../../shared/ui-language' import { normalizeWorktreeVisibilityDefaults } from '../../../shared/external-worktree-visibility' import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { addMobilePairingCustomAddress, normalizeMobilePairingCustomAddress, @@ -39,7 +39,7 @@ import { } from './terminal-settings-migrations' export type SettingsMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState removeRetainedBlob: ( slot: Parameters[0] ) => void diff --git a/src/main/persistence/applying-settings/ui-state-update.ts b/src/main/persistence/applying-settings/ui-state-update.ts index f757fa5a1c4..b93ddfb1296 100644 --- a/src/main/persistence/applying-settings/ui-state-update.ts +++ b/src/main/persistence/applying-settings/ui-state-update.ts @@ -24,7 +24,6 @@ import { normalizeContextualTourIds } from '../../../shared/contextual-tours' import { normalizeFeatureInteractions } from '../../../shared/feature-interactions' import { mergeWorkspaceCleanupUIState } from '../../../shared/workspace-cleanup-ui-state' import { persistedUIValuesEqual } from '../../../shared/persisted-ui-equality' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { PROTECTED_SECRET_SLOT, type ProtectedSecretPersistence @@ -44,7 +43,7 @@ import { } from './ui-interaction-merge' export type UIUpdateOperations = { - state: StoreOwnedPersistedState + state: PersistedState removeRetainedBlob: ( slot: Parameters[0] ) => void diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts index 4a094ddb242..65b879795dc 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts @@ -1,10 +1,10 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { toSshExecutionHostId } from '../../../shared/execution-host' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' export type SshPtyBindingCleanupOperations = { - state: StoreOwnedPersistedState + state: PersistedState toComparablePtyId: (targetId: string, ptyId: string) => string scheduleSave: () => void } diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts index f7c8f07f24a..3b259989906 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts @@ -1,11 +1,11 @@ import type { SshPtyConsumerRecovery } from '../../../shared/ssh-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' import { normalizeSshPtyConsumerRecovery } from './ssh-normalization' export type SshPtyConsumerRecoveryOperations = { - state: StoreOwnedPersistedState + state: PersistedState protectedSecrets: Pick flushDurableStateOrThrowAsync: () => Promise } diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts index ef8fd642f24..2662f7f02da 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts @@ -1,10 +1,9 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { isTerminalLeafId } from '../../../shared/stable-pane-id' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' export type SshPtyLeaseOperations = { - state: StoreOwnedPersistedState + state: PersistedState toStoredPtyId: (targetId: string, ptyId: string) => string clearBindingsForTarget: (targetId: string) => void clearBindingsForLeases: (targetId: string, leases: SshRemotePtyLease[]) => boolean diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts index e0722e805c0..8dd5425b833 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts @@ -1,6 +1,6 @@ import type { ProjectHostSetup } from '../../../shared/project-types' import { toSshExecutionHostId } from '../../../shared/execution-host' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { migrateUiHostScopeSshTargetId, migrateWorkspaceSessionSshTargetId @@ -13,7 +13,7 @@ import { } from '../../worktree-retirement-namespace' export type SshTargetReassignmentOperations = { - state: StoreOwnedPersistedState + state: PersistedState protectedSecrets: Pick syncProjectHostSetupCompatibilityState: () => void scheduleSave: () => void @@ -22,7 +22,7 @@ export type SshTargetReassignmentOperations = { /** Retirement namespaces key on the endpoint a target reaches, so a rotation moves them only when * the endpoint itself changed — plus any pre-identity key that embedded the row id. */ function migrateRetirementNamespaces( - state: StoreOwnedPersistedState, + state: PersistedState, oldTargetId: string, newTargetId: string ): boolean { diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts index dafd76b1b6d..dbdb03b89a1 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts @@ -1,6 +1,5 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' import { @@ -15,7 +14,7 @@ import { } from '../../worktree-retirement-namespace' export type SshTargetStateOperations = { - state: StoreOwnedPersistedState + state: PersistedState protectedSecrets: Pick scheduleSave: () => void flush: () => void diff --git a/src/main/persistence/loading-store/automation-persistence.ts b/src/main/persistence/loading-store/automation-persistence.ts new file mode 100644 index 00000000000..fc62a4baf1e --- /dev/null +++ b/src/main/persistence/loading-store/automation-persistence.ts @@ -0,0 +1,158 @@ +import type { + Automation, + AutomationCreateInput, + AutomationDispatchResult, + AutomationRun, + AutomationRunTrigger, + AutomationUpdateInput +} from '../../../shared/automations-types' +import { getWorktreePathBasenameFromId } from '../../../shared/worktree/id' +import { normalizeAutomationRunWorkspaceDisplayName } from '../scheduling-automations/automation-context-migration' +import { + createAutomation as createAutomationOperation, + deleteAutomation as deleteAutomationOperation, + listAutomations as listAutomationsOperation, + updateAutomation as updateAutomationOperation, + type AutomationDefinitionOperations +} from '../scheduling-automations/automation-definition-operations' +import { + createAutomationRun as createAutomationRunOperation, + listAutomationRuns as listAutomationRunsOperation, + snapshotAutomationRunWorkspaceDisplayName as snapshotAutomationRunWorkspaceDisplayNameOperation, + updateAutomationRun as updateAutomationRunOperation, + type AutomationRunOperations +} from '../scheduling-automations/automation-run-operations' +import { + advanceAutomationNextRun as advanceAutomationNextRunOperation, + getLatestAutomationOccurrence as getLatestAutomationOccurrenceOperation +} from '../scheduling-automations/automation-schedule-operations' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' +import type { ProfilePreferences } from './profile-preferences' + +type AutomationPersistenceRuntime = Pick + +const automationPersistenceContext = Symbol('AutomationPersistence') +type AutomationPersistenceContext = { + runtime: AutomationPersistenceRuntime + flushBarriers: WriteFlushBarrierOperations + preferences: ProfilePreferences +} + +export class AutomationPersistence { + readonly [automationPersistenceContext]: AutomationPersistenceContext + + constructor( + runtime: AutomationPersistenceRuntime, + flushBarriers: WriteFlushBarrierOperations, + preferences: ProfilePreferences + ) { + this[automationPersistenceContext] = { runtime, flushBarriers, preferences } + } + + listAutomations(): Automation[] { + return listAutomationsOperation(this[automationPersistenceContext].runtime.state) + } + + listAutomationRuns(automationId?: string): AutomationRun[] { + return listAutomationRunsOperation( + this[automationPersistenceContext].runtime.state, + automationId + ) + } + + createAutomation(input: AutomationCreateInput): Automation { + return createAutomationOperation(getAutomationDefinitionOperations(this), input) + } + + updateAutomation(id: string, updates: AutomationUpdateInput): Automation { + return updateAutomationOperation(getAutomationDefinitionOperations(this), id, updates) + } + + deleteAutomation(id: string): void { + deleteAutomationOperation(getAutomationDefinitionOperations(this), id) + } + + createAutomationRun( + automation: Automation, + scheduledFor: number, + trigger: AutomationRunTrigger = 'scheduled' + ): AutomationRun { + return createAutomationRunOperation( + getAutomationRunOperations(this), + automation, + scheduledFor, + trigger + ) + } + + updateAutomationRun(result: AutomationDispatchResult): AutomationRun { + return updateAutomationRunOperation(getAutomationRunOperations(this), result) + } + + snapshotAutomationRunWorkspaceDisplayName(workspaceId: string, displayName: string): number { + return snapshotAutomationRunWorkspaceDisplayNameOperation( + getAutomationRunOperations(this), + workspaceId, + displayName + ) + } + + advanceAutomationNextRun(id: string, now = Date.now()): Automation { + return advanceAutomationNextRunOperation( + this[automationPersistenceContext].runtime.state, + () => this[automationPersistenceContext].flushBarriers.flush(), + id, + now + ) + } + + getLatestAutomationOccurrence(automation: Automation, now = Date.now()): number | null { + return getLatestAutomationOccurrenceOperation(automation, now) + } +} + +export function getAutomationDefinitionOperations( + owner: AutomationPersistence +): AutomationDefinitionOperations { + return { + state: owner[automationPersistenceContext].runtime.state, + flush: () => owner[automationPersistenceContext].flushBarriers.flush(), + recordCreated: () => + owner[automationPersistenceContext].preferences.recordFeatureInteraction('automation-created') + } +} + +export function getAutomationRunOperations(owner: AutomationPersistence): AutomationRunOperations { + return { + state: owner[automationPersistenceContext].runtime.state, + flush: () => owner[automationPersistenceContext].flushBarriers.flush(), + recordManualRun: () => + owner[automationPersistenceContext].preferences.recordFeatureInteraction('automation-run'), + getWorkspaceDisplayName: (workspaceId) => + getAutomationRunWorkspaceDisplayName(owner, workspaceId) + } +} + +export function getAutomationRunWorkspaceDisplayName( + owner: AutomationPersistence, + workspaceId: string | null | undefined +): string | null { + if (!workspaceId) { + return null + } + return normalizeAutomationRunWorkspaceDisplayName( + owner[automationPersistenceContext].runtime.state.worktreeMeta[workspaceId]?.displayName ?? + getWorktreePathBasenameFromId(workspaceId) + ) +} + +export function installAutomationPersistenceContext( + target: object, + source: AutomationPersistence +): void { + Object.defineProperty(target, automationPersistenceContext, { + value: source[automationPersistenceContext] + }) +} diff --git a/src/main/persistence/loading-store/backup-recovery-rotation.ts b/src/main/persistence/loading-store/backup-recovery-rotation.ts new file mode 100644 index 00000000000..7cf0a7a8078 --- /dev/null +++ b/src/main/persistence/loading-store/backup-recovery-rotation.ts @@ -0,0 +1,147 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { access, copyFile, rename, rm, stat } from 'node:fs/promises' +import { dirname } from 'node:path' + +const BACKUP_COUNT = 5 +const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 + +function backupPath(dataFile: string, index: number): string { + return `${dataFile}.bak.${index}` +} + +/** existsSync's non-blocking twin: existsSync is an access(F_OK) probe, so access() is the exact analogue. */ +async function exists(path: string): Promise { + return access(path).then( + () => true, + () => false + ) +} + +export function hasStateBackup(dataFile: string): boolean { + for (let index = 0; index < BACKUP_COUNT; index += 1) { + if (existsSync(backupPath(dataFile, index))) { + return true + } + } + return false +} + +import type { StoreRuntimeState } from './store-runtime-state' + +type BackupRecoveryRotationOperationsRuntime = Pick + +export class BackupRecoveryRotationOperations { + constructor(private readonly runtime: BackupRecoveryRotationOperationsRuntime) {} + + shouldRotateBackups(now: number, dataFile: string): boolean { + try { + const mtime = statSync(backupPath(dataFile, 0)).mtimeMs + return now - mtime >= BACKUP_MIN_INTERVAL_MS + } catch { + return true + } + } + + async shouldRotateBackupsAsync(dataFile: string): Promise { + try { + const mtime = (await stat(backupPath(dataFile, 0))).mtimeMs + return Date.now() - mtime >= BACKUP_MIN_INTERVAL_MS + } catch { + return true + } + } + + async rotateBackupsAsync(dataFile: string): Promise { + if (this.runtime.backupRotationInFlight) { + return + } + this.runtime.backupRotationInFlight = true + try { + if (!(await this.shouldRotateBackupsAsync(dataFile))) { + return + } + if (!(await exists(dataFile))) { + return + } + await rm(backupPath(dataFile, BACKUP_COUNT - 1)).catch((err: unknown) => { + if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[persistence] Failed to remove oldest backup:', err) + } + }) + for (let i = BACKUP_COUNT - 2; i >= 0; i--) { + const src = backupPath(dataFile, i) + const dst = backupPath(dataFile, i + 1) + // Why probe instead of rename-then-swallow-ENOENT: a degraded mount rejects a rename of an + // absent slot with ESTALE/EIO, which would log once per empty slot on every debounced save. + if (await exists(src)) { + await rename(src, dst).catch((err) => { + console.error('[persistence] Failed to rotate backup', src, '->', dst, err) + }) + } + } + await copyFile(dataFile, backupPath(dataFile, 0)).catch((err) => { + console.error('[persistence] Failed to snapshot current file to .bak.0:', err) + }) + } finally { + this.runtime.backupRotationInFlight = false + } + } + + rotateBackupsSync(dataFile: string): void { + if (!existsSync(dataFile)) { + return + } + try { + unlinkSync(backupPath(dataFile, BACKUP_COUNT - 1)) + } catch (err) { + if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[persistence] Failed to remove oldest backup:', err) + } + } + for (let i = BACKUP_COUNT - 2; i >= 0; i--) { + const src = backupPath(dataFile, i) + const dst = backupPath(dataFile, i + 1) + if (existsSync(src)) { + try { + renameSync(src, dst) + } catch (err) { + console.error('[persistence] Failed to rotate backup', src, '->', dst, err) + } + } + } + try { + copyFileSync(dataFile, backupPath(dataFile, 0)) + } catch (err) { + console.error('[persistence] Failed to snapshot current file to .bak.0:', err) + } + } + + restoreFromBackup(dataFile: string): boolean { + for (let i = 0; i < BACKUP_COUNT; i++) { + const path = backupPath(dataFile, i) + if (!existsSync(path)) { + continue + } + try { + const raw = readFileSync(path, 'utf-8') + JSON.parse(raw) + mkdirSync(dirname(dataFile), { recursive: true }) + writeFileSync(dataFile, raw, 'utf-8') + console.warn(`[persistence] Recovered state from backup slot ${i}: ${path}`) + return true + } catch (err) { + console.error(`[persistence] Backup slot ${i} unusable, trying next:`, err) + } + } + return false + } +} diff --git a/src/main/persistence/loading-store/loaded-cohort-migrations.ts b/src/main/persistence/loading-store/loaded-cohort-migrations.ts new file mode 100644 index 00000000000..c4f21df6b46 --- /dev/null +++ b/src/main/persistence/loading-store/loaded-cohort-migrations.ts @@ -0,0 +1,66 @@ +import { randomUUID } from 'node:crypto' +import type { PersistedState } from '../../../shared/persisted-state-types' + +import type { StoreRuntimeState } from './store-runtime-state' + +type LoadedCohortMigrationOperationsRuntime = Pick + +export class LoadedCohortMigrationOperations { + constructor(private readonly runtime: LoadedCohortMigrationOperationsRuntime) {} + + migrateTabSwitchKeybindings(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { + const existing = state.settings?.tabSwitchKeybindingSeed + if (existing === 'pending' || existing === 'done') { + return state + } + // Why: mark dirty so the frozen cohort persists; else a fresh install re-reads as "existing" after its file lands. + this.runtime.loadNeedsSave = true + return { + ...state, + settings: { + ...state.settings, + // Existing installs pin old chords via a keybindings.json seed; fresh installs use the new registry defaults. + tabSwitchKeybindingSeed: fileExistedOnLoad ? 'pending' : 'done' + } + } + } + + migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { + const existing = state.settings?.telemetry + // Why: require all three invariants; keying on existedBeforeTelemetryRelease alone lets a partial block skip migration. + if ( + typeof existing?.existedBeforeTelemetryRelease === 'boolean' && + typeof existing.installId === 'string' && + existing.installId.length > 0 && + (existing.optedIn === true || existing.optedIn === false || existing.optedIn === null) + ) { + return state + } + // Why: resolve cohort once; re-inferring it in the optedIn fallback could misclassify a partially-written new user. + const resolvedExistedBefore = + typeof existing?.existedBeforeTelemetryRelease === 'boolean' + ? existing.existedBeforeTelemetryRelease + : fileExistedOnLoad + return { + ...state, + settings: { + ...state.settings, + telemetry: { + ...existing, + existedBeforeTelemetryRelease: resolvedExistedBefore, + // Why: preserve any explicit opt-in/out; fall back to cohort default only when optedIn is undefined, never when false. + optedIn: + existing?.optedIn === true || existing?.optedIn === false || existing?.optedIn === null + ? existing.optedIn + : resolvedExistedBefore + ? null + : true, + installId: + typeof existing?.installId === 'string' && existing.installId.length > 0 + ? existing.installId + : randomUUID() + } + } + } + } +} diff --git a/src/main/persistence/loading-store/loaded-state-adaptation.ts b/src/main/persistence/loading-store/loaded-state-adaptation.ts new file mode 100644 index 00000000000..fe350f6b851 --- /dev/null +++ b/src/main/persistence/loading-store/loaded-state-adaptation.ts @@ -0,0 +1,116 @@ +import { isFolderRepo } from '../../../shared/repo-kind' +import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' +import { createProjectGroup } from '../../../shared/project-groups' +import { createNestedProjectGroupResolver } from '../../project-groups/nested-repo-import' + +import type { StoreRuntimeState } from './store-runtime-state' + +type LoadedStateAdaptationOperationsRuntime = Pick + +export class LoadedStateAdaptationOperations { + constructor(private readonly runtime: LoadedStateAdaptationOperationsRuntime) {} + + hydrateFolderWorkspaceDiffComments(): void { + const stored = this.runtime.state.folderWorkspaceDiffComments + let relocatedInline = false + for (const workspace of this.runtime.state.folderWorkspaces ?? []) { + if (Array.isArray(workspace.diffComments) && workspace.diffComments.length > 0) { + // Inline wins: an intervening rollback to a #14112 build writes notes inline and leaves the + // older map untouched, so inline is the last notes-aware write. Also makes the relocation + // durable even if the user never edits anything this session. + relocatedInline = true + continue + } + const comments = stored?.[workspace.id] + // Not `??`: a degenerate `{ id: [] }` entry must not delete an intact inline value. + if (Array.isArray(comments) && comments.length > 0) { + workspace.diffComments = comments + } + } + if (relocatedInline) { + this.runtime.loadNeedsSave = true + } + // Write-only projection: buildStateToSave() is the only producer, so leaving the loaded map in + // state would make it a stale second source of truth that getDurableState() spreads back out. + delete this.runtime.state.folderWorkspaceDiffComments + } + + adaptFlatFolderScanProjectGroups(): boolean { + // Why: older folder imports kept a real parent path but flat repos; upgrade that shape into v1 sparse folder scopes. + const groups = this.runtime.state.projectGroups ?? [] + const repos = this.runtime.state.repos + if (groups.length === 0 || repos.length === 0) { + return false + } + + let changed = false + let maxOrder = -1 + for (const group of groups) { + maxOrder = Math.max(maxOrder, group.tabOrder) + } + + const childGroupIds = new Set( + groups.flatMap((group) => (group.parentGroupId ? [group.parentGroupId] : [])) + ) + const initialGroupCount = groups.length + for (let groupIndex = 0; groupIndex < initialGroupCount; groupIndex += 1) { + const rootGroup = groups[groupIndex] + if (!rootGroup) { + continue + } + if ( + rootGroup.createdFrom !== 'folder-scan' || + !rootGroup.parentPath || + rootGroup.parentGroupId || + childGroupIds.has(rootGroup.id) + ) { + continue + } + const rootPath = rootGroup.parentPath + const repoCandidates = repos.filter( + (repo) => + !isFolderRepo(repo) && + repo.projectGroupId === rootGroup.id && + isPathInsideOrEqual(rootPath, repo.path) + ) + if (repoCandidates.length < 2) { + continue + } + + const resolver = createNestedProjectGroupResolver({ + parentPath: rootPath, + groupName: rootGroup.name, + mode: 'group', + repoPaths: repoCandidates.map((repo) => repo.path), + createGroup: (input) => { + if (!input.parentGroupId) { + return rootGroup + } + maxOrder += 1 + const group = createProjectGroup({ + ...input, + tabOrder: maxOrder + }) + groups.push(group) + changed = true + return group + } + }) + const nextOrderByGroupId = new Map() + for (const repo of repoCandidates) { + const group = resolver.getGroupForRepo(repo.path) + if (!group) { + continue + } + const nextOrder = nextOrderByGroupId.get(group.id) ?? 0 + nextOrderByGroupId.set(group.id, nextOrder + 1) + if (repo.projectGroupId !== group.id || repo.projectGroupOrder !== nextOrder) { + repo.projectGroupId = group.id + repo.projectGroupOrder = nextOrder + changed = true + } + } + } + return changed + } +} diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts new file mode 100644 index 00000000000..2d29844ffb0 --- /dev/null +++ b/src/main/persistence/loading-store/loaded-state-parsing.ts @@ -0,0 +1,267 @@ +import { readFileSync, existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { normalizeProxyUrl } from '../../../shared/network-proxy' +import { normalizeKagiSessionLink } from '../../../shared/browser-url' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { SshPtyConsumerRecovery } from '../../../shared/ssh-types' +import { getDefaultPersistedState } from '../../../shared/constants' +import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' +import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' +import { clearMissingProjectGroupMemberships } from '../../../shared/project-groups' +import { migrateWorkspaceSessionTerminalScrollbackSnapshots } from '../../terminal-scrollback-snapshots' +import { + isStartupDiagnosticsEnabled, + logStartupDiagnostic +} from '../../startup/startup-diagnostics' +import { + PROTECTED_SECRET_SLOT, + sshPtyOwnerLeaseSecretSlot +} from '../../protected-secret-persistence' +import { + isLegacyOpenCodeSessionCookie, + isLegacySshPtyOwnerLease +} from '../leasing-ssh-ptys/secret-validation' +import { readGithubCacheSnapshot } from './user-data-path' +import { + gcStaleWorktreeMeta, + normalizeWorktreeLinkedItemMetadata +} from '../tracking-repos/worktree-metadata-normalization' +import { backfillLegacyAutomationContexts } from '../scheduling-automations/automation-context-migration' +import { + ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH, + normalizeSshPtyConsumerRecovery +} from '../leasing-ssh-ptys/ssh-normalization' +import { + mergeProjectHostSetupCompatibilityState, + projectHostSetupCompatibilityStateEqual +} from '../tracking-repos/project-host-compatibility' +import { backfillFolderScopeConnectionIds } from '../restoring-sessions/folder-scope-migration' +import { hasStateBackup } from './backup-recovery-rotation' +import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-settings' +import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings' +import { normalizeLoadedProfileState } from './normalize-loaded-profile-state' + +function logPersistenceStartupMilestone( + event: string, + details: Record = {} +): void { + if (isStartupDiagnosticsEnabled()) { + logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) + } +} + +import type { StoreRuntimeState } from './store-runtime-state' +import type { BackupRecoveryRotationOperations } from './backup-recovery-rotation' +import type { LoadedCohortMigrationOperations } from './loaded-cohort-migrations' + +type LoadedStateParsingOperationsRuntime = Pick< + StoreRuntimeState, + | 'dataFile' + | 'githubCacheDirty' + | 'loadNeedsSave' + | 'protectedSecrets' + | 'terminalScrollbackSnapshotStorage' +> + +export class LoadedStateParsingOperations { + constructor( + private readonly runtime: LoadedStateParsingOperationsRuntime, + private readonly backups: BackupRecoveryRotationOperations, + private readonly cohorts: LoadedCohortMigrationOperations + ) {} + + load(allowBackupRecovery = true): PersistedState { + // Capture "has run Orca before?" for telemetry cohort; the telemetry field is new, so field inference misclassifies old users as fresh. + const dataFile = this.runtime.dataFile + const fileExistedOnLoad = existsSync(dataFile) + logPersistenceStartupMilestone('persistence-load-start', { + fileExists: fileExistedOnLoad + }) + + let result: PersistedState | null = null + try { + if (fileExistedOnLoad) { + const readStartedAt = performance.now() + const raw = readFileSync(dataFile, 'utf-8') + logPersistenceStartupMilestone('persistence-read-done', { + bytes: Buffer.byteLength(raw), + durationMs: Math.round(performance.now() - readStartedAt) + }) + logPersistenceStartupMilestone('persistence-json-parse-start') + const parsed = JSON.parse(raw) as PersistedState + logPersistenceStartupMilestone('persistence-json-parse-done') + + // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. + if (parsed.settings?.opencodeSessionCookie) { + parsed.settings.opencodeSessionCookie = this.runtime.protectedSecrets.decrypt( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + parsed.settings.opencodeSessionCookie, + isLegacyOpenCodeSessionCookie + ) + } + if (parsed.settings?.httpProxyUrl) { + const decryptedProxy = this.runtime.protectedSecrets.decryptWithStatus( + PROTECTED_SECRET_SLOT.httpProxyUrl, + parsed.settings.httpProxyUrl, + (value) => normalizeProxyUrl(value).ok + ) + // Why (STA-3442): after a keychain reset decrypt returns raw ciphertext; a non-URL + // value must not masquerade as a configured proxy (silent DIRECT fallback) or + // re-persist as garbage. Plaintext URLs still pass, preserving the upgrade path. + if ( + decryptedProxy.status === 'unavailable' || + (decryptedProxy.status === 'failed' && !decryptedProxy.plaintext) + ) { + parsed.settings.httpProxyUrl = '' + } else if (normalizeProxyUrl(decryptedProxy.plaintext).ok) { + parsed.settings.httpProxyUrl = decryptedProxy.plaintext + } else { + console.warn( + '[persistence] httpProxyUrl could not be decrypted — clearing the stored proxy URL. Re-enter it in Settings > Advanced > Network.' + ) + parsed.settings.httpProxyUrl = '' + this.runtime.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) + this.runtime.loadNeedsSave = true + } + } + if (parsed.ui?.browserKagiSessionLink) { + parsed.ui.browserKagiSessionLink = this.runtime.protectedSecrets.decrypt( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + parsed.ui.browserKagiSessionLink, + (value) => normalizeKagiSessionLink(value) !== null + ) + } + parsed.sshPtyConsumerRecoveries = ( + Array.isArray(parsed.sshPtyConsumerRecoveries) ? parsed.sshPtyConsumerRecoveries : [] + ) + .map((record) => + normalizeSshPtyConsumerRecovery(record, ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH) + ) + .filter((record): record is SshPtyConsumerRecovery => record !== null) + .map((record) => { + const slot = sshPtyOwnerLeaseSecretSlot(record.targetId) + const decrypted = this.runtime.protectedSecrets.decryptWithStatus( + slot, + record.ownerLease, + isLegacySshPtyOwnerLease + ) + const normalized = + decrypted.status === 'unavailable' || + (decrypted.status === 'failed' && !decrypted.plaintext) + ? record + : normalizeSshPtyConsumerRecovery({ ...record, ownerLease: decrypted.plaintext }) + if (!normalized) { + this.runtime.protectedSecrets.removeRetainedBlob(slot) + } + return normalized + }) + .filter((record): record is SshPtyConsumerRecovery => record !== null) + + const terminalSettings = prepareLoadedTerminalSettings(parsed, () => { + this.runtime.loadNeedsSave = true + }) + const profileSettings = prepareLoadedProfileSettings( + parsed, + terminalSettings.defaults, + () => { + this.runtime.loadNeedsSave = true + } + ) + result = normalizeLoadedProfileState(parsed, terminalSettings, profileSettings, () => { + this.runtime.loadNeedsSave = true + }) + } + } catch (err) { + console.error('[persistence] Failed to load primary state, trying backups:', err) + } + + // Corrupt-file and no-file paths converge here; a corrupted install counts as existing, so it sees the opt-in banner. + if (result === null && allowBackupRecovery) { + const hasBackup = hasStateBackup(dataFile) + if (fileExistedOnLoad || hasBackup) { + if (this.backups.restoreFromBackup(dataFile)) { + return this.load(false) + } + console.error('[persistence] No usable state file or backup found, using defaults') + } + } + + if (result === null) { + result = getDefaultPersistedState(homedir()) + } + + const workspaceSession = pruneWorkspaceSessionBrowserHistory( + pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos) + ) + const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( + workspaceSession, + this.runtime.terminalScrollbackSnapshotStorage + ) + if (migratedScrollback.changed) { + this.runtime.loadNeedsSave = true + } + + const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) + const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) + if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { + this.runtime.loadNeedsSave = true + } + + const automationContextMigration = backfillLegacyAutomationContexts({ + ...result, + repos, + ...projectHostSetupCompatibility + }) + if (automationContextMigration.changed) { + this.runtime.loadNeedsSave = true + } + result = { + ...result, + automations: automationContextMigration.state.automations, + automationRuns: automationContextMigration.state.automationRuns + } + + const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({ + ...result, + repos, + ...projectHostSetupCompatibility, + workspaceSession: migratedScrollback.session + }) + if (folderScopeConnectionMigration.changed) { + this.runtime.loadNeedsSave = true + } + result = folderScopeConnectionMigration.state + + if (normalizeWorktreeLinkedItemMetadata(result)) { + this.runtime.loadNeedsSave = true + } + + if (gcStaleWorktreeMeta(result) > 0) { + this.runtime.loadNeedsSave = true + } + + const migrated = this.cohorts.migrateTabSwitchKeybindings( + this.cohorts.migrateTelemetry(result, fileExistedOnLoad), + fileExistedOnLoad + ) + + // githubCache is a sidecar file now (see getGithubCacheFile); legacy in-file caches seed the session, then get stripped. + const legacyCache = migrated.githubCache + const hasLegacyCache = + Object.keys(legacyCache?.pr ?? {}).length > 0 || + Object.keys(legacyCache?.issue ?? {}).length > 0 + if (hasLegacyCache) { + this.runtime.loadNeedsSave = true + // Why: mark dirty so the first flush writes the sidecar even without a poll refresh this session, preserving the seed. + this.runtime.githubCacheDirty = true + } else { + migrated.githubCache = readGithubCacheSnapshot(this.runtime.dataFile) ?? migrated.githubCache + } + + logPersistenceStartupMilestone('persistence-load-done', { + repos: migrated.repos.length, + workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) + }) + return migrated + } +} diff --git a/src/main/persistence/loading-store/metadata-lineage-operations.ts b/src/main/persistence/loading-store/metadata-lineage-operations.ts new file mode 100644 index 00000000000..056991dd1f4 --- /dev/null +++ b/src/main/persistence/loading-store/metadata-lineage-operations.ts @@ -0,0 +1,254 @@ +import { randomUUID } from 'node:crypto' +import type { WorkspaceKey } from '../../../shared/folder-workspace-types' +import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-context' +import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' +import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner' +import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../shared/workspace-statuses' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { + workspaceSessionOwnerPartitionForHost, + workspaceSessionPartitionIdsForHost +} from '../restoring-sessions/session-owner-removal' +import { migrateWorktreeIdentity as migrateWorktreeIdentityOperation } from '../tracking-repos/worktree-identity-migration' + +function getDefaultWorktreeMeta(): WorktreeMeta { + return { + instanceId: randomUUID(), + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null, + linkedWorkItem: null, + linkedTaskSourceContext: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: Date.now(), + lastActivityAt: 0, + workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID + } +} + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import type { SessionHostPartitionOperations } from './session-host-partitions' +import { scheduleSave } from './write-scheduling' +import { + hasPersistedWorkspaceSession, + partitionOwnsWorktreeTabs, + partitionHasOtherRepoWorktreeTabs, + removeWorkspaceSessionOwnerInPartition +} from './session-host-partitions' + +type MetadataLineageOperationsRuntime = Pick + +const metadataLineageOperationsContext = Symbol('MetadataLineageOperations') +type MetadataLineageOperationsContext = { + runtime: MetadataLineageOperationsRuntime + scheduling: WriteSchedulingOperations + sessions: SessionHostPartitionOperations +} + +export class MetadataLineageOperations { + readonly [metadataLineageOperationsContext]: MetadataLineageOperationsContext + + constructor( + runtime: MetadataLineageOperationsRuntime, + scheduling: WriteSchedulingOperations, + sessions: SessionHostPartitionOperations + ) { + this[metadataLineageOperationsContext] = { runtime, scheduling, sessions } + } + + getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined { + return this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] + } + + getAllWorktreeMeta(): Record { + return this[metadataLineageOperationsContext].runtime.state.worktreeMeta + } + + setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { + const existing = + this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] || + getDefaultWorktreeMeta() + const updated = { ...existing, ...meta } + updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem) + const linkedTaskSourceContext = normalizeStoredTaskSourceContext( + updated.linkedTaskSourceContext + ) + updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( + updated.linkedWorkItem, + linkedTaskSourceContext + ) + ? linkedTaskSourceContext + : null + if (!updated.instanceId) { + updated.instanceId = randomUUID() + } + this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] = updated + scheduleSave(this[metadataLineageOperationsContext].scheduling) + return updated + } + + removeWorktreeMeta(worktreeId: string, hostId?: ExecutionHostId | null): void { + // A host-qualified removal names the owner; the persisted host is the fallback. + const persistedOwner = + this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId]?.hostId + const owner = hostId ?? persistedOwner + const preservesDifferentPersistedOwner = Boolean( + hostId && persistedOwner && persistedOwner !== hostId + ) + const ownerPartition = workspaceSessionOwnerPartitionForHost(owner) + const preservesSameIdSessionOwner = Boolean( + preservesDifferentPersistedOwner || + (owner && + hasWorktreeRemovalRepoOwnerOnOtherHost( + { getRepos: () => this[metadataLineageOperationsContext].runtime.state.repos }, + getRepoIdFromWorktreeId(worktreeId), + ownerPartition + )) + ) + // Skip partitions main never wrote: materializing one fences every sibling worktree of the repo. + const partitions = new Set( + workspaceSessionPartitionIdsForHost(owner).filter( + (partition) => + hasPersistedWorkspaceSession( + this[metadataLineageOperationsContext].sessions, + partition + ) && + // The local partition can be a remote spill surface or a same-id owner. + // Preserve it whenever another owner may still use the bare id. + (!preservesSameIdSessionOwner || partition === ownerPartition) + ) + ) + // A repo-wide fence must not rebase a sibling's unpersisted tabs onto main's copy, and a spill + // partition that never held this worktree has no claim on the repo at all. + const fencedPartitions = new Set( + [...partitions].filter( + (partition) => + partitionOwnsWorktreeTabs( + this[metadataLineageOperationsContext].sessions, + worktreeId, + partition + ) || + (partition === ownerPartition && + !partitionHasOtherRepoWorktreeTabs( + this[metadataLineageOperationsContext].sessions, + worktreeId, + partition + )) + ) + ) + if (!preservesDifferentPersistedOwner) { + delete this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] + delete this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] + delete this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + worktreeWorkspaceKey(worktreeId) + ] + } + for (const partition of partitions) { + removeWorkspaceSessionOwnerInPartition( + this[metadataLineageOperationsContext].sessions, + worktreeId, + partition, + { + advanceTerminalTopologyRevision: fencedPartitions.has(partition) + } + ) + } + scheduleSave(this[metadataLineageOperationsContext].scheduling) + } + + getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined { + return this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] + } + + getAllWorktreeLineage(): Record { + return this[metadataLineageOperationsContext].runtime.state.worktreeLineageById + } + + setWorktreeLineage(worktreeId: string, lineage: WorktreeLineage): WorktreeLineage { + this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] = lineage + scheduleSave(this[metadataLineageOperationsContext].scheduling) + return lineage + } + + removeWorktreeLineage(worktreeId: string): void { + delete this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] + scheduleSave(this[metadataLineageOperationsContext].scheduling) + } + + migrateWorktreeIdentity(oldWorktreeId: string, newWorktreeId: string): void { + if ( + migrateWorktreeIdentityOperation( + this[metadataLineageOperationsContext].runtime.state, + oldWorktreeId, + newWorktreeId + ) + ) { + scheduleSave(this[metadataLineageOperationsContext].scheduling) + } + } + + getWorkspaceLineage(childWorkspaceKey: WorkspaceKey): WorkspaceLineage | undefined { + return this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + childWorkspaceKey + ] + } + + getAllWorkspaceLineage(): Record { + return this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey + } + + setWorkspaceLineage(lineage: WorkspaceLineage): WorkspaceLineage { + this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + lineage.childWorkspaceKey + ] = lineage + scheduleSave(this[metadataLineageOperationsContext].scheduling) + return lineage + } + + removeWorkspaceLineage(childWorkspaceKey: WorkspaceKey): void { + delete this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + childWorkspaceKey + ] + scheduleSave(this[metadataLineageOperationsContext].scheduling) + } +} + +export function removeWorkspaceLineageForFolderParent( + owner: MetadataLineageOperations, + folderWorkspaceId: string +): void { + const parentKey = folderWorkspaceKey(folderWorkspaceId) + for (const [childKey, lineage] of Object.entries( + owner[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey + )) { + if (lineage.parentWorkspaceKey === parentKey) { + delete owner[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + childKey as WorkspaceKey + ] + } + } +} + +export function installMetadataLineageOperationsContext( + target: object, + source: MetadataLineageOperations +): void { + Object.defineProperty(target, metadataLineageOperationsContext, { + value: source[metadataLineageOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/mobile-tab-selection-persistence.ts b/src/main/persistence/loading-store/mobile-tab-selection-persistence.ts new file mode 100644 index 00000000000..321baad1aaf --- /dev/null +++ b/src/main/persistence/loading-store/mobile-tab-selection-persistence.ts @@ -0,0 +1,42 @@ +import type { PersistedMobileClientTabSelections } from '../../../shared/persisted-state-types' +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type MobileTabSelectionRuntime = Pick + +const mobileTabSelectionPersistenceContext = Symbol('MobileTabSelectionPersistence') +type MobileTabSelectionPersistenceContext = { + runtime: MobileTabSelectionRuntime + scheduling: WriteSchedulingOperations +} + +export class MobileTabSelectionPersistence { + readonly [mobileTabSelectionPersistenceContext]: MobileTabSelectionPersistenceContext + + constructor(runtime: MobileTabSelectionRuntime, scheduling: WriteSchedulingOperations) { + this[mobileTabSelectionPersistenceContext] = { runtime, scheduling } + } + + getMobileClientTabSelections(): PersistedMobileClientTabSelections { + return ( + this[mobileTabSelectionPersistenceContext].runtime.state + .mobileClientTabSelectionsByDeviceId ?? {} + ) + } + + setMobileClientTabSelections(next: PersistedMobileClientTabSelections): void { + this[mobileTabSelectionPersistenceContext].runtime.state.mobileClientTabSelectionsByDeviceId = + next + scheduleSave(this[mobileTabSelectionPersistenceContext].scheduling) + } +} + +export function installMobileTabSelectionPersistenceContext( + target: object, + source: MobileTabSelectionPersistence +): void { + Object.defineProperty(target, mobileTabSelectionPersistenceContext, { + value: source[mobileTabSelectionPersistenceContext] + }) +} diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts new file mode 100644 index 00000000000..fb5cc9f1fe8 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts @@ -0,0 +1,138 @@ +import { getDefaultVoiceSettings } from '../../../shared/constants' +import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides' +import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands' +import { normalizeOpenInApplications } from '../../../shared/open-in-applications' +import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings' +import { normalizeAppIconId } from '../../../shared/app-icon' +import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' +import { projectSourceControlAiToLegacyCommitMessageAi } from '../../../shared/source-control-ai' +import { normalizeUiLanguage } from '../../../shared/ui-language' +import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' +import { readLegacySidekickFlag } from '../applying-settings/onboarding-normalization' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { PreparedLoadedTerminalSettings } from './prepare-loaded-terminal-settings' +import type { PreparedLoadedProfileSettings } from './prepare-loaded-profile-settings' + +export function normalizeLoadedGlobalSettings( + parsed: PersistedState, + terminal: PreparedLoadedTerminalSettings, + profile: PreparedLoadedProfileSettings +): PersistedState['settings'] { + const { + defaults, + migratedExternalVisibility, + migratedTerminalScrollback, + migratedTerminalTuiScrollSensitivity, + migratedSourceControlAi, + migratedOptionAsAlt, + migratedFloatingTerminalEnabled, + migratedOsc52Clipboard, + migratedFloatingTerminalCwd, + migratedFloatingTerminalTrustedCwds + } = terminal + const { + migratedExperimentalActivity, + migratedAutoRenameBranchFromWork, + migratedTerminalCursorStyle, + migratedTerminalLineHeight, + terminalRightClickToPasteDefaultedForPlatform, + taskProviderSettings, + primarySelectionDefaultedForLinux, + primarySelectionDefaultedForTerminalDefaults, + migratePrimarySelectionPlatformDefault, + stampPrimarySelectionTerminalDefaults, + migratedDisabledTuiAgents, + migratedAgentYoloDefaults, + migratedWindowsRuntimeDefault, + migratedLocalAccountRuntime, + loadedCompactWorktreeCards, + mobilePairingCustomAddress, + mobilePairingCustomAddresses, + normalizedNotifications, + normalizedSourceControlGroupOrder + } = profile + + return { + ...defaults.settings, + // Why (#7977): v1.4.130 onboarding persisted this as a plain boolean, making the + // old default indistinguishable from a real opt-in. Preserve stored `true`; only + // the default changed. + ...stripRetiredGlobalSettings(parsed.settings), + worktreeVisibilityDefaults: migratedExternalVisibility.defaults, + prBotAuthorOverrides: normalizePRBotAuthorOverrides(parsed.settings?.prBotAuthorOverrides), + // Why: v1.3.42 renamed the sidekick setting to pet; carry the old flag forward once so enabled users don't lose it. + experimentalPet: parsed.settings?.experimentalPet ?? readLegacySidekickFlag(parsed) ?? false, + // Why: early builds saved the disabled default; flip Linux/macOS profiles once to match platform, guards keep opt-outs. + primarySelectionMiddleClickPaste: migratePrimarySelectionPlatformDefault + ? true + : (parsed.settings?.primarySelectionMiddleClickPaste ?? + defaults.settings.primarySelectionMiddleClickPaste), + primarySelectionMiddleClickPasteDefaultedForLinux: + primarySelectionDefaultedForLinux || + (process.platform === 'linux' && migratePrimarySelectionPlatformDefault), + primarySelectionMiddleClickPasteDefaultedForTerminalDefaults: + primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults, + ...migratedAutoRenameBranchFromWork, + ...migratedTerminalCursorStyle, + terminalLineHeight: migratedTerminalLineHeight, + // Why: the old true default was inherited, but false was always an explicit opt-out and must survive this one-shot reset. + terminalRightClickToPaste: terminalRightClickToPasteDefaultedForPlatform + ? (parsed.settings?.terminalRightClickToPaste ?? defaults.settings.terminalRightClickToPaste) + : parsed.settings?.terminalRightClickToPaste === false + ? false + : defaults.settings.terminalRightClickToPaste, + terminalRightClickToPasteDefaultedForPlatform: true, + ...migratedTerminalTuiScrollSensitivity.settings, + experimentalActivity: migratedExperimentalActivity, + experimentalActivityDefaultedOffForAllUsers: true, + // Why: compact worktree cards graduated from Experimental; preserve the old opt-in for rollout-era profiles. + compactWorktreeCards: loadedCompactWorktreeCards, + experimentalCompactWorktreeCards: undefined, + terminalMacOptionAsAlt: migratedOptionAsAlt, + terminalMacOptionAsAltMigrated: true, + localWindowsRuntimeDefault: migratedWindowsRuntimeDefault, + localAccountRuntime: migratedLocalAccountRuntime, + localAccountRuntimeDefaultedToAutoForAllUsers: true, + ...migratedOsc52Clipboard, + floatingTerminalEnabled: migratedFloatingTerminalEnabled, + floatingTerminalDefaultedForAllUsers: true, + floatingTerminalCwd: migratedFloatingTerminalCwd, + floatingTerminalTrustedCwds: migratedFloatingTerminalTrustedCwds, + floatingTerminalCwdMigratedToAppWorkspace: true, + terminalScrollbackRows: migratedTerminalScrollback.rows, + terminalQuickCommands: normalizeTerminalQuickCommands(parsed.settings?.terminalQuickCommands), + terminalCustomThemes: normalizeTerminalCustomThemes(parsed.settings?.terminalCustomThemes), + appIcon: normalizeAppIconId(parsed.settings?.appIcon), + mobilePairingCustomAddress, + mobilePairingCustomAddresses, + // Why: persisted settings may be hand-edited or from older builds; keep tray-minimize false unless stored value is true. + minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true, + // Why: missing means default-on; round-trips unchanged on non-mac since darwin consumers gate the effect. + showMenuBarIcon: parsed.settings?.showMenuBarIcon !== false, + uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage), + defaultTaskSource: taskProviderSettings.defaultTaskSource, + visibleTaskProviders: taskProviderSettings.visibleTaskProviders, + visibleTaskProvidersDefaultedForJira: true, + terminalShortcutPolicy: normalizeTerminalShortcutPolicy( + parsed.settings?.terminalShortcutPolicy + ), + disabledTuiAgents: migratedDisabledTuiAgents, + ...migratedAgentYoloDefaults, + claudeAgentTeamsDefaultDisabledMigrated: true, + openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, { + seedDefaults: true + }), + notifications: normalizedNotifications, + sourceControlAi: migratedSourceControlAi, + sourceControlGroupOrder: normalizedSourceControlGroupOrder, + // Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat. + commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( + migratedSourceControlAi, + parsed.settings?.commitMessageAi ?? defaults.settings.commitMessageAi + ), + voice: { + ...getDefaultVoiceSettings(), + ...parsed.settings?.voice + } + } +} diff --git a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts new file mode 100644 index 00000000000..464a1eaa9b8 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -0,0 +1,91 @@ +import { normalizePersistedMobileClientTabSelections } from '../../runtime/client-session-tab-selection-persistence' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import { normalizeFeatureInteractionTelemetryBuckets } from '../../../shared/feature-interactions' +import { normalizeFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments' +import { normalizeFolderWorkspaces } from '../../../shared/folder-workspaces' +import { normalizeWorkspaceLineageByChildKey } from '../applying-settings/ui-interaction-merge' +import { + normalizeSshRemotePtyLease, + normalizeSshTarget +} from '../leasing-ssh-ptys/ssh-normalization' +import { + normalizeClaudeLivePtySessionIds, + normalizeLegacyPaneKeyAliasEntries, + normalizeMigrationUnsupportedPtyEntries +} from '../restoring-sessions/pane-alias-normalization' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { PreparedLoadedTerminalSettings } from './prepare-loaded-terminal-settings' +import type { PreparedLoadedProfileSettings } from './prepare-loaded-profile-settings' +import { normalizeLoadedGlobalSettings } from './normalize-loaded-global-settings' +import { normalizeLoadedUiState } from './normalize-loaded-ui-state' +import { + normalizeLoadedAutomationRuns, + normalizeLoadedHostSessions, + normalizeLoadedLocalSession +} from './normalize-loaded-state-collections' +import { normalizeRetiredNameRegistryMap } from './retired-name-registry-normalization' + +export function normalizeLoadedProfileState( + parsed: PersistedState, + terminal: PreparedLoadedTerminalSettings, + profile: PreparedLoadedProfileSettings, + markNeedsSave: () => void +): PersistedState { + const { defaults, migratedExternalVisibility, osc52ClipboardNoticePending } = terminal + const { normalizedOnboarding, normalizedProjectGroups, loadedCompactWorktreeCards } = profile + + return { + ...defaults, + ...parsed, + featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets( + parsed.featureInteractionTelemetryBuckets + ), + projectGroups: normalizedProjectGroups, + repos: migratedExternalVisibility.repos, + folderWorkspaces: normalizeFolderWorkspaces(parsed.folderWorkspaces, normalizedProjectGroups), + folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments( + parsed.folderWorkspaceDiffComments + ), + worktreeLineageById: parsed.worktreeLineageById ?? {}, + mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( + parsed.mobileClientTabSelectionsByDeviceId + ), + workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey( + parsed.workspaceLineageByChildKey + ), + settings: normalizeLoadedGlobalSettings(parsed, terminal, profile), + // Why: legacy 'recent' meant the smart sort; migrate once on the raw value so a fresh 'recent' default isn't remigrated. + ui: normalizeLoadedUiState( + parsed, + defaults, + normalizedOnboarding, + loadedCompactWorktreeCards, + osc52ClipboardNoticePending, + markNeedsSave + ), + // Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash. + workspaceSession: normalizeLoadedLocalSession(parsed, defaults, markNeedsSave), + // Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat. + workspaceSessionsByHostId: normalizeLoadedHostSessions(parsed, defaults, markNeedsSave), + sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), + deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) + ? parsed.deletedSshConfigAliases.filter((alias): alias is string => typeof alias === 'string') + : [], + retiredWorktreeNamesByRepo: normalizeRetiredNameRegistryMap(parsed.retiredWorktreeNamesByRepo), + retiredWorktreeNamesByNamespace: normalizeRetiredNameRegistryMap( + parsed.retiredWorktreeNamesByNamespace + ), + sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) + .map(normalizeSshRemotePtyLease) + .filter((lease): lease is SshRemotePtyLease => lease !== null), + sshPtyConsumerRecoveries: parsed.sshPtyConsumerRecoveries, + claudeLivePtySessionIds: normalizeClaudeLivePtySessionIds(parsed.claudeLivePtySessionIds), + migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( + parsed.migrationUnsupportedPtyEntries + ), + legacyPaneKeyAliasEntries: normalizeLegacyPaneKeyAliasEntries(parsed.legacyPaneKeyAliasEntries), + automations: Array.isArray(parsed.automations) ? parsed.automations : [], + automationRuns: normalizeLoadedAutomationRuns(parsed, markNeedsSave), + onboarding: normalizedOnboarding + } +} diff --git a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts new file mode 100644 index 00000000000..978515a5752 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts @@ -0,0 +1,66 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' +import { + backfillAutomationRunNumbers, + pruneAutomationRuns +} from '../../../shared/automation-run-retention' +import { + parseWorkspaceSessionsByHostId, + workspaceSessionSalvageLogDetails +} from './workspace-session-partitions' + +export function normalizeLoadedLocalSession( + parsed: PersistedState, + defaults: PersistedState, + markNeedsSave: () => void +): WorkspaceSessionState { + if (parsed.workspaceSession === undefined) { + return defaults.workspaceSession + } + const result = parseWorkspaceSessionSalvaging(parsed.workspaceSession) + if (!result.ok) { + console.error('[persistence] Corrupt workspace session, using defaults:', result.error) + return defaults.workspaceSession + } + if (result.droppedCount > 0) { + console.warn( + '[persistence] Salvaged workspace session; dropped corrupt entries:', + workspaceSessionSalvageLogDetails(result) + ) + // Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch. + markNeedsSave() + } + return { ...defaults.workspaceSession, ...result.value } +} + +export function normalizeLoadedHostSessions( + parsed: PersistedState, + defaults: PersistedState, + markNeedsSave: () => void +): PersistedState['workspaceSessionsByHostId'] { + const { partitions, repaired } = parseWorkspaceSessionsByHostId( + parsed.workspaceSessionsByHostId, + defaults.workspaceSession + ) + if (repaired) { + // Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch. + markNeedsSave() + } + return partitions +} + +export function normalizeLoadedAutomationRuns( + parsed: PersistedState, + markNeedsSave: () => void +): PersistedState['automationRuns'] { + if (!Array.isArray(parsed.automationRuns)) { + return [] + } + const runs = pruneAutomationRuns(backfillAutomationRunNumbers(parsed.automationRuns)) + // Why: nothing else marks dirty, so an oversized legacy file would otherwise only shrink at the next unrelated save. + if (runs.length !== parsed.automationRuns.length) { + markNeedsSave() + } + return runs +} diff --git a/src/main/persistence/loading-store/normalize-loaded-ui-state.ts b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts new file mode 100644 index 00000000000..d26f4e68430 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts @@ -0,0 +1,206 @@ +import { + getWorktreeCardModeProperties, + isDefaultedCompactWorktreeCardProperties, + normalizeWorktreeCardProperties +} from '../../../shared/constants' +import { isExistingPersistedProfile } from '../../../shared/project-order-manual-default-notice' +import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../../../shared/usage-percentage-display-change-notice' +import { normalizePersistedWorkspaceStatuses } from '../../../shared/workspace-statuses' +import { + normalizeRightSidebarExplorerView, + normalizeRightSidebarTab, + normalizeShowDotfilesByWorktree, + normalizeSortBy +} from '../applying-settings/ui-selection-normalization' +import { stripMainOwnedTelemetryMarkerFromUI } from '../applying-settings/ui-interaction-merge' +import { + readDeprecatedExperimentFlag, + resolveSetupGuideSidebarDismissedOnLoad +} from '../applying-settings/onboarding-normalization' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { OnboardingState } from '../../../shared/onboarding-state-types' + +export function normalizeLoadedUiState( + parsed: PersistedState, + defaults: PersistedState, + normalizedOnboarding: OnboardingState, + loadedCompactWorktreeCards: boolean, + osc52ClipboardNoticePending: boolean, + markNeedsSave: () => void +): PersistedState['ui'] { + const rawSort = parsed.ui?.sortBy + const sort = normalizeSortBy(rawSort) + const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent' + const rightSidebarOpen = + typeof parsed.ui?.rightSidebarOpen === 'boolean' + ? parsed.ui.rightSidebarOpen + : typeof parsed.settings?.rightSidebarOpenByDefault === 'boolean' + ? parsed.settings.rightSidebarOpenByDefault + : defaults.ui.rightSidebarOpen + if (typeof parsed.ui?.rightSidebarOpen !== 'boolean') { + markNeedsSave() + } + const workspaceStatusesDefaultOrderMigrated = + parsed.ui?._workspaceStatusesDefaultOrderMigrated === true + // Why: a short-lived default put Done on the left; repair only the exact raw payload once so user reorders survive. + const workspaceStatusesReorderedDefaultRepaired = + parsed.ui?._workspaceStatusesReorderedDefaultRepaired === true + // Why: only exact legacy default payloads migrate; customized status labels/colors/icons/order are kept. + const workspaceStatusesDefaultWorkflowMigrated = + parsed.ui?._workspaceStatusesDefaultWorkflowMigrated === true + // Why: visual migration has its own guard so later user choices of valid legacy color/icon IDs are preserved. + const workspaceStatusesDefaultVisualsMigrated = + parsed.ui?._workspaceStatusesDefaultVisualsMigrated === true + const workspaceStatuses = normalizePersistedWorkspaceStatuses(parsed.ui?.workspaceStatuses, { + migrateDefaultWorkflowStatuses: !workspaceStatusesDefaultWorkflowMigrated, + repairReorderedDefaultStatuses: !workspaceStatusesReorderedDefaultRepaired, + migrateLegacyDefaultStatusVisuals: !workspaceStatusesDefaultVisualsMigrated + }) + if ( + !workspaceStatusesDefaultOrderMigrated || + !workspaceStatusesReorderedDefaultRepaired || + !workspaceStatusesDefaultWorkflowMigrated || + !workspaceStatusesDefaultVisualsMigrated + ) { + markNeedsSave() + } + const rawCardProps = parsed.ui?.worktreeCardProperties + const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true + const expandedCardPropsMigrated = parsed.ui?._expandedWorktreeCardPropertiesDefaulted === true + const jiraIssueCardPropDefaulted = parsed.ui?._jiraIssueWorktreeCardPropertyDefaulted === true + const hadExperimentOn = readDeprecatedExperimentFlag(parsed) + const deliberateUncheck = + hadExperimentOn && Array.isArray(rawCardProps) && !rawCardProps.includes('inline-agents') + const needsInlineAgentsMigration = + !inlineAgentsMigrated && + !deliberateUncheck && + Array.isArray(rawCardProps) && + !rawCardProps.includes('inline-agents') + const needsLegacyDefaultedCompactMigration = + loadedCompactWorktreeCards && + parsed.ui?._worktreeCardModeDefaulted === true && + isDefaultedCompactWorktreeCardProperties(rawCardProps) + const migratedCardProps = (() => { + if (!Array.isArray(rawCardProps)) { + return undefined + } + if (needsLegacyDefaultedCompactMigration) { + return getWorktreeCardModeProperties('Compact') + } + const candidate = needsInlineAgentsMigration + ? [...rawCardProps, 'inline-agents' as const] + : rawCardProps + const expandedCandidate = (() => { + if (expandedCardPropsMigrated) { + return candidate + } + const next = [...candidate] + // Why: Linear rode the 'issue' property and Ports were always shown; split them out once to preserve existing cards. + if (candidate.includes('issue') && !candidate.includes('linear-issue')) { + next.push('linear-issue' as const) + } + if (!candidate.includes('ports')) { + next.push('ports' as const) + } + return next + })() + // Why: 'jira-issue' joined the defaults after the expansion migration already stamped upgraded profiles, so it needs its own one-shot backfill. + const jiraCandidate = + jiraIssueCardPropDefaulted || expandedCandidate.includes('jira-issue') + ? expandedCandidate + : [...expandedCandidate, 'jira-issue' as const] + const normalized = normalizeWorktreeCardProperties(jiraCandidate) + const changed = + normalized.length !== rawCardProps.length || + normalized.some((property, index) => property !== rawCardProps[index]) + return changed ? normalized : undefined + })() + if ( + migratedCardProps !== undefined || + !inlineAgentsMigrated || + !expandedCardPropsMigrated || + !jiraIssueCardPropDefaulted + ) { + markNeedsSave() + } + const rawExplorerView = parsed.ui?.rightSidebarExplorerView + const rightSidebarExplorerView = normalizeRightSidebarExplorerView( + rawExplorerView, + parsed.ui?.rightSidebarTab + ) + // Why: without a dirty mark the legacy "Search tab, no explorer view" repair stays + // in memory only, so a profile that never writes again redoes it on every launch. + if ( + rawExplorerView === undefined + ? rightSidebarExplorerView !== defaults.ui.rightSidebarExplorerView + : rawExplorerView !== rightSidebarExplorerView + ) { + markNeedsSave() + } + const setupGuideSidebarDismissed = resolveSetupGuideSidebarDismissedOnLoad( + parsed.ui?.setupGuideSidebarDismissed, + normalizedOnboarding + ) + if ( + parsed.ui?.setupGuideSidebarDismissed !== setupGuideSidebarDismissed && + (setupGuideSidebarDismissed || parsed.ui?.setupGuideSidebarDismissed !== undefined) + ) { + markNeedsSave() + } + // Why: only upgraded profiles still on the new default get the one-time usage-display notice; fresh profiles stay quiet. + const usagePercentageDisplayChangeNoticeDismissed = + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: parsed.ui?.usagePercentageDisplayChangeNoticeDismissed, + rawUsagePercentageDisplay: parsed.ui?.usagePercentageDisplay, + isExistingProfile: isExistingPersistedProfile({ + repoCount: parsed.repos?.length ?? 0, + onboardingClosedAt: normalizedOnboarding.closedAt, + ui: parsed.ui + }) + }) + if ( + parsed.ui?.usagePercentageDisplayChangeNoticeDismissed !== + usagePercentageDisplayChangeNoticeDismissed + ) { + markNeedsSave() + } + return { + ...defaults.ui, + // Why: missing card properties follow the persisted layout mode; explicit choices are preserved below. + worktreeCardProperties: getWorktreeCardModeProperties( + loadedCompactWorktreeCards ? 'Compact' : 'Default' + ), + ...stripMainOwnedTelemetryMarkerFromUI(parsed.ui), + // Why: migrate once from the retired Appearance setting only when no explicit chrome preference exists yet. + rightSidebarOpen, + rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), + // Why here and not in getPersistedUI: only the raw payload still shows the legacy + // "Search tab, no explorer view" shape — the defaults spread above fills in 'files'. + rightSidebarExplorerView, + setupGuideSidebarDismissed, + usagePercentageDisplayChangeNoticeDismissed, + setupGuideBrowserMilestoneMigrated: + typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' + ? parsed.ui.setupGuideBrowserMilestoneMigrated + : false, + setupGuideBrowserMilestoneLegacyComplete: + parsed.ui?.setupGuideBrowserMilestoneLegacyComplete === true, + // Why persist rather than notify inline: the flip lands during load, before any + // window exists, and it must survive a crash before the user ever sees the notice. + osc52ClipboardDefaultOnNoticePending: osc52ClipboardNoticePending, + sortBy: migrate ? ('smart' as const) : sort, + showDotfilesByWorktree: normalizeShowDotfilesByWorktree(parsed.ui?.showDotfilesByWorktree), + workspaceStatuses, + _workspaceStatusesDefaultOrderMigrated: true, + _workspaceStatusesReorderedDefaultRepaired: true, + _workspaceStatusesDefaultWorkflowMigrated: true, + _workspaceStatusesDefaultVisualsMigrated: true, + _sortBySmartMigrated: true, + ...(migratedCardProps !== undefined ? { worktreeCardProperties: migratedCardProps } : {}), + // Why: keep stamping the legacy flag for rollback forward-compat; the new flag actually gates the migration. + _inlineAgentsDefaultedForExperiment: true, + _inlineAgentsDefaultedForAllUsers: true, + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true + } +} diff --git a/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts b/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts new file mode 100644 index 00000000000..2410e68be54 --- /dev/null +++ b/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts @@ -0,0 +1,255 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import { deriveGlobalWindowsRuntimeDefaultFromLegacySettings } from '../../../shared/project-execution-runtime' +import { normalizeTaskProviderSettings } from '../../../shared/task-providers' +import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings' +import { + addMobilePairingCustomAddress, + normalizeMobilePairingCustomAddress, + normalizeMobilePairingCustomAddresses +} from '../../../shared/mobile-pairing-custom-address' +import { normalizeSourceControlGroupOrder } from '../../../shared/source-control-group-order' +import { normalizeProjectGroups } from '../../../shared/project-groups' +import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' +import { hasUnsupportedTuiAgentArgs } from '../../../shared/tui-agent-launch-defaults' +import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' +import { normalizeTerminalLineHeight } from '../../../shared/terminal-line-height-settings' +import { migrateAgentYoloDefaults } from '../applying-settings/terminal-settings-migrations' +import { + normalizeLoadedOnboardingState, + normalizeNotificationSettings, + persistedNotificationSettingsRepaired +} from '../applying-settings/onboarding-normalization' + +export type PreparedLoadedProfileSettings = { + migratedExperimentalActivity: GlobalSettings['experimentalActivity'] + migratedAutoRenameBranchFromWork: Pick< + GlobalSettings, + 'autoRenameBranchFromWork' | 'autoRenameBranchFromWorkDefaultedOn' + > + migratedTerminalCursorStyle: Pick< + GlobalSettings, + 'terminalCursorStyle' | 'terminalCursorStyleDefaultedToBlock' + > + migratedTerminalLineHeight: GlobalSettings['terminalLineHeight'] + terminalRightClickToPasteDefaultedForPlatform: boolean + taskProviderSettings: Pick + primarySelectionDefaultedForLinux: boolean + primarySelectionDefaultedForTerminalDefaults: boolean + migratePrimarySelectionPlatformDefault: boolean + stampPrimarySelectionTerminalDefaults: boolean + migratedDisabledTuiAgents: GlobalSettings['disabledTuiAgents'] + migratedAgentYoloDefaults: Pick< + GlobalSettings, + 'agentDefaultArgs' | 'agentDefaultEnv' | 'agentYoloDefaultsMigrated' + > + migratedWindowsRuntimeDefault: GlobalSettings['localWindowsRuntimeDefault'] + migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] + loadedCompactWorktreeCards: boolean + mobilePairingCustomAddress: GlobalSettings['mobilePairingCustomAddress'] + mobilePairingCustomAddresses: GlobalSettings['mobilePairingCustomAddresses'] + normalizedNotifications: GlobalSettings['notifications'] + normalizedSourceControlGroupOrder: GlobalSettings['sourceControlGroupOrder'] + normalizedOnboarding: PersistedState['onboarding'] + normalizedProjectGroups: ProjectGroup[] +} + +export function prepareLoadedProfileSettings( + parsed: PersistedState, + defaults: PersistedState, + markNeedsSave: () => void +): PreparedLoadedProfileSettings { + const experimentalActivityDefaultedOffForAllUsers = + parsed.settings?.experimentalActivityDefaultedOffForAllUsers === true + // Why: the Agents view moved back behind Experimental; flip pre-migration profiles off once, then preserve opt-ins. + const migratedExperimentalActivity = experimentalActivityDefaultedOffForAllUsers + ? (parsed.settings?.experimentalActivity ?? false) + : false + const autoRenameBranchFromWorkDefaultedOn = + parsed.settings?.autoRenameBranchFromWorkDefaultedOn === true + // Why: default-on rollout activates old profiles once, but a later Settings opt-out survives reloads. + const migratedAutoRenameBranchFromWork = normalizeAutoRenameBranchFromWorkDefaultOn( + parsed.settings + ) + const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings) + if ( + parsed.settings?.terminalCursorStyle !== migratedTerminalCursorStyle.terminalCursorStyle || + parsed.settings?.terminalCursorStyleDefaultedToBlock !== true + ) { + markNeedsSave() + } + const migratedTerminalLineHeight = normalizeTerminalLineHeight( + parsed.settings?.terminalLineHeight + ) + const terminalRightClickToPasteDefaultedForPlatform = + parsed.settings?.terminalRightClickToPasteDefaultedForPlatform === true + if (!terminalRightClickToPasteDefaultedForPlatform) { + markNeedsSave() + } + if ( + parsed.settings?.terminalLineHeight !== undefined && + parsed.settings.terminalLineHeight !== migratedTerminalLineHeight + ) { + markNeedsSave() + } + const rawTaskProviderSettings = normalizeTaskProviderSettings({ + visibleTaskProviders: parsed.settings?.visibleTaskProviders, + defaultTaskSource: parsed.settings?.defaultTaskSource + }) + const visibleTaskProvidersDefaultedForJira = + parsed.settings?.visibleTaskProvidersDefaultedForJira === true + const migratedVisibleTaskProviders = visibleTaskProvidersDefaultedForJira + ? rawTaskProviderSettings.visibleTaskProviders + : rawTaskProviderSettings.visibleTaskProviders.includes('jira') + ? rawTaskProviderSettings.visibleTaskProviders + : [...rawTaskProviderSettings.visibleTaskProviders, 'jira' as const] + const taskProviderSettings = normalizeTaskProviderSettings({ + visibleTaskProviders: migratedVisibleTaskProviders, + defaultTaskSource: rawTaskProviderSettings.defaultTaskSource + }) + const primarySelectionDefaultedForLinux = + parsed.settings?.primarySelectionMiddleClickPasteDefaultedForLinux === true + const primarySelectionDefaultedForTerminalDefaults = + parsed.settings?.primarySelectionMiddleClickPasteDefaultedForTerminalDefaults === true + const primarySelectionPlatformDefaultEnabled = + defaults.settings.primarySelectionMiddleClickPaste === true + const primarySelectionAlreadyDefaultedForPlatform = + primarySelectionDefaultedForTerminalDefaults || + (process.platform === 'linux' && primarySelectionDefaultedForLinux) + const migratePrimarySelectionPlatformDefault = + primarySelectionPlatformDefaultEnabled && !primarySelectionAlreadyDefaultedForPlatform + const stampPrimarySelectionTerminalDefaults = + primarySelectionPlatformDefaultEnabled && !primarySelectionDefaultedForTerminalDefaults + if (migratePrimarySelectionPlatformDefault || stampPrimarySelectionTerminalDefaults) { + markNeedsSave() + } + if (!visibleTaskProvidersDefaultedForJira) { + markNeedsSave() + } + const claudeAgentTeamsDefaultDisabledMigrated = + parsed.settings?.claudeAgentTeamsDefaultDisabledMigrated === true + if (!claudeAgentTeamsDefaultDisabledMigrated) { + markNeedsSave() + } + const migratedDisabledTuiAgents = normalizeDisabledTuiAgents(parsed.settings?.disabledTuiAgents) + const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings) + if ( + parsed.settings?.agentYoloDefaultsMigrated !== true || + hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) || + hasUnsupportedTuiAgentArgs('kilo', parsed.settings?.agentDefaultArgs?.kilo) + ) { + markNeedsSave() + } + if ( + !claudeAgentTeamsDefaultDisabledMigrated && + !migratedDisabledTuiAgents.includes('claude-agent-teams') + ) { + migratedDisabledTuiAgents.push('claude-agent-teams') + } + const migratedWindowsRuntimeDefault = + parsed.settings?.localWindowsRuntimeDefault === undefined + ? deriveGlobalWindowsRuntimeDefaultFromLegacySettings(parsed.settings).defaultRuntime + : parsed.settings.localWindowsRuntimeDefault + if ( + parsed.settings?.localWindowsRuntimeDefault === undefined && + migratedWindowsRuntimeDefault.kind === 'wsl' + ) { + markNeedsSave() + } + // Why (#9537): migrate the indistinguishable legacy host default once so WSL-default users follow their runtime. + const localAccountRuntimeAlreadyMigrated = + parsed.settings?.localAccountRuntimeDefaultedToAutoForAllUsers === true + const migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] = + localAccountRuntimeAlreadyMigrated + ? (parsed.settings?.localAccountRuntime ?? defaults.settings.localAccountRuntime) + : parsed.settings?.localAccountRuntime === 'wsl' + ? 'wsl' + : 'auto' + if (!localAccountRuntimeAlreadyMigrated) { + markNeedsSave() + } + if (!autoRenameBranchFromWorkDefaultedOn) { + markNeedsSave() + } + const normalizedOnboarding = normalizeLoadedOnboardingState( + parsed.onboarding, + defaults.onboarding + ) + if (!parsed.onboarding) { + markNeedsSave() + } + const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups) + const loadedCompactWorktreeCards = + parsed.settings?.compactWorktreeCards ?? + parsed.settings?.experimentalCompactWorktreeCards ?? + defaults.settings.compactWorktreeCards + const mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( + parsed.settings?.mobilePairingCustomAddress + ) + const rawMobilePairingCustomAddresses = parsed.settings?.mobilePairingCustomAddresses + const mobilePairingCustomAddresses = mobilePairingCustomAddress + ? addMobilePairingCustomAddress( + normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses), + mobilePairingCustomAddress + ) + : normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses) + if ( + parsed.settings?.mobilePairingCustomAddress !== undefined && + parsed.settings.mobilePairingCustomAddress !== mobilePairingCustomAddress + ) { + markNeedsSave() + } + const customAddressesMatch = + Array.isArray(rawMobilePairingCustomAddresses) && + rawMobilePairingCustomAddresses.length === mobilePairingCustomAddresses.length && + rawMobilePairingCustomAddresses.every( + (address, index) => address === mobilePairingCustomAddresses[index] + ) + if ( + (rawMobilePairingCustomAddresses !== undefined || mobilePairingCustomAddress !== null) && + !customAddressesMatch + ) { + markNeedsSave() + } + const normalizedNotifications = normalizeNotificationSettings(parsed.settings?.notifications) + // Why: a type-flipped notification field is repaired in memory only; without a dirty mark the + // bad value stays on disk and the repair reruns on every launch. + if ( + persistedNotificationSettingsRepaired(parsed.settings?.notifications, normalizedNotifications) + ) { + markNeedsSave() + } + const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( + parsed.settings?.sourceControlGroupOrder + ) + if ( + parsed.settings?.sourceControlGroupOrder !== undefined && + parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder + ) { + markNeedsSave() + } + return { + migratedExperimentalActivity, + migratedAutoRenameBranchFromWork, + migratedTerminalCursorStyle, + migratedTerminalLineHeight, + terminalRightClickToPasteDefaultedForPlatform, + taskProviderSettings, + primarySelectionDefaultedForLinux, + primarySelectionDefaultedForTerminalDefaults, + migratePrimarySelectionPlatformDefault, + stampPrimarySelectionTerminalDefaults, + migratedDisabledTuiAgents, + migratedAgentYoloDefaults, + migratedWindowsRuntimeDefault, + migratedLocalAccountRuntime, + loadedCompactWorktreeCards, + mobilePairingCustomAddress, + mobilePairingCustomAddresses, + normalizedNotifications, + normalizedSourceControlGroupOrder, + normalizedOnboarding, + normalizedProjectGroups + } +} diff --git a/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts b/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts new file mode 100644 index 00000000000..65bf099891d --- /dev/null +++ b/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts @@ -0,0 +1,172 @@ +import { homedir } from 'node:os' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { + GlobalSettings, + WorktreeVisibilityDefaults +} from '../../../shared/global-settings-types' +import type { Repo } from '../../../shared/repo-types' +import { getDefaultPersistedState } from '../../../shared/constants' +import { migrateExternalWorktreeVisibilityDefaults } from '../../../shared/external-worktree-visibility' +import { + mergeLegacyCommitMessageAiIntoSourceControlAi, + sourceControlAiSettingsFromLegacy +} from '../../../shared/source-control-ai' +import { + normalizeOsc52ClipboardDefaultOn, + osc52ClipboardDefaultOnOverridesPersistedOff +} from '../../../shared/osc52-clipboard-settings' +import { + migrateTerminalScrollbackRows, + migrateTerminalTuiScrollSensitivityDefault +} from '../applying-settings/terminal-settings-migrations' +import { + canonicalizePersistedFloatingWorkspaceDirectory, + normalizeFloatingWorkspaceTrustedCwds +} from '../restoring-sessions/floating-workspace-normalization' + +export type PreparedLoadedTerminalSettings = { + defaults: PersistedState + migratedExternalVisibility: { + repos: Repo[] + defaults: WorktreeVisibilityDefaults + changed: boolean + } + migratedTerminalScrollback: { rows: number; needsSave: boolean } + migratedTerminalTuiScrollSensitivity: { + settings: Pick< + GlobalSettings, + 'terminalTuiScrollSensitivity' | 'terminalTuiScrollSensitivityDefaultedToOne' + > + needsSave: boolean + } + migratedSourceControlAi: NonNullable + migratedOptionAsAlt: GlobalSettings['terminalMacOptionAsAlt'] + migratedFloatingTerminalEnabled: GlobalSettings['floatingTerminalEnabled'] + migratedOsc52Clipboard: Pick< + GlobalSettings, + 'terminalAllowOsc52Clipboard' | 'terminalAllowOsc52ClipboardDefaultedOnForAllUsers' + > + migratedFloatingTerminalCwd: GlobalSettings['floatingTerminalCwd'] + migratedFloatingTerminalTrustedCwds: GlobalSettings['floatingTerminalTrustedCwds'] + osc52ClipboardNoticePending: boolean +} + +export function prepareLoadedTerminalSettings( + parsed: PersistedState, + markNeedsSave: () => void +): PreparedLoadedTerminalSettings { + // Merge with defaults in case new fields were added + const homeDir = homedir() + const defaults = getDefaultPersistedState(homeDir) + const migratedExternalVisibility = migrateExternalWorktreeVisibilityDefaults( + Array.isArray(parsed.repos) ? parsed.repos : [], + parsed.settings?.worktreeVisibilityDefaults + ) + if (migratedExternalVisibility.changed) { + markNeedsSave() + } + const migratedTerminalScrollback = migrateTerminalScrollbackRows(parsed.settings) + if (migratedTerminalScrollback.needsSave) { + markNeedsSave() + } + if ( + parsed.settings && + typeof parsed.settings === 'object' && + Object.hasOwn(parsed.settings, 'enableGitHubAttribution') + ) { + markNeedsSave() + } + const migratedTerminalTuiScrollSensitivity = migrateTerminalTuiScrollSensitivityDefault( + parsed.settings + ) + if (migratedTerminalTuiScrollSensitivity.needsSave) { + markNeedsSave() + } + const rawSourceControlAi = parsed.settings?.sourceControlAi + const rawSourceControlAiMissing = rawSourceControlAi === undefined + const rawSourceControlAiActionsMissing = + rawSourceControlAi !== undefined && rawSourceControlAi.actions === undefined + if (rawSourceControlAiMissing || rawSourceControlAiActionsMissing) { + markNeedsSave() + } + const legacyCommitMessageAi = parsed.settings?.commitMessageAi + const migratedSourceControlAi = rawSourceControlAiMissing + ? sourceControlAiSettingsFromLegacy(legacyCommitMessageAi ?? defaults.settings.commitMessageAi) + : mergeLegacyCommitMessageAiIntoSourceControlAi( + parsed.settings?.sourceControlAi, + legacyCommitMessageAi + ) + // Why (issue #903): old 'true' default broke non-US Option-layer chars; flip 'true'→'auto' once so the layout probe decides. + const rawOptionAsAlt = parsed.settings?.terminalMacOptionAsAlt + const alreadyMigrated = parsed.settings?.terminalMacOptionAsAltMigrated === true + const migratedOptionAsAlt: 'auto' | 'true' | 'false' | 'left' | 'right' = alreadyMigrated + ? (rawOptionAsAlt ?? 'auto') + : rawOptionAsAlt === undefined || rawOptionAsAlt === 'true' + ? 'auto' + : rawOptionAsAlt + const floatingTerminalDefaultedForAllUsers = + parsed.settings?.floatingTerminalDefaultedForAllUsers === true + // Why: early builds persisted the old off default; flip only unmigrated profiles so a later opt-out survives reload. + const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers + ? (parsed.settings?.floatingTerminalEnabled ?? true) + : true + // Why: the old off default persisted `false` for every profile, indistinguishable from a real opt-out — flip unmigrated profiles once (#10567). + const migratedOsc52Clipboard = normalizeOsc52ClipboardDefaultOn(parsed.settings) + const osc52ClipboardNoticePending = + osc52ClipboardDefaultOnOverridesPersistedOff(parsed.settings) || + parsed.ui?.osc52ClipboardDefaultOnNoticePending === true + if (parsed.settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== true) { + markNeedsSave() + } + const floatingTerminalCwdMigrated = + parsed.settings?.floatingTerminalCwdMigratedToAppWorkspace === true + // Why: an earlier migration wrote '' for the notes dir; floating terminals still open at home, notes use a separate IPC. + const migratedFloatingTerminalCwd = floatingTerminalCwdMigrated + ? !parsed.settings?.floatingTerminalCwd + ? defaults.settings.floatingTerminalCwd + : parsed.settings.floatingTerminalCwd + : parsed.settings?.floatingTerminalCwd === undefined + ? defaults.settings.floatingTerminalCwd + : parsed.settings.floatingTerminalCwd + const normalizedFloatingTerminalTrustedCwds = normalizeFloatingWorkspaceTrustedCwds( + parsed.settings?.floatingTerminalTrustedCwds, + homeDir + ) + const migratedFloatingTerminalTrustedCwds = [...normalizedFloatingTerminalTrustedCwds.trustedCwds] + const rawLegacyFloatingTerminalCwd = parsed.settings?.floatingTerminalCwd + const shouldTrustLegacyFloatingTerminalCwd = + !floatingTerminalCwdMigrated && + typeof rawLegacyFloatingTerminalCwd === 'string' && + rawLegacyFloatingTerminalCwd.trim().length > 0 && + rawLegacyFloatingTerminalCwd.trim() !== '~' + if (!floatingTerminalCwdMigrated) { + markNeedsSave() + } + if (shouldTrustLegacyFloatingTerminalCwd && rawLegacyFloatingTerminalCwd) { + const canonicalLegacyCwd = canonicalizePersistedFloatingWorkspaceDirectory( + rawLegacyFloatingTerminalCwd, + homeDir + ) + if (canonicalLegacyCwd && !migratedFloatingTerminalTrustedCwds.includes(canonicalLegacyCwd)) { + // Why: pre-grant profiles with an explicit Floating Workspace cwd already showed intent; migrate only that legacy value. + migratedFloatingTerminalTrustedCwds.push(canonicalLegacyCwd) + normalizedFloatingTerminalTrustedCwds.changed = true + } + } + if (normalizedFloatingTerminalTrustedCwds.changed) { + markNeedsSave() + } + return { + defaults, + migratedExternalVisibility, + migratedTerminalScrollback, + migratedTerminalTuiScrollSensitivity, + migratedSourceControlAi, + migratedOptionAsAlt, + migratedFloatingTerminalEnabled, + migratedOsc52Clipboard, + migratedFloatingTerminalCwd, + migratedFloatingTerminalTrustedCwds, + osc52ClipboardNoticePending + } +} diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts new file mode 100644 index 00000000000..9d6e476ebe5 --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -0,0 +1,282 @@ +import { mkdirSync, existsSync, unlinkSync } from 'node:fs' +import { mkdir, open, rm } from 'node:fs/promises' +import { durableWriteTempPath, renameDurable, writeFileDurableSync } from '../../durable-file-write' +import { dirname } from 'node:path' +import { + parseCodexResetCreditAttemptLedger, + type CodexResetCreditAttemptLedger +} from '../../../shared/codex-reset-credit-attempt-ledger' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' +import type { BackupRecoveryRotationOperations } from './backup-recovery-rotation' + +type PrimaryStateWriteOperationsRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'backupRotationInFlight' + | 'dataFile' + | 'flushOrThrow' + | 'firstPendingSaveAt' + | 'inFlightAsyncTmpFile' + | 'lastDurableWriteGeneration' + | 'lastWrittenStateHash' + | 'pendingSnapshotFileWork' + | 'pendingWrite' + | 'protectedSecrets' + | 'quitFlushStarted' + | 'staleTempCleanup' + | 'state' + | 'writeGeneration' + | 'writeTimer' + | 'writesFrozen' +> + +const primaryStateWriteOperationsContext = Symbol('PrimaryStateWriteOperations') +type PrimaryStateWriteOperationsContext = { + runtime: PrimaryStateWriteOperationsRuntime + serialization: StateSerializationSecretHandlingOperations + backups: BackupRecoveryRotationOperations +} + +export class PrimaryStateWriteOperations { + readonly [primaryStateWriteOperationsContext]: PrimaryStateWriteOperationsContext + + constructor( + runtime: PrimaryStateWriteOperationsRuntime, + serialization: StateSerializationSecretHandlingOperations, + backups: BackupRecoveryRotationOperations + ) { + this[primaryStateWriteOperationsContext] = { runtime, serialization, backups } + } + + flushOrThrow(): void { + if (this[primaryStateWriteOperationsContext].runtime.quitFlushStarted) { + throw new Error('Cannot synchronously flush after final persistence has started') + } + if (this[primaryStateWriteOperationsContext].runtime.writeTimer) { + clearTimeout(this[primaryStateWriteOperationsContext].runtime.writeTimer) + this[primaryStateWriteOperationsContext].runtime.writeTimer = null + } + this[primaryStateWriteOperationsContext].runtime.firstPendingSaveAt = null + const asyncWriteWasInFlight = + this[primaryStateWriteOperationsContext].runtime.pendingWrite !== null + // Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write. + this[primaryStateWriteOperationsContext].runtime.writeGeneration++ + if (this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile) { + try { + unlinkSync(this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile) + this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = null + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + void enqueueWrite(this).catch(() => {}) + throw error + } + } + } + // Why: later async flushes must remain serialized behind the invalidated writer. + writeToDiskSync(this, { + force: asyncWriteWasInFlight, + skipBackupRotation: this[primaryStateWriteOperationsContext].runtime.backupRotationInFlight + }) + } + + flushActiveViewPreferenceOrThrow(): void { + this[primaryStateWriteOperationsContext].runtime.activeViewPreference.flushOrThrow() + } + + getCodexResetCreditAttemptLedger(): CodexResetCreditAttemptLedger { + return parseCodexResetCreditAttemptLedger( + this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger + ) + } + + replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void { + if (this[primaryStateWriteOperationsContext].runtime.writesFrozen) { + throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen') + } + const next = parseCodexResetCreditAttemptLedger(ledger) + const previous = this[primaryStateWriteOperationsContext].runtime.state + .codexResetCreditAttemptLedger + ? structuredClone( + this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger + ) + : undefined + this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger = next + try { + this[primaryStateWriteOperationsContext].runtime.flushOrThrow() + } catch (error) { + // Why: callers use a successful return as the durability barrier before + // handing a scarce-credit mutation to the provider. + this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger = + previous + throw error + } + } +} + +export function enqueueWrite(owner: PrimaryStateWriteOperations): Promise { + const previousWrite = Promise.all([ + owner[primaryStateWriteOperationsContext].runtime.pendingWrite ?? + owner[primaryStateWriteOperationsContext].runtime.staleTempCleanup, + owner[primaryStateWriteOperationsContext].runtime.pendingSnapshotFileWork ?? Promise.resolve() + ]).then(() => {}) + const write = previousWrite.then(() => writeToDiskAsync(owner)) + const trackedWrite = write + .catch((err) => { + console.error('[persistence] Failed to write state:', err) + }) + .finally(() => { + if (owner[primaryStateWriteOperationsContext].runtime.pendingWrite === trackedWrite) { + owner[primaryStateWriteOperationsContext].runtime.pendingWrite = null + } + }) + owner[primaryStateWriteOperationsContext].runtime.pendingWrite = trackedWrite + return write +} + +export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Promise { + if (owner[primaryStateWriteOperationsContext].runtime.writesFrozen) { + return + } + const gen = owner[primaryStateWriteOperationsContext].runtime.writeGeneration + const { payload, stateHash, protectedSecretUpdates } = + owner[primaryStateWriteOperationsContext].serialization.buildStateToSave() + // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. + if (stateHash === owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash) { + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, + gen + ) + return + } + const dataFile = owner[primaryStateWriteOperationsContext].runtime.dataFile + const dir = dirname(dataFile) + await mkdir(dir, { recursive: true }).catch(() => {}) + const tmpFile = durableWriteTempPath(dataFile) + + // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. + let renamed = false + try { + // Why: fsync before rename, then fsync the directory; see writeFileDurable. + const handle = await open(tmpFile, 'w') + try { + await handle.writeFile(payload, 'utf-8') + await handle.sync() + } finally { + await handle.close() + } + // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. + if (owner[primaryStateWriteOperationsContext].runtime.writeGeneration !== gen) { + return + } + owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = tmpFile + try { + await renameDurable(tmpFile, dataFile) + renamed = true + } catch (error) { + if ( + (error as NodeJS.ErrnoException).code !== 'ENOENT' || + owner[primaryStateWriteOperationsContext].runtime.writeGeneration === gen + ) { + throw error + } + } finally { + if (owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile === tmpFile) { + owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = null + } + } + // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. + if (renamed && owner[primaryStateWriteOperationsContext].runtime.writeGeneration === gen) { + owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = stateHash + owner[primaryStateWriteOperationsContext].runtime.protectedSecrets.commitRetentionUpdates( + protectedSecretUpdates + ) + } else if (renamed) { + owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = null + } + if (renamed) { + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, + gen + ) + } + } finally { + if (!renamed) { + await rm(tmpFile).catch(() => {}) + } + } + if (!renamed) { + return + } + // Why (#1158): rotate only after the primary rename while this write still owns its generation. + if (owner[primaryStateWriteOperationsContext].runtime.writeGeneration !== gen) { + return + } + await owner[primaryStateWriteOperationsContext].backups.rotateBackupsAsync(dataFile) +} + +export function writeToDiskSync( + owner: PrimaryStateWriteOperations, + opts: { force?: boolean; skipBackupRotation?: boolean } = {} +): void { + if (owner[primaryStateWriteOperationsContext].runtime.writesFrozen) { + return + } + const { payload, stateHash, protectedSecretUpdates } = + owner[primaryStateWriteOperationsContext].serialization.buildStateToSave() + // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. + if ( + !opts.force && + stateHash === owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash + ) { + return + } + const dataFile = owner[primaryStateWriteOperationsContext].runtime.dataFile + const dir = dirname(dataFile) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + + // Why: on any write/rename failure, remove the tmp file so shutdown crashes don't leak orphans. + let renamed = false + try { + // Why: fsync the temp file and the directory; a bare rename can survive as stale or empty + // content after power loss, losing projects/tabs back to the newest usable .bak slot. + writeFileDurableSync(tmpFile, dataFile, payload) + renamed = true + owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = stateHash + owner[primaryStateWriteOperationsContext].runtime.protectedSecrets.commitRetentionUpdates( + protectedSecretUpdates + ) + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, + owner[primaryStateWriteOperationsContext].runtime.writeGeneration + ) + } finally { + if (!renamed) { + try { + unlinkSync(tmpFile) + } catch { + // Best-effort cleanup; the write already failed, swallow secondary error. + } + } + } + const now = Date.now() + if ( + !opts.skipBackupRotation && + owner[primaryStateWriteOperationsContext].backups.shouldRotateBackups(now, dataFile) + ) { + owner[primaryStateWriteOperationsContext].backups.rotateBackupsSync(dataFile) + } +} + +export function installPrimaryStateWriteOperationsContext( + target: object, + source: PrimaryStateWriteOperations +): void { + Object.defineProperty(target, primaryStateWriteOperationsContext, { + value: source[primaryStateWriteOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/profile-preferences.ts b/src/main/persistence/loading-store/profile-preferences.ts new file mode 100644 index 00000000000..228229d2584 --- /dev/null +++ b/src/main/persistence/loading-store/profile-preferences.ts @@ -0,0 +1,194 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { OnboardingChecklistState } from '../../../shared/onboarding-state-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { getDefaultOnboardingState } from '../../../shared/constants' +import type { FeatureInteractionId } from '../../../shared/feature-interactions' +import { + updateSettings as updateSettingsOperation, + type SettingsMutationOperations +} from '../applying-settings/settings-update' +import { getPersistedUI } from '../applying-settings/ui-state-read' +import { updatePersistedUI, type UIUpdateOperations } from '../applying-settings/ui-state-update' +import { + recordFeatureInteraction as recordFeatureInteractionOperation, + type FeatureInteractionOperations +} from '../applying-settings/feature-interaction-recording' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type ProfilePreferencesRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'githubCacheDirty' + | 'githubCacheGeneration' + | 'protectedSecrets' + | 'settingsChangeListeners' + | 'state' + | 'uiChangeListeners' +> + +const profilePreferencesContext = Symbol('ProfilePreferences') +type ProfilePreferencesContext = { + runtime: ProfilePreferencesRuntime + scheduling: WriteSchedulingOperations +} + +export class ProfilePreferences { + readonly [profilePreferencesContext]: ProfilePreferencesContext + + constructor(runtime: ProfilePreferencesRuntime, scheduling: WriteSchedulingOperations) { + this[profilePreferencesContext] = { runtime, scheduling } + } + + getSettings(): GlobalSettings { + return this[profilePreferencesContext].runtime.state.settings + } + + onSettingsChanged( + listener: ( + updates: Partial, + settings: GlobalSettings, + originWebContentsId?: number + ) => void + ): () => void { + this[profilePreferencesContext].runtime.settingsChangeListeners.add(listener) + return () => { + this[profilePreferencesContext].runtime.settingsChangeListeners.delete(listener) + } + } + + onUIChanged(listener: (ui: PersistedState['ui']) => void): () => void { + this[profilePreferencesContext].runtime.uiChangeListeners.add(listener) + return () => { + this[profilePreferencesContext].runtime.uiChangeListeners.delete(listener) + } + } + + updateSettings( + updates: Partial, + options: { notifyListeners?: boolean; originWebContentsId?: number } = {} + ): GlobalSettings { + return updateSettingsOperation(getSettingsMutationOperations(this), updates, options) + } + + getUI(): PersistedState['ui'] { + return getPersistedUI( + this[profilePreferencesContext].runtime.state, + this[profilePreferencesContext].runtime.activeViewPreference.get() + ) + } + + updateUI(updates: Partial): void { + updatePersistedUI(getUIUpdateOperations(this), updates) + } + + recordFeatureInteraction(id: FeatureInteractionId): PersistedState['ui'] { + return recordFeatureInteractionOperation(getFeatureInteractionOperations(this), id) + } + + getOnboarding(): PersistedState['onboarding'] { + const defaults = getDefaultOnboardingState() + return { + ...defaults, + ...this[profilePreferencesContext].runtime.state.onboarding, + checklist: { + ...defaults.checklist, + ...this[profilePreferencesContext].runtime.state.onboarding?.checklist + } + } + } + + updateOnboarding( + updates: Partial> & { + checklist?: Partial + } + ): PersistedState['onboarding'] { + const current = this.getOnboarding() + this[profilePreferencesContext].runtime.state.onboarding = { + ...current, + ...updates, + checklist: { + ...current.checklist, + ...updates.checklist + } + } + scheduleSave(this[profilePreferencesContext].scheduling) + return this.getOnboarding() + } + + getGitHubCache(): PersistedState['githubCache'] { + return this[profilePreferencesContext].runtime.state.githubCache + } + + setGitHubCache(cache: PersistedState['githubCache']): void { + // Why no scheduleSave: cache is memory-only and snapshotted to a sidecar at flush; persisting here rewrote the whole state file every poll cycle. + this[profilePreferencesContext].runtime.state.githubCache = cache + this[profilePreferencesContext].runtime.githubCacheDirty = true + this[profilePreferencesContext].runtime.githubCacheGeneration += 1 + } +} + +export function notifySettingsChanged( + owner: ProfilePreferences, + updates: Partial, + originWebContentsId?: number +): void { + for (const listener of owner[profilePreferencesContext].runtime.settingsChangeListeners) { + listener(updates, owner[profilePreferencesContext].runtime.state.settings, originWebContentsId) + } +} + +export function notifyUIChanged(owner: ProfilePreferences): void { + if (owner[profilePreferencesContext].runtime.uiChangeListeners.size === 0) { + return + } + const ui = owner.getUI() + for (const listener of owner[profilePreferencesContext].runtime.uiChangeListeners) { + listener(ui) + } +} + +export function getSettingsMutationOperations( + owner: ProfilePreferences +): SettingsMutationOperations { + return { + state: owner[profilePreferencesContext].runtime.state, + removeRetainedBlob: (slot) => + owner[profilePreferencesContext].runtime.protectedSecrets.removeRetainedBlob(slot), + scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + notifySettingsChanged: (updates, originWebContentsId) => + notifySettingsChanged(owner, updates, originWebContentsId) + } +} + +export function getUIUpdateOperations(owner: ProfilePreferences): UIUpdateOperations { + return { + state: owner[profilePreferencesContext].runtime.state, + removeRetainedBlob: (slot) => + owner[profilePreferencesContext].runtime.protectedSecrets.removeRetainedBlob(slot), + setActiveView: (activeView) => + owner[profilePreferencesContext].runtime.activeViewPreference.set(activeView), + getUI: () => owner.getUI(), + scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + notifyUIChanged: () => notifyUIChanged(owner) + } +} + +export function getFeatureInteractionOperations( + owner: ProfilePreferences +): FeatureInteractionOperations { + return { + state: owner[profilePreferencesContext].runtime.state, + scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + notifyUIChanged: () => notifyUIChanged(owner), + getUI: () => owner.getUI() + } +} + +export function installProfilePreferencesContext(target: object, source: ProfilePreferences): void { + Object.defineProperty(target, profilePreferencesContext, { + value: source[profilePreferencesContext] + }) +} diff --git a/src/main/persistence/loading-store/project-collection-operations.ts b/src/main/persistence/loading-store/project-collection-operations.ts new file mode 100644 index 00000000000..e7100478037 --- /dev/null +++ b/src/main/persistence/loading-store/project-collection-operations.ts @@ -0,0 +1,233 @@ +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { + Project, + ProjectHostSetup, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, + ProjectUpdateArgs +} from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import { ProjectGroupPersistenceOperations } from '../tracking-repos/project-group-operations' +import { FolderWorkspacePersistenceOperations } from '../restoring-sessions/folder-workspace-operations' +import { ProjectHostPersistenceOperations } from '../tracking-repos/project-host-operations' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { RepoLifecycleOperations } from './repo-lifecycle-operations' +import type { WriteSchedulingOperations } from './write-scheduling' +import type { MetadataLineageOperations } from './metadata-lineage-operations' +import { + hydrateRepo, + updateRepoBackedProjectHostSetup, + updateIndependentProjectHostSetup, + pruneMobileClientTabSelections +} from './repo-lifecycle-operations' +import { scheduleSave } from './write-scheduling' +import { removeWorkspaceLineageForFolderParent } from './metadata-lineage-operations' + +type ProjectCollectionOperationsRuntime = Pick< + StoreRuntimeState, + | 'folderWorkspaceOperations' + | 'gitUsernameCache' + | 'projectGroupOperations' + | 'projectHostOperations' + | 'state' +> + +const projectCollectionOperationsContext = Symbol('ProjectCollectionOperations') +type ProjectCollectionOperationsContext = { + runtime: ProjectCollectionOperationsRuntime + repos: RepoLifecycleOperations + scheduling: WriteSchedulingOperations + metadata: MetadataLineageOperations +} + +export class ProjectCollectionOperations { + readonly [projectCollectionOperationsContext]: ProjectCollectionOperationsContext + + constructor( + runtime: ProjectCollectionOperationsRuntime, + repos: RepoLifecycleOperations, + scheduling: WriteSchedulingOperations, + metadata: MetadataLineageOperations + ) { + this[projectCollectionOperationsContext] = { runtime, repos, scheduling, metadata } + } + + getRepos(): Repo[] { + return getProjectHostOperations(this).getRepos() + } + + getProjects(): Project[] { + return getProjectHostOperations(this).getProjects() + } + + updateProject(id: string, updates: ProjectUpdateArgs['updates']): Project | null { + return getProjectHostOperations(this).updateProject(id, updates) + } + + getProjectHostSetups(): ProjectHostSetup[] { + return getProjectHostOperations(this).getProjectHostSetups() + } + + createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult | null { + return getProjectHostOperations(this).createProjectHostSetup(args) + } + + updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | null { + return getProjectHostOperations(this).updateProjectHostSetup(args) + } + + deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null { + return getProjectHostOperations(this).deleteProjectHostSetup(args) + } + + getRepoCount(): number { + return getProjectHostOperations(this).getRepoCount() + } + + getRepo(id: string): Repo | undefined { + return getProjectHostOperations(this).getRepo(id) + } + + setResolvedRepoGitUsername( + target: Pick, + username: string + ): boolean { + return getProjectHostOperations(this).setResolvedRepoGitUsername(target, username) + } + + getProjectGroups(): ProjectGroup[] { + return getProjectGroupOperations(this).getProjectGroups() + } + + createProjectGroup( + input: Parameters[0] + ): ProjectGroup { + return getProjectGroupOperations(this).createProjectGroup(input) + } + + updateProjectGroup( + groupId: string, + updates: Parameters[1] + ): ProjectGroup | null { + return getProjectGroupOperations(this).updateProjectGroup(groupId, updates) + } + + deleteProjectGroup(groupId: string): boolean { + return getProjectGroupOperations(this).deleteProjectGroup(groupId) + } + + getFolderWorkspaces(): FolderWorkspace[] { + return getFolderWorkspaceOperations(this).getFolderWorkspaces() + } + + getFolderWorkspace(id: string): FolderWorkspace | undefined { + return getFolderWorkspaceOperations(this).getFolderWorkspace(id) + } + + createFolderWorkspace( + input: Parameters[0] + ): FolderWorkspace { + return getFolderWorkspaceOperations(this).createFolderWorkspace(input) + } + + updateFolderWorkspace( + id: string, + updates: Parameters[1] + ): FolderWorkspace | null { + return getFolderWorkspaceOperations(this).updateFolderWorkspace(id, updates) + } + + removeFolderWorkspace(id: string): boolean { + return getFolderWorkspaceOperations(this).removeFolderWorkspace(id) + } + + moveProjectToGroup(repoId: string, groupId: string | null, order?: number): Repo | null { + return getFolderWorkspaceOperations(this).moveProjectToGroup(repoId, groupId, order) + } +} + +export function getProjectHostOperations( + owner: ProjectCollectionOperations +): ProjectHostPersistenceOperations { + owner[projectCollectionOperationsContext].runtime.projectHostOperations ??= + new ProjectHostPersistenceOperations({ + state: owner[projectCollectionOperationsContext].runtime.state, + gitUsernameCache: owner[projectCollectionOperationsContext].runtime.gitUsernameCache, + hydrateRepo: (repo) => hydrateRepo(owner[projectCollectionOperationsContext].repos, repo), + updateRepoBackedProjectHostSetup: (setup, repo, updates) => + updateRepoBackedProjectHostSetup( + owner[projectCollectionOperationsContext].repos, + setup, + repo, + updates + ), + updateIndependentProjectHostSetup: (setup, updates) => + updateIndependentProjectHostSetup( + owner[projectCollectionOperationsContext].repos, + setup, + updates + ), + removeProjectForHost: (id, hostId) => + owner[projectCollectionOperationsContext].repos.removeProjectForHost(id, hostId), + scheduleSave: () => scheduleSave(owner[projectCollectionOperationsContext].scheduling) + }) + return owner[projectCollectionOperationsContext].runtime.projectHostOperations +} + +export function getProjectGroupOperations( + owner: ProjectCollectionOperations +): ProjectGroupPersistenceOperations { + owner[projectCollectionOperationsContext].runtime.projectGroupOperations ??= + new ProjectGroupPersistenceOperations({ + state: owner[projectCollectionOperationsContext].runtime.state, + scheduleSave: () => scheduleSave(owner[projectCollectionOperationsContext].scheduling), + removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => + removeWorkspaceLineageForFolderParent( + owner[projectCollectionOperationsContext].metadata, + folderWorkspaceId + ), + pruneMobileClientTabSelections: (matchesWorktreeId) => + pruneMobileClientTabSelections( + owner[projectCollectionOperationsContext].repos, + matchesWorktreeId + ) + }) + return owner[projectCollectionOperationsContext].runtime.projectGroupOperations +} + +export function getFolderWorkspaceOperations( + owner: ProjectCollectionOperations +): FolderWorkspacePersistenceOperations { + owner[projectCollectionOperationsContext].runtime.folderWorkspaceOperations ??= + new FolderWorkspacePersistenceOperations({ + state: owner[projectCollectionOperationsContext].runtime.state, + scheduleSave: () => scheduleSave(owner[projectCollectionOperationsContext].scheduling), + removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => + removeWorkspaceLineageForFolderParent( + owner[projectCollectionOperationsContext].metadata, + folderWorkspaceId + ), + pruneMobileClientTabSelections: (matchesWorktreeId) => + pruneMobileClientTabSelections( + owner[projectCollectionOperationsContext].repos, + matchesWorktreeId + ), + hydrateRepo: (repo) => hydrateRepo(owner[projectCollectionOperationsContext].repos, repo) + }) + return owner[projectCollectionOperationsContext].runtime.folderWorkspaceOperations +} + +export function installProjectCollectionOperationsContext( + target: object, + source: ProjectCollectionOperations +): void { + Object.defineProperty(target, projectCollectionOperationsContext, { + value: source[projectCollectionOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts new file mode 100644 index 00000000000..3c5ff20c480 --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -0,0 +1,240 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { + cloneLayoutNode, + layoutContainsLeafId +} from '../restoring-sessions/terminal-layout-normalization' +import { + cloneWorkspaceSessionState, + createMinimalPersistedTerminalTab +} from '../restoring-sessions/session-owner-fields' + +import type { PtyBindingSourceExpectation } from './store' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { SessionHostPartitionOperations } from './session-host-partitions' +import { resolveHostId } from './session-host-partitions' + +type PtyBindingPersistenceOperationsRuntime = Pick + +const ptyBindingPersistenceOperationsContext = Symbol('PtyBindingPersistenceOperations') +type PtyBindingPersistenceOperationsContext = { + runtime: PtyBindingPersistenceOperationsRuntime + sessions: SessionHostPartitionOperations +} + +export class PtyBindingPersistenceOperations { + readonly [ptyBindingPersistenceOperationsContext]: PtyBindingPersistenceOperationsContext + + constructor( + runtime: PtyBindingPersistenceOperationsRuntime, + sessions: SessionHostPartitionOperations + ) { + this[ptyBindingPersistenceOperationsContext] = { runtime, sessions } + } + + persistPtyBinding( + args: { + worktreeId: string + tabId: string + leafId: string + ptyId: string + incarnationId?: string + startupCwd?: string + expectedBinding?: { ptyId: string; incarnationId?: string } + expectedSourceBinding?: PtyBindingSourceExpectation + /** Set by host-initiated creates, which have no renderer session writer behind them. */ + hostAdmittedMembership?: boolean + }, + hostId?: string | null + ): boolean { + const resolvedHostId = resolveHostId(hostId) + const session = + this[ptyBindingPersistenceOperationsContext].sessions.getWorkspaceSession(resolvedHostId) + const paneKey = `${args.tabId}:${args.leafId}` + const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId + if (args.expectedSourceBinding) { + const expected = args.expectedSourceBinding + if (expected.tabId !== args.tabId) { + return false + } + const sourceTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === expected.tabId && candidate.worktreeId === bindingWorktreeId + ) + const sourceLayout = session.terminalLayoutsByTabId?.[expected.tabId] + const sourcePaneKey = `${expected.tabId}:${expected.leafId}` + if ( + !sourceTab || + sourceLayout?.ptyIdsByLeafId?.[expected.leafId] !== expected.ptyId || + !layoutContainsLeafId(sourceLayout.root, expected.leafId) || + (expected.incarnationId !== undefined && + session.terminalPtyIncarnationsByPaneKey?.[sourcePaneKey] !== expected.incarnationId) + ) { + return false + } + } + if (args.expectedBinding) { + const tab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === args.tabId && candidate.worktreeId === bindingWorktreeId + ) + const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] + if ( + !tab || + boundPtyId !== args.expectedBinding.ptyId || + session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId + ) { + return false + } + } + if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { + this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId, + [resolvedHostId]: session + } + } + const sessionBeforeBinding = cloneWorkspaceSessionState(session) + const reconciledIncarnation = + args.expectedBinding !== undefined && + args.incarnationId !== args.expectedBinding.incarnationId + let terminalMembershipChanged = false + let hostAdmittedTabCreated = false + const advanceTopologyFence = (): void => { + const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) + const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 + // Why: a split, or a host-admitted tab the renderer has never seen, is itself + // the authority — with no fence the renderer's pre-create tab list replays + // over it and the tab is lost even on the repo's first such change. + const establishesMembershipAuthority = + args.expectedSourceBinding !== undefined || hostAdmittedTabCreated + if ( + !reconciledIncarnation && + (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) + ) { + return + } + // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. + session.terminalTopologyRevisionByRepoId = { + ...session.terminalTopologyRevisionByRepoId, + [repoId]: currentRevision + 1 + } + } + const restoreSession = (): void => { + if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { + this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSession = + sessionBeforeBinding + } else { + this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId, + [resolvedHostId]: sessionBeforeBinding + } + } + } + if (args.incarnationId) { + session.terminalPtyIncarnationsByPaneKey = { + ...session.terminalPtyIncarnationsByPaneKey, + [paneKey]: args.incarnationId + } + if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { + session.terminalSurfaceTombstonesByPaneKey = { + ...session.terminalSurfaceTombstonesByPaneKey + } + delete session.terminalSurfaceTombstonesByPaneKey[paneKey] + } + } + const tabs = session.tabsByWorktree?.[bindingWorktreeId] + const tab = tabs?.find((t) => t.id === args.tabId) + if (tab) { + tab.ptyId = args.ptyId + } else { + terminalMembershipChanged = true + hostAdmittedTabCreated = args.hostAdmittedMembership === true + // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. + const nextTabs = [ + ...(tabs ?? []), + createMinimalPersistedTerminalTab({ + ...args, + worktreeId: bindingWorktreeId, + existingTabCount: tabs?.length ?? 0 + }) + ] + session.tabsByWorktree = { + ...session.tabsByWorktree, + [bindingWorktreeId]: nextTabs + } + session.activeWorktreeId ??= bindingWorktreeId + session.activeTabId ??= args.tabId + session.activeTabIdByWorktree = { + ...session.activeTabIdByWorktree, + [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId + } + } + if (!isTerminalLeafId(args.leafId)) { + // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. + advanceTopologyFence() + try { + this[ptyBindingPersistenceOperationsContext].runtime.flushOrThrow() + } catch (err) { + restoreSession() + throw err + } + return true + } + const layout = session.terminalLayoutsByTabId?.[args.tabId] + if (layout) { + if (!layout.root) { + terminalMembershipChanged = true + // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. + layout.root = { type: 'leaf', leafId: args.leafId } + layout.activeLeafId = args.leafId + layout.expandedLeafId = null + } else if (!layoutContainsLeafId(layout.root, args.leafId)) { + terminalMembershipChanged = true + // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. + layout.root = { + type: 'split', + direction: 'vertical', + first: cloneLayoutNode(layout.root), + second: { type: 'leaf', leafId: args.leafId } + } + layout.activeLeafId = args.leafId + if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { + layout.expandedLeafId = null + } + } + layout.ptyIdsByLeafId = { + ...layout.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } + } else { + terminalMembershipChanged = true + // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. + session.terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + root: { type: 'leaf', leafId: args.leafId }, + activeLeafId: args.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [args.leafId]: args.ptyId } + } + } + } + advanceTopologyFence() + try { + this[ptyBindingPersistenceOperationsContext].runtime.flushOrThrow() + } catch (err) { + restoreSession() + throw err + } + return true + } +} + +export function installPtyBindingPersistenceOperationsContext( + target: object, + source: PtyBindingPersistenceOperations +): void { + Object.defineProperty(target, ptyBindingPersistenceOperationsContext, { + value: source[ptyBindingPersistenceOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts new file mode 100644 index 00000000000..bdd3718d9a6 --- /dev/null +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -0,0 +1,264 @@ +import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import { + removeRepoFromHostWorkspaceSessions, + removeRepoFromWorkspaceSession +} from '../../orca-profiles/profile-project-session-state' +import { + getRepoExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { mergeProjectHostSetupCompatibilityState } from '../tracking-repos/project-host-compatibility' +import { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations' +import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from '../tracking-repos/repo-worktree-pruning' +import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration' +import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' +import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type RepoLifecycleOperationsRuntime = Pick< + StoreRuntimeState, + | 'gitUsernameCache' + | 'projectHostSetupOperations' + | 'repoOrderOperations' + | 'repoUpdateOperations' + | 'state' +> + +const repoLifecycleOperationsContext = Symbol('RepoLifecycleOperations') +type RepoLifecycleOperationsContext = { + runtime: RepoLifecycleOperationsRuntime + scheduling: WriteSchedulingOperations +} + +export class RepoLifecycleOperations { + readonly [repoLifecycleOperationsContext]: RepoLifecycleOperationsContext + + constructor(runtime: RepoLifecycleOperationsRuntime, scheduling: WriteSchedulingOperations) { + this[repoLifecycleOperationsContext] = { runtime, scheduling } + } + + addRepo(repo: Repo): void { + getRepoOrderOperations(this).addRepo(repo) + } + + reorderRepos(orderedIds: string[]): boolean { + return getRepoOrderOperations(this).reorderRepos(orderedIds) + } + + reorderReposForHost(orderedIds: string[], hostId: ExecutionHostId): boolean { + return getRepoOrderOperations(this).reorderReposForHost(orderedIds, hostId) + } + + removeProject(id: string): void { + this[repoLifecycleOperationsContext].runtime.state.repos = this[ + repoLifecycleOperationsContext + ].runtime.state.repos.filter((r) => r.id !== id) + syncProjectHostSetupCompatibilityState(this) + // Why: presets are repo-scoped and unreachable once the repo is gone, so drop them with it. + delete this[repoLifecycleOperationsContext].runtime.state.sparsePresetsByRepo[id] + delete this[repoLifecycleOperationsContext].runtime.state.retiredWorktreeNamesByRepo?.[id] + pruneWorktreeStateForRepo(this, id, null) + this[repoLifecycleOperationsContext].runtime.state.workspaceSession = + removeRepoFromWorkspaceSession( + this[repoLifecycleOperationsContext].runtime.state.workspaceSession, + id + ) + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId = + removeRepoFromHostWorkspaceSessions( + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId, + id + ) + scheduleSave(this[repoLifecycleOperationsContext].scheduling) + } + + removeProjectForHost(id: string, hostId: ExecutionHostId): void { + this[repoLifecycleOperationsContext].runtime.state.repos = this[ + repoLifecycleOperationsContext + ].runtime.state.repos.filter((r) => !(r.id === id && getRepoExecutionHostId(r) === hostId)) + const idStillPresent = this[repoLifecycleOperationsContext].runtime.state.repos.some( + (r) => r.id === id + ) + // Why: presets and retirements are repo-id-scoped (not host-scoped); drop them only when the last host's copy is gone. + if (!idStillPresent) { + delete this[repoLifecycleOperationsContext].runtime.state.sparsePresetsByRepo[id] + delete this[repoLifecycleOperationsContext].runtime.state.retiredWorktreeNamesByRepo?.[id] + } + syncProjectHostSetupCompatibilityState(this) + // Why: prune only this host's worktree metas if the id survives elsewhere; otherwise prune everything (matches removeProject). + pruneWorktreeStateForRepo(this, id, idStillPresent ? hostId : null) + if (!idStillPresent) { + this[repoLifecycleOperationsContext].runtime.state.workspaceSession = + removeRepoFromWorkspaceSession( + this[repoLifecycleOperationsContext].runtime.state.workspaceSession, + id + ) + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId = + removeRepoFromHostWorkspaceSessions( + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId, + id + ) + } else if (parseExecutionHostId(hostId)?.kind === 'runtime') { + const session = + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId?.[hostId] + if (session) { + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId, + [hostId]: removeRepoFromWorkspaceSession(session, id) + } + } + } + scheduleSave(this[repoLifecycleOperationsContext].scheduling) + } + + updateRepo( + id: string, + updates: Partial< + Pick< + Repo, + | 'displayName' + | 'badgeColor' + | 'repoIcon' + | 'upstream' + | 'gitRemoteIdentity' + | 'hookSettings' + | 'worktreeBaseRef' + | 'worktreeBasePath' + | 'kind' + | 'executionHostId' + | 'symlinkPaths' + | 'issueSourcePreference' + | 'forkSyncMode' + | 'externalWorktreeVisibilityPromptDismissedAt' + | 'externalWorktreeInboxBaselinePaths' + | 'importedExternalWorktreePaths' + | 'customWorktreeVisibilitySources' + | 'worktreeVisibilitySourcePreferences' + | 'projectGroupId' + | 'projectGroupOrder' + | 'projectHostSetupMethod' + > + > & { + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null + agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null + sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + }, + hostId?: ExecutionHostId + ): Repo | null { + return getRepoUpdateOperations(this).updateRepo(id, updates, hostId) + } +} + +export function getRepoOrderOperations( + owner: RepoLifecycleOperations +): RepoOrderPersistenceOperations { + owner[repoLifecycleOperationsContext].runtime.repoOrderOperations ??= + new RepoOrderPersistenceOperations({ + state: owner[repoLifecycleOperationsContext].runtime.state, + syncProjectHostSetupCompatibilityState: () => syncProjectHostSetupCompatibilityState(owner), + scheduleSave: () => scheduleSave(owner[repoLifecycleOperationsContext].scheduling) + }) + return owner[repoLifecycleOperationsContext].runtime.repoOrderOperations +} + +export function pruneWorktreeStateForRepo( + owner: RepoLifecycleOperations, + id: string, + hostId: ExecutionHostId | null +): void { + pruneWorktreeStateForRepoOperation( + owner[repoLifecycleOperationsContext].runtime.state, + id, + hostId, + (matchesWorktreeId) => pruneMobileClientTabSelections(owner, matchesWorktreeId) + ) +} + +export function pruneMobileClientTabSelections( + owner: RepoLifecycleOperations, + matchesWorktreeId: (worktreeId: string) => boolean +): void { + for (const [clientNavigationId, selectionsByWorktree] of Object.entries( + owner[repoLifecycleOperationsContext].runtime.state.mobileClientTabSelectionsByDeviceId ?? {} + )) { + for (const worktreeId of Object.keys(selectionsByWorktree)) { + if (matchesWorktreeId(worktreeId)) { + delete selectionsByWorktree[worktreeId] + } + } + if (Object.keys(selectionsByWorktree).length === 0) { + delete owner[repoLifecycleOperationsContext].runtime.state + .mobileClientTabSelectionsByDeviceId?.[clientNavigationId] + } + } +} + +export function getRepoUpdateOperations( + owner: RepoLifecycleOperations +): RepoUpdatePersistenceOperations { + owner[repoLifecycleOperationsContext].runtime.repoUpdateOperations ??= + new RepoUpdatePersistenceOperations({ + state: owner[repoLifecycleOperationsContext].runtime.state, + syncProjectHostSetupCompatibilityState: () => syncProjectHostSetupCompatibilityState(owner), + scheduleSave: () => scheduleSave(owner[repoLifecycleOperationsContext].scheduling), + hydrateRepo: (repo) => hydrateRepo(owner, repo) + }) + return owner[repoLifecycleOperationsContext].runtime.repoUpdateOperations +} + +export function syncProjectHostSetupCompatibilityState(owner: RepoLifecycleOperations): void { + const compatibilityState = mergeProjectHostSetupCompatibilityState( + owner[repoLifecycleOperationsContext].runtime.state, + owner[repoLifecycleOperationsContext].runtime.state.repos + ) + owner[repoLifecycleOperationsContext].runtime.state.projects = compatibilityState.projects + owner[repoLifecycleOperationsContext].runtime.state.projectHostSetups = + compatibilityState.projectHostSetups +} + +export function getProjectHostSetupOperations( + owner: RepoLifecycleOperations +): ProjectHostSetupPersistenceOperations { + owner[repoLifecycleOperationsContext].runtime.projectHostSetupOperations ??= + new ProjectHostSetupPersistenceOperations({ + state: owner[repoLifecycleOperationsContext].runtime.state, + updateRepo: (id, updates, hostId) => owner.updateRepo(id, updates, hostId), + scheduleSave: () => scheduleSave(owner[repoLifecycleOperationsContext].scheduling) + }) + return owner[repoLifecycleOperationsContext].runtime.projectHostSetupOperations +} + +export function updateRepoBackedProjectHostSetup( + owner: RepoLifecycleOperations, + setup: ProjectHostSetup, + repo: Repo, + updates: ProjectHostSetupUpdateArgs['updates'] +): { setup: ProjectHostSetup; repo: Repo } | null { + return getProjectHostSetupOperations(owner).updateRepoBackedProjectHostSetup(setup, repo, updates) +} + +export function updateIndependentProjectHostSetup( + owner: RepoLifecycleOperations, + setup: ProjectHostSetup, + updates: ProjectHostSetupUpdateArgs['updates'] +): ProjectHostSetup { + return getProjectHostSetupOperations(owner).updateIndependentProjectHostSetup(setup, updates) +} + +export function hydrateRepo(owner: RepoLifecycleOperations, repo: Repo): Repo { + return hydrateRepoOperation(repo, owner[repoLifecycleOperationsContext].runtime.gitUsernameCache) +} + +export function installRepoLifecycleOperationsContext( + target: object, + source: RepoLifecycleOperations +): void { + Object.defineProperty(target, repoLifecycleOperationsContext, { + value: source[repoLifecycleOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/retired-name-registry-normalization.ts b/src/main/persistence/loading-store/retired-name-registry-normalization.ts new file mode 100644 index 00000000000..d1c412f0f46 --- /dev/null +++ b/src/main/persistence/loading-store/retired-name-registry-normalization.ts @@ -0,0 +1,46 @@ +import { normalizeRetirableGeneratedName } from '../../worktree-name-retirement' +import { + clampExhaustedTiers, + compactRetiredNames, + isEmptyRetiredNameRegistry, + type RetiredNameRegistry +} from '../../../shared/worktree/retired-name-registry' + +function normalizeRetiredNameRegistry(row: unknown): RetiredNameRegistry { + const isPlainArray = Array.isArray(row) + const rawRow = row as { exhaustedTiers?: unknown; names?: unknown } | null | undefined + const rawNames = isPlainArray ? row : Array.isArray(rawRow?.names) ? rawRow.names : [] + const names = new Set() + for (const entry of rawNames) { + if (typeof entry !== 'string') { + continue + } + const normalized = normalizeRetirableGeneratedName(entry) + if (normalized) { + names.add(normalized) + } + } + return compactRetiredNames({ + exhaustedTiers: isPlainArray ? 0 : clampExhaustedTiers(rawRow?.exhaustedTiers), + names: [...names] + }) +} + +export function normalizeRetiredNameRegistryMap( + value: unknown +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + const byRepo: Record = {} + for (const [repoId, row] of Object.entries(value as Record)) { + if (!repoId) { + continue + } + const registry = normalizeRetiredNameRegistry(row) + if (!isEmptyRetiredNameRegistry(registry)) { + byRepo[repoId] = registry + } + } + return byRepo +} diff --git a/src/main/persistence/loading-store/retired-worktree-name-persistence.ts b/src/main/persistence/loading-store/retired-worktree-name-persistence.ts new file mode 100644 index 00000000000..c5260850522 --- /dev/null +++ b/src/main/persistence/loading-store/retired-worktree-name-persistence.ts @@ -0,0 +1,119 @@ +import { normalizeRetirableGeneratedName } from '../../worktree-name-retirement' +import { recordRetirementNamespaceRegistry } from '../../worktree-retirement-namespace' +import { + addRetiredNames, + EMPTY_RETIRED_NAME_REGISTRY, + type RetiredNameRegistry +} from '../../../shared/worktree/retired-name-registry' +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type RetiredWorktreeNameRuntime = Pick + +const retiredWorktreeNamePersistenceContext = Symbol('RetiredWorktreeNamePersistence') +type RetiredWorktreeNamePersistenceContext = { + runtime: RetiredWorktreeNameRuntime + scheduling: WriteSchedulingOperations +} + +export class RetiredWorktreeNamePersistence { + readonly [retiredWorktreeNamePersistenceContext]: RetiredWorktreeNamePersistenceContext + + constructor(runtime: RetiredWorktreeNameRuntime, scheduling: WriteSchedulingOperations) { + this[retiredWorktreeNamePersistenceContext] = { runtime, scheduling } + } + + getRetiredWorktreeNameRegistry(repoId: string): RetiredNameRegistry { + const stored = + this[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByRepo?.[repoId] + return stored + ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } + : EMPTY_RETIRED_NAME_REGISTRY + } + + getRetiredWorktreeNameRegistryForNamespace(namespaceKey: string): RetiredNameRegistry { + const stored = + this[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByNamespace?.[ + namespaceKey + ] + return stored + ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } + : EMPTY_RETIRED_NAME_REGISTRY + } + + addRetiredWorktreeName(repoId: string, name: string): void { + const normalized = normalizeRetirableGeneratedName(name) + if (!repoId || !normalized) { + return + } + applyRetiredWorktreeNames(this, repoId, [normalized]) + } + + mergeRetiredWorktreeNames(repoId: string, names: Iterable): boolean { + if (!repoId) { + return false + } + const incoming = new Set() + for (const name of names) { + const normalized = normalizeRetirableGeneratedName(name) + if (normalized) { + incoming.add(normalized) + } + } + return incoming.size > 0 && applyRetiredWorktreeNames(this, repoId, incoming) + } + + mergeRetiredWorktreeNamesForNamespace(namespaceKey: string, names: Iterable): boolean { + if (!namespaceKey) { + return false + } + const normalized = new Set() + for (const name of names) { + const candidate = normalizeRetirableGeneratedName(name) + if (candidate) { + normalized.add(candidate) + } + } + const next = addRetiredNames( + this.getRetiredWorktreeNameRegistryForNamespace(namespaceKey), + normalized + ) + if (!next) { + return false + } + this[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByNamespace ??= {} + recordRetirementNamespaceRegistry( + this[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByNamespace, + namespaceKey, + next + ) + scheduleSave(this[retiredWorktreeNamePersistenceContext].scheduling) + return true + } +} + +export function applyRetiredWorktreeNames( + owner: RetiredWorktreeNamePersistence, + repoId: string, + names: Iterable +): boolean { + const next = addRetiredNames(owner.getRetiredWorktreeNameRegistry(repoId), names) + if (!next) { + return false + } + owner[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByRepo ??= {} + owner[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByRepo[repoId] = + next + scheduleSave(owner[retiredWorktreeNamePersistenceContext].scheduling) + return true +} + +export function installRetiredWorktreeNamePersistenceContext( + target: object, + source: RetiredWorktreeNamePersistence +): void { + Object.defineProperty(target, retiredWorktreeNamePersistenceContext, { + value: source[retiredWorktreeNamePersistenceContext] + }) +} diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts new file mode 100644 index 00000000000..53c7ed5ea06 --- /dev/null +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -0,0 +1,190 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' +import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { readTerminalScrollbackSnapshotSync } from '../../terminal-scrollback-snapshots' +import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' +import { + removeWorkspaceSessionOwner, + workspaceSessionPartitionIdsForHost +} from '../restoring-sessions/session-owner-removal' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type SessionHostPartitionOperationsRuntime = Pick< + StoreRuntimeState, + 'state' | 'terminalScrollbackSnapshotStorage' +> + +const sessionHostPartitionOperationsContext = Symbol('SessionHostPartitionOperations') +type SessionHostPartitionOperationsContext = { + runtime: SessionHostPartitionOperationsRuntime + scheduling: WriteSchedulingOperations +} + +export class SessionHostPartitionOperations { + readonly [sessionHostPartitionOperationsContext]: SessionHostPartitionOperationsContext + + constructor( + runtime: SessionHostPartitionOperationsRuntime, + scheduling: WriteSchedulingOperations + ) { + this[sessionHostPartitionOperationsContext] = { runtime, scheduling } + } + + getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] { + const resolved = resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + return ( + this[sessionHostPartitionOperationsContext].runtime.state.workspaceSession ?? + getDefaultWorkspaceSession() + ) + } + return ( + this[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[ + resolved + ] ?? getDefaultWorkspaceSession() + ) + } + + getWorkspaceSessionHostIds(): ExecutionHostId[] { + const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) + for (const key of Object.keys( + this[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId ?? {} + )) { + const hostId = normalizeExecutionHostId(key) + if (hostId) { + hostIds.add(hostId) + } + } + return [...hostIds] + } + + readTerminalScrollbackSnapshot(ref: string): string | null { + return readTerminalScrollbackSnapshotSync( + ref, + this[sessionHostPartitionOperationsContext].runtime.terminalScrollbackSnapshotStorage + ) + } + + getWorktreeIdForTab(tabId: string): string | undefined { + return findWorktreeIdForTab(this.getWorkspaceSession(), tabId) + } + + removeWorkspaceSessionStateForWorktree( + worktreeId: string, + hostId?: ExecutionHostId | null, + options: { advanceTerminalTopologyRevision?: boolean } = {} + ): void { + for (const resolved of workspaceSessionPartitionIdsForHost(hostId)) { + removeWorkspaceSessionOwnerInPartition(this, worktreeId, resolved, options) + } + } +} + +export function resolveHostId(hostId?: string | null): ExecutionHostId { + return normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID +} + +export function hasPersistedWorkspaceSession( + owner: SessionHostPartitionOperations, + hostId: ExecutionHostId +): boolean { + return ( + hostId === LOCAL_EXECUTION_HOST_ID || + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[ + hostId + ] !== undefined + ) +} + +export function removeWorkspaceSessionOwnerInPartition( + owner: SessionHostPartitionOperations, + worktreeId: string, + resolved: ExecutionHostId, + options: { advanceTerminalTopologyRevision?: boolean } +): void { + if (!hasPersistedWorkspaceSession(owner, resolved)) { + return + } + const current = owner.getWorkspaceSession(resolved) + const session = removeWorkspaceSessionOwner(current, worktreeId, { + advanceTerminalTopologyRevision: options.advanceTerminalTopologyRevision ?? true + }) + if (!session) { + return + } + if (resolved === LOCAL_EXECUTION_HOST_ID) { + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSession = session + } else { + // Host scoping matters because identical repo/path ids may exist on two servers. + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId, + [resolved]: session + } + } + scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling) +} + +export function partitionOwnsWorktreeTabs( + owner: SessionHostPartitionOperations, + worktreeId: string, + hostId: ExecutionHostId +): boolean { + return owner.getWorkspaceSession(hostId).tabsByWorktree?.[worktreeId] !== undefined +} + +export function partitionHasOtherRepoWorktreeTabs( + owner: SessionHostPartitionOperations, + worktreeId: string, + hostId: ExecutionHostId +): boolean { + const repoId = getRepoIdFromWorktreeId(worktreeId) + const tabsByWorktree = owner.getWorkspaceSession(hostId).tabsByWorktree ?? {} + return Object.entries(tabsByWorktree).some( + ([id, tabs]) => + id !== worktreeId && getRepoIdFromWorktreeId(id) === repoId && (tabs?.length ?? 0) > 0 + ) +} + +export function setHostWorkspaceSession( + owner: SessionHostPartitionOperations, + hostId: ExecutionHostId, + session: WorkspaceSessionState +): void { + // Why: each partition owns its topology fence; renderer writes omit it and must rebase locally. + session = sanitizeWorkspaceSessionTerminalRetirements( + session, + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[hostId] + ) + const pruned = pruneWorkspaceSessionBrowserHistory( + pruneLocalTerminalScrollbackBuffers( + session, + owner[sessionHostPartitionOperationsContext].runtime.state.repos + ) + ) + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId, + [hostId]: pruned + } + scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling) +} + +export function installSessionHostPartitionOperationsContext( + target: object, + source: SessionHostPartitionOperations +): void { + Object.defineProperty(target, sessionHostPartitionOperationsContext, { + value: source[sessionHostPartitionOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/session-snapshot-operations.ts b/src/main/persistence/loading-store/session-snapshot-operations.ts new file mode 100644 index 00000000000..2f5f1c64b86 --- /dev/null +++ b/src/main/persistence/loading-store/session-snapshot-operations.ts @@ -0,0 +1,102 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' + +import { workspaceSessionPatchNeedsFullNormalization } from './terminal-session-cleanup' + +import { setLocalWorkspaceSession } from './workspace-session-snapshot-publication' +import type { StoreRuntimeState } from './store-runtime-state' +import type { SessionHostPartitionOperations } from './session-host-partitions' +import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' +import type { WriteSchedulingOperations } from './write-scheduling' +import { resolveHostId, setHostWorkspaceSession } from './session-host-partitions' +import { scheduleSave } from './write-scheduling' + +type SessionSnapshotOperationsRuntime = Pick< + StoreRuntimeState, + 'pendingSnapshotFileWork' | 'state' | 'terminalScrollbackSnapshotStorage' +> + +const sessionSnapshotOperationsContext = Symbol('SessionSnapshotOperations') +type SessionSnapshotOperationsContext = { + runtime: SessionSnapshotOperationsRuntime + sessions: SessionHostPartitionOperations + bindingRecovery: TerminalBindingRecoveryOperations + scheduling: WriteSchedulingOperations +} + +export class SessionSnapshotOperations { + readonly [sessionSnapshotOperationsContext]: SessionSnapshotOperationsContext + + constructor( + runtime: SessionSnapshotOperationsRuntime, + sessions: SessionHostPartitionOperations, + bindingRecovery: TerminalBindingRecoveryOperations, + scheduling: WriteSchedulingOperations + ) { + this[sessionSnapshotOperationsContext] = { runtime, sessions, bindingRecovery, scheduling } + } + + setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { + const resolved = resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + setLocalWorkspaceSession(this, session) + return + } + setHostWorkspaceSession(this[sessionSnapshotOperationsContext].sessions, resolved, session) + } + + stageWorkspaceSessionBeforeUnload( + session: PersistedState['workspaceSession'], + hostId?: string | null + ): void { + const resolved = resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + setLocalWorkspaceSession(this, session, true) + return + } + setHostWorkspaceSession(this[sessionSnapshotOperationsContext].sessions, resolved, session) + } + + patchWorkspaceSession(patch: WorkspaceSessionPatch, hostId?: string | null): void { + const resolved = resolveHostId(hostId) + // Why: the debounced hot path sends only changed slices; scalar/UI patches skip terminal normalization, topology patches keep stale-PTY protections. + let next: WorkspaceSessionState = { + ...this[sessionSnapshotOperationsContext].sessions.getWorkspaceSession(resolved), + ...patch + } + if (workspaceSessionPatchNeedsFullNormalization(patch)) { + this.setWorkspaceSession(next, resolved) + return + } + if (Object.hasOwn(patch, 'browserUrlHistory')) { + next = pruneWorkspaceSessionBrowserHistory(next) + } + if (resolved === LOCAL_EXECUTION_HOST_ID) { + this[sessionSnapshotOperationsContext].runtime.state.workspaceSession = next + } else { + this[sessionSnapshotOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...this[sessionSnapshotOperationsContext].runtime.state.workspaceSessionsByHostId, + [resolved]: next + } + } + scheduleSave(this[sessionSnapshotOperationsContext].scheduling) + } +} + +export function getSessionSnapshotOperationsContext(owner: SessionSnapshotOperations) { + return owner[sessionSnapshotOperationsContext] +} + +export function installSessionSnapshotOperationsContext( + target: object, + source: SessionSnapshotOperations +): void { + Object.defineProperty(target, sessionSnapshotOperationsContext, { + value: source[sessionSnapshotOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/sparse-preset-persistence.ts b/src/main/persistence/loading-store/sparse-preset-persistence.ts new file mode 100644 index 00000000000..2de83313ecd --- /dev/null +++ b/src/main/persistence/loading-store/sparse-preset-persistence.ts @@ -0,0 +1,55 @@ +import type { SparsePreset } from '../../../shared/worktree/create-types' +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type SparsePresetRuntime = Pick + +const sparsePresetPersistenceContext = Symbol('SparsePresetPersistence') +type SparsePresetPersistenceContext = { + runtime: SparsePresetRuntime + scheduling: WriteSchedulingOperations +} + +export class SparsePresetPersistence { + readonly [sparsePresetPersistenceContext]: SparsePresetPersistenceContext + + constructor(runtime: SparsePresetRuntime, scheduling: WriteSchedulingOperations) { + this[sparsePresetPersistenceContext] = { runtime, scheduling } + } + + getSparsePresets(repoId: string): SparsePreset[] { + return [ + ...(this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[repoId] ?? []) + ].sort((left, right) => left.name.localeCompare(right.name)) + } + + saveSparsePreset(preset: SparsePreset): SparsePreset { + const existing = + this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[preset.repoId] ?? [] + const index = existing.findIndex((entry) => entry.id === preset.id) + this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[preset.repoId] = + index === -1 + ? [...existing, preset] + : existing.map((entry, entryIndex) => (entryIndex === index ? preset : entry)) + scheduleSave(this[sparsePresetPersistenceContext].scheduling) + return preset + } + + removeSparsePreset(repoId: string, presetId: string): void { + const existing = + this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[repoId] ?? [] + this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[repoId] = + existing.filter((entry) => entry.id !== presetId) + scheduleSave(this[sparsePresetPersistenceContext].scheduling) + } +} + +export function installSparsePresetPersistenceContext( + target: object, + source: SparsePresetPersistence +): void { + Object.defineProperty(target, sparsePresetPersistenceContext, { + value: source[sparsePresetPersistenceContext] + }) +} diff --git a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts new file mode 100644 index 00000000000..e7cea585765 --- /dev/null +++ b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts @@ -0,0 +1,182 @@ +import type { SshPtyConsumerRecovery, SshRemotePtyLease } from '../../../shared/ssh-types' +import { + getSshRemotePtyLeases as getSshRemotePtyLeasesOperation, + markSshRemotePtyLease as markSshRemotePtyLeaseOperation, + markSshRemotePtyLeases as markSshRemotePtyLeasesOperation, + markSshRemotePtyLeasesAsync as markSshRemotePtyLeasesAsyncOperation, + markSshRemotePtyLeasesAttachedAsync as markSshRemotePtyLeasesAttachedAsyncOperation, + markSshRemotePtyLeasesForShutdown as markSshRemotePtyLeasesForShutdownOperation, + removeSshRemotePtyLease as removeSshRemotePtyLeaseOperation, + removeSshRemotePtyLeases as removeSshRemotePtyLeasesOperation, + type SshPtyLeaseOperations, + upsertSshRemotePtyLease as upsertSshRemotePtyLeaseOperation +} from '../leasing-ssh-ptys/ssh-pty-lease-operations' +import { + getSshPtyConsumerRecovery as getSshPtyConsumerRecoveryOperation, + removeSshPtyConsumerRecovery as removeSshPtyConsumerRecoveryOperation, + type SshPtyConsumerRecoveryOperations, + upsertSshPtyConsumerRecovery as upsertSshPtyConsumerRecoveryOperation +} from '../leasing-ssh-ptys/ssh-pty-consumer-recovery' +import { + clearSshRemotePtyBindingsForLeases as clearSshRemotePtyBindingsForLeasesOperation, + clearSshRemotePtyBindingsForTarget as clearSshRemotePtyBindingsForTargetOperation, + type SshPtyBindingCleanupOperations +} from '../leasing-ssh-ptys/ssh-pty-binding-cleanup' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' +import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' +import type { WriteSchedulingOperations } from './write-scheduling' +import { flushDurableStateOrThrowAsync } from './write-flush-barriers' +import { scheduleSave } from './write-scheduling' + +type SshLeaseRecoveryOperationsRuntime = Pick + +const sshLeaseRecoveryOperationsContext = Symbol('SshLeaseRecoveryOperations') +type SshLeaseRecoveryOperationsContext = { + runtime: SshLeaseRecoveryOperationsRuntime + flushBarriers: WriteFlushBarrierOperations + bindingRecovery: TerminalBindingRecoveryOperations + scheduling: WriteSchedulingOperations +} + +export class SshLeaseRecoveryOperations { + readonly [sshLeaseRecoveryOperationsContext]: SshLeaseRecoveryOperationsContext + + constructor( + runtime: SshLeaseRecoveryOperationsRuntime, + flushBarriers: WriteFlushBarrierOperations, + bindingRecovery: TerminalBindingRecoveryOperations, + scheduling: WriteSchedulingOperations + ) { + this[sshLeaseRecoveryOperationsContext] = { + runtime, + flushBarriers, + bindingRecovery, + scheduling + } + } + + getSshPtyConsumerRecovery(targetId: string): SshPtyConsumerRecovery | null { + return getSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), targetId) + } + + async upsertSshPtyConsumerRecovery(record: SshPtyConsumerRecovery): Promise { + await upsertSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), record) + } + + async removeSshPtyConsumerRecovery(targetId: string): Promise { + await removeSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), targetId) + } + + getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { + return getSshRemotePtyLeasesOperation( + this[sshLeaseRecoveryOperationsContext].runtime.state, + targetId + ) + } + + upsertSshRemotePtyLease( + lease: Omit & + Partial> + ): void { + upsertSshRemotePtyLeaseOperation(getSshPtyLeaseOperations(this), lease) + } + + markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { + markSshRemotePtyLeasesOperation(getSshPtyLeaseOperations(this), targetId, state) + } + + markSshRemotePtyLeasesForShutdown(targetId: string, state: SshRemotePtyLease['state']): void { + markSshRemotePtyLeasesForShutdownOperation(getSshPtyLeaseOperations(this), targetId, state) + } + + async markSshRemotePtyLeasesAsync( + targetId: string, + state: SshRemotePtyLease['state'] + ): Promise { + await markSshRemotePtyLeasesAsyncOperation(getSshPtyLeaseOperations(this), targetId, state) + } + + async markSshRemotePtyLeasesAttachedAsync( + targetId: string, + ptyIds: readonly string[] + ): Promise { + await markSshRemotePtyLeasesAttachedAsyncOperation( + getSshPtyLeaseOperations(this), + targetId, + ptyIds + ) + } + + markSshRemotePtyLease(targetId: string, ptyId: string, state: SshRemotePtyLease['state']): void { + markSshRemotePtyLeaseOperation(getSshPtyLeaseOperations(this), targetId, ptyId, state) + } + + removeSshRemotePtyLease(targetId: string, ptyId: string): void { + removeSshRemotePtyLeaseOperation(getSshPtyLeaseOperations(this), targetId, ptyId) + } + + removeSshRemotePtyLeases(targetId: string): void { + removeSshRemotePtyLeasesOperation(getSshPtyLeaseOperations(this), targetId) + } +} + +export function getSshPtyConsumerRecoveryOperations( + owner: SshLeaseRecoveryOperations +): SshPtyConsumerRecoveryOperations { + return { + state: owner[sshLeaseRecoveryOperationsContext].runtime.state, + protectedSecrets: owner[sshLeaseRecoveryOperationsContext].runtime.protectedSecrets, + flushDurableStateOrThrowAsync: () => + flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) + } +} + +export function getSshPtyBindingCleanupOperations( + owner: SshLeaseRecoveryOperations +): SshPtyBindingCleanupOperations { + return { + state: owner[sshLeaseRecoveryOperationsContext].runtime.state, + toComparablePtyId: (targetId, ptyId) => + owner[sshLeaseRecoveryOperationsContext].bindingRecovery.getRelayPtyIdForSshLeaseComparison( + targetId, + ptyId + ), + scheduleSave: () => scheduleSave(owner[sshLeaseRecoveryOperationsContext].scheduling) + } +} + +export function getSshPtyLeaseOperations(owner: SshLeaseRecoveryOperations): SshPtyLeaseOperations { + return { + state: owner[sshLeaseRecoveryOperationsContext].runtime.state, + toStoredPtyId: (targetId, ptyId) => + owner[sshLeaseRecoveryOperationsContext].bindingRecovery.getRelayPtyIdForSshLeaseStorage( + targetId, + ptyId + ), + clearBindingsForTarget: (targetId) => + clearSshRemotePtyBindingsForTargetOperation( + getSshPtyBindingCleanupOperations(owner), + targetId + ), + clearBindingsForLeases: (targetId, leases) => + clearSshRemotePtyBindingsForLeasesOperation( + getSshPtyBindingCleanupOperations(owner), + targetId, + leases + ), + flush: () => owner[sshLeaseRecoveryOperationsContext].flushBarriers.flush(), + flushDurableStateOrThrowAsync: () => + flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) + } +} + +export function installSshLeaseRecoveryOperationsContext( + target: object, + source: SshLeaseRecoveryOperations +): void { + Object.defineProperty(target, sshLeaseRecoveryOperationsContext, { + value: source[sshLeaseRecoveryOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/ssh-profile-operations.ts b/src/main/persistence/loading-store/ssh-profile-operations.ts new file mode 100644 index 00000000000..5ab7c635f27 --- /dev/null +++ b/src/main/persistence/loading-store/ssh-profile-operations.ts @@ -0,0 +1,142 @@ +import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' +import { + type SshTargetStateOperations, + addClaudeLivePtySessionId as addClaudeLivePtySessionIdOperation, + addDeletedSshConfigAlias as addDeletedSshConfigAliasOperation, + addRemovedSshTargetTombstone as addRemovedSshTargetTombstoneOperation, + addSshTarget as addSshTargetOperation, + clearDeletedSshConfigAliases as clearDeletedSshConfigAliasesOperation, + getClaudeLivePtySessionIds as getClaudeLivePtySessionIdsOperation, + getDeletedSshConfigAliases as getDeletedSshConfigAliasesOperation, + getRemovedSshTargetTombstones as getRemovedSshTargetTombstonesOperation, + getSshTarget as getSshTargetOperation, + getSshTargets as getSshTargetsOperation, + removeClaudeLivePtySessionId as removeClaudeLivePtySessionIdOperation, + removeDeletedSshConfigAlias as removeDeletedSshConfigAliasOperation, + removeRemovedSshTargetTombstone as removeRemovedSshTargetTombstoneOperation, + removeSshTarget as removeSshTargetOperation, + updateSshTarget as updateSshTargetOperation +} from '../leasing-ssh-ptys/ssh-target-state' +import { + reassignSshTargetId as reassignSshTargetIdOperation, + type SshTargetReassignmentOperations +} from '../leasing-ssh-ptys/ssh-target-reassignment' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' +import type { RepoLifecycleOperations } from './repo-lifecycle-operations' +import { syncProjectHostSetupCompatibilityState } from './repo-lifecycle-operations' +import { scheduleSave } from './write-scheduling' + +type SshProfileOperationsRuntime = Pick + +const sshProfileOperationsContext = Symbol('SshProfileOperations') +type SshProfileOperationsContext = { + runtime: SshProfileOperationsRuntime + scheduling: WriteSchedulingOperations + flushBarriers: WriteFlushBarrierOperations + repos: RepoLifecycleOperations +} + +export class SshProfileOperations { + readonly [sshProfileOperationsContext]: SshProfileOperationsContext + + constructor( + runtime: SshProfileOperationsRuntime, + scheduling: WriteSchedulingOperations, + flushBarriers: WriteFlushBarrierOperations, + repos: RepoLifecycleOperations + ) { + this[sshProfileOperationsContext] = { runtime, scheduling, flushBarriers, repos } + } + + getSshTargets(): SshTarget[] { + return getSshTargetsOperation(this[sshProfileOperationsContext].runtime.state) + } + + getSshTarget(id: string): SshTarget | undefined { + return getSshTargetOperation(this[sshProfileOperationsContext].runtime.state, id) + } + + addSshTarget(target: SshTarget): void { + addSshTargetOperation(getSshTargetStateOperations(this), target) + } + + updateSshTarget(id: string, updates: Partial>): SshTarget | null { + return updateSshTargetOperation(getSshTargetStateOperations(this), id, updates) + } + + removeSshTarget(id: string): void { + removeSshTargetOperation(getSshTargetStateOperations(this), id) + } + + getClaudeLivePtySessionIds(): string[] { + return getClaudeLivePtySessionIdsOperation(this[sshProfileOperationsContext].runtime.state) + } + + addClaudeLivePtySessionId(sessionId: string): void { + addClaudeLivePtySessionIdOperation(getSshTargetStateOperations(this), sessionId) + } + + removeClaudeLivePtySessionId(sessionId: string): void { + removeClaudeLivePtySessionIdOperation(getSshTargetStateOperations(this), sessionId) + } + + getDeletedSshConfigAliases(): string[] { + return getDeletedSshConfigAliasesOperation(this[sshProfileOperationsContext].runtime.state) + } + + addDeletedSshConfigAlias(alias: string): void { + addDeletedSshConfigAliasOperation(getSshTargetStateOperations(this), alias) + } + + removeDeletedSshConfigAlias(alias: string): void { + removeDeletedSshConfigAliasOperation(getSshTargetStateOperations(this), alias) + } + + clearDeletedSshConfigAliases(): void { + clearDeletedSshConfigAliasesOperation(getSshTargetStateOperations(this)) + } + + getRemovedSshTargetTombstones(): RemovedSshTargetTombstone[] { + return getRemovedSshTargetTombstonesOperation(this[sshProfileOperationsContext].runtime.state) + } + + addRemovedSshTargetTombstone(tombstone: RemovedSshTargetTombstone): void { + addRemovedSshTargetTombstoneOperation(getSshTargetStateOperations(this), tombstone) + } + + removeRemovedSshTargetTombstone(oldTargetId: string): void { + removeRemovedSshTargetTombstoneOperation(getSshTargetStateOperations(this), oldTargetId) + } + + reassignSshTargetId(oldTargetId: string, newTargetId: string): string[] { + const operations: SshTargetReassignmentOperations = { + state: this[sshProfileOperationsContext].runtime.state, + protectedSecrets: this[sshProfileOperationsContext].runtime.protectedSecrets, + syncProjectHostSetupCompatibilityState: () => + syncProjectHostSetupCompatibilityState(this[sshProfileOperationsContext].repos), + scheduleSave: () => scheduleSave(this[sshProfileOperationsContext].scheduling) + } + return reassignSshTargetIdOperation(operations, oldTargetId, newTargetId) + } +} + +export function getSshTargetStateOperations(owner: SshProfileOperations): SshTargetStateOperations { + return { + state: owner[sshProfileOperationsContext].runtime.state, + protectedSecrets: owner[sshProfileOperationsContext].runtime.protectedSecrets, + scheduleSave: () => scheduleSave(owner[sshProfileOperationsContext].scheduling), + flush: () => owner[sshProfileOperationsContext].flushBarriers.flush() + } +} + +export function installSshProfileOperationsContext( + target: object, + source: SshProfileOperations +): void { + Object.defineProperty(target, sshProfileOperationsContext, { + value: source[sshProfileOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts new file mode 100644 index 00000000000..c9d81f071a5 --- /dev/null +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -0,0 +1,125 @@ +import { createHash, randomUUID } from 'node:crypto' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { collectFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments' +import { + PROTECTED_SECRET_SLOT, + sshPtyOwnerLeaseSecretSlot, + type ProtectedSecretRetentionUpdate +} from '../../protected-secret-persistence' +import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' + +import type { StoreRuntimeState } from './store-runtime-state' + +type StateSerializationSecretHandlingOperationsRuntime = Pick< + StoreRuntimeState, + 'protectedSecrets' | 'state' +> + +export class StateSerializationSecretHandlingOperations { + constructor(private readonly runtime: StateSerializationSecretHandlingOperationsRuntime) {} + + getDurableState(): Omit { + const { githubCache: _memoryOnly, ...durable } = this.runtime.state + return durable + } + + buildStateToSave(): { + payload: string + stateHash: string + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] + } { + // Why sentinels (not a blob/key string match): the substitution must be + // position-exact. A plain search for the ciphertext — or even for a + // `"key":"blob"` token — can be mimicked by user-controlled state (e.g. an + // agentDefaultEnv var named after a secret field, or a value equal to a + // ciphertext), which would substitute the wrong site and let two DISTINCT + // states normalize equal → a silently dropped write (data loss), reachable + // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot + // random UUID can't occur anywhere else in the serialized state (the user + // sets their data before it is minted), so it appears exactly once. + const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = [] + const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] + let protectedStorageDegraded = false + const encryptToSentinel = (slot: string, plaintext: string): string => { + const encrypted = this.runtime.protectedSecrets.encrypt(slot, plaintext) + if (encrypted.retentionUpdate) { + protectedSecretUpdates.push(encrypted.retentionUpdate) + } + protectedStorageDegraded ||= encrypted.degraded + const { blob, hashValue = plaintext } = encrypted + // Values already identical in payload and hash need no sentinel substitution. + if (blob === plaintext && hashValue === plaintext) { + return blob + } + const sentinel = `orca-secret-slot-${randomUUID()}` + secretSubs.push({ sentinel, blob, hashValue }) + return sentinel + } + const encryptOptionalToSentinel = ( + slot: string, + plaintext: string | null | undefined + ): string | null => { + const encrypted = encryptToSentinel(slot, plaintext ?? '') + return encrypted || null + } + // Why: clone before encrypting secrets so in-memory this.state stays plaintext. + const stateToSave = { + ...this.getDurableState(), + // Why both keys unconditionally: the explicit keys always win over the spread, and + // JSON.stringify drops the `undefined` value so a note-free profile gains no key on disk. + // The strip builds a new array here only; this.state records keep their notes in memory. + folderWorkspaces: (this.runtime.state.folderWorkspaces ?? []).map( + ({ diffComments: _relocated, ...rest }) => rest + ), + folderWorkspaceDiffComments: collectFolderWorkspaceDiffComments( + this.runtime.state.folderWorkspaces + ), + sshPtyConsumerRecoveries: (this.runtime.state.sshPtyConsumerRecoveries ?? []).map( + (record) => ({ + ...record, + ownerLease: encryptToSentinel( + sshPtyOwnerLeaseSecretSlot(record.targetId), + record.ownerLease + ) + }) + ), + settings: { + ...stripRetiredGlobalSettings(this.runtime.state.settings), + opencodeSessionCookie: encryptToSentinel( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + this.runtime.state.settings.opencodeSessionCookie + ), + httpProxyUrl: encryptToSentinel( + PROTECTED_SECRET_SLOT.httpProxyUrl, + this.runtime.state.settings.httpProxyUrl ?? '' + ) + }, + ui: { + ...this.runtime.state.ui, + browserKagiSessionLink: encryptOptionalToSentinel( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + this.runtime.state.ui.browserKagiSessionLink + ) + } + } + // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. + // One full-state stringify; secret slots currently hold sentinels. + const serialized = JSON.stringify(stateToSave) + // Substitute each unique sentinel exactly once: ciphertext for the on-disk + // payload, a stable normalized value for the guard hash. Function-form + // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped + // in `serialized`, so each replace is byte-for-byte position-exact. + let payload = serialized + let hashInput = serialized + for (const { sentinel, blob, hashValue } of secretSubs) { + const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) + payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) + hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) + } + const stateHash = createHash('sha1') + .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '') + .update(hashInput) + .digest('hex') + return { payload, stateHash, protectedSecretUpdates } + } +} diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts new file mode 100644 index 00000000000..425ee2f1745 --- /dev/null +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -0,0 +1,187 @@ +import type { StoreRuntimeState } from './store-runtime-state' +import { LoadedStateAdaptationOperations } from './loaded-state-adaptation' +import { BackupRecoveryRotationOperations } from './backup-recovery-rotation' +import { LoadedCohortMigrationOperations } from './loaded-cohort-migrations' +import { LoadedStateParsingOperations } from './loaded-state-parsing' +import { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' +import { + PrimaryStateWriteOperations, + installPrimaryStateWriteOperationsContext +} from './primary-state-writes' +import { + WriteSchedulingOperations, + installWriteSchedulingOperationsContext +} from './write-scheduling' +import { + WriteFlushBarrierOperations, + installWriteFlushBarrierOperationsContext +} from './write-flush-barriers' +import { ProfilePreferences, installProfilePreferencesContext } from './profile-preferences' +import { + RepoLifecycleOperations, + installRepoLifecycleOperationsContext +} from './repo-lifecycle-operations' +import { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' +import { + SessionHostPartitionOperations, + installSessionHostPartitionOperationsContext +} from './session-host-partitions' +import { + SessionSnapshotOperations, + installSessionSnapshotOperationsContext +} from './session-snapshot-operations' +import { + MetadataLineageOperations, + installMetadataLineageOperationsContext +} from './metadata-lineage-operations' +import { + ProjectCollectionOperations, + installProjectCollectionOperationsContext +} from './project-collection-operations' +import { + AutomationPersistence, + installAutomationPersistenceContext +} from './automation-persistence' +import { + MobileTabSelectionPersistence, + installMobileTabSelectionPersistenceContext +} from './mobile-tab-selection-persistence' +import { + SparsePresetPersistence, + installSparsePresetPersistenceContext +} from './sparse-preset-persistence' +import { + PtyBindingPersistenceOperations, + installPtyBindingPersistenceOperationsContext +} from './pty-binding-persistence' +import { SshProfileOperations, installSshProfileOperationsContext } from './ssh-profile-operations' +import { + RetiredWorktreeNamePersistence, + installRetiredWorktreeNamePersistenceContext +} from './retired-worktree-name-persistence' +import { + SshLeaseRecoveryOperations, + installSshLeaseRecoveryOperationsContext +} from './ssh-lease-recovery-operations' + +export type StoreDomains = { + adaptation: LoadedStateAdaptationOperations + backups: BackupRecoveryRotationOperations + cohorts: LoadedCohortMigrationOperations + loader: LoadedStateParsingOperations + serialization: StateSerializationSecretHandlingOperations + writes: PrimaryStateWriteOperations + scheduling: WriteSchedulingOperations + flushBarriers: WriteFlushBarrierOperations + preferences: ProfilePreferences + repos: RepoLifecycleOperations + bindingRecovery: TerminalBindingRecoveryOperations + sessions: SessionHostPartitionOperations + sessionSnapshots: SessionSnapshotOperations + metadata: MetadataLineageOperations + projects: ProjectCollectionOperations + automations: AutomationPersistence + mobileTabSelections: MobileTabSelectionPersistence + sparsePresets: SparsePresetPersistence + ptyBindings: PtyBindingPersistenceOperations + sshProfiles: SshProfileOperations + retiredWorktreeNames: RetiredWorktreeNamePersistence + sshLeases: SshLeaseRecoveryOperations +} + +export const STORE_DOMAIN_OPERATION_CLASSES = [ + WriteSchedulingOperations, + PrimaryStateWriteOperations, + ProjectCollectionOperations, + RepoLifecycleOperations, + MobileTabSelectionPersistence, + SparsePresetPersistence, + AutomationPersistence, + MetadataLineageOperations, + ProfilePreferences, + SessionHostPartitionOperations, + SessionSnapshotOperations, + PtyBindingPersistenceOperations, + SshProfileOperations, + RetiredWorktreeNamePersistence, + SshLeaseRecoveryOperations, + WriteFlushBarrierOperations +] as const + +export function installStoreDomainContexts(target: object, domains: StoreDomains): void { + installWriteSchedulingOperationsContext(target, domains.scheduling) + installPrimaryStateWriteOperationsContext(target, domains.writes) + installProjectCollectionOperationsContext(target, domains.projects) + installRepoLifecycleOperationsContext(target, domains.repos) + installMobileTabSelectionPersistenceContext(target, domains.mobileTabSelections) + installSparsePresetPersistenceContext(target, domains.sparsePresets) + installAutomationPersistenceContext(target, domains.automations) + installMetadataLineageOperationsContext(target, domains.metadata) + installProfilePreferencesContext(target, domains.preferences) + installSessionHostPartitionOperationsContext(target, domains.sessions) + installSessionSnapshotOperationsContext(target, domains.sessionSnapshots) + installPtyBindingPersistenceOperationsContext(target, domains.ptyBindings) + installSshProfileOperationsContext(target, domains.sshProfiles) + installRetiredWorktreeNamePersistenceContext(target, domains.retiredWorktreeNames) + installSshLeaseRecoveryOperationsContext(target, domains.sshLeases) + installWriteFlushBarrierOperationsContext(target, domains.flushBarriers) +} + +export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { + const adaptation = new LoadedStateAdaptationOperations(runtime) + const backups = new BackupRecoveryRotationOperations(runtime) + const cohorts = new LoadedCohortMigrationOperations(runtime) + const loader = new LoadedStateParsingOperations(runtime, backups, cohorts) + const serialization = new StateSerializationSecretHandlingOperations(runtime) + const writes = new PrimaryStateWriteOperations(runtime, serialization, backups) + const scheduling = new WriteSchedulingOperations(runtime, writes) + const flushBarriers = new WriteFlushBarrierOperations(runtime, writes) + const preferences = new ProfilePreferences(runtime, scheduling) + const repos = new RepoLifecycleOperations(runtime, scheduling) + const bindingRecovery = new TerminalBindingRecoveryOperations(runtime) + const sessions = new SessionHostPartitionOperations(runtime, scheduling) + const sessionSnapshots = new SessionSnapshotOperations( + runtime, + sessions, + bindingRecovery, + scheduling + ) + const metadata = new MetadataLineageOperations(runtime, scheduling, sessions) + const projects = new ProjectCollectionOperations(runtime, repos, scheduling, metadata) + const automations = new AutomationPersistence(runtime, flushBarriers, preferences) + const mobileTabSelections = new MobileTabSelectionPersistence(runtime, scheduling) + const sparsePresets = new SparsePresetPersistence(runtime, scheduling) + const ptyBindings = new PtyBindingPersistenceOperations(runtime, sessions) + const sshProfiles = new SshProfileOperations(runtime, scheduling, flushBarriers, repos) + const retiredWorktreeNames = new RetiredWorktreeNamePersistence(runtime, scheduling) + const sshLeases = new SshLeaseRecoveryOperations( + runtime, + flushBarriers, + bindingRecovery, + scheduling + ) + return { + adaptation, + backups, + cohorts, + loader, + serialization, + writes, + scheduling, + flushBarriers, + preferences, + repos, + bindingRecovery, + sessions, + sessionSnapshots, + metadata, + projects, + automations, + mobileTabSelections, + sparsePresets, + ptyBindings, + sshProfiles, + retiredWorktreeNames, + sshLeases + } +} diff --git a/src/main/persistence/loading-store/store-owned-state.ts b/src/main/persistence/loading-store/store-owned-state.ts deleted file mode 100644 index 9c752b06f92..00000000000 --- a/src/main/persistence/loading-store/store-owned-state.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { PersistedState } from '../../../shared/persisted-state-types' - -/** - * The Store's live state object, never a copy. Extracted operations read and mutate it in place, so a caller that - * passes a clone or a spread silently loses every write. The Store stays its sole owner and is the only thing that - * may hand this reference out. - */ -export type StoreOwnedPersistedState = PersistedState diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts new file mode 100644 index 00000000000..284f556ae41 --- /dev/null +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -0,0 +1,79 @@ +import { removeStaleDurableWriteTempFiles } from '../../durable-file-write' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { ActiveViewPreference } from '../../active-view-preference' +import { + getProfileTerminalScrollbackSnapshotRoot, + type TerminalScrollbackSnapshotStorage +} from '../../terminal-scrollback-snapshots' +import { ProtectedSecretPersistence } from '../../protected-secret-persistence' +import { getDataFile, getGithubCacheFile } from './user-data-path' +import { STALE_DURABLE_WRITE_TEMP_AGE_MS } from '../tracking-repos/worktree-metadata-normalization' +import type { ProjectGroupPersistenceOperations } from '../tracking-repos/project-group-operations' +import type { FolderWorkspacePersistenceOperations } from '../restoring-sessions/folder-workspace-operations' +import type { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations' +import type { ProjectHostPersistenceOperations } from '../tracking-repos/project-host-operations' +import type { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' +import type { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' + +export type StoreRuntimeOptions = { dataFile?: string } + +/** Mutable coordination state shared only with this Store's private collaborators. */ +export class StoreRuntimeState { + state!: PersistedState + readonly dataFile: string + activeViewPreference!: ActiveViewPreference + readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage + writeTimer: ReturnType | null = null + pendingWrite: Promise | null = null + pendingSnapshotFileWork: Promise | null = null + readonly staleTempCleanup: Promise + writeGeneration = 0 + inFlightAsyncTmpFile: string | null = null + backupRotationInFlight = false + writesFrozen = false + quitFlushStarted = false + quitFlushPromise: Promise | null = null + lastWrittenStateHash: string | null = null + lastDurableWriteGeneration = -1 + firstPendingSaveAt: number | null = null + githubCacheDirty = false + githubCacheGeneration = 0 + pendingGithubCacheWrite: Promise | null = null + readonly staleGithubCacheTempCleanup: Promise + readonly gitUsernameCache = new Map() + readonly protectedSecrets = new ProtectedSecretPersistence() + loadNeedsSave = false + flushOrThrow!: () => void + settingsChangeListeners = new Set< + ( + updates: Partial, + settings: GlobalSettings, + originWebContentsId?: number + ) => void + >() + uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>() + projectHostOperations: ProjectHostPersistenceOperations | null = null + projectGroupOperations: ProjectGroupPersistenceOperations | null = null + folderWorkspaceOperations: FolderWorkspacePersistenceOperations | null = null + repoOrderOperations: RepoOrderPersistenceOperations | null = null + repoUpdateOperations: RepoUpdatePersistenceOperations | null = null + projectHostSetupOperations: ProjectHostSetupPersistenceOperations | null = null + + constructor(options: StoreRuntimeOptions = {}) { + this.dataFile = options.dataFile ?? getDataFile() + this.staleTempCleanup = removeStaleDurableWriteTempFiles(this.dataFile, { + minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS + }) + this.staleGithubCacheTempCleanup = removeStaleDurableWriteTempFiles( + getGithubCacheFile(this.dataFile), + { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS } + ) + const profileSnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(this.dataFile) + const legacySnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(getDataFile()) + this.terminalScrollbackSnapshotStorage = { + snapshotRoot: profileSnapshotRoot, + fallbackSnapshotRoot: legacySnapshotRoot === profileSnapshotRoot ? null : legacySnapshotRoot + } + } +} diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index b977f40074e..c66615a03df 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -1,505 +1,39 @@ -/* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration, and load/save/flush in one file so the storage contract reviews as a unit. */ -import { - readFileSync, - writeFileSync, - mkdirSync, - existsSync, - renameSync, - unlinkSync, - copyFileSync, - statSync -} from 'node:fs' -import { rename, mkdir, rm, copyFile, open, stat, access, writeFile } from 'node:fs/promises' -import { - durableWriteTempPath, - removeStaleDurableWriteTempFiles, - renameDurable, - writeFileDurableSync -} from '../../durable-file-write' import { dirname } from 'node:path' -import { homedir } from 'node:os' -import { createHash, randomUUID } from 'node:crypto' -import type { - Automation, - AutomationCreateInput, - AutomationDispatchResult, - AutomationRun, - AutomationRunTrigger, - AutomationUpdateInput -} from '../../../shared/automations-types' -import { normalizeProxyUrl } from '../../../shared/network-proxy' -import { normalizeKagiSessionLink } from '../../../shared/browser-url' -import type { FolderWorkspace, WorkspaceKey } from '../../../shared/folder-workspace-types' -import type { GlobalSettings } from '../../../shared/global-settings-types' -import type { OnboardingChecklistState } from '../../../shared/onboarding-state-types' -import type { - PersistedMobileClientTabSelections, - PersistedState -} from '../../../shared/persisted-state-types' -import type { ProjectGroup } from '../../../shared/project-group-types' -import type { - Project, - ProjectHostSetup, - ProjectHostSetupCreateArgs, - ProjectHostSetupCreateResult, - ProjectHostSetupDeleteArgs, - ProjectHostSetupDeleteResult, - ProjectHostSetupUpdateArgs, - ProjectHostSetupUpdateResult, - ProjectUpdateArgs -} from '../../../shared/project-types' -import type { Repo } from '../../../shared/repo-types' -import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' -import type { - WorkspaceSessionPatch, - WorkspaceSessionState -} from '../../../shared/workspace-session-state-types' -import type { SparsePreset } from '../../../shared/worktree/create-types' -import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' -import type { WorktreeMeta } from '../../../shared/worktree/meta-types' -import { deriveGlobalWindowsRuntimeDefaultFromLegacySettings } from '../../../shared/project-execution-runtime' -import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-context' -import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' -import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' -import { normalizePersistedMobileClientTabSelections } from '../../runtime/client-session-tab-selection-persistence' -import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' -import { - removeRepoFromHostWorkspaceSessions, - removeRepoFromWorkspaceSession -} from '../../orca-profiles/profile-project-session-state' -import type { - RemovedSshTargetTombstone, - SshPtyConsumerRecovery, - SshRemotePtyLease, - SshTarget -} from '../../../shared/ssh-types' -import { isFolderRepo } from '../../../shared/repo-kind' -import { - getRepoExecutionHostId, - parseExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - normalizeExecutionHostId, - type ExecutionHostId -} from '../../../shared/execution-host' -import { - getDefaultPersistedState, - getDefaultOnboardingState, - getDefaultVoiceSettings, - getDefaultWorkspaceSession, - getWorktreeCardModeProperties, - isDefaultedCompactWorktreeCardProperties, - normalizeWorktreeCardProperties -} from '../../../shared/constants' -import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' -import { isExistingPersistedProfile } from '../../../shared/project-order-manual-default-notice' -import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../../../shared/usage-percentage-display-change-notice' -import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides' -import { toRelaySshPtyId } from '../../providers/ssh-pty-id' -import { isTerminalLeafId } from '../../../shared/stable-pane-id' import { setMigrationUnsupportedPty, setMigrationUnsupportedPtyPersistenceListener } from '../../agent-hooks/migration-unsupported-pty-state' import { agentHookServer } from '../../agent-hooks/server' -import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' -import { - backfillAutomationRunNumbers, - pruneAutomationRuns -} from '../../../shared/automation-run-retention' -import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' -import { normalizeRetirableGeneratedName } from '../../worktree-name-retirement' -import { recordRetirementNamespaceRegistry } from '../../worktree-retirement-namespace' -import { - addRetiredNames, - clampExhaustedTiers, - compactRetiredNames, - EMPTY_RETIRED_NAME_REGISTRY, - isEmptyRetiredNameRegistry, - type RetiredNameRegistry -} from '../../../shared/worktree/retired-name-registry' -import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../../../shared/worktree/id' -import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner' -import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' -import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands' -import { normalizeTaskProviderSettings } from '../../../shared/task-providers' -import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings' -import { - addMobilePairingCustomAddress, - normalizeMobilePairingCustomAddress, - normalizeMobilePairingCustomAddresses -} from '../../../shared/mobile-pairing-custom-address' -import { normalizeOpenInApplications } from '../../../shared/open-in-applications' -import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings' -import { normalizeSourceControlGroupOrder } from '../../../shared/source-control-group-order' -import { normalizeAppIconId } from '../../../shared/app-icon' -import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' -import { - normalizeFeatureInteractionTelemetryBuckets, - type FeatureInteractionId -} from '../../../shared/feature-interactions' -import { - parseCodexResetCreditAttemptLedger, - type CodexResetCreditAttemptLedger -} from '../../../shared/codex-reset-credit-attempt-ledger' -import { - DEFAULT_WORKSPACE_STATUS_ID, - normalizePersistedWorkspaceStatuses -} from '../../../shared/workspace-statuses' -import { migrateExternalWorktreeVisibilityDefaults } from '../../../shared/external-worktree-visibility' -import { - clearMissingProjectGroupMemberships, - createProjectGroup, - normalizeProjectGroups -} from '../../../shared/project-groups' -import { createNestedProjectGroupResolver } from '../../project-groups/nested-repo-import' -import { - mergeLegacyCommitMessageAiIntoSourceControlAi, - projectSourceControlAiToLegacyCommitMessageAi, - sourceControlAiSettingsFromLegacy -} from '../../../shared/source-control-ai' -import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' -import { hasUnsupportedTuiAgentArgs } from '../../../shared/tui-agent-launch-defaults' -import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' -import { - normalizeOsc52ClipboardDefaultOn, - osc52ClipboardDefaultOnOverridesPersistedOff -} from '../../../shared/osc52-clipboard-settings' -import { normalizeTerminalLineHeight } from '../../../shared/terminal-line-height-settings' -import { normalizeUiLanguage } from '../../../shared/ui-language' import { ActiveViewPreference } from '../../active-view-preference' +import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-normalization' +import { normalizePersistedPaneIdentityState } from '../restoring-sessions/workspace-pane-normalization' +import { StoreRuntimeState } from './store-runtime-state' import { - collectFolderWorkspaceDiffComments, - normalizeFolderWorkspaceDiffComments -} from '../../folder-workspace-diff-comments' -import { normalizeFolderWorkspaces } from '../../../shared/folder-workspaces' -import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' -import { - collectTerminalScrollbackSnapshotRefs, - deleteTerminalScrollbackSnapshotSync, - getProfileTerminalScrollbackSnapshotRoot, - migrateWorkspaceSessionTerminalScrollbackSnapshots, - readTerminalScrollbackSnapshotSync, - type TerminalScrollbackSnapshotStorage -} from '../../terminal-scrollback-snapshots' -import { - deleteRemovedTerminalScrollbackSnapshotsAsync, - migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync -} from '../../terminal-scrollback-snapshot-async-migration' -import { - isStartupDiagnosticsEnabled, - logStartupDiagnostic -} from '../../startup/startup-diagnostics' -import { - PROTECTED_SECRET_SLOT, - ProtectedSecretPersistence, - sshPtyOwnerLeaseSecretSlot, - type ProtectedSecretRetentionUpdate -} from '../../protected-secret-persistence' -import { - isLegacyOpenCodeSessionCookie, - isLegacySshPtyOwnerLease -} from '../leasing-ssh-ptys/secret-validation' -import { getDataFile, getGithubCacheFile, readGithubCacheSnapshot } from './user-data-path' -import { - STALE_DURABLE_WRITE_TEMP_AGE_MS, - gcStaleWorktreeMeta, - normalizeWorktreeLinkedItemMetadata -} from '../tracking-repos/worktree-metadata-normalization' -import { - migrateAgentYoloDefaults, - migrateTerminalScrollbackRows, - migrateTerminalTuiScrollSensitivityDefault, - stripRetiredGlobalSettings -} from '../applying-settings/terminal-settings-migrations' -import { - normalizeRightSidebarExplorerView, - normalizeRightSidebarTab, - normalizeShowDotfilesByWorktree, - normalizeSortBy -} from '../applying-settings/ui-selection-normalization' -import { - normalizeWorkspaceLineageByChildKey, - stripMainOwnedTelemetryMarkerFromUI -} from '../applying-settings/ui-interaction-merge' -import { - backfillLegacyAutomationContexts, - normalizeAutomationRunWorkspaceDisplayName -} from '../scheduling-automations/automation-context-migration' -import { - normalizeLoadedOnboardingState, - normalizeNotificationSettings, - persistedNotificationSettingsRepaired, - readDeprecatedExperimentFlag, - readLegacySidekickFlag, - resolveSetupGuideSidebarDismissedOnLoad -} from '../applying-settings/onboarding-normalization' -import { - canonicalizePersistedFloatingWorkspaceDirectory, - normalizeFloatingWorkspaceTrustedCwds -} from '../restoring-sessions/floating-workspace-normalization' -import { - ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH, - normalizeSshPtyConsumerRecovery, - normalizeSshRemotePtyLease, - normalizeSshTarget -} from '../leasing-ssh-ptys/ssh-normalization' -import { - type SshTargetStateOperations, - addClaudeLivePtySessionId as addClaudeLivePtySessionIdOperation, - addDeletedSshConfigAlias as addDeletedSshConfigAliasOperation, - addRemovedSshTargetTombstone as addRemovedSshTargetTombstoneOperation, - addSshTarget as addSshTargetOperation, - clearDeletedSshConfigAliases as clearDeletedSshConfigAliasesOperation, - getClaudeLivePtySessionIds as getClaudeLivePtySessionIdsOperation, - getDeletedSshConfigAliases as getDeletedSshConfigAliasesOperation, - getRemovedSshTargetTombstones as getRemovedSshTargetTombstonesOperation, - getSshTarget as getSshTargetOperation, - getSshTargets as getSshTargetsOperation, - removeClaudeLivePtySessionId as removeClaudeLivePtySessionIdOperation, - removeDeletedSshConfigAlias as removeDeletedSshConfigAliasOperation, - removeRemovedSshTargetTombstone as removeRemovedSshTargetTombstoneOperation, - removeSshTarget as removeSshTargetOperation, - updateSshTarget as updateSshTargetOperation -} from '../leasing-ssh-ptys/ssh-target-state' -import { - reassignSshTargetId as reassignSshTargetIdOperation, - type SshTargetReassignmentOperations -} from '../leasing-ssh-ptys/ssh-target-reassignment' -import { - getSshRemotePtyLeases as getSshRemotePtyLeasesOperation, - markSshRemotePtyLease as markSshRemotePtyLeaseOperation, - markSshRemotePtyLeases as markSshRemotePtyLeasesOperation, - markSshRemotePtyLeasesAsync as markSshRemotePtyLeasesAsyncOperation, - markSshRemotePtyLeasesAttachedAsync as markSshRemotePtyLeasesAttachedAsyncOperation, - markSshRemotePtyLeasesForShutdown as markSshRemotePtyLeasesForShutdownOperation, - removeSshRemotePtyLease as removeSshRemotePtyLeaseOperation, - removeSshRemotePtyLeases as removeSshRemotePtyLeasesOperation, - type SshPtyLeaseOperations, - upsertSshRemotePtyLease as upsertSshRemotePtyLeaseOperation -} from '../leasing-ssh-ptys/ssh-pty-lease-operations' -import { - getSshPtyConsumerRecovery as getSshPtyConsumerRecoveryOperation, - removeSshPtyConsumerRecovery as removeSshPtyConsumerRecoveryOperation, - type SshPtyConsumerRecoveryOperations, - upsertSshPtyConsumerRecovery as upsertSshPtyConsumerRecoveryOperation -} from '../leasing-ssh-ptys/ssh-pty-consumer-recovery' -import { - clearSshRemotePtyBindingsForLeases as clearSshRemotePtyBindingsForLeasesOperation, - clearSshRemotePtyBindingsForTarget as clearSshRemotePtyBindingsForTargetOperation, - type SshPtyBindingCleanupOperations -} from '../leasing-ssh-ptys/ssh-pty-binding-cleanup' -import { - cloneLayoutNode, - layoutContainsLeafId, - preserveMissingLeafRecordEntries -} from '../restoring-sessions/terminal-layout-normalization' -import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' -import { - normalizeClaudeLivePtySessionIds, - normalizeLegacyPaneKeyAliasEntries, - normalizeMigrationUnsupportedPtyEntries, - registerPersistedPaneKeyAlias -} from '../restoring-sessions/pane-alias-normalization' -import { - normalizePersistedPaneIdentityState, - normalizeWorkspaceSessionPaneIdentities, - remapAcknowledgedAgentPaneKeys, - remapSshRemotePtyLeaseLeafIds, - type WorkspaceSessionPaneIdentityRemap -} from '../restoring-sessions/workspace-pane-normalization' -import { - mergeProjectHostSetupCompatibilityState, - projectHostSetupCompatibilityStateEqual -} from '../tracking-repos/project-host-compatibility' -import { - cloneWorkspaceSessionState, - createMinimalPersistedTerminalTab -} from '../restoring-sessions/session-owner-fields' -import { - removeWorkspaceSessionOwner, - workspaceSessionOwnerPartitionForHost, - workspaceSessionPartitionIdsForHost -} from '../restoring-sessions/session-owner-removal' -import { backfillFolderScopeConnectionIds } from '../restoring-sessions/folder-scope-migration' -import { - createAutomation as createAutomationOperation, - deleteAutomation as deleteAutomationOperation, - listAutomations as listAutomationsOperation, - updateAutomation as updateAutomationOperation, - type AutomationDefinitionOperations -} from '../scheduling-automations/automation-definition-operations' -import { - createAutomationRun as createAutomationRunOperation, - listAutomationRuns as listAutomationRunsOperation, - snapshotAutomationRunWorkspaceDisplayName as snapshotAutomationRunWorkspaceDisplayNameOperation, - updateAutomationRun as updateAutomationRunOperation, - type AutomationRunOperations -} from '../scheduling-automations/automation-run-operations' -import { - advanceAutomationNextRun as advanceAutomationNextRunOperation, - getLatestAutomationOccurrence as getLatestAutomationOccurrenceOperation -} from '../scheduling-automations/automation-schedule-operations' -import { migrateWorktreeIdentity as migrateWorktreeIdentityOperation } from '../tracking-repos/worktree-identity-migration' -import { - updateSettings as updateSettingsOperation, - type SettingsMutationOperations -} from '../applying-settings/settings-update' -import { getPersistedUI } from '../applying-settings/ui-state-read' -import { updatePersistedUI, type UIUpdateOperations } from '../applying-settings/ui-state-update' -import { ProjectGroupPersistenceOperations } from '../tracking-repos/project-group-operations' -import { FolderWorkspacePersistenceOperations } from '../restoring-sessions/folder-workspace-operations' -import { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations' -import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from '../tracking-repos/repo-worktree-pruning' -import { ProjectHostPersistenceOperations } from '../tracking-repos/project-host-operations' -import { - recordFeatureInteraction as recordFeatureInteractionOperation, - type FeatureInteractionOperations -} from '../applying-settings/feature-interaction-recording' -import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration' -import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' -import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' - -// Why (issue #1158): keep 5 rolling backups at >=1h spacing so a corrupt/empty write leaves an earlier copy recoverable. -const BACKUP_COUNT = 5 -const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 -const WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS = new Set([ - 'tabsByWorktree', - 'terminalLayoutsByTabId' -]) - -function logPersistenceStartupMilestone( - event: string, - details: Record = {} -): void { - if (isStartupDiagnosticsEnabled()) { - logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) - } -} - -function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatch): boolean { - return Object.keys(patch).some((key) => - WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS.has(key as keyof WorkspaceSessionState) - ) -} - -function workspaceSessionSalvageLogDetails(result: { - droppedCount: number - droppedPaths: string[] -}): { count: number; fields: string[]; detailsTruncated: boolean } { - return { - count: result.droppedCount, - fields: [...new Set(result.droppedPaths.map((path) => path.split('.', 1)[0]))], - detailsTruncated: result.droppedCount > result.droppedPaths.length - } -} - -/** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge. - * Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */ -function parseWorkspaceSessionsByHostId( - raw: unknown, - defaults: WorkspaceSessionState -): { partitions: Partial>; repaired: boolean } { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return { partitions: {}, repaired: raw !== undefined } - } - let repaired = false - const partitions: Partial> = {} - for (const [key, value] of Object.entries(raw as Record)) { - const hostId = normalizeExecutionHostId(key) - // Why: 'local' lives in workspaceSession; a local/invalid key here is legacy noise that must not shadow the canonical partition. - if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) { - continue - } - const result = parseWorkspaceSessionSalvaging(value) - if (!result.ok) { - repaired = true - console.error( - `[persistence] Corrupt workspace session for host ${hostId}, using defaults:`, - result.error - ) - continue - } - if (result.droppedCount > 0) { - console.warn( - `[persistence] Salvaged workspace session for host ${hostId}; dropped corrupt entries:`, - workspaceSessionSalvageLogDetails(result) - ) - repaired = true - } - partitions[hostId] = { ...defaults, ...result.value } - } - return { partitions, repaired } -} - -function backupPath(dataFile: string, index: number): string { - return `${dataFile}.bak.${index}` -} - -/** existsSync's non-blocking twin: existsSync is an access(F_OK) probe, so access() is the exact analogue. */ -async function exists(path: string): Promise { - return access(path).then( - () => true, - () => false - ) -} - -function normalizeRetiredNameRegistry(row: unknown): RetiredNameRegistry { - const isPlainArray = Array.isArray(row) - const rawRow = row as { exhaustedTiers?: unknown; names?: unknown } | null | undefined - const rawNames = isPlainArray ? row : Array.isArray(rawRow?.names) ? rawRow.names : [] - const names = new Set() - for (const entry of rawNames) { - if (typeof entry !== 'string') { - continue - } - const normalized = normalizeRetirableGeneratedName(entry) - if (normalized) { - names.add(normalized) - } - } - return compactRetiredNames({ - exhaustedTiers: isPlainArray ? 0 : clampExhaustedTiers(rawRow?.exhaustedTiers), - names: [...names] - }) -} - -function normalizeRetiredNameRegistryMap(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return {} - } - const byRepo: Record = {} - for (const [repoId, row] of Object.entries(value as Record)) { - if (!repoId) { - continue - } - const registry = normalizeRetiredNameRegistry(row) - if (!isEmptyRetiredNameRegistry(registry)) { - byRepo[repoId] = registry - } - } - return byRepo -} - -function deleteRemovedTerminalScrollbackSnapshots( - prior: WorkspaceSessionState | undefined, - next: WorkspaceSessionState, - storage?: TerminalScrollbackSnapshotStorage -): void { - if (!prior) { - return - } - const nextRefs = collectTerminalScrollbackSnapshotRefs(next) - for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) { - if (!nextRefs.has(ref)) { - deleteTerminalScrollbackSnapshotSync(ref, storage) - } - } -} - -export type StoreOptions = { - dataFile?: string -} + createStoreDomains, + installStoreDomainContexts, + STORE_DOMAIN_OPERATION_CLASSES, + type StoreDomains +} from './store-domain-composition' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { scheduleSave } from './write-scheduling' +import type { WriteSchedulingOperations } from './write-scheduling' +import type { PrimaryStateWriteOperations } from './primary-state-writes' +import type { ProjectCollectionOperations } from './project-collection-operations' +import type { RepoLifecycleOperations } from './repo-lifecycle-operations' +import type { MobileTabSelectionPersistence } from './mobile-tab-selection-persistence' +import type { SparsePresetPersistence } from './sparse-preset-persistence' +import type { AutomationPersistence } from './automation-persistence' +import type { MetadataLineageOperations } from './metadata-lineage-operations' +import type { ProfilePreferences } from './profile-preferences' +import type { SessionHostPartitionOperations } from './session-host-partitions' +import type { SessionSnapshotOperations } from './session-snapshot-operations' +import type { PtyBindingPersistenceOperations } from './pty-binding-persistence' +import type { SshProfileOperations } from './ssh-profile-operations' +import type { RetiredWorktreeNamePersistence } from './retired-worktree-name-persistence' +import type { SshLeaseRecoveryOperations } from './ssh-lease-recovery-operations' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' +export type StoreOptions = { dataFile?: string } export type PtyBindingSourceExpectation = { worktreeId?: string tabId: string @@ -508,69 +42,28 @@ export type PtyBindingSourceExpectation = { incarnationId?: string } +/** Concrete composition root for profile persistence. */ +// oxlint-disable-next-line typescript-eslint/no-unsafe-declaration-merging -- Store installs the exact concrete domain class descriptors and contexts below export class Store { - // Why readonly: the operations wrappers below capture this reference once and are memoized. + private readonly runtime: StoreRuntimeState + private readonly domains: StoreDomains private readonly state: PersistedState - private readonly dataFile: string - private readonly activeViewPreference: ActiveViewPreference - private readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage - private writeTimer: ReturnType | null = null - private pendingWrite: Promise | null = null - private pendingSnapshotFileWork: Promise | null = null - private readonly staleTempCleanup: Promise - private writeGeneration = 0 - private inFlightAsyncTmpFile: string | null = null - // Prevent a sync flush from interleaving a second rotation with awaited ring mutations. - private backupRotationInFlight = false - // Why: after a profile transfer rewrites this file on disk, a late flush of stale in-memory state would resurrect the moved project. - private writesFrozen = false - /** Set by flushAsync so the quit flush is the final write; see scheduleSave. */ - private quitFlushStarted = false - private quitFlushPromise: Promise | null = null - // Content hash at last write, to skip no-op writes; derived from the payload with encrypted blobs normalized back to plaintext (see buildStateToSave), since encrypt() uses a random IV per call. - private lastWrittenStateHash: string | null = null - private lastDurableWriteGeneration = -1 - private firstPendingSaveAt: number | null = null - private githubCacheDirty = false - private githubCacheGeneration = 0 - private pendingGithubCacheWrite: Promise | null = null - private readonly staleGithubCacheTempCleanup: Promise - private readonly gitUsernameCache = new Map() - private readonly protectedSecrets = new ProtectedSecretPersistence() - private loadNeedsSave = false - private settingsChangeListeners = new Set< - ( - updates: Partial, - settings: GlobalSettings, - originWebContentsId?: number - ) => void - >() - private uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>() constructor(options: StoreOptions = {}) { - // Why: profile switching yields multiple state paths; capture per Store so late async writes can't follow a global path. - this.dataFile = options.dataFile ?? getDataFile() - this.staleTempCleanup = removeStaleDurableWriteTempFiles(this.dataFile, { - minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS - }) - this.staleGithubCacheTempCleanup = removeStaleDurableWriteTempFiles( - getGithubCacheFile(this.dataFile), - { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS } - ) - const profileSnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(this.dataFile) - const legacySnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(getDataFile()) - this.terminalScrollbackSnapshotStorage = { - snapshotRoot: profileSnapshotRoot, - fallbackSnapshotRoot: legacySnapshotRoot === profileSnapshotRoot ? null : legacySnapshotRoot - } - const loaded = this.load() + this.runtime = new StoreRuntimeState(options) + this.domains = createStoreDomains(this.runtime) + installStoreDomainContexts(this, this.domains) + this.runtime.flushOrThrow = () => this.flushOrThrow() + const loaded = this.domains.loader.load() const normalized = normalizePersistedPaneIdentityState(loaded) this.state = normalized.state - // Why: activeView is a frequent, tiny preference; keeping it beside the - // profile avoids serializing the multi-MB recovery store on navigation. - this.activeViewPreference = new ActiveViewPreference(this.dataFile, this.state.ui?.activeView) - const adaptedProjectGroups = this.adaptFlatFolderScanProjectGroups() - this.hydrateFolderWorkspaceDiffComments() + this.runtime.state = this.state + this.runtime.activeViewPreference = new ActiveViewPreference( + this.runtime.dataFile, + this.state.ui?.activeView + ) + const adaptedProjectGroups = this.domains.adaptation.adaptFlatFolderScanProjectGroups() + this.domains.adaptation.hydrateFolderWorkspaceDiffComments() for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } @@ -579,3341 +72,55 @@ export class Store { } setMigrationUnsupportedPtyPersistenceListener((entries) => { this.state.migrationUnsupportedPtyEntries = entries - this.scheduleSave() + scheduleSave(this.domains.scheduling) }) agentHookServer.setPaneKeyAliasPersistenceListener((entries) => { this.state.legacyPaneKeyAliasEntries = entries - this.scheduleSave() + scheduleSave(this.domains.scheduling) }) - if (normalized.changed || this.loadNeedsSave || adaptedProjectGroups) { - // Why: rewrite legacy pane:1 leaves so older renderer writes can't revive them; other migrations also set loadNeedsSave. - this.scheduleSave() + if (normalized.changed || this.runtime.loadNeedsSave || adaptedProjectGroups) { + scheduleSave(this.domains.scheduling) } } - // Why: notes live top-level on disk so an older build's field-by-field - // normalizeFolderWorkspaces can't drop them; re-attach them to the in-memory records here. - private hydrateFolderWorkspaceDiffComments(): void { - const stored = this.state.folderWorkspaceDiffComments - let relocatedInline = false - for (const workspace of this.state.folderWorkspaces ?? []) { - if (Array.isArray(workspace.diffComments) && workspace.diffComments.length > 0) { - // Inline wins: an intervening rollback to a #14112 build writes notes inline and leaves the - // older map untouched, so inline is the last notes-aware write. Also makes the relocation - // durable even if the user never edits anything this session. - relocatedInline = true - continue - } - const comments = stored?.[workspace.id] - // Not `??`: a degenerate `{ id: [] }` entry must not delete an intact inline value. - if (Array.isArray(comments) && comments.length > 0) { - workspace.diffComments = comments - } - } - if (relocatedInline) { - this.loadNeedsSave = true - } - // Write-only projection: buildStateToSave() is the only producer, so leaving the loaded map in - // state would make it a stale second source of truth that getDurableState() spreads back out. - delete this.state.folderWorkspaceDiffComments - } - - private adaptFlatFolderScanProjectGroups(): boolean { - // Why: older folder imports kept a real parent path but flat repos; upgrade that shape into v1 sparse folder scopes. - const groups = this.state.projectGroups ?? [] - const repos = this.state.repos - if (groups.length === 0 || repos.length === 0) { - return false - } - - let changed = false - let maxOrder = -1 - for (const group of groups) { - maxOrder = Math.max(maxOrder, group.tabOrder) - } - - const childGroupIds = new Set( - groups.flatMap((group) => (group.parentGroupId ? [group.parentGroupId] : [])) - ) - const initialGroupCount = groups.length - for (let groupIndex = 0; groupIndex < initialGroupCount; groupIndex += 1) { - const rootGroup = groups[groupIndex] - if (!rootGroup) { - continue - } - if ( - rootGroup.createdFrom !== 'folder-scan' || - !rootGroup.parentPath || - rootGroup.parentGroupId || - childGroupIds.has(rootGroup.id) - ) { - continue - } - const rootPath = rootGroup.parentPath - const repoCandidates = repos.filter( - (repo) => - !isFolderRepo(repo) && - repo.projectGroupId === rootGroup.id && - isPathInsideOrEqual(rootPath, repo.path) - ) - if (repoCandidates.length < 2) { - continue - } - - const resolver = createNestedProjectGroupResolver({ - parentPath: rootPath, - groupName: rootGroup.name, - mode: 'group', - repoPaths: repoCandidates.map((repo) => repo.path), - createGroup: (input) => { - if (!input.parentGroupId) { - return rootGroup - } - maxOrder += 1 - const group = createProjectGroup({ - ...input, - tabOrder: maxOrder - }) - groups.push(group) - changed = true - return group - } - }) - const nextOrderByGroupId = new Map() - for (const repo of repoCandidates) { - const group = resolver.getGroupForRepo(repo.path) - if (!group) { - continue - } - const nextOrder = nextOrderByGroupId.get(group.id) ?? 0 - nextOrderByGroupId.set(group.id, nextOrder + 1) - if (repo.projectGroupId !== group.id || repo.projectGroupOrder !== nextOrder) { - repo.projectGroupId = group.id - repo.projectGroupOrder = nextOrder - changed = true - } - } - } - return changed - } - - // Why (#1158): debounced writes fire ~every 300ms; throttle backups to distinct moments, not near-identical snapshots. - private shouldRotateBackups(now: number, dataFile: string): boolean { - try { - const mtime = statSync(backupPath(dataFile, 0)).mtimeMs - return now - mtime >= BACKUP_MIN_INTERVAL_MS - } catch { - return true - } - } - - // Why separate from the sync twin: a statSync here parks the Electron main thread in uninterruptible - // sleep on a stalled SMB/NFS profile mount. Error semantics are deliberately identical: rotate. - private async shouldRotateBackupsAsync(dataFile: string): Promise { - try { - const mtime = (await stat(backupPath(dataFile, 0))).mtimeMs - return Date.now() - mtime >= BACKUP_MIN_INTERVAL_MS - } catch { - return true - } - } - - // Why: rotate current file into the .bak ring so load() can recover if a later primary write is truncated or corrupt. - private async rotateBackupsAsync(dataFile: string): Promise { - if (this.backupRotationInFlight) { - return - } - this.backupRotationInFlight = true - try { - if (!(await this.shouldRotateBackupsAsync(dataFile))) { - return - } - if (!(await exists(dataFile))) { - return - } - await rm(backupPath(dataFile, BACKUP_COUNT - 1)).catch((err: unknown) => { - if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('[persistence] Failed to remove oldest backup:', err) - } - }) - for (let i = BACKUP_COUNT - 2; i >= 0; i--) { - const src = backupPath(dataFile, i) - const dst = backupPath(dataFile, i + 1) - // Why probe instead of rename-then-swallow-ENOENT: a degraded mount rejects a rename of an - // absent slot with ESTALE/EIO, which would log once per empty slot on every debounced save. - if (await exists(src)) { - await rename(src, dst).catch((err) => { - console.error('[persistence] Failed to rotate backup', src, '->', dst, err) - }) - } - } - await copyFile(dataFile, backupPath(dataFile, 0)).catch((err) => { - console.error('[persistence] Failed to snapshot current file to .bak.0:', err) - }) - } finally { - this.backupRotationInFlight = false - } - } - - private rotateBackupsSync(dataFile: string): void { - if (!existsSync(dataFile)) { - return - } - try { - unlinkSync(backupPath(dataFile, BACKUP_COUNT - 1)) - } catch (err) { - if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('[persistence] Failed to remove oldest backup:', err) - } - } - for (let i = BACKUP_COUNT - 2; i >= 0; i--) { - const src = backupPath(dataFile, i) - const dst = backupPath(dataFile, i + 1) - if (existsSync(src)) { - try { - renameSync(src, dst) - } catch (err) { - console.error('[persistence] Failed to rotate backup', src, '->', dst, err) - } - } - } - try { - copyFileSync(dataFile, backupPath(dataFile, 0)) - } catch (err) { - console.error('[persistence] Failed to snapshot current file to .bak.0:', err) - } - } - - private restoreFromBackup(dataFile: string): boolean { - for (let i = 0; i < BACKUP_COUNT; i++) { - const path = backupPath(dataFile, i) - if (!existsSync(path)) { - continue - } - try { - const raw = readFileSync(path, 'utf-8') - JSON.parse(raw) - mkdirSync(dirname(dataFile), { recursive: true }) - writeFileSync(dataFile, raw, 'utf-8') - console.warn(`[persistence] Recovered state from backup slot ${i}: ${path}`) - return true - } catch (err) { - console.error(`[persistence] Backup slot ${i} unusable, trying next:`, err) - } - } - return false - } - - private load(allowBackupRecovery = true): PersistedState { - // Capture "has run Orca before?" for telemetry cohort; the telemetry field is new, so field inference misclassifies old users as fresh. - const dataFile = this.dataFile - const fileExistedOnLoad = existsSync(dataFile) - logPersistenceStartupMilestone('persistence-load-start', { - fileExists: fileExistedOnLoad - }) - - let result: PersistedState | null = null - try { - if (fileExistedOnLoad) { - const readStartedAt = performance.now() - const raw = readFileSync(dataFile, 'utf-8') - logPersistenceStartupMilestone('persistence-read-done', { - bytes: Buffer.byteLength(raw), - durationMs: Math.round(performance.now() - readStartedAt) - }) - logPersistenceStartupMilestone('persistence-json-parse-start') - const parsed = JSON.parse(raw) as PersistedState - logPersistenceStartupMilestone('persistence-json-parse-done') - - // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. - if (parsed.settings?.opencodeSessionCookie) { - parsed.settings.opencodeSessionCookie = this.protectedSecrets.decrypt( - PROTECTED_SECRET_SLOT.opencodeSessionCookie, - parsed.settings.opencodeSessionCookie, - isLegacyOpenCodeSessionCookie - ) - } - if (parsed.settings?.httpProxyUrl) { - const decryptedProxy = this.protectedSecrets.decryptWithStatus( - PROTECTED_SECRET_SLOT.httpProxyUrl, - parsed.settings.httpProxyUrl, - (value) => normalizeProxyUrl(value).ok - ) - // Why (STA-3442): after a keychain reset decrypt returns raw ciphertext; a non-URL - // value must not masquerade as a configured proxy (silent DIRECT fallback) or - // re-persist as garbage. Plaintext URLs still pass, preserving the upgrade path. - if ( - decryptedProxy.status === 'unavailable' || - (decryptedProxy.status === 'failed' && !decryptedProxy.plaintext) - ) { - parsed.settings.httpProxyUrl = '' - } else if (normalizeProxyUrl(decryptedProxy.plaintext).ok) { - parsed.settings.httpProxyUrl = decryptedProxy.plaintext - } else { - console.warn( - '[persistence] httpProxyUrl could not be decrypted — clearing the stored proxy URL. Re-enter it in Settings > Advanced > Network.' - ) - parsed.settings.httpProxyUrl = '' - this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) - this.loadNeedsSave = true - } - } - if (parsed.ui?.browserKagiSessionLink) { - parsed.ui.browserKagiSessionLink = this.protectedSecrets.decrypt( - PROTECTED_SECRET_SLOT.browserKagiSessionLink, - parsed.ui.browserKagiSessionLink, - (value) => normalizeKagiSessionLink(value) !== null - ) - } - parsed.sshPtyConsumerRecoveries = ( - Array.isArray(parsed.sshPtyConsumerRecoveries) ? parsed.sshPtyConsumerRecoveries : [] - ) - .map((record) => - normalizeSshPtyConsumerRecovery(record, ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH) - ) - .filter((record): record is SshPtyConsumerRecovery => record !== null) - .map((record) => { - const slot = sshPtyOwnerLeaseSecretSlot(record.targetId) - const decrypted = this.protectedSecrets.decryptWithStatus( - slot, - record.ownerLease, - isLegacySshPtyOwnerLease - ) - const normalized = - decrypted.status === 'unavailable' || - (decrypted.status === 'failed' && !decrypted.plaintext) - ? record - : normalizeSshPtyConsumerRecovery({ ...record, ownerLease: decrypted.plaintext }) - if (!normalized) { - this.protectedSecrets.removeRetainedBlob(slot) - } - return normalized - }) - .filter((record): record is SshPtyConsumerRecovery => record !== null) - - // Merge with defaults in case new fields were added - const homeDir = homedir() - const defaults = getDefaultPersistedState(homeDir) - const migratedExternalVisibility = migrateExternalWorktreeVisibilityDefaults( - Array.isArray(parsed.repos) ? parsed.repos : [], - parsed.settings?.worktreeVisibilityDefaults - ) - if (migratedExternalVisibility.changed) { - this.loadNeedsSave = true - } - const migratedTerminalScrollback = migrateTerminalScrollbackRows(parsed.settings) - if (migratedTerminalScrollback.needsSave) { - this.loadNeedsSave = true - } - if ( - parsed.settings && - typeof parsed.settings === 'object' && - Object.hasOwn(parsed.settings, 'enableGitHubAttribution') - ) { - this.loadNeedsSave = true - } - const migratedTerminalTuiScrollSensitivity = migrateTerminalTuiScrollSensitivityDefault( - parsed.settings - ) - if (migratedTerminalTuiScrollSensitivity.needsSave) { - this.loadNeedsSave = true - } - const rawSourceControlAi = parsed.settings?.sourceControlAi - const rawSourceControlAiMissing = rawSourceControlAi === undefined - const rawSourceControlAiActionsMissing = - rawSourceControlAi !== undefined && rawSourceControlAi.actions === undefined - if (rawSourceControlAiMissing || rawSourceControlAiActionsMissing) { - this.loadNeedsSave = true - } - const legacyCommitMessageAi = parsed.settings?.commitMessageAi - const migratedSourceControlAi = rawSourceControlAiMissing - ? sourceControlAiSettingsFromLegacy( - legacyCommitMessageAi ?? defaults.settings.commitMessageAi - ) - : mergeLegacyCommitMessageAiIntoSourceControlAi( - parsed.settings?.sourceControlAi, - legacyCommitMessageAi - ) - // Why (issue #903): old 'true' default broke non-US Option-layer chars; flip 'true'→'auto' once so the layout probe decides. - const rawOptionAsAlt = parsed.settings?.terminalMacOptionAsAlt - const alreadyMigrated = parsed.settings?.terminalMacOptionAsAltMigrated === true - const migratedOptionAsAlt: 'auto' | 'true' | 'false' | 'left' | 'right' = alreadyMigrated - ? (rawOptionAsAlt ?? 'auto') - : rawOptionAsAlt === undefined || rawOptionAsAlt === 'true' - ? 'auto' - : rawOptionAsAlt - const floatingTerminalDefaultedForAllUsers = - parsed.settings?.floatingTerminalDefaultedForAllUsers === true - // Why: early builds persisted the old off default; flip only unmigrated profiles so a later opt-out survives reload. - const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers - ? (parsed.settings?.floatingTerminalEnabled ?? true) - : true - // Why: the old off default persisted `false` for every profile, indistinguishable from a real opt-out — flip unmigrated profiles once (#10567). - const migratedOsc52Clipboard = normalizeOsc52ClipboardDefaultOn(parsed.settings) - const osc52ClipboardNoticePending = - osc52ClipboardDefaultOnOverridesPersistedOff(parsed.settings) || - parsed.ui?.osc52ClipboardDefaultOnNoticePending === true - if (parsed.settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== true) { - this.loadNeedsSave = true - } - const floatingTerminalCwdMigrated = - parsed.settings?.floatingTerminalCwdMigratedToAppWorkspace === true - // Why: an earlier migration wrote '' for the notes dir; floating terminals still open at home, notes use a separate IPC. - const migratedFloatingTerminalCwd = floatingTerminalCwdMigrated - ? !parsed.settings?.floatingTerminalCwd - ? defaults.settings.floatingTerminalCwd - : parsed.settings.floatingTerminalCwd - : parsed.settings?.floatingTerminalCwd === undefined - ? defaults.settings.floatingTerminalCwd - : parsed.settings.floatingTerminalCwd - const normalizedFloatingTerminalTrustedCwds = normalizeFloatingWorkspaceTrustedCwds( - parsed.settings?.floatingTerminalTrustedCwds, - homeDir - ) - const migratedFloatingTerminalTrustedCwds = [ - ...normalizedFloatingTerminalTrustedCwds.trustedCwds - ] - const rawLegacyFloatingTerminalCwd = parsed.settings?.floatingTerminalCwd - const shouldTrustLegacyFloatingTerminalCwd = - !floatingTerminalCwdMigrated && - typeof rawLegacyFloatingTerminalCwd === 'string' && - rawLegacyFloatingTerminalCwd.trim().length > 0 && - rawLegacyFloatingTerminalCwd.trim() !== '~' - if (!floatingTerminalCwdMigrated) { - this.loadNeedsSave = true - } - if (shouldTrustLegacyFloatingTerminalCwd && rawLegacyFloatingTerminalCwd) { - const canonicalLegacyCwd = canonicalizePersistedFloatingWorkspaceDirectory( - rawLegacyFloatingTerminalCwd, - homeDir - ) - if ( - canonicalLegacyCwd && - !migratedFloatingTerminalTrustedCwds.includes(canonicalLegacyCwd) - ) { - // Why: pre-grant profiles with an explicit Floating Workspace cwd already showed intent; migrate only that legacy value. - migratedFloatingTerminalTrustedCwds.push(canonicalLegacyCwd) - normalizedFloatingTerminalTrustedCwds.changed = true - } - } - if (normalizedFloatingTerminalTrustedCwds.changed) { - this.loadNeedsSave = true - } - const experimentalActivityDefaultedOffForAllUsers = - parsed.settings?.experimentalActivityDefaultedOffForAllUsers === true - // Why: the Agents view moved back behind Experimental; flip pre-migration profiles off once, then preserve opt-ins. - const migratedExperimentalActivity = experimentalActivityDefaultedOffForAllUsers - ? (parsed.settings?.experimentalActivity ?? false) - : false - const autoRenameBranchFromWorkDefaultedOn = - parsed.settings?.autoRenameBranchFromWorkDefaultedOn === true - // Why: default-on rollout activates old profiles once, but a later Settings opt-out survives reloads. - const migratedAutoRenameBranchFromWork = normalizeAutoRenameBranchFromWorkDefaultOn( - parsed.settings - ) - const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings) - if ( - parsed.settings?.terminalCursorStyle !== - migratedTerminalCursorStyle.terminalCursorStyle || - parsed.settings?.terminalCursorStyleDefaultedToBlock !== true - ) { - this.loadNeedsSave = true - } - const migratedTerminalLineHeight = normalizeTerminalLineHeight( - parsed.settings?.terminalLineHeight - ) - const terminalRightClickToPasteDefaultedForPlatform = - parsed.settings?.terminalRightClickToPasteDefaultedForPlatform === true - if (!terminalRightClickToPasteDefaultedForPlatform) { - this.loadNeedsSave = true - } - if ( - parsed.settings?.terminalLineHeight !== undefined && - parsed.settings.terminalLineHeight !== migratedTerminalLineHeight - ) { - this.loadNeedsSave = true - } - const rawTaskProviderSettings = normalizeTaskProviderSettings({ - visibleTaskProviders: parsed.settings?.visibleTaskProviders, - defaultTaskSource: parsed.settings?.defaultTaskSource - }) - const visibleTaskProvidersDefaultedForJira = - parsed.settings?.visibleTaskProvidersDefaultedForJira === true - const migratedVisibleTaskProviders = visibleTaskProvidersDefaultedForJira - ? rawTaskProviderSettings.visibleTaskProviders - : rawTaskProviderSettings.visibleTaskProviders.includes('jira') - ? rawTaskProviderSettings.visibleTaskProviders - : [...rawTaskProviderSettings.visibleTaskProviders, 'jira' as const] - const taskProviderSettings = normalizeTaskProviderSettings({ - visibleTaskProviders: migratedVisibleTaskProviders, - defaultTaskSource: rawTaskProviderSettings.defaultTaskSource - }) - const primarySelectionDefaultedForLinux = - parsed.settings?.primarySelectionMiddleClickPasteDefaultedForLinux === true - const primarySelectionDefaultedForTerminalDefaults = - parsed.settings?.primarySelectionMiddleClickPasteDefaultedForTerminalDefaults === true - const primarySelectionPlatformDefaultEnabled = - defaults.settings.primarySelectionMiddleClickPaste === true - const primarySelectionAlreadyDefaultedForPlatform = - primarySelectionDefaultedForTerminalDefaults || - (process.platform === 'linux' && primarySelectionDefaultedForLinux) - const migratePrimarySelectionPlatformDefault = - primarySelectionPlatformDefaultEnabled && !primarySelectionAlreadyDefaultedForPlatform - const stampPrimarySelectionTerminalDefaults = - primarySelectionPlatformDefaultEnabled && !primarySelectionDefaultedForTerminalDefaults - if (migratePrimarySelectionPlatformDefault || stampPrimarySelectionTerminalDefaults) { - this.loadNeedsSave = true - } - if (!visibleTaskProvidersDefaultedForJira) { - this.loadNeedsSave = true - } - const claudeAgentTeamsDefaultDisabledMigrated = - parsed.settings?.claudeAgentTeamsDefaultDisabledMigrated === true - if (!claudeAgentTeamsDefaultDisabledMigrated) { - this.loadNeedsSave = true - } - const migratedDisabledTuiAgents = normalizeDisabledTuiAgents( - parsed.settings?.disabledTuiAgents - ) - const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings) - if ( - parsed.settings?.agentYoloDefaultsMigrated !== true || - hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) || - hasUnsupportedTuiAgentArgs('kilo', parsed.settings?.agentDefaultArgs?.kilo) - ) { - this.loadNeedsSave = true - } - if ( - !claudeAgentTeamsDefaultDisabledMigrated && - !migratedDisabledTuiAgents.includes('claude-agent-teams') - ) { - migratedDisabledTuiAgents.push('claude-agent-teams') - } - const migratedWindowsRuntimeDefault = - parsed.settings?.localWindowsRuntimeDefault === undefined - ? deriveGlobalWindowsRuntimeDefaultFromLegacySettings(parsed.settings).defaultRuntime - : parsed.settings.localWindowsRuntimeDefault - if ( - parsed.settings?.localWindowsRuntimeDefault === undefined && - migratedWindowsRuntimeDefault.kind === 'wsl' - ) { - this.loadNeedsSave = true - } - // Why (#9537): migrate the indistinguishable legacy host default once so WSL-default users follow their runtime. - const localAccountRuntimeAlreadyMigrated = - parsed.settings?.localAccountRuntimeDefaultedToAutoForAllUsers === true - const migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] = - localAccountRuntimeAlreadyMigrated - ? (parsed.settings?.localAccountRuntime ?? defaults.settings.localAccountRuntime) - : parsed.settings?.localAccountRuntime === 'wsl' - ? 'wsl' - : 'auto' - if (!localAccountRuntimeAlreadyMigrated) { - this.loadNeedsSave = true - } - if (!autoRenameBranchFromWorkDefaultedOn) { - this.loadNeedsSave = true - } - const normalizedOnboarding = normalizeLoadedOnboardingState( - parsed.onboarding, - defaults.onboarding - ) - if (!parsed.onboarding) { - this.loadNeedsSave = true - } - const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups) - const loadedCompactWorktreeCards = - parsed.settings?.compactWorktreeCards ?? - parsed.settings?.experimentalCompactWorktreeCards ?? - defaults.settings.compactWorktreeCards - const mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( - parsed.settings?.mobilePairingCustomAddress - ) - const rawMobilePairingCustomAddresses = parsed.settings?.mobilePairingCustomAddresses - const mobilePairingCustomAddresses = mobilePairingCustomAddress - ? addMobilePairingCustomAddress( - normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses), - mobilePairingCustomAddress - ) - : normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses) - if ( - parsed.settings?.mobilePairingCustomAddress !== undefined && - parsed.settings.mobilePairingCustomAddress !== mobilePairingCustomAddress - ) { - this.loadNeedsSave = true - } - const customAddressesMatch = - Array.isArray(rawMobilePairingCustomAddresses) && - rawMobilePairingCustomAddresses.length === mobilePairingCustomAddresses.length && - rawMobilePairingCustomAddresses.every( - (address, index) => address === mobilePairingCustomAddresses[index] - ) - if ( - (rawMobilePairingCustomAddresses !== undefined || mobilePairingCustomAddress !== null) && - !customAddressesMatch - ) { - this.loadNeedsSave = true - } - const normalizedNotifications = normalizeNotificationSettings( - parsed.settings?.notifications - ) - // Why: a type-flipped notification field is repaired in memory only; without a dirty mark the - // bad value stays on disk and the repair reruns on every launch. - if ( - persistedNotificationSettingsRepaired( - parsed.settings?.notifications, - normalizedNotifications - ) - ) { - this.loadNeedsSave = true - } - const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( - parsed.settings?.sourceControlGroupOrder - ) - if ( - parsed.settings?.sourceControlGroupOrder !== undefined && - parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder - ) { - this.loadNeedsSave = true - } - result = { - ...defaults, - ...parsed, - featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets( - parsed.featureInteractionTelemetryBuckets - ), - projectGroups: normalizedProjectGroups, - repos: migratedExternalVisibility.repos, - folderWorkspaces: normalizeFolderWorkspaces( - parsed.folderWorkspaces, - normalizedProjectGroups - ), - folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments( - parsed.folderWorkspaceDiffComments - ), - worktreeLineageById: parsed.worktreeLineageById ?? {}, - mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( - parsed.mobileClientTabSelectionsByDeviceId - ), - workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey( - parsed.workspaceLineageByChildKey - ), - settings: { - ...defaults.settings, - // Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed. - ...stripRetiredGlobalSettings(parsed.settings), - worktreeVisibilityDefaults: migratedExternalVisibility.defaults, - prBotAuthorOverrides: normalizePRBotAuthorOverrides( - parsed.settings?.prBotAuthorOverrides - ), - // Why: v1.3.42 renamed the sidekick setting to pet; carry the old flag forward once so enabled users don't lose it. - experimentalPet: - parsed.settings?.experimentalPet ?? readLegacySidekickFlag(parsed) ?? false, - // Why: early builds saved the disabled default; flip Linux/macOS profiles once to match platform, guards keep opt-outs. - primarySelectionMiddleClickPaste: migratePrimarySelectionPlatformDefault - ? true - : (parsed.settings?.primarySelectionMiddleClickPaste ?? - defaults.settings.primarySelectionMiddleClickPaste), - primarySelectionMiddleClickPasteDefaultedForLinux: - primarySelectionDefaultedForLinux || - (process.platform === 'linux' && migratePrimarySelectionPlatformDefault), - primarySelectionMiddleClickPasteDefaultedForTerminalDefaults: - primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults, - ...migratedAutoRenameBranchFromWork, - ...migratedTerminalCursorStyle, - terminalLineHeight: migratedTerminalLineHeight, - // Why: the old true default was inherited, but false was always an explicit opt-out and must survive this one-shot reset. - terminalRightClickToPaste: terminalRightClickToPasteDefaultedForPlatform - ? (parsed.settings?.terminalRightClickToPaste ?? - defaults.settings.terminalRightClickToPaste) - : parsed.settings?.terminalRightClickToPaste === false - ? false - : defaults.settings.terminalRightClickToPaste, - terminalRightClickToPasteDefaultedForPlatform: true, - ...migratedTerminalTuiScrollSensitivity.settings, - experimentalActivity: migratedExperimentalActivity, - experimentalActivityDefaultedOffForAllUsers: true, - // Why: compact worktree cards graduated from Experimental; preserve the old opt-in for rollout-era profiles. - compactWorktreeCards: loadedCompactWorktreeCards, - experimentalCompactWorktreeCards: undefined, - terminalMacOptionAsAlt: migratedOptionAsAlt, - terminalMacOptionAsAltMigrated: true, - localWindowsRuntimeDefault: migratedWindowsRuntimeDefault, - localAccountRuntime: migratedLocalAccountRuntime, - localAccountRuntimeDefaultedToAutoForAllUsers: true, - ...migratedOsc52Clipboard, - floatingTerminalEnabled: migratedFloatingTerminalEnabled, - floatingTerminalDefaultedForAllUsers: true, - floatingTerminalCwd: migratedFloatingTerminalCwd, - floatingTerminalTrustedCwds: migratedFloatingTerminalTrustedCwds, - floatingTerminalCwdMigratedToAppWorkspace: true, - terminalScrollbackRows: migratedTerminalScrollback.rows, - terminalQuickCommands: normalizeTerminalQuickCommands( - parsed.settings?.terminalQuickCommands - ), - terminalCustomThemes: normalizeTerminalCustomThemes( - parsed.settings?.terminalCustomThemes - ), - appIcon: normalizeAppIconId(parsed.settings?.appIcon), - mobilePairingCustomAddress, - mobilePairingCustomAddresses, - // Why: persisted settings may be hand-edited or from older builds; keep tray-minimize false unless stored value is true. - minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true, - // Why: missing means default-on; round-trips unchanged on non-mac since darwin consumers gate the effect. - showMenuBarIcon: parsed.settings?.showMenuBarIcon !== false, - uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage), - defaultTaskSource: taskProviderSettings.defaultTaskSource, - visibleTaskProviders: taskProviderSettings.visibleTaskProviders, - visibleTaskProvidersDefaultedForJira: true, - terminalShortcutPolicy: normalizeTerminalShortcutPolicy( - parsed.settings?.terminalShortcutPolicy - ), - disabledTuiAgents: migratedDisabledTuiAgents, - ...migratedAgentYoloDefaults, - claudeAgentTeamsDefaultDisabledMigrated: true, - openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, { - seedDefaults: true - }), - notifications: normalizedNotifications, - sourceControlAi: migratedSourceControlAi, - sourceControlGroupOrder: normalizedSourceControlGroupOrder, - // Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat. - commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( - migratedSourceControlAi, - parsed.settings?.commitMessageAi ?? defaults.settings.commitMessageAi - ), - voice: { - ...getDefaultVoiceSettings(), - ...parsed.settings?.voice - } - }, - // Why: legacy 'recent' meant the smart sort; migrate once on the raw value so a fresh 'recent' default isn't remigrated. - ui: (() => { - const rawSort = parsed.ui?.sortBy - const sort = normalizeSortBy(rawSort) - const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent' - const rightSidebarOpen = - typeof parsed.ui?.rightSidebarOpen === 'boolean' - ? parsed.ui.rightSidebarOpen - : typeof parsed.settings?.rightSidebarOpenByDefault === 'boolean' - ? parsed.settings.rightSidebarOpenByDefault - : defaults.ui.rightSidebarOpen - if (typeof parsed.ui?.rightSidebarOpen !== 'boolean') { - this.loadNeedsSave = true - } - const workspaceStatusesDefaultOrderMigrated = - parsed.ui?._workspaceStatusesDefaultOrderMigrated === true - // Why: a short-lived default put Done on the left; repair only the exact raw payload once so user reorders survive. - const workspaceStatusesReorderedDefaultRepaired = - parsed.ui?._workspaceStatusesReorderedDefaultRepaired === true - // Why: only exact legacy default payloads migrate; customized status labels/colors/icons/order are kept. - const workspaceStatusesDefaultWorkflowMigrated = - parsed.ui?._workspaceStatusesDefaultWorkflowMigrated === true - // Why: visual migration has its own guard so later user choices of valid legacy color/icon IDs are preserved. - const workspaceStatusesDefaultVisualsMigrated = - parsed.ui?._workspaceStatusesDefaultVisualsMigrated === true - const workspaceStatuses = normalizePersistedWorkspaceStatuses( - parsed.ui?.workspaceStatuses, - { - migrateDefaultWorkflowStatuses: !workspaceStatusesDefaultWorkflowMigrated, - repairReorderedDefaultStatuses: !workspaceStatusesReorderedDefaultRepaired, - migrateLegacyDefaultStatusVisuals: !workspaceStatusesDefaultVisualsMigrated - } - ) - if ( - !workspaceStatusesDefaultOrderMigrated || - !workspaceStatusesReorderedDefaultRepaired || - !workspaceStatusesDefaultWorkflowMigrated || - !workspaceStatusesDefaultVisualsMigrated - ) { - this.loadNeedsSave = true - } - const rawCardProps = parsed.ui?.worktreeCardProperties - const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true - const expandedCardPropsMigrated = - parsed.ui?._expandedWorktreeCardPropertiesDefaulted === true - const jiraIssueCardPropDefaulted = - parsed.ui?._jiraIssueWorktreeCardPropertyDefaulted === true - const hadExperimentOn = readDeprecatedExperimentFlag(parsed) - const deliberateUncheck = - hadExperimentOn && - Array.isArray(rawCardProps) && - !rawCardProps.includes('inline-agents') - const needsInlineAgentsMigration = - !inlineAgentsMigrated && - !deliberateUncheck && - Array.isArray(rawCardProps) && - !rawCardProps.includes('inline-agents') - const needsLegacyDefaultedCompactMigration = - loadedCompactWorktreeCards && - parsed.ui?._worktreeCardModeDefaulted === true && - isDefaultedCompactWorktreeCardProperties(rawCardProps) - const migratedCardProps = (() => { - if (!Array.isArray(rawCardProps)) { - return undefined - } - if (needsLegacyDefaultedCompactMigration) { - return getWorktreeCardModeProperties('Compact') - } - const candidate = needsInlineAgentsMigration - ? [...rawCardProps, 'inline-agents' as const] - : rawCardProps - const expandedCandidate = (() => { - if (expandedCardPropsMigrated) { - return candidate - } - const next = [...candidate] - // Why: Linear rode the 'issue' property and Ports were always shown; split them out once to preserve existing cards. - if (candidate.includes('issue') && !candidate.includes('linear-issue')) { - next.push('linear-issue' as const) - } - if (!candidate.includes('ports')) { - next.push('ports' as const) - } - return next - })() - // Why: 'jira-issue' joined the defaults after the expansion migration already stamped upgraded profiles, so it needs its own one-shot backfill. - const jiraCandidate = - jiraIssueCardPropDefaulted || expandedCandidate.includes('jira-issue') - ? expandedCandidate - : [...expandedCandidate, 'jira-issue' as const] - const normalized = normalizeWorktreeCardProperties(jiraCandidate) - const changed = - normalized.length !== rawCardProps.length || - normalized.some((property, index) => property !== rawCardProps[index]) - return changed ? normalized : undefined - })() - if ( - migratedCardProps !== undefined || - !inlineAgentsMigrated || - !expandedCardPropsMigrated || - !jiraIssueCardPropDefaulted - ) { - this.loadNeedsSave = true - } - const rawExplorerView = parsed.ui?.rightSidebarExplorerView - const rightSidebarExplorerView = normalizeRightSidebarExplorerView( - rawExplorerView, - parsed.ui?.rightSidebarTab - ) - // Why: without a dirty mark the legacy "Search tab, no explorer view" repair stays - // in memory only, so a profile that never writes again redoes it on every launch. - if ( - rawExplorerView === undefined - ? rightSidebarExplorerView !== defaults.ui.rightSidebarExplorerView - : rawExplorerView !== rightSidebarExplorerView - ) { - this.loadNeedsSave = true - } - const setupGuideSidebarDismissed = resolveSetupGuideSidebarDismissedOnLoad( - parsed.ui?.setupGuideSidebarDismissed, - normalizedOnboarding - ) - if ( - parsed.ui?.setupGuideSidebarDismissed !== setupGuideSidebarDismissed && - (setupGuideSidebarDismissed || parsed.ui?.setupGuideSidebarDismissed !== undefined) - ) { - this.loadNeedsSave = true - } - // Why: only upgraded profiles still on the new default get the one-time usage-display notice; fresh profiles stay quiet. - const usagePercentageDisplayChangeNoticeDismissed = - resolveUsagePercentageDisplayChangeNoticeDismissed({ - rawDismissed: parsed.ui?.usagePercentageDisplayChangeNoticeDismissed, - rawUsagePercentageDisplay: parsed.ui?.usagePercentageDisplay, - isExistingProfile: isExistingPersistedProfile({ - repoCount: parsed.repos?.length ?? 0, - onboardingClosedAt: normalizedOnboarding.closedAt, - ui: parsed.ui - }) - }) - if ( - parsed.ui?.usagePercentageDisplayChangeNoticeDismissed !== - usagePercentageDisplayChangeNoticeDismissed - ) { - this.loadNeedsSave = true - } - return { - ...defaults.ui, - // Why: missing card properties follow the persisted layout mode; explicit choices are preserved below. - worktreeCardProperties: getWorktreeCardModeProperties( - loadedCompactWorktreeCards ? 'Compact' : 'Default' - ), - ...stripMainOwnedTelemetryMarkerFromUI(parsed.ui), - // Why: migrate once from the retired Appearance setting only when no explicit chrome preference exists yet. - rightSidebarOpen, - rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), - // Why here and not in getPersistedUI: only the raw payload still shows the legacy - // "Search tab, no explorer view" shape — the defaults spread above fills in 'files'. - rightSidebarExplorerView, - setupGuideSidebarDismissed, - usagePercentageDisplayChangeNoticeDismissed, - setupGuideBrowserMilestoneMigrated: - typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' - ? parsed.ui.setupGuideBrowserMilestoneMigrated - : false, - setupGuideBrowserMilestoneLegacyComplete: - parsed.ui?.setupGuideBrowserMilestoneLegacyComplete === true, - // Why persist rather than notify inline: the flip lands during load, before any - // window exists, and it must survive a crash before the user ever sees the notice. - osc52ClipboardDefaultOnNoticePending: osc52ClipboardNoticePending, - sortBy: migrate ? ('smart' as const) : sort, - showDotfilesByWorktree: normalizeShowDotfilesByWorktree( - parsed.ui?.showDotfilesByWorktree - ), - workspaceStatuses, - _workspaceStatusesDefaultOrderMigrated: true, - _workspaceStatusesReorderedDefaultRepaired: true, - _workspaceStatusesDefaultWorkflowMigrated: true, - _workspaceStatusesDefaultVisualsMigrated: true, - _sortBySmartMigrated: true, - ...(migratedCardProps !== undefined - ? { worktreeCardProperties: migratedCardProps } - : {}), - // Why: keep stamping the legacy flag for rollback forward-compat; the new flag actually gates the migration. - _inlineAgentsDefaultedForExperiment: true, - _inlineAgentsDefaultedForAllUsers: true, - _expandedWorktreeCardPropertiesDefaulted: true, - _jiraIssueWorktreeCardPropertyDefaulted: true - } - })(), - // Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash. - workspaceSession: (() => { - if (parsed.workspaceSession === undefined) { - return defaults.workspaceSession - } - const result = parseWorkspaceSessionSalvaging(parsed.workspaceSession) - if (!result.ok) { - console.error( - '[persistence] Corrupt workspace session, using defaults:', - result.error - ) - return defaults.workspaceSession - } - if (result.droppedCount > 0) { - console.warn( - '[persistence] Salvaged workspace session; dropped corrupt entries:', - workspaceSessionSalvageLogDetails(result) - ) - // Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch. - this.loadNeedsSave = true - } - return { ...defaults.workspaceSession, ...result.value } - })(), - // Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat. - workspaceSessionsByHostId: (() => { - const { partitions, repaired } = parseWorkspaceSessionsByHostId( - parsed.workspaceSessionsByHostId, - defaults.workspaceSession - ) - if (repaired) { - // Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch. - this.loadNeedsSave = true - } - return partitions - })(), - sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), - deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) - ? parsed.deletedSshConfigAliases.filter( - (alias): alias is string => typeof alias === 'string' - ) - : [], - retiredWorktreeNamesByRepo: normalizeRetiredNameRegistryMap( - parsed.retiredWorktreeNamesByRepo - ), - retiredWorktreeNamesByNamespace: normalizeRetiredNameRegistryMap( - parsed.retiredWorktreeNamesByNamespace - ), - sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) - .map(normalizeSshRemotePtyLease) - .filter((lease): lease is SshRemotePtyLease => lease !== null), - sshPtyConsumerRecoveries: parsed.sshPtyConsumerRecoveries, - claudeLivePtySessionIds: normalizeClaudeLivePtySessionIds(parsed.claudeLivePtySessionIds), - migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( - parsed.migrationUnsupportedPtyEntries - ), - legacyPaneKeyAliasEntries: normalizeLegacyPaneKeyAliasEntries( - parsed.legacyPaneKeyAliasEntries - ), - automations: Array.isArray(parsed.automations) ? parsed.automations : [], - automationRuns: (() => { - if (!Array.isArray(parsed.automationRuns)) { - return [] - } - const runs = pruneAutomationRuns(backfillAutomationRunNumbers(parsed.automationRuns)) - // Why: nothing else marks dirty, so an oversized legacy file would otherwise only shrink at the next unrelated save. - if (runs.length !== parsed.automationRuns.length) { - this.loadNeedsSave = true - } - return runs - })(), - onboarding: normalizedOnboarding - } - } - } catch (err) { - console.error('[persistence] Failed to load primary state, trying backups:', err) - } - - // Corrupt-file and no-file paths converge here; a corrupted install counts as existing, so it sees the opt-in banner. - if (result === null && allowBackupRecovery) { - let hasBackup = false - for (let i = 0; i < BACKUP_COUNT; i++) { - if (existsSync(backupPath(dataFile, i))) { - hasBackup = true - break - } - } - if (fileExistedOnLoad || hasBackup) { - if (this.restoreFromBackup(dataFile)) { - return this.load(false) - } - console.error('[persistence] No usable state file or backup found, using defaults') - } - } - - if (result === null) { - result = getDefaultPersistedState(homedir()) - } - - const workspaceSession = pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos) - ) - const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( - workspaceSession, - this.terminalScrollbackSnapshotStorage - ) - if (migratedScrollback.changed) { - this.loadNeedsSave = true - } - - const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) - const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) - if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { - this.loadNeedsSave = true - } - - const automationContextMigration = backfillLegacyAutomationContexts({ - ...result, - repos, - ...projectHostSetupCompatibility - }) - if (automationContextMigration.changed) { - this.loadNeedsSave = true - } - result = { - ...result, - automations: automationContextMigration.state.automations, - automationRuns: automationContextMigration.state.automationRuns - } - - const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({ - ...result, - repos, - ...projectHostSetupCompatibility, - workspaceSession: migratedScrollback.session - }) - if (folderScopeConnectionMigration.changed) { - this.loadNeedsSave = true - } - result = folderScopeConnectionMigration.state - - if (normalizeWorktreeLinkedItemMetadata(result)) { - this.loadNeedsSave = true - } - - if (gcStaleWorktreeMeta(result) > 0) { - this.loadNeedsSave = true - } - - const migrated = this.migrateTabSwitchKeybindings( - this.migrateTelemetry(result, fileExistedOnLoad), - fileExistedOnLoad - ) - - // githubCache is a sidecar file now (see getGithubCacheFile); legacy in-file caches seed the session, then get stripped. - const legacyCache = migrated.githubCache - const hasLegacyCache = - Object.keys(legacyCache?.pr ?? {}).length > 0 || - Object.keys(legacyCache?.issue ?? {}).length > 0 - if (hasLegacyCache) { - this.loadNeedsSave = true - // Why: mark dirty so the first flush writes the sidecar even without a poll refresh this session, preserving the seed. - this.githubCacheDirty = true - } else { - migrated.githubCache = readGithubCacheSnapshot(this.dataFile) ?? migrated.githubCache - } - - logPersistenceStartupMilestone('persistence-load-done', { - repos: migrated.repos.length, - workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) - }) - return migrated - } - - // One-shot telemetry cohort migration: seeds existedBeforeTelemetryRelease, optedIn, and installId (no-op once set). - // One-shot tab-switch cohort freeze: fileExistedOnLoad tells existing vs fresh only on the first launch, so persist now. - private migrateTabSwitchKeybindings( - state: PersistedState, - fileExistedOnLoad: boolean - ): PersistedState { - const existing = state.settings?.tabSwitchKeybindingSeed - if (existing === 'pending' || existing === 'done') { - return state - } - // Why: mark dirty so the frozen cohort persists; else a fresh install re-reads as "existing" after its file lands. - this.loadNeedsSave = true - return { - ...state, - settings: { - ...state.settings, - // Existing installs pin old chords via a keybindings.json seed; fresh installs use the new registry defaults. - tabSwitchKeybindingSeed: fileExistedOnLoad ? 'pending' : 'done' - } - } - } - - private migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { - const existing = state.settings?.telemetry - // Why: require all three invariants; keying on existedBeforeTelemetryRelease alone lets a partial block skip migration. - if ( - typeof existing?.existedBeforeTelemetryRelease === 'boolean' && - typeof existing.installId === 'string' && - existing.installId.length > 0 && - (existing.optedIn === true || existing.optedIn === false || existing.optedIn === null) - ) { - return state - } - // Why: resolve cohort once; re-inferring it in the optedIn fallback could misclassify a partially-written new user. - const resolvedExistedBefore = - typeof existing?.existedBeforeTelemetryRelease === 'boolean' - ? existing.existedBeforeTelemetryRelease - : fileExistedOnLoad - return { - ...state, - settings: { - ...state.settings, - telemetry: { - ...existing, - existedBeforeTelemetryRelease: resolvedExistedBefore, - // Why: preserve any explicit opt-in/out; fall back to cohort default only when optedIn is undefined, never when false. - optedIn: - existing?.optedIn === true || existing?.optedIn === false || existing?.optedIn === null - ? existing.optedIn - : resolvedExistedBefore - ? null - : true, - installId: - typeof existing?.installId === 'string' && existing.installId.length > 0 - ? existing.installId - : randomUUID() - } - } - } - } - - // Why 1s trailing + 5s max-wait (was 300ms unbounded): coalesce mutation bursts; max-wait bounds crash staleness at 5s. - private static SAVE_DEBOUNCE_MS = 1_000 - private static SAVE_MAX_WAIT_MS = 5_000 - - private scheduleSave(): void { - // Why: once the quit flush has snapshotted, a newly debounced write would fire during - // teardown with nothing awaiting it, and the process can exit mid-rename. The quit - // flush is the last write by construction. - if (this.quitFlushStarted) { - return - } - this.writeGeneration += 1 - const now = Date.now() - this.firstPendingSaveAt ??= now - if (this.writeTimer) { - clearTimeout(this.writeTimer) - } - const untilMaxWait = Math.max(0, this.firstPendingSaveAt + Store.SAVE_MAX_WAIT_MS - now) - const delay = Math.min(Store.SAVE_DEBOUNCE_MS, untilMaxWait) - this.writeTimer = setTimeout(() => { - this.writeTimer = null - this.firstPendingSaveAt = null - void this.enqueueWrite() - }, delay) - } - - private enqueueWrite(): Promise { - const previousWrite = Promise.all([ - this.pendingWrite ?? this.staleTempCleanup, - this.pendingSnapshotFileWork ?? Promise.resolve() - ]).then(() => {}) - const write = previousWrite.then(() => this.writeToDiskAsync()) - const trackedWrite = write - .catch((err) => { - console.error('[persistence] Failed to write state:', err) - }) - .finally(() => { - if (this.pendingWrite === trackedWrite) { - this.pendingWrite = null - } - }) - this.pendingWrite = trackedWrite - return write - } - - /** Wait for any in-flight async disk write to complete. Used in tests. */ - async waitForPendingWrite(): Promise { - await Promise.all([this.pendingWrite, this.activeViewPreference.waitForPendingWrite()]) - } - - // Why githubCache is omitted: memory-only this session (see getGithubCacheFile), so refreshes never touch the durable file. - private getDurableState(): Omit { - const { githubCache: _memoryOnly, ...durable } = this.state - return durable - } - - // Why: build payload synchronously so hash and bytes reflect one state tick. A degraded prefix makes the first healthy retry durable even when plaintext state is unchanged. - private buildStateToSave(): { - payload: string - stateHash: string - protectedSecretUpdates: ProtectedSecretRetentionUpdate[] - } { - // Why sentinels (not a blob/key string match): the substitution must be - // position-exact. A plain search for the ciphertext — or even for a - // `"key":"blob"` token — can be mimicked by user-controlled state (e.g. an - // agentDefaultEnv var named after a secret field, or a value equal to a - // ciphertext), which would substitute the wrong site and let two DISTINCT - // states normalize equal → a silently dropped write (data loss), reachable - // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot - // random UUID can't occur anywhere else in the serialized state (the user - // sets their data before it is minted), so it appears exactly once. - const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = [] - const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] - let protectedStorageDegraded = false - const encryptToSentinel = (slot: string, plaintext: string): string => { - const encrypted = this.protectedSecrets.encrypt(slot, plaintext) - if (encrypted.retentionUpdate) { - protectedSecretUpdates.push(encrypted.retentionUpdate) - } - protectedStorageDegraded ||= encrypted.degraded - const { blob, hashValue = plaintext } = encrypted - // Values already identical in payload and hash need no sentinel substitution. - if (blob === plaintext && hashValue === plaintext) { - return blob - } - const sentinel = `orca-secret-slot-${randomUUID()}` - secretSubs.push({ sentinel, blob, hashValue }) - return sentinel - } - const encryptOptionalToSentinel = ( - slot: string, - plaintext: string | null | undefined - ): string | null => { - const encrypted = encryptToSentinel(slot, plaintext ?? '') - return encrypted || null - } - // Why: clone before encrypting secrets so in-memory this.state stays plaintext. - const stateToSave = { - ...this.getDurableState(), - // Why both keys unconditionally: the explicit keys always win over the spread, and - // JSON.stringify drops the `undefined` value so a note-free profile gains no key on disk. - // The strip builds a new array here only; this.state records keep their notes in memory. - folderWorkspaces: (this.state.folderWorkspaces ?? []).map( - ({ diffComments: _relocated, ...rest }) => rest - ), - folderWorkspaceDiffComments: collectFolderWorkspaceDiffComments(this.state.folderWorkspaces), - sshPtyConsumerRecoveries: (this.state.sshPtyConsumerRecoveries ?? []).map((record) => ({ - ...record, - ownerLease: encryptToSentinel( - sshPtyOwnerLeaseSecretSlot(record.targetId), - record.ownerLease - ) - })), - settings: { - ...stripRetiredGlobalSettings(this.state.settings), - opencodeSessionCookie: encryptToSentinel( - PROTECTED_SECRET_SLOT.opencodeSessionCookie, - this.state.settings.opencodeSessionCookie - ), - httpProxyUrl: encryptToSentinel( - PROTECTED_SECRET_SLOT.httpProxyUrl, - this.state.settings.httpProxyUrl ?? '' - ) - }, - ui: { - ...this.state.ui, - browserKagiSessionLink: encryptOptionalToSentinel( - PROTECTED_SECRET_SLOT.browserKagiSessionLink, - this.state.ui.browserKagiSessionLink - ) - } - } - // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. - // One full-state stringify; secret slots currently hold sentinels. - const serialized = JSON.stringify(stateToSave) - // Substitute each unique sentinel exactly once: ciphertext for the on-disk - // payload, a stable normalized value for the guard hash. Function-form - // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped - // in `serialized`, so each replace is byte-for-byte position-exact. - let payload = serialized - let hashInput = serialized - for (const { sentinel, blob, hashValue } of secretSubs) { - const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) - payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) - hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) - } - const stateHash = createHash('sha1') - .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '') - .update(hashInput) - .digest('hex') - return { payload, stateHash, protectedSecretUpdates } - } - - // Why: async writes avoid blocking the main Electron thread on every debounced save. - private async writeToDiskAsync(): Promise { - if (this.writesFrozen) { - return - } - const gen = this.writeGeneration - const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() - // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. - if (stateHash === this.lastWrittenStateHash) { - this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) - return - } - const dataFile = this.dataFile - const dir = dirname(dataFile) - await mkdir(dir, { recursive: true }).catch(() => {}) - const tmpFile = durableWriteTempPath(dataFile) - - // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. - let renamed = false - try { - // Why: fsync before rename, then fsync the directory; see writeFileDurable. - const handle = await open(tmpFile, 'w') - try { - await handle.writeFile(payload, 'utf-8') - await handle.sync() - } finally { - await handle.close() - } - // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. - if (this.writeGeneration !== gen) { - return - } - this.inFlightAsyncTmpFile = tmpFile - try { - await renameDurable(tmpFile, dataFile) - renamed = true - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || this.writeGeneration === gen) { - throw error - } - } finally { - if (this.inFlightAsyncTmpFile === tmpFile) { - this.inFlightAsyncTmpFile = null - } - } - // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. - if (renamed && this.writeGeneration === gen) { - this.lastWrittenStateHash = stateHash - this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) - } else if (renamed) { - this.lastWrittenStateHash = null - } - if (renamed) { - this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) - } - } finally { - if (!renamed) { - await rm(tmpFile).catch(() => {}) - } - } - if (!renamed) { - return - } - // Why (#1158): rotate only after the primary rename while this write still owns its generation. - if (this.writeGeneration !== gen) { - return - } - await this.rotateBackupsAsync(dataFile) - } - - // Why: sync variant only for flush() at shutdown, where the process may exit before an async write completes. - private writeToDiskSync(opts: { force?: boolean; skipBackupRotation?: boolean } = {}): void { - if (this.writesFrozen) { - return - } - const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() - // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. - if (!opts.force && stateHash === this.lastWrittenStateHash) { - return - } - const dataFile = this.dataFile - const dir = dirname(dataFile) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - - // Why: on any write/rename failure, remove the tmp file so shutdown crashes don't leak orphans. - let renamed = false - try { - // Why: fsync the temp file and the directory; a bare rename can survive as stale or empty - // content after power loss, losing projects/tabs back to the newest usable .bak slot. - writeFileDurableSync(tmpFile, dataFile, payload) - renamed = true - this.lastWrittenStateHash = stateHash - this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) - this.lastDurableWriteGeneration = Math.max( - this.lastDurableWriteGeneration, - this.writeGeneration - ) - } finally { - if (!renamed) { - try { - unlinkSync(tmpFile) - } catch { - // Best-effort cleanup; the write already failed, swallow secondary error. - } - } - } - const now = Date.now() - if (!opts.skipBackupRotation && this.shouldRotateBackups(now, dataFile)) { - this.rotateBackupsSync(dataFile) - } - } - - flushOrThrow(): void { - if (this.quitFlushStarted) { - throw new Error('Cannot synchronously flush after final persistence has started') - } - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - this.firstPendingSaveAt = null - const asyncWriteWasInFlight = this.pendingWrite !== null - // Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write. - this.writeGeneration++ - if (this.inFlightAsyncTmpFile) { - try { - unlinkSync(this.inFlightAsyncTmpFile) - this.inFlightAsyncTmpFile = null - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - void this.enqueueWrite().catch(() => {}) - throw error - } - } - } - // Why: later async flushes must remain serialized behind the invalidated writer. - this.writeToDiskSync({ - force: asyncWriteWasInFlight, - skipBackupRotation: this.backupRotationInFlight - }) - } - - flushActiveViewPreferenceOrThrow(): void { - this.activeViewPreference.flushOrThrow() - } - - getCodexResetCreditAttemptLedger(): CodexResetCreditAttemptLedger { - return parseCodexResetCreditAttemptLedger(this.state.codexResetCreditAttemptLedger) - } - - replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void { - if (this.writesFrozen) { - throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen') - } - const next = parseCodexResetCreditAttemptLedger(ledger) - const previous = this.state.codexResetCreditAttemptLedger - ? structuredClone(this.state.codexResetCreditAttemptLedger) - : undefined - this.state.codexResetCreditAttemptLedger = next - try { - this.flushOrThrow() - } catch (error) { - // Why: callers use a successful return as the durability barrier before - // handing a scarce-credit mutation to the provider. - this.state.codexResetCreditAttemptLedger = previous - throw error - } - } - - // ── Repos ────────────────────────────────────────────────────────── - getProfileStorageDirectory(): string { - return dirname(this.dataFile) + return dirname(this.runtime.dataFile) } - private projectHostOperations: ProjectHostPersistenceOperations | null = null - - private getProjectHostOperations(): ProjectHostPersistenceOperations { - this.projectHostOperations ??= new ProjectHostPersistenceOperations({ - state: this.state, - gitUsernameCache: this.gitUsernameCache, - hydrateRepo: (repo) => this.hydrateRepo(repo), - updateRepoBackedProjectHostSetup: (setup, repo, updates) => - this.updateRepoBackedProjectHostSetup(setup, repo, updates), - updateIndependentProjectHostSetup: (setup, updates) => - this.updateIndependentProjectHostSetup(setup, updates), - removeProjectForHost: (id, hostId) => this.removeProjectForHost(id, hostId), - scheduleSave: () => this.scheduleSave() - }) - return this.projectHostOperations - } - - getRepos(): Repo[] { - return this.getProjectHostOperations().getRepos() - } - - getProjects(): Project[] { - return this.getProjectHostOperations().getProjects() - } - - updateProject(id: string, updates: ProjectUpdateArgs['updates']): Project | null { - return this.getProjectHostOperations().updateProject(id, updates) - } - - getProjectHostSetups(): ProjectHostSetup[] { - return this.getProjectHostOperations().getProjectHostSetups() - } - - createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult | null { - return this.getProjectHostOperations().createProjectHostSetup(args) - } - - updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | null { - return this.getProjectHostOperations().updateProjectHostSetup(args) - } - - deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null { - return this.getProjectHostOperations().deleteProjectHostSetup(args) - } - - getRepoCount(): number { - return this.getProjectHostOperations().getRepoCount() - } - - getRepo(id: string): Repo | undefined { - return this.getProjectHostOperations().getRepo(id) - } - - setResolvedRepoGitUsername( - target: Pick, - username: string - ): boolean { - return this.getProjectHostOperations().setResolvedRepoGitUsername(target, username) - } - - private projectGroupOperations: ProjectGroupPersistenceOperations | null = null - - private getProjectGroupOperations(): ProjectGroupPersistenceOperations { - this.projectGroupOperations ??= new ProjectGroupPersistenceOperations({ - state: this.state, - scheduleSave: () => this.scheduleSave(), - removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => - this.removeWorkspaceLineageForFolderParent(folderWorkspaceId), - pruneMobileClientTabSelections: (matchesWorktreeId) => - this.pruneMobileClientTabSelections(matchesWorktreeId) - }) - return this.projectGroupOperations - } - - getProjectGroups(): ProjectGroup[] { - return this.getProjectGroupOperations().getProjectGroups() - } - - createProjectGroup( - input: Parameters[0] - ): ProjectGroup { - return this.getProjectGroupOperations().createProjectGroup(input) - } - - updateProjectGroup( - groupId: string, - updates: Parameters[1] - ): ProjectGroup | null { - return this.getProjectGroupOperations().updateProjectGroup(groupId, updates) - } - - deleteProjectGroup(groupId: string): boolean { - return this.getProjectGroupOperations().deleteProjectGroup(groupId) - } - - private folderWorkspaceOperations: FolderWorkspacePersistenceOperations | null = null - - private getFolderWorkspaceOperations(): FolderWorkspacePersistenceOperations { - this.folderWorkspaceOperations ??= new FolderWorkspacePersistenceOperations({ - state: this.state, - scheduleSave: () => this.scheduleSave(), - removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => - this.removeWorkspaceLineageForFolderParent(folderWorkspaceId), - pruneMobileClientTabSelections: (matchesWorktreeId) => - this.pruneMobileClientTabSelections(matchesWorktreeId), - hydrateRepo: (repo) => this.hydrateRepo(repo) - }) - return this.folderWorkspaceOperations - } - - getFolderWorkspaces(): FolderWorkspace[] { - return this.getFolderWorkspaceOperations().getFolderWorkspaces() - } - - getFolderWorkspace(id: string): FolderWorkspace | undefined { - return this.getFolderWorkspaceOperations().getFolderWorkspace(id) - } - - createFolderWorkspace( - input: Parameters[0] - ): FolderWorkspace { - return this.getFolderWorkspaceOperations().createFolderWorkspace(input) - } - - updateFolderWorkspace( - id: string, - updates: Parameters[1] - ): FolderWorkspace | null { - return this.getFolderWorkspaceOperations().updateFolderWorkspace(id, updates) - } - - removeFolderWorkspace(id: string): boolean { - return this.getFolderWorkspaceOperations().removeFolderWorkspace(id) - } - - moveProjectToGroup(repoId: string, groupId: string | null, order?: number): Repo | null { - return this.getFolderWorkspaceOperations().moveProjectToGroup(repoId, groupId, order) - } - - private repoOrderOperations: RepoOrderPersistenceOperations | null = null - - private getRepoOrderOperations(): RepoOrderPersistenceOperations { - this.repoOrderOperations ??= new RepoOrderPersistenceOperations({ - state: this.state, - syncProjectHostSetupCompatibilityState: () => this.syncProjectHostSetupCompatibilityState(), - scheduleSave: () => this.scheduleSave() - }) - return this.repoOrderOperations - } - - addRepo(repo: Repo): void { - this.getRepoOrderOperations().addRepo(repo) - } - - reorderRepos(orderedIds: string[]): boolean { - return this.getRepoOrderOperations().reorderRepos(orderedIds) - } - - reorderReposForHost(orderedIds: string[], hostId: ExecutionHostId): boolean { - return this.getRepoOrderOperations().reorderReposForHost(orderedIds, hostId) - } - - removeProject(id: string): void { - this.state.repos = this.state.repos.filter((r) => r.id !== id) - this.syncProjectHostSetupCompatibilityState() - // Why: presets are repo-scoped and unreachable once the repo is gone, so drop them with it. - delete this.state.sparsePresetsByRepo[id] - delete this.state.retiredWorktreeNamesByRepo?.[id] - this.pruneWorktreeStateForRepo(id, null) - this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id) - this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( - this.state.workspaceSessionsByHostId, - id - ) - this.scheduleSave() - } - - // Why: the same repo id can exist on multiple execution hosts; remove only this host's row and metadata, never another host's. - removeProjectForHost(id: string, hostId: ExecutionHostId): void { - this.state.repos = this.state.repos.filter( - (r) => !(r.id === id && getRepoExecutionHostId(r) === hostId) - ) - const idStillPresent = this.state.repos.some((r) => r.id === id) - // Why: presets and retirements are repo-id-scoped (not host-scoped); drop them only when the last host's copy is gone. - if (!idStillPresent) { - delete this.state.sparsePresetsByRepo[id] - delete this.state.retiredWorktreeNamesByRepo?.[id] - } - this.syncProjectHostSetupCompatibilityState() - // Why: prune only this host's worktree metas if the id survives elsewhere; otherwise prune everything (matches removeProject). - this.pruneWorktreeStateForRepo(id, idStillPresent ? hostId : null) - if (!idStillPresent) { - this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id) - this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( - this.state.workspaceSessionsByHostId, - id - ) - } else if (parseExecutionHostId(hostId)?.kind === 'runtime') { - const session = this.state.workspaceSessionsByHostId?.[hostId] - if (session) { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [hostId]: removeRepoFromWorkspaceSession(session, id) - } - } - } - this.scheduleSave() - } - - // Prune worktree meta/lineage for a repo id; hostId null prunes all entries, else only that host's (missing meta.hostId = local). - private pruneWorktreeStateForRepo(id: string, hostId: ExecutionHostId | null): void { - pruneWorktreeStateForRepoOperation(this.state, id, hostId, (matchesWorktreeId) => - this.pruneMobileClientTabSelections(matchesWorktreeId) - ) - } - - private pruneMobileClientTabSelections(matchesWorktreeId: (worktreeId: string) => boolean): void { - for (const [clientNavigationId, selectionsByWorktree] of Object.entries( - this.state.mobileClientTabSelectionsByDeviceId ?? {} - )) { - for (const worktreeId of Object.keys(selectionsByWorktree)) { - if (matchesWorktreeId(worktreeId)) { - delete selectionsByWorktree[worktreeId] - } - } - if (Object.keys(selectionsByWorktree).length === 0) { - delete this.state.mobileClientTabSelectionsByDeviceId?.[clientNavigationId] - } - } - } - - private repoUpdateOperations: RepoUpdatePersistenceOperations | null = null - - private getRepoUpdateOperations(): RepoUpdatePersistenceOperations { - this.repoUpdateOperations ??= new RepoUpdatePersistenceOperations({ - state: this.state, - syncProjectHostSetupCompatibilityState: () => this.syncProjectHostSetupCompatibilityState(), - scheduleSave: () => this.scheduleSave(), - hydrateRepo: (repo) => this.hydrateRepo(repo) - }) - return this.repoUpdateOperations - } - - updateRepo( - id: string, - updates: Partial< - Pick< - Repo, - | 'displayName' - | 'badgeColor' - | 'repoIcon' - | 'upstream' - | 'gitRemoteIdentity' - | 'hookSettings' - | 'worktreeBaseRef' - | 'worktreeBasePath' - | 'kind' - | 'executionHostId' - | 'symlinkPaths' - | 'issueSourcePreference' - | 'forkSyncMode' - | 'externalWorktreeVisibilityPromptDismissedAt' - | 'externalWorktreeInboxBaselinePaths' - | 'importedExternalWorktreePaths' - | 'customWorktreeVisibilitySources' - | 'worktreeVisibilitySourcePreferences' - | 'projectGroupId' - | 'projectGroupOrder' - | 'projectHostSetupMethod' - > - > & { - externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null - agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null - sourceControlAi?: Repo['sourceControlAi'] | null - externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null - }, - hostId?: ExecutionHostId - ): Repo | null { - return this.getRepoUpdateOperations().updateRepo(id, updates, hostId) - } - - private syncProjectHostSetupCompatibilityState(): void { - const compatibilityState = mergeProjectHostSetupCompatibilityState(this.state, this.state.repos) - this.state.projects = compatibilityState.projects - this.state.projectHostSetups = compatibilityState.projectHostSetups - } - - private projectHostSetupOperations: ProjectHostSetupPersistenceOperations | null = null - - private getProjectHostSetupOperations(): ProjectHostSetupPersistenceOperations { - this.projectHostSetupOperations ??= new ProjectHostSetupPersistenceOperations({ - state: this.state, - updateRepo: (id, updates, hostId) => this.updateRepo(id, updates, hostId), - scheduleSave: () => this.scheduleSave() - }) - return this.projectHostSetupOperations - } - - private updateRepoBackedProjectHostSetup( - setup: ProjectHostSetup, - repo: Repo, - updates: ProjectHostSetupUpdateArgs['updates'] - ): { setup: ProjectHostSetup; repo: Repo } | null { - return this.getProjectHostSetupOperations().updateRepoBackedProjectHostSetup( - setup, - repo, - updates - ) - } - - private updateIndependentProjectHostSetup( - setup: ProjectHostSetup, - updates: ProjectHostSetupUpdateArgs['updates'] - ): ProjectHostSetup { - return this.getProjectHostSetupOperations().updateIndependentProjectHostSetup(setup, updates) - } - - private hydrateRepo(repo: Repo): Repo { - return hydrateRepoOperation(repo, this.gitUsernameCache) - } - - // ── Sparse Presets ───────────────────────────────────────────────── - - // ── Mobile client tab selections ────────────────────────────────── - - getMobileClientTabSelections(): PersistedMobileClientTabSelections { - return this.state.mobileClientTabSelectionsByDeviceId ?? {} - } - - setMobileClientTabSelections(next: PersistedMobileClientTabSelections): void { - this.state.mobileClientTabSelectionsByDeviceId = next - this.scheduleSave() - } - - getSparsePresets(repoId: string): SparsePreset[] { - return [...(this.state.sparsePresetsByRepo[repoId] ?? [])].sort((left, right) => - left.name.localeCompare(right.name) - ) - } - - saveSparsePreset(preset: SparsePreset): SparsePreset { - const existing = this.state.sparsePresetsByRepo[preset.repoId] ?? [] - const index = existing.findIndex((entry) => entry.id === preset.id) - this.state.sparsePresetsByRepo[preset.repoId] = - index === -1 - ? [...existing, preset] - : existing.map((entry, i) => (i === index ? preset : entry)) - this.scheduleSave() - return preset - } - - removeSparsePreset(repoId: string, presetId: string): void { - const existing = this.state.sparsePresetsByRepo[repoId] ?? [] - this.state.sparsePresetsByRepo[repoId] = existing.filter((entry) => entry.id !== presetId) - this.scheduleSave() - } - - // ── Automations ─────────────────────────────────────────────────── - - private getAutomationDefinitionOperations(): AutomationDefinitionOperations { - return { - state: this.state, - flush: () => this.flush(), - recordCreated: () => this.recordFeatureInteraction('automation-created') - } - } - - private getAutomationRunOperations(): AutomationRunOperations { - return { - state: this.state, - flush: () => this.flush(), - recordManualRun: () => this.recordFeatureInteraction('automation-run'), - getWorkspaceDisplayName: (workspaceId) => - this.getAutomationRunWorkspaceDisplayName(workspaceId) - } - } - - listAutomations(): Automation[] { - return listAutomationsOperation(this.state) - } - - listAutomationRuns(automationId?: string): AutomationRun[] { - return listAutomationRunsOperation(this.state, automationId) - } - - createAutomation(input: AutomationCreateInput): Automation { - return createAutomationOperation(this.getAutomationDefinitionOperations(), input) - } - - updateAutomation(id: string, updates: AutomationUpdateInput): Automation { - return updateAutomationOperation(this.getAutomationDefinitionOperations(), id, updates) - } - - deleteAutomation(id: string): void { - deleteAutomationOperation(this.getAutomationDefinitionOperations(), id) - } - - createAutomationRun( - automation: Automation, - scheduledFor: number, - trigger: AutomationRunTrigger = 'scheduled' - ): AutomationRun { - return createAutomationRunOperation( - this.getAutomationRunOperations(), - automation, - scheduledFor, - trigger - ) - } - - updateAutomationRun(result: AutomationDispatchResult): AutomationRun { - return updateAutomationRunOperation(this.getAutomationRunOperations(), result) - } - - snapshotAutomationRunWorkspaceDisplayName(workspaceId: string, displayName: string): number { - return snapshotAutomationRunWorkspaceDisplayNameOperation( - this.getAutomationRunOperations(), - workspaceId, - displayName - ) - } - - private getAutomationRunWorkspaceDisplayName( - workspaceId: string | null | undefined - ): string | null { - if (!workspaceId) { - return null - } - return normalizeAutomationRunWorkspaceDisplayName( - this.state.worktreeMeta[workspaceId]?.displayName ?? - getWorktreePathBasenameFromId(workspaceId) - ) - } - - advanceAutomationNextRun(id: string, now = Date.now()): Automation { - return advanceAutomationNextRunOperation(this.state, () => this.flush(), id, now) - } - - getLatestAutomationOccurrence(automation: Automation, now = Date.now()): number | null { - return getLatestAutomationOccurrenceOperation(automation, now) - } - - // ── Worktree Meta ────────────────────────────────────────────────── - - getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined { - return this.state.worktreeMeta[worktreeId] - } - - getAllWorktreeMeta(): Record { - return this.state.worktreeMeta - } - - setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { - const existing = this.state.worktreeMeta[worktreeId] || getDefaultWorktreeMeta() - const updated = { ...existing, ...meta } - updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem) - const linkedTaskSourceContext = normalizeStoredTaskSourceContext( - updated.linkedTaskSourceContext - ) - updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( - updated.linkedWorkItem, - linkedTaskSourceContext - ) - ? linkedTaskSourceContext - : null - if (!updated.instanceId) { - updated.instanceId = randomUUID() - } - this.state.worktreeMeta[worktreeId] = updated - this.scheduleSave() - return updated - } - - removeWorktreeMeta(worktreeId: string, hostId?: ExecutionHostId | null): void { - // A host-qualified removal names the owner; the persisted host is the fallback. - const persistedOwner = this.state.worktreeMeta[worktreeId]?.hostId - const owner = hostId ?? persistedOwner - const preservesDifferentPersistedOwner = Boolean( - hostId && persistedOwner && persistedOwner !== hostId - ) - const ownerPartition = workspaceSessionOwnerPartitionForHost(owner) - const preservesSameIdSessionOwner = Boolean( - preservesDifferentPersistedOwner || - (owner && - hasWorktreeRemovalRepoOwnerOnOtherHost( - this, - getRepoIdFromWorktreeId(worktreeId), - ownerPartition - )) - ) - // Skip partitions main never wrote: materializing one fences every sibling worktree of the repo. - const partitions = new Set( - workspaceSessionPartitionIdsForHost(owner).filter( - (partition) => - this.hasPersistedWorkspaceSession(partition) && - // The local partition can be a remote spill surface or a same-id owner. - // Preserve it whenever another owner may still use the bare id. - (!preservesSameIdSessionOwner || partition === ownerPartition) - ) - ) - // A repo-wide fence must not rebase a sibling's unpersisted tabs onto main's copy, and a spill - // partition that never held this worktree has no claim on the repo at all. - const fencedPartitions = new Set( - [...partitions].filter( - (partition) => - this.partitionOwnsWorktreeTabs(worktreeId, partition) || - (partition === ownerPartition && - !this.partitionHasOtherRepoWorktreeTabs(worktreeId, partition)) - ) - ) - if (!preservesDifferentPersistedOwner) { - delete this.state.worktreeMeta[worktreeId] - delete this.state.worktreeLineageById[worktreeId] - delete this.state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)] - } - for (const partition of partitions) { - this.removeWorkspaceSessionOwnerInPartition(worktreeId, partition, { - advanceTerminalTopologyRevision: fencedPartitions.has(partition) - }) - } - this.scheduleSave() - } - - getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined { - return this.state.worktreeLineageById[worktreeId] - } - - getAllWorktreeLineage(): Record { - return this.state.worktreeLineageById - } - - setWorktreeLineage(worktreeId: string, lineage: WorktreeLineage): WorktreeLineage { - this.state.worktreeLineageById[worktreeId] = lineage - this.scheduleSave() - return lineage - } - - removeWorktreeLineage(worktreeId: string): void { - delete this.state.worktreeLineageById[worktreeId] - this.scheduleSave() - } - - /** - * Re-key every worktreeId-keyed record from `oldWorktreeId` to `newWorktreeId` after the worktree folder (and its - * `${repoId}::${path}` id) was renamed on disk, so a refresh re-binds state instead of orphaning it. Records the old id on - * the new meta's `priorWorktreeIds` so session GC/hydration still recognizes PTY sessions minted under it. No-op when ids match. - * Renderer counterpart: `buildWorktreeRenameState` in store/slices/worktrees.ts. - */ - migrateWorktreeIdentity(oldWorktreeId: string, newWorktreeId: string): void { - if (migrateWorktreeIdentityOperation(this.state, oldWorktreeId, newWorktreeId)) { - this.scheduleSave() - } - } - - getWorkspaceLineage(childWorkspaceKey: WorkspaceKey): WorkspaceLineage | undefined { - return this.state.workspaceLineageByChildKey[childWorkspaceKey] - } - - getAllWorkspaceLineage(): Record { - return this.state.workspaceLineageByChildKey - } - - setWorkspaceLineage(lineage: WorkspaceLineage): WorkspaceLineage { - this.state.workspaceLineageByChildKey[lineage.childWorkspaceKey] = lineage - this.scheduleSave() - return lineage - } - - removeWorkspaceLineage(childWorkspaceKey: WorkspaceKey): void { - delete this.state.workspaceLineageByChildKey[childWorkspaceKey] - this.scheduleSave() - } - - private removeWorkspaceLineageForFolderParent(folderWorkspaceId: string): void { - const parentKey = folderWorkspaceKey(folderWorkspaceId) - for (const [childKey, lineage] of Object.entries(this.state.workspaceLineageByChildKey)) { - if (lineage.parentWorkspaceKey === parentKey) { - delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] - } - } - } - - // ── Settings ─────────────────────────────────────────────────────── - - getSettings(): GlobalSettings { - return this.state.settings - } - - onSettingsChanged( - listener: ( - updates: Partial, - settings: GlobalSettings, - originWebContentsId?: number - ) => void - ): () => void { - this.settingsChangeListeners.add(listener) - return () => { - this.settingsChangeListeners.delete(listener) - } - } - - private notifySettingsChanged( - updates: Partial, - originWebContentsId?: number - ): void { - for (const listener of this.settingsChangeListeners) { - listener(updates, this.state.settings, originWebContentsId) - } - } - - // Why: renderer-visible UI state is written from desktop and mobile, so notify to keep bi-directional sync. - onUIChanged(listener: (ui: PersistedState['ui']) => void): () => void { - this.uiChangeListeners.add(listener) - return () => { - this.uiChangeListeners.delete(listener) - } - } - - private notifyUIChanged(): void { - if (this.uiChangeListeners.size === 0) { - return - } - const ui = this.getUI() - for (const listener of this.uiChangeListeners) { - listener(ui) - } - } - - private getSettingsMutationOperations(): SettingsMutationOperations { - return { - state: this.state, - removeRetainedBlob: (slot) => this.protectedSecrets.removeRetainedBlob(slot), - scheduleSave: () => this.scheduleSave(), - notifySettingsChanged: (updates, originWebContentsId) => - this.notifySettingsChanged(updates, originWebContentsId) - } - } - - updateSettings( - updates: Partial, - options: { notifyListeners?: boolean; originWebContentsId?: number } = {} - ): GlobalSettings { - return updateSettingsOperation(this.getSettingsMutationOperations(), updates, options) - } - - // ── UI State ─────────────────────────────────────────────────────── - - private getUIUpdateOperations(): UIUpdateOperations { - return { - state: this.state, - removeRetainedBlob: (slot) => this.protectedSecrets.removeRetainedBlob(slot), - setActiveView: (activeView) => this.activeViewPreference.set(activeView), - getUI: () => this.getUI(), - scheduleSave: () => this.scheduleSave(), - notifyUIChanged: () => this.notifyUIChanged() - } - } - - getUI(): PersistedState['ui'] { - return getPersistedUI(this.state, this.activeViewPreference.get()) - } - - updateUI(updates: Partial): void { - updatePersistedUI(this.getUIUpdateOperations(), updates) - } - - private getFeatureInteractionOperations(): FeatureInteractionOperations { - return { - state: this.state, - scheduleSave: () => this.scheduleSave(), - notifyUIChanged: () => this.notifyUIChanged(), - getUI: () => this.getUI() - } - } - - recordFeatureInteraction(id: FeatureInteractionId): PersistedState['ui'] { - return recordFeatureInteractionOperation(this.getFeatureInteractionOperations(), id) - } - - // ── Onboarding ──────────────────────────────────────────────────── - - getOnboarding(): PersistedState['onboarding'] { - const defaults = getDefaultOnboardingState() - return { - ...defaults, - ...this.state.onboarding, - checklist: { - ...defaults.checklist, - ...this.state.onboarding?.checklist - } - } - } - - updateOnboarding( - updates: Partial> & { - checklist?: Partial - } - ): PersistedState['onboarding'] { - const current = this.getOnboarding() - this.state.onboarding = { - ...current, - ...updates, - checklist: { - ...current.checklist, - ...updates.checklist - } - } - this.scheduleSave() - return this.getOnboarding() - } - - // ── GitHub Cache ────────────────────────────────────────────────── - - getGitHubCache(): PersistedState['githubCache'] { - return this.state.githubCache - } - - setGitHubCache(cache: PersistedState['githubCache']): void { - // Why no scheduleSave: cache is memory-only and snapshotted to a sidecar at flush; persisting here rewrote the whole state file every poll cycle. - this.state.githubCache = cache - this.githubCacheDirty = true - this.githubCacheGeneration += 1 - } - - // ── Workspace Session ───────────────────────────────────────────── - - /** Resolve an execution host argument to a canonical id; unknown/empty falls back to 'local' for legacy callers. */ - private resolveHostId(hostId?: string | null): ExecutionHostId { - return normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID - } - - getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] { - const resolved = this.resolveHostId(hostId) - if (resolved === LOCAL_EXECUTION_HOST_ID) { - return this.state.workspaceSession ?? getDefaultWorkspaceSession() - } - return this.state.workspaceSessionsByHostId?.[resolved] ?? getDefaultWorkspaceSession() - } - - /** Whether a partition was ever written; `getWorkspaceSession` defaults absent ones and cannot tell them apart. */ - private hasPersistedWorkspaceSession(hostId: ExecutionHostId): boolean { - return ( - hostId === LOCAL_EXECUTION_HOST_ID || - this.state.workspaceSessionsByHostId?.[hostId] !== undefined - ) - } - - getWorkspaceSessionHostIds(): ExecutionHostId[] { - const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) - for (const key of Object.keys(this.state.workspaceSessionsByHostId ?? {})) { - const hostId = normalizeExecutionHostId(key) - if (hostId) { - hostIds.add(hostId) - } - } - return [...hostIds] - } - - readTerminalScrollbackSnapshot(ref: string): string | null { - return readTerminalScrollbackSnapshotSync(ref, this.terminalScrollbackSnapshotStorage) - } - - /** Resolve the worktree a terminal tab belongs to; more reliable than agent-echoed hook fields. */ - getWorktreeIdForTab(tabId: string): string | undefined { - return findWorktreeIdForTab(this.getWorkspaceSession(), tabId) - } - - setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { - const resolved = this.resolveHostId(hostId) - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.setLocalWorkspaceSession(session) - return - } - this.setHostWorkspaceSession(resolved, session) - } - - removeWorkspaceSessionStateForWorktree( - worktreeId: string, - hostId?: ExecutionHostId | null, - options: { advanceTerminalTopologyRevision?: boolean } = {} - ): void { - for (const resolved of workspaceSessionPartitionIdsForHost(hostId)) { - this.removeWorkspaceSessionOwnerInPartition(worktreeId, resolved, options) - } - } - - private removeWorkspaceSessionOwnerInPartition( - worktreeId: string, - resolved: ExecutionHostId, - options: { advanceTerminalTopologyRevision?: boolean } - ): void { - if (!this.hasPersistedWorkspaceSession(resolved)) { - return - } - const current = this.getWorkspaceSession(resolved) - const session = removeWorkspaceSessionOwner(current, worktreeId, { - advanceTerminalTopologyRevision: options.advanceTerminalTopologyRevision ?? true - }) - if (!session) { - return - } - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSession = session - } else { - // Host scoping matters because identical repo/path ids may exist on two servers. - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolved]: session - } - } - this.scheduleSave() - } - - /** Whether a partition still holds terminal membership for `worktreeId`. */ - private partitionOwnsWorktreeTabs(worktreeId: string, hostId: ExecutionHostId): boolean { - return this.getWorkspaceSession(hostId).tabsByWorktree?.[worktreeId] !== undefined - } - - /** Whether fencing this partition would rebase a sibling worktree of the same repo. */ - private partitionHasOtherRepoWorktreeTabs(worktreeId: string, hostId: ExecutionHostId): boolean { - const repoId = getRepoIdFromWorktreeId(worktreeId) - const tabsByWorktree = this.getWorkspaceSession(hostId).tabsByWorktree ?? {} - return Object.entries(tabsByWorktree).some( - ([id, tabs]) => - id !== worktreeId && getRepoIdFromWorktreeId(id) === repoId && (tabs?.length ?? 0) > 0 - ) - } - - stageWorkspaceSessionBeforeUnload( - session: PersistedState['workspaceSession'], - hostId?: string | null - ): void { - const resolved = this.resolveHostId(hostId) - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.setLocalWorkspaceSession(session, true) - return - } - this.setHostWorkspaceSession(resolved, session) - } - - /** Persist a non-'local' host partition; remote hosts skip setLocalWorkspaceSession's local-daemon PTY-binding race guards. */ - private setHostWorkspaceSession(hostId: ExecutionHostId, session: WorkspaceSessionState): void { - // Why: each partition owns its topology fence; renderer writes omit it and must rebase locally. - session = sanitizeWorkspaceSessionTerminalRetirements( - session, - this.state.workspaceSessionsByHostId?.[hostId] - ) - const pruned = pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - ) - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [hostId]: pruned - } - this.scheduleSave() - } - - private setLocalWorkspaceSession( - session: PersistedState['workspaceSession'], - deferSnapshotFiles = false - ): void { - const prior = this.state.workspaceSession - session = sanitizeWorkspaceSessionTerminalRetirements(session, prior) - session = pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - ) - - // Why (Issue #217): merge existing bindings when the incoming binding is empty, so a stale pre-spawn snapshot can't overwrite the durable PTY binding. - const normalized = normalizeWorkspaceSessionPaneIdentities( - session, - prior?.terminalLayoutsByTabId - ) - for (const entry of normalized.migrationUnsupportedEntries) { - setMigrationUnsupportedPty(entry) - } - const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys( - this.state.ui?.acknowledgedAgentsByPaneKey, - normalized.leafIdByInputLeafIdByTabId - ) - if (remappedAcknowledgements.changed) { - this.state.ui = { - ...this.state.ui, - acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements - } - } - for (const entry of normalized.legacyPaneKeyAliasEntries) { - registerPersistedPaneKeyAlias(entry) - } - session = normalized.session - const remapsByHostId = new Map([ - [LOCAL_EXECUTION_HOST_ID, normalized] - ]) - const remappedLeases = remapSshRemotePtyLeaseLeafIds( - this.state.sshRemotePtyLeases ?? [], - remapsByHostId, - new Set(this.getWorkspaceSessionHostIds()) - ) - if (remappedLeases.changed) { - this.state.sshRemotePtyLeases = remappedLeases.leases - } - if (session && prior) { - const priorTabs = prior.tabsByWorktree ?? {} - const nextTabs = session.tabsByWorktree ?? {} - const worktreeIdByTabId = new Map() - for (const [worktreeId, tabs] of Object.entries({ ...priorTabs, ...nextTabs })) { - for (const tab of tabs) { - worktreeIdByTabId.set(tab.id, worktreeId) - } - } - for (const [worktreeId, tabs] of Object.entries(nextTabs)) { - const priorList = priorTabs[worktreeId] - if (!priorList) { - continue - } - for (const tab of tabs) { - if (tab.ptyId) { - continue - } - const priorTab = priorList.find((t) => t.id === tab.id) - if ( - priorTab?.ptyId && - this.isRestorablePtyBinding({ - ptyId: priorTab.ptyId, - worktreeId, - targetId: this.getConnectionIdForWorktree(worktreeId), - tabId: tab.id - }) - ) { - tab.ptyId = priorTab.ptyId - } - } - } - const priorLayouts = prior.terminalLayoutsByTabId ?? {} - const nextLayouts = session.terminalLayoutsByTabId ?? {} - for (const [tabId, layout] of Object.entries(nextLayouts)) { - const priorLayout = priorLayouts[tabId] - if (!priorLayout?.ptyIdsByLeafId) { - continue - } - const incoming = layout.ptyIdsByLeafId ?? {} - const incomingHasAnyBinding = Object.keys(incoming).length > 0 - const liveLeafIds = this.getTerminalLayoutLeafIds(layout.root) - const worktreeId = worktreeIdByTabId.get(tabId) - const targetId = worktreeId ? this.getConnectionIdForWorktree(worktreeId) : null - const restorableBindings = Object.fromEntries( - Object.entries(priorLayout.ptyIdsByLeafId).filter( - ([leafId, ptyId]) => - liveLeafIds.has(leafId) && - incoming[leafId] === undefined && - // Why: an empty layout map may be a stale pre-spawn snapshot; a partial map is intentional unless a durable SSH lease proves it. - (incomingHasAnyBinding - ? this.hasRestorableSshRemotePtyLease({ - ptyId, - targetId, - worktreeId, - tabId, - leafId - }) - : this.isRestorablePtyBinding({ ptyId, targetId, worktreeId, tabId, leafId })) - ) - ) - if (Object.keys(restorableBindings).length > 0) { - layout.ptyIdsByLeafId = { ...restorableBindings, ...incoming } - // Why: the same stale write that drops ptyIdsByLeafId may come from an older renderer lacking UUID-keyed metadata. - const buffersByLeafId = preserveMissingLeafRecordEntries( - priorLayout.buffersByLeafId, - layout.buffersByLeafId, - liveLeafIds - ) - const scrollbackRefsByLeafId = preserveMissingLeafRecordEntries( - priorLayout.scrollbackRefsByLeafId, - layout.scrollbackRefsByLeafId, - liveLeafIds - ) - const titlesByLeafId = preserveMissingLeafRecordEntries( - priorLayout.titlesByLeafId, - layout.titlesByLeafId, - liveLeafIds - ) - if (buffersByLeafId) { - layout.buffersByLeafId = buffersByLeafId - } - if (scrollbackRefsByLeafId) { - layout.scrollbackRefsByLeafId = scrollbackRefsByLeafId - } - if (titlesByLeafId) { - layout.titlesByLeafId = titlesByLeafId - } - } - } - } - session = pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - if (!deferSnapshotFiles) { - const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( - session, - this.terminalScrollbackSnapshotStorage - ) - session = migratedScrollback.session - deleteRemovedTerminalScrollbackSnapshots( - prior, - session, - this.terminalScrollbackSnapshotStorage - ) - } - this.state.workspaceSession = session - if (deferSnapshotFiles) { - this.enqueueTerminalScrollbackSnapshotWork(prior, session) - } - this.scheduleSave() - } - - private enqueueTerminalScrollbackSnapshotWork( - prior: WorkspaceSessionState | undefined, - staged: WorkspaceSessionState - ): void { - const previous = this.pendingSnapshotFileWork ?? Promise.resolve() - const work = previous - .then(async () => { - if (this.state.workspaceSession !== staged) { - if (this.state.workspaceSession) { - await deleteRemovedTerminalScrollbackSnapshotsAsync( - prior, - this.state.workspaceSession, - this.terminalScrollbackSnapshotStorage - ) - } - return - } - const migrated = await migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync( - staged, - this.terminalScrollbackSnapshotStorage - ) - const current = - this.state.workspaceSession === staged ? migrated : this.state.workspaceSession - if (this.state.workspaceSession === staged) { - this.state.workspaceSession = migrated - } else if (current) { - await deleteRemovedTerminalScrollbackSnapshotsAsync( - migrated, - current, - this.terminalScrollbackSnapshotStorage - ) - } - if (current) { - await deleteRemovedTerminalScrollbackSnapshotsAsync( - prior, - current, - this.terminalScrollbackSnapshotStorage - ) - } - }) - .catch((error) => { - console.error('[terminal-scrollback] Failed to prepare unload snapshots:', error) - }) - .finally(() => { - if (this.pendingSnapshotFileWork === work) { - this.pendingSnapshotFileWork = null - } - }) - this.pendingSnapshotFileWork = work - } - - patchWorkspaceSession(patch: WorkspaceSessionPatch, hostId?: string | null): void { - const resolved = this.resolveHostId(hostId) - // Why: the debounced hot path sends only changed slices; scalar/UI patches skip terminal normalization, topology patches keep stale-PTY protections. - let next: WorkspaceSessionState = { - ...this.getWorkspaceSession(resolved), - ...patch - } - if (workspaceSessionPatchNeedsFullNormalization(patch)) { - this.setWorkspaceSession(next, resolved) - return - } - if (Object.hasOwn(patch, 'browserUrlHistory')) { - next = pruneWorkspaceSessionBrowserHistory(next) - } - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSession = next - } else { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolved]: next - } - } - this.scheduleSave() - } - - private getTerminalLayoutLeafIds(root: TerminalPaneLayoutNode | null): Set { - const leafIds = new Set() - const visit = (node: TerminalPaneLayoutNode | null): void => { - if (!node) { - return - } - if (node.type === 'leaf') { - if (isTerminalLeafId(node.leafId)) { - leafIds.add(node.leafId) - } - return - } - visit(node.first) - visit(node.second) - } - visit(root) - return leafIds - } - - private isRestorablePtyBinding(binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - }): boolean { - const leases = this.state.sshRemotePtyLeases?.filter((entry) => - this.sshRemotePtyLeaseMatchesBinding(entry, binding) - ) - return !leases?.some((lease) => lease.state === 'terminated' || lease.state === 'expired') - } - - private getRelayPtyIdForSshLeaseComparison(targetId: string, ptyId: string): string { - try { - return toRelaySshPtyId(targetId, ptyId) - } catch { - return ptyId - } - } - - private getRelayPtyIdForSshLeaseStorage(targetId: string, ptyId: string): string { - return toRelaySshPtyId(targetId, ptyId) - } - - private sshRemotePtyLeaseMatchesBinding( - lease: SshRemotePtyLease, - binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - } - ): boolean { - const bindingPtyId = this.getRelayPtyIdForSshLeaseComparison(lease.targetId, binding.ptyId) - if (lease.ptyId !== bindingPtyId) { - return false - } - // Why: remote PTY ids are scoped to a relay target; require stored lease context to match so missing fields don't tombstone unrelated panes. - return ( - (binding.targetId === undefined || - binding.targetId === null || - lease.targetId === binding.targetId) && - (binding.worktreeId === undefined || lease.worktreeId === binding.worktreeId) && - (binding.tabId === undefined || lease.tabId === binding.tabId) && - (binding.leafId === undefined || lease.leafId === binding.leafId) - ) - } - - private hasRestorableSshRemotePtyLease(binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - }): boolean { - return ( - this.state.sshRemotePtyLeases?.some( - (lease) => - this.sshRemotePtyLeaseMatchesBinding(lease, binding) && - lease.state !== 'terminated' && - lease.state !== 'expired' - ) ?? false - ) - } - - private getConnectionIdForWorktree(worktreeId: string): string | null { - const repoId = getRepoIdFromWorktreeId(worktreeId) - return this.state.repos.find((repo) => repo.id === repoId)?.connectionId ?? null - } - - // Why: sync-flush the pty binding before pty:spawn returns to close the spawn/persist SIGKILL race (Issue #217). - persistPtyBinding( - args: { - worktreeId: string - tabId: string - leafId: string - ptyId: string - incarnationId?: string - startupCwd?: string - expectedBinding?: { ptyId: string; incarnationId?: string } - expectedSourceBinding?: PtyBindingSourceExpectation - /** Set by host-initiated creates, which have no renderer session writer behind them. */ - hostAdmittedMembership?: boolean - }, - hostId?: string | null - ): boolean { - const resolvedHostId = this.resolveHostId(hostId) - const session = this.getWorkspaceSession(resolvedHostId) - const paneKey = `${args.tabId}:${args.leafId}` - const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId - if (args.expectedSourceBinding) { - const expected = args.expectedSourceBinding - if (expected.tabId !== args.tabId) { - return false - } - const sourceTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( - (candidate) => candidate.id === expected.tabId && candidate.worktreeId === bindingWorktreeId - ) - const sourceLayout = session.terminalLayoutsByTabId?.[expected.tabId] - const sourcePaneKey = `${expected.tabId}:${expected.leafId}` - if ( - !sourceTab || - sourceLayout?.ptyIdsByLeafId?.[expected.leafId] !== expected.ptyId || - !layoutContainsLeafId(sourceLayout.root, expected.leafId) || - (expected.incarnationId !== undefined && - session.terminalPtyIncarnationsByPaneKey?.[sourcePaneKey] !== expected.incarnationId) - ) { - return false - } - } - if (args.expectedBinding) { - const tab = session.tabsByWorktree?.[bindingWorktreeId]?.find( - (candidate) => candidate.id === args.tabId && candidate.worktreeId === bindingWorktreeId - ) - const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] - if ( - !tab || - boundPtyId !== args.expectedBinding.ptyId || - session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId - ) { - return false - } - } - if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolvedHostId]: session - } - } - const sessionBeforeBinding = cloneWorkspaceSessionState(session) - const reconciledIncarnation = - args.expectedBinding !== undefined && - args.incarnationId !== args.expectedBinding.incarnationId - let terminalMembershipChanged = false - let hostAdmittedTabCreated = false - const advanceTopologyFence = (): void => { - const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) - const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - // Why: a split, or a host-admitted tab the renderer has never seen, is itself - // the authority — with no fence the renderer's pre-create tab list replays - // over it and the tab is lost even on the repo's first such change. - const establishesMembershipAuthority = - args.expectedSourceBinding !== undefined || hostAdmittedTabCreated - if ( - !reconciledIncarnation && - (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) - ) { - return - } - // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. - session.terminalTopologyRevisionByRepoId = { - ...session.terminalTopologyRevisionByRepoId, - [repoId]: currentRevision + 1 - } - } - const restoreSession = (): void => { - if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSession = sessionBeforeBinding - } else { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolvedHostId]: sessionBeforeBinding - } - } - } - if (args.incarnationId) { - session.terminalPtyIncarnationsByPaneKey = { - ...session.terminalPtyIncarnationsByPaneKey, - [paneKey]: args.incarnationId - } - if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { - session.terminalSurfaceTombstonesByPaneKey = { - ...session.terminalSurfaceTombstonesByPaneKey - } - delete session.terminalSurfaceTombstonesByPaneKey[paneKey] - } - } - const tabs = session.tabsByWorktree?.[bindingWorktreeId] - const tab = tabs?.find((t) => t.id === args.tabId) - if (tab) { - tab.ptyId = args.ptyId - } else { - terminalMembershipChanged = true - hostAdmittedTabCreated = args.hostAdmittedMembership === true - // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. - const nextTabs = [ - ...(tabs ?? []), - createMinimalPersistedTerminalTab({ - ...args, - worktreeId: bindingWorktreeId, - existingTabCount: tabs?.length ?? 0 - }) - ] - session.tabsByWorktree = { - ...session.tabsByWorktree, - [bindingWorktreeId]: nextTabs - } - session.activeWorktreeId ??= bindingWorktreeId - session.activeTabId ??= args.tabId - session.activeTabIdByWorktree = { - ...session.activeTabIdByWorktree, - [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId - } - } - if (!isTerminalLeafId(args.leafId)) { - // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. - advanceTopologyFence() - try { - this.flushOrThrow() - } catch (err) { - restoreSession() - throw err - } - return true - } - const layout = session.terminalLayoutsByTabId?.[args.tabId] - if (layout) { - if (!layout.root) { - terminalMembershipChanged = true - // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. - layout.root = { type: 'leaf', leafId: args.leafId } - layout.activeLeafId = args.leafId - layout.expandedLeafId = null - } else if (!layoutContainsLeafId(layout.root, args.leafId)) { - terminalMembershipChanged = true - // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. - layout.root = { - type: 'split', - direction: 'vertical', - first: cloneLayoutNode(layout.root), - second: { type: 'leaf', leafId: args.leafId } - } - layout.activeLeafId = args.leafId - if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { - layout.expandedLeafId = null - } - } - layout.ptyIdsByLeafId = { - ...layout.ptyIdsByLeafId, - [args.leafId]: args.ptyId - } - } else { - terminalMembershipChanged = true - // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. - session.terminalLayoutsByTabId = { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - root: { type: 'leaf', leafId: args.leafId }, - activeLeafId: args.leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [args.leafId]: args.ptyId } - } - } - } - advanceTopologyFence() - try { - this.flushOrThrow() - } catch (err) { - restoreSession() - throw err - } - return true - } - - // ── SSH Targets ──────────────────────────────────────────────────── - - private getSshTargetStateOperations(): SshTargetStateOperations { - return { - state: this.state, - protectedSecrets: this.protectedSecrets, - scheduleSave: () => this.scheduleSave(), - flush: () => this.flush() - } - } - - getSshTargets(): SshTarget[] { - return getSshTargetsOperation(this.state) - } - - getSshTarget(id: string): SshTarget | undefined { - return getSshTargetOperation(this.state, id) - } - - addSshTarget(target: SshTarget): void { - addSshTargetOperation(this.getSshTargetStateOperations(), target) - } - - updateSshTarget(id: string, updates: Partial>): SshTarget | null { - return updateSshTargetOperation(this.getSshTargetStateOperations(), id, updates) - } - - removeSshTarget(id: string): void { - removeSshTargetOperation(this.getSshTargetStateOperations(), id) - } - - // ── Live Claude PTY sessions ─────────────────────────────────────── - - getClaudeLivePtySessionIds(): string[] { - return getClaudeLivePtySessionIdsOperation(this.state) - } - - addClaudeLivePtySessionId(sessionId: string): void { - addClaudeLivePtySessionIdOperation(this.getSshTargetStateOperations(), sessionId) - } - - removeClaudeLivePtySessionId(sessionId: string): void { - removeClaudeLivePtySessionIdOperation(this.getSshTargetStateOperations(), sessionId) - } - - getRetiredWorktreeNameRegistry(repoId: string): RetiredNameRegistry { - const stored = this.state.retiredWorktreeNamesByRepo?.[repoId] - return stored - ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } - : EMPTY_RETIRED_NAME_REGISTRY - } - - getRetiredWorktreeNameRegistryForNamespace(namespaceKey: string): RetiredNameRegistry { - const stored = this.state.retiredWorktreeNamesByNamespace?.[namespaceKey] - return stored - ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } - : EMPTY_RETIRED_NAME_REGISTRY - } - - addRetiredWorktreeName(repoId: string, name: string): void { - const normalized = normalizeRetirableGeneratedName(name) - if (!repoId || !normalized) { - return - } - this.applyRetiredWorktreeNames(repoId, [normalized]) - } - - mergeRetiredWorktreeNames(repoId: string, names: Iterable): boolean { - if (!repoId) { - return false - } - const incoming = new Set() - for (const name of names) { - const normalized = normalizeRetirableGeneratedName(name) - if (normalized) { - incoming.add(normalized) - } - } - return incoming.size > 0 && this.applyRetiredWorktreeNames(repoId, incoming) - } - - mergeRetiredWorktreeNamesForNamespace(namespaceKey: string, names: Iterable): boolean { - if (!namespaceKey) { - return false - } - const normalized = new Set() - for (const name of names) { - const candidate = normalizeRetirableGeneratedName(name) - if (candidate) { - normalized.add(candidate) - } - } - const next = addRetiredNames( - this.getRetiredWorktreeNameRegistryForNamespace(namespaceKey), - normalized - ) - if (!next) { - return false - } - this.state.retiredWorktreeNamesByNamespace ??= {} - recordRetirementNamespaceRegistry( - this.state.retiredWorktreeNamesByNamespace, - namespaceKey, - next - ) - this.scheduleSave() - return true - } - - private applyRetiredWorktreeNames(repoId: string, names: Iterable): boolean { - const next = addRetiredNames(this.getRetiredWorktreeNameRegistry(repoId), names) - if (!next) { - return false - } - this.state.retiredWorktreeNamesByRepo ??= {} - this.state.retiredWorktreeNamesByRepo[repoId] = next - this.scheduleSave() - return true - } - - getDeletedSshConfigAliases(): string[] { - return getDeletedSshConfigAliasesOperation(this.state) - } - - addDeletedSshConfigAlias(alias: string): void { - addDeletedSshConfigAliasOperation(this.getSshTargetStateOperations(), alias) - } - - removeDeletedSshConfigAlias(alias: string): void { - removeDeletedSshConfigAliasOperation(this.getSshTargetStateOperations(), alias) - } - - clearDeletedSshConfigAliases(): void { - clearDeletedSshConfigAliasesOperation(this.getSshTargetStateOperations()) - } - - getRemovedSshTargetTombstones(): RemovedSshTargetTombstone[] { - return getRemovedSshTargetTombstonesOperation(this.state) - } - - addRemovedSshTargetTombstone(tombstone: RemovedSshTargetTombstone): void { - addRemovedSshTargetTombstoneOperation(this.getSshTargetStateOperations(), tombstone) - } - - removeRemovedSshTargetTombstone(oldTargetId: string): void { - removeRemovedSshTargetTombstoneOperation(this.getSshTargetStateOperations(), oldTargetId) - } - - /** - * Re-point every repo and worktree meta pinned to a removed SSH target id onto - * a re-added target's id so orphaned workspaces reattach. Returns re-pointed repo ids. - */ - reassignSshTargetId(oldTargetId: string, newTargetId: string): string[] { - const operations: SshTargetReassignmentOperations = { - state: this.state, - protectedSecrets: this.protectedSecrets, - syncProjectHostSetupCompatibilityState: () => this.syncProjectHostSetupCompatibilityState(), - scheduleSave: () => this.scheduleSave() - } - return reassignSshTargetIdOperation(operations, oldTargetId, newTargetId) - } - - // ── SSH PTY Consumer Recovery ────────────────────────────────────── - - private getSshPtyConsumerRecoveryOperations(): SshPtyConsumerRecoveryOperations { - return { - state: this.state, - protectedSecrets: this.protectedSecrets, - flushDurableStateOrThrowAsync: () => this.flushDurableStateOrThrowAsync() - } - } - - getSshPtyConsumerRecovery(targetId: string): SshPtyConsumerRecovery | null { - return getSshPtyConsumerRecoveryOperation(this.getSshPtyConsumerRecoveryOperations(), targetId) - } - - async upsertSshPtyConsumerRecovery(record: SshPtyConsumerRecovery): Promise { - await upsertSshPtyConsumerRecoveryOperation(this.getSshPtyConsumerRecoveryOperations(), record) - } - - async removeSshPtyConsumerRecovery(targetId: string): Promise { - await removeSshPtyConsumerRecoveryOperation( - this.getSshPtyConsumerRecoveryOperations(), - targetId - ) - } - - // ── SSH Remote PTY Leases ────────────────────────────────────────── - - private getSshPtyBindingCleanupOperations(): SshPtyBindingCleanupOperations { - return { - state: this.state, - toComparablePtyId: (targetId, ptyId) => - this.getRelayPtyIdForSshLeaseComparison(targetId, ptyId), - scheduleSave: () => this.scheduleSave() - } - } - - private getSshPtyLeaseOperations(): SshPtyLeaseOperations { - return { - state: this.state, - toStoredPtyId: (targetId, ptyId) => this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId), - clearBindingsForTarget: (targetId) => - clearSshRemotePtyBindingsForTargetOperation( - this.getSshPtyBindingCleanupOperations(), - targetId - ), - clearBindingsForLeases: (targetId, leases) => - clearSshRemotePtyBindingsForLeasesOperation( - this.getSshPtyBindingCleanupOperations(), - targetId, - leases - ), - flush: () => this.flush(), - flushDurableStateOrThrowAsync: () => this.flushDurableStateOrThrowAsync() - } - } - - getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { - return getSshRemotePtyLeasesOperation(this.state, targetId) - } - - upsertSshRemotePtyLease( - lease: Omit & - Partial> - ): void { - upsertSshRemotePtyLeaseOperation(this.getSshPtyLeaseOperations(), lease) - } - - markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { - markSshRemotePtyLeasesOperation(this.getSshPtyLeaseOperations(), targetId, state) - } - - // Why no write of its own: the committed quit path calls this immediately before the final store - // flush, and that flush is what persists it. A durable write here would race the flush and be - // rejected the moment it latches, which is exactly how an attached lease used to survive quit. - markSshRemotePtyLeasesForShutdown(targetId: string, state: SshRemotePtyLease['state']): void { - markSshRemotePtyLeasesForShutdownOperation(this.getSshPtyLeaseOperations(), targetId, state) - } - - async markSshRemotePtyLeasesAsync( - targetId: string, - state: SshRemotePtyLease['state'] - ): Promise { - await markSshRemotePtyLeasesAsyncOperation(this.getSshPtyLeaseOperations(), targetId, state) - } - - async markSshRemotePtyLeasesAttachedAsync( - targetId: string, - ptyIds: readonly string[] - ): Promise { - await markSshRemotePtyLeasesAttachedAsyncOperation( - this.getSshPtyLeaseOperations(), - targetId, - ptyIds - ) - } - - markSshRemotePtyLease(targetId: string, ptyId: string, state: SshRemotePtyLease['state']): void { - markSshRemotePtyLeaseOperation(this.getSshPtyLeaseOperations(), targetId, ptyId, state) - } - - removeSshRemotePtyLease(targetId: string, ptyId: string): void { - removeSshRemotePtyLeaseOperation(this.getSshPtyLeaseOperations(), targetId, ptyId) - } - - removeSshRemotePtyLeases(targetId: string): void { - removeSshRemotePtyLeasesOperation(this.getSshPtyLeaseOperations(), targetId) - } - - // ── Flush (for shutdown) ─────────────────────────────────────────── - - flush(): void { - if (this.quitFlushStarted) { - return - } - try { - this.flushOrThrow() - } catch (err) { - console.error('[persistence] Failed to flush state:', err) - } - try { - this.flushActiveViewPreferenceOrThrow() - } catch (err) { - console.error('[active-view] Failed to flush preference:', err) - } - this.writeGithubCacheSnapshotSync() - } - - /** - * Async twin of flush() for the quit path. - * - * Why the quit path needs one: writeToDiskSync fsyncs a multi-MB file from the Electron - * main thread. On a stalled network profile mount that syscall is uninterruptible, so the - * app stops repainting and Force Quit stops working — and no main-thread deadline can - * bound it, because the deadline's own timer is stuck behind the same block. - * - * Never throws — it joins the quit teardown barrier, where a rejection is noise. - */ - flushAsync(): Promise { - if (this.quitFlushPromise) { - return this.quitFlushPromise - } - this.quitFlushStarted = true - this.quitFlushPromise = this.flushCurrentStateAsync(true).catch(() => {}) - return this.quitFlushPromise - } - - flushPendingAsync(): Promise { - // Best-effort callers must not livelock while the live app keeps mutating state. - return this.flushCurrentStateAsync(false, undefined, false).catch(() => {}) - } - - flushPendingOrThrowAsync( - options: { signal?: AbortSignal; drainToStableGeneration?: boolean } = {} - ): Promise { - if (this.writesFrozen || this.quitFlushStarted) { - return Promise.reject(new Error('Cannot flush while persistence is finalized')) - } - return this.flushCurrentStateAsync(false, options.signal, options.drainToStableGeneration, true) - } - - // Async twin of flushOrThrow: durable state only. Active-view and GitHub sidecars are - // quit/startup work and must not be snapshotted on the live SSH establish/reconnect path. - private async flushDurableStateOrThrowAsync(): Promise { - if (this.writesFrozen || this.quitFlushStarted) { - throw new Error('Cannot flush while persistence is finalized') - } - for (;;) { - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - this.firstPendingSaveAt = null - const generation = this.writeGeneration - await this.enqueueWrite() - if (generation === this.writeGeneration) { - break - } - } - } - - private async flushCurrentStateAsync( - final: boolean, - signal?: AbortSignal, - drainToStableGeneration = true, - requireInitialGenerationDurable = false - ): Promise { - const requiredDurableGeneration = requireInitialGenerationDurable ? this.writeGeneration : null - for (;;) { - if (signal?.aborted) { - throw new Error('Persistence flush aborted') - } - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - this.firstPendingSaveAt = null - const generation = this.writeGeneration - try { - await this.enqueueWrite() - } catch (error) { - await (final - ? this.activeViewPreference.flushAsync() - : this.activeViewPreference.flushPendingAsync(signal)) - await this.writeGithubCacheSnapshotAsync(final, signal) - throw error - } - await (final - ? this.activeViewPreference.flushAsync() - : this.activeViewPreference.flushPendingAsync(signal)) - await this.writeGithubCacheSnapshotAsync(final, signal) - if (signal?.aborted) { - throw new Error('Persistence flush aborted') - } - if (!drainToStableGeneration) { - if ( - requiredDurableGeneration === null || - this.lastDurableWriteGeneration >= requiredDurableGeneration - ) { - break - } - continue - } - if (generation === this.writeGeneration) { - break - } - } - } - - // Why best-effort: the sidecar is a refetchable cache; a failed write only costs a cold badge paint next launch, never data. - private async writeGithubCacheSnapshotAsync( - drainToStableGeneration = true, - signal?: AbortSignal - ): Promise { - if (!this.githubCacheDirty) { - return - } - const previousWrite = this.pendingGithubCacheWrite ?? this.staleGithubCacheTempCleanup - const nextWrite = previousWrite - .then(async () => { - while (this.githubCacheDirty) { - if (signal?.aborted) { - throw new Error('GitHub cache flush aborted') - } - const generation = this.githubCacheGeneration - const cacheFile = getGithubCacheFile(this.dataFile) - const tmpFile = durableWriteTempPath(cacheFile) - let renamed = false - try { - await writeFile(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') - if (generation === this.githubCacheGeneration) { - await rename(tmpFile, cacheFile) - renamed = true - if (generation === this.githubCacheGeneration) { - this.githubCacheDirty = false - } - } - } finally { - if (!renamed) { - await rm(tmpFile).catch(() => {}) - } - } - if (signal?.aborted) { - throw new Error('GitHub cache flush aborted') - } - if (!drainToStableGeneration) { - break - } - } - }) - .catch((err) => { - console.warn('[persistence] Failed to write github cache snapshot:', err) - }) - .finally(() => { - if (this.pendingGithubCacheWrite === nextWrite) { - this.pendingGithubCacheWrite = null - } - }) - this.pendingGithubCacheWrite = nextWrite - await nextWrite - } - - // Why: a project move rewrote the data file directly; in-memory state is now stale and any write would undo the transfer. freezeWrites(): void { - this.writesFrozen = true - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - } - - // Why best-effort: the sidecar is a refetchable cache; a failed write only costs a cold badge paint next launch, never data. - private writeGithubCacheSnapshotSync(): void { - if (!this.githubCacheDirty) { - return - } - if (this.pendingGithubCacheWrite) { - void this.writeGithubCacheSnapshotAsync() - return - } - const cacheFile = getGithubCacheFile(this.dataFile) - const generation = this.githubCacheGeneration - const tmpFile = durableWriteTempPath(cacheFile) - try { - writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') - renameSync(tmpFile, cacheFile) - if (generation === this.githubCacheGeneration) { - this.githubCacheDirty = false - } - } catch (err) { - try { - unlinkSync(tmpFile) - } catch { - // Best-effort cleanup. - } - console.warn('[persistence] Failed to write github cache snapshot:', err) + this.runtime.writesFrozen = true + if (this.runtime.writeTimer) { + clearTimeout(this.runtime.writeTimer) + this.runtime.writeTimer = null } } } -function getDefaultWorktreeMeta(): WorktreeMeta { - return { - instanceId: randomUUID(), - displayName: '', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - linkedGitLabMR: null, - linkedGitLabIssue: null, - linkedBitbucketPR: null, - linkedAzureDevOpsPR: null, - linkedGiteaPR: null, - linkedWorkItem: null, - linkedTaskSourceContext: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: Date.now(), - lastActivityAt: 0, - workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID +// oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging derives Store's prototype API directly from the exact concrete domain classes installed below +export interface Store + extends + WriteSchedulingOperations, + PrimaryStateWriteOperations, + ProjectCollectionOperations, + RepoLifecycleOperations, + MobileTabSelectionPersistence, + SparsePresetPersistence, + AutomationPersistence, + MetadataLineageOperations, + ProfilePreferences, + SessionHostPartitionOperations, + SessionSnapshotOperations, + PtyBindingPersistenceOperations, + SshProfileOperations, + RetiredWorktreeNamePersistence, + SshLeaseRecoveryOperations, + WriteFlushBarrierOperations {} + +for (const OperationClass of STORE_DOMAIN_OPERATION_CLASSES) { + const descriptors = Object.getOwnPropertyDescriptors(OperationClass.prototype) + for (const [name, descriptor] of Object.entries(descriptors)) { + if (name !== 'constructor') { + Object.defineProperty(Store.prototype, name, descriptor) + } } } diff --git a/src/main/persistence/loading-store/terminal-binding-recovery.ts b/src/main/persistence/loading-store/terminal-binding-recovery.ts new file mode 100644 index 00000000000..a3e03098b70 --- /dev/null +++ b/src/main/persistence/loading-store/terminal-binding-recovery.ts @@ -0,0 +1,104 @@ +import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import { toRelaySshPtyId } from '../../providers/ssh-pty-id' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' + +import type { StoreRuntimeState } from './store-runtime-state' + +type TerminalBindingRecoveryOperationsRuntime = Pick + +export class TerminalBindingRecoveryOperations { + constructor(private readonly runtime: TerminalBindingRecoveryOperationsRuntime) {} + + getTerminalLayoutLeafIds(root: TerminalPaneLayoutNode | null): Set { + const leafIds = new Set() + const visit = (node: TerminalPaneLayoutNode | null): void => { + if (!node) { + return + } + if (node.type === 'leaf') { + if (isTerminalLeafId(node.leafId)) { + leafIds.add(node.leafId) + } + return + } + visit(node.first) + visit(node.second) + } + visit(root) + return leafIds + } + + isRestorablePtyBinding(binding: { + ptyId: string + targetId?: string | null + worktreeId?: string + tabId?: string + leafId?: string + }): boolean { + const leases = this.runtime.state.sshRemotePtyLeases?.filter((entry) => + this.sshRemotePtyLeaseMatchesBinding(entry, binding) + ) + return !leases?.some((lease) => lease.state === 'terminated' || lease.state === 'expired') + } + + getRelayPtyIdForSshLeaseComparison(targetId: string, ptyId: string): string { + try { + return toRelaySshPtyId(targetId, ptyId) + } catch { + return ptyId + } + } + + getRelayPtyIdForSshLeaseStorage(targetId: string, ptyId: string): string { + return toRelaySshPtyId(targetId, ptyId) + } + + sshRemotePtyLeaseMatchesBinding( + lease: SshRemotePtyLease, + binding: { + ptyId: string + targetId?: string | null + worktreeId?: string + tabId?: string + leafId?: string + } + ): boolean { + const bindingPtyId = this.getRelayPtyIdForSshLeaseComparison(lease.targetId, binding.ptyId) + if (lease.ptyId !== bindingPtyId) { + return false + } + // Why: remote PTY ids are scoped to a relay target; require stored lease context to match so missing fields don't tombstone unrelated panes. + return ( + (binding.targetId === undefined || + binding.targetId === null || + lease.targetId === binding.targetId) && + (binding.worktreeId === undefined || lease.worktreeId === binding.worktreeId) && + (binding.tabId === undefined || lease.tabId === binding.tabId) && + (binding.leafId === undefined || lease.leafId === binding.leafId) + ) + } + + hasRestorableSshRemotePtyLease(binding: { + ptyId: string + targetId?: string | null + worktreeId?: string + tabId?: string + leafId?: string + }): boolean { + return ( + this.runtime.state.sshRemotePtyLeases?.some( + (lease) => + this.sshRemotePtyLeaseMatchesBinding(lease, binding) && + lease.state !== 'terminated' && + lease.state !== 'expired' + ) ?? false + ) + } + + getConnectionIdForWorktree(worktreeId: string): string | null { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return this.runtime.state.repos.find((repo) => repo.id === repoId)?.connectionId ?? null + } +} diff --git a/src/main/persistence/loading-store/terminal-session-cleanup.ts b/src/main/persistence/loading-store/terminal-session-cleanup.ts new file mode 100644 index 00000000000..2981cabc114 --- /dev/null +++ b/src/main/persistence/loading-store/terminal-session-cleanup.ts @@ -0,0 +1,36 @@ +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import { + collectTerminalScrollbackSnapshotRefs, + deleteTerminalScrollbackSnapshotSync, + type TerminalScrollbackSnapshotStorage +} from '../../terminal-scrollback-snapshots' + +const WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS = new Set([ + 'tabsByWorktree', + 'terminalLayoutsByTabId' +]) + +export function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatch): boolean { + return Object.keys(patch).some((key) => + WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS.has(key as keyof WorkspaceSessionState) + ) +} + +export function deleteRemovedTerminalScrollbackSnapshots( + prior: WorkspaceSessionState | undefined, + next: WorkspaceSessionState, + storage?: TerminalScrollbackSnapshotStorage +): void { + if (!prior) { + return + } + const nextRefs = collectTerminalScrollbackSnapshotRefs(next) + for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) { + if (!nextRefs.has(ref)) { + deleteTerminalScrollbackSnapshotSync(ref, storage) + } + } +} diff --git a/src/main/persistence/loading-store/workspace-session-partitions.ts b/src/main/persistence/loading-store/workspace-session-partitions.ts new file mode 100644 index 00000000000..50e9d4ae8cc --- /dev/null +++ b/src/main/persistence/loading-store/workspace-session-partitions.ts @@ -0,0 +1,56 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' + +export function workspaceSessionSalvageLogDetails(result: { + droppedCount: number + droppedPaths: string[] +}): { count: number; fields: string[]; detailsTruncated: boolean } { + return { + count: result.droppedCount, + fields: [...new Set(result.droppedPaths.map((path) => path.split('.', 1)[0]))], + detailsTruncated: result.droppedCount > result.droppedPaths.length + } +} + +/** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge. + * Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */ +export function parseWorkspaceSessionsByHostId( + raw: unknown, + defaults: WorkspaceSessionState +): { partitions: Partial>; repaired: boolean } { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { partitions: {}, repaired: raw !== undefined } + } + let repaired = false + const partitions: Partial> = {} + for (const [key, value] of Object.entries(raw as Record)) { + const hostId = normalizeExecutionHostId(key) + // Why: 'local' lives in workspaceSession; a local/invalid key here is legacy noise that must not shadow the canonical partition. + if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) { + continue + } + const result = parseWorkspaceSessionSalvaging(value) + if (!result.ok) { + repaired = true + console.error( + `[persistence] Corrupt workspace session for host ${hostId}, using defaults:`, + result.error + ) + continue + } + if (result.droppedCount > 0) { + console.warn( + `[persistence] Salvaged workspace session for host ${hostId}; dropped corrupt entries:`, + workspaceSessionSalvageLogDetails(result) + ) + repaired = true + } + partitions[hostId] = { ...defaults, ...result.value } + } + return { partitions, repaired } +} diff --git a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts new file mode 100644 index 00000000000..6f52fdc77ca --- /dev/null +++ b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts @@ -0,0 +1,242 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' +import { setMigrationUnsupportedPty } from '../../agent-hooks/migration-unsupported-pty-state' +import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' +import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' +import { migrateWorkspaceSessionTerminalScrollbackSnapshots } from '../../terminal-scrollback-snapshots' +import { + deleteRemovedTerminalScrollbackSnapshotsAsync, + migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync +} from '../../terminal-scrollback-snapshot-async-migration' +import { preserveMissingLeafRecordEntries } from '../restoring-sessions/terminal-layout-normalization' +import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-normalization' +import { + normalizeWorkspaceSessionPaneIdentities, + remapAcknowledgedAgentPaneKeys, + remapSshRemotePtyLeaseLeafIds, + type WorkspaceSessionPaneIdentityRemap +} from '../restoring-sessions/workspace-pane-normalization' +import { deleteRemovedTerminalScrollbackSnapshots } from './terminal-session-cleanup' +import { + getSessionSnapshotOperationsContext, + type SessionSnapshotOperations +} from './session-snapshot-operations' +import { scheduleSave } from './write-scheduling' + +export function setLocalWorkspaceSession( + owner: SessionSnapshotOperations, + session: PersistedState['workspaceSession'], + deferSnapshotFiles = false +): void { + const context = getSessionSnapshotOperationsContext(owner) + const prior = context.runtime.state.workspaceSession + session = sanitizeWorkspaceSessionTerminalRetirements(session, prior) + session = pruneWorkspaceSessionBrowserHistory( + pruneLocalTerminalScrollbackBuffers(session, context.runtime.state.repos) + ) + + // Why (Issue #217): merge existing bindings when the incoming binding is empty, so a stale pre-spawn snapshot can't overwrite the durable PTY binding. + const normalized = normalizeWorkspaceSessionPaneIdentities(session, prior?.terminalLayoutsByTabId) + for (const entry of normalized.migrationUnsupportedEntries) { + setMigrationUnsupportedPty(entry) + } + const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys( + context.runtime.state.ui?.acknowledgedAgentsByPaneKey, + normalized.leafIdByInputLeafIdByTabId + ) + if (remappedAcknowledgements.changed) { + context.runtime.state.ui = { + ...context.runtime.state.ui, + acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements + } + } + for (const entry of normalized.legacyPaneKeyAliasEntries) { + registerPersistedPaneKeyAlias(entry) + } + session = normalized.session + const remapsByHostId = new Map([ + [LOCAL_EXECUTION_HOST_ID, normalized] + ]) + const remappedLeases = remapSshRemotePtyLeaseLeafIds( + context.runtime.state.sshRemotePtyLeases ?? [], + remapsByHostId, + new Set(context.sessions.getWorkspaceSessionHostIds()) + ) + if (remappedLeases.changed) { + context.runtime.state.sshRemotePtyLeases = remappedLeases.leases + } + if (session && prior) { + const priorTabs = prior.tabsByWorktree ?? {} + const nextTabs = session.tabsByWorktree ?? {} + const worktreeIdByTabId = new Map() + for (const [worktreeId, tabs] of Object.entries({ ...priorTabs, ...nextTabs })) { + for (const tab of tabs) { + worktreeIdByTabId.set(tab.id, worktreeId) + } + } + for (const [worktreeId, tabs] of Object.entries(nextTabs)) { + const priorList = priorTabs[worktreeId] + if (!priorList) { + continue + } + for (const tab of tabs) { + if (tab.ptyId) { + continue + } + const priorTab = priorList.find((t) => t.id === tab.id) + if ( + priorTab?.ptyId && + context.bindingRecovery.isRestorablePtyBinding({ + ptyId: priorTab.ptyId, + worktreeId, + targetId: context.bindingRecovery.getConnectionIdForWorktree(worktreeId), + tabId: tab.id + }) + ) { + tab.ptyId = priorTab.ptyId + } + } + } + const priorLayouts = prior.terminalLayoutsByTabId ?? {} + const nextLayouts = session.terminalLayoutsByTabId ?? {} + for (const [tabId, layout] of Object.entries(nextLayouts)) { + const priorLayout = priorLayouts[tabId] + if (!priorLayout?.ptyIdsByLeafId) { + continue + } + const incoming = layout.ptyIdsByLeafId ?? {} + const incomingHasAnyBinding = Object.keys(incoming).length > 0 + const liveLeafIds = context.bindingRecovery.getTerminalLayoutLeafIds(layout.root) + const worktreeId = worktreeIdByTabId.get(tabId) + const targetId = worktreeId + ? context.bindingRecovery.getConnectionIdForWorktree(worktreeId) + : null + const restorableBindings = Object.fromEntries( + Object.entries(priorLayout.ptyIdsByLeafId).filter( + ([leafId, ptyId]) => + liveLeafIds.has(leafId) && + incoming[leafId] === undefined && + // Why: an empty layout map may be a stale pre-spawn snapshot; a partial map is intentional unless a durable SSH lease proves it. + (incomingHasAnyBinding + ? context.bindingRecovery.hasRestorableSshRemotePtyLease({ + ptyId, + targetId, + worktreeId, + tabId, + leafId + }) + : context.bindingRecovery.isRestorablePtyBinding({ + ptyId, + targetId, + worktreeId, + tabId, + leafId + })) + ) + ) + if (Object.keys(restorableBindings).length > 0) { + layout.ptyIdsByLeafId = { ...restorableBindings, ...incoming } + // Why: the same stale write that drops ptyIdsByLeafId may come from an older renderer lacking UUID-keyed metadata. + const buffersByLeafId = preserveMissingLeafRecordEntries( + priorLayout.buffersByLeafId, + layout.buffersByLeafId, + liveLeafIds + ) + const scrollbackRefsByLeafId = preserveMissingLeafRecordEntries( + priorLayout.scrollbackRefsByLeafId, + layout.scrollbackRefsByLeafId, + liveLeafIds + ) + const titlesByLeafId = preserveMissingLeafRecordEntries( + priorLayout.titlesByLeafId, + layout.titlesByLeafId, + liveLeafIds + ) + if (buffersByLeafId) { + layout.buffersByLeafId = buffersByLeafId + } + if (scrollbackRefsByLeafId) { + layout.scrollbackRefsByLeafId = scrollbackRefsByLeafId + } + if (titlesByLeafId) { + layout.titlesByLeafId = titlesByLeafId + } + } + } + } + session = pruneLocalTerminalScrollbackBuffers(session, context.runtime.state.repos) + if (!deferSnapshotFiles) { + const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( + session, + context.runtime.terminalScrollbackSnapshotStorage + ) + session = migratedScrollback.session + deleteRemovedTerminalScrollbackSnapshots( + prior, + session, + context.runtime.terminalScrollbackSnapshotStorage + ) + } + context.runtime.state.workspaceSession = session + if (deferSnapshotFiles) { + enqueueTerminalScrollbackSnapshotWork(owner, prior, session) + } + scheduleSave(context.scheduling) +} + +export function enqueueTerminalScrollbackSnapshotWork( + owner: SessionSnapshotOperations, + prior: WorkspaceSessionState | undefined, + staged: WorkspaceSessionState +): void { + const context = getSessionSnapshotOperationsContext(owner) + const previous = context.runtime.pendingSnapshotFileWork ?? Promise.resolve() + const work = previous + .then(async () => { + if (context.runtime.state.workspaceSession !== staged) { + if (context.runtime.state.workspaceSession) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + prior, + context.runtime.state.workspaceSession, + context.runtime.terminalScrollbackSnapshotStorage + ) + } + return + } + const migrated = await migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync( + staged, + context.runtime.terminalScrollbackSnapshotStorage + ) + const current = + context.runtime.state.workspaceSession === staged + ? migrated + : context.runtime.state.workspaceSession + if (context.runtime.state.workspaceSession === staged) { + context.runtime.state.workspaceSession = migrated + } else if (current) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + migrated, + current, + context.runtime.terminalScrollbackSnapshotStorage + ) + } + if (current) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + prior, + current, + context.runtime.terminalScrollbackSnapshotStorage + ) + } + }) + .catch((error) => { + console.error('[terminal-scrollback] Failed to prepare unload snapshots:', error) + }) + .finally(() => { + if (context.runtime.pendingSnapshotFileWork === work) { + context.runtime.pendingSnapshotFileWork = null + } + }) + context.runtime.pendingSnapshotFileWork = work +} diff --git a/src/main/persistence/loading-store/write-flush-barriers.ts b/src/main/persistence/loading-store/write-flush-barriers.ts new file mode 100644 index 00000000000..81424dc931b --- /dev/null +++ b/src/main/persistence/loading-store/write-flush-barriers.ts @@ -0,0 +1,276 @@ +import { writeFileSync, renameSync, unlinkSync } from 'node:fs' +import { rename, rm, writeFile } from 'node:fs/promises' +import { durableWriteTempPath } from '../../durable-file-write' +import { getGithubCacheFile } from './user-data-path' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { PrimaryStateWriteOperations } from './primary-state-writes' +import { enqueueWrite } from './primary-state-writes' + +type WriteFlushBarrierOperationsRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'dataFile' + | 'firstPendingSaveAt' + | 'githubCacheDirty' + | 'githubCacheGeneration' + | 'lastDurableWriteGeneration' + | 'pendingGithubCacheWrite' + | 'quitFlushPromise' + | 'quitFlushStarted' + | 'staleGithubCacheTempCleanup' + | 'state' + | 'writeGeneration' + | 'writeTimer' + | 'writesFrozen' +> + +const writeFlushBarrierOperationsContext = Symbol('WriteFlushBarrierOperations') +type WriteFlushBarrierOperationsContext = { + runtime: WriteFlushBarrierOperationsRuntime + writes: PrimaryStateWriteOperations +} + +export class WriteFlushBarrierOperations { + readonly [writeFlushBarrierOperationsContext]: WriteFlushBarrierOperationsContext + + constructor(runtime: WriteFlushBarrierOperationsRuntime, writes: PrimaryStateWriteOperations) { + this[writeFlushBarrierOperationsContext] = { runtime, writes } + } + + flush(): void { + if (this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted) { + return + } + try { + this[writeFlushBarrierOperationsContext].writes.flushOrThrow() + } catch (err) { + console.error('[persistence] Failed to flush state:', err) + } + try { + this[writeFlushBarrierOperationsContext].writes.flushActiveViewPreferenceOrThrow() + } catch (err) { + console.error('[active-view] Failed to flush preference:', err) + } + writeGithubCacheSnapshotSync(this) + } + + flushAsync(): Promise { + if (this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise) { + return this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise + } + this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted = true + this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise = flushCurrentStateAsync( + this, + true + ).catch(() => {}) + return this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise + } + + flushPendingAsync(): Promise { + // Best-effort callers must not livelock while the live app keeps mutating state. + return flushCurrentStateAsync(this, false, undefined, false).catch(() => {}) + } + + flushPendingOrThrowAsync( + options: { signal?: AbortSignal; drainToStableGeneration?: boolean } = {} + ): Promise { + if ( + this[writeFlushBarrierOperationsContext].runtime.writesFrozen || + this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted + ) { + return Promise.reject(new Error('Cannot flush while persistence is finalized')) + } + return flushCurrentStateAsync( + this, + false, + options.signal, + options.drainToStableGeneration, + true + ) + } +} + +export async function flushDurableStateOrThrowAsync( + owner: WriteFlushBarrierOperations +): Promise { + if ( + owner[writeFlushBarrierOperationsContext].runtime.writesFrozen || + owner[writeFlushBarrierOperationsContext].runtime.quitFlushStarted + ) { + throw new Error('Cannot flush while persistence is finalized') + } + for (;;) { + if (owner[writeFlushBarrierOperationsContext].runtime.writeTimer) { + clearTimeout(owner[writeFlushBarrierOperationsContext].runtime.writeTimer) + owner[writeFlushBarrierOperationsContext].runtime.writeTimer = null + } + owner[writeFlushBarrierOperationsContext].runtime.firstPendingSaveAt = null + const generation = owner[writeFlushBarrierOperationsContext].runtime.writeGeneration + await enqueueWrite(owner[writeFlushBarrierOperationsContext].writes) + if (generation === owner[writeFlushBarrierOperationsContext].runtime.writeGeneration) { + break + } + } +} + +export async function flushCurrentStateAsync( + owner: WriteFlushBarrierOperations, + final: boolean, + signal?: AbortSignal, + drainToStableGeneration = true, + requireInitialGenerationDurable = false +): Promise { + const requiredDurableGeneration = requireInitialGenerationDurable + ? owner[writeFlushBarrierOperationsContext].runtime.writeGeneration + : null + for (;;) { + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if (owner[writeFlushBarrierOperationsContext].runtime.writeTimer) { + clearTimeout(owner[writeFlushBarrierOperationsContext].runtime.writeTimer) + owner[writeFlushBarrierOperationsContext].runtime.writeTimer = null + } + owner[writeFlushBarrierOperationsContext].runtime.firstPendingSaveAt = null + const generation = owner[writeFlushBarrierOperationsContext].runtime.writeGeneration + try { + await enqueueWrite(owner[writeFlushBarrierOperationsContext].writes) + } catch (error) { + await (final + ? owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushAsync() + : owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushPendingAsync( + signal + )) + await writeGithubCacheSnapshotAsync(owner, final, signal) + throw error + } + await (final + ? owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushAsync() + : owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushPendingAsync( + signal + )) + await writeGithubCacheSnapshotAsync(owner, final, signal) + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if (!drainToStableGeneration) { + if ( + requiredDurableGeneration === null || + owner[writeFlushBarrierOperationsContext].runtime.lastDurableWriteGeneration >= + requiredDurableGeneration + ) { + break + } + continue + } + if (generation === owner[writeFlushBarrierOperationsContext].runtime.writeGeneration) { + break + } + } +} + +export async function writeGithubCacheSnapshotAsync( + owner: WriteFlushBarrierOperations, + drainToStableGeneration = true, + signal?: AbortSignal +): Promise { + if (!owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + return + } + const previousWrite = + owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite ?? + owner[writeFlushBarrierOperationsContext].runtime.staleGithubCacheTempCleanup + const nextWrite = previousWrite + .then(async () => { + while (owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + if (signal?.aborted) { + throw new Error('GitHub cache flush aborted') + } + const generation = owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + const cacheFile = getGithubCacheFile( + owner[writeFlushBarrierOperationsContext].runtime.dataFile + ) + const tmpFile = durableWriteTempPath(cacheFile) + let renamed = false + try { + await writeFile( + tmpFile, + JSON.stringify(owner[writeFlushBarrierOperationsContext].runtime.state.githubCache), + 'utf-8' + ) + if ( + generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + ) { + await rename(tmpFile, cacheFile) + renamed = true + if ( + generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + ) { + owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty = false + } + } + } finally { + if (!renamed) { + await rm(tmpFile).catch(() => {}) + } + } + if (signal?.aborted) { + throw new Error('GitHub cache flush aborted') + } + if (!drainToStableGeneration) { + break + } + } + }) + .catch((err) => { + console.warn('[persistence] Failed to write github cache snapshot:', err) + }) + .finally(() => { + if (owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite === nextWrite) { + owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite = null + } + }) + owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite = nextWrite + await nextWrite +} + +export function writeGithubCacheSnapshotSync(owner: WriteFlushBarrierOperations): void { + if (!owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + return + } + if (owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite) { + void writeGithubCacheSnapshotAsync(owner) + return + } + const cacheFile = getGithubCacheFile(owner[writeFlushBarrierOperationsContext].runtime.dataFile) + const generation = owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + const tmpFile = durableWriteTempPath(cacheFile) + try { + writeFileSync( + tmpFile, + JSON.stringify(owner[writeFlushBarrierOperationsContext].runtime.state.githubCache), + 'utf-8' + ) + renameSync(tmpFile, cacheFile) + if (generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration) { + owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty = false + } + } catch (err) { + try { + unlinkSync(tmpFile) + } catch { + // Best-effort cleanup. + } + console.warn('[persistence] Failed to write github cache snapshot:', err) + } +} + +export function installWriteFlushBarrierOperationsContext( + target: object, + source: WriteFlushBarrierOperations +): void { + Object.defineProperty(target, writeFlushBarrierOperationsContext, { + value: source[writeFlushBarrierOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/write-scheduling.ts b/src/main/persistence/loading-store/write-scheduling.ts new file mode 100644 index 00000000000..bf9fad874c4 --- /dev/null +++ b/src/main/persistence/loading-store/write-scheduling.ts @@ -0,0 +1,71 @@ +import type { StoreRuntimeState } from './store-runtime-state' +import type { PrimaryStateWriteOperations } from './primary-state-writes' +import { enqueueWrite } from './primary-state-writes' + +const SAVE_DEBOUNCE_MS = 1_000 +const SAVE_MAX_WAIT_MS = 5_000 + +type WriteSchedulingOperationsRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'firstPendingSaveAt' + | 'pendingWrite' + | 'quitFlushStarted' + | 'writeGeneration' + | 'writeTimer' +> + +const writeSchedulingOperationsContext = Symbol('WriteSchedulingOperations') +type WriteSchedulingOperationsContext = { + runtime: WriteSchedulingOperationsRuntime + writes: PrimaryStateWriteOperations +} + +export class WriteSchedulingOperations { + readonly [writeSchedulingOperationsContext]: WriteSchedulingOperationsContext + + constructor(runtime: WriteSchedulingOperationsRuntime, writes: PrimaryStateWriteOperations) { + this[writeSchedulingOperationsContext] = { runtime, writes } + } + + async waitForPendingWrite(): Promise { + await Promise.all([ + this[writeSchedulingOperationsContext].runtime.pendingWrite, + this[writeSchedulingOperationsContext].runtime.activeViewPreference.waitForPendingWrite() + ]) + } +} + +export function scheduleSave(owner: WriteSchedulingOperations): void { + // Why: once the quit flush has snapshotted, a newly debounced write would fire during + // teardown with nothing awaiting it, and the process can exit mid-rename. The quit + // flush is the last write by construction. + if (owner[writeSchedulingOperationsContext].runtime.quitFlushStarted) { + return + } + owner[writeSchedulingOperationsContext].runtime.writeGeneration += 1 + const now = Date.now() + owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt ??= now + if (owner[writeSchedulingOperationsContext].runtime.writeTimer) { + clearTimeout(owner[writeSchedulingOperationsContext].runtime.writeTimer) + } + const untilMaxWait = Math.max( + 0, + owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt + SAVE_MAX_WAIT_MS - now + ) + const delay = Math.min(SAVE_DEBOUNCE_MS, untilMaxWait) + owner[writeSchedulingOperationsContext].runtime.writeTimer = setTimeout(() => { + owner[writeSchedulingOperationsContext].runtime.writeTimer = null + owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt = null + void enqueueWrite(owner[writeSchedulingOperationsContext].writes) + }, delay) +} + +export function installWriteSchedulingOperationsContext( + target: object, + source: WriteSchedulingOperations +): void { + Object.defineProperty(target, writeSchedulingOperationsContext, { + value: source[writeSchedulingOperationsContext] + }) +} diff --git a/src/main/persistence/restoring-sessions/folder-workspace-operations.ts b/src/main/persistence/restoring-sessions/folder-workspace-operations.ts index 77c6547d000..1d3c2cbbcaf 100644 --- a/src/main/persistence/restoring-sessions/folder-workspace-operations.ts +++ b/src/main/persistence/restoring-sessions/folder-workspace-operations.ts @@ -8,11 +8,10 @@ import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-co import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' import { folderWorkspaceKey } from '../../../shared/workspace-scope' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { removeWorkspaceSessionOwner } from './session-owner-removal' export type FolderWorkspaceMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState scheduleSave: () => void removeWorkspaceLineageForFolderParent: (folderWorkspaceId: string) => void pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void diff --git a/src/main/persistence/scheduling-automations/automation-definition-operations.ts b/src/main/persistence/scheduling-automations/automation-definition-operations.ts index 677655c575e..801746f91ff 100644 --- a/src/main/persistence/scheduling-automations/automation-definition-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-definition-operations.ts @@ -7,7 +7,6 @@ import type { import type { PersistedState } from '../../../shared/persisted-state-types' import { normalizeAutomationPrecheck } from '../../../shared/automation-precheck' import { nextAutomationOccurrenceAfter } from '../../../shared/automation-schedules' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { getAutomationContextsForRepo, getAutomationSchedulerOwner, @@ -16,7 +15,7 @@ import { } from './automation-context-migration' export type AutomationDefinitionOperations = { - state: StoreOwnedPersistedState + state: PersistedState flush: () => void recordCreated: () => void } diff --git a/src/main/persistence/scheduling-automations/automation-run-operations.ts b/src/main/persistence/scheduling-automations/automation-run-operations.ts index f83117edf8b..d1ebfe31c66 100644 --- a/src/main/persistence/scheduling-automations/automation-run-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-run-operations.ts @@ -10,7 +10,6 @@ import { nextAutomationRunNumber, pruneAutomationRuns } from '../../../shared/automation-run-retention' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { normalizeAutomationPrecheckResult, normalizeAutomationRunOutputSnapshot, @@ -20,7 +19,7 @@ import { } from './automation-context-migration' export type AutomationRunOperations = { - state: StoreOwnedPersistedState + state: PersistedState flush: () => void recordManualRun: () => void getWorkspaceDisplayName: (workspaceId: string | null | undefined) => string | null diff --git a/src/main/persistence/scheduling-automations/automation-schedule-operations.ts b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts index b52de27baa0..25788a158cb 100644 --- a/src/main/persistence/scheduling-automations/automation-schedule-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts @@ -1,12 +1,12 @@ import type { Automation } from '../../../shared/automations-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { latestAutomationOccurrenceAtOrBefore, nextAutomationOccurrenceAfter } from '../../../shared/automation-schedules' export function advanceAutomationNextRun( - state: StoreOwnedPersistedState, + state: PersistedState, flush: () => void, id: string, now = Date.now() diff --git a/src/main/persistence/tracking-repos/project-group-operations.ts b/src/main/persistence/tracking-repos/project-group-operations.ts index 95a5f2f1627..b3918a9a98f 100644 --- a/src/main/persistence/tracking-repos/project-group-operations.ts +++ b/src/main/persistence/tracking-repos/project-group-operations.ts @@ -6,11 +6,10 @@ import { normalizeProjectGroupName } from '../../../shared/project-groups' import { folderWorkspaceKey } from '../../../shared/workspace-scope' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { removeWorkspaceSessionOwner } from '../restoring-sessions/session-owner-removal' export type ProjectGroupMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState scheduleSave: () => void removeWorkspaceLineageForFolderParent: (folderWorkspaceId: string) => void pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void diff --git a/src/main/persistence/tracking-repos/project-host-operations.ts b/src/main/persistence/tracking-repos/project-host-operations.ts index baaae4777f6..c6a8441f58a 100644 --- a/src/main/persistence/tracking-repos/project-host-operations.ts +++ b/src/main/persistence/tracking-repos/project-host-operations.ts @@ -13,12 +13,11 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { Repo } from '../../../shared/repo-types' import { getRepoExecutionHostId, normalizeExecutionHostId } from '../../../shared/execution-host' import { normalizeProjectRuntimePreference } from '../../../shared/project-execution-runtime' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { makeProjectHostSetupId } from './project-host-compatibility' import { repoGitUsernameCacheKey } from './repo-hydration' export type ProjectHostMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState gitUsernameCache: Map hydrateRepo: (repo: Repo) => Repo updateRepoBackedProjectHostSetup: ( diff --git a/src/main/persistence/tracking-repos/project-host-setup-update.ts b/src/main/persistence/tracking-repos/project-host-setup-update.ts index 2a72ab055c8..05d291bd171 100644 --- a/src/main/persistence/tracking-repos/project-host-setup-update.ts +++ b/src/main/persistence/tracking-repos/project-host-setup-update.ts @@ -1,11 +1,10 @@ -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import type { PersistedState } from '../../../shared/persisted-state-types' import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' import type { RepoUpdatePersistenceOperations } from './repo-update-operations' export type ProjectHostSetupUpdateOperations = { - state: StoreOwnedPersistedState + state: PersistedState updateRepo: RepoUpdatePersistenceOperations['updateRepo'] scheduleSave: () => void } diff --git a/src/main/persistence/tracking-repos/repo-order-operations.ts b/src/main/persistence/tracking-repos/repo-order-operations.ts index d578b20dcf7..55cec62226e 100644 --- a/src/main/persistence/tracking-repos/repo-order-operations.ts +++ b/src/main/persistence/tracking-repos/repo-order-operations.ts @@ -1,10 +1,9 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { Repo } from '../../../shared/repo-types' import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' export type RepoOrderMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState syncProjectHostSetupCompatibilityState: () => void scheduleSave: () => void } diff --git a/src/main/persistence/tracking-repos/repo-update-operations.ts b/src/main/persistence/tracking-repos/repo-update-operations.ts index 669dce1a2cc..f39e0e1e26f 100644 --- a/src/main/persistence/tracking-repos/repo-update-operations.ts +++ b/src/main/persistence/tracking-repos/repo-update-operations.ts @@ -5,11 +5,10 @@ import { getRepoExecutionHostId } from '../../../shared/execution-host' import { isLegacyRepoForExternalWorktreeVisibility } from '../../../shared/external-worktree-visibility' import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' import { normalizeWorktreeVisibilitySourcePreferences } from '../../../shared/worktree/visibility-sources' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { sanitizeRepoUpdatesForPersistence } from './repo-sanitization' export type RepoUpdateMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState syncProjectHostSetupCompatibilityState: () => void scheduleSave: () => void hydrateRepo: (repo: Repo) => Repo diff --git a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts index 50c4fe5f745..7032ef2ed60 100644 --- a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts +++ b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts @@ -1,7 +1,7 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal' import { getWorktreeIdFromHostIdentity, @@ -9,7 +9,7 @@ import { } from '../../../shared/worktree/host-qualified-identity' export function pruneWorktreeStateForRepo( - state: StoreOwnedPersistedState, + state: PersistedState, id: string, hostId: ExecutionHostId | null, pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration.ts b/src/main/persistence/tracking-repos/worktree-identity-migration.ts index a9d3397aa7c..d9451258634 100644 --- a/src/main/persistence/tracking-repos/worktree-identity-migration.ts +++ b/src/main/persistence/tracking-repos/worktree-identity-migration.ts @@ -1,11 +1,11 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import type { PersistedState } from '../../../shared/persisted-state-types' import { getWorktreeIdFromHostIdentity, isWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' /** * Re-keys every worktreeId-keyed record in `state` from `oldWorktreeId` to `newWorktreeId`. Mutates `state` in place; @@ -13,7 +13,7 @@ import type { StoreOwnedPersistedState } from '../loading-store/store-owned-stat * See `Store.migrateWorktreeIdentity` for why the rename happens. */ export function migrateWorktreeIdentity( - state: StoreOwnedPersistedState, + state: PersistedState, oldWorktreeId: string, newWorktreeId: string ): boolean { diff --git a/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts b/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts index 0ddba9dc730..5886ebb7196 100644 --- a/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts +++ b/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { WorktreeMeta } from '../../../shared/worktree/meta-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { normalizeWorktreeLinkedItemMetadata } from './worktree-metadata-normalization' // Only the presence of an entry matters here; the normalizer never reads its linked-item fields. @@ -8,20 +8,20 @@ function makeMeta(): WorktreeMeta { return { createdAt: 1 } as WorktreeMeta } -function makeState(overrides: Partial): StoreOwnedPersistedState { +function makeState(overrides: Partial): PersistedState { return { worktreeMeta: {}, worktreeLineageById: {}, workspaceLineageByChildKey: {}, ...overrides - } as StoreOwnedPersistedState + } as PersistedState } describe('normalizeWorktreeLinkedItemMetadata', () => { it('reports a null lineage map repair as changed so the load path re-saves it', () => { const state = makeState({ worktreeMeta: { 'r1::/tmp/wt': makeMeta() }, - worktreeLineageById: null as unknown as StoreOwnedPersistedState['worktreeLineageById'] + worktreeLineageById: null as unknown as PersistedState['worktreeLineageById'] }) // Without this the map stays null on disk and is repaired again on every reload. @@ -32,8 +32,7 @@ describe('normalizeWorktreeLinkedItemMetadata', () => { it('reports a null child-key lineage map repair as changed', () => { const state = makeState({ worktreeMeta: {}, - workspaceLineageByChildKey: - null as unknown as StoreOwnedPersistedState['workspaceLineageByChildKey'] + workspaceLineageByChildKey: null as unknown as PersistedState['workspaceLineageByChildKey'] }) expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true) diff --git a/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts index 72f64b82906..81c6e94843e 100644 --- a/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts +++ b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts @@ -14,14 +14,14 @@ import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' // Why: worktrees deleted outside Orca orphan their worktreeMeta, so the map grew monotonically (63% dead on a heavy install). // GC stays narrow: local-host entries only (a local existsSync would falsely condemn SSH/WSL remote paths) and only after a 30-day idle grace. export const WORKTREE_META_GC_GRACE_MS = 30 * 24 * 60 * 60 * 1000 export const STALE_DURABLE_WRITE_TEMP_AGE_MS = 24 * 60 * 60 * 1000 -export function gcStaleWorktreeMeta(state: StoreOwnedPersistedState): number { +export function gcStaleWorktreeMeta(state: PersistedState): number { // Why: a hand-corrupted "worktreeMeta": null overrides the defaults merge; normalize here instead of throwing. // Companion lineage maps get the same guard because the deletes below index them directly. state.worktreeMeta ??= {} @@ -80,7 +80,7 @@ export function gcStaleWorktreeMeta(state: StoreOwnedPersistedState): number { return removed } -export function normalizeWorktreeLinkedItemMetadata(state: StoreOwnedPersistedState): boolean { +export function normalizeWorktreeLinkedItemMetadata(state: PersistedState): boolean { // Why: a hand-corrupted null lineage map would throw on the companion deletes below. The repair // itself is dirty state — without it the map stays null on disk and is repaired again every load. let changed = From b6a24ecac12f196979a9253c8617a5bd7182429f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:46:07 -0700 Subject: [PATCH 35/65] refactor: split GitHub backend modules (#16194) --- config/max-lines-baseline.txt | 3 - src/main/github/issue-timeline.ts | 189 +++ src/main/github/issue-work-item-details.ts | 260 ++++ .../github/pr-refresh-candidate-policy.ts | 183 +++ ...resh-coordinator-rate-limit-budget.test.ts | 54 + ...refresh-coordinator-refresh-events.test.ts | 77 + src/main/github/pr-refresh-coordinator.ts | 963 +----------- src/main/github/pr-refresh-event-publisher.ts | 69 + src/main/github/pr-refresh-pacing.ts | 114 ++ src/main/github/pr-refresh-queue-drainer.ts | 242 +++ src/main/github/pr-refresh-queue.ts | 225 +++ src/main/github/pr-refresh-rate-limit-gate.ts | 27 + src/main/github/pr-refresh-retry-state.ts | 46 + src/main/github/pr-refresh-visibility.ts | 44 + src/main/github/pull-request-file-contents.ts | 132 ++ src/main/github/pull-request-file-data.ts | 247 +++ .../work-item-details-api-parity.test.ts | 37 + .../github/work-item-details-pr-files.test.ts | 30 + src/main/github/work-item-details.ts | 1143 +------------- src/main/github/work-item-participants.ts | 241 +++ src/main/ipc/github-account-handlers.ts | 31 + .../ipc/github-ipc-channel-parity.test.ts | 100 ++ .../ipc/github-issue-mutation-handlers.ts | 106 ++ src/main/ipc/github-pr-mutation-handlers.ts | 212 +++ src/main/ipc/github-pr-read-handlers.ts | 178 +++ src/main/ipc/github-pr-refresh-handlers.ts | 161 ++ src/main/ipc/github-pr-review-handlers.ts | 180 +++ src/main/ipc/github-project-view-handlers.ts | 89 ++ src/main/ipc/github-repo-routing.ts | 130 ++ src/main/ipc/github-work-item-handlers.ts | 214 +++ .../ipc/github-work-item-mutation-events.ts | 15 + src/main/ipc/github.ts | 1323 +---------------- 32 files changed, 3745 insertions(+), 3320 deletions(-) create mode 100644 src/main/github/issue-timeline.ts create mode 100644 src/main/github/issue-work-item-details.ts create mode 100644 src/main/github/pr-refresh-candidate-policy.ts create mode 100644 src/main/github/pr-refresh-event-publisher.ts create mode 100644 src/main/github/pr-refresh-pacing.ts create mode 100644 src/main/github/pr-refresh-queue-drainer.ts create mode 100644 src/main/github/pr-refresh-queue.ts create mode 100644 src/main/github/pr-refresh-rate-limit-gate.ts create mode 100644 src/main/github/pr-refresh-retry-state.ts create mode 100644 src/main/github/pr-refresh-visibility.ts create mode 100644 src/main/github/pull-request-file-contents.ts create mode 100644 src/main/github/pull-request-file-data.ts create mode 100644 src/main/github/work-item-details-api-parity.test.ts create mode 100644 src/main/github/work-item-participants.ts create mode 100644 src/main/ipc/github-account-handlers.ts create mode 100644 src/main/ipc/github-ipc-channel-parity.test.ts create mode 100644 src/main/ipc/github-issue-mutation-handlers.ts create mode 100644 src/main/ipc/github-pr-mutation-handlers.ts create mode 100644 src/main/ipc/github-pr-read-handlers.ts create mode 100644 src/main/ipc/github-pr-refresh-handlers.ts create mode 100644 src/main/ipc/github-pr-review-handlers.ts create mode 100644 src/main/ipc/github-project-view-handlers.ts create mode 100644 src/main/ipc/github-repo-routing.ts create mode 100644 src/main/ipc/github-work-item-handlers.ts create mode 100644 src/main/ipc/github-work-item-mutation-events.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 9e24dc1b39f..f2a9bebb747 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -27,14 +27,11 @@ inline src/main/daemon/pty-subprocess.ts inline src/main/git/runner.ts inline src/main/git/status.ts inline src/main/git/worktree.ts -inline src/main/github/pr-refresh-coordinator.ts inline src/main/github/project-view.ts inline src/main/github/project-view/mutations.ts -inline src/main/github/work-item-details.ts inline src/main/index.ts inline src/main/ipc/filesystem-watcher.ts inline src/main/ipc/filesystem.ts -inline src/main/ipc/github.ts inline src/main/ipc/repos.ts inline src/main/ipc/ssh.ts inline src/main/ipc/worktree-remote.ts diff --git a/src/main/github/issue-timeline.ts b/src/main/github/issue-timeline.ts new file mode 100644 index 00000000000..3cc1f36eaac --- /dev/null +++ b/src/main/github/issue-timeline.ts @@ -0,0 +1,189 @@ +import type { + GitHubIssueTimelineItem, + GitHubIssueTimelineTarget +} from '../../shared/github/comment-types' +import { ghExecFileAsync } from './gh-utils' +import type { GitHubApiRepository, GitHubRepoExecOptions } from './github-api-repository' +import { githubHostExecOptions } from './github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const MAX_ISSUE_TIMELINE_ITEMS = 300 +const GITHUB_REST_PAGE_SIZE = 100 + +type RestTimelineUser = { + login?: string | null + avatar_url?: string | null +} + +type RestTimelineIssue = { + number?: number | null + title?: string | null + html_url?: string | null + repository?: { + name?: string | null + owner?: { login?: string | null } | null + } | null + pull_request?: unknown +} + +type RestTimelineEvent = { + id?: number | string | null + node_id?: string | null + event?: string | null + actor?: RestTimelineUser | null + user?: RestTimelineUser | null + assignee?: RestTimelineUser | null + created_at?: string | null + source?: { issue?: RestTimelineIssue | null } | null + closer?: RestTimelineIssue | null + state_reason?: string | null + project_card?: { + column_name?: string | null + previous_column_name?: string | null + project_url?: string | null + } | null + project?: { name?: string | null } | null + project_column_name?: string | null + previous_column_name?: string | null +} + +function isSupportedTimelineEvent( + eventName: string | null | undefined +): eventName is GitHubIssueTimelineItem['event'] { + return ( + eventName === 'assigned' || + eventName === 'unassigned' || + eventName === 'mentioned' || + eventName === 'cross-referenced' || + eventName === 'closed' || + eventName === 'reopened' || + eventName === 'moved_columns_in_project' + ) +} + +function mapTimelineTarget( + issue: RestTimelineIssue | null | undefined +): GitHubIssueTimelineTarget | undefined { + if (!issue || typeof issue.number !== 'number' || !issue.html_url) { + return undefined + } + const owner = issue.repository?.owner?.login + const repo = issue.repository?.name + return { + type: issue.pull_request ? 'pr' : 'issue', + number: issue.number, + title: issue.title ?? '', + url: issue.html_url, + repository: owner && repo ? `${owner}/${repo}` : undefined + } +} + +function getTimelineActor(event: RestTimelineEvent): { login: string; avatarUrl: string } { + const actor = event.actor ?? event.user + return { + login: actor?.login ?? 'ghost', + avatarUrl: actor?.avatar_url ?? '' + } +} + +function mapRestTimelineEvent(event: RestTimelineEvent): GitHubIssueTimelineItem | null { + const eventName = event.event + if (!isSupportedTimelineEvent(eventName) || !event.created_at) { + return null + } + const actor = getTimelineActor(event) + const id = String(event.node_id ?? event.id ?? `${eventName}:${event.created_at}`) + const base = { + id, + event: eventName, + actor: actor.login, + actorAvatarUrl: actor.avatarUrl, + createdAt: event.created_at + } + if (eventName === 'assigned' || eventName === 'unassigned') { + return { ...base, assignee: event.assignee?.login ?? undefined } + } + if (eventName === 'mentioned' || eventName === 'cross-referenced') { + return { ...base, source: mapTimelineTarget(event.source?.issue) } + } + if (eventName === 'closed') { + return { + ...base, + stateReason: event.state_reason ?? null, + closer: mapTimelineTarget(event.closer ?? event.source?.issue) + } + } + if (eventName === 'moved_columns_in_project') { + return { + ...base, + previousColumnName: + event.previous_column_name ?? event.project_card?.previous_column_name ?? null, + columnName: event.project_column_name ?? event.project_card?.column_name ?? null, + projectName: event.project?.name ?? null + } + } + return base +} + +function parseRestTimelineEventLines(stdout: string): RestTimelineEvent[] { + const events: RestTimelineEvent[] = [] + for (const line of stdout.split('\n')) { + const trimmed = line.trim() + if (!trimmed) { + continue + } + try { + const parsed = JSON.parse(trimmed) as unknown + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + events.push(parsed) + } + } catch { + // Timeline activity is auxiliary to issue details. + } + } + return events +} + +export async function getIssueTimelineItems( + repository: GitHubApiRepository, + issueNumber: number, + ghOptions: GitHubRepoExecOptions +): Promise { + try { + const items: GitHubIssueTimelineItem[] = [] + for (let page = 1; items.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) { + if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) { + return items + } + noteRepositoryRateLimitSpend(repository, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${repository.owner}/${repository.repo}/issues/${issueNumber}/timeline?per_page=${GITHUB_REST_PAGE_SIZE}&page=${page}`, + '--jq', + '.[] | @json' + ], + { ...ghOptions, ...githubHostExecOptions(repository) } + ) + const pageEvents = parseRestTimelineEventLines(stdout) + for (const event of pageEvents) { + const item = mapRestTimelineEvent(event) + if (!item) { + continue + } + items.push(item) + if (items.length === MAX_ISSUE_TIMELINE_ITEMS) { + break + } + } + if (pageEvents.length < GITHUB_REST_PAGE_SIZE) { + break + } + } + return items + } catch { + return [] + } +} diff --git a/src/main/github/issue-work-item-details.ts b/src/main/github/issue-work-item-details.ts new file mode 100644 index 00000000000..d58491abd87 --- /dev/null +++ b/src/main/github/issue-work-item-details.ts @@ -0,0 +1,260 @@ +import type { GitHubIssueTimelineItem, PRComment } from '../../shared/github/comment-types' +import type { GitHubAssignableUser } from '../../shared/github/pull-request-types' +import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from './gh-utils' +import type { LocalGitExecOptions } from './gh-utils' +import { githubHostExecOptions, type GitHubApiRepository } from './github-api-repository' +import { getIssueTimelineItems } from './issue-timeline' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const ISSUE_DETAILS_QUERY = `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + body + assignees(first: 50) { nodes { login avatarUrl(size: 48) ... on User { name } } } + participants(first: 100) { + nodes { login avatarUrl(size: 48) ... on User { name } } + } + comments(first: 100) { + nodes { + databaseId + body + createdAt + url + author { + login + avatarUrl(size: 48) + ... on Bot { __typename } + } + } + } + } + } +}` + +type GraphQLIssueDetailsResponse = { + data?: { + repository?: { + issue?: { + body?: string | null + assignees?: { nodes?: { login?: string; avatarUrl?: string; name?: string | null }[] } + participants?: { nodes?: GitHubAssignableUser[] } + comments?: { + nodes?: { + databaseId?: number | null + body?: string | null + createdAt?: string | null + url?: string | null + author?: { + login?: string | null + avatarUrl?: string | null + __typename?: string + } | null + }[] + } + } | null + } | null + } + errors?: { message?: string }[] +} + +export type CollapsedIssueDetails = { + body: string + comments: PRComment[] + assignees: string[] + assigneeUsers: GitHubAssignableUser[] + participants: GitHubAssignableUser[] + timelineItems: GitHubIssueTimelineItem[] +} + +export async function getIssueDetailsViaGraphQL( + repoPath: string, + issueNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (!repository) { + return null + } + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + return null + } + try { + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${ISSUE_DETAILS_QUERY}`, + '-f', + `owner=${repository.owner}`, + '-f', + `repo=${repository.repo}`, + '-F', + `number=${issueNumber}` + ], + ghOptions + ) + const parsed = JSON.parse(stdout) as GraphQLIssueDetailsResponse + if (parsed.errors && parsed.errors.length > 0) { + return null + } + const issue = parsed.data?.repository?.issue + if (!issue) { + return null + } + const comments: PRComment[] = (issue.comments?.nodes ?? []) + .filter((comment) => typeof comment.databaseId === 'number') + .map((comment) => ({ + id: comment.databaseId as number, + author: comment.author?.login ?? 'ghost', + authorAvatarUrl: comment.author?.avatarUrl ?? '', + body: comment.body ?? '', + createdAt: comment.createdAt ?? '', + url: comment.url ?? '', + isBot: comment.author?.__typename === 'Bot' + })) + const assigneeUsers: GitHubAssignableUser[] = (issue.assignees?.nodes ?? []) + .filter((assignee): assignee is { login: string; avatarUrl?: string; name?: string | null } => + Boolean(assignee.login) + ) + .map((assignee) => ({ + login: assignee.login, + name: assignee.name ?? null, + avatarUrl: assignee.avatarUrl ?? '' + })) + const participants: GitHubAssignableUser[] = (issue.participants?.nodes ?? []) + .filter((user) => Boolean(user.login)) + .map((user) => ({ + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + })) + return { + body: issue.body ?? '', + comments, + assignees: assigneeUsers.map((assignee) => assignee.login), + assigneeUsers, + participants, + timelineItems: await getIssueTimelineItems(repository, issueNumber, ghOptions) + } + } catch { + return null + } +} + +export async function getIssueBodyAndComments( + repoPath: string, + issueNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ + body: string + comments: PRComment[] + assignees: string[] + timelineItems: GitHubIssueTimelineItem[] +}> { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + try { + if (repository) { + if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) { + return { body: '', comments: [], assignees: [], timelineItems: [] } + } + noteRepositoryRateLimitSpend(repository, 'core', 2, ghOptions) + const [issueResult, commentsResult, timelineItems] = await Promise.all([ + ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${repository.owner}/${repository.repo}/issues/${issueNumber}` + ], + ghOptions + ), + ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${repository.owner}/${repository.repo}/issues/${issueNumber}/comments?per_page=100` + ], + ghOptions + ), + getIssueTimelineItems(repository, issueNumber, ghOptions) + ]) + const issue = JSON.parse(issueResult.stdout) as { + body?: string | null + assignees?: { login: string }[] + } + type RESTComment = { + id: number + user: { login: string; avatar_url: string; type?: string } | null + body: string + created_at: string + html_url: string + } + const comments = (JSON.parse(commentsResult.stdout) as RESTComment[]).map( + (comment): PRComment => ({ + id: comment.id, + author: comment.user?.login ?? 'ghost', + authorAvatarUrl: comment.user?.avatar_url ?? '', + body: comment.body ?? '', + createdAt: comment.created_at, + url: comment.html_url, + isBot: comment.user?.type === 'Bot' + }) + ) + return { + body: issue.body ?? '', + comments, + assignees: (issue.assignees ?? []).map((assignee) => assignee.login), + timelineItems + } + } + if (connectionId) { + // A connection-backed lookup must not use ambient local GH_REPO/GH_HOST. + return { body: '', comments: [], assignees: [], timelineItems: [] } + } + const { stdout } = await ghExecFileAsync( + ['issue', 'view', String(issueNumber), '--json', 'body,comments,assignees'], + ghOptions + ) + const data = JSON.parse(stdout) as { + body?: string + comments?: { + author: { login: string } + body: string + createdAt: string + url: string + }[] + assignees?: { login: string }[] + } + const comments = (data.comments ?? []).map( + (comment, index): PRComment => ({ + id: index, + author: comment.author?.login ?? 'ghost', + authorAvatarUrl: '', + body: comment.body ?? '', + createdAt: comment.createdAt, + url: comment.url ?? '' + }) + ) + return { + body: data.body ?? '', + comments, + assignees: (data.assignees ?? []).map((assignee) => assignee.login), + timelineItems: [] + } + } catch { + return { body: '', comments: [], assignees: [], timelineItems: [] } + } +} diff --git a/src/main/github/pr-refresh-candidate-policy.ts b/src/main/github/pr-refresh-candidate-policy.ts new file mode 100644 index 00000000000..e2868edf051 --- /dev/null +++ b/src/main/github/pr-refresh-candidate-policy.ts @@ -0,0 +1,183 @@ +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + GitHubPRRefreshReason, + GitHubPRRefreshSkippedReason, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import type { GitHubPRBranchLookupOptions } from './client' +import { NO_REVIEW_REFRESH_INTERVAL_MS } from '../source-control/hosted-review-refresh-pacing' + +export const MANUAL_MERGEABILITY_PENDING_REFRESH_MS = 2_500 +export const POST_PUSH_DELAY_MS = 2_500 + +type PRBranchLookupCandidate = Pick< + GitHubPRRefreshCandidate, + 'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource' | 'currentHeadOid' +> + +function shouldAcceptMergedFallbackPR(candidate: PRBranchLookupCandidate): boolean { + return ( + candidate.linkedPRNumber == null && + candidate.fallbackPRNumber != null && + candidate.fallbackPRSource != null + ) +} + +export function hostedReviewOptionArgs( + candidate: PRBranchLookupCandidate +): [] | [GitHubPRBranchLookupOptions] { + const options: GitHubPRBranchLookupOptions = {} + if (candidate.localGitOptions?.wslDistro) { + options.localGitExecOptions = { wslDistro: candidate.localGitOptions.wslDistro } + } + if (shouldAcceptMergedFallbackPR(candidate)) { + options.acceptMergedFallbackPR = true + } + if (typeof candidate.currentHeadOid === 'string' && candidate.currentHeadOid.trim().length > 0) { + options.currentHeadOid = candidate.currentHeadOid.trim() + } + return Object.keys(options).length > 0 ? [options] : [] +} + +export function refreshKey(candidate: GitHubPRRefreshCandidate): string { + const connectionScope = candidate.connectionId ?? 'local' + const runtimeScope = candidate.connectionId + ? 'remote' + : `runtime:${candidate.localGitOptions?.wslDistro ? `wsl:${candidate.localGitOptions.wslDistro}` : 'host'}` + if (typeof candidate.linkedPRNumber === 'number') { + return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::pr::${candidate.linkedPRNumber}` + } + return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::branch::${candidate.branch}` +} + +export function validateCandidate( + candidate: GitHubPRRefreshCandidate +): GitHubPRRefreshSkippedReason | null { + if (candidate.repoKind !== 'git') { + return 'not-git' + } + if (candidate.isBare) { + return 'bare' + } + if (candidate.isArchived) { + return 'archived' + } + if (candidate.connectionId && candidate.connectionState === 'disconnected') { + return 'disconnected' + } + if (!candidate.branch && typeof candidate.linkedPRNumber !== 'number') { + return 'fresh' + } + return null +} + +export function bypassesFreshnessDelay(reason: GitHubPRRefreshReason): boolean { + return reason === 'manual' || reason === 'active' || reason === 'post-push' +} + +export function isBackground(reason: GitHubPRRefreshReason): boolean { + return reason !== 'manual' +} + +export function isBudgetedBackground(reason: GitHubPRRefreshReason): boolean { + return reason === 'visible' || reason === 'swr' +} + +export function shouldBroadcastQueued(reason: GitHubPRRefreshReason, dueAt: number): boolean { + if (isBudgetedBackground(reason)) { + return false + } + const delay = dueAt - Date.now() + return delay > 0 && delay <= 5_000 +} + +export function shouldSkipFresh( + candidate: GitHubPRRefreshCandidate, + reason: GitHubPRRefreshReason +): boolean { + if (bypassesFreshnessDelay(reason) || candidate.cachedFetchedAt == null) { + return false + } + return Date.now() - candidate.cachedFetchedAt < refreshIntervalForCandidate(candidate) +} + +export function freshRetryAt(candidate: GitHubPRRefreshCandidate): number | null { + return candidate.cachedFetchedAt == null + ? null + : candidate.cachedFetchedAt + refreshIntervalForCandidate(candidate) +} + +export function aliasFromCandidate(candidate: GitHubPRRefreshCandidate): GitHubPRRefreshAlias { + return { + cacheKey: candidate.cacheKey, + repoId: candidate.repoId, + repoPath: candidate.repoPath, + branch: candidate.branch, + worktreeId: candidate.worktreeId, + connectionId: candidate.connectionId ?? null, + currentHeadOid: candidate.currentHeadOid ?? null, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: + candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, + fallbackPRSource: candidate.linkedPRNumber == null ? (candidate.fallbackPRSource ?? null) : null + } +} + +export function visibleCandidateAfterOutcome( + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome +): GitHubPRRefreshCandidate { + if (outcome.kind === 'upstream-error') { + return candidate + } + return { + ...candidate, + cachedFetchedAt: outcome.fetchedAt, + cachedHasPR: outcome.kind === 'found', + cachedPRState: outcome.kind === 'found' ? outcome.pr.state : null, + cachedChecksStatus: outcome.kind === 'found' ? outcome.pr.checksStatus : null, + cachedMergeable: outcome.kind === 'found' ? outcome.pr.mergeable : null, + cachedMergeStateStatus: outcome.kind === 'found' ? (outcome.pr.mergeStateStatus ?? null) : null + } +} + +function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): number { + if (candidate.cachedPRState === 'closed' || candidate.cachedPRState === 'merged') { + return 30 * 60_000 + } + if (candidate.cachedHasPR === false) { + return NO_REVIEW_REFRESH_INTERVAL_MS + } + if ( + candidate.cachedHasPR === true && + candidate.cachedPRState === 'open' && + candidate.cachedMergeable === 'UNKNOWN' && + !hasResolvedMergeStateStatus(candidate.cachedMergeStateStatus) + ) { + return 10_000 + } + if (candidate.cachedChecksStatus === 'success') { + return 10 * 60_000 + } + if (candidate.cachedChecksStatus === 'failure') { + return 3 * 60_000 + } + if (candidate.cachedChecksStatus === 'pending') { + return 90_000 + } + return 60_000 +} + +function hasResolvedMergeStateStatus(status: string | null | undefined): boolean { + return status === 'CLEAN' || status === 'BEHIND' || status === 'BLOCKED' +} + +export function isMergeabilityPendingOutcome(outcome: PRRefreshOutcome): boolean { + return ( + outcome.kind === 'found' && + outcome.pr.state === 'open' && + outcome.pr.mergeable === 'UNKNOWN' && + !hasResolvedMergeStateStatus(outcome.pr.mergeStateStatus) + ) +} diff --git a/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts b/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts index 8d81faeb0f7..29b5309d26b 100644 --- a/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts +++ b/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts @@ -164,4 +164,58 @@ describe('pr-refresh-coordinator', () => { 'feature/0' ]) }) + + it.each([ + { primaryResetAt: 4, retryDisabledUntil: 5_000, expectedGateUntil: 5_000 }, + { primaryResetAt: 6, retryDisabledUntil: 5_000, expectedGateUntil: 6_000 } + ])( + 'uses the later manual gate when the primary reset is $primaryResetAt seconds', + async ({ primaryResetAt, retryDisabledUntil, expectedGateUntil }) => { + const { refreshPRNow, reportVisiblePRRefreshCandidates } = + await import('./pr-refresh-coordinator') + const candidate = makeCandidate() + getPRForBranchOutcomeMock.mockResolvedValueOnce({ + kind: 'upstream-error', + errorType: 'rate_limited', + message: 'limited', + fetchedAt: Date.now(), + retryDisabledUntil + }) + + reportVisiblePRRefreshCandidates([candidate], 1, 1) + await refreshPRNow(candidate) + getPRForBranchOutcomeMock.mockClear() + repositoryRateLimitGuardMock.mockImplementation((_repository, bucket) => + bucket === 'core' + ? { blocked: true, remaining: 0, limit: 5_000, resetAt: primaryResetAt } + : { blocked: false } + ) + + const outcome = await refreshPRNow(candidate) + + expect(outcome).toEqual({ + kind: 'upstream-error', + errorType: 'rate_limited', + message: 'GitHub is temporarily limiting requests. Try again after the limit resets.', + fetchedAt: 1_000, + nextAutoRetryAt: expectedGateUntil, + retryDisabledUntil: expectedGateUntil + }) + expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled() + expect(sendMock.mock.calls.map(([, event]) => event).at(-1)).toEqual( + expect.objectContaining({ + reason: 'manual', + status: 'paused', + pausedUntil: expectedGateUntil, + skippedReason: 'rate-limit' + }) + ) + + getPRForBranchOutcomeMock.mockResolvedValue({ kind: 'no-pr', fetchedAt: expectedGateUntil }) + await vi.advanceTimersByTimeAsync(expectedGateUntil - Date.now() - 1) + expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledOnce() + } + ) }) diff --git a/src/main/github/pr-refresh-coordinator-refresh-events.test.ts b/src/main/github/pr-refresh-coordinator-refresh-events.test.ts index 9d8d0745645..45a026dabbd 100644 --- a/src/main/github/pr-refresh-coordinator-refresh-events.test.ts +++ b/src/main/github/pr-refresh-coordinator-refresh-events.test.ts @@ -27,6 +27,43 @@ describe('pr-refresh-coordinator', () => { vi.useRealTimers() }) + it('preserves the coordinator public module API', async () => { + const coordinator = await import('./pr-refresh-coordinator') + + expect(Object.keys(coordinator).sort()).toEqual([ + '_getPRRefreshAliasCountForTests', + '_getPRRefreshErrorBackoffCountForTests', + '_getPRRefreshQueueSizeForTests', + '_getVisiblePRRefreshWindowCountForTests', + 'clearVisiblePRRefreshWindow', + 'enqueuePRRefresh', + 'pruneWorktreePRRefreshAliases', + 'refreshPRNow', + 'reportVisiblePRRefreshCandidates', + 'setPRRefreshOutcomeObserver' + ]) + }) + + it('ignores a stale visibility generation before it can replace queued work', async () => { + const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock.mockResolvedValue({ kind: 'no-pr', fetchedAt: Date.now() }) + + reportVisiblePRRefreshCandidates( + [makeCandidate({ branch: 'feature/current', cacheKey: '/repo::feature/current' })], + 2, + 1 + ) + reportVisiblePRRefreshCandidates( + [makeCandidate({ branch: 'feature/stale', cacheKey: '/repo::feature/stale' })], + 1, + 1 + ) + await vi.advanceTimersByTimeAsync(0) + + expect(getPRForBranchOutcomeMock).toHaveBeenCalledOnce() + expect(getPRForBranchOutcomeMock.mock.calls[0]?.[1]).toBe('feature/current') + }) + it('sends each refresh event once without broadcasting to 100 browser guests', async () => { const guestSends = Array.from({ length: 100 }, () => vi.fn()) getAllWebContentsMock.mockReturnValue( @@ -109,6 +146,46 @@ describe('pr-refresh-coordinator', () => { expect(outcome?.sequence).toBe(inFlight?.sequence) }) + it('keeps each manual outcome on its request sequence when completions race', async () => { + const { refreshPRNow } = await import('./pr-refresh-coordinator') + let resolveFirst!: (outcome: ReturnType) => void + let resolveSecond!: (outcome: ReturnType) => void + const first = new Promise>((resolve) => { + resolveFirst = resolve + }) + const second = new Promise>((resolve) => { + resolveSecond = resolve + }) + getPRForBranchOutcomeMock + .mockImplementationOnce(async () => ({ + kind: 'found', + pr: await first, + fetchedAt: Date.now() + })) + .mockImplementationOnce(async () => ({ + kind: 'found', + pr: await second, + fetchedAt: Date.now() + })) + + const firstRefresh = refreshPRNow(makeCandidate()) + const secondRefresh = refreshPRNow(makeCandidate()) + await vi.advanceTimersByTimeAsync(0) + resolveSecond(makePR({ number: 22 })) + await secondRefresh + resolveFirst(makePR({ number: 11 })) + await firstRefresh + + const events = sendMock.mock.calls.map(([, event]) => event) + const inFlight = events.filter((event) => event.status === 'in-flight') + const outcomes = events.filter((event) => event.outcome) + const firstOutcome = outcomes.find((event) => event.outcome.pr?.number === 11) + const secondOutcome = outcomes.find((event) => event.outcome.pr?.number === 22) + expect(firstOutcome?.sequence).toBe(inFlight[0]?.sequence) + expect(secondOutcome?.sequence).toBe(inFlight[1]?.sequence) + expect(firstOutcome?.sequence).toBeLessThan(secondOutcome?.sequence) + }) + it('accepts merged fallback PRs for visible fallback refreshes', async () => { const { refreshPRNow } = await import('./pr-refresh-coordinator') getPRForBranchOutcomeMock.mockResolvedValueOnce({ diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 2ff9d0e410b..22846cbe17c 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -1,817 +1,61 @@ -/* eslint-disable max-lines -- Why: queueing, pacing, and broadcast rules stay together so freshness and rate-limit invariants are reviewable in one place. */ -import { webContents } from 'electron' import type { - GitHubPRRefreshAlias, GitHubPRRefreshCandidate, - GitHubPRRefreshEvent, GitHubPRRefreshReason, - GitHubPRRefreshSkippedReason, PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types' -import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' -import { getOriginGitHubApiRepository } from './github-api-repository' -import { ghRepoExecOptions, githubRepoContext } from './gh-utils' -import { getRateLimit, repositoryRateLimitGuard, spendsSharedGitHubComQuota } from './rate-limit' +import { getPRForBranchOutcome } from './client' import { - lookupBackoffDelayMs, - NO_REVIEW_REFRESH_INTERVAL_MS -} from '../source-control/hosted-review-refresh-pacing' -import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' -import { sendToTrustedUIRenderer } from '../ipc/ui' + aliasFromCandidate, + hostedReviewOptionArgs, + MANUAL_MERGEABILITY_PENDING_REFRESH_MS, + refreshKey, + shouldBroadcastQueued, + validateCandidate +} from './pr-refresh-candidate-policy' +import { + PRRefreshEventPublisher, + type PRRefreshOutcomeObserver +} from './pr-refresh-event-publisher' +import { PRRefreshPacing } from './pr-refresh-pacing' +import { PRRefreshQueue } from './pr-refresh-queue' +import { PRRefreshQueueDrainer } from './pr-refresh-queue-drainer' +import { prRefreshRateLimitPausedUntil } from './pr-refresh-rate-limit-gate' +import { PRRefreshRetryState } from './pr-refresh-retry-state' +import { PRRefreshVisibility } from './pr-refresh-visibility' -type QueueEntry = { - key: string - candidate: GitHubPRRefreshCandidate - aliases: Map - reason: GitHubPRRefreshReason - priority: number - dueAt: number - queuedAt: number - bypassBackgroundBudget?: boolean - activeDelayNotified?: boolean - windowId?: number -} - -type PRRefreshOutcomeObserver = ( - candidate: GitHubPRRefreshCandidate, - outcome: PRRefreshOutcome -) => void - -type PRBranchLookupCandidate = Pick< - GitHubPRRefreshCandidate, - 'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource' | 'currentHeadOid' -> - -function shouldAcceptMergedFallbackPR(candidate: PRBranchLookupCandidate): boolean { - return ( - candidate.linkedPRNumber == null && - candidate.fallbackPRNumber != null && - candidate.fallbackPRSource != null - ) -} - -function hostedReviewOptionArgs( - candidate: PRBranchLookupCandidate -): [] | [GitHubPRBranchLookupOptions] { - const options: GitHubPRBranchLookupOptions = {} - if (candidate.localGitOptions?.wslDistro) { - options.localGitExecOptions = { wslDistro: candidate.localGitOptions.wslDistro } - } - if (shouldAcceptMergedFallbackPR(candidate)) { - options.acceptMergedFallbackPR = true - } - if (typeof candidate.currentHeadOid === 'string' && candidate.currentHeadOid.trim().length > 0) { - options.currentHeadOid = candidate.currentHeadOid.trim() - } - return Object.keys(options).length > 0 ? [options] : [] -} - -const MIN_BACKGROUND_REFRESH_AGE_MS = 60_000 -const MERGEABILITY_PENDING_REFRESH_MS = 10_000 -const MANUAL_MERGEABILITY_PENDING_REFRESH_MS = 2_500 -const BACKGROUND_BUDGET_WINDOW_MS = 5 * 60_000 -const MIN_BACKGROUND_SPACING_MS = 10_000 -const BACKGROUND_BUDGET_MAX = 20 -const POST_PUSH_DELAY_MS = 2_500 -const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000 -const ACTIVE_BURST_WINDOW_MS = 30_000 -const ACTIVE_BURST_MAX = 3 - -let sequence = 0 -let queueOrder = 0 -let draining = false -let drainTimer: ReturnType | null = null -const queue = new Map() -const backgroundStarts: number[] = [] -const activeStartsByScope = new Map() -const errorBackoff = new Map() -// Per-key manual-retry cooldown (secondary rate-limit Retry-After); refreshPRNow refuses manual retry until this time so a stale renderer can't bypass it. -const manualRetryGates = new Map() -let lastBackgroundStartAt = 0 - -/** - * Track (or clear) the manual-retry cooldown for a key from a broadcast outcome. - * Only a rate-limit outcome carrying `retryDisabledUntil` sets a gate; any other settled outcome clears it. - */ -function noteManualRetryGate(key: string, outcome: PRRefreshOutcome): void { - if (outcome.kind === 'upstream-error' && outcome.retryDisabledUntil !== undefined) { - manualRetryGates.set(key, outcome.retryDisabledUntil) - } else { - manualRetryGates.delete(key) - } -} - -/** Reset all per-key retry state (backoff + manual cooldown) when a key resets. */ -function resetKeyRetryState(key: string): void { - errorBackoff.delete(key) - manualRetryGates.delete(key) -} -const visibleByWindow = new Map }>() -let outcomeObserver: PRRefreshOutcomeObserver | null = null -const diagnosticsCounters = { - enqueued: 0, - coalesced: 0, - skipped: 0, - backgroundPauses: 0 -} +const retry = new PRRefreshRetryState() +const queue = new PRRefreshQueue((key) => retry.reset(key)) +const pacing = new PRRefreshPacing() +const visibility = new PRRefreshVisibility() +const events = new PRRefreshEventPublisher() +const drainer = new PRRefreshQueueDrainer(queue, pacing, visibility, retry, events) export function setPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void { - outcomeObserver = observer + events.setOutcomeObserver(observer) } function removeInvisibleVisibleRefreshes(): void { - for (const [key, entry] of queue) { - if (entry.reason === 'visible' && !isVisibleKey(key)) { - queue.delete(key) - resetKeyRetryState(key) - broadcast({ - aliases: Array.from(entry.aliases.values()), - reason: 'visible', - status: 'skipped', - skippedReason: 'fresh' - }) - } - } -} - -function recordPRRefreshQueueDiagnostic( - event: 'enqueued' | 'coalesced' | 'skipped' | 'background-pause', - reason: GitHubPRRefreshReason, - skippedReason?: GitHubPRRefreshSkippedReason -): void { - recordCoalescedCrashBreadcrumb({ - name: 'pr_refresh_queue', - coalesceKey: `pr-refresh-queue:${event}:${reason}:${skippedReason ?? ''}`, - minIntervalMs: DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS, - data: { - event, - reason, - ...(skippedReason ? { skippedReason } : {}), - enqueued: diagnosticsCounters.enqueued, - coalesced: diagnosticsCounters.coalesced, - skipped: diagnosticsCounters.skipped, - backgroundPauses: diagnosticsCounters.backgroundPauses - } - }) -} - -function clearActiveBurstWindow(windowId: number): void { - const windowPrefix = `${windowId}::` - for (const scope of Array.from(activeStartsByScope.keys())) { - if (scope.startsWith(windowPrefix)) { - activeStartsByScope.delete(scope) - } + const removed = queue.removeInvisibleVisibleEntries((key) => visibility.has(key)) + for (const entry of removed) { + events.broadcast({ + aliases: Array.from(entry.aliases.values()), + reason: 'visible', + status: 'skipped', + skippedReason: 'fresh' + }) } } export function clearVisiblePRRefreshWindow(windowId: number): void { - const hadVisibleRefreshes = visibleByWindow.delete(windowId) - clearActiveBurstWindow(windowId) + const hadVisibleRefreshes = visibility.clearWindow(windowId) + pacing.clearActiveBurstWindow(windowId) if (hadVisibleRefreshes) { - // Why: visible follow-ups are owned by the reporting renderer; if its WebContents is destroyed, no later visibility report arrives. removeInvisibleVisibleRefreshes() } } -/** - * Drop a removed worktree's aliases from every queue entry. - * - * Why: aliases are otherwise only pruned when a candidate is re-enqueued as invalid, so churning worktrees grow these maps unbounded (OOM creep). - */ export function pruneWorktreePRRefreshAliases(worktreeId: string): void { - for (const [key, entry] of queue) { - let removed = false - for (const [cacheKey, alias] of entry.aliases) { - if (alias.worktreeId === worktreeId) { - entry.aliases.delete(cacheKey) - removed = true - } - } - if (!removed) { - continue - } - // No aliases left means no worktree still cares about this refresh. - if (entry.aliases.size === 0) { - queue.delete(key) - resetKeyRetryState(key) - continue - } - // Keep the entry alive but replace its representative so it isn't a dangling reference to the removed worktree. - if (entry.candidate.worktreeId === worktreeId) { - const replacementAlias = entry.aliases.values().next().value - if (replacementAlias) { - entry.candidate = { - ...entry.candidate, - cacheKey: replacementAlias.cacheKey, - branch: replacementAlias.branch, - worktreeId: replacementAlias.worktreeId, - // Why: the probe now represents the replacement worktree, so use its head — else the removed worktree's link never clears. - currentHeadOid: replacementAlias.currentHeadOid ?? null - } - } - } - } -} - -function nextSequence(): number { - sequence += 1 - return sequence -} - -function nextQueueOrder(): number { - queueOrder += 1 - return queueOrder -} - -function broadcast(event: Omit, sequenceOverride?: number): void { - const payload = { ...event, sequence: sequenceOverride ?? nextSequence() } as GitHubPRRefreshEvent - sendToTrustedUIRenderer('gh:prRefreshEvent', payload) -} - -function refreshKey(candidate: GitHubPRRefreshCandidate): string { - const connectionScope = candidate.connectionId ?? 'local' - const runtimeScope = candidate.connectionId - ? 'remote' - : `runtime:${candidate.localGitOptions?.wslDistro ? `wsl:${candidate.localGitOptions.wslDistro}` : 'host'}` - if (typeof candidate.linkedPRNumber === 'number') { - return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::pr::${candidate.linkedPRNumber}` - } - return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::branch::${candidate.branch}` -} - -function isVisibleKey(key: string): boolean { - const liveWindowIds = new Set( - webContents - .getAllWebContents() - .filter((wc) => !wc.isDestroyed()) - .map((wc) => wc.id) - ) - for (const windowId of Array.from(visibleByWindow.keys())) { - if (!liveWindowIds.has(windowId)) { - visibleByWindow.delete(windowId) - } - } - for (const visible of visibleByWindow.values()) { - if (visible.keys.has(key)) { - return true - } - } - return false -} - -function isManual(reason: GitHubPRRefreshReason): boolean { - return reason === 'manual' -} - -function bypassesFreshnessDelay(reason: GitHubPRRefreshReason): boolean { - return reason === 'manual' || reason === 'active' || reason === 'post-push' -} - -function isBackground(reason: GitHubPRRefreshReason): boolean { - return reason !== 'manual' -} - -function isBudgetedBackground(reason: GitHubPRRefreshReason): boolean { - return reason === 'visible' || reason === 'swr' -} - -function isBudgetedQueueEntry(entry: QueueEntry): boolean { - return isBudgetedBackground(entry.reason) && entry.bypassBackgroundBudget !== true -} - -function validateCandidate( - candidate: GitHubPRRefreshCandidate -): GitHubPRRefreshSkippedReason | null { - if (candidate.repoKind !== 'git') { - return 'not-git' - } - if (candidate.isBare) { - return 'bare' - } - if (candidate.isArchived) { - return 'archived' - } - if (candidate.connectionId && candidate.connectionState === 'disconnected') { - return 'disconnected' - } - if (!candidate.branch && typeof candidate.linkedPRNumber !== 'number') { - return 'fresh' - } - return null -} - -function shouldSkipFresh( - candidate: GitHubPRRefreshCandidate, - reason: GitHubPRRefreshReason -): boolean { - if (bypassesFreshnessDelay(reason) || candidate.cachedFetchedAt == null) { - return false - } - return Date.now() - candidate.cachedFetchedAt < refreshIntervalForCandidate(candidate) -} - -function shouldBroadcastQueued(reason: GitHubPRRefreshReason, dueAt: number): boolean { - if (isBudgetedBackground(reason)) { - return false - } - const delay = dueAt - Date.now() - if (delay <= 0) { - return false - } - return delay <= 5_000 -} - -function freshRetryAt(candidate: GitHubPRRefreshCandidate): number | null { - return candidate.cachedFetchedAt == null - ? null - : candidate.cachedFetchedAt + refreshIntervalForCandidate(candidate) -} - -function aliasFromCandidate(candidate: GitHubPRRefreshCandidate): GitHubPRRefreshAlias { - return { - cacheKey: candidate.cacheKey, - repoId: candidate.repoId, - repoPath: candidate.repoPath, - branch: candidate.branch, - worktreeId: candidate.worktreeId, - connectionId: candidate.connectionId ?? null, - currentHeadOid: candidate.currentHeadOid ?? null, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: - candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, - fallbackPRSource: candidate.linkedPRNumber == null ? (candidate.fallbackPRSource ?? null) : null - } -} - -function visibleCandidateAfterOutcome( - candidate: GitHubPRRefreshCandidate, - outcome: PRRefreshOutcome -): GitHubPRRefreshCandidate { - if (outcome.kind === 'upstream-error') { - return candidate - } - return { - ...candidate, - cachedFetchedAt: outcome.fetchedAt, - cachedHasPR: outcome.kind === 'found', - cachedPRState: outcome.kind === 'found' ? outcome.pr.state : null, - cachedChecksStatus: outcome.kind === 'found' ? outcome.pr.checksStatus : null, - cachedMergeable: outcome.kind === 'found' ? outcome.pr.mergeable : null, - cachedMergeStateStatus: outcome.kind === 'found' ? (outcome.pr.mergeStateStatus ?? null) : null - } -} - -function setVisibleFollowUp(entry: QueueEntry): void { - const existing = queue.get(entry.key) - if (!existing) { - queue.set(entry.key, entry) - return - } - - for (const alias of entry.aliases.values()) { - existing.aliases.set(alias.cacheKey, alias) - } - - // Why: a user activation can arrive while a background refresh awaits gh; the follow-up must not overwrite that pending active/manual work. - if ( - bypassesFreshnessDelay(existing.reason) || - existing.priority > entry.priority || - existing.dueAt <= entry.dueAt - ) { - return - } - - queue.set(entry.key, { - ...entry, - aliases: existing.aliases - }) -} - -function removeQueuedAliasForInvalidCandidate(key: string, alias: GitHubPRRefreshAlias): void { - const existing = queue.get(key) - if (!existing) { - return - } - - existing.aliases.delete(alias.cacheKey) - const replacementAlias = existing.aliases.values().next().value - if (!replacementAlias) { - queue.delete(key) - resetKeyRetryState(key) - return - } - - if (existing.candidate.cacheKey === alias.cacheKey) { - existing.candidate = { - ...existing.candidate, - cacheKey: replacementAlias.cacheKey, - branch: replacementAlias.branch, - worktreeId: replacementAlias.worktreeId, - // Why: the probe now represents the replacement worktree, so use its head — else the survivor's link never clears. - currentHeadOid: replacementAlias.currentHeadOid ?? null, - isArchived: false, - isBare: false - } - } -} - -/** - * Advances the visible-key error backoff and returns the earliest retry time. - * Why: only visible keys auto-retry, so callers must gate on `isVisibleKey` first. - */ -function nextVisibleErrorRetryAt(key: string): number { - const failures = (errorBackoff.get(key)?.failures ?? 0) + 1 - const retryAt = Date.now() + lookupBackoffDelayMs(failures) - errorBackoff.set(key, { failures, retryAt }) - return retryAt -} - -/** - * Stamps the auto-retry time onto an error outcome before broadcast. - * Why: sets only `nextAutoRetryAt`, never `retryDisabledUntil` — that disables *manual* Retry and is reserved for a real rate-limit gate. - */ -function withErrorSchedule(outcome: PRRefreshOutcome, retryAt: number): PRRefreshOutcome { - if (outcome.kind !== 'upstream-error') { - return outcome - } - // Why: honor a real Retry-After cooldown the client stamped (`retryDisabledUntil`) so we don't auto-retry into an active secondary rate limit. - const cooldownUntil = outcome.retryDisabledUntil - return { - ...outcome, - nextAutoRetryAt: cooldownUntil !== undefined ? Math.max(retryAt, cooldownUntil) : retryAt - } -} - -function scheduleVisibleFollowUp( - key: string, - candidate: GitHubPRRefreshCandidate, - outcome: PRRefreshOutcome, - priority: number, - aliases: GitHubPRRefreshAlias[], - windowId?: number, - options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number } -): void { - if (!isVisibleKey(key)) { - // Why: manual/active refreshes can remove the queued visible retry after its owner window is gone, orphaning the backoff. - resetKeyRetryState(key) - return - } - if (outcome.kind === 'upstream-error') { - // Why: reuse the retry time already computed for the broadcast so the same failure isn't counted twice against the backoff. - const retryAt = options?.plannedRetryAt ?? nextVisibleErrorRetryAt(key) - setVisibleFollowUp({ - key, - candidate, - aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), - reason: 'visible', - priority, - dueAt: retryAt, - queuedAt: nextQueueOrder(), - windowId - }) - // Why: this is a delayed retry, not active work; a spinner would make visible worktrees look stuck until backoff expires. - scheduleDrain(retryAt - Date.now()) - return - } - resetKeyRetryState(key) - const followUpCandidate = visibleCandidateAfterOutcome(candidate, outcome) - const regularDueAt = freshRetryAt(followUpCandidate) ?? Date.now() - const pendingMergeabilityDueAt = - options?.pendingMergeabilityDelayMs !== undefined && isMergeabilityPendingOutcome(outcome) - ? outcome.fetchedAt + options.pendingMergeabilityDelayMs - : null - const dueAt = - pendingMergeabilityDueAt === null - ? regularDueAt - : Math.min(regularDueAt, pendingMergeabilityDueAt) - // Why: a coalesced linked-PR refresh may represent several branches; preserve every alias so all cache entries keep getting updates. - setVisibleFollowUp({ - key, - candidate: followUpCandidate, - aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), - reason: 'visible', - priority, - dueAt, - queuedAt: nextQueueOrder(), - // Why: this manual one-shot fixes GitHub's transient UNKNOWN state; visible spacing would delay it past the prompt retry window. - bypassBackgroundBudget: pendingMergeabilityDueAt !== null, - windowId - }) - scheduleDrain(Math.max(0, dueAt - Date.now())) -} - -function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): number { - if (candidate.cachedPRState === 'closed' || candidate.cachedPRState === 'merged') { - return 30 * 60_000 - } - if (candidate.cachedHasPR === false) { - return NO_REVIEW_REFRESH_INTERVAL_MS - } - if ( - candidate.cachedHasPR === true && - candidate.cachedPRState === 'open' && - candidate.cachedMergeable === 'UNKNOWN' && - !hasResolvedMergeStateStatus(candidate.cachedMergeStateStatus) - ) { - // Why: GitHub returns transient UNKNOWN mergeability while computing the test merge; visible merge buttons need a prompt follow-up. - return MERGEABILITY_PENDING_REFRESH_MS - } - if (candidate.cachedChecksStatus === 'success') { - return 10 * 60_000 - } - if (candidate.cachedChecksStatus === 'failure') { - return 3 * 60_000 - } - if (candidate.cachedChecksStatus === 'pending') { - return 90_000 - } - return MIN_BACKGROUND_REFRESH_AGE_MS -} - -function hasResolvedMergeStateStatus(status: string | null | undefined): boolean { - return status === 'CLEAN' || status === 'BEHIND' || status === 'BLOCKED' -} - -function isMergeabilityPendingOutcome(outcome: PRRefreshOutcome): boolean { - return ( - outcome.kind === 'found' && - outcome.pr.state === 'open' && - outcome.pr.mergeable === 'UNKNOWN' && - !hasResolvedMergeStateStatus(outcome.pr.mergeStateStatus) - ) -} - -function backgroundRefreshBuckets(): ('core' | 'graphql')[] { - // Why: branch refreshes prefer REST but can fall back to `gh pr list`, so guard both buckets until the client exposes a per-lookup cost plan. - return ['core', 'graphql'] -} - -function noteBackgroundStart(): void { - const now = Date.now() - lastBackgroundStartAt = now - backgroundStarts.push(now) - while (backgroundStarts.length > 0 && now - backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS) { - backgroundStarts.shift() - } -} - -function nextBudgetDelay(): number { - const now = Date.now() - while (backgroundStarts.length > 0 && now - backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS) { - backgroundStarts.shift() - } - const spacingDelay = - lastBackgroundStartAt > 0 - ? Math.max(0, MIN_BACKGROUND_SPACING_MS - (now - lastBackgroundStartAt)) - : 0 - const windowDelay = - backgroundStarts.length < BACKGROUND_BUDGET_MAX - ? 0 - : Math.max(1_000, BACKGROUND_BUDGET_WINDOW_MS - (now - backgroundStarts[0])) - return Math.max(spacingDelay, windowDelay) -} - -function activeBurstScope(entry: QueueEntry): string { - const runtimeScope = entry.candidate.connectionId - ? `ssh:${entry.candidate.connectionId}` - : `local:${entry.candidate.localGitOptions?.wslDistro ?? 'host'}` - return `${entry.windowId ?? 'global'}::${runtimeScope}` -} - -function pruneActiveStarts(scope: string, now: number): number[] { - const activeStarts = activeStartsByScope.get(scope) ?? [] - while (activeStarts.length > 0 && now - activeStarts[0] >= ACTIVE_BURST_WINDOW_MS) { - activeStarts.shift() - } - if (activeStarts.length === 0) { - activeStartsByScope.delete(scope) - } else { - activeStartsByScope.set(scope, activeStarts) - } - return activeStarts -} - -function nextActiveBurstDelay(entry: QueueEntry): number { - const now = Date.now() - const activeStarts = pruneActiveStarts(activeBurstScope(entry), now) - if (activeStarts.length < ACTIVE_BURST_MAX) { - return 0 - } - return Math.max(1, ACTIVE_BURST_WINDOW_MS - (now - activeStarts[0])) -} - -function noteActiveStart(entry: QueueEntry): void { - const now = Date.now() - const scope = activeBurstScope(entry) - const activeStarts = pruneActiveStarts(scope, now) - activeStarts.push(now) - activeStartsByScope.set(scope, activeStarts) -} - -function activeOrder(a: QueueEntry, b: QueueEntry): number { - if (a.reason !== 'active' || b.reason !== 'active') { - return 0 - } - if (activeBurstScope(a) !== activeBurstScope(b)) { - return 0 - } - // Why: refresh the worktree the user lands on before stale transient selections in the same window/runtime scope. - return b.queuedAt - a.queuedAt -} - -function entryDelay(entry: QueueEntry): number { - const activeBurstDelay = entry.reason === 'active' ? nextActiveBurstDelay(entry) : 0 - if (activeBurstDelay > 0) { - return activeBurstDelay - } - return isBudgetedQueueEntry(entry) ? nextBudgetDelay() : 0 -} - -function isActiveBurstDelayed(entry: QueueEntry): boolean { - return entry.reason === 'active' && nextActiveBurstDelay(entry) > 0 -} - -function nextQueuedWakeDelay(excludedKey: string): number | null { - const now = Date.now() - let nextDelay = Number.POSITIVE_INFINITY - for (const entry of queue.values()) { - if (entry.key === excludedKey) { - continue - } - const delay = entry.dueAt > now ? entry.dueAt - now : entryDelay(entry) - nextDelay = Math.min(nextDelay, delay) - } - return Number.isFinite(nextDelay) ? Math.max(0, nextDelay) : null -} - -function scheduleDrain(delay = 0): void { - if (drainTimer) { - clearTimeout(drainTimer) - } - drainTimer = setTimeout(() => { - drainTimer = null - void drainQueue() - }, delay) -} - -function queuedEntriesByPriority(): QueueEntry[] { - const now = Date.now() - return Array.from(queue.values()).sort((a, b) => { - const aReady = a.dueAt <= now - const bReady = b.dueAt <= now - if (aReady && bReady) { - return b.priority - a.priority || activeOrder(a, b) || a.dueAt - b.dueAt - } - if (aReady !== bReady) { - return aReady ? -1 : 1 - } - return a.dueAt - b.dueAt || b.priority - a.priority - }) -} - -async function drainQueue(): Promise { - if (draining) { - return - } - draining = true - try { - while (queue.size > 0) { - let next = queuedEntriesByPriority()[0] - const waitMs = next.dueAt - Date.now() - if (waitMs > 0) { - scheduleDrain(waitMs) - return - } - - let delay = entryDelay(next) - if (delay > 0) { - const runnable = queuedEntriesByPriority().find( - (entry) => entry.dueAt <= Date.now() && entryDelay(entry) === 0 - ) - if (runnable && runnable.key !== next.key) { - next = runnable - delay = 0 - } else { - if (isActiveBurstDelayed(next) && !next.activeDelayNotified) { - next.activeDelayNotified = true - broadcast({ - aliases: Array.from(next.aliases.values()), - reason: next.reason, - status: 'queued' - }) - } - if (isBudgetedQueueEntry(next) && nextBudgetDelay() > 0) { - diagnosticsCounters.backgroundPauses += 1 - recordPRRefreshQueueDiagnostic('background-pause', next.reason) - } - scheduleDrain(Math.min(delay, nextQueuedWakeDelay(next.key) ?? delay)) - return - } - } - - queue.delete(next.key) - const aliases = Array.from(next.aliases.values()) - const skippedReason = validateCandidate(next.candidate) - if (skippedReason) { - diagnosticsCounters.skipped += 1 - recordPRRefreshQueueDiagnostic('skipped', next.reason, skippedReason) - broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason }) - continue - } - if (next.reason === 'visible' && !isVisibleKey(next.key)) { - resetKeyRetryState(next.key) - broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason: 'fresh' }) - continue - } - const requestSequence = nextSequence() - const requestStartedAt = Date.now() - broadcast( - { aliases, reason: next.reason, status: 'in-flight', requestStartedAt }, - requestSequence - ) - - if (isBackground(next.reason)) { - const executionOptions = ghRepoExecOptions( - githubRepoContext( - next.candidate.repoPath, - next.candidate.connectionId, - next.candidate.localGitOptions - ) - ) - const repository = await getOriginGitHubApiRepository( - next.candidate.repoPath, - next.candidate.connectionId, - executionOptions - ) - // Why: only native github.com uses the singleton snapshot; scoped breakers protect GHES and WSL. - if (spendsSharedGitHubComQuota(repository, executionOptions)) { - // Why: the probe only warms the cache, so failures must fail open (#7553). - await getRateLimit() - } - const buckets = backgroundRefreshBuckets() - const blockedGuard = buckets - .map((bucket) => repositoryRateLimitGuard(repository, bucket, executionOptions)) - .find((guard) => guard.blocked) - if (blockedGuard?.blocked) { - const retryAt = blockedGuard.resetAt * 1000 - queue.set(next.key, { ...next, dueAt: retryAt }) - broadcast({ - aliases, - reason: next.reason, - status: 'paused', - pausedUntil: retryAt, - skippedReason: 'rate-limit' - }) - scheduleDrain(Math.max(1_000, retryAt - Date.now())) - continue - } - if (isBudgetedQueueEntry(next)) { - noteBackgroundStart() - } - if (next.reason === 'active') { - // Why: tab/worktree churn can enqueue many distinct active refreshes that each probe local Git. - noteActiveStart(next) - } - // Why (#11532): the lookup itself now debits the snapshot, so every - // caller is accounted for; debiting here too would double-count. - } - - const outcome = await getPRForBranchOutcome( - next.candidate.repoPath, - next.candidate.branch, - next.candidate.linkedPRNumber ?? null, - next.candidate.connectionId ?? null, - next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null, - ...hostedReviewOptionArgs(next.candidate) - ) - // Why: compute the retry schedule before broadcasting so the outcome and its follow-up share one timing; only visible keys auto-retry. - let plannedRetryAt: number | undefined - let broadcastOutcome = outcome - if (outcome.kind === 'upstream-error' && isVisibleKey(next.key)) { - plannedRetryAt = nextVisibleErrorRetryAt(next.key) - broadcastOutcome = withErrorSchedule(outcome, plannedRetryAt) - } - outcomeObserver?.(next.candidate, outcome) - noteManualRetryGate(next.key, broadcastOutcome) - broadcast( - { aliases, reason: next.reason, outcome: broadcastOutcome, requestStartedAt }, - requestSequence - ) - scheduleVisibleFollowUp( - next.key, - next.candidate, - outcome, - next.priority, - aliases, - next.windowId, - { - plannedRetryAt - } - ) - } - } finally { - draining = false - } + queue.pruneWorktreeAliases(worktreeId) } export function enqueuePRRefresh( @@ -824,66 +68,18 @@ export function enqueuePRRefresh( const key = refreshKey(candidate) const skippedReason = validateCandidate(candidate) if (skippedReason) { - removeQueuedAliasForInvalidCandidate(key, alias) - diagnosticsCounters.skipped += 1 - recordPRRefreshQueueDiagnostic('skipped', reason, skippedReason) - broadcast({ - aliases: [alias], - reason, - status: 'skipped', - skippedReason - }) + queue.removeInvalidAlias(key, alias) + events.record('skipped', reason, skippedReason) + events.broadcast({ aliases: [alias], reason, status: 'skipped', skippedReason }) return } - const existing = queue.get(key) - const freshDueAt = shouldSkipFresh(candidate, reason) ? freshRetryAt(candidate) : null - const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0) - if (existing) { - existing.aliases.set(alias.cacheKey, alias) - diagnosticsCounters.coalesced += 1 - recordPRRefreshQueueDiagnostic('coalesced', reason) - const shouldPromoteExisting = - priority > existing.priority || - isManual(reason) || - (reason === 'active' && existing.reason === 'active') || - (priority >= existing.priority && dueAt < existing.dueAt && bypassesFreshnessDelay(reason)) - if (shouldPromoteExisting) { - existing.priority = priority - existing.reason = reason - existing.dueAt = Math.min(existing.dueAt, dueAt) - existing.queuedAt = nextQueueOrder() - existing.activeDelayNotified = false - existing.candidate = candidate - existing.windowId = windowId ?? existing.windowId - } else if (existing.candidate.worktreeId === candidate.worktreeId) { - // Why: the representative drives the probe head; refresh probe inputs when its worktree moved head/branch, else a merged linked PR lingers after a switch. - existing.candidate = { - ...existing.candidate, - cacheKey: candidate.cacheKey, - branch: candidate.branch, - currentHeadOid: candidate.currentHeadOid ?? null - } - } - } else { - diagnosticsCounters.enqueued += 1 - recordPRRefreshQueueDiagnostic('enqueued', reason) - queue.set(key, { - key, - candidate, - aliases: new Map([[alias.cacheKey, alias]]), - reason, - priority, - dueAt, - queuedAt: nextQueueOrder(), - windowId - }) + const enqueued = queue.enqueue(candidate, reason, priority, windowId) + events.record(enqueued.coalesced ? 'coalesced' : 'enqueued', reason) + if (shouldBroadcastQueued(reason, enqueued.dueAt)) { + events.broadcast({ aliases: [enqueued.alias], reason, status: 'queued' }) } - // Why: visible/SWR are background maintenance behind the budget queue; only user/action-driven queueing should surface in UI. - if (shouldBroadcastQueued(reason, dueAt)) { - broadcast({ aliases: [alias], reason, status: 'queued' }) - } - scheduleDrain() + drainer.schedule() } export function reportVisiblePRRefreshCandidates( @@ -891,11 +87,9 @@ export function reportVisiblePRRefreshCandidates( generation: number, windowId: number ): void { - const existingVisible = visibleByWindow.get(windowId) - if (existingVisible && generation < existingVisible.generation) { + if (!visibility.report(candidates, generation, windowId)) { return } - visibleByWindow.set(windowId, { generation, keys: new Set(candidates.map(refreshKey)) }) removeInvisibleVisibleRefreshes() for (const candidate of candidates) { enqueuePRRefresh(candidate, 'visible', 40, windowId) @@ -903,11 +97,11 @@ export function reportVisiblePRRefreshCandidates( } export function _getVisiblePRRefreshWindowCountForTests(): number { - return visibleByWindow.size + return visibility.windowCount } export function _getPRRefreshErrorBackoffCountForTests(): number { - return errorBackoff.size + return retry.errorBackoffCount } export function _getPRRefreshQueueSizeForTests(): number { @@ -915,7 +109,7 @@ export function _getPRRefreshQueueSizeForTests(): number { } export function _getPRRefreshAliasCountForTests(key: string): number { - return queue.get(key)?.aliases.size ?? 0 + return queue.aliasCount(key) } export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise { @@ -927,68 +121,54 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise const aliases = Array.from(aliasMap.values()) const skippedReason = validateCandidate(candidate) if (skippedReason) { - removeQueuedAliasForInvalidCandidate(key, alias) + queue.removeInvalidAlias(key, alias) const outcome: PRRefreshOutcome = { kind: 'upstream-error', errorType: 'unknown', message: `Cannot refresh PR for this worktree: ${skippedReason}`, fetchedAt: Date.now() } - broadcast({ aliases: [alias], reason: 'manual', status: 'skipped', skippedReason }) + events.broadcast({ aliases: [alias], reason: 'manual', status: 'skipped', skippedReason }) return outcome } - // Why: enforce the rate-limit gate so a stale renderer can't bypass it; refuse without spending quota until the later of the two cooldowns. - const manualExecutionOptions = ghRepoExecOptions( - githubRepoContext(candidate.repoPath, candidate.connectionId, candidate.localGitOptions) - ) - const manualRepository = await getOriginGitHubApiRepository( - candidate.repoPath, - candidate.connectionId, - manualExecutionOptions - ) - const manualBlockedGuard = backgroundRefreshBuckets() - .map((bucket) => repositoryRateLimitGuard(manualRepository, bucket, manualExecutionOptions)) - .find((guard) => guard.blocked) - const secondaryGateUntil = manualRetryGates.get(key) - const gateUntil = Math.max( - manualBlockedGuard?.blocked ? manualBlockedGuard.resetAt * 1000 : 0, - secondaryGateUntil !== undefined ? secondaryGateUntil : 0 - ) + const primaryGateUntil = await prRefreshRateLimitPausedUntil(candidate, false) + const gateUntil = Math.max(primaryGateUntil ?? 0, retry.manualGateUntil(key)) if (gateUntil > Date.now()) { - const retryAt = gateUntil - // Why: paused maps `pausedUntil` into the renderer's auto-retry, so requeue at reset (finding 12) — don't advertise an unscheduled retry. queue.set(key, { key, candidate, aliases: aliasMap, reason: 'manual', priority: 40, - dueAt: retryAt, - queuedAt: nextQueueOrder() + dueAt: gateUntil, + queuedAt: queue.nextOrder() }) - broadcast({ + events.broadcast({ aliases, reason: 'manual', status: 'paused', - pausedUntil: retryAt, + pausedUntil: gateUntil, skippedReason: 'rate-limit' }) - scheduleDrain(Math.max(1_000, retryAt - Date.now())) + drainer.schedule(Math.max(1_000, gateUntil - Date.now())) return { kind: 'upstream-error', errorType: 'rate_limited', message: 'GitHub is temporarily limiting requests. Try again after the limit resets.', fetchedAt: Date.now(), - nextAutoRetryAt: retryAt, - retryDisabledUntil: retryAt + nextAutoRetryAt: gateUntil, + retryDisabledUntil: gateUntil } } queue.delete(key) - const requestSequence = nextSequence() + const requestSequence = events.nextSequence() const requestStartedAt = Date.now() - broadcast({ aliases, reason: 'manual', status: 'in-flight', requestStartedAt }, requestSequence) + events.broadcast( + { aliases, reason: 'manual', status: 'in-flight', requestStartedAt }, + requestSequence + ) const outcome = await getPRForBranchOutcome( candidate.repoPath, candidate.branch, @@ -999,19 +179,18 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise ) let plannedRetryAt: number | undefined let broadcastOutcome = outcome - if (outcome.kind === 'upstream-error' && isVisibleKey(key)) { - plannedRetryAt = nextVisibleErrorRetryAt(key) - broadcastOutcome = withErrorSchedule(outcome, plannedRetryAt) + if (outcome.kind === 'upstream-error' && visibility.has(key)) { + plannedRetryAt = retry.nextVisibleErrorRetryAt(key) + broadcastOutcome = retry.withErrorSchedule(outcome, plannedRetryAt) } - outcomeObserver?.(candidate, outcome) - noteManualRetryGate(key, broadcastOutcome) - broadcast( + events.observe(candidate, outcome) + retry.noteManualGate(key, broadcastOutcome) + events.broadcast( { aliases, reason: 'manual', outcome: broadcastOutcome, requestStartedAt }, requestSequence ) - scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, { + drainer.scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, { plannedRetryAt, - // Why: GitHub reports UNKNOWN right after `gh pr reopen`; one prompt visible retry replaces the transient label. pendingMergeabilityDelayMs: MANUAL_MERGEABILITY_PENDING_REFRESH_MS }) return broadcastOutcome diff --git a/src/main/github/pr-refresh-event-publisher.ts b/src/main/github/pr-refresh-event-publisher.ts new file mode 100644 index 00000000000..b028729f1f5 --- /dev/null +++ b/src/main/github/pr-refresh-event-publisher.ts @@ -0,0 +1,69 @@ +import type { + GitHubPRRefreshCandidate, + GitHubPRRefreshEvent, + GitHubPRRefreshReason, + GitHubPRRefreshSkippedReason, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { sendToTrustedUIRenderer } from '../ipc/ui' + +export type PRRefreshOutcomeObserver = ( + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome +) => void + +const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000 + +export class PRRefreshEventPublisher { + private sequence = 0 + private observer: PRRefreshOutcomeObserver | null = null + private readonly counters = { enqueued: 0, coalesced: 0, skipped: 0, backgroundPauses: 0 } + + setOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void { + this.observer = observer + } + + observe(candidate: GitHubPRRefreshCandidate, outcome: PRRefreshOutcome): void { + this.observer?.(candidate, outcome) + } + + nextSequence(): number { + this.sequence += 1 + return this.sequence + } + + broadcast(event: Omit, sequenceOverride?: number): void { + const payload = { + ...event, + sequence: sequenceOverride ?? this.nextSequence() + } as GitHubPRRefreshEvent + sendToTrustedUIRenderer('gh:prRefreshEvent', payload) + } + + record( + event: 'enqueued' | 'coalesced' | 'skipped' | 'background-pause', + reason: GitHubPRRefreshReason, + skippedReason?: GitHubPRRefreshSkippedReason + ): void { + if (event === 'background-pause') { + this.counters.backgroundPauses += 1 + } else { + this.counters[event] += 1 + } + recordCoalescedCrashBreadcrumb({ + name: 'pr_refresh_queue', + coalesceKey: `pr-refresh-queue:${event}:${reason}:${skippedReason ?? ''}`, + minIntervalMs: DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS, + data: { + event, + reason, + ...(skippedReason ? { skippedReason } : {}), + enqueued: this.counters.enqueued, + coalesced: this.counters.coalesced, + skipped: this.counters.skipped, + backgroundPauses: this.counters.backgroundPauses + } + }) + } +} diff --git a/src/main/github/pr-refresh-pacing.ts b/src/main/github/pr-refresh-pacing.ts new file mode 100644 index 00000000000..7a88bd74a0a --- /dev/null +++ b/src/main/github/pr-refresh-pacing.ts @@ -0,0 +1,114 @@ +import type { PRRefreshQueueEntry } from './pr-refresh-queue' + +const BACKGROUND_BUDGET_WINDOW_MS = 5 * 60_000 +const MIN_BACKGROUND_SPACING_MS = 10_000 +const BACKGROUND_BUDGET_MAX = 20 +const ACTIVE_BURST_WINDOW_MS = 30_000 +const ACTIVE_BURST_MAX = 3 + +export class PRRefreshPacing { + private readonly backgroundStarts: number[] = [] + private readonly activeStartsByScope = new Map() + private lastBackgroundStartAt = 0 + + clearActiveBurstWindow(windowId: number): void { + const windowPrefix = `${windowId}::` + for (const scope of Array.from(this.activeStartsByScope.keys())) { + if (scope.startsWith(windowPrefix)) { + this.activeStartsByScope.delete(scope) + } + } + } + + noteBackgroundStart(): void { + const now = Date.now() + this.lastBackgroundStartAt = now + this.backgroundStarts.push(now) + this.pruneBackgroundStarts(now) + } + + nextBudgetDelay(): number { + const now = Date.now() + this.pruneBackgroundStarts(now) + const spacingDelay = + this.lastBackgroundStartAt > 0 + ? Math.max(0, MIN_BACKGROUND_SPACING_MS - (now - this.lastBackgroundStartAt)) + : 0 + const windowDelay = + this.backgroundStarts.length < BACKGROUND_BUDGET_MAX + ? 0 + : Math.max(1_000, BACKGROUND_BUDGET_WINDOW_MS - (now - this.backgroundStarts[0])) + return Math.max(spacingDelay, windowDelay) + } + + activeOrder(a: PRRefreshQueueEntry, b: PRRefreshQueueEntry): number { + if (a.reason !== 'active' || b.reason !== 'active') { + return 0 + } + if (this.activeBurstScope(a) !== this.activeBurstScope(b)) { + return 0 + } + return b.queuedAt - a.queuedAt + } + + entryDelay(entry: PRRefreshQueueEntry): number { + const activeDelay = entry.reason === 'active' ? this.nextActiveBurstDelay(entry) : 0 + if (activeDelay > 0) { + return activeDelay + } + return entry.bypassBackgroundBudget !== true && + (entry.reason === 'visible' || entry.reason === 'swr') + ? this.nextBudgetDelay() + : 0 + } + + isActiveBurstDelayed(entry: PRRefreshQueueEntry): boolean { + return entry.reason === 'active' && this.nextActiveBurstDelay(entry) > 0 + } + + noteActiveStart(entry: PRRefreshQueueEntry): void { + const now = Date.now() + const scope = this.activeBurstScope(entry) + const activeStarts = this.pruneActiveStarts(scope, now) + activeStarts.push(now) + this.activeStartsByScope.set(scope, activeStarts) + } + + private pruneBackgroundStarts(now: number): void { + while ( + this.backgroundStarts.length > 0 && + now - this.backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS + ) { + this.backgroundStarts.shift() + } + } + + private activeBurstScope(entry: PRRefreshQueueEntry): string { + const runtimeScope = entry.candidate.connectionId + ? `ssh:${entry.candidate.connectionId}` + : `local:${entry.candidate.localGitOptions?.wslDistro ?? 'host'}` + return `${entry.windowId ?? 'global'}::${runtimeScope}` + } + + private pruneActiveStarts(scope: string, now: number): number[] { + const activeStarts = this.activeStartsByScope.get(scope) ?? [] + while (activeStarts.length > 0 && now - activeStarts[0] >= ACTIVE_BURST_WINDOW_MS) { + activeStarts.shift() + } + if (activeStarts.length === 0) { + this.activeStartsByScope.delete(scope) + } else { + this.activeStartsByScope.set(scope, activeStarts) + } + return activeStarts + } + + private nextActiveBurstDelay(entry: PRRefreshQueueEntry): number { + const now = Date.now() + const activeStarts = this.pruneActiveStarts(this.activeBurstScope(entry), now) + if (activeStarts.length < ACTIVE_BURST_MAX) { + return 0 + } + return Math.max(1, ACTIVE_BURST_WINDOW_MS - (now - activeStarts[0])) + } +} diff --git a/src/main/github/pr-refresh-queue-drainer.ts b/src/main/github/pr-refresh-queue-drainer.ts new file mode 100644 index 00000000000..d1e37492e32 --- /dev/null +++ b/src/main/github/pr-refresh-queue-drainer.ts @@ -0,0 +1,242 @@ +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import { getPRForBranchOutcome } from './client' +import { + freshRetryAt, + hostedReviewOptionArgs, + isBackground, + isMergeabilityPendingOutcome, + validateCandidate, + visibleCandidateAfterOutcome +} from './pr-refresh-candidate-policy' +import type { PRRefreshEventPublisher } from './pr-refresh-event-publisher' +import type { PRRefreshPacing } from './pr-refresh-pacing' +import type { PRRefreshQueue, PRRefreshQueueEntry } from './pr-refresh-queue' +import { prRefreshRateLimitPausedUntil } from './pr-refresh-rate-limit-gate' +import type { PRRefreshRetryState } from './pr-refresh-retry-state' +import type { PRRefreshVisibility } from './pr-refresh-visibility' + +export class PRRefreshQueueDrainer { + private draining = false + private timer: ReturnType | null = null + + constructor( + private readonly queue: PRRefreshQueue, + private readonly pacing: PRRefreshPacing, + private readonly visibility: PRRefreshVisibility, + private readonly retry: PRRefreshRetryState, + private readonly events: PRRefreshEventPublisher + ) {} + + schedule(delay = 0): void { + if (this.timer) { + clearTimeout(this.timer) + } + this.timer = setTimeout(() => { + this.timer = null + void this.drain() + }, delay) + } + + scheduleVisibleFollowUp( + key: string, + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome, + priority: number, + aliases: GitHubPRRefreshAlias[], + windowId?: number, + options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number } + ): void { + if (!this.visibility.has(key)) { + this.retry.reset(key) + return + } + if (outcome.kind === 'upstream-error') { + const retryAt = options?.plannedRetryAt ?? this.retry.nextVisibleErrorRetryAt(key) + this.queue.setVisibleFollowUp({ + key, + candidate, + aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), + reason: 'visible', + priority, + dueAt: retryAt, + queuedAt: this.queue.nextOrder(), + windowId + }) + this.schedule(retryAt - Date.now()) + return + } + this.retry.reset(key) + const followUpCandidate = visibleCandidateAfterOutcome(candidate, outcome) + const regularDueAt = freshRetryAt(followUpCandidate) ?? Date.now() + const pendingDueAt = + options?.pendingMergeabilityDelayMs !== undefined && isMergeabilityPendingOutcome(outcome) + ? outcome.fetchedAt + options.pendingMergeabilityDelayMs + : null + const dueAt = pendingDueAt === null ? regularDueAt : Math.min(regularDueAt, pendingDueAt) + this.queue.setVisibleFollowUp({ + key, + candidate: followUpCandidate, + aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), + reason: 'visible', + priority, + dueAt, + queuedAt: this.queue.nextOrder(), + bypassBackgroundBudget: pendingDueAt !== null, + windowId + }) + this.schedule(Math.max(0, dueAt - Date.now())) + } + + private ordered(): PRRefreshQueueEntry[] { + return this.queue.ordered((a, b) => this.pacing.activeOrder(a, b)) + } + + private nextQueuedWakeDelay(excludedKey: string): number | null { + const now = Date.now() + let nextDelay = Number.POSITIVE_INFINITY + for (const entry of this.queue.values()) { + if (entry.key === excludedKey) { + continue + } + const delay = entry.dueAt > now ? entry.dueAt - now : this.pacing.entryDelay(entry) + nextDelay = Math.min(nextDelay, delay) + } + return Number.isFinite(nextDelay) ? Math.max(0, nextDelay) : null + } + + private async drain(): Promise { + if (this.draining) { + return + } + this.draining = true + try { + while (this.queue.size > 0) { + let next = this.ordered()[0] + const waitMs = next.dueAt - Date.now() + if (waitMs > 0) { + this.schedule(waitMs) + return + } + + let delay = this.pacing.entryDelay(next) + if (delay > 0) { + const runnable = this.ordered().find( + (entry) => entry.dueAt <= Date.now() && this.pacing.entryDelay(entry) === 0 + ) + if (runnable && runnable.key !== next.key) { + next = runnable + delay = 0 + } else { + this.notePacingDelay(next) + this.schedule(Math.min(delay, this.nextQueuedWakeDelay(next.key) ?? delay)) + return + } + } + + this.queue.delete(next.key) + const aliases = Array.from(next.aliases.values()) + const skippedReason = validateCandidate(next.candidate) + if (skippedReason) { + this.events.record('skipped', next.reason, skippedReason) + this.events.broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason }) + continue + } + if (next.reason === 'visible' && !this.visibility.has(next.key)) { + this.retry.reset(next.key) + this.events.broadcast({ + aliases, + reason: next.reason, + status: 'skipped', + skippedReason: 'fresh' + }) + continue + } + const requestSequence = this.events.nextSequence() + const requestStartedAt = Date.now() + this.events.broadcast( + { aliases, reason: next.reason, status: 'in-flight', requestStartedAt }, + requestSequence + ) + + if (isBackground(next.reason)) { + const pausedUntil = await prRefreshRateLimitPausedUntil(next.candidate, true) + if (pausedUntil !== null) { + this.queue.set(next.key, { ...next, dueAt: pausedUntil }) + this.events.broadcast({ + aliases, + reason: next.reason, + status: 'paused', + pausedUntil, + skippedReason: 'rate-limit' + }) + this.schedule(Math.max(1_000, pausedUntil - Date.now())) + continue + } + if ( + next.bypassBackgroundBudget !== true && + (next.reason === 'visible' || next.reason === 'swr') + ) { + this.pacing.noteBackgroundStart() + } + if (next.reason === 'active') { + this.pacing.noteActiveStart(next) + } + } + + const outcome = await getPRForBranchOutcome( + next.candidate.repoPath, + next.candidate.branch, + next.candidate.linkedPRNumber ?? null, + next.candidate.connectionId ?? null, + next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null, + ...hostedReviewOptionArgs(next.candidate) + ) + let plannedRetryAt: number | undefined + let broadcastOutcome = outcome + if (outcome.kind === 'upstream-error' && this.visibility.has(next.key)) { + plannedRetryAt = this.retry.nextVisibleErrorRetryAt(next.key) + broadcastOutcome = this.retry.withErrorSchedule(outcome, plannedRetryAt) + } + this.events.observe(next.candidate, outcome) + this.retry.noteManualGate(next.key, broadcastOutcome) + this.events.broadcast( + { aliases, reason: next.reason, outcome: broadcastOutcome, requestStartedAt }, + requestSequence + ) + this.scheduleVisibleFollowUp( + next.key, + next.candidate, + outcome, + next.priority, + aliases, + next.windowId, + { plannedRetryAt } + ) + } + } finally { + this.draining = false + } + } + + private notePacingDelay(entry: PRRefreshQueueEntry): void { + if (this.pacing.isActiveBurstDelayed(entry) && !entry.activeDelayNotified) { + entry.activeDelayNotified = true + this.events.broadcast({ + aliases: Array.from(entry.aliases.values()), + reason: entry.reason, + status: 'queued' + }) + } + if ( + entry.bypassBackgroundBudget !== true && + (entry.reason === 'visible' || entry.reason === 'swr') && + this.pacing.nextBudgetDelay() > 0 + ) { + this.events.record('background-pause', entry.reason) + } + } +} diff --git a/src/main/github/pr-refresh-queue.ts b/src/main/github/pr-refresh-queue.ts new file mode 100644 index 00000000000..2cc185535fa --- /dev/null +++ b/src/main/github/pr-refresh-queue.ts @@ -0,0 +1,225 @@ +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + GitHubPRRefreshReason +} from '../../shared/github/pull-request-refresh-types' +import { + aliasFromCandidate, + bypassesFreshnessDelay, + freshRetryAt, + POST_PUSH_DELAY_MS, + refreshKey, + shouldSkipFresh +} from './pr-refresh-candidate-policy' + +export type PRRefreshQueueEntry = { + key: string + candidate: GitHubPRRefreshCandidate + aliases: Map + reason: GitHubPRRefreshReason + priority: number + dueAt: number + queuedAt: number + bypassBackgroundBudget?: boolean + activeDelayNotified?: boolean + windowId?: number +} + +export type PRRefreshEnqueue = { + alias: GitHubPRRefreshAlias + key: string + dueAt: number + coalesced: boolean +} + +export class PRRefreshQueue { + private readonly entries = new Map() + private order = 0 + + constructor(private readonly resetRetryState: (key: string) => void) {} + + get size(): number { + return this.entries.size + } + + get(key: string): PRRefreshQueueEntry | undefined { + return this.entries.get(key) + } + + set(key: string, entry: PRRefreshQueueEntry): void { + this.entries.set(key, entry) + } + + delete(key: string): void { + this.entries.delete(key) + } + + values(): IterableIterator { + return this.entries.values() + } + + nextOrder(): number { + this.order += 1 + return this.order + } + + aliasCount(key: string): number { + return this.entries.get(key)?.aliases.size ?? 0 + } + + enqueue( + candidate: GitHubPRRefreshCandidate, + reason: GitHubPRRefreshReason, + priority: number, + windowId?: number + ): PRRefreshEnqueue { + const alias = aliasFromCandidate(candidate) + const key = refreshKey(candidate) + const existing = this.entries.get(key) + const freshDueAt = shouldSkipFresh(candidate, reason) ? freshRetryAt(candidate) : null + const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0) + if (!existing) { + this.entries.set(key, { + key, + candidate, + aliases: new Map([[alias.cacheKey, alias]]), + reason, + priority, + dueAt, + queuedAt: this.nextOrder(), + windowId + }) + return { alias, key, dueAt, coalesced: false } + } + + existing.aliases.set(alias.cacheKey, alias) + const shouldPromote = + priority > existing.priority || + reason === 'manual' || + (reason === 'active' && existing.reason === 'active') || + (priority >= existing.priority && dueAt < existing.dueAt && bypassesFreshnessDelay(reason)) + if (shouldPromote) { + existing.priority = priority + existing.reason = reason + existing.dueAt = Math.min(existing.dueAt, dueAt) + existing.queuedAt = this.nextOrder() + existing.activeDelayNotified = false + existing.candidate = candidate + existing.windowId = windowId ?? existing.windowId + } else if (existing.candidate.worktreeId === candidate.worktreeId) { + existing.candidate = { + ...existing.candidate, + cacheKey: candidate.cacheKey, + branch: candidate.branch, + currentHeadOid: candidate.currentHeadOid ?? null + } + } + return { alias, key, dueAt, coalesced: true } + } + + removeInvalidAlias(key: string, alias: GitHubPRRefreshAlias): void { + const existing = this.entries.get(key) + if (!existing) { + return + } + existing.aliases.delete(alias.cacheKey) + const replacement = existing.aliases.values().next().value + if (!replacement) { + this.entries.delete(key) + this.resetRetryState(key) + return + } + if (existing.candidate.cacheKey === alias.cacheKey) { + existing.candidate = { + ...existing.candidate, + cacheKey: replacement.cacheKey, + branch: replacement.branch, + worktreeId: replacement.worktreeId, + currentHeadOid: replacement.currentHeadOid ?? null, + isArchived: false, + isBare: false + } + } + } + + pruneWorktreeAliases(worktreeId: string): void { + for (const [key, entry] of this.entries) { + let removed = false + for (const [cacheKey, alias] of entry.aliases) { + if (alias.worktreeId === worktreeId) { + entry.aliases.delete(cacheKey) + removed = true + } + } + if (!removed) { + continue + } + if (entry.aliases.size === 0) { + this.entries.delete(key) + this.resetRetryState(key) + continue + } + if (entry.candidate.worktreeId === worktreeId) { + const replacement = entry.aliases.values().next().value + if (replacement) { + entry.candidate = { + ...entry.candidate, + cacheKey: replacement.cacheKey, + branch: replacement.branch, + worktreeId: replacement.worktreeId, + currentHeadOid: replacement.currentHeadOid ?? null + } + } + } + } + } + + removeInvisibleVisibleEntries(isVisible: (key: string) => boolean): PRRefreshQueueEntry[] { + const removed: PRRefreshQueueEntry[] = [] + for (const [key, entry] of this.entries) { + if (entry.reason !== 'visible' || isVisible(key)) { + continue + } + this.entries.delete(key) + this.resetRetryState(key) + removed.push(entry) + } + return removed + } + + setVisibleFollowUp(entry: PRRefreshQueueEntry): void { + const existing = this.entries.get(entry.key) + if (!existing) { + this.entries.set(entry.key, entry) + return + } + for (const alias of entry.aliases.values()) { + existing.aliases.set(alias.cacheKey, alias) + } + if ( + bypassesFreshnessDelay(existing.reason) || + existing.priority > entry.priority || + existing.dueAt <= entry.dueAt + ) { + return + } + this.entries.set(entry.key, { ...entry, aliases: existing.aliases }) + } + + ordered( + activeOrder: (a: PRRefreshQueueEntry, b: PRRefreshQueueEntry) => number + ): PRRefreshQueueEntry[] { + const now = Date.now() + return Array.from(this.entries.values()).sort((a, b) => { + const aReady = a.dueAt <= now + const bReady = b.dueAt <= now + if (aReady && bReady) { + return b.priority - a.priority || activeOrder(a, b) || a.dueAt - b.dueAt + } + if (aReady !== bReady) { + return aReady ? -1 : 1 + } + return a.dueAt - b.dueAt || b.priority - a.priority + }) + } +} diff --git a/src/main/github/pr-refresh-rate-limit-gate.ts b/src/main/github/pr-refresh-rate-limit-gate.ts new file mode 100644 index 00000000000..19cee2e215d --- /dev/null +++ b/src/main/github/pr-refresh-rate-limit-gate.ts @@ -0,0 +1,27 @@ +import type { GitHubPRRefreshCandidate } from '../../shared/github/pull-request-refresh-types' +import { getOriginGitHubApiRepository } from './github-api-repository' +import { ghRepoExecOptions, githubRepoContext } from './gh-utils' +import { getRateLimit, repositoryRateLimitGuard, spendsSharedGitHubComQuota } from './rate-limit' + +const BUCKETS = ['core', 'graphql'] as const + +export async function prRefreshRateLimitPausedUntil( + candidate: GitHubPRRefreshCandidate, + warmSharedSnapshot: boolean +): Promise { + const executionOptions = ghRepoExecOptions( + githubRepoContext(candidate.repoPath, candidate.connectionId, candidate.localGitOptions) + ) + const repository = await getOriginGitHubApiRepository( + candidate.repoPath, + candidate.connectionId, + executionOptions + ) + if (warmSharedSnapshot && spendsSharedGitHubComQuota(repository, executionOptions)) { + await getRateLimit() + } + const blockedGuard = BUCKETS.map((bucket) => + repositoryRateLimitGuard(repository, bucket, executionOptions) + ).find((guard) => guard.blocked) + return blockedGuard?.blocked ? blockedGuard.resetAt * 1000 : null +} diff --git a/src/main/github/pr-refresh-retry-state.ts b/src/main/github/pr-refresh-retry-state.ts new file mode 100644 index 00000000000..fa66bf021a4 --- /dev/null +++ b/src/main/github/pr-refresh-retry-state.ts @@ -0,0 +1,46 @@ +import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types' +import { lookupBackoffDelayMs } from '../source-control/hosted-review-refresh-pacing' + +export class PRRefreshRetryState { + private readonly errorBackoff = new Map() + private readonly manualRetryGates = new Map() + + get errorBackoffCount(): number { + return this.errorBackoff.size + } + + reset(key: string): void { + this.errorBackoff.delete(key) + this.manualRetryGates.delete(key) + } + + noteManualGate(key: string, outcome: PRRefreshOutcome): void { + if (outcome.kind === 'upstream-error' && outcome.retryDisabledUntil !== undefined) { + this.manualRetryGates.set(key, outcome.retryDisabledUntil) + } else { + this.manualRetryGates.delete(key) + } + } + + manualGateUntil(key: string): number { + return this.manualRetryGates.get(key) ?? 0 + } + + nextVisibleErrorRetryAt(key: string): number { + const failures = (this.errorBackoff.get(key)?.failures ?? 0) + 1 + const retryAt = Date.now() + lookupBackoffDelayMs(failures) + this.errorBackoff.set(key, { failures, retryAt }) + return retryAt + } + + withErrorSchedule(outcome: PRRefreshOutcome, retryAt: number): PRRefreshOutcome { + if (outcome.kind !== 'upstream-error') { + return outcome + } + const cooldownUntil = outcome.retryDisabledUntil + return { + ...outcome, + nextAutoRetryAt: cooldownUntil !== undefined ? Math.max(retryAt, cooldownUntil) : retryAt + } + } +} diff --git a/src/main/github/pr-refresh-visibility.ts b/src/main/github/pr-refresh-visibility.ts new file mode 100644 index 00000000000..9673252b6fe --- /dev/null +++ b/src/main/github/pr-refresh-visibility.ts @@ -0,0 +1,44 @@ +import { webContents } from 'electron' +import type { GitHubPRRefreshCandidate } from '../../shared/github/pull-request-refresh-types' +import { refreshKey } from './pr-refresh-candidate-policy' + +export class PRRefreshVisibility { + private readonly visibleByWindow = new Map }>() + + get windowCount(): number { + return this.visibleByWindow.size + } + + clearWindow(windowId: number): boolean { + return this.visibleByWindow.delete(windowId) + } + + report(candidates: GitHubPRRefreshCandidate[], generation: number, windowId: number): boolean { + const existing = this.visibleByWindow.get(windowId) + if (existing && generation < existing.generation) { + return false + } + this.visibleByWindow.set(windowId, { generation, keys: new Set(candidates.map(refreshKey)) }) + return true + } + + has(key: string): boolean { + const liveWindowIds = new Set( + webContents + .getAllWebContents() + .filter((contents) => !contents.isDestroyed()) + .map((contents) => contents.id) + ) + for (const windowId of Array.from(this.visibleByWindow.keys())) { + if (!liveWindowIds.has(windowId)) { + this.visibleByWindow.delete(windowId) + } + } + for (const visible of this.visibleByWindow.values()) { + if (visible.keys.has(key)) { + return true + } + } + return false + } +} diff --git a/src/main/github/pull-request-file-contents.ts b/src/main/github/pull-request-file-contents.ts new file mode 100644 index 00000000000..fa367e1c68b --- /dev/null +++ b/src/main/github/pull-request-file-contents.ts @@ -0,0 +1,132 @@ +import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types' +import { acquire, ghExecFileAsync, ghRepoExecOptions, githubRepoContext, release } from './gh-utils' +import type { LocalGitExecOptions } from './gh-utils' +import { + githubHostExecOptions, + resolveGitHubRepoExecution, + type GitHubApiRepository +} from './github-api-repository' +import { isMaxBufferOverflowError } from '../git/max-buffer-overflow' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES = 8 * 1024 * 1024 + +function encodeGitHubContentPath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/') +} + +async function fetchContentAtRef(args: { + repoPath: string + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + ownerRepo: GitHubApiRepository + path: string + ref: string +}): Promise<{ content: string; isBinary: boolean; tooLarge?: boolean }> { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions)), + ...githubHostExecOptions(args.ownerRepo), + maxBuffer: GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES + } + if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { + return { content: '', isBinary: false } + } + try { + noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '300s', + '-H', + 'Accept: application/vnd.github.raw', + `repos/${args.ownerRepo.owner}/${args.ownerRepo.repo}/contents/${encodeGitHubContentPath(args.path)}?ref=${encodeURIComponent(args.ref)}` + ], + ghOptions + ) + const sample = stdout.slice(0, 2048) + if (sample.includes('\u0000')) { + return { content: '', isBinary: true } + } + return { content: stdout, isBinary: false } + } catch (error) { + if (isMaxBufferOverflowError(error)) { + return { content: '', isBinary: false, tooLarge: true } + } + return { content: '', isBinary: false } + } +} + +export async function getPRFileContents(args: { + repoPath: string + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + prRepo?: GitHubApiRepository | null + prNumber: number + path: string + oldPath?: string + status: GitHubPRFile['status'] + headSha: string + baseSha: string +}): Promise { + const { ownerRepo } = await resolveGitHubRepoExecution( + args.repoPath, + args.prRepo, + args.connectionId, + args.localGitOptions + ) + if (!ownerRepo) { + return { + original: '', + modified: '', + originalIsBinary: false, + modifiedIsBinary: false + } + } + + await acquire() + try { + // Added and removed files have no content on one side of the comparison. + const needsOriginal = args.status !== 'added' + const needsModified = args.status !== 'removed' + const originalPath = args.oldPath ?? args.path + const [original, modified] = await Promise.all([ + needsOriginal + ? fetchContentAtRef({ + repoPath: args.repoPath, + connectionId: args.connectionId, + localGitOptions: args.localGitOptions, + ownerRepo, + path: originalPath, + ref: args.baseSha + }) + : Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({ + content: '', + isBinary: false + }), + needsModified + ? fetchContentAtRef({ + repoPath: args.repoPath, + connectionId: args.connectionId, + localGitOptions: args.localGitOptions, + ownerRepo, + path: args.path, + ref: args.headSha + }) + : Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({ + content: '', + isBinary: false + }) + ]) + return { + original: original.content, + modified: modified.content, + originalIsBinary: original.isBinary, + modifiedIsBinary: modified.isBinary, + originalTooLarge: original.tooLarge, + modifiedTooLarge: modified.tooLarge + } + } finally { + release() + } +} diff --git a/src/main/github/pull-request-file-data.ts b/src/main/github/pull-request-file-data.ts new file mode 100644 index 00000000000..62622e4583f --- /dev/null +++ b/src/main/github/pull-request-file-data.ts @@ -0,0 +1,247 @@ +import type { GitHubPRFile, GitHubPRFileViewedState } from '../../shared/github/pull-request-types' +import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from './gh-utils' +import type { LocalGitExecOptions } from './gh-utils' +import { githubHostExecOptions, type GitHubApiRepository } from './github-api-repository' +import { getPRReviewCommentLineNumbersFromPatch } from './pr-review-comment-lines' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const MAX_PR_FILES = 300 + +const PR_FILE_VIEWED_STATES_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + id + files(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + path + viewerViewedState + } + } + } + } +}` + +type RESTPRFile = { + filename: string + previous_filename?: string + status: string + additions: number + deletions: number + changes: number + patch?: string +} + +export type PRFileViewedStatesResult = { + pullRequestId: string + viewedStates: Map +} + +function mapFileStatus(raw: string): GitHubPRFile['status'] { + switch (raw) { + case 'added': + return 'added' + case 'removed': + return 'removed' + case 'modified': + return 'modified' + case 'renamed': + return 'renamed' + case 'copied': + return 'copied' + case 'changed': + return 'changed' + case 'unchanged': + return 'unchanged' + default: + return 'modified' + } +} + +function isBinaryHint(file: RESTPRFile): boolean { + return file.patch === undefined && file.changes > 0 +} + +export async function getPRMetadata( + repoPath: string, + prNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ body: string; headSha?: string; baseSha?: string }> { + if (!repository) { + return { body: '' } + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) { + return { body: '' } + } + try { + noteRepositoryRateLimitSpend(repository, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + ['api', '--cache', '60s', `repos/${repository.owner}/${repository.repo}/pulls/${prNumber}`], + ghOptions + ) + const data = JSON.parse(stdout) as { + body?: string | null + head?: { sha?: string } + base?: { sha?: string } + } + return { + body: data.body ?? '', + ...(data.head?.sha ? { headSha: data.head.sha } : {}), + ...(data.base?.sha ? { baseSha: data.base.sha } : {}) + } + } catch { + return { body: '' } + } +} + +// null means unavailable; [] means a successfully fetched empty PR. +export async function getPRFiles( + repoPath: string, + prNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (!repository) { + return null + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + try { + const data: RESTPRFile[] = [] + for (let page = 1; data.length < MAX_PR_FILES; page += 1) { + if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) { + return null + } + const pageSuffix = page === 1 ? '' : `&page=${page}` + noteRepositoryRateLimitSpend(repository, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${repository.owner}/${repository.repo}/pulls/${prNumber}/files?per_page=100${pageSuffix}` + ], + ghOptions + ) + const pageData = JSON.parse(stdout) as RESTPRFile[] + data.push(...pageData.slice(0, MAX_PR_FILES - data.length)) + if (pageData.length < 100) { + break + } + } + return data.map((file) => ({ + path: file.filename, + oldPath: file.previous_filename, + status: mapFileStatus(file.status), + additions: file.additions, + deletions: file.deletions, + isBinary: isBinaryHint(file), + reviewCommentLineNumbers: getPRReviewCommentLineNumbersFromPatch(file.patch) + })) + } catch { + return null + } +} + +export async function getPRFileViewedStates( + repoPath: string, + prNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (!repository) { + return null + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + return null + } + const viewedStates = new Map() + let pullRequestId: string | null = null + let after: string | null = null + + try { + for (let fetched = 0; fetched < MAX_PR_FILES; fetched += 100) { + const args = [ + 'api', + 'graphql', + '-f', + `query=${PR_FILE_VIEWED_STATES_QUERY}`, + '-f', + `owner=${repository.owner}`, + '-f', + `repo=${repository.repo}`, + '-F', + `number=${prNumber}` + ] + if (after) { + args.push('-f', `after=${after}`) + } + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync(args, ghOptions) + const parsed = JSON.parse(stdout) as { + data?: { + repository?: { + pullRequest?: { + id?: string + files?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: { + path?: string | null + viewerViewedState?: GitHubPRFileViewedState | null + }[] + } + } | null + } | null + } + errors?: { message?: string }[] + } + if (parsed.errors && parsed.errors.length > 0) { + return null + } + const pullRequest = parsed.data?.repository?.pullRequest + if (!pullRequest?.id) { + return null + } + pullRequestId = pullRequest.id + for (const file of pullRequest.files?.nodes ?? []) { + if (file.path && file.viewerViewedState) { + viewedStates.set(file.path, file.viewerViewedState) + } + } + if (!pullRequest.files?.pageInfo?.hasNextPage || !pullRequest.files.pageInfo.endCursor) { + break + } + after = pullRequest.files.pageInfo.endCursor + } + } catch { + return null + } + return pullRequestId ? { pullRequestId, viewedStates } : null +} + +export function mergePRFileViewedStates( + files: GitHubPRFile[], + viewedStates: PRFileViewedStatesResult | null +): GitHubPRFile[] { + if (!viewedStates) { + return files + } + return files.map((file) => ({ + ...file, + viewerViewedState: viewedStates.viewedStates.get(file.path) ?? 'UNVIEWED' + })) +} diff --git a/src/main/github/work-item-details-api-parity.test.ts b/src/main/github/work-item-details-api-parity.test.ts new file mode 100644 index 00000000000..187680f64cd --- /dev/null +++ b/src/main/github/work-item-details-api-parity.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types' +import type { GitHubWorkItemDetails } from '../../shared/github/work-item-types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import type { LocalGitExecOptions } from './gh-utils' +import type { GitHubApiRepository } from './github-api-repository' +import * as workItemDetails from './work-item-details' + +type GetWorkItemDetails = ( + repoPath: string, + number: number, + type?: 'issue' | 'pr', + connectionId?: string | null, + localGitOptions?: LocalGitExecOptions, + preference?: IssueSourcePreference +) => Promise + +type GetPRFileContents = (args: { + repoPath: string + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + prRepo?: GitHubApiRepository | null + prNumber: number + path: string + oldPath?: string + status: GitHubPRFile['status'] + headSha: string + baseSha: string +}) => Promise + +describe('work-item-details public API parity', () => { + it('retains only the established runtime exports and call signatures', () => { + expect(Object.keys(workItemDetails).sort()).toEqual(['getPRFileContents', 'getWorkItemDetails']) + expectTypeOf(workItemDetails.getWorkItemDetails).toEqualTypeOf() + expectTypeOf(workItemDetails.getPRFileContents).toEqualTypeOf() + }) +}) diff --git a/src/main/github/work-item-details-pr-files.test.ts b/src/main/github/work-item-details-pr-files.test.ts index cac3c33eaf4..d0205332bd5 100644 --- a/src/main/github/work-item-details-pr-files.test.ts +++ b/src/main/github/work-item-details-pr-files.test.ts @@ -333,6 +333,36 @@ describe('getWorkItemDetails PR file listing', () => { ).toHaveLength(2) }) + it('skips an added file base read and preserves a head max-buffer overflow', async () => { + ghExecFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('stdout maxBuffer length exceeded'), { code: 'ENOBUFS' }) + ) + + const contents = await getPRFileContents({ + repoPath: '/repo-root', + prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }, + prNumber: 7, + path: 'src/large.ts', + status: 'added', + headSha: 'head-sha', + baseSha: 'base-sha' + }) + + expect(contents).toEqual({ + original: '', + modified: '', + originalIsBinary: false, + modifiedIsBinary: false, + originalTooLarge: undefined, + modifiedTooLarge: true + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledOnce() + expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain( + 'repos/team/orca/contents/src/large.ts?ref=head-sha' + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledOnce() + }) + it('does not fetch raw PR file contents while the repository core budget is blocked', async () => { repositoryRateLimitGuardMock.mockReturnValue({ blocked: true, diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 276ee9d33eb..dafc4f514d6 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -1,1012 +1,41 @@ -/* eslint-disable max-lines -- Why: groups the PR/Issue fetch paths and file-contents resolver so caching/rate-limit strategy lives in one place. */ import type { PRCheckDetail } from '../../shared/github/check-types' -import type { - GitHubIssueTimelineItem, - GitHubIssueTimelineTarget, - PRComment -} from '../../shared/github/comment-types' -import type { - GitHubAssignableUser, - GitHubPRFile, - GitHubPRFileContents, - GitHubPRFileViewedState -} from '../../shared/github/pull-request-types' +import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types' import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../shared/github/work-item-types' import type { IssueSourcePreference } from '../../shared/repo-types' -import { - ghExecFileAsync, - acquire, - release, - ghRepoExecOptions, - githubRepoContext, - type LocalGitExecOptions -} from './gh-utils' -import { getWorkItem, getPRChecks, getPRComments } from './client' +import { getPRChecks, getPRComments, getWorkItem } from './client' +import { acquire, release, type LocalGitExecOptions } from './gh-utils' import { getIssueGitHubApiRepository, - githubHostExecOptions, resolveGitHubRepoExecution, type GitHubApiRepository } from './github-api-repository' -import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' -import { getPRReviewCommentLineNumbersFromPatch } from './pr-review-comment-lines' -import { isMaxBufferOverflowError } from '../git/max-buffer-overflow' - -// Why: cap total PR files so a massive PR can't starve the gh semaphore while paging (100/page). -const MAX_PR_FILES = 300 -// Why: bound noisy issue timelines so one huge issue can't monopolize gh/API time. -const MAX_ISSUE_TIMELINE_ITEMS = 300 -const GITHUB_REST_PAGE_SIZE = 100 -// Why: raw-fetch buffer must exceed the renderer's large-diff threshold, else the UI shows an empty diff instead of the fallback. -const GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES = 8 * 1024 * 1024 +import { getIssueBodyAndComments, getIssueDetailsViaGraphQL } from './issue-work-item-details' +import { + getPRFiles, + getPRFileViewedStates, + getPRMetadata, + mergePRFileViewedStates +} from './pull-request-file-data' +import { getPRFileContents as loadPRFileContents } from './pull-request-file-contents' +import { + enrichItemDisplayAvatars, + getMentionParticipants, + getWorkItemParticipants +} from './work-item-participants' function localGitOptionArgs(options: LocalGitExecOptions = {}): [] | [LocalGitExecOptions] { return Object.keys(options).length > 0 ? [options] : [] } -function encodeGitHubContentPath(path: string): string { - return path.split('/').map(encodeURIComponent).join('/') -} - -const PR_FILE_VIEWED_STATES_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $after: String) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - id - files(first: 100, after: $after) { - pageInfo { hasNextPage endCursor } - nodes { - path - viewerViewedState - } - } - } - } -}` - -const WORK_ITEM_PARTICIPANTS_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $isPr: Boolean!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) @include(if: $isPr) { - participants(first: 100) { - nodes { login avatarUrl(size: 48) ... on User { name } } - } - } - issue(number: $number) @skip(if: $isPr) { - participants(first: 100) { - nodes { login avatarUrl(size: 48) ... on User { name } } - } - } - } -}` - -// Why: one GraphQL round-trip replaces the 3 serial gh subprocesses (REST issue + comments + participants); falls back to the legacy path on failure. -const ISSUE_DETAILS_QUERY = `query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $number) { - body - assignees(first: 50) { nodes { login avatarUrl(size: 48) ... on User { name } } } - participants(first: 100) { - nodes { login avatarUrl(size: 48) ... on User { name } } - } - comments(first: 100) { - nodes { - databaseId - body - createdAt - url - author { - login - avatarUrl(size: 48) - ... on Bot { __typename } - } - } - } - } - } -}` - -type GraphQLIssueDetailsResponse = { - data?: { - repository?: { - issue?: { - body?: string | null - assignees?: { nodes?: { login?: string; avatarUrl?: string; name?: string | null }[] } - participants?: { nodes?: GitHubAssignableUser[] } - comments?: { - nodes?: { - databaseId?: number | null - body?: string | null - createdAt?: string | null - url?: string | null - author?: { - login?: string | null - avatarUrl?: string | null - __typename?: string - } | null - }[] - } - } | null - } | null - } - errors?: { message?: string }[] -} - -type RestTimelineUser = { - login?: string | null - avatar_url?: string | null -} - -type RestTimelineIssue = { - number?: number | null - title?: string | null - html_url?: string | null - repository?: { - name?: string | null - owner?: { login?: string | null } | null - } | null - pull_request?: unknown -} - -type RestTimelineEvent = { - id?: number | string | null - node_id?: string | null - event?: string | null - actor?: RestTimelineUser | null - user?: RestTimelineUser | null - assignee?: RestTimelineUser | null - created_at?: string | null - source?: { - issue?: RestTimelineIssue | null - } | null - closer?: RestTimelineIssue | null - state_reason?: string | null - project_card?: { - column_name?: string | null - previous_column_name?: string | null - project_url?: string | null - } | null - project?: { - name?: string | null - } | null - project_column_name?: string | null - previous_column_name?: string | null -} - -function isSupportedTimelineEvent( - eventName: string | null | undefined -): eventName is GitHubIssueTimelineItem['event'] { - return ( - eventName === 'assigned' || - eventName === 'unassigned' || - eventName === 'mentioned' || - eventName === 'cross-referenced' || - eventName === 'closed' || - eventName === 'reopened' || - eventName === 'moved_columns_in_project' - ) -} - -function mapTimelineTarget( - issue: RestTimelineIssue | null | undefined -): GitHubIssueTimelineTarget | undefined { - if (!issue || typeof issue.number !== 'number' || !issue.html_url) { - return undefined - } - const owner = issue.repository?.owner?.login - const repo = issue.repository?.name - return { - type: issue.pull_request ? 'pr' : 'issue', - number: issue.number, - title: issue.title ?? '', - url: issue.html_url, - repository: owner && repo ? `${owner}/${repo}` : undefined - } -} - -function getTimelineActor(event: RestTimelineEvent): { login: string; avatarUrl: string } { - const actor = event.actor ?? event.user - return { - login: actor?.login ?? 'ghost', - avatarUrl: actor?.avatar_url ?? '' - } -} - -function mapRestTimelineEvent(event: RestTimelineEvent): GitHubIssueTimelineItem | null { - const eventName = event.event - if (!isSupportedTimelineEvent(eventName)) { - return null - } - if (!event.created_at) { - return null - } - const actor = getTimelineActor(event) - const id = String(event.node_id ?? event.id ?? `${eventName}:${event.created_at}`) - const base = { - id, - event: eventName, - actor: actor.login, - actorAvatarUrl: actor.avatarUrl, - createdAt: event.created_at - } - if (eventName === 'assigned' || eventName === 'unassigned') { - return { - ...base, - assignee: event.assignee?.login ?? undefined - } - } - if (eventName === 'mentioned' || eventName === 'cross-referenced') { - return { - ...base, - source: mapTimelineTarget(event.source?.issue) - } - } - if (eventName === 'closed') { - return { - ...base, - stateReason: event.state_reason ?? null, - closer: mapTimelineTarget(event.closer ?? event.source?.issue) - } - } - if (eventName === 'moved_columns_in_project') { - return { - ...base, - previousColumnName: - event.previous_column_name ?? event.project_card?.previous_column_name ?? null, - columnName: event.project_column_name ?? event.project_card?.column_name ?? null, - projectName: event.project?.name ?? null - } - } - return base -} - -function parseRestTimelineEventLines(stdout: string): RestTimelineEvent[] { - const events: RestTimelineEvent[] = [] - for (const line of stdout.split('\n')) { - const trimmed = line.trim() - if (!trimmed) { - continue - } - try { - const parsed = JSON.parse(trimmed) as unknown - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - events.push(parsed) - } - } catch { - // Skip malformed jq lines; timeline activity is auxiliary to issue details. - } - } - return events -} - -async function getIssueTimelineItems( - ownerRepo: GitHubApiRepository, - issueNumber: number, - ghOptions: ReturnType -): Promise { +async function withWorkItemDetailsPermit(operation: () => Promise): Promise { + await acquire() try { - const items: GitHubIssueTimelineItem[] = [] - for (let page = 1; items.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) { - if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { - return items - } - noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/timeline?per_page=${GITHUB_REST_PAGE_SIZE}&page=${page}`, - '--jq', - '.[] | @json' - ], - { ...ghOptions, ...githubHostExecOptions(ownerRepo) } - ) - // Why: --jq emits NDJSON and explicit paging lets us stop once we hit the drawer cap. - const pageEvents = parseRestTimelineEventLines(stdout) - for (const event of pageEvents) { - const item = mapRestTimelineEvent(event) - if (!item) { - continue - } - items.push(item) - if (items.length === MAX_ISSUE_TIMELINE_ITEMS) { - break - } - } - if (pageEvents.length < GITHUB_REST_PAGE_SIZE) { - break - } - } - return items - } catch { - return [] + return await operation() + } finally { + release() } } -/** - * Fetch an issue's body, comments, assignees, participants, and timeline in one GraphQL round-trip. - * Returns null on any partial error so the caller falls back to the strict REST path. - * Avatars are resolved here so GHE users don't render blank. - */ -async function getIssueDetailsViaGraphQL( - repoPath: string, - issueNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ - body: string - comments: PRComment[] - assignees: string[] - // Avatar-bearing assignees, kept separate from the login-only `assignees`; enriches avatars `gh` leaves blank (GHE). - assigneeUsers: GitHubAssignableUser[] - participants: GitHubAssignableUser[] - timelineItems: GitHubIssueTimelineItem[] -} | null> { - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (!ownerRepo) { - return null - } - if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { - return null - } - try { - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - 'graphql', - '-f', - `query=${ISSUE_DETAILS_QUERY}`, - '-f', - `owner=${ownerRepo.owner}`, - '-f', - `repo=${ownerRepo.repo}`, - '-F', - `number=${issueNumber}` - ], - ghOptions - ) - const parsed = JSON.parse(stdout) as GraphQLIssueDetailsResponse - if (parsed.errors && parsed.errors.length > 0) { - // Why: any partial GraphQL error forces the strict REST fallback so the drawer never paints a half-built shell. - return null - } - const issue = parsed.data?.repository?.issue - if (!issue) { - return null - } - const comments: PRComment[] = (issue.comments?.nodes ?? []) - .filter((c) => typeof c.databaseId === 'number') - .map((c) => ({ - id: c.databaseId as number, - author: c.author?.login ?? 'ghost', - authorAvatarUrl: c.author?.avatarUrl ?? '', - body: c.body ?? '', - createdAt: c.createdAt ?? '', - url: c.url ?? '', - isBot: c.author?.__typename === 'Bot' - })) - const assigneeUsers: GitHubAssignableUser[] = (issue.assignees?.nodes ?? []) - .filter((a): a is { login: string; avatarUrl?: string; name?: string | null } => - Boolean(a.login) - ) - .map((a) => ({ - login: a.login, - name: a.name ?? null, - avatarUrl: a.avatarUrl ?? '' - })) - const assignees = assigneeUsers.map((a) => a.login) - const participants: GitHubAssignableUser[] = (issue.participants?.nodes ?? []) - .filter((u) => Boolean(u.login)) - .map((u) => ({ - login: u.login, - name: u.name ?? null, - avatarUrl: u.avatarUrl ?? '' - })) - const timelineItems = await getIssueTimelineItems(ownerRepo, issueNumber, ghOptions) - return { - body: issue.body ?? '', - comments, - assignees, - assigneeUsers, - participants, - timelineItems - } - } catch { - return null - } -} - -function mergeGitHubUsers(users: GitHubAssignableUser[]): GitHubAssignableUser[] { - const byLogin = new Map() - for (const user of users) { - if (!user.login) { - continue - } - const key = user.login.toLowerCase() - const existing = byLogin.get(key) - if (existing) { - // Why: return a new merged record instead of mutating caller-provided objects. - byLogin.set(key, { - login: existing.login, - name: existing.name ?? user.name ?? null, - avatarUrl: existing.avatarUrl || user.avatarUrl || '' - }) - continue - } - byLogin.set(key, { - login: user.login, - name: user.name ?? null, - avatarUrl: user.avatarUrl ?? '' - }) - } - return Array.from(byLogin.values()) -} - -type RESTPRFile = { - filename: string - previous_filename?: string - status: string - additions: number - deletions: number - changes: number - /** Raw patch text when available; absent for binary files or patches over GitHub's size cap. */ - patch?: string -} - -function mapFileStatus(raw: string): GitHubPRFile['status'] { - switch (raw) { - case 'added': - return 'added' - case 'removed': - return 'removed' - case 'modified': - return 'modified' - case 'renamed': - return 'renamed' - case 'copied': - return 'copied' - case 'changed': - return 'changed' - case 'unchanged': - return 'unchanged' - default: - return 'modified' - } -} - -// Why: REST doesn't flag binaries but omits `patch` for them; treat "changes but no patch" as binary so the diff tab shows a placeholder. -function isBinaryHint(file: RESTPRFile): boolean { - if (file.status === 'removed' || file.status === 'added') { - // Added/removed file with changes but no patch is almost always binary (images, oversized lockfiles). - return file.patch === undefined && file.changes > 0 - } - return file.patch === undefined && file.changes > 0 -} - -async function getPRMetadata( - repoPath: string, - prNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ body: string; headSha?: string; baseSha?: string }> { - if (!ownerRepo) { - // Why: a bare `gh pr view` can honor ambient GH_HOST/GH_REPO after hosted - // repository resolution fails, returning metadata for the wrong PR. - return { body: '' } - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { - return { body: '' } - } - try { - noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - ['api', '--cache', '60s', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`], - ghOptions - ) - const data = JSON.parse(stdout) as { - body?: string | null - head?: { sha?: string } - base?: { sha?: string } - } - return { - body: data.body ?? '', - ...(data.head?.sha ? { headSha: data.head.sha } : {}), - ...(data.base?.sha ? { baseSha: data.base.sha } : {}) - } - } catch { - return { body: '' } - } -} - -// Why: null = failed/blocked fetch (Files tab shows retry, not "No files changed."); [] = genuinely empty PR. -async function getPRFiles( - repoPath: string, - prNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - if (!ownerRepo) { - return null - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - try { - const data: RESTPRFile[] = [] - for (let page = 1; data.length < MAX_PR_FILES; page += 1) { - if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { - return null - } - const pageSuffix = page === 1 ? '' : `&page=${page}` - noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}/files?per_page=100${pageSuffix}` - ], - ghOptions - ) - const pageData = JSON.parse(stdout) as RESTPRFile[] - data.push(...pageData.slice(0, MAX_PR_FILES - data.length)) - if (pageData.length < 100) { - break - } - } - return data.map((file) => ({ - path: file.filename, - oldPath: file.previous_filename, - status: mapFileStatus(file.status), - additions: file.additions, - deletions: file.deletions, - isBinary: isBinaryHint(file), - reviewCommentLineNumbers: getPRReviewCommentLineNumbersFromPatch(file.patch) - })) - } catch { - return null - } -} - -type PRFileViewedStatesResult = { - pullRequestId: string - viewedStates: Map -} - -async function getPRFileViewedStates( - repoPath: string, - prNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - if (!ownerRepo) { - return null - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { - return null - } - const viewedStates = new Map() - let pullRequestId: string | null = null - let after: string | null = null - - try { - for (let fetched = 0; fetched < MAX_PR_FILES; fetched += 100) { - const args = [ - 'api', - 'graphql', - '-f', - `query=${PR_FILE_VIEWED_STATES_QUERY}`, - '-f', - `owner=${ownerRepo.owner}`, - '-f', - `repo=${ownerRepo.repo}`, - '-F', - `number=${prNumber}` - ] - if (after) { - args.push('-f', `after=${after}`) - } - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - const { stdout } = await ghExecFileAsync(args, ghOptions) - const parsed = JSON.parse(stdout) as { - data?: { - repository?: { - pullRequest?: { - id?: string - files?: { - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: { - path?: string | null - viewerViewedState?: GitHubPRFileViewedState | null - }[] - } - } | null - } | null - } - errors?: { message?: string }[] - } - if (parsed.errors && parsed.errors.length > 0) { - return null - } - const pullRequest = parsed.data?.repository?.pullRequest - if (!pullRequest?.id) { - return null - } - pullRequestId = pullRequest.id - for (const file of pullRequest.files?.nodes ?? []) { - if (file.path && file.viewerViewedState) { - viewedStates.set(file.path, file.viewerViewedState) - } - } - if (!pullRequest.files?.pageInfo?.hasNextPage || !pullRequest.files.pageInfo.endCursor) { - break - } - after = pullRequest.files.pageInfo.endCursor - } - } catch { - return null - } - - return pullRequestId ? { pullRequestId, viewedStates } : null -} - -function mergePRFileViewedStates( - files: GitHubPRFile[], - viewedStates: PRFileViewedStatesResult | null -): GitHubPRFile[] { - if (!viewedStates) { - return files - } - return files.map((file) => ({ - ...file, - viewerViewedState: viewedStates.viewedStates.get(file.path) ?? 'UNVIEWED' - })) -} - -async function getIssueBodyAndComments( - repoPath: string, - issueNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ - body: string - comments: PRComment[] - assignees: string[] - timelineItems: GitHubIssueTimelineItem[] -}> { - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - try { - if (ownerRepo) { - if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { - return { body: '', comments: [], assignees: [], timelineItems: [] } - } - // Why: the fallback starts the issue and comments reads together; debit - // both before spawning so a failed response cannot leave quota overstated. - noteRepositoryRateLimitSpend(ownerRepo, 'core', 2, ghOptions) - const [issueResult, commentsResult, timelineItems] = await Promise.all([ - ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}` - ], - ghOptions - ), - ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/comments?per_page=100` - ], - ghOptions - ), - getIssueTimelineItems(ownerRepo, issueNumber, ghOptions) - ]) - const issue = JSON.parse(issueResult.stdout) as { - body?: string | null - assignees?: { login: string }[] - } - type RESTComment = { - id: number - user: { login: string; avatar_url: string; type?: string } | null - body: string - created_at: string - html_url: string - } - const comments = (JSON.parse(commentsResult.stdout) as RESTComment[]).map( - (c): PRComment => ({ - id: c.id, - author: c.user?.login ?? 'ghost', - authorAvatarUrl: c.user?.avatar_url ?? '', - body: c.body ?? '', - createdAt: c.created_at, - url: c.html_url, - isBot: c.user?.type === 'Bot' - }) - ) - const assignees = (issue.assignees ?? []).map((a) => a.login) - return { body: issue.body ?? '', comments, assignees, timelineItems } - } - if (connectionId) { - // Why: connection-backed gh has no cwd. A bare issue lookup could honor - // process GH_REPO/GH_HOST and return an unrelated repository's issue. - return { body: '', comments: [], assignees: [], timelineItems: [] } - } - // Fallback: non-GitHub remote - const { stdout } = await ghExecFileAsync( - ['issue', 'view', String(issueNumber), '--json', 'body,comments,assignees'], - ghOptions - ) - const data = JSON.parse(stdout) as { - body?: string - comments?: { - author: { login: string } - body: string - createdAt: string - url: string - }[] - assignees?: { login: string }[] - } - const comments = (data.comments ?? []).map( - (c, i): PRComment => ({ - id: i, - author: c.author?.login ?? 'ghost', - authorAvatarUrl: '', - body: c.body ?? '', - createdAt: c.createdAt, - url: c.url ?? '' - }) - ) - const fallbackAssignees = (data.assignees ?? []).map((a) => a.login) - return { body: data.body ?? '', comments, assignees: fallbackAssignees, timelineItems: [] } - } catch { - return { body: '', comments: [], assignees: [], timelineItems: [] } - } -} - -async function getWorkItemParticipants( - repoPath: string, - item: Pick, - resolvedRepository: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - // Why: reuse the fan-out's repository identity so details cannot drift across hosts. - const ownerRepo = resolvedRepository - if (!ownerRepo) { - return [] - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { - return [] - } - try { - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - 'graphql', - '-f', - `query=${WORK_ITEM_PARTICIPANTS_QUERY}`, - '-f', - `owner=${ownerRepo.owner}`, - '-f', - `repo=${ownerRepo.repo}`, - '-F', - `number=${item.number}`, - '-F', - `isPr=${item.type === 'pr'}` - ], - ghOptions - ) - const data = JSON.parse(stdout) as { - data?: { - repository?: { - pullRequest?: { - participants?: { nodes?: GitHubAssignableUser[] } - } | null - issue?: { - participants?: { nodes?: GitHubAssignableUser[] } - } | null - } - } - } - const nodes = - data.data?.repository?.pullRequest?.participants?.nodes ?? - data.data?.repository?.issue?.participants?.nodes ?? - [] - return nodes - .map((user) => ({ - login: user.login, - name: user.name ?? null, - avatarUrl: user.avatarUrl ?? '' - })) - .filter((user) => user.login) - } catch { - return [] - } -} - -async function getGitHubUsersByLogin( - repoPath: string, - logins: string[], - repository: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - if (!repository) { - return [] - } - const uniqueLogins = Array.from( - new Set(logins.filter((login) => login && login !== 'ghost').map((login) => login.trim())) - ).slice(0, 40) - if (uniqueLogins.length === 0) { - return [] - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(repository) - } - const guard = repositoryRateLimitGuard(repository, 'graphql', ghOptions) - if (guard.blocked) { - // Why: log the skip — callers degrade silently to blank GHE avatars, so it's otherwise untraceable. - console.warn( - `getGitHubUsersByLogin skipped: GraphQL rate-limit budget exhausted (${uniqueLogins.length} logins unresolved)` - ) - return [] - } - const fields = uniqueLogins - .map( - (login, index) => - `u${index}: user(login: ${JSON.stringify(login)}) { login name avatarUrl(size: 48) }` - ) - .join('\n') - try { - noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - ['api', 'graphql', '-f', `query=query { ${fields} }`], - ghOptions - ) - const data = JSON.parse(stdout) as { - data?: Record< - string, - { - login?: string - name?: string | null - avatarUrl?: string | null - } | null - > - } - return Object.values(data.data ?? {}) - .filter( - ( - user - ): user is { - login: string - name?: string | null - avatarUrl?: string | null - } => Boolean(user?.login) - ) - .map((user) => ({ - login: user.login, - name: user.name ?? null, - avatarUrl: user.avatarUrl ?? '' - })) - } catch { - return [] - } -} - -/** - * Stamp GraphQL-resolved avatars onto a work item's author, reviewers, review requests, latest reviews, and assignees. - * - * Why: `gh pr view` omits avatar_url, so login-based avatars 404 on GHE; knownUsers carries the GraphQL-resolved ones. See #8784. - */ -function enrichItemDisplayAvatars( - item: Omit, - knownUsers: GitHubAssignableUser[] -): Omit { - const avatarByLogin = new Map() - for (const user of knownUsers) { - if (user.login && user.avatarUrl) { - avatarByLogin.set(user.login.toLowerCase(), user.avatarUrl) - } - } - if (avatarByLogin.size === 0) { - return item - } - // Why: prefer the GraphQL-resolved avatar — `gh pr view` returns empty/`u/0` placeholders for enterprise users; fall back to the original only when the lookup is empty. - const avatarFor = (login: string): string | undefined => avatarByLogin.get(login.toLowerCase()) - const resolvedAvatar = (login: string, existing?: string | null): string | undefined => - avatarFor(login) || existing || undefined - // Callers coalesce a missing result to each field's "no avatar" sentinel ('' or null); GitHubUserAvatar falls back to login URL then initials. - const authorAvatarUrl = (item.author ? avatarFor(item.author) : undefined) || item.authorAvatarUrl - return { - ...item, - ...(authorAvatarUrl ? { authorAvatarUrl } : {}), - ...(item.reviewRequests - ? { - reviewRequests: item.reviewRequests.map((user) => ({ - ...user, - avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' - })) - } - : {}), - ...(item.latestReviews - ? { - latestReviews: item.latestReviews.map((review) => ({ - ...review, - avatarUrl: resolvedAvatar(review.login, review.avatarUrl) ?? null - })) - } - : {}), - ...(item.assignees - ? { - assignees: item.assignees.map((user) => ({ - ...user, - avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' - })) - } - : {}) - } -} - -async function getMentionParticipants( - repoPath: string, - item: Pick< - GitHubWorkItem, - 'author' | 'number' | 'type' | 'reviewRequests' | 'latestReviews' | 'assignees' - >, - comments: PRComment[], - participants: GitHubAssignableUser[], - repository: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - // Why: resolve mention authors + display users (reviewers/assignees) in one aliased trip so avatars reuse it without a second rate-limited lookup (#8784). - // Why: order display users before comment authors so the 40-login cap in getGitHubUsersByLogin can't drop a reviewer/assignee avatar. - const visibleLogins = [ - item.author ?? '', - ...(item.reviewRequests ?? []).map((user) => user.login), - ...(item.latestReviews ?? []).map((review) => review.login), - ...(item.assignees ?? []).map((user) => user.login), - ...comments.map((comment) => comment.author) - ] - const graphQlUsers = await getGitHubUsersByLogin( - repoPath, - visibleLogins, - repository, - connectionId, - localGitOptions - ) - return mergeGitHubUsers([...participants, ...graphQlUsers]) -} - async function getPRChecksForDetails( repoPath: string, prNumber: number, @@ -1028,22 +57,12 @@ async function getPRChecksForDetails( connectionId, ...localGitOptionArgs(localGitOptions) ) - } catch (err) { - // Why: checks are auxiliary — a gh failure must not block opening the PR drawer. - console.warn('getWorkItemDetails PR checks failed:', err) + } catch (error) { + console.warn('getWorkItemDetails PR checks failed:', error) return [] } } -async function withWorkItemDetailsPermit(operation: () => Promise): Promise { - await acquire() - try { - return await operation() - } finally { - release() - } -} - export async function getWorkItemDetails( repoPath: string, number: number, @@ -1072,7 +91,6 @@ export async function getWorkItemDetails( if (item.type === 'issue') { return withWorkItemDetailsPermit(async () => { - // Why: one GraphQL trip returns all issue details; fall back to the legacy fan-out on failure. const collapsed = await getIssueDetailsViaGraphQL( repoPath, item.number, @@ -1082,7 +100,6 @@ export async function getWorkItemDetails( ) if (collapsed) { return { - // Include assigneeUsers: non-participating assignees are absent from participants and keep a blank avatar (GHE). item: enrichItemDisplayAvatars(item, [ ...collapsed.participants, ...collapsed.assigneeUsers @@ -1094,7 +111,6 @@ export async function getWorkItemDetails( timelineItems: collapsed.timelineItems } } - // Fallback: fetch body/comments and participants in parallel. const [{ body, comments, assignees, timelineItems }, participants] = await Promise.all([ getIssueBodyAndComments( repoPath, @@ -1125,8 +141,7 @@ export async function getWorkItemDetails( }) } - // Why: getPRComments and getPRChecks own their semaphore permits. Keeping an - // outer permit while awaiting either can deadlock four concurrent detail loads. + // Nested client reads own their semaphore permits, so they stay outside these permits. const [[metadata, files, viewedStates, participants], comments] = await Promise.all([ Promise.all([ withWorkItemDetailsPermit(() => @@ -1159,7 +174,6 @@ export async function getWorkItemDetails( : Promise.resolve([]) ]) - // Why: mention hydration spawns gh directly while checks owns a permit; bound them without nesting. const [mentionParticipants, checks] = await Promise.all([ withWorkItemDetailsPermit(() => getMentionParticipants( @@ -1193,57 +207,12 @@ export async function getWorkItemDetails( baseSha: metadata.baseSha, pullRequestId: viewedStates?.pullRequestId, checks, - // Why: null (failed fetch) vs empty PR — Files tab shows retry, not "No files changed." files: files === null ? undefined : mergePRFileViewedStates(files, viewedStates), filesUnavailable: files === null, participants: mentionParticipants } } -// Why: Monaco DiffViewer needs original/modified text (not patches); --cache bounds rate-limit spend on rapid file-expands. -async function fetchContentAtRef(args: { - repoPath: string - connectionId?: string | null - localGitOptions?: LocalGitExecOptions - ownerRepo: GitHubApiRepository - path: string - ref: string -}): Promise<{ content: string; isBinary: boolean; tooLarge?: boolean }> { - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions)), - ...githubHostExecOptions(args.ownerRepo), - maxBuffer: GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES - } - if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { - return { content: '', isBinary: false } - } - try { - noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--cache', - '300s', - '-H', - 'Accept: application/vnd.github.raw', - `repos/${args.ownerRepo.owner}/${args.ownerRepo.repo}/contents/${encodeGitHubContentPath(args.path)}?ref=${encodeURIComponent(args.ref)}` - ], - ghOptions - ) - // Heuristic: a NUL byte in the first 2KB means binary (execFile decodes as lossy utf-8). - const sample = stdout.slice(0, 2048) - if (sample.includes('\u0000')) { - return { content: '', isBinary: true } - } - return { content: stdout, isBinary: false } - } catch (error) { - if (isMaxBufferOverflowError(error)) { - return { content: '', isBinary: false, tooLarge: true } - } - return { content: '', isBinary: false } - } -} - export async function getPRFileContents(args: { repoPath: string connectionId?: string | null @@ -1256,67 +225,5 @@ export async function getPRFileContents(args: { headSha: string baseSha: string }): Promise { - const { ownerRepo } = await resolveGitHubRepoExecution( - args.repoPath, - args.prRepo, - args.connectionId, - args.localGitOptions - ) - if (!ownerRepo) { - return { - original: '', - modified: '', - originalIsBinary: false, - modifiedIsBinary: false - } - } - - await acquire() - try { - // Why: added files have no base-ref original, removed files no head-ref modified; skip those to avoid spurious 404s. - const needsOriginal = args.status !== 'added' - const needsModified = args.status !== 'removed' - const originalRef = args.baseSha - const originalPath = args.oldPath ?? args.path - - const [original, modified] = await Promise.all([ - needsOriginal - ? fetchContentAtRef({ - repoPath: args.repoPath, - connectionId: args.connectionId, - localGitOptions: args.localGitOptions, - ownerRepo, - path: originalPath, - ref: originalRef - }) - : Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({ - content: '', - isBinary: false - }), - needsModified - ? fetchContentAtRef({ - repoPath: args.repoPath, - connectionId: args.connectionId, - localGitOptions: args.localGitOptions, - ownerRepo, - path: args.path, - ref: args.headSha - }) - : Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({ - content: '', - isBinary: false - }) - ]) - - return { - original: original.content, - modified: modified.content, - originalIsBinary: original.isBinary, - modifiedIsBinary: modified.isBinary, - originalTooLarge: original.tooLarge, - modifiedTooLarge: modified.tooLarge - } - } finally { - release() - } + return loadPRFileContents(args) } diff --git a/src/main/github/work-item-participants.ts b/src/main/github/work-item-participants.ts new file mode 100644 index 00000000000..0ac793b4812 --- /dev/null +++ b/src/main/github/work-item-participants.ts @@ -0,0 +1,241 @@ +import type { PRComment } from '../../shared/github/comment-types' +import type { GitHubAssignableUser } from '../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../shared/github/work-item-types' +import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from './gh-utils' +import type { LocalGitExecOptions } from './gh-utils' +import { githubHostExecOptions, type GitHubApiRepository } from './github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const WORK_ITEM_PARTICIPANTS_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $isPr: Boolean!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) @include(if: $isPr) { + participants(first: 100) { + nodes { login avatarUrl(size: 48) ... on User { name } } + } + } + issue(number: $number) @skip(if: $isPr) { + participants(first: 100) { + nodes { login avatarUrl(size: 48) ... on User { name } } + } + } + } +}` + +function mergeGitHubUsers(users: GitHubAssignableUser[]): GitHubAssignableUser[] { + const byLogin = new Map() + for (const user of users) { + if (!user.login) { + continue + } + const key = user.login.toLowerCase() + const existing = byLogin.get(key) + if (existing) { + byLogin.set(key, { + login: existing.login, + name: existing.name ?? user.name ?? null, + avatarUrl: existing.avatarUrl || user.avatarUrl || '' + }) + continue + } + byLogin.set(key, { + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + }) + } + return Array.from(byLogin.values()) +} + +export async function getWorkItemParticipants( + repoPath: string, + item: Pick, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (!repository) { + return [] + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + return [] + } + try { + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${WORK_ITEM_PARTICIPANTS_QUERY}`, + '-f', + `owner=${repository.owner}`, + '-f', + `repo=${repository.repo}`, + '-F', + `number=${item.number}`, + '-F', + `isPr=${item.type === 'pr'}` + ], + ghOptions + ) + const data = JSON.parse(stdout) as { + data?: { + repository?: { + pullRequest?: { participants?: { nodes?: GitHubAssignableUser[] } } | null + issue?: { participants?: { nodes?: GitHubAssignableUser[] } } | null + } + } + } + const nodes = + data.data?.repository?.pullRequest?.participants?.nodes ?? + data.data?.repository?.issue?.participants?.nodes ?? + [] + return nodes + .map((user) => ({ + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + })) + .filter((user) => user.login) + } catch { + return [] + } +} + +async function getGitHubUsersByLogin( + repoPath: string, + logins: string[], + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (!repository) { + return [] + } + const uniqueLogins = Array.from( + new Set(logins.filter((login) => login && login !== 'ghost').map((login) => login.trim())) + ).slice(0, 40) + if (uniqueLogins.length === 0) { + return [] + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + console.warn( + `getGitHubUsersByLogin skipped: GraphQL rate-limit budget exhausted (${uniqueLogins.length} logins unresolved)` + ) + return [] + } + const fields = uniqueLogins + .map( + (login, index) => + `u${index}: user(login: ${JSON.stringify(login)}) { login name avatarUrl(size: 48) }` + ) + .join('\n') + try { + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + ['api', 'graphql', '-f', `query=query { ${fields} }`], + ghOptions + ) + const data = JSON.parse(stdout) as { + data?: Record< + string, + { login?: string; name?: string | null; avatarUrl?: string | null } | null + > + } + return Object.values(data.data ?? {}) + .filter((user): user is { login: string; name?: string | null; avatarUrl?: string | null } => + Boolean(user?.login) + ) + .map((user) => ({ + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + })) + } catch { + return [] + } +} + +export async function getMentionParticipants( + repoPath: string, + item: Pick< + GitHubWorkItem, + 'author' | 'number' | 'type' | 'reviewRequests' | 'latestReviews' | 'assignees' + >, + comments: PRComment[], + participants: GitHubAssignableUser[], + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const visibleLogins = [ + item.author ?? '', + ...(item.reviewRequests ?? []).map((user) => user.login), + ...(item.latestReviews ?? []).map((review) => review.login), + ...(item.assignees ?? []).map((user) => user.login), + ...comments.map((comment) => comment.author) + ] + const graphQlUsers = await getGitHubUsersByLogin( + repoPath, + visibleLogins, + repository, + connectionId, + localGitOptions + ) + return mergeGitHubUsers([...participants, ...graphQlUsers]) +} + +export function enrichItemDisplayAvatars( + item: Omit, + knownUsers: GitHubAssignableUser[] +): Omit { + const avatarByLogin = new Map() + for (const user of knownUsers) { + if (user.login && user.avatarUrl) { + avatarByLogin.set(user.login.toLowerCase(), user.avatarUrl) + } + } + if (avatarByLogin.size === 0) { + return item + } + const avatarFor = (login: string): string | undefined => avatarByLogin.get(login.toLowerCase()) + const resolvedAvatar = (login: string, existing?: string | null): string | undefined => + avatarFor(login) || existing || undefined + const authorAvatarUrl = (item.author ? avatarFor(item.author) : undefined) || item.authorAvatarUrl + return { + ...item, + ...(authorAvatarUrl ? { authorAvatarUrl } : {}), + ...(item.reviewRequests + ? { + reviewRequests: item.reviewRequests.map((user) => ({ + ...user, + avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' + })) + } + : {}), + ...(item.latestReviews + ? { + latestReviews: item.latestReviews.map((review) => ({ + ...review, + avatarUrl: resolvedAvatar(review.login, review.avatarUrl) ?? null + })) + } + : {}), + ...(item.assignees + ? { + assignees: item.assignees.map((user) => ({ + ...user, + avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' + })) + } + : {}) + } +} diff --git a/src/main/ipc/github-account-handlers.ts b/src/main/ipc/github-account-handlers.ts new file mode 100644 index 00000000000..b5d91b6757a --- /dev/null +++ b/src/main/ipc/github-account-handlers.ts @@ -0,0 +1,31 @@ +import { ipcMain } from 'electron' +import { appStarSourceSchema } from '../../shared/gh-star-source' +import { diagnoseGhAuth } from '../github/auth-diagnose' +import { checkOrcaStarred, getAuthenticatedViewer, starOrca } from '../github/client' +import { getRateLimit } from '../github/rate-limit' +import { getCohortAtEmit } from '../telemetry/cohort-classifier' +import { track } from '../telemetry/client' + +export function registerGitHubAccountHandlers(): void { + ipcMain.handle('gh:viewer', () => getAuthenticatedViewer()) + ipcMain.handle('gh:checkOrcaStarred', () => checkOrcaStarred()) + ipcMain.handle('gh:starOrca', async (_event, source: unknown) => { + const sourceParse = appStarSourceSchema.safeParse(source) + const starred = await starOrca() + if (starred && sourceParse.success) { + track('app_starred_orca', { + source: sourceParse.data, + ...getCohortAtEmit() + }) + } + return starred + }) + + ipcMain.handle('gh:rateLimit', (_event, args?: { force?: boolean }) => + getRateLimit(args?.force ? { force: true } : undefined) + ) + + ipcMain.handle('gh:diagnoseAuth', (_event, args?: { host?: string }) => + diagnoseGhAuth(args?.host) + ) +} diff --git a/src/main/ipc/github-ipc-channel-parity.test.ts b/src/main/ipc/github-ipc-channel-parity.test.ts new file mode 100644 index 00000000000..a54b7cfbf19 --- /dev/null +++ b/src/main/ipc/github-ipc-channel-parity.test.ts @@ -0,0 +1,100 @@ +import { readFileSync } from 'node:fs' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = await vi.hoisted(async () => { + const { createGitHubIpcMocks } = await import('./github-ipc-module-mocks') + return createGitHubIpcMocks() +}) + +vi.mock('electron', () => mocks.electron) +vi.mock('../github/client', () => mocks.client) +vi.mock('../github/work-item-details', () => mocks.workItemDetails) +vi.mock('../github/pr-refresh-coordinator', () => mocks.prRefresh) +vi.mock('../telemetry/client', () => mocks.telemetry) +vi.mock('../telemetry/cohort-classifier', () => mocks.cohort) +vi.mock('./ui', () => mocks.ui) + +import * as github from './github' +import { createGitHubIpcHarness } from './github-ipc-test-harness' + +const EXPECTED_GITHUB_IPC_CHANNELS = [ + 'gh:prForBranch', + 'gh:refreshPRNow', + 'gh:enqueuePRRefresh', + 'gh:reportVisiblePRRefreshCandidates', + 'gh:issue', + 'gh:listIssues', + 'gh:createIssue', + 'gh:listWorkItems', + 'gh:countWorkItems', + 'gh:workItem', + 'gh:workItemByOwnerRepo', + 'gh:workItemDetails', + 'gh:notifyWorkItemMutated', + 'gh:prFileContents', + 'gh:repoSlug', + 'gh:repoUpstream', + 'gh:prChecks', + 'gh:prCheckDetails', + 'gh:prComments', + 'gh:setPRCommentReaction', + 'gh:resolveReviewThread', + 'gh:setPRFileViewed', + 'gh:addPRReviewCommentReply', + 'gh:addPRReviewComment', + 'gh:updatePRTitle', + 'gh:mergePR', + 'gh:setPRAutoMerge', + 'gh:updatePRState', + 'gh:rerunPRChecks', + 'gh:requestPRReviewers', + 'gh:removePRReviewers', + 'gh:updateIssue', + 'gh:addIssueComment', + 'gh:listLabels', + 'gh:listAssignableUsers', + 'gh:viewer', + 'gh:checkOrcaStarred', + 'gh:starOrca', + 'gh:rateLimit', + 'gh:diagnoseAuth', + 'gh:listAccessibleProjects', + 'gh:resolveProjectRef', + 'gh:listProjectViews', + 'gh:getProjectViewTable', + 'gh:projectWorkItemDetailsBySlug', + 'gh:updateProjectItemField', + 'gh:clearProjectItemField', + 'gh:updateIssueBySlug', + 'gh:updatePullRequestBySlug', + 'gh:addIssueCommentBySlug', + 'gh:updateIssueCommentBySlug', + 'gh:deleteIssueCommentBySlug', + 'gh:listLabelsBySlug', + 'gh:listAssignableUsersBySlug', + 'gh:listIssueTypesBySlug', + 'gh:updateIssueTypeBySlug' +] as const + +describe('GitHub IPC channel parity', () => { + const harness = createGitHubIpcHarness(mocks) + + beforeEach(harness.reset) + + it('preserves the facade and fixed IPC channel contract', () => { + github.registerGitHubHandlers(harness.store as never, harness.stats as never) + + const preloadSource = readFileSync(new URL('../../preload/index.ts', import.meta.url), 'utf8') + const exposedChannels = [...preloadSource.matchAll(/ipcRenderer\.invoke\('(gh:[^']+)'/g)].map( + (match) => match[1] + ) + const registeredChannels = mocks.electron.ipcMain.handle.mock.calls.map( + ([channel]) => channel as string + ) + + expect(Object.keys(github)).toEqual(['registerGitHubHandlers']) + expect(new Set(registeredChannels).size).toBe(registeredChannels.length) + expect(registeredChannels).toEqual(EXPECTED_GITHUB_IPC_CHANNELS) + expect(registeredChannels.toSorted()).toEqual([...new Set(exposedChannels)].toSorted()) + }) +}) diff --git a/src/main/ipc/github-issue-mutation-handlers.ts b/src/main/ipc/github-issue-mutation-handlers.ts new file mode 100644 index 00000000000..992f0c6e11f --- /dev/null +++ b/src/main/ipc/github-issue-mutation-handlers.ts @@ -0,0 +1,106 @@ +import { ipcMain } from 'electron' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { GitHubIssueUpdate } from '../../shared/issue-mutation-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { addIssueComment, listAssignableUsers, listLabels, updateIssue } from '../github/client' +import type { Store } from '../persistence' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId, + type GitHubRepoScopedArgs +} from './github-repo-routing' +import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-events' + +export function registerGitHubIssueMutationHandlers(store: Store): void { + ipcMain.handle( + 'gh:updateIssue', + async (event, args: GitHubRepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => { + const repo = assertRegisteredGitHubRepo(args, store) + if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { + return { ok: false, error: 'Invalid issue number' } + } + if (!args.updates || typeof args.updates !== 'object') { + return { ok: false, error: 'Updates object is required' } + } + const result = await updateIssue( + repo.path, + args.number, + args.updates, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + if (result.ok) { + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: 'issue', number: args.number }, + event.sender.id + ) + } + return result + } + ) + + ipcMain.handle( + 'gh:addIssueComment', + async ( + event, + args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + body: string + type?: 'issue' | 'pr' + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { + return { ok: false, error: 'Invalid issue number' } + } + if (!args.body?.trim()) { + return { ok: false, error: 'Comment body required' } + } + const result = await addIssueComment( + repo.path, + args.number, + args.body.trim(), + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + if (result.ok) { + broadcastGitHubWorkItemMutation( + { + repoPath: repo.path, + repoId: repo.id, + type: args.type ?? 'issue', + number: args.number + }, + event.sender.id + ) + } + return result + } + ) + + ipcMain.handle('gh:listLabels', (_event, args: GitHubRepoScopedArgs) => { + const repo = assertRegisteredGitHubRepo(args, store) + return listLabels( + repo.path, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + }) + + ipcMain.handle('gh:listAssignableUsers', (_event, args: GitHubRepoScopedArgs) => { + const repo = assertRegisteredGitHubRepo(args, store) + return listAssignableUsers( + repo.path, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + }) +} diff --git a/src/main/ipc/github-pr-mutation-handlers.ts b/src/main/ipc/github-pr-mutation-handlers.ts new file mode 100644 index 00000000000..1d3aecc38e3 --- /dev/null +++ b/src/main/ipc/github-pr-mutation-handlers.ts @@ -0,0 +1,212 @@ +import { ipcMain } from 'electron' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { GitHubPullRequestStateUpdate } from '../../shared/issue-mutation-types' +import { + mergePR, + removePRReviewers, + requestPRReviewers, + rerunPRChecks, + setPRAutoMerge, + updatePRState, + updatePRTitle +} from '../github/client' +import type { Store } from '../persistence' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId, + type GitHubRepoScopedArgs +} from './github-repo-routing' +import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-events' + +function broadcastSuccessfulPRMutation( + ok: boolean, + repoPath: string, + repoId: string, + prNumber: number, + senderId: number +): void { + if (ok) { + broadcastGitHubWorkItemMutation({ repoPath, repoId, type: 'pr', number: prNumber }, senderId) + } +} + +export function registerGitHubPRMutationHandlers(store: Store): void { + ipcMain.handle( + 'gh:updatePRTitle', + async ( + event, + args: { repoPath: string; prNumber: number; title: string; prRepo?: GitHubOwnerRepo | null } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const ok = await updatePRTitle( + repo.path, + args.prNumber, + args.title, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(ok, repo.path, repo.id, args.prNumber, event.sender.id) + return ok + } + ) + + ipcMain.handle( + 'gh:mergePR', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + method?: 'merge' | 'squash' | 'rebase' + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const result = await mergePR( + repo.path, + args.prNumber, + args.method, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) + + ipcMain.handle( + 'gh:setPRAutoMerge', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + enabled: boolean + method?: 'merge' | 'squash' | 'rebase' + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const result = await setPRAutoMerge( + repo.path, + args.prNumber, + args.enabled, + args.method, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) + + ipcMain.handle( + 'gh:updatePRState', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + updates: GitHubPullRequestStateUpdate + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid pull request number' } + } + const result = await updatePRState( + repo.path, + args.prNumber, + args.updates, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) + + ipcMain.handle( + 'gh:rerunPRChecks', + async ( + _event, + args: GitHubRepoScopedArgs & { + prNumber: number + headSha?: string + failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid pull request number' } + } + return rerunPRChecks( + repo.path, + args.prNumber, + { headSha: args.headSha, failedOnly: args.failedOnly, prRepo: args.prRepo ?? null }, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:requestPRReviewers', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const result = await requestPRReviewers( + repo.path, + args.prNumber, + args.reviewers, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) + + ipcMain.handle( + 'gh:removePRReviewers', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const result = await removePRReviewers( + repo.path, + args.prNumber, + args.reviewers, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) +} diff --git a/src/main/ipc/github-pr-read-handlers.ts b/src/main/ipc/github-pr-read-handlers.ts new file mode 100644 index 00000000000..1c200218f91 --- /dev/null +++ b/src/main/ipc/github-pr-read-handlers.ts @@ -0,0 +1,178 @@ +import { ipcMain } from 'electron' +import type { GitHubReactionContent } from '../../shared/github/comment-types' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { + getPRCheckDetails, + getPRChecks, + getPRComments, + getRepoSlug, + getRepoUpstream, + resolveReviewThread, + setPRCommentReaction +} from '../github/client' +import type { Store } from '../persistence' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId +} from './github-repo-routing' + +export function registerGitHubPRReadHandlers(store: Store): void { + ipcMain.handle('gh:repoSlug', (_event, args: { repoPath: string }) => { + const repo = assertRegisteredGitHubRepo(args, store) + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] + return localGitOptions + ? getRepoSlug(repo.path, getGitHubRepoConnectionId(repo), { + localGitExecOptions: localGitOptions + }) + : getRepoSlug(repo.path, getGitHubRepoConnectionId(repo)) + }) + + ipcMain.handle('gh:repoUpstream', (_event, args: { repoPath: string }) => { + const repo = assertRegisteredGitHubRepo(args, store) + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] + return localGitOptions + ? getRepoUpstream(repo.path, getGitHubRepoConnectionId(repo), { + localGitExecOptions: localGitOptions + }) + : getRepoUpstream(repo.path, getGitHubRepoConnectionId(repo)) + }) + + ipcMain.handle( + 'gh:prChecks', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + headSha?: string + prRepo?: GitHubOwnerRepo | null + noCache?: boolean + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getPRChecks( + repo.path, + args.prNumber, + args.headSha, + args.prRepo ?? null, + { noCache: args.noCache }, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:prCheckDetails', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + checkRunId?: number + workflowRunId?: number + checkName?: string + url?: string | null + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getPRCheckDetails( + repo.path, + { + checkRunId: args.checkRunId, + workflowRunId: args.workflowRunId, + checkName: args.checkName, + url: args.url, + prRepo: args.prRepo ?? null + }, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:prComments', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + noCache?: boolean + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getPRComments( + repo.path, + args.prNumber, + { noCache: args.noCache, prRepo: args.prRepo ?? null }, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:setPRCommentReaction', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if (!args.reactionSubjectId?.trim()) { + return false + } + return setPRCommentReaction( + repo.path, + args.reactionSubjectId.trim(), + args.content, + args.reacted, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:resolveReviewThread', + async ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + threadId: string + resolve: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return resolveReviewThread( + repo.path, + args.threadId, + args.resolve, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) +} diff --git a/src/main/ipc/github-pr-refresh-handlers.ts b/src/main/ipc/github-pr-refresh-handlers.ts new file mode 100644 index 00000000000..ab4dc74ec7d --- /dev/null +++ b/src/main/ipc/github-pr-refresh-handlers.ts @@ -0,0 +1,161 @@ +import { ipcMain } from 'electron' +import { resolve } from 'node:path' +import type { + GitHubPRRefreshCandidate, + GitHubPRRefreshEnqueueResult, + GitHubPRRefreshReason, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import type { Repo } from '../../shared/repo-types' +import { getPRForBranch, type GitHubPRBranchLookupOptions } from '../github/client' +import { + clearVisiblePRRefreshWindow, + enqueuePRRefresh, + refreshPRNow, + reportVisiblePRRefreshCandidates, + setPRRefreshOutcomeObserver +} from '../github/pr-refresh-coordinator' +import type { Store } from '../persistence' +import type { StatsCollector } from '../stats/collector' +import { + applyRegisteredRepoToPRRefreshCandidate, + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId, + validateAutomaticPRRefreshCandidate +} from './github-repo-routing' + +const visibilityCleanupRegistered = new Set() + +export function registerGitHubPRRefreshHandlers(store: Store, stats: StatsCollector): void { + function recordPRIfNeeded(repo: Repo, outcome: PRRefreshOutcome): void { + if (outcome.kind === 'found' && !stats.hasCountedPR(outcome.pr.url)) { + stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: outcome.pr.number, prUrl: outcome.pr.url } + }) + } + } + + setPRRefreshOutcomeObserver((candidate, outcome) => { + const repo = + store.getRepos().find((entry) => entry.id === candidate.repoId) ?? + store.getRepos().find((entry) => resolve(entry.path) === resolve(candidate.repoPath)) + if (repo) { + recordPRIfNeeded(repo, outcome) + } + }) + + ipcMain.handle( + 'gh:prForBranch', + async ( + _event, + args: { + repoPath: string + branch: string + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + acceptMergedFallbackPR?: boolean + currentHeadOid?: string | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] + const hostedReviewOptions = localGitOptions + ? { localGitExecOptions: localGitOptions } + : undefined + const currentHeadOid = + typeof args.currentHeadOid === 'string' && args.currentHeadOid.trim().length > 0 + ? args.currentHeadOid.trim() + : null + const lookupOptions: GitHubPRBranchLookupOptions | undefined = hostedReviewOptions + ? { ...hostedReviewOptions } + : args.acceptMergedFallbackPR === true || currentHeadOid !== null + ? {} + : undefined + if (lookupOptions && args.acceptMergedFallbackPR === true) { + lookupOptions.acceptMergedFallbackPR = true + } + if (lookupOptions && currentHeadOid !== null) { + lookupOptions.currentHeadOid = currentHeadOid + } + const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = lookupOptions + ? [lookupOptions] + : [] + const pr = await getPRForBranch( + repo.path, + args.branch, + args.linkedPRNumber ?? null, + getGitHubRepoConnectionId(repo), + args.linkedPRNumber == null ? (args.fallbackPRNumber ?? null) : null, + ...lookupOptionArgs + ) + if (pr && !stats.hasCountedPR(pr.url)) { + stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: pr.number, prUrl: pr.url } + }) + } + return pr + } + ) + + ipcMain.handle( + 'gh:refreshPRNow', + async (_event, args: { candidate: GitHubPRRefreshCandidate }) => { + const repo = assertRegisteredGitHubRepo(args.candidate, store) + const outcome = await refreshPRNow( + applyRegisteredRepoToPRRefreshCandidate(store, repo, args.candidate) + ) + recordPRIfNeeded(repo, outcome) + return outcome + } + ) + + ipcMain.handle( + 'gh:enqueuePRRefresh', + ( + event, + args: { + candidate: GitHubPRRefreshCandidate + reason: GitHubPRRefreshReason + priority?: number + } + ): GitHubPRRefreshEnqueueResult => { + const validation = validateAutomaticPRRefreshCandidate(args.candidate, store) + if (validation.kind === 'skipped') { + return validation.result + } + enqueuePRRefresh(validation.candidate, args.reason, args.priority ?? 0, event?.sender?.id) + return { kind: 'queued' } + } + ) + + ipcMain.handle( + 'gh:reportVisiblePRRefreshCandidates', + (event, args: { candidates: GitHubPRRefreshCandidate[]; generation: number }) => { + const senderId = event.sender.id + if (!visibilityCleanupRegistered.has(senderId)) { + visibilityCleanupRegistered.add(senderId) + event.sender.once('destroyed', () => { + visibilityCleanupRegistered.delete(senderId) + clearVisiblePRRefreshWindow(senderId) + }) + } + const candidates: GitHubPRRefreshCandidate[] = [] + const repos = store.getRepos() + for (const candidate of args.candidates) { + const validation = validateAutomaticPRRefreshCandidate(candidate, store, repos) + if (validation.kind === 'ok') { + candidates.push(validation.candidate) + } + } + reportVisiblePRRefreshCandidates(candidates, args.generation, senderId) + return true + } + ) +} diff --git a/src/main/ipc/github-pr-review-handlers.ts b/src/main/ipc/github-pr-review-handlers.ts new file mode 100644 index 00000000000..637eb74c1de --- /dev/null +++ b/src/main/ipc/github-pr-review-handlers.ts @@ -0,0 +1,180 @@ +import { ipcMain } from 'electron' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { addPRReviewComment, addPRReviewCommentReply, setPRFileViewed } from '../github/client' +import type { Store } from '../persistence' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId +} from './github-repo-routing' +import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-events' + +export function registerGitHubPRReviewHandlers(store: Store): void { + ipcMain.handle( + 'gh:setPRFileViewed', + async ( + event, + args: { + repoPath: string + repoId?: string + prNumber: number + prRepo?: GitHubOwnerRepo | null + pullRequestId: string + path: string + viewed: boolean + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return false + } + if (!args.pullRequestId?.trim() || !args.path?.trim()) { + return false + } + const ok = await setPRFileViewed({ + repoPath: repo.path, + connectionId: getGitHubRepoConnectionId(repo), + localGitOptions: getGitHubLocalGitOptionArgs(store, repo)[0], + prRepo: args.prRepo ?? null, + pullRequestId: args.pullRequestId.trim(), + path: args.path, + viewed: Boolean(args.viewed) + }) + if (ok) { + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return ok + } + ) + + ipcMain.handle( + 'gh:addPRReviewCommentReply', + async ( + event, + args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + commentId: number + body: string + threadId?: string + path?: string + line?: number + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid PR number' } + } + if ( + typeof args.commentId !== 'number' || + !Number.isInteger(args.commentId) || + args.commentId < 1 + ) { + return { ok: false, error: 'Invalid comment ID' } + } + if (!args.body?.trim()) { + return { ok: false, error: 'Comment body required' } + } + const result = await addPRReviewCommentReply( + repo.path, + args.prNumber, + args.commentId, + args.body.trim(), + args.threadId, + args.path, + args.line, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + if (result.ok) { + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return result + } + ) + + ipcMain.handle( + 'gh:addPRReviewComment', + async ( + event, + args: { + repoPath: string + prNumber: number + prRepo?: GitHubOwnerRepo | null + commitId: string + path: string + line: number + startLine?: number + body: string + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid PR number' } + } + if (typeof args.line !== 'number' || !Number.isInteger(args.line) || args.line < 1) { + return { ok: false, error: 'Invalid line number' } + } + if ( + args.startLine !== undefined && + (typeof args.startLine !== 'number' || + !Number.isInteger(args.startLine) || + args.startLine < 1 || + args.startLine > args.line) + ) { + return { ok: false, error: 'Invalid start line' } + } + if (!args.commitId?.trim()) { + return { ok: false, error: 'Missing PR head SHA' } + } + if (!args.path?.trim()) { + return { ok: false, error: 'File path required' } + } + if (!args.body?.trim()) { + return { ok: false, error: 'Comment body required' } + } + const result = await addPRReviewComment({ + repoPath: repo.path, + prRepo: args.prRepo ?? null, + prNumber: args.prNumber, + commitId: args.commitId.trim(), + path: args.path, + line: args.line, + startLine: args.startLine, + body: args.body.trim(), + connectionId: getGitHubRepoConnectionId(repo), + localGitOptions: getGitHubLocalGitOptionArgs(store, repo)[0] + }) + if (result.ok) { + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return result + } + ) +} diff --git a/src/main/ipc/github-project-view-handlers.ts b/src/main/ipc/github-project-view-handlers.ts new file mode 100644 index 00000000000..0106bd80bc7 --- /dev/null +++ b/src/main/ipc/github-project-view-handlers.ts @@ -0,0 +1,89 @@ +import { ipcMain } from 'electron' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GetProjectViewTableArgs, + ListAccessibleProjectsArgs, + ListAssignableUsersBySlugArgs, + ListIssueTypesBySlugArgs, + ListLabelsBySlugArgs, + ListProjectViewsArgs, + ProjectWorkItemDetailsBySlugArgs, + ResolveProjectRefArgs, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdateProjectItemFieldArgs, + UpdatePullRequestBySlugArgs +} from '../../shared/github/project-request-types' +import { + addIssueCommentBySlug, + clearProjectItemFieldValue, + deleteIssueCommentBySlug, + getProjectViewTable, + getWorkItemDetailsBySlug, + listAccessibleProjects, + listAssignableUsersBySlug, + listIssueTypesBySlug, + listLabelsBySlug, + listProjectViews, + resolveProjectRef, + updateIssueBySlug, + updateIssueCommentBySlug, + updateIssueTypeBySlug, + updateProjectItemFieldValue, + updatePullRequestBySlug +} from '../github/project-view' + +export function registerGitHubProjectViewHandlers(): void { + ipcMain.handle('gh:listAccessibleProjects', (_event, args?: ListAccessibleProjectsArgs) => + listAccessibleProjects(args) + ) + ipcMain.handle('gh:resolveProjectRef', (_event, args: ResolveProjectRefArgs) => + resolveProjectRef(args) + ) + ipcMain.handle('gh:listProjectViews', (_event, args: ListProjectViewsArgs) => + listProjectViews(args) + ) + ipcMain.handle('gh:getProjectViewTable', (_event, args: GetProjectViewTableArgs) => + getProjectViewTable(args) + ) + ipcMain.handle( + 'gh:projectWorkItemDetailsBySlug', + (_event, args: ProjectWorkItemDetailsBySlugArgs) => getWorkItemDetailsBySlug(args) + ) + ipcMain.handle('gh:updateProjectItemField', (_event, args: UpdateProjectItemFieldArgs) => + updateProjectItemFieldValue(args) + ) + ipcMain.handle('gh:clearProjectItemField', (_event, args: ClearProjectItemFieldArgs) => + clearProjectItemFieldValue(args) + ) + ipcMain.handle('gh:updateIssueBySlug', (_event, args: UpdateIssueBySlugArgs) => + updateIssueBySlug(args) + ) + ipcMain.handle('gh:updatePullRequestBySlug', (_event, args: UpdatePullRequestBySlugArgs) => + updatePullRequestBySlug(args) + ) + ipcMain.handle('gh:addIssueCommentBySlug', (_event, args: AddIssueCommentBySlugArgs) => + addIssueCommentBySlug(args) + ) + ipcMain.handle('gh:updateIssueCommentBySlug', (_event, args: UpdateIssueCommentBySlugArgs) => + updateIssueCommentBySlug(args) + ) + ipcMain.handle('gh:deleteIssueCommentBySlug', (_event, args: DeleteIssueCommentBySlugArgs) => + deleteIssueCommentBySlug(args) + ) + ipcMain.handle('gh:listLabelsBySlug', (_event, args: ListLabelsBySlugArgs) => + listLabelsBySlug(args) + ) + ipcMain.handle('gh:listAssignableUsersBySlug', (_event, args: ListAssignableUsersBySlugArgs) => + listAssignableUsersBySlug(args) + ) + ipcMain.handle('gh:listIssueTypesBySlug', (_event, args: ListIssueTypesBySlugArgs) => + listIssueTypesBySlug(args) + ) + ipcMain.handle('gh:updateIssueTypeBySlug', (_event, args: UpdateIssueTypeBySlugArgs) => + updateIssueTypeBySlug(args) + ) +} diff --git a/src/main/ipc/github-repo-routing.ts b/src/main/ipc/github-repo-routing.ts new file mode 100644 index 00000000000..a7f93c2b44f --- /dev/null +++ b/src/main/ipc/github-repo-routing.ts @@ -0,0 +1,130 @@ +import { resolve } from 'node:path' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import type { + GitHubPRRefreshCandidate, + GitHubPRRefreshEnqueueResult +} from '../../shared/github/pull-request-refresh-types' +import type { Repo } from '../../shared/repo-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { + notePRRefreshValidationDenial, + type PRRefreshValidationDenialReason +} from '../github/pr-refresh-validation-backoff' +import type { Store } from '../persistence' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' + +export type GitHubRepoScopedArgs = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} + +type RegisteredRepoValidationResult = + | { kind: 'ok'; repo: Repo } + | { kind: 'denied'; reason: PRRefreshValidationDenialReason; message: string } + +function validateRegisteredRepo( + args: string | GitHubRepoScopedArgs, + store: Store, + repos = store.getRepos() +): RegisteredRepoValidationResult { + const repoPath = typeof args === 'string' ? args : args.repoPath + const repoId = typeof args === 'string' ? undefined : args.repoId + const resolvedRepoPath = resolve(repoPath) + const repo = repos.find((candidate) => + repoId ? candidate.id === repoId : resolve(candidate.path) === resolvedRepoPath + ) + if (!repo) { + return { + kind: 'denied', + reason: 'unknown-repo', + message: 'Access denied: unknown repository path' + } + } + if (repoId && resolve(repo.path) !== resolvedRepoPath) { + return { + kind: 'denied', + reason: 'repo-path-mismatch', + message: 'Access denied: repository path does not match repo id' + } + } + if ( + typeof args !== 'string' && + args.sourceContext?.provider === 'github' && + args.sourceContext.hostId !== getRepoExecutionHostId(repo) + ) { + return { + kind: 'denied', + reason: 'host-mismatch', + message: 'Access denied: GitHub source host does not match repository host' + } + } + return { kind: 'ok', repo } +} + +export function assertRegisteredGitHubRepo( + args: string | GitHubRepoScopedArgs, + store: Store +): Repo { + const result = validateRegisteredRepo(args, store) + if (result.kind === 'denied') { + throw new Error(result.message) + } + return result.repo +} + +export function getGitHubRepoConnectionId(repo: Repo): string | null { + return repo.connectionId ?? null +} + +export function getGitHubLocalGitOptionArgs( + store: Store, + repo: Repo +): [] | [{ wslDistro?: string }] { + const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] +} + +export function applyRegisteredRepoToPRRefreshCandidate( + store: Store, + repo: Repo, + candidate: GitHubPRRefreshCandidate +): GitHubPRRefreshCandidate { + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] + const appliedCandidate = { ...candidate } + delete appliedCandidate.localGitOptions + delete appliedCandidate.connectionId + delete appliedCandidate.executionHostId + delete appliedCandidate.connectionState + return { + ...appliedCandidate, + repoPath: repo.path, + repoId: repo.id, + ...(localGitOptions ? { localGitOptions } : {}), + connectionId: getGitHubRepoConnectionId(repo), + executionHostId: repo.executionHostId ?? null, + connectionState: repo.connectionId ? 'connected' : 'unknown' + } +} + +export function validateAutomaticPRRefreshCandidate( + candidate: GitHubPRRefreshCandidate, + store: Store, + repos = store.getRepos() +): + | { kind: 'ok'; candidate: GitHubPRRefreshCandidate } + | { kind: 'skipped'; result: Extract } { + const result = validateRegisteredRepo(candidate, store, repos) + if (result.kind === 'denied') { + const skippedReason = notePRRefreshValidationDenial({ + repoId: candidate.repoId, + repoPath: candidate.repoPath, + reason: result.reason + }) + return { kind: 'skipped', result: { kind: 'skipped', skippedReason } } + } + return { + kind: 'ok', + candidate: applyRegisteredRepoToPRRefreshCandidate(store, result.repo, candidate) + } +} diff --git a/src/main/ipc/github-work-item-handlers.ts b/src/main/ipc/github-work-item-handlers.ts new file mode 100644 index 00000000000..26c21b3db81 --- /dev/null +++ b/src/main/ipc/github-work-item-handlers.ts @@ -0,0 +1,214 @@ +import { ipcMain } from 'electron' +import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-request-types' +import type { GitHubCreateIssueFields } from '../../shared/issue-mutation-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { + countWorkItems, + createIssue, + getIssue, + getWorkItem, + getWorkItemByOwnerRepo, + listIssues, + listWorkItems +} from '../github/client' +import { getPRFileContents, getWorkItemDetails } from '../github/work-item-details' +import type { Store } from '../persistence' +import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId, + type GitHubRepoScopedArgs +} from './github-repo-routing' +import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-events' + +export function registerGitHubWorkItemHandlers(store: Store): void { + ipcMain.handle( + 'gh:issue', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + number: number + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getIssue( + repo.path, + args.number, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle('gh:listIssues', (_event, args: { repoPath: string; limit?: number }) => { + const repo = assertRegisteredGitHubRepo(args, store) + return listIssues( + repo.path, + args.limit, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ).then((result) => result.items) + }) + + ipcMain.handle( + 'gh:createIssue', + ( + _event, + args: GitHubRepoScopedArgs & { title: string; body: string } & GitHubCreateIssueFields + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const fields = + args.labels !== undefined || args.assignees !== undefined + ? { labels: args.labels, assignees: args.assignees } + : undefined + return createIssue( + repo.path, + args.title, + args.body, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + fields, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:listWorkItems', + ( + _event, + args: { + repoPath: string + repoId?: string + limit?: number + query?: string + page?: number + noCache?: boolean + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return listWorkItems( + repo.path, + args.limit, + args.query, + args.page, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + args.noCache, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle('gh:countWorkItems', (_event, args: { repoPath: string; query?: string }) => { + const repo = assertRegisteredGitHubRepo(args, store) + return countWorkItems( + repo.path, + args.query, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + }) + + ipcMain.handle('gh:workItem', (_event, args: WorkItemArgs) => { + const repo = assertRegisteredGitHubRepo(args, store) + return dispatchWorkItem(args, repo, getWorkItem, getGitHubLocalGitOptionArgs(store, repo)[0]) + }) + + ipcMain.handle( + 'gh:workItemByOwnerRepo', + ( + _event, + args: { + repoPath: string + owner: string + repo: string + host?: string + number: number + type: 'issue' | 'pr' + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getWorkItemByOwnerRepo( + repo.path, + { owner: args.owner, repo: args.repo, ...(args.host ? { host: args.host } : {}) }, + args.number, + args.type, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => { + const repo = assertRegisteredGitHubRepo(args, store) + return dispatchWorkItem( + args, + repo, + getWorkItemDetails, + getGitHubLocalGitOptionArgs(store, repo)[0] + ) + }) + + ipcMain.handle( + 'gh:notifyWorkItemMutated', + (event, args: { repoPath: string; repoId?: string; type: 'issue' | 'pr'; number: number }) => { + const repo = args.repoId + ? store.getRepos().find((candidate) => candidate.id === args.repoId) + : assertRegisteredGitHubRepo(args, store) + if (!repo) { + return false + } + if ( + (args.type !== 'issue' && args.type !== 'pr') || + typeof args.number !== 'number' || + !Number.isInteger(args.number) || + args.number < 1 + ) { + return false + } + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: args.type, number: args.number }, + event.sender.id + ) + return true + } + ) + + ipcMain.handle( + 'gh:prFileContents', + ( + _event, + args: { + repoPath: string + prNumber: number + prRepo?: GitHubOwnerRepo | null + path: string + oldPath?: string + status: GitHubPRFile['status'] + headSha: string + baseSha: string + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getPRFileContents({ + repoPath: repo.path, + connectionId: getGitHubRepoConnectionId(repo), + localGitOptions: getGitHubLocalGitOptionArgs(store, repo)[0], + prRepo: args.prRepo ?? null, + prNumber: args.prNumber, + path: args.path, + oldPath: args.oldPath, + status: args.status, + headSha: args.headSha, + baseSha: args.baseSha + }) + } + ) +} diff --git a/src/main/ipc/github-work-item-mutation-events.ts b/src/main/ipc/github-work-item-mutation-events.ts new file mode 100644 index 00000000000..5466dd8bdc4 --- /dev/null +++ b/src/main/ipc/github-work-item-mutation-events.ts @@ -0,0 +1,15 @@ +import { sendToTrustedUIRenderer } from './ui' + +export type GitHubWorkItemMutation = { + repoPath: string + repoId?: string + type: 'issue' | 'pr' + number: number +} + +export function broadcastGitHubWorkItemMutation( + payload: GitHubWorkItemMutation, + senderId?: number +): void { + sendToTrustedUIRenderer('gh:workItemMutated', payload, senderId) +} diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 7673aff06ce..668d9fdca79 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -1,1312 +1,21 @@ -/* eslint-disable max-lines -- Why: all GitHub IPC handlers stay co-located so -the repo-path validation, preference-threading, and stats wiring patterns are -reviewable as one surface. Splitting by feature area would risk drifting -validation/gate conventions across handler files. */ -import { ipcMain } from 'electron' -import { resolve } from 'node:path' -import type { GitHubReactionContent } from '../../shared/github/comment-types' -import type { - GitHubPRRefreshCandidate, - GitHubPRRefreshEnqueueResult, - GitHubPRRefreshReason, - PRRefreshOutcome -} from '../../shared/github/pull-request-refresh-types' -import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-request-types' -import type { - GitHubCreateIssueFields, - GitHubIssueUpdate, - GitHubPullRequestStateUpdate -} from '../../shared/issue-mutation-types' -import type { Repo } from '../../shared/repo-types' -import { getRepoExecutionHostId } from '../../shared/execution-host' -import type { TaskSourceContext } from '../../shared/task-source-context' import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' -import { - getPRForBranch, - getIssue, - getRepoSlug, - getRepoUpstream, - listIssues, - listWorkItems, - countWorkItems, - getWorkItem, - getWorkItemByOwnerRepo, - createIssue, - updateIssue, - addIssueComment, - listLabels, - listAssignableUsers, - getAuthenticatedViewer, - getPRChecks, - getPRCheckDetails, - getPRComments, - setPRCommentReaction, - resolveReviewThread, - setPRFileViewed, - addPRReviewComment, - addPRReviewCommentReply, - updatePRTitle, - mergePR, - setPRAutoMerge, - updatePRState, - rerunPRChecks, - requestPRReviewers, - removePRReviewers, - checkOrcaStarred, - starOrca -} from '../github/client' -import type { GitHubPRBranchLookupOptions } from '../github/client' -import { - clearVisiblePRRefreshWindow, - enqueuePRRefresh, - refreshPRNow, - reportVisiblePRRefreshCandidates, - setPRRefreshOutcomeObserver -} from '../github/pr-refresh-coordinator' -import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' -import { getRateLimit } from '../github/rate-limit' -import { diagnoseGhAuth } from '../github/auth-diagnose' -import { - notePRRefreshValidationDenial, - type PRRefreshValidationDenialReason -} from '../github/pr-refresh-validation-backoff' -import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' -import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' -import { - getProjectViewTable, - listAccessibleProjects, - resolveProjectRef, - listProjectViews, - getWorkItemDetailsBySlug, - updateProjectItemFieldValue, - clearProjectItemFieldValue, - updateIssueBySlug, - updatePullRequestBySlug, - addIssueCommentBySlug, - updateIssueCommentBySlug, - deleteIssueCommentBySlug, - listLabelsBySlug, - listAssignableUsersBySlug, - listIssueTypesBySlug, - updateIssueTypeBySlug -} from '../github/project-view' -import type { - AddIssueCommentBySlugArgs, - ClearProjectItemFieldArgs, - DeleteIssueCommentBySlugArgs, - GetProjectViewTableArgs, - ListAccessibleProjectsArgs, - ListAssignableUsersBySlugArgs, - ListIssueTypesBySlugArgs, - ListLabelsBySlugArgs, - ListProjectViewsArgs, - ProjectWorkItemDetailsBySlugArgs, - ResolveProjectRefArgs, - UpdateIssueBySlugArgs, - UpdateIssueCommentBySlugArgs, - UpdateIssueTypeBySlugArgs, - UpdateProjectItemFieldArgs, - UpdatePullRequestBySlugArgs -} from '../../shared/github/project-request-types' -import { appStarSourceSchema } from '../../shared/gh-star-source' -import { track } from '../telemetry/client' -import { getCohortAtEmit } from '../telemetry/cohort-classifier' -import { sendToTrustedUIRenderer } from './ui' - -const prRefreshVisibilityCleanupRegistered = new Set() - -// Why: the app renderer owns the SWR cache; browser guests cannot consume this -// event. Skip the origin because it already updated its cache optimistically. -function broadcastWorkItemMutated( - payload: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number - }, - senderId?: number -): void { - sendToTrustedUIRenderer('gh:workItemMutated', payload, senderId) -} - -// Why: returns the full Repo object instead of just the path string so that -// callers have access to repo.id for stat tracking and other context. -type RepoScopedArgs = { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null -} - -type RegisteredRepoValidationResult = - | { kind: 'ok'; repo: Repo } - | { kind: 'denied'; reason: PRRefreshValidationDenialReason; message: string } - -function validateRegisteredRepo( - args: string | RepoScopedArgs, - store: Store, - repos = store.getRepos() -): RegisteredRepoValidationResult { - const repoPath = typeof args === 'string' ? args : args.repoPath - const repoId = typeof args === 'string' ? undefined : args.repoId - const resolvedRepoPath = resolve(repoPath) - const repo = repos.find((r) => (repoId ? r.id === repoId : resolve(r.path) === resolvedRepoPath)) - if (!repo) { - return { - kind: 'denied', - reason: 'unknown-repo', - message: 'Access denied: unknown repository path' - } - } - if (repoId && resolve(repo.path) !== resolvedRepoPath) { - return { - kind: 'denied', - reason: 'repo-path-mismatch', - message: 'Access denied: repository path does not match repo id' - } - } - if ( - typeof args !== 'string' && - args.sourceContext?.provider === 'github' && - args.sourceContext.hostId !== getRepoExecutionHostId(repo) - ) { - return { - kind: 'denied', - reason: 'host-mismatch', - message: 'Access denied: GitHub source host does not match repository host' - } - } - return { kind: 'ok', repo } -} - -function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo { - const result = validateRegisteredRepo(args, store) - if (result.kind === 'denied') { - throw new Error(result.message) - } - return result.repo -} - -function repoConnectionId(repo: Repo): string | null { - return repo.connectionId ?? null -} - -function localGitOptionArgs(store: Store, repo: Repo): [] | [{ wslDistro?: string }] { - const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) - return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] -} - -function applyRepoToPRRefreshCandidate( - store: Store, - repo: Repo, - candidate: GitHubPRRefreshCandidate -): GitHubPRRefreshCandidate { - const localGitOptions = localGitOptionArgs(store, repo)[0] - const appliedCandidate = { ...candidate } - delete appliedCandidate.localGitOptions - delete appliedCandidate.connectionId - delete appliedCandidate.executionHostId - delete appliedCandidate.connectionState - return { - ...appliedCandidate, - repoPath: repo.path, - repoId: repo.id, - ...(localGitOptions ? { localGitOptions } : {}), - connectionId: repoConnectionId(repo), - executionHostId: repo.executionHostId ?? null, - connectionState: repo.connectionId ? 'connected' : 'unknown' - } -} - -function validateAutomaticPRRefreshCandidate( - candidate: GitHubPRRefreshCandidate, - store: Store, - repos = store.getRepos() -): - | { kind: 'ok'; candidate: GitHubPRRefreshCandidate } - | { - kind: 'skipped' - result: Extract - } { - const result = validateRegisteredRepo(candidate, store, repos) - if (result.kind === 'denied') { - const skippedReason = notePRRefreshValidationDenial({ - repoId: candidate.repoId, - repoPath: candidate.repoPath, - reason: result.reason - }) - return { kind: 'skipped', result: { kind: 'skipped', skippedReason } } - } - return { kind: 'ok', candidate: applyRepoToPRRefreshCandidate(store, result.repo, candidate) } -} +import { registerGitHubAccountHandlers } from './github-account-handlers' +import { registerGitHubIssueMutationHandlers } from './github-issue-mutation-handlers' +import { registerGitHubPRMutationHandlers } from './github-pr-mutation-handlers' +import { registerGitHubPRReadHandlers } from './github-pr-read-handlers' +import { registerGitHubPRRefreshHandlers } from './github-pr-refresh-handlers' +import { registerGitHubPRReviewHandlers } from './github-pr-review-handlers' +import { registerGitHubProjectViewHandlers } from './github-project-view-handlers' +import { registerGitHubWorkItemHandlers } from './github-work-item-handlers' export function registerGitHubHandlers(store: Store, stats: StatsCollector): void { - function recordPRIfNeeded(repo: Repo, outcome: PRRefreshOutcome): void { - if (outcome.kind === 'found' && !stats.hasCountedPR(outcome.pr.url)) { - stats.record({ - type: 'pr_created', - at: Date.now(), - repoId: repo.id, - meta: { prNumber: outcome.pr.number, prUrl: outcome.pr.url } - }) - } - } - - setPRRefreshOutcomeObserver((candidate, outcome) => { - const repo = - store.getRepos().find((r) => r.id === candidate.repoId) ?? - store.getRepos().find((r) => resolve(r.path) === resolve(candidate.repoPath)) - if (repo) { - recordPRIfNeeded(repo, outcome) - } - }) - - ipcMain.handle( - 'gh:prForBranch', - async ( - _event, - args: { - repoPath: string - branch: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - acceptMergedFallbackPR?: boolean - currentHeadOid?: string | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - const hostedReviewOptionArgs: [] | [{ localGitExecOptions: { wslDistro?: string } }] = - localGitOptions ? [{ localGitExecOptions: localGitOptions }] : [] - const currentHeadOid = - typeof args.currentHeadOid === 'string' && args.currentHeadOid.trim().length > 0 - ? args.currentHeadOid.trim() - : null - const lookupOptions: GitHubPRBranchLookupOptions | undefined = hostedReviewOptionArgs[0] - ? { ...hostedReviewOptionArgs[0] } - : args.acceptMergedFallbackPR === true || currentHeadOid !== null - ? {} - : undefined - if (lookupOptions && args.acceptMergedFallbackPR === true) { - lookupOptions.acceptMergedFallbackPR = true - } - if (lookupOptions && currentHeadOid !== null) { - lookupOptions.currentHeadOid = currentHeadOid - } - const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = lookupOptions - ? [lookupOptions] - : [] - const pr = await getPRForBranch( - repo.path, - args.branch, - args.linkedPRNumber ?? null, - repoConnectionId(repo), - args.linkedPRNumber == null ? (args.fallbackPRNumber ?? null) : null, - ...lookupOptionArgs - ) - // Emit pr_created when a PR is first detected for a branch. - // Why here: the renderer polls gh:prForBranch to check PR status per worktree. - // This captures PRs opened from any workflow (Orca UI, gh CLI, github.com). - if (pr && !stats.hasCountedPR(pr.url)) { - stats.record({ - type: 'pr_created', - at: Date.now(), - repoId: repo.id, - meta: { prNumber: pr.number, prUrl: pr.url } - }) - } - return pr - } - ) - - ipcMain.handle( - 'gh:refreshPRNow', - async (_event, args: { candidate: GitHubPRRefreshCandidate }) => { - const repo = assertRegisteredRepo(args.candidate, store) - const outcome = await refreshPRNow(applyRepoToPRRefreshCandidate(store, repo, args.candidate)) - recordPRIfNeeded(repo, outcome) - return outcome - } - ) - - ipcMain.handle( - 'gh:enqueuePRRefresh', - ( - event, - args: { - candidate: GitHubPRRefreshCandidate - reason: GitHubPRRefreshReason - priority?: number - } - ): GitHubPRRefreshEnqueueResult => { - const validation = validateAutomaticPRRefreshCandidate(args.candidate, store) - if (validation.kind === 'skipped') { - return validation.result - } - const senderWindowId = event?.sender?.id - enqueuePRRefresh(validation.candidate, args.reason, args.priority ?? 0, senderWindowId) - return { kind: 'queued' } - } - ) - - ipcMain.handle( - 'gh:reportVisiblePRRefreshCandidates', - (event, args: { candidates: GitHubPRRefreshCandidate[]; generation: number }) => { - const senderId = event.sender.id - if (!prRefreshVisibilityCleanupRegistered.has(senderId)) { - prRefreshVisibilityCleanupRegistered.add(senderId) - event.sender.once('destroyed', () => { - prRefreshVisibilityCleanupRegistered.delete(senderId) - clearVisiblePRRefreshWindow(senderId) - }) - } - const candidates: GitHubPRRefreshCandidate[] = [] - const repos = store.getRepos() - for (const candidate of args.candidates) { - const validation = validateAutomaticPRRefreshCandidate(candidate, store, repos) - if (validation.kind === 'ok') { - candidates.push(validation.candidate) - } - } - reportVisiblePRRefreshCandidates(candidates, args.generation, senderId) - return true - } - ) - - ipcMain.handle( - 'gh:issue', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - number: number - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getIssue( - repo.path, - args.number, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle('gh:listIssues', (_event, args: { repoPath: string; limit?: number }) => { - const repo = assertRegisteredRepo(args, store) - // Why: listIssues now returns { items, error? }. The IPC handler unwraps to - // the items array for the existing contract; feature 1's UI consumes the - // richer envelope through `gh:listWorkItems` instead. - return listIssues( - repo.path, - args.limit, - repo.issueSourcePreference, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ).then((r) => r.items) - }) - - ipcMain.handle( - 'gh:createIssue', - (_event, args: RepoScopedArgs & { title: string; body: string } & GitHubCreateIssueFields) => { - const repo = assertRegisteredRepo(args, store) - const fields = - args.labels !== undefined || args.assignees !== undefined - ? { labels: args.labels, assignees: args.assignees } - : undefined - return createIssue( - repo.path, - args.title, - args.body, - repo.issueSourcePreference, - repoConnectionId(repo), - fields, - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:listWorkItems', - ( - _event, - args: { - repoPath: string - repoId?: string - limit?: number - query?: string - page?: number - noCache?: boolean - } - ) => { - const repo = assertRegisteredRepo(args, store) - return listWorkItems( - repo.path, - args.limit, - args.query, - args.page, - repo.issueSourcePreference, - repoConnectionId(repo), - args.noCache, - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle('gh:countWorkItems', (_event, args: { repoPath: string; query?: string }) => { - const repo = assertRegisteredRepo(args, store) - return countWorkItems( - repo.path, - args.query, - repo.issueSourcePreference, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - }) - - ipcMain.handle('gh:workItem', (_event, args: WorkItemArgs) => { - const repo = assertRegisteredRepo(args, store) - return dispatchWorkItem(args, repo, getWorkItem, localGitOptionArgs(store, repo)[0]) - }) - ipcMain.handle( - 'gh:workItemByOwnerRepo', - ( - _event, - args: { - repoPath: string - owner: string - repo: string - host?: string - number: number - type: 'issue' | 'pr' - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getWorkItemByOwnerRepo( - repo.path, - // Why: Enterprise host identity must survive the IPC boundary or the - // lookup falls back to gh's default host for a same-named repo. - { owner: args.owner, repo: args.repo, ...(args.host ? { host: args.host } : {}) }, - args.number, - args.type, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => { - const repo = assertRegisteredRepo(args, store) - return dispatchWorkItem(args, repo, getWorkItemDetails, localGitOptionArgs(store, repo)[0]) - }) - - ipcMain.handle( - 'gh:notifyWorkItemMutated', - ( - event, - args: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number - } - ) => { - const repo = args.repoId - ? store.getRepos().find((candidate) => candidate.id === args.repoId) - : assertRegisteredRepo(args, store) - if (!repo) { - return false - } - if ( - (args.type !== 'issue' && args.type !== 'pr') || - typeof args.number !== 'number' || - !Number.isInteger(args.number) || - args.number < 1 - ) { - return false - } - broadcastWorkItemMutated( - { - repoPath: repo.path, - repoId: repo.id, - type: args.type, - number: args.number - }, - event.sender.id - ) - return true - } - ) - - ipcMain.handle( - 'gh:prFileContents', - ( - _event, - args: { - repoPath: string - prNumber: number - prRepo?: GitHubOwnerRepo | null - path: string - oldPath?: string - status: GitHubPRFile['status'] - headSha: string - baseSha: string - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getPRFileContents({ - repoPath: repo.path, - connectionId: repoConnectionId(repo), - localGitOptions: localGitOptionArgs(store, repo)[0], - prRepo: args.prRepo ?? null, - prNumber: args.prNumber, - path: args.path, - oldPath: args.oldPath, - status: args.status, - headSha: args.headSha, - baseSha: args.baseSha - }) - } - ) - - ipcMain.handle('gh:repoSlug', (_event, args: { repoPath: string }) => { - const repo = assertRegisteredRepo(args, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - return localGitOptions - ? getRepoSlug(repo.path, repoConnectionId(repo), { localGitExecOptions: localGitOptions }) - : getRepoSlug(repo.path, repoConnectionId(repo)) - }) - - ipcMain.handle('gh:repoUpstream', (_event, args: { repoPath: string }) => { - const repo = assertRegisteredRepo(args, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - return localGitOptions - ? getRepoUpstream(repo.path, repoConnectionId(repo), { localGitExecOptions: localGitOptions }) - : getRepoUpstream(repo.path, repoConnectionId(repo)) - }) - - ipcMain.handle( - 'gh:prChecks', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - prNumber: number - headSha?: string - prRepo?: GitHubOwnerRepo | null - noCache?: boolean - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getPRChecks( - repo.path, - args.prNumber, - args.headSha, - args.prRepo ?? null, - { - noCache: args.noCache - }, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:prCheckDetails', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - checkRunId?: number - workflowRunId?: number - checkName?: string - url?: string | null - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getPRCheckDetails( - repo.path, - { - checkRunId: args.checkRunId, - workflowRunId: args.workflowRunId, - checkName: args.checkName, - url: args.url, - prRepo: args.prRepo ?? null - }, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:prComments', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - noCache?: boolean - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getPRComments( - repo.path, - args.prNumber, - { noCache: args.noCache, prRepo: args.prRepo ?? null }, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:setPRCommentReaction', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - reactionSubjectId: string - content: GitHubReactionContent - reacted: boolean - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if (!args.reactionSubjectId?.trim()) { - return false - } - return setPRCommentReaction( - repo.path, - args.reactionSubjectId.trim(), - args.content, - args.reacted, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:resolveReviewThread', - async ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - threadId: string - resolve: boolean - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - // Why: thread resolve doesn't carry the PR number, so we cannot target - // a specific cache entry. The renderer cache stores per-(repo, type, number) - // entries — emitting a path-wide invalidation here would require a new - // event shape; instead, the drawer's existing thread-resolve UI updates - // its local state immediately and the next reopen pays one fresh fetch. - return resolveReviewThread( - repo.path, - args.threadId, - args.resolve, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:setPRFileViewed', - async ( - event, - args: { - repoPath: string - repoId?: string - prNumber: number - prRepo?: GitHubOwnerRepo | null - pullRequestId: string - path: string - viewed: boolean - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return false - } - if (!args.pullRequestId?.trim() || !args.path?.trim()) { - return false - } - const ok = await setPRFileViewed({ - repoPath: repo.path, - connectionId: repoConnectionId(repo), - localGitOptions: localGitOptionArgs(store, repo)[0], - prRepo: args.prRepo ?? null, - pullRequestId: args.pullRequestId.trim(), - path: args.path, - viewed: Boolean(args.viewed) - }) - if (ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return ok - } - ) - - ipcMain.handle( - 'gh:addPRReviewCommentReply', - async ( - event, - args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - commentId: number - body: string - threadId?: string - path?: string - line?: number - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return { ok: false, error: 'Invalid PR number' } - } - if ( - typeof args.commentId !== 'number' || - !Number.isInteger(args.commentId) || - args.commentId < 1 - ) { - return { ok: false, error: 'Invalid comment ID' } - } - if (!args.body?.trim()) { - return { ok: false, error: 'Comment body required' } - } - const result = await addPRReviewCommentReply( - repo.path, - args.prNumber, - args.commentId, - args.body.trim(), - args.threadId, - args.path, - args.line, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:addPRReviewComment', - async ( - event, - args: { - repoPath: string - prNumber: number - prRepo?: GitHubOwnerRepo | null - commitId: string - path: string - line: number - startLine?: number - body: string - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return { ok: false, error: 'Invalid PR number' } - } - if (typeof args.line !== 'number' || !Number.isInteger(args.line) || args.line < 1) { - return { ok: false, error: 'Invalid line number' } - } - if ( - args.startLine !== undefined && - (typeof args.startLine !== 'number' || - !Number.isInteger(args.startLine) || - args.startLine < 1 || - args.startLine > args.line) - ) { - return { ok: false, error: 'Invalid start line' } - } - if (!args.commitId?.trim()) { - return { ok: false, error: 'Missing PR head SHA' } - } - if (!args.path?.trim()) { - return { ok: false, error: 'File path required' } - } - if (!args.body?.trim()) { - return { ok: false, error: 'Comment body required' } - } - const result = await addPRReviewComment({ - repoPath: repo.path, - prRepo: args.prRepo ?? null, - prNumber: args.prNumber, - commitId: args.commitId.trim(), - path: args.path, - line: args.line, - startLine: args.startLine, - body: args.body.trim(), - connectionId: repoConnectionId(repo), - localGitOptions: localGitOptionArgs(store, repo)[0] - }) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:updatePRTitle', - async ( - event, - args: { repoPath: string; prNumber: number; title: string; prRepo?: GitHubOwnerRepo | null } - ) => { - const repo = assertRegisteredRepo(args, store) - const ok = await updatePRTitle( - repo.path, - args.prNumber, - args.title, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return ok - } - ) - - ipcMain.handle( - 'gh:mergePR', - async ( - event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - prNumber: number - method?: 'merge' | 'squash' | 'rebase' - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const result = await mergePR( - repo.path, - args.prNumber, - args.method, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:setPRAutoMerge', - async ( - event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - prNumber: number - enabled: boolean - method?: 'merge' | 'squash' | 'rebase' - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const result = await setPRAutoMerge( - repo.path, - args.prNumber, - args.enabled, - args.method, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:updatePRState', - async ( - event, - args: RepoScopedArgs & { - prNumber: number - updates: GitHubPullRequestStateUpdate - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return { ok: false, error: 'Invalid pull request number' } - } - const result = await updatePRState( - repo.path, - args.prNumber, - args.updates, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:rerunPRChecks', - async ( - _event, - args: RepoScopedArgs & { - prNumber: number - headSha?: string - failedOnly?: boolean - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return { ok: false, error: 'Invalid pull request number' } - } - return rerunPRChecks( - repo.path, - args.prNumber, - { headSha: args.headSha, failedOnly: args.failedOnly, prRepo: args.prRepo ?? null }, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:requestPRReviewers', - async ( - event, - args: RepoScopedArgs & { - prNumber: number - reviewers: string[] - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const result = await requestPRReviewers( - repo.path, - args.prNumber, - args.reviewers, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:removePRReviewers', - async ( - event, - args: RepoScopedArgs & { - prNumber: number - reviewers: string[] - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const result = await removePRReviewers( - repo.path, - args.prNumber, - args.reviewers, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:updateIssue', - async (event, args: RepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => { - const repo = assertRegisteredRepo(args, store) - if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { - return { ok: false, error: 'Invalid issue number' } - } - if (!args.updates || typeof args.updates !== 'object') { - return { ok: false, error: 'Updates object is required' } - } - const result = await updateIssue( - repo.path, - args.number, - args.updates, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'issue', number: args.number }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:addIssueComment', - async ( - event, - args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - body: string - type?: 'issue' | 'pr' - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { - return { ok: false, error: 'Invalid issue number' } - } - if (!args.body?.trim()) { - return { ok: false, error: 'Comment body required' } - } - const result = await addIssueComment( - repo.path, - args.number, - args.body.trim(), - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - // Why: PR conversation comments hit `/issues/N/comments` too, but the - // drawer's cache key uses type='pr'. The caller passes through which - // drawer they're posting from so we only invalidate the matching key - // — broadcasting both would evict an unrelated PR/issue that happens - // to share the number. - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: args.type ?? 'issue', number: args.number }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle('gh:listLabels', (_event, args: RepoScopedArgs) => { - const repo = assertRegisteredRepo(args, store) - return listLabels( - repo.path, - repo.issueSourcePreference, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - }) - - ipcMain.handle('gh:listAssignableUsers', (_event, args: RepoScopedArgs) => { - const repo = assertRegisteredRepo(args, store) - return listAssignableUsers( - repo.path, - repo.issueSourcePreference, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - }) - - // Star operations target the Orca repo itself — no repoPath validation needed - ipcMain.handle('gh:viewer', () => getAuthenticatedViewer()) - ipcMain.handle('gh:checkOrcaStarred', () => checkOrcaStarred()) - ipcMain.handle('gh:starOrca', async (_event, source: unknown) => { - const sourceParse = appStarSourceSchema.safeParse(source) - const starred = await starOrca() - if (starred && sourceParse.success) { - // Why: this main-owned event bypasses renderer telemetry IPC, so cohort - // context must be attached here on the successful star path. - track('app_starred_orca', { - source: sourceParse.data, - ...getCohortAtEmit() - }) - } - return starred - }) - - // Why: `rate_limit` is exempt from GitHub's rate-limit accounting, so - // polling is cheap. A 30s in-process cache still avoids the gh subprocess - // cost on every render — see getRateLimit for the ttl rationale. Force - // parameter lets the renderer bust the cache after a known-expensive op - // (e.g. post-ProjectPicker discovery) without waiting out the ttl. - ipcMain.handle('gh:rateLimit', (_event, args?: { force?: boolean }) => - getRateLimit(args?.force ? { force: true } : undefined) - ) - - ipcMain.handle('gh:diagnoseAuth', (_event, args?: { host?: string }) => - diagnoseGhAuth(args?.host) - ) - - // ── GitHub ProjectV2 view handlers ───────────────────────────────── - // Why: registered unconditionally so enabling the experimental flag at - // runtime takes effect without a restart. The renderer gates entry points. - // Handlers never throw across IPC — every failure mode resolves through the - // GitHubProjectViewError envelope. - - ipcMain.handle('gh:listAccessibleProjects', (_event, args?: ListAccessibleProjectsArgs) => - listAccessibleProjects(args) - ) - - ipcMain.handle('gh:resolveProjectRef', (_event, args: ResolveProjectRefArgs) => - resolveProjectRef(args) - ) - - ipcMain.handle('gh:listProjectViews', (_event, args: ListProjectViewsArgs) => - listProjectViews(args) - ) - - ipcMain.handle('gh:getProjectViewTable', (_event, args: GetProjectViewTableArgs) => - getProjectViewTable(args) - ) - - ipcMain.handle( - 'gh:projectWorkItemDetailsBySlug', - (_event, args: ProjectWorkItemDetailsBySlugArgs) => getWorkItemDetailsBySlug(args) - ) - - ipcMain.handle('gh:updateProjectItemField', (_event, args: UpdateProjectItemFieldArgs) => - updateProjectItemFieldValue(args) - ) - - ipcMain.handle('gh:clearProjectItemField', (_event, args: ClearProjectItemFieldArgs) => - clearProjectItemFieldValue(args) - ) - - ipcMain.handle('gh:updateIssueBySlug', (_event, args: UpdateIssueBySlugArgs) => - updateIssueBySlug(args) - ) - - ipcMain.handle('gh:updatePullRequestBySlug', (_event, args: UpdatePullRequestBySlugArgs) => - updatePullRequestBySlug(args) - ) - - ipcMain.handle('gh:addIssueCommentBySlug', (_event, args: AddIssueCommentBySlugArgs) => - addIssueCommentBySlug(args) - ) - - ipcMain.handle('gh:updateIssueCommentBySlug', (_event, args: UpdateIssueCommentBySlugArgs) => - updateIssueCommentBySlug(args) - ) - - ipcMain.handle('gh:deleteIssueCommentBySlug', (_event, args: DeleteIssueCommentBySlugArgs) => - deleteIssueCommentBySlug(args) - ) - - ipcMain.handle('gh:listLabelsBySlug', (_event, args: ListLabelsBySlugArgs) => - listLabelsBySlug(args) - ) - - ipcMain.handle('gh:listAssignableUsersBySlug', (_event, args: ListAssignableUsersBySlugArgs) => - listAssignableUsersBySlug(args) - ) - - ipcMain.handle('gh:listIssueTypesBySlug', (_event, args: ListIssueTypesBySlugArgs) => - listIssueTypesBySlug(args) - ) - - ipcMain.handle('gh:updateIssueTypeBySlug', (_event, args: UpdateIssueTypeBySlugArgs) => - updateIssueTypeBySlug(args) - ) - - // Why: issue-source preference writes go through the generic `repos:update` - // IPC (extended in this PR to accept `issueSourcePreference`). Routing - // through the same channel keeps a single write path, guarantees the - // `repos:changed` broadcast is emitted, and avoids two channels racing to - // persist the same field with different validation and eviction semantics. - // Reads piggyback on the `Repo` record already delivered by `repos:list`. + registerGitHubPRRefreshHandlers(store, stats) + registerGitHubWorkItemHandlers(store) + registerGitHubPRReadHandlers(store) + registerGitHubPRReviewHandlers(store) + registerGitHubPRMutationHandlers(store) + registerGitHubIssueMutationHandlers(store) + registerGitHubAccountHandlers() + registerGitHubProjectViewHandlers() } From d74f4693795c48a2c3a94ca8305703825bdc4dbd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:57:21 -0700 Subject: [PATCH 36/65] refactor(feature-wall): split animated browser and terminal stories (#16142) * refactor feature wall animated visuals * fix(feature-wall): restore merge-base render behavior in split visuals - Hoist workbench reduced-motion state to module constants so cursorTarget identity is stable and the cursor layout effect stops re-firing per render. - Render one frame component and branch on the state source so toggling reducedMotion re-renders the storyboard instead of remounting its DOM. --- config/max-lines-baseline.txt | 2 - .../BrowserAgentVerificationPane.tsx | 160 +++ .../feature-wall/BrowserAnimatedVisual.tsx | 1048 +--------------- .../feature-wall/BrowserPricingStoryboard.tsx | 87 ++ .../feature-wall/BrowserSignupStoryboard.tsx | 17 + .../feature-wall/BrowserStoryboardWindow.tsx | 386 ++++++ .../feature-wall/WorkbenchAnimatedVisual.tsx | 1099 +++-------------- .../feature-wall/WorkbenchTerminalPanes.tsx | 349 ++++++ .../WorkbenchTerminalSplitMenu.tsx | 113 ++ .../browser-animated-visual-phase.ts | 115 ++ .../browser-animated-visual-sequence.test.ts | 104 ++ .../browser-animated-visual-sequence.ts | 203 +++ .../use-browser-animated-visual-storyboard.ts | 168 +++ .../use-workbench-terminal-cursor.ts | 55 + .../use-workbench-terminal-storyboard.ts | 89 ++ ...bench-terminal-storyboard-sequence.test.ts | 106 ++ .../workbench-terminal-storyboard-sequence.ts | 227 ++++ .../workbench-terminal-storyboard-state.ts | 46 + 18 files changed, 2453 insertions(+), 1921 deletions(-) create mode 100644 src/renderer/src/components/feature-wall/BrowserAgentVerificationPane.tsx create mode 100644 src/renderer/src/components/feature-wall/BrowserPricingStoryboard.tsx create mode 100644 src/renderer/src/components/feature-wall/BrowserSignupStoryboard.tsx create mode 100644 src/renderer/src/components/feature-wall/BrowserStoryboardWindow.tsx create mode 100644 src/renderer/src/components/feature-wall/WorkbenchTerminalPanes.tsx create mode 100644 src/renderer/src/components/feature-wall/WorkbenchTerminalSplitMenu.tsx create mode 100644 src/renderer/src/components/feature-wall/browser-animated-visual-phase.ts create mode 100644 src/renderer/src/components/feature-wall/browser-animated-visual-sequence.test.ts create mode 100644 src/renderer/src/components/feature-wall/browser-animated-visual-sequence.ts create mode 100644 src/renderer/src/components/feature-wall/use-browser-animated-visual-storyboard.ts create mode 100644 src/renderer/src/components/feature-wall/use-workbench-terminal-cursor.ts create mode 100644 src/renderer/src/components/feature-wall/use-workbench-terminal-storyboard.ts create mode 100644 src/renderer/src/components/feature-wall/workbench-terminal-storyboard-sequence.test.ts create mode 100644 src/renderer/src/components/feature-wall/workbench-terminal-storyboard-sequence.ts create mode 100644 src/renderer/src/components/feature-wall/workbench-terminal-storyboard-state.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index f2a9bebb747..e732ac529ba 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -87,9 +87,7 @@ inline src/renderer/src/components/editor/CombinedDiffViewer.tsx inline src/renderer/src/components/editor/EditorContent.tsx inline src/renderer/src/components/editor/IpynbViewer.tsx inline src/renderer/src/components/editor/MarkdownPreview.tsx -inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx -inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx inline src/renderer/src/components/github-project/ProjectCell.tsx inline src/renderer/src/components/github-project/ProjectPicker.tsx diff --git a/src/renderer/src/components/feature-wall/BrowserAgentVerificationPane.tsx b/src/renderer/src/components/feature-wall/BrowserAgentVerificationPane.tsx new file mode 100644 index 00000000000..f8efab939af --- /dev/null +++ b/src/renderer/src/components/feature-wall/BrowserAgentVerificationPane.tsx @@ -0,0 +1,160 @@ +import type { JSX, ReactNode } from 'react' +import { ClaudeIcon } from '@/components/status-bar/icons' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { + BROWSER_PROMPT_TEXT, + browserPhaseAtLeast, + type BrowserVisualPhase +} from './browser-animated-visual-phase' + +type TerminalEntry = + | { kind: 'prompt'; text: string } + | { kind: 'working' } + | { kind: 'ok'; content: ReactNode } + | { kind: 'tool'; tool: string; arg: string } + | { kind: 'tool-muted'; tool: string; muted: string } + +type PhasedTerminalEntry = { + id: string + entry: TerminalEntry + minPhase: BrowserVisualPhase +} + +function terminalEntries(): readonly PhasedTerminalEntry[] { + return [ + { + id: 'change-prompt', + entry: { kind: 'prompt', text: BROWSER_PROMPT_TEXT }, + minPhase: 'working' + }, + { id: 'working', entry: { kind: 'working' }, minPhase: 'working' }, + { + id: 'updated', + entry: { + kind: 'ok', + content: ( + <> + {translate( + 'auto.components.feature.wall.BrowserAnimatedVisual.4fa59ca545', + '✓ Updated' + )}{' '} + + {translate( + 'auto.components.feature.wall.BrowserAnimatedVisual.051c97d15a', + '.pp-card[data-card="starter"] .pp-cta' + )} + + + ) + }, + minPhase: 'updated' + }, + { + id: 'verify-prompt', + entry: { + kind: 'prompt', + text: 'Let me click Try free to verify it still works.' + }, + minPhase: 'verify-intent' + }, + { + id: 'click', + entry: { kind: 'tool', tool: 'click', arg: '"Try free"' }, + minPhase: 'click-press' + }, + { + id: 'screenshot', + entry: { kind: 'tool-muted', tool: 'screenshot', muted: '(capturing page)' }, + minPhase: 'screenshot-line' + }, + { + id: 'verified', + entry: { + kind: 'ok', + content: translate( + 'auto.components.feature.wall.BrowserAnimatedVisual.eb88125c6f', + '✓ Verified — Try free still works.' + ) + }, + minPhase: 'verified' + } + ] +} + +export function BrowserAgentVerificationPane(props: { + phase: BrowserVisualPhase + splitVisible: boolean +}): JSX.Element { + return ( +
+
+ + + {translate('auto.components.feature.wall.BrowserAnimatedVisual.6e4616d039', 'Claude')} + +
+
+ {terminalEntries().map(({ id, entry, minPhase }) => ( + + + + ))} +
+
+ ) +} + +function TerminalEntryView(props: { entry: TerminalEntry }): JSX.Element { + const { entry } = props + if (entry.kind === 'prompt') { + return ( + + + {translate('auto.components.feature.wall.BrowserAnimatedVisual.f2034c4930', '>')} + {' '} + {entry.text} + + ) + } + if (entry.kind === 'working') { + return ( + + + {translate('auto.components.feature.wall.BrowserAnimatedVisual.0ce7c24b4d', 'Working…')} + + ) + } + if (entry.kind === 'ok') { + return {entry.content} + } + if (entry.kind === 'tool') { + return ( + + {entry.tool}{' '} + {entry.arg} + + ) + } + return ( + + {entry.tool}{' '} + {entry.muted} + + ) +} + +function TerminalLine(props: { visible: boolean; children: ReactNode }): JSX.Element { + return ( + + {props.children} + + ) +} diff --git a/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx index 3d229ddaa91..2966f05a60b 100644 --- a/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx @@ -1,499 +1,49 @@ -/* eslint-disable max-lines -- Why: this animation is a self-contained storyboard; splitting the phase markup from its timing constants would make the sequence harder to verify. */ -import { useEffect, useRef, useState, type JSX, type ReactNode } from 'react' -import { cn } from '@/lib/utils' -import { ClaudeIcon } from '@/components/status-bar/icons' +import type { JSX } from 'react' import { useShortcutLabel } from '@/hooks/useShortcutLabel' -import { FeatureWallClickRing } from './FeatureWallClickRing' import { translate } from '@/i18n/i18n' - -// Why: this animation tells the full Orca story end-to-end — the user opens a -// new browser tab, annotates a target on the pricing page, types a change, -// hands off to Claude in a split pane, and Claude edits + verifies the page. -// The DOM and timing track docs/feature-wall-workbench-tile-mock.html so the -// modal stays in lockstep with the design source. - -const PROMPT_TEXT = 'Make Starter card stand out' - -// Why: these hand-rolled tour popovers need the same dark-mode separation as -// Orca's dropdown/popover primitives while staying inside the storyboard DOM. -const TOUR_FLOATING_SURFACE_CLASS = - 'border border-black/14 bg-[rgba(255,255,255,0.82)] text-popover-foreground shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)]' - -const PRE_INTRO_MS = 600 -const NEWTAB_APPROACH_MS = 700 -const NEWTAB_CLICK_MS = 180 -const NEWTAB_DWELL_MS = 700 -const NEWTAB_ROW_HOVER_MS = 1050 -const NEWTAB_ROW_CLICK_MS = 220 -const TAB_REVEAL_MS = 500 -const APPROACH_CARD_MS = 900 -const INSPECT_MS = 700 -const ANNOTATE_OPEN_MS = 360 -const ANNOTATE_TYPE_INTERVAL_MS = 58 -const ANNOTATE_HOLD_MS = 900 -const SEND_APPROACH_MS = 500 -const SEND_CLICK_MS = 250 -const HANDOFF_MS = 200 -const WORKING_LINE_STAGGER_MS = 260 -const WORKING_HOLD_MS = 1400 -const UPDATED_HOLD_MS = 900 -const VERIFY_INTENT_MS = 1100 -const CLICK_APPROACH_MS = 620 -const CLICK_PRESS_MS = 280 -const NAVIGATED_HOLD_MS = 700 -const SCREENSHOT_LINE_HOLD_MS = 420 -const SCREENSHOT_FLASH_HOLD_MS = 700 -const VERIFIED_HOLD_MS = 2400 -const RESET_HOLD_MS = 300 -const CLICK_RING_MS = 460 - -type Phase = - | 'idle' - | 'newtab-approach' - | 'newtab-click' - | 'newtab-row-approach' - | 'newtab-row-click' - | 'tab-revealed' - | 'approach-card' - | 'inspect' - | 'annotate' - | 'send-approach' - | 'send-click' - | 'handoff' - | 'working' - | 'updated' - | 'verify-intent' - | 'click-approach' - | 'click-press' - | 'navigated' - | 'screenshot-line' - | 'screenshot-flash' - | 'verified' - -const PHASE_ORDER: readonly Phase[] = [ - 'idle', - 'newtab-approach', - 'newtab-click', - 'newtab-row-approach', - 'newtab-row-click', - 'tab-revealed', - 'approach-card', - 'inspect', - 'annotate', - 'send-approach', - 'send-click', - 'handoff', - 'working', - 'updated', - 'verify-intent', - 'click-approach', - 'click-press', - 'navigated', - 'screenshot-line', - 'screenshot-flash', - 'verified' -] - -function phaseAtLeast(current: Phase, target: Phase): boolean { - return PHASE_ORDER.indexOf(current) >= PHASE_ORDER.indexOf(target) -} - -const SPLIT_PHASES: readonly Phase[] = [ - 'working', - 'updated', - 'verify-intent', - 'click-approach', - 'click-press', - 'navigated', - 'screenshot-line', - 'screenshot-flash', - 'verified' -] - -function isSplitPhase(phase: Phase): boolean { - return SPLIT_PHASES.includes(phase) -} - -type TermEntry = - | { kind: 'prompt'; text: string } - | { kind: 'working' } - | { kind: 'ok'; html: ReactNode } - | { kind: 'tool'; tool: string; arg: string } - | { kind: 'tool-muted'; tool: string; muted: string } - -const TERM_ENTRIES: readonly { entry: TermEntry; minPhase: Phase }[] = [ - { entry: { kind: 'prompt', text: PROMPT_TEXT }, minPhase: 'working' }, - { entry: { kind: 'working' }, minPhase: 'working' }, - { - entry: { - kind: 'ok', - get html() { - return ( - <> - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.4fa59ca545', - '✓ Updated' - )}{' '} - - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.051c97d15a', - '.pp-card[data-card="starter"] .pp-cta' - )} - - - ) - } - }, - minPhase: 'updated' - }, - { - entry: { - kind: 'prompt', - text: 'Let me click Try free to verify it still works.' - }, - minPhase: 'verify-intent' - }, - { entry: { kind: 'tool', tool: 'click', arg: '"Try free"' }, minPhase: 'click-press' }, - { - entry: { kind: 'tool-muted', tool: 'screenshot', muted: '(capturing page)' }, - minPhase: 'screenshot-line' - }, - { - entry: { - kind: 'ok', - get html() { - return ( - <> - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.eb88125c6f', - '✓ Verified — Try free still works.' - )} - - ) - } - }, - minPhase: 'verified' - } -] +import { BrowserAgentVerificationPane } from './BrowserAgentVerificationPane' +import { BrowserStoryboardWindow } from './BrowserStoryboardWindow' +import { + BROWSER_REDUCED_MOTION_STATE, + isBrowserSplitPhase, + type BrowserVisualState, + type BrowserVisualTargetRefs +} from './browser-animated-visual-phase' +import { + useBrowserAnimatedVisualStoryboard, + useBrowserVisualTargetRefs +} from './use-browser-animated-visual-storyboard' export function BrowserAnimatedVisual(props: { reducedMotion: boolean onCycleComplete?: () => void }): JSX.Element { - const { reducedMotion, onCycleComplete } = props + const targets = useBrowserVisualTargetRefs() const newBrowserShortcutLabel = useShortcutLabel('tab.newBrowser') - const [phase, setPhase] = useState('idle') - const [typedChars, setTypedChars] = useState(0) - const [flashKey, setFlashKey] = useState(0) - const [clickRingKey, setClickRingKey] = useState(0) - const [clickRingVisible, setClickRingVisible] = useState(false) - const [menuOffsetX, setMenuOffsetX] = useState(0) - const [annotateAnchor, setAnnotateAnchor] = useState<{ left: number; top: number }>({ - left: 116, - top: 70 - }) + // Why: branch on the state source, not the element type, so a reducedMotion + // toggle re-renders the storyboard in place instead of remounting it. + const animatedState = useBrowserAnimatedVisualStoryboard( + targets, + props.reducedMotion, + props.onCycleComplete + ) - const browserPageRef = useRef(null) - const titlebarRef = useRef(null) - const newtabBtnRef = useRef(null) - const newtabRowRef = useRef(null) - const starterCardRef = useRef(null) - const ctaRef = useRef(null) - const sendBtnRef = useRef(null) - const cursorPosRef = useRef<{ x: number; y: number }>({ x: 40, y: 18 }) - const [cursorPos, setCursorPos] = useState<{ x: number; y: number }>({ x: 40, y: 18 }) - - // Why: cursor coordinates are computed against .browser-page so they survive - // grid reflow when the split lands. We stash both a ref (synchronous read - // for hover-then-click sequences) and state (for transition animation). - function setCursorTo(x: number, y: number): void { - cursorPosRef.current = { x, y } - setCursorPos({ x, y }) - } - - function transformForElement( - el: HTMLElement | null, - offsetX = 0, - offsetY = 0 - ): { x: number; y: number } { - const page = browserPageRef.current - if (!page || !el) { - return cursorPosRef.current - } - const pageRect = page.getBoundingClientRect() - const elRect = el.getBoundingClientRect() - return { - x: elRect.left - pageRect.left + elRect.width / 2 - 8 + offsetX, - y: elRect.top - pageRect.top + elRect.height / 2 - 8 + offsetY - } - } - - /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: this - storyboard effect owns timed local animation state; deriving every phase - during render would break measured cursor sequencing. */ - useEffect(() => { - if (reducedMotion) { - setPhase('verified') - setTypedChars(PROMPT_TEXT.length) - setClickRingVisible(false) - return - } - let cancelled = false - const timeouts: number[] = [] - const wait = (ms: number): Promise => - new Promise((resolve) => { - const id = window.setTimeout(() => resolve(), ms) - timeouts.push(id) - }) - function pulseClickRing(): void { - setClickRingKey((key) => key + 1) - setClickRingVisible(true) - const id = window.setTimeout(() => { - if (!cancelled) { - setClickRingVisible(false) - } - }, CLICK_RING_MS) - timeouts.push(id) - } - - async function loop(): Promise { - while (!cancelled) { - setPhase('idle') - setTypedChars(0) - setClickRingVisible(false) - setCursorTo(40, 18) - await wait(PRE_INTRO_MS) - if (cancelled) { - return - } - - // 1. Cursor approaches the "+" in the tab strip. - const newtabPos = transformForElement(newtabBtnRef.current) - setCursorTo(newtabPos.x, newtabPos.y) - setPhase('newtab-approach') - await wait(NEWTAB_APPROACH_MS) - if (cancelled) { - return - } - - // 2. Click "+". Capture the dropdown's left offset relative to the - // titlebar so the menu lines up with the button. - setPhase('newtab-click') - pulseClickRing() - if (titlebarRef.current && newtabBtnRef.current) { - const tbRect = titlebarRef.current.getBoundingClientRect() - const btnRect = newtabBtnRef.current.getBoundingClientRect() - setMenuOffsetX(btnRect.left - tbRect.left) - } - await wait(NEWTAB_CLICK_MS) - if (cancelled) { - return - } - await wait(NEWTAB_DWELL_MS) - if (cancelled) { - return - } - - // 3. Move to "New Browser Tab" row. - const rowPos = transformForElement(newtabRowRef.current, 6, 0) - setCursorTo(rowPos.x, rowPos.y) - setPhase('newtab-row-approach') - await wait(NEWTAB_ROW_HOVER_MS) - if (cancelled) { - return - } - - // 4. Click → dropdown closes, browser tab reveals, page comes alive. - setPhase('newtab-row-click') - pulseClickRing() - await wait(NEWTAB_ROW_CLICK_MS) - if (cancelled) { - return - } - setPhase('tab-revealed') - await wait(TAB_REVEAL_MS) - if (cancelled) { - return - } - - // Cursor approaches the Starter card. - const starterPos = transformForElement(starterCardRef.current, 0, -8) - setCursorTo(starterPos.x, starterPos.y) - setPhase('approach-card') - await wait(APPROACH_CARD_MS) - if (cancelled) { - return - } - - setPhase('inspect') - pulseClickRing() - await wait(INSPECT_MS) - if (cancelled) { - return - } - - // Anchor the annotate popover to the Starter card's actual position so - // it lines up regardless of how the parent grid lays out. - if (browserPageRef.current && starterCardRef.current) { - const pageRect = browserPageRef.current.getBoundingClientRect() - const cardRect = starterCardRef.current.getBoundingClientRect() - setAnnotateAnchor({ - left: cardRect.right - pageRect.left + 6, - top: cardRect.top - pageRect.top - }) - } - setPhase('annotate') - await wait(ANNOTATE_OPEN_MS) - if (cancelled) { - return - } - for (let i = 1; i <= PROMPT_TEXT.length; i += 1) { - if (cancelled) { - return - } - setTypedChars(i) - await wait(ANNOTATE_TYPE_INTERVAL_MS) - } - await wait(ANNOTATE_HOLD_MS) - if (cancelled) { - return - } - - const sendPos = transformForElement(sendBtnRef.current) - setCursorTo(sendPos.x, sendPos.y) - setPhase('send-approach') - await wait(SEND_APPROACH_MS) - if (cancelled) { - return - } - - setPhase('send-click') - pulseClickRing() - await wait(SEND_CLICK_MS) - if (cancelled) { - return - } - - setPhase('handoff') - await wait(HANDOFF_MS) - if (cancelled) { - return - } - - // Split lands. Working line + prompt are gated on phase >= 'working'; - // small stagger keeps them from popping in simultaneously. - setPhase('working') - await wait(WORKING_LINE_STAGGER_MS * 2) - if (cancelled) { - return - } - await wait(WORKING_HOLD_MS) - if (cancelled) { - return - } - - setPhase('updated') - await wait(UPDATED_HOLD_MS) - if (cancelled) { - return - } - - setPhase('verify-intent') - await wait(VERIFY_INTENT_MS) - if (cancelled) { - return - } - - const ctaPos = transformForElement(ctaRef.current) - setCursorTo(ctaPos.x, ctaPos.y) - setPhase('click-approach') - await wait(CLICK_APPROACH_MS) - if (cancelled) { - return - } - - setPhase('click-press') - pulseClickRing() - await wait(CLICK_PRESS_MS) - if (cancelled) { - return - } - - setPhase('navigated') - await wait(NAVIGATED_HOLD_MS) - if (cancelled) { - return - } - - setPhase('screenshot-line') - await wait(SCREENSHOT_LINE_HOLD_MS) - if (cancelled) { - return - } - - setPhase('screenshot-flash') - setFlashKey((k) => k + 1) - await wait(SCREENSHOT_FLASH_HOLD_MS) - if (cancelled) { - return - } - - setPhase('verified') - await wait(VERIFIED_HOLD_MS) - if (cancelled) { - return - } - onCycleComplete?.() - - await wait(RESET_HOLD_MS) - } - } - - loop() - return () => { - cancelled = true - timeouts.forEach((id) => window.clearTimeout(id)) - } - }, [onCycleComplete, reducedMotion]) - /* oxlint-enable react-doctor/no-adjust-state-on-prop-change */ - - const isIntroPhase = - phase === 'idle' || - phase === 'newtab-approach' || - phase === 'newtab-click' || - phase === 'newtab-row-approach' || - phase === 'newtab-row-click' - const browserChromeVisible = !isIntroPhase - const browserTabVisible = !isIntroPhase - const terminalTabMinimized = !isIntroPhase - const newtabActive = phase === 'newtab-click' || phase === 'newtab-row-approach' - const newtabRowActive = phase === 'newtab-row-approach' - const dropdownVisible = - phase === 'newtab-click' || phase === 'newtab-row-approach' || phase === 'newtab-row-click' - const cursorVisible = (phase !== 'idle' && phase !== 'navigated') || clickRingVisible - const ringStarter = - phase === 'inspect' || - phase === 'annotate' || - phase === 'send-approach' || - phase === 'send-click' || - phase === 'handoff' - const annotateOpen = phase === 'annotate' || phase === 'send-approach' || phase === 'send-click' - const sendPressed = phase === 'send-click' - const isSplit = isSplitPhase(phase) - const ctaHighlighted = phaseAtLeast(phase, 'updated') - const ctaPressing = phase === 'click-press' - const showSignup = - phase === 'navigated' || - phase === 'screenshot-line' || - phase === 'screenshot-flash' || - phase === 'verified' - const flashing = phase === 'screenshot-flash' - // While the cursor is acting on the titlebar / dropdown, let the cursor - // overflow the body's clipping bounds. Once the page is live we re-clip so - // the cursor never escapes the browser body. - const bodyOverflowVisible = isIntroPhase + return ( + + ) +} +function BrowserVisualFrame(props: { + state: BrowserVisualState + targets: BrowserVisualTargetRefs + newBrowserShortcutLabel: string +}): JSX.Element { + const isSplit = isBrowserSplitPhase(props.state.phase) return (
@@ -504,246 +54,12 @@ export function BrowserAnimatedVisual(props: { gap: isSplit ? 10 : 0 }} > - {/* Browser app-window — column 1 */} -
-
-
- } - title={translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab', - 'Terminal 1' - )} - /> - {browserTabVisible ? ( - } - title={translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.7da6eed7bf', - 'localhost:3000' - )} - /> - ) : null} - - - -
- {/* New-tab dropdown menu */} -
- -
- - - - - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.0a2bd01c02', - 'New Browser Tab' - )} - - - {newBrowserShortcutLabel} - -
- -
-
- - {/* URL toolbar — hidden until tab reveals so the panel reads as - "tab created → page came alive" instead of a static frame. */} -
- - ‹ - › - ↻ - -
- {isSplit ? ( - - {`...${showSignup ? '/signup' : '/pricing'}`} - - ) : ( - <> - - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.7da6eed7bf', - 'localhost:3000' - )} - - - {showSignup - ? translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.f39be6ca14', - '/signup' - ) - : translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.73bbb46073', - '/pricing' - )} - - - )} -
-
- - {/* Browser body — relative + overflow-{hidden|visible} so the - cursor can escape during the new-tab intro to reach the - titlebar's "+" / dropdown rows. */} -
-
- {showSignup ? ( - - ) : ( - - )} - -
- - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.d8856b604a', - 'div.pricing-grid > div.card.starter:nth-of-type(1) > a.cta' - )} - - -
- {typedChars > 0 ? ( - <> - {PROMPT_TEXT.slice(0, typedChars)} - - - ) : ( - - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.3d2352f94b', - 'Describe the change…' - )} - - )} -
-
- - - -
-
- - -
-
-
- - {clickRingVisible ? : null} -
-
-
-
- -
-
- - - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.6e4616d039', - 'Claude' - )} - -
-
- {TERM_ENTRIES.map(({ entry, minPhase }, i) => ( - - - - ))} -
-
+ +