From 7b40cf68fce654bcc99363545d03b7c9941d46a6 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 1 Sep 2026 15:27:48 -0400 Subject: [PATCH] refactor(runtime): restore split command surfaces --- mobile/.oxlintrc.json | 18 - .../mobile-web/mobile-native-baseline-mode.ts | 6 - .../terminal-foreground-recovery.test.ts | 27 +- .../terminal-input-connection-gate.test.ts | 72 +- .../terminal-ios-dictation-write-back.test.ts | 16 +- .../terminal-ios-ime-keyboard.test.ts | 19 +- ...erminal-keyboard-avoidance-webview.test.ts | 6 +- .../terminal-live-input-affordance.test.ts | 24 +- .../terminal/terminal-viewport-refit.test.ts | 9 +- .../terminal/terminal-webview-engine.test.ts | 9 +- ...rminal-webview-html-source.test-support.ts | 16 + mobile/src/terminal/terminal-webview-html.ts | 1909 +- .../terminal-webview-html/fragment-01.ts | 173 + .../terminal-webview-html/fragment-02.ts | 180 + .../terminal-webview-html/fragment-03.ts | 287 + .../terminal-webview-html/fragment-04.ts | 139 + .../terminal-webview-html/fragment-05.ts | 230 + .../terminal-webview-html/fragment-06.ts | 204 + .../terminal-webview-html/fragment-07.ts | 188 + .../terminal-webview-html/fragment-08.ts | 194 + .../terminal-webview-html/fragment-09.ts | 138 + .../terminal-webview-html/fragment-10.ts | 132 + .../terminal/terminal-webview-html/theme.ts | 34 + .../terminal/terminal-webview-reflow.test.ts | 4 +- .../terminal-webview-scroll-routing.test.ts | 13 +- .../terminal-webview-text-zoom.test.ts | 16 +- src/main/runtime/orca-runtime-browser.ts | 2301 +- src/main/runtime/orca-runtime-files.ts | 2961 +- src/main/runtime/orca-runtime-state-fields.ts | 42 + src/main/runtime/orca-runtime.ts | 44602 +--------------- .../runtime/runtime-file-command-surface.ts | 2 + ...e-commands-write-terminal-artifact-file.ts | 80 +- .../runtime/runtime-hosted-review-commands.ts | 19 + .../runtime-repository-command-surface.ts | 2 + 34 files changed, 2289 insertions(+), 51783 deletions(-) create mode 100644 mobile/src/terminal/terminal-webview-html-source.test-support.ts create mode 100644 mobile/src/terminal/terminal-webview-html/fragment-01.ts create mode 100644 mobile/src/terminal/terminal-webview-html/fragment-02.ts create mode 100644 mobile/src/terminal/terminal-webview-html/fragment-03.ts create mode 100644 mobile/src/terminal/terminal-webview-html/fragment-04.ts create mode 100644 mobile/src/terminal/terminal-webview-html/fragment-05.ts create mode 100644 mobile/src/terminal/terminal-webview-html/fragment-06.ts create mode 100644 mobile/src/terminal/terminal-webview-html/fragment-07.ts create mode 100644 mobile/src/terminal/terminal-webview-html/fragment-08.ts create mode 100644 mobile/src/terminal/terminal-webview-html/fragment-09.ts create mode 100644 mobile/src/terminal/terminal-webview-html/fragment-10.ts create mode 100644 mobile/src/terminal/terminal-webview-html/theme.ts diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json index 703a9dfda62..d58682609a3 100644 --- a/mobile/.oxlintrc.json +++ b/mobile/.oxlintrc.json @@ -15,30 +15,12 @@ "unicorn/prefer-node-protocol": "off" }, "overrides": [ - { - "files": ["app/h/*/tasks.tsx"], - "rules": { - "max-lines": ["error", { "max": 14682, "skipBlankLines": true, "skipComments": true }] - } - }, - { - "files": ["app/h/*/session/*.tsx"], - "rules": { - "max-lines": ["error", { "max": 5015, "skipBlankLines": true, "skipComments": true }] - } - }, { "files": ["src/terminal/TerminalWebView.tsx"], "rules": { "max-lines": ["error", { "max": 379, "skipBlankLines": true, "skipComments": true }] } }, - { - "files": ["src/terminal/terminal-webview-html.ts"], - "rules": { - "max-lines": ["error", { "max": 1784, "skipBlankLines": true, "skipComments": true }] - } - }, { "files": ["app/h/*/source-control/*.tsx"], "rules": { diff --git a/mobile/src/mobile-web/mobile-native-baseline-mode.ts b/mobile/src/mobile-web/mobile-native-baseline-mode.ts index f9b39aa83c9..416cd835538 100644 --- a/mobile/src/mobile-web/mobile-native-baseline-mode.ts +++ b/mobile/src/mobile-web/mobile-native-baseline-mode.ts @@ -2,8 +2,6 @@ const NATIVE_BASELINE_FLAG = '1' const NATIVE_ARCHITECTURE = 'native' const HYBRID_ARCHITECTURE = 'hybrid' -export type MobileArchitecture = 'native' | 'hybrid' - export function mobileNativeBaselineMode(args: { developmentBuild: boolean requested: string | undefined @@ -30,7 +28,3 @@ export const MOBILE_NATIVE_BASELINE_MODE = mobileNativeBaselineMode({ requested: process.env.EXPO_PUBLIC_ORCA_E2E_MOBILE_NATIVE_BASELINE, architecture: process.env.EXPO_PUBLIC_ORCA_MOBILE_ARCHITECTURE }) - -export const MOBILE_ARCHITECTURE: MobileArchitecture = MOBILE_NATIVE_BASELINE_MODE - ? 'native' - : 'hybrid' diff --git a/mobile/src/terminal/terminal-foreground-recovery.test.ts b/mobile/src/terminal/terminal-foreground-recovery.test.ts index 74d567ff62a..abf1bd01045 100644 --- a/mobile/src/terminal/terminal-foreground-recovery.test.ts +++ b/mobile/src/terminal/terminal-foreground-recovery.test.ts @@ -1,25 +1,22 @@ -import { readFileSync } from 'node:fs' import type { RefObject } from 'react' import { describe, expect, it, vi } from 'vitest' import type { ConnectionState } from '../transport/types' import type { TerminalWebViewHandle } from './TerminalWebView' +import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' import { TERMINAL_FOREGROUND_RECOVERY_DELAY_MS, recoverActiveTerminalAfterForeground, shouldRecoverTerminalOnAppStateChange } from './terminal-foreground-recovery' -const sessionSource = readFileSync( - new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), - 'utf8' -) +const lifecycleSource = readMobileSessionRouteSource('../session/use-mobile-session-lifecycle.ts') function sliceSessionSource(startPattern: string, endPattern: string): string { - const start = sessionSource.indexOf(startPattern) + const start = lifecycleSource.indexOf(startPattern) expect(start).toBeGreaterThanOrEqual(0) - const end = sessionSource.indexOf(endPattern, start) + const end = lifecycleSource.indexOf(endPattern, start) expect(end).toBeGreaterThan(start) - return sessionSource.slice(start, end) + return lifecycleSource.slice(start, end) } type RecoveryHarness = { @@ -149,14 +146,14 @@ describe('terminal foreground recovery', () => { 'previousAppState = nextAppState' ) - expect(sessionSource).toContain('shouldRecoverTerminalOnAppStateChange') + expect(lifecycleSource).toContain('shouldRecoverTerminalOnAppStateChange') expect(foregroundPredicate).toContain('Platform.OS') - expect(sessionSource).toContain('recoverActiveTerminalAfterForeground({') - expect(sessionSource).toContain("AppState.addEventListener('change'") - const readinessInvalidation = sessionSource.indexOf( + expect(lifecycleSource).toContain('recoverActiveTerminalAfterForeground({') + expect(lifecycleSource).toContain("AppState.addEventListener('change'") + const readinessInvalidation = lifecycleSource.indexOf( 'terminalRef.prepareForForegroundRecovery()' ) - const replay = sessionSource.indexOf('recoverActiveTerminalAfterForeground({') + const replay = lifecycleSource.indexOf('recoverActiveTerminalAfterForeground({') expect(readinessInvalidation).toBeGreaterThanOrEqual(0) expect(replay).toBeGreaterThan(readinessInvalidation) }) @@ -165,7 +162,9 @@ describe('terminal foreground recovery', () => { // Why: resume usually lands mid-reconnect; the session screen must retry // recovery on the connState→connected transition or blanked panes stay // stale until a manual tab switch. - expect(sessionSource).toContain("pendingForegroundRecoveryRef.current = outcome === 'deferred'") + expect(lifecycleSource).toContain( + "pendingForegroundRecoveryRef.current = outcome === 'deferred'" + ) const reconnectRetry = sliceSessionSource( "if (connState !== 'connected' || !pendingForegroundRecoveryRef.current)", 'recoverActiveTerminalAfterForeground({' diff --git a/mobile/src/terminal/terminal-input-connection-gate.test.ts b/mobile/src/terminal/terminal-input-connection-gate.test.ts index e55f5dfb523..f046a674a8b 100644 --- a/mobile/src/terminal/terminal-input-connection-gate.test.ts +++ b/mobile/src/terminal/terminal-input-connection-gate.test.ts @@ -1,29 +1,27 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { resolveMobileTerminalInputGate } from './terminal-input-connection-gate' import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request' +import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' -const sessionRouteSource = readFileSync( - new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), - 'utf8' +const runtimeSource = readMobileSessionRouteSource( + '../session/use-mobile-session-terminal-runtime.ts' ) -const nativeTerminalOperationsSource = readFileSync( - new URL('../session/native-host-session-terminal-operations.ts', import.meta.url), - 'utf8' +const sendActionsSource = readMobileSessionRouteSource( + '../session/use-mobile-session-terminal-send-actions.ts' ) -const accessoryRawSendSource = readFileSync( - new URL('./terminal-live-accessory-raw-send.ts', import.meta.url), - 'utf8' +const terminalInputSource = readMobileSessionRouteSource( + '../session/use-mobile-session-terminal-input.ts' ) +const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx') -function routeSlice(anchorStart: string, anchorEnd: string): string { - const start = sessionRouteSource.indexOf(anchorStart) +function sourceSlice(source: string, anchorStart: string, anchorEnd: string): string { + const start = source.indexOf(anchorStart) expect(start).toBeGreaterThanOrEqual(0) // Why: a duplicated start anchor would silently slice the wrong region. - expect(sessionRouteSource.indexOf(anchorStart, start + 1)).toBe(-1) - const end = sessionRouteSource.indexOf(anchorEnd, start) + expect(source.indexOf(anchorStart, start + 1)).toBe(-1) + const end = source.indexOf(anchorEnd, start) expect(end).toBeGreaterThan(start) - return sessionRouteSource.slice(start, end + anchorEnd.length) + return source.slice(start, end + anchorEnd.length) } describe('terminal input connection gate', () => { @@ -87,32 +85,46 @@ describe('terminal input connection gate', () => { describe('session route offline-compose wiring', () => { it('derives both gates from the shared resolver', () => { - expect(sessionRouteSource).toContain('resolveMobileTerminalInputGate({') + expect(runtimeSource).toContain('resolveMobileTerminalInputGate({') }) it('keeps the buffered command box editable offline while the live capture stays send-gated', () => { - const bufferedInput = routeSlice( + const bufferedInput = sourceSlice( + commandDockSource, 'ref={commandInputRef}', 'onSubmitEditing={() => void handleSend()}' ) expect(bufferedInput).toContain('editable={canCompose}') - const liveCapture = routeSlice('ref={liveInputRef}', 'importantForAutofill="no"') + const liveCapture = sourceSlice( + commandDockSource, + 'ref={liveInputRef}', + 'importantForAutofill="no"' + ) expect(liveCapture).toContain('editable={canSend}') }) it('keeps the send button connection-gated so held text cannot fire into a dead link', () => { - const sendButton = routeSlice('styles.sendButton,', 'accessibilityLabel="Send command"') + const sendButton = sourceSlice( + commandDockSource, + 'styles.sendButton,', + 'accessibilityLabel="Send command"' + ) expect(sendButton).toContain('disabled={!canSend}') }) it('holds composed text when the return key submits offline', () => { - const handleSend = routeSlice('async function handleSend()', 'sendingRef.current = true') + const handleSend = sourceSlice( + sendActionsSource, + 'async function handleSend()', + 'sendingRef.current = true' + ) expect(handleSend).toContain('!canSend') }) it('keeps the live/buffered mode toggle reachable offline', () => { - const modeToggle = routeSlice( + const modeToggle = sourceSlice( + commandDockSource, 'liveInputEnabled && styles.accessoryKeyActive', 'onPress={toggleLiveInput}' ) @@ -120,13 +132,25 @@ describe('session route offline-compose wiring', () => { }) it('tells the live-input commit hook about connection loss so stale mirror state resets', () => { - const hookCall = routeSlice('useTerminalLiveInputCommit({', 'setLiveInputCapture') + const hookCall = sourceSlice( + runtimeSource, + 'useTerminalLiveInputCommit({', + 'setLiveInputCapture' + ) expect(hookCall).toContain("connected: connState === 'connected'") }) it('keeps every keystroke-grade terminal send now-or-never so nothing replays after reconnect', () => { - expect(nativeTerminalOperationsSource).toContain('TERMINAL_INPUT_SEND_OPTIONS') - expect(accessoryRawSendSource).toContain('TERMINAL_INPUT_SEND_OPTIONS') + // Live mirror, buffered send, and gesture arrows must all opt out of the + // connect wait — a parked send replays stale bytes into the PTY. Accessory + // keys get the same option inside terminal-live-accessory-raw-send.ts. + expect(sendActionsSource).toContain('TERMINAL_INPUT_SEND_OPTIONS') + expect(terminalInputSource).toContain('TERMINAL_INPUT_SEND_OPTIONS') + const optionUses = [sendActionsSource, terminalInputSource].flatMap( + (source) => source.match(/TERMINAL_INPUT_SEND_OPTIONS/g) ?? [] + ).length + // Two owner imports plus one buffered, one live, and one gesture send. + expect(optionUses).toBe(5) expect(TERMINAL_INPUT_SEND_OPTIONS).toEqual({ failWhenDisconnected: true }) }) diff --git a/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts b/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts index 093ef8e17dc..9929f09c37d 100644 --- a/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts +++ b/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts @@ -1,9 +1,9 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' +import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' -const sessionRouteSource = readFileSync( - new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), - 'utf8' +const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx') +const sendActionsSource = readMobileSessionRouteSource( + '../session/use-mobile-session-terminal-send-actions.ts' ) // Why: iOS terminates an active keyboard-dictation (and IME) session whenever @@ -13,13 +13,11 @@ const sessionRouteSource = readFileSync( // apply dash normalization only on the send/mirror path. See stablyai/orca#7925. describe('terminal iOS dictation write-back', () => { it('does not write normalized text back into the buffered command input value', () => { - expect(sessionRouteSource).toContain('onChangeText={bufferedTerminalDraftState.setInput}') - expect(sessionRouteSource).not.toContain( - 'setInput((previousText) => normalizeTerminalTextInput' - ) + expect(commandDockSource).toContain('onChangeText={bufferedTerminalDraftState.setInput}') + expect(commandDockSource).not.toContain('setInput((previousText) => normalizeTerminalTextInput') }) it('still normalizes the buffered command text at send time', () => { - expect(sessionRouteSource).toContain('normalizeTerminalTextInput(draft)') + expect(sendActionsSource).toContain('normalizeTerminalTextInput(draft)') }) }) diff --git a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts b/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts index e34aef649c4..573dcf04bbf 100644 --- a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts +++ b/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts @@ -1,26 +1,23 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' +import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' -const sessionRouteSource = readFileSync( - new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), - 'utf8' -) +const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx') describe('terminal iOS IME keyboard', () => { it('does not force terminal inputs onto the ASCII-only iOS keyboard', () => { - expect(sessionRouteSource).not.toContain("'ascii-capable'") - expect(sessionRouteSource).not.toContain('"ascii-capable"') + expect(commandDockSource).not.toContain("'ascii-capable'") + expect(commandDockSource).not.toContain('"ascii-capable"') }) it('subscribes live capture to onChange so the marked-text report survives', () => { // onChangeText hands over only a string, discarding the preedit report that // decides whether the text may reach the PTY at all. - expect(sessionRouteSource).toContain('onChange={handleLiveInputChange}') - expect(sessionRouteSource).not.toContain('onChangeText={handleLiveInputChange}') + expect(commandDockSource).toContain('onChange={handleLiveInputChange}') + expect(commandDockSource).not.toContain('onChangeText={handleLiveInputChange}') }) it('does not put terminal keyboard capture behind iOS textContentType semantics', () => { - expect(sessionRouteSource).not.toContain('textContentType="none"') - expect(sessionRouteSource).toContain('autoComplete="off"') + expect(commandDockSource).not.toContain('textContentType="none"') + expect(commandDockSource).toContain('autoComplete="off"') }) }) diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts index 3a09ae3acd4..203db63bc65 100644 --- a/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts +++ b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts @@ -4,11 +4,9 @@ import { Terminal } from '@xterm/xterm' import { describe, expect, it, vi } from 'vitest' import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from './terminal-keyboard-avoidance-metrics-injected' import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract' +import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' -const terminalHtmlSource = readFileSync( - new URL('./terminal-webview-html.ts', import.meta.url), - 'utf8' -) +const terminalHtmlSource = readTerminalWebViewHtmlSource() const reflowSource = readFileSync( new URL('./terminal-webview-reflow-injected.ts', import.meta.url), 'utf8' diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts index 4a7b3bde817..565b355d771 100644 --- a/mobile/src/terminal/terminal-live-input-affordance.test.ts +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -1,9 +1,13 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' -const sessionRouteSource = readFileSync( - new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), - 'utf8' +const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx') +const terminalRuntimeSource = readMobileSessionRouteSource( + '../session/use-mobile-session-terminal-runtime.ts' +) +const nativeChatSource = readMobileSessionRouteSource( + '../session/use-mobile-session-native-chat-dictation.ts' ) const liveInputStatusSource = readFileSync( new URL('../session/MobileTerminalLiveInputStatus.tsx', import.meta.url), @@ -23,11 +27,11 @@ const sendCompletionGenerationSource = readFileSync( ) function liveInputBarBlock(): string { - const start = sessionRouteSource.indexOf('{liveInputEnabled ? (') + const start = commandDockSource.indexOf('{liveInputEnabled ? (') expect(start).toBeGreaterThanOrEqual(0) - const end = sessionRouteSource.indexOf(') : (', start) + const end = commandDockSource.indexOf(') : (', start) expect(end).toBeGreaterThan(start) - return sessionRouteSource.slice(start, end) + return commandDockSource.slice(start, end) } describe('terminal live input affordance', () => { @@ -44,9 +48,9 @@ describe('terminal live input affordance', () => { expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled') expect(block).toContain('showSoftInputOnFocus') expect(block).toContain('liveInputText={liveInputCapture}') - expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({') - expect(sessionRouteSource).toContain('useMobileSendCompletionGeneration({') - expect(sessionRouteSource).toContain('onBlur: resetLiveInputFocus') + expect(terminalRuntimeSource).toContain('useTerminalLiveInputFocus({') + expect(nativeChatSource).toContain('useMobileSendCompletionGeneration({') + expect(nativeChatSource).toContain('onBlur: resetLiveInputFocus') expect(sendCompletionGenerationSource).toContain('return () => {') expect(sendCompletionGenerationSource).toContain('onBlur()') expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current') diff --git a/mobile/src/terminal/terminal-viewport-refit.test.ts b/mobile/src/terminal/terminal-viewport-refit.test.ts index e11c05b9801..e71c5336e56 100644 --- a/mobile/src/terminal/terminal-viewport-refit.test.ts +++ b/mobile/src/terminal/terminal-viewport-refit.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import type { RpcResponse } from '../transport/types' +import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support' import { isTerminalUpdateViewportApplied, isTerminalUpdateViewportUpdated, @@ -13,10 +14,10 @@ import { } from './terminal-viewport-refit-state' const hookSource = readFileSync(new URL('./terminal-viewport-refit.ts', import.meta.url), 'utf8') -const sessionSource = readFileSync( - new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), - 'utf8' -) +const sessionSource = [ + readMobileSessionRouteSource('../session/use-mobile-session-keyboard-state.ts'), + readMobileSessionRouteSource('../session/MobileSessionActiveContent.tsx') +].join('\n') describe('terminal viewport refit', () => { it('refits when the window dimensions change (fold/unfold, rotation)', () => { diff --git a/mobile/src/terminal/terminal-webview-engine.test.ts b/mobile/src/terminal/terminal-webview-engine.test.ts index 503a25a1961..ac415922851 100644 --- a/mobile/src/terminal/terminal-webview-engine.test.ts +++ b/mobile/src/terminal/terminal-webview-engine.test.ts @@ -1,15 +1,14 @@ -import { readFileSync } from 'node:fs' import { Script } from 'node:vm' import { parse } from 'acorn' import { describe, expect, it, vi } from 'vitest' import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' import { XTERM_HTML } from './terminal-webview-html' +import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' -const terminalHtmlSource = readFileSync( - new URL('./terminal-webview-html.ts', import.meta.url), - 'utf8' -) +// Assert against the assembled document so extracted fragments cannot silently +// disappear from the WebView while source-level checks still pass. +const terminalHtmlSource = readTerminalWebViewHtmlSource() function createWebglRecoveryHarness(failSecondAttach = false) { const variablesStart = terminalHtmlSource.indexOf(' var webglAddon = null;') diff --git a/mobile/src/terminal/terminal-webview-html-source.test-support.ts b/mobile/src/terminal/terminal-webview-html-source.test-support.ts new file mode 100644 index 00000000000..2491591572f --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html-source.test-support.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs' + +const SOURCE_FILES = [ + './terminal-webview-html.ts', + ...Array.from( + { length: 10 }, + (_, index) => `./terminal-webview-html/fragment-${String(index + 1).padStart(2, '0')}.ts` + ) +] as const + +/** Reads the TypeScript source that assembles the in-WebView document. */ +export function readTerminalWebViewHtmlSource(): string { + return SOURCE_FILES.map((relativePath) => + readFileSync(new URL(relativePath, import.meta.url), 'utf8') + ).join('\n') +} diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 8d79ecfab67..b55421bbe4d 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -1,1885 +1,28 @@ -// xterm.js WebView document + default Tokyonight theme; extracted from TerminalWebView.tsx for the max-lines budget. -import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' -import { colors } from '../theme/mobile-theme' -import { TERMINAL_TEXT_SCALES } from '../storage/preferences' -import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected' -import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from './terminal-keyboard-avoidance-metrics-injected' -import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' -import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected' -import { TERMINAL_SURFACE_SWAP_JS } from './terminal-webview-surface-swap-injected' -import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected' -import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected' -import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' -import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap' -import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' -import { TERMINAL_MOUSE_CLICK_DRAG_JS } from './terminal-webview-mouse-click-drag-injected' -import { TERMINAL_MOUSE_REPORT_CELL_JS } from './terminal-webview-mouse-report-cell-injected' -import { TERMINAL_WHEEL_SCROLL_JS } from './terminal-webview-wheel-scroll-injected' +import { TERMINAL_HTML_FRAGMENT_01 } from './terminal-webview-html/fragment-01' +import { TERMINAL_HTML_FRAGMENT_02 } from './terminal-webview-html/fragment-02' +import { TERMINAL_HTML_FRAGMENT_03 } from './terminal-webview-html/fragment-03' +import { TERMINAL_HTML_FRAGMENT_04 } from './terminal-webview-html/fragment-04' +import { TERMINAL_HTML_FRAGMENT_05 } from './terminal-webview-html/fragment-05' +import { TERMINAL_HTML_FRAGMENT_06 } from './terminal-webview-html/fragment-06' +import { TERMINAL_HTML_FRAGMENT_07 } from './terminal-webview-html/fragment-07' +import { TERMINAL_HTML_FRAGMENT_08 } from './terminal-webview-html/fragment-08' +import { TERMINAL_HTML_FRAGMENT_09 } from './terminal-webview-html/fragment-09' +import { TERMINAL_HTML_FRAGMENT_10 } from './terminal-webview-html/fragment-10' + +export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme' + +// Why: keep the document source stable while each script/style concern remains independently reviewable. +export const XTERM_HTML = [ + TERMINAL_HTML_FRAGMENT_01, + TERMINAL_HTML_FRAGMENT_02, + TERMINAL_HTML_FRAGMENT_03, + TERMINAL_HTML_FRAGMENT_04, + TERMINAL_HTML_FRAGMENT_05, + TERMINAL_HTML_FRAGMENT_06, + TERMINAL_HTML_FRAGMENT_07, + TERMINAL_HTML_FRAGMENT_08, + TERMINAL_HTML_FRAGMENT_09, + TERMINAL_HTML_FRAGMENT_10 +].join('') -const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = { - background: colors.terminalBg, - foreground: '#c0caf5', - cursor: '#c0caf5', - cursorAccent: colors.terminalBg, - selectionBackground: '#33467c', - selectionForeground: '#c0caf5', - black: '#15161e', - red: '#f7768e', - green: '#9ece6a', - yellow: '#e0af68', - blue: '#7aa2f7', - magenta: '#bb9af7', - cyan: '#7dcfff', - white: '#a9b1d6', - brightBlack: '#414868', - brightRed: '#f7768e', - brightGreen: '#9ece6a', - brightYellow: '#e0af68', - brightBlue: '#7aa2f7', - brightMagenta: '#bb9af7', - brightCyan: '#7dcfff', - brightWhite: '#c0caf5' -} - -export const MOBILE_TERMINAL_CARET_OPTIONS = { - cursorBlink: false, - cursorStyle: 'bar', - showCursorImmediately: true, - cursorInactiveStyle: 'block' -} as const - -// Why: TUI escape codes assume the desktop's cols/rows, so init xterm at those dims and fit the phone via a measured CSS scale() instead of resizing. -export const XTERM_HTML = ` - - - - - - - - - -
-
-
-
-
-
-
-
- - -
-
- - - -` - -// Why: some WebViews treat source identity as page identity; keep this stable so re-renders don't reload xterm. export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML } diff --git a/mobile/src/terminal/terminal-webview-html/fragment-01.ts b/mobile/src/terminal/terminal-webview-html/fragment-01.ts new file mode 100644 index 00000000000..77dba06232a --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/fragment-01.ts @@ -0,0 +1,173 @@ +import { colors } from '../../theme/mobile-theme' +import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from '../terminal-webview-engine.generated' + +export const TERMINAL_HTML_FRAGMENT_01 = ` + + + + + + + + + +
+
+
+
+
+
+
+
+ + +
+
+ + + +` diff --git a/mobile/src/terminal/terminal-webview-html/theme.ts b/mobile/src/terminal/terminal-webview-html/theme.ts new file mode 100644 index 00000000000..185405f04d1 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/theme.ts @@ -0,0 +1,34 @@ +import type { RuntimeMobileTerminalTheme } from '../../../../src/shared/runtime-types' +import { colors } from '../../theme/mobile-theme' + +export const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = { + background: colors.terminalBg, + foreground: '#c0caf5', + cursor: '#c0caf5', + cursorAccent: colors.terminalBg, + selectionBackground: '#33467c', + selectionForeground: '#c0caf5', + black: '#15161e', + red: '#f7768e', + green: '#9ece6a', + yellow: '#e0af68', + blue: '#7aa2f7', + magenta: '#bb9af7', + cyan: '#7dcfff', + white: '#a9b1d6', + brightBlack: '#414868', + brightRed: '#f7768e', + brightGreen: '#9ece6a', + brightYellow: '#e0af68', + brightBlue: '#7aa2f7', + brightMagenta: '#bb9af7', + brightCyan: '#7dcfff', + brightWhite: '#c0caf5' +} + +export const MOBILE_TERMINAL_CARET_OPTIONS = { + cursorBlink: false, + cursorStyle: 'bar', + showCursorImmediately: true, + cursorInactiveStyle: 'block' +} as const diff --git a/mobile/src/terminal/terminal-webview-reflow.test.ts b/mobile/src/terminal/terminal-webview-reflow.test.ts index 5fb78bb33ec..90112740d1d 100644 --- a/mobile/src/terminal/terminal-webview-reflow.test.ts +++ b/mobile/src/terminal/terminal-webview-reflow.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { XTERM_HTML } from './terminal-webview-html' +import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' // The reflow logic lives as injected in-WebView JS; the message dispatch and // handle wiring live in terminal-webview-html.ts / TerminalWebView.tsx. Assert @@ -9,7 +10,8 @@ const reflowSource = readFileSync( new URL('./terminal-webview-reflow-injected.ts', import.meta.url), 'utf8' ) -const htmlSource = readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8') +// Use the assembled document so the test covers the fragments that run in the WebView. +const htmlSource = readTerminalWebViewHtmlSource() const handleSource = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') function reflowFnBody(): string { diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index 32bc6dbd529..962cfddaa4d 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -8,9 +8,18 @@ const source = readFileSync(new URL('./terminal-webview-pending-messages.ts', import.meta.url), 'utf8') + readFileSync(new URL('./terminal-webview-url-tap.ts', import.meta.url), 'utf8') + readFileSync(new URL('./terminal-webview-tap-dispatch-injected.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8') + readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8') + + Array.from({ length: 10 }, (_, index) => + readFileSync( + new URL( + `./terminal-webview-html/fragment-${String(index + 1).padStart(2, '0')}.ts`, + import.meta.url + ), + 'utf8' + ) + ).join('') const sessionSource = readFileSync( - new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), + new URL('../session/use-mobile-session-terminal-input.ts', import.meta.url), 'utf8' ) const sessionHelperSource = readFileSync( diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index cf16a93c0e6..0deee79f670 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -1,15 +1,23 @@ import { readFileSync } from 'node:fs' import { Script } from 'node:vm' import { describe, expect, it } from 'vitest' +import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' const terminalWebViewSource = readFileSync( new URL('./TerminalWebView.tsx', import.meta.url), 'utf8' ) -const terminalHtmlSource = readFileSync( +const terminalHtmlModuleSource = readFileSync( new URL('./terminal-webview-html.ts', import.meta.url), 'utf8' ) +const terminalHtmlFragmentSource = readFileSync( + new URL('./terminal-webview-html/fragment-01.ts', import.meta.url), + 'utf8' +) +// Read behavior from the assembled document; the module source only contains +// fragment imports and cannot prove the injected code is present. +const terminalHtmlSource = readTerminalWebViewHtmlSource() const terminalWebglRecoverySource = readFileSync( new URL('./terminal-webview-webgl-recovery-injected.ts', import.meta.url), 'utf8' @@ -75,7 +83,9 @@ describe('TerminalWebView text zoom', () => { const end = terminalWebViewSource.indexOf('/>', start) expect(end).toBeGreaterThan(start) const webViewProps = terminalWebViewSource.slice(start, end) - expect(terminalHtmlSource).toContain('export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }') + expect(terminalHtmlModuleSource).toContain( + 'export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }' + ) expect(webViewProps).toContain('source={XTERM_WEBVIEW_SOURCE}') expect(webViewProps).not.toContain('source={{ html: XTERM_HTML }}') }) @@ -140,7 +150,7 @@ describe('TerminalWebView text zoom', () => { }) it('loads Unicode 11 before replaying mobile terminal bytes', () => { - expect(terminalHtmlSource).toContain('XTERM_ENGINE_JS') + expect(terminalHtmlFragmentSource).toContain('XTERM_ENGINE_JS') expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon') const open = terminalHtmlSource.indexOf('term.open(surface)') const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'") diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index 9ac6135cd3d..ab0868b1345 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -1,2298 +1,5 @@ -/* eslint-disable max-lines -- Why: this file is a command adapter for one external surface, Agent Browser automation. It stays separate from OrcaRuntimeService so runtime state does not grow further while browser routing remains easy to scan in one place. */ -import { randomUUID } from 'node:crypto' -import { ipcMain, webContents, type BrowserWindow } from 'electron' -import type { - BrowserBackResult, - BrowserCaptureStartResult, - BrowserCheckResult, - BrowserCaptureStopResult, - BrowserClearResult, - BrowserClickResult, - BrowserConsoleResult, - BrowserCookieDeleteResult, - BrowserCookieGetResult, - BrowserCookieSetResult, - BrowserDetectProfilesResult, - BrowserDragResult, - BrowserEvalResult, - BrowserFillResult, - BrowserFocusResult, - BrowserGeolocationResult, - BrowserGotoResult, - BrowserHoverResult, - BrowserInterceptDisableResult, - BrowserInterceptEnableResult, - BrowserKeypressResult, - BrowserNetworkLogResult, - BrowserPdfResult, - BrowserProfileClearDefaultCookiesResult, - BrowserProfileCreateResult, - BrowserProfileDeleteResult, - BrowserProfileImportFromBrowserResult, - BrowserProfileListResult, - BrowserReloadResult, - BrowserScreenshotResult, - BrowserScreencastResult, - BrowserScrollResult, - BrowserSelectAllResult, - BrowserSelectResult, - BrowserSnapshotResult, - BrowserTabCurrentResult, - BrowserTabListResult, - BrowserTabProfileCloneResult, - BrowserTabProfileShowResult, - BrowserTabSetProfileResult, - BrowserTabShowResult, - BrowserTabSwitchResult, - BrowserTypeResult, - BrowserUploadResult, - BrowserViewportResult, - BrowserWaitResult -} from '../../shared/runtime-types' -import type { - BrowserCertificateProceedResult, - BrowserSessionUserAgentMode -} from '../../shared/browser-workspace-types' -import type { BrowserNetworkExecutionHost } from '../../shared/browser-client-host-protocol' -import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY } from '../../shared/browser-client-automation-protocol' -import type { BrowserPageCreationPlacement } from '../../shared/browser-client-host-placement' -import type { ExecutionHostId } from '../../shared/execution-host' -import { browserNetworkExecutionHostKey } from '../browser/browser-network-execution-route' -import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' -import type { BrowserBackend } from '../browser/browser-backend' -import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' -import { BrowserError } from '../browser/cdp-bridge' -import { startBrowserScreencast } from '../browser/browser-screencast-stream' -import { - browserScreencastFrameBudgetsEqual, - mergeBrowserScreencastFrameBudgets -} from '../browser/browser-screencast-frame-budget' -import type { - BrowserScreencastFrameBudget, - BrowserScreencastSession, - BrowserScreencastViewport -} from '../browser/browser-screencast-stream-types' -import { browserSessionRegistry } from '../browser/browser-session-registry' -import { - detectInstalledBrowsers, - importCookiesFromBrowser, - selectBrowserProfile -} from '../browser/browser-cookie-import' -import { - waitForTabRegistration, - waitForWorktreeTabRegistration -} from '../ipc/browser-tab-registration-wait' -import { sendRemoteBrowserScreencastFrame } from './remote-browser-screencast-frame-admission' -import { - INITIAL_SCREENCAST_SUBSCRIBER_DELIVERY, - recordScreencastSubscriberSend, - screencastSubscriberIsGhost, - type ScreencastSubscriberDeliveryState -} from './browser-screencast-ghost-subscriber-eviction' -import { - publishCreatedBrowserSessionTab, - publishSwitchedBrowserSessionTab, - resolveBrowserTabCreateFocus, - type BrowserSessionTabSelectionOptions -} from './browser-tab-create-publication' -import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' -import type { BrowserHostLeaseRegistry } from './browser-host-lease-registry' -import { BROWSER_HOST_WEBVIEW_CAPABILITY } from './browser-host-capability-selection' -import { - closeRuntimeBrowserClientPage, - createRuntimeBrowserClientPage, - navigateRuntimeBrowserClientPage -} from './runtime-browser-client-page-creation' -import type { - RuntimeBrowserClientPage, - RuntimeBrowserPageRegistry -} from './runtime-browser-page-registry' +import { RuntimeBrowserCommandsWithListLogicalBrowserTabs } from './runtime-browser-commands-list-logical-browser-tabs' -export type BrowserCommandTargetParams = { - worktree?: string - page?: string -} - -type ResolvedBrowserCommandTarget = { - worktreeId?: string - browserPageId?: string -} - -type ResolvedBrowserPageWebContents = { - browserPageId: string - webContents: Electron.WebContents -} - -type BrowserScreencastParams = { - format: 'jpeg' | 'png' - quality?: number - maxWidth?: number - maxHeight?: number - viewportWidth?: number - viewportHeight?: number - deviceScaleFactor?: number - mobile?: boolean - everyNthFrame?: number - minFrameIntervalMs?: number -} & BrowserCommandTargetParams - -type BrowserScreencastStartResult = { - subscriptionId: string - ready: Extract - // The frame budget belongs to the shared page, not to one subscriber's handle. - session: Omit - // Why: callers gate frames until they have emitted `ready`, and the snapshot captured - // for a joining subscriber lands inside that window. This replays it once the gate opens. - flushPendingFrame: () => void -} - -type ActiveBrowserScreencastSubscriber = { - sendBinary: (bytes: Uint8Array) => boolean | void - emit?: (event: BrowserScreencastResult) => void - done: Promise - resolveDone: () => void - viewport: BrowserScreencastViewport - budget: BrowserScreencastFrameBudget - pendingFrame: Uint8Array | null - // Why: identifies the viewer across reconnects, which the RPC connectionId cannot — a new - // socket never reuses the old id, so a reconnecting device would stack a second subscription. - pairedDeviceId?: string - delivery: ScreencastSubscriberDeliveryState -} - -type ActiveBrowserScreencastPage = { - format: 'jpeg' | 'png' - session: BrowserScreencastSession | null - started: Promise - stopping: boolean - subscribers: Map - viewportOwnerSubscriptionId: string | null - appliedBudget: BrowserScreencastFrameBudget -} - -async function applySharedScreencastFrameBudget( - active: ActiveBrowserScreencastPage, - session: BrowserScreencastSession -): Promise { - const merged = mergeBrowserScreencastFrameBudgets( - Array.from(active.subscribers.values(), (subscriber) => subscriber.budget) - ) - if (!merged || browserScreencastFrameBudgetsEqual(merged, active.appliedBudget)) { - return - } - active.appliedBudget = merged - await session.updateFrameBudget(merged) -} - -function normalizeScreencastViewport(params: BrowserScreencastParams): BrowserScreencastViewport { - return { - viewportWidth: clampOptionalInteger(params.viewportWidth, 320, 3840), - viewportHeight: clampOptionalInteger(params.viewportHeight, 240, 2160), - deviceScaleFactor: clampOptionalNumber(params.deviceScaleFactor, 1, 4), - mobile: params.mobile === true - } -} - -function normalizeScreencastFrameBudget( - params: BrowserScreencastParams -): BrowserScreencastFrameBudget { - return { - quality: clampInteger(params.quality, 10, 100, 70), - maxWidth: clampInteger(params.maxWidth, 320, 3840, 1440), - maxHeight: clampInteger(params.maxHeight, 240, 2160, 1200), - everyNthFrame: clampInteger(params.everyNthFrame, 1, 10, 2), - minFrameIntervalMs: clampInteger(params.minFrameIntervalMs, 0, 1000, 0) - } -} - -function hasScreencastViewportSize(viewport: BrowserScreencastViewport): boolean { - return viewport.viewportWidth !== undefined && viewport.viewportHeight !== undefined -} - -function clampInteger( - value: number | undefined, - min: number, - max: number, - fallback: number -): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return fallback - } - return Math.min(max, Math.max(min, Math.round(value))) -} - -function clampOptionalInteger( - value: number | undefined, - min: number, - max: number -): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return undefined - } - return Math.min(max, Math.max(min, Math.round(value))) -} - -function clampOptionalNumber( - value: number | undefined, - min: number, - max: number -): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return undefined - } - return Math.min(max, Math.max(min, value)) -} - -export type RuntimeBrowserCommandHost = { - getAgentBrowserBridge(): AgentBrowserBridge | null - resolveWorktreeSelector(selector: string): Promise<{ - id: string - repoId?: string - hostId?: ExecutionHostId - }> - resolveBrowserWorkspace(selector: string): Promise<{ - id: string - repoId?: string - hostId?: ExecutionHostId - }> - resolveBrowserNetworkExecutionHost(worktree?: { - id: string - repoId?: string - hostId?: ExecutionHostId - }): BrowserNetworkExecutionHost | Promise - getBrowserHostLeaseRegistry(): BrowserHostLeaseRegistry - getRuntimeBrowserPageRegistry(): RuntimeBrowserPageRegistry - getAuthoritativeWindow(): BrowserWindow - getAvailableAuthoritativeWindow(): BrowserWindow | null - // Why: headless serve backs pages with a main-process offscreen backend; null when the environment can't support offscreen browsing. - getOffscreenBrowserBackend(): BrowserBackend | null - // Why: the session-tab snapshot owns focus, so a headless create must mark itself active or paired clients snap back to a terminal. - markHeadlessBrowserSessionTabActive?( - worktreeId: string | undefined, - browserPageId: string, - options?: BrowserSessionTabSelectionOptions - ): void - notifyHeadlessBrowserSessionTabsChanged?(worktreeId: string): void - /** True when a runtime-owned session row for that page existed and was retired. */ - retireRuntimeOwnedBrowserSessionTab?(worktreeId: string, browserPageId: string): boolean | void -} - -export class RuntimeBrowserCommands { - private readonly activeScreencastsByPageId = new Map() - - constructor(private readonly host: RuntimeBrowserCommandHost) {} - - private requireAgentBrowserBridge(): AgentBrowserBridge { - const bridge = this.host.getAgentBrowserBridge() - if (!bridge) { - throw new BrowserError('browser_no_tab', 'No browser session is active') - } - return bridge - } - - /** - * Retires a session row for a page nothing here can close any more. - * - * A client-hosted page whose runtime record is gone -- released as unrecoverable, or created by a - * build that kept its records in memory only -- still leaves a row every paired device can see, - * and every close path below is keyed on a live guest this runtime does not have. Without this - * the row's X fails closed and the ghost outlives the browser it named. - */ - private retireGhostBrowserSessionRow( - worktreeId: string | undefined, - browserPageId: string - ): boolean { - return ( - worktreeId !== undefined && - this.host.retireRuntimeOwnedBrowserSessionTab?.(worktreeId, browserPageId) === true - ) - } - - private hasLiveRegisteredBrowserTab( - bridge: AgentBrowserBridge, - worktreeId: string | undefined - ): boolean { - for (const [, webContentsId] of bridge.getRegisteredTabs(worktreeId)) { - const guest = webContents.fromId(webContentsId) - if (guest && !guest.isDestroyed()) { - return true - } - } - return false - } - - private hasLiveRegisteredBrowserPage( - bridge: AgentBrowserBridge, - worktreeId: string | undefined, - browserPageId: string - ): boolean { - const webContentsId = bridge.getRegisteredTabs(worktreeId).get(browserPageId) - if (webContentsId == null) { - return false - } - const guest = webContents.fromId(webContentsId) - return Boolean(guest && !guest.isDestroyed()) - } - - // Why: the CLI sends selectors (e.g. "path:/...") but the bridge keys tabs by "repoId::path"; resolve to that store-compatible id. - private async resolveBrowserWorktreeId(selector?: string): Promise { - if (!selector) { - // Why: after restart, webviews mount only when the pane is visible; activate the view so persisted tabs become operable via registerGuest. - const bridge = this.host.getAgentBrowserBridge() - if (bridge && !this.hasLiveRegisteredBrowserTab(bridge, undefined)) { - try { - await this.ensureBrowserWorktreeActive(undefined) - } catch { - // Window may not exist yet (e.g. during startup or in tests) - } - } - return undefined - } - - const worktreeId = (await this.host.resolveWorktreeSelector(selector)).id - // Why: explicit selectors are user intent, so resolution errors surface (not silently widen scope); only activation stays best-effort. - const bridge = this.host.getAgentBrowserBridge() - if (bridge && !this.hasLiveRegisteredBrowserTab(bridge, worktreeId)) { - try { - await this.ensureBrowserWorktreeActive(worktreeId) - } catch { - // Fall through with the validated worktree id so routing stays scoped to the caller's explicit selector. - } - } - return worktreeId - } - - private async resolveBrowserCommandTarget( - params: BrowserCommandTargetParams - ): Promise { - const browserPageId = - typeof params.page === 'string' && params.page.length > 0 ? params.page : undefined - if (!browserPageId) { - return { - worktreeId: await this.resolveBrowserWorktreeId(params.worktree) - } - } - - const worktreeId = params.worktree - ? (await this.host.resolveWorktreeSelector(params.worktree)).id - : undefined - const bridge = this.host.getAgentBrowserBridge() - if (bridge && !this.hasLiveRegisteredBrowserPage(bridge, worktreeId, browserPageId)) { - try { - await this.ensureBrowserPageActive(worktreeId, browserPageId) - } catch { - // Fall through with the explicit page target; downstream routing surfaces a clear "tab not found" error if wake fails. - } - } - return { - // Why: an explicit browserPageId is already a stable tab identity, so don't auto-resolve cwd worktree scoping on top of it. - worktreeId, - browserPageId - } - } - - private resolveBrowserPageWebContents( - worktreeId: string | undefined, - browserPageId: string | undefined - ): ResolvedBrowserPageWebContents { - const bridge = this.requireAgentBrowserBridge() - const resolvedPageId = browserPageId ?? bridge.getActivePageId(worktreeId) - if (!resolvedPageId) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - const webContentsId = bridge.getRegisteredTabs(worktreeId).get(resolvedPageId) - if (webContentsId == null) { - const scope = worktreeId ? ' in this worktree' : '' - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${resolvedPageId} was not found${scope}` - ) - } - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${resolvedPageId} is no longer available` - ) - } - return { browserPageId: resolvedPageId, webContents: guest } - } - - // Why: background-mount the worktree via a hidden visibility lease so the webview guest can register without stealing the user's visible pane. - private async ensureBrowserWorktreeActive(worktreeId: string | undefined): Promise { - const win = this.host.getAuthoritativeWindow() - win.webContents.send('browser:activateView', worktreeId ? { worktreeId } : {}) - // Why: the pane is operable only after the webview mounts and calls registerGuest; wait on that IPC rather than a flaky fixed sleep. - await waitForWorktreeTabRegistration(worktreeId) - } - - private async ensureBrowserPageActive( - worktreeId: string | undefined, - browserPageId: string - ): Promise { - const win = this.host.getAuthoritativeWindow() - win.webContents.send( - 'browser:activateView', - worktreeId ? { worktreeId, browserPageId } : { browserPageId } - ) - await waitForTabRegistration(browserPageId) - } - - // Why: helper-driven clicks can bypass Electron navigation events; push authoritative URL/title updates after automation. - private notifyRendererNavigation(browserPageId: string, url: string, title: string): void { - try { - const win = this.host.getAuthoritativeWindow() - win.webContents.send('browser:navigation-update', { browserPageId, url, title }) - } catch { - // Window may not exist during shutdown - } - } - - // Why: carry worktreeId (not a global setActiveWorktree) so one agent's --focus can't steal the screen from another agent's parallel worktree. - private notifyRendererBrowserPaneFocus( - worktreeId: string | undefined, - browserPageId: string - ): void { - try { - const win = this.host.getAuthoritativeWindow() - win.webContents.send('browser:pane-focus', { - worktreeId: worktreeId ?? null, - browserPageId - }) - } catch { - // Window may not exist during shutdown - } - } - - async browserSnapshot(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().snapshot(target.worktreeId, target.browserPageId) - } - - async browserClick( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.click(params.element, target.worktreeId, target.browserPageId) - // Why: clicks can trigger navigation, so push the tab's live URL/title to the renderer even when automation targeted a non-active page. - const page = bridge.getPageInfo(target.worktreeId, target.browserPageId) - if (page) { - this.notifyRendererNavigation(page.browserPageId, page.url, page.title) - } - return result - } - - async browserGoto( - params: { url: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.goto(params.url, target.worktreeId, target.browserPageId) - const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) - if (pageId) { - this.notifyRendererNavigation(pageId, result.url, result.title) - } - if (!this.host.getAvailableAuthoritativeWindow() && target.worktreeId) { - this.host.notifyHeadlessBrowserSessionTabsChanged?.(target.worktreeId) - } - return result - } - - async browserFill( - params: { - element: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().fill( - params.element, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserType( - params: { input: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().type( - params.input, - target.worktreeId, - target.browserPageId - ) - } - - async browserSelect( - params: { - element: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().select( - params.element, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserScroll( - params: { direction: 'up' | 'down'; amount?: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().scroll( - params.direction, - params.amount, - target.worktreeId, - target.browserPageId - ) - } - - async browserBack(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.back(target.worktreeId, target.browserPageId) - const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) - if (pageId) { - this.notifyRendererNavigation(pageId, result.url, result.title) - } - return result - } - - async browserReload(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.reload(target.worktreeId, target.browserPageId) - const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) - if (pageId) { - this.notifyRendererNavigation(pageId, result.url, result.title) - } - return result - } - - async browserScreenshot( - params: { - format?: 'png' | 'jpeg' - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().screenshot( - params.format, - target.worktreeId, - target.browserPageId - ) - } - - // The single leave path: an explicit stop, a ghost eviction and a same-device replacement all - // unwind through here, so viewport hand-off, budget release and stream teardown cannot drift. - private leaveScreencastSubscriber( - active: ActiveBrowserScreencastPage, - subscriptionId: string, - session: BrowserScreencastSession - ): void { - const subscriber = active.subscribers.get(subscriptionId) - if (!subscriber) { - return - } - active.subscribers.delete(subscriptionId) - subscriber.resolveDone() - if (active.viewportOwnerSubscriptionId === subscriptionId) { - const fallback = Array.from(active.subscribers.entries()).findLast(([, candidate]) => - hasScreencastViewportSize(candidate.viewport) - ) - active.viewportOwnerSubscriptionId = fallback?.[0] ?? null - if (fallback) { - void session.updateViewport(fallback[1].viewport).catch(() => {}) - } - } - if (active.subscribers.size === 0) { - active.stopping = true - session.stop() - return - } - // Why: a departed subscriber's caps would otherwise pin the shared stream for - // the rest of its life, long after the client that asked for them is gone. - void applySharedScreencastFrameBudget(active, session).catch(() => {}) - } - - async browserScreencast( - params: BrowserScreencastParams, - stream: { - sendBinary: (bytes: Uint8Array) => boolean | void - emit?: (event: BrowserScreencastResult) => void - pairedDeviceId?: string - } - ): Promise { - if (await this.resolveClientHostedBrowserPage(params)) { - throw new BrowserError( - 'browser_error', - 'Client-hosted browser pages do not support server screencast.' - ) - } - const target = await this.resolveBrowserCommandTarget(params) - const { browserPageId, webContents: guest } = this.resolveBrowserPageWebContents( - target.worktreeId, - target.browserPageId - ) - const subscriptionId = `browser-screencast:${browserPageId}:${randomUUID()}` - const viewport = normalizeScreencastViewport(params) - const budget = normalizeScreencastFrameBudget(params) - let resolveSubscriberDone!: () => void - const subscriberDone = new Promise((resolve) => { - resolveSubscriberDone = resolve - }) - let createdPageStream = false - let active = this.activeScreencastsByPageId.get(browserPageId) - while (active?.stopping) { - await active.session?.done - active = this.activeScreencastsByPageId.get(browserPageId) - } - if (!active) { - createdPageStream = true - const subscribers = new Map() - const record = { - format: params.format, - session: null, - stopping: false, - subscribers, - viewportOwnerSubscriptionId: null, - appliedBudget: budget - } as ActiveBrowserScreencastPage - record.started = startBrowserScreencast(guest, { - format: params.format, - ...budget, - ...viewport, - onFrame: (bytes) => { - const ghosts: string[] = [] - for (const [subscriptionId, subscriber] of record.subscribers) { - // A slow viewer drops this frame without stalling every other viewer, but the - // newest refusal is retained so a gate that opens later can still be filled. - const delivered = sendRemoteBrowserScreencastFrame(subscriber.sendBinary, bytes) - subscriber.pendingFrame = delivered ? null : bytes - subscriber.delivery = recordScreencastSubscriberSend(subscriber.delivery, delivered) - if (screencastSubscriberIsGhost(subscriber.delivery)) { - ghosts.push(subscriptionId) - } - } - // Evicting after the fan-out keeps a teardown that stops the session from cutting the - // remaining viewers out of this frame. - for (const subscriptionId of ghosts) { - if (record.session) { - this.leaveScreencastSubscriber(record, subscriptionId, record.session) - } - } - return true - }, - onEvent: (event) => { - for (const subscriber of record.subscribers.values()) { - // Navigation events are a browser-stream implementation detail; the - // RPC contract forwards dialog lifecycle events only. - if (event.type === 'dialog' || event.type === 'dialogClosed') { - subscriber.emit?.(event) - } - } - }, - onError: (message) => { - for (const subscriber of record.subscribers.values()) { - subscriber.emit?.({ type: 'error', message }) - } - } - }) - active = record - this.activeScreencastsByPageId.set(browserPageId, record) - void record.started - .then((session) => { - record.session = session - return session.done - }) - .finally(() => { - if (this.activeScreencastsByPageId.get(browserPageId) === record) { - this.activeScreencastsByPageId.delete(browserPageId) - } - for (const subscriber of record.subscribers.values()) { - subscriber.resolveDone() - } - record.subscribers.clear() - }) - .catch(() => {}) - } - active.subscribers.set(subscriptionId, { - sendBinary: stream.sendBinary, - emit: stream.emit, - done: subscriberDone, - resolveDone: resolveSubscriberDone, - viewport, - budget, - pendingFrame: null, - pairedDeviceId: stream.pairedDeviceId, - delivery: INITIAL_SCREENCAST_SUBSCRIBER_DELIVERY - }) - // Why: normalizeScreencastViewport keeps undefined dimensions, so a sizeless - // subscriber taking ownership would clear the emulation for every viewer. - if (hasScreencastViewportSize(viewport)) { - active.viewportOwnerSubscriptionId = subscriptionId - } - let session: BrowserScreencastSession - try { - session = await active.started - } catch (error) { - active.subscribers.delete(subscriptionId) - resolveSubscriberDone() - throw error - } - // Why: a device that force-quit and reconnected arrives on a fresh socket, so the - // connection-keyed replacement upstream cannot see its old subscription. Run this after the - // joiner is registered — the page then never empties mid-replacement and stops the stream. - if (stream.pairedDeviceId !== undefined) { - // Deleting the entry being visited is well defined for a Map, and no other entry is touched. - for (const [candidateId, candidate] of active.subscribers) { - if (candidateId !== subscriptionId && candidate.pairedDeviceId === stream.pairedDeviceId) { - this.leaveScreencastSubscriber(active, candidateId, session) - } - } - } - if (!createdPageStream) { - if (active.viewportOwnerSubscriptionId === subscriptionId) { - await session.updateViewport(viewport) - } - await applySharedScreencastFrameBudget(active, session) - } - return { - subscriptionId, - flushPendingFrame: () => { - const subscriber = active.subscribers.get(subscriptionId) - const bytes = subscriber?.pendingFrame - if (!subscriber || !bytes) { - return - } - const delivered = sendRemoteBrowserScreencastFrame(subscriber.sendBinary, bytes) - subscriber.pendingFrame = delivered ? null : bytes - // The replay is this subscriber's first chance to reach its socket, so it is also where - // an eviction-eligible delivery history starts. - subscriber.delivery = recordScreencastSubscriberSend(subscriber.delivery, delivered) - }, - session: { - done: subscriberDone, - stop: () => this.leaveScreencastSubscriber(active, subscriptionId, session), - updateViewport: session.updateViewport - }, - ready: { - type: 'ready', - subscriptionId, - browserPageId, - format: active.format, - tab: this.describeBrowserTab(browserPageId, target.worktreeId) - } - } - } - - async browserEval( - params: { expression: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().evaluate( - params.expression, - target.worktreeId, - target.browserPageId - ) - } - - async browserTabList(params: { worktree?: string }): Promise { - const workspaceId = params.worktree - ? (await this.host.resolveBrowserWorkspace(params.worktree)).id - : undefined - const clientPages = this.host.getRuntimeBrowserPageRegistry().listPages(workspaceId) - let bridgeWorktreeId = workspaceId - if (this.host.getAgentBrowserBridge()) { - try { - bridgeWorktreeId = await this.resolveBrowserWorktreeId(params.worktree) - } catch (error) { - if (clientPages.length === 0) { - throw error - } - } - } - return { tabs: this.listLogicalBrowserTabs(bridgeWorktreeId, clientPages) } - } - - async browserProceedCertificate( - params: { challengeId: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - if (!target.browserPageId) { - return { ok: false, reason: 'missing' } - } - return browserCertificateTrustController.proceed(target.browserPageId, params.challengeId) - } - - async browserTabShow(params: { page: string; worktree?: string }): Promise { - const clientPage = this.host.getRuntimeBrowserPageRegistry().getPage(params.page) - if (clientPage) { - await this.assertClientPageWorkspace(clientPage, params.worktree) - const tab = this.listLogicalBrowserTabs( - clientPage.workspaceId, - this.host.getRuntimeBrowserPageRegistry().listPages(clientPage.workspaceId) - ).find((candidate) => candidate.browserPageId === clientPage.browserPageId) - if (!tab) { - throw new BrowserError('browser_tab_not_found', `Browser page ${params.page} was not found`) - } - return { tab } - } - const target = await this.resolveBrowserCommandTarget(params) - return { tab: this.describeBrowserTab(params.page, target.worktreeId) } - } - - async browserTabCurrent(params: { worktree?: string }): Promise { - const tab = (await this.browserTabList(params)).tabs.find((candidate) => candidate.active) - if (!tab) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - return { tab } - } - - async browserTabSwitch( - params: { - index?: number - focus?: boolean - } & BrowserCommandTargetParams - ): Promise { - const listed = await this.browserTabList({ worktree: params.worktree }) - const switchedIndex = params.page - ? listed.tabs.findIndex((tab) => tab.browserPageId === params.page) - : (params.index ?? -1) - const selected = listed.tabs[switchedIndex] - if (!selected) { - const label = params.page ? `Browser page ${params.page}` : `Tab index ${params.index}` - throw new BrowserError( - 'browser_tab_not_found', - `${label} out of range (0-${listed.tabs.length - 1})` - ) - } - const clientPage = this.host.getRuntimeBrowserPageRegistry().getPage(selected.browserPageId) - if (clientPage) { - this.host - .getRuntimeBrowserPageRegistry() - .activatePage(clientPage.browserPageId, clientPage.placement) - publishSwitchedBrowserSessionTab(this.host, { - placementKind: 'client', - browserPageId: clientPage.browserPageId, - worktreeId: clientPage.workspaceId, - focus: params.focus - }) - return { switched: switchedIndex, browserPageId: clientPage.browserPageId } - } - const bridge = this.requireAgentBrowserBridge() - const worktreeId = - typeof selected.worktreeId === 'string' - ? selected.worktreeId - : params.worktree - ? (await this.host.resolveBrowserWorkspace(params.worktree)).id - : undefined - const result = await bridge.tabSwitch(undefined, worktreeId, selected.browserPageId) - this.host.getRuntimeBrowserPageRegistry().deactivateGlobal() - if (worktreeId) { - this.host.getRuntimeBrowserPageRegistry().deactivateWorkspace(worktreeId) - } - // Why: scope focus to the tab's owning worktree; the renderer never yanks the user across worktrees on this signal (see focusBrowserTabInWorktree). - const focusWorktreeId = - worktreeId ?? browserManager.getWorktreeIdForTab(result.browserPageId) ?? undefined - publishSwitchedBrowserSessionTab(this.host, { - placementKind: 'bridge', - browserPageId: result.browserPageId, - worktreeId: focusWorktreeId, - focus: params.focus - }) - if (params.focus) { - this.notifyRendererBrowserPaneFocus(focusWorktreeId, result.browserPageId) - } - return { ...result, switched: switchedIndex } - } - - async browserHover( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().hover( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserDrag( - params: { - from: string - to: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().drag( - params.from, - params.to, - target.worktreeId, - target.browserPageId - ) - } - - async browserUpload( - params: { element: string; files: string[] } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().upload( - params.element, - params.files, - target.worktreeId, - target.browserPageId - ) - } - - async browserWait( - params: { - selector?: string - timeout?: number - text?: string - url?: string - load?: string - fn?: string - state?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const { worktree: _, page: __, ...options } = params - return this.requireAgentBrowserBridge().wait(options, target.worktreeId, target.browserPageId) - } - - async browserCheck( - params: { element: string; checked: boolean } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().check( - params.element, - params.checked, - target.worktreeId, - target.browserPageId - ) - } - - async browserFocus( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().focus( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserClear( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().clear( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserSelectAll( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().selectAll( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserKeypress( - params: { key: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().keypress( - params.key, - target.worktreeId, - target.browserPageId - ) - } - - async browserPdf(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().pdf(target.worktreeId, target.browserPageId) - } - - async browserFullScreenshot( - params: { - format?: 'png' | 'jpeg' - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().fullPageScreenshot( - params.format, - target.worktreeId, - target.browserPageId - ) - } - - // ── Cookie management ── - - async browserCookieGet( - params: { url?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().cookieGet( - params.url, - target.worktreeId, - target.browserPageId - ) - } - - async browserCookieSet( - params: { - name: string - value: string - domain?: string - path?: string - secure?: boolean - httpOnly?: boolean - sameSite?: string - expires?: number - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().cookieSet( - params, - target.worktreeId, - target.browserPageId - ) - } - - async browserCookieDelete( - params: { - name: string - domain?: string - url?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().cookieDelete( - params.name, - params.domain, - params.url, - target.worktreeId, - target.browserPageId - ) - } - - // ── Viewport ── - - async browserSetViewport( - params: { - width: number - height: number - deviceScaleFactor?: number - mobile?: boolean - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setViewport( - params.width, - params.height, - params.deviceScaleFactor, - params.mobile, - target.worktreeId, - target.browserPageId - ) - } - - // ── Geolocation ── - - async browserSetGeolocation( - params: { - latitude: number - longitude: number - accuracy?: number - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setGeolocation( - params.latitude, - params.longitude, - params.accuracy, - target.worktreeId, - target.browserPageId - ) - } - - // ── Request interception ── - - async browserInterceptEnable( - params: { - patterns?: string[] - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().interceptEnable( - params.patterns, - target.worktreeId, - target.browserPageId - ) - } - - async browserInterceptDisable( - params: BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().interceptDisable( - target.worktreeId, - target.browserPageId - ) - } - - async browserInterceptList(params: BrowserCommandTargetParams): Promise<{ requests: unknown[] }> { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().interceptList(target.worktreeId, target.browserPageId) - } - - // ── Console/network capture ── - - async browserCaptureStart( - params: BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().captureStart(target.worktreeId, target.browserPageId) - } - - async browserCaptureStop(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().captureStop(target.worktreeId, target.browserPageId) - } - - async browserConsoleLog( - params: { limit?: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().consoleLog( - params.limit, - target.worktreeId, - target.browserPageId - ) - } - - async browserNetworkLog( - params: { limit?: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().networkLog( - params.limit, - target.worktreeId, - target.browserPageId - ) - } - - // ── Additional core commands ── - - async browserDblclick( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().dblclick( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserForward(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().forward(target.worktreeId, target.browserPageId) - } - - async browserScrollIntoView( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().scrollIntoView( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserGet( - params: { - what: string - selector?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().get( - params.what, - params.selector, - target.worktreeId, - target.browserPageId - ) - } - - async browserIs( - params: { what: string; selector: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().is( - params.what, - params.selector, - target.worktreeId, - target.browserPageId - ) - } - - // ── Keyboard insert text ── - - async browserKeyboardInsertText( - params: { text: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().keyboardInsertText( - params.text, - target.worktreeId, - target.browserPageId - ) - } - - // ── Mouse commands ── - - async browserMouseMove( - params: { x: number; y: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseMove( - params.x, - params.y, - target.worktreeId, - target.browserPageId - ) - } - - async browserMouseDown( - params: { button?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseDown( - params.button, - target.worktreeId, - target.browserPageId - ) - } - - async browserMouseClick( - params: { - x: number - y: number - button?: string - radius?: number - modifiers?: ('cmd' | 'ctrl' | 'alt' | 'shift')[] - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseClick( - params.x, - params.y, - params.button, - target.worktreeId, - target.browserPageId, - clampOptionalNumber(params.radius, 0, 64), - params.modifiers - ) - } - - async browserMouseUp(params: { button?: string } & BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseUp( - params.button, - target.worktreeId, - target.browserPageId - ) - } - - async browserMouseWheel( - params: { - dy: number - dx?: number - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseWheel( - params.dy, - params.dx, - target.worktreeId, - target.browserPageId - ) - } - - // ── Find (semantic locators) ── - - async browserFind( - params: { - locator: string - value: string - action: string - text?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().find( - params.locator, - params.value, - params.action, - params.text, - target.worktreeId, - target.browserPageId - ) - } - - // ── Set commands ── - - async browserSetDevice(params: { name: string } & BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setDevice( - params.name, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetOffline( - params: { state?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setOffline( - params.state, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetHeaders( - params: { headers: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setHeaders( - params.headers, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetCredentials( - params: { - user: string - pass: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setCredentials( - params.user, - params.pass, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetMedia( - params: { - colorScheme?: string - reducedMotion?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setMedia( - params.colorScheme, - params.reducedMotion, - target.worktreeId, - target.browserPageId - ) - } - - // ── Clipboard commands ── - - async browserClipboardRead(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().clipboardRead(target.worktreeId, target.browserPageId) - } - - async browserClipboardWrite( - params: { text: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().clipboardWrite( - params.text, - target.worktreeId, - target.browserPageId - ) - } - - // ── Dialog commands ── - - async browserDialogAccept( - params: { text?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().dialogAccept( - params.text, - target.worktreeId, - target.browserPageId - ) - } - - async browserDialogDismiss(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().dialogDismiss(target.worktreeId, target.browserPageId) - } - - // ── Storage commands ── - - async browserStorageLocalGet( - params: { key: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageLocalGet( - params.key, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageLocalSet( - params: { - key: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageLocalSet( - params.key, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageLocalClear(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageLocalClear( - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageSessionGet( - params: { key: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageSessionGet( - params.key, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageSessionSet( - params: { - key: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageSessionSet( - params.key, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageSessionClear(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageSessionClear( - target.worktreeId, - target.browserPageId - ) - } - - // ── Download command ── - - async browserDownload( - params: { - selector: string - path: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().download( - params.selector, - params.path, - target.worktreeId, - target.browserPageId - ) - } - - // ── Highlight command ── - - async browserHighlight( - params: { selector: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().highlight( - params.selector, - target.worktreeId, - target.browserPageId - ) - } - - // ── New: exec passthrough + tab lifecycle ── - - async browserExec(params: { command: string } & BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().exec( - params.command, - target.worktreeId, - target.browserPageId - ) - } - - async browserTabCreate( - params: { - url?: string - worktree?: string - page?: string - profileId?: string - waitForRegistration?: boolean - activate?: boolean - navigation?: RuntimeNavigationTarget - targetGroupId?: string - placement?: BrowserPageCreationPlacement - }, - caller?: { pairedDeviceId?: string; clientKind?: 'mobile' | 'runtime' } - ): Promise<{ browserPageId: string }> { - const url = params.url ?? 'about:blank' - const focus = resolveBrowserTabCreateFocus({ - activate: params.activate, - navigation: params.navigation, - clientKind: caller?.clientKind - }) - const worktree = params.worktree - ? params.placement?.kind === 'client' - ? await this.host.resolveBrowserWorkspace(params.worktree) - : await this.host.resolveWorktreeSelector(params.worktree) - : undefined - const worktreeId = worktree?.id - const sessionPartition = browserSessionRegistry.resolveKnownPartition(params.profileId) - if (!sessionPartition) { - throw new BrowserError( - 'invalid_argument', - `Browser profile ${params.profileId} was not found` - ) - } - if (params.placement?.kind === 'client') { - if (!caller?.pairedDeviceId) { - throw new BrowserError( - 'forbidden', - 'Client-hosted browser pages require an authenticated paired runtime.' - ) - } - if (!worktree) { - throw new BrowserError( - 'invalid_argument', - 'Client-hosted browser pages require an explicit workspace.' - ) - } - const browserPageId = params.page ?? randomUUID() - const executionHost = await this.host.resolveBrowserNetworkExecutionHost(worktree) - const authority = this.host.getBrowserHostLeaseRegistry() - const browserProfileId = params.profileId ?? browserSessionRegistry.getDefaultProfile().id - const created = await createRuntimeBrowserClientPage(authority, { - browserPageId, - browserHostClientId: params.placement.browserHostClientId, - pairedDeviceId: caller.pairedDeviceId, - browserProfileId, - executionHost, - workspaceId: worktree.id - }) - const pages = this.host.getRuntimeBrowserPageRegistry() - pages.publishClientPage({ - browserPageId, - workspaceId: worktree.id, - browserProfileId, - executionHostKey: browserNetworkExecutionHostKey(executionHost), - placement: created.placement, - pairedDeviceId: caller.pairedDeviceId, - url: 'about:blank', - loading: url !== 'about:blank', - active: focus.startsActive - }) - publishCreatedBrowserSessionTab(this.host, { - placementKind: 'client', - browserPageId, - worktreeId: worktree.id, - focus, - clientNavigationId: caller.pairedDeviceId, - targetGroupId: params.targetGroupId - }) - if (url !== 'about:blank') { - try { - await navigateRuntimeBrowserClientPage(authority, { - browserPageId, - placement: created.placement, - url - }) - pages.updatePage(browserPageId, created.placement, { url, loading: false }) - } catch { - pages.updatePage(browserPageId, created.placement, { loading: false }) - } - this.host.notifyHeadlessBrowserSessionTabsChanged?.(worktree.id) - } - return { browserPageId } - } - // Why: headless serve has no renderer , so back the page with a main-process offscreen WebContents instead. - if (!this.host.getAvailableAuthoritativeWindow()) { - const offscreen = this.host.getOffscreenBrowserBackend() - if (!offscreen) { - throw new BrowserError('browser_error', 'This host does not support browser panes.') - } - // Why: the offscreen backend registers synchronously, so there is no webview-mount wait. - const created = await offscreen.createTab({ - url, - worktreeId, - profileId: params.profileId, - ...(params.page ? { browserPageId: params.page } : {}) - }) - publishCreatedBrowserSessionTab(this.host, { - placementKind: 'offscreen', - browserPageId: created.browserPageId, - worktreeId, - focus, - ...(caller?.pairedDeviceId ? { clientNavigationId: caller.pairedDeviceId } : {}), - targetGroupId: params.targetGroupId - }) - return { browserPageId: created.browserPageId } - } - const { browserPageId } = await this.createBrowserTabInRenderer( - url, - worktreeId, - params.profileId, - params.profileId ? sessionPartition : undefined, - focus.focusesHost, - params.page - ) - - // Why: the webview must mount and register before the tab is operable, so wait here (returning the ID anyway on timeout). - if (params.waitForRegistration !== false) { - try { - await waitForTabRegistration(browserPageId) - } catch { - // Tab exists in the renderer even if the webview hasn't mounted; subsequent commands surface a clear error if it never loads. - } - } - - const bridge = this.requireAgentBrowserBridge() - publishCreatedBrowserSessionTab(this.host, { - placementKind: 'renderer', - browserPageId, - worktreeId, - focus, - ...(caller?.pairedDeviceId ? { clientNavigationId: caller.pairedDeviceId } : {}), - targetGroupId: params.targetGroupId - }) - - // Why: the webview loads about:blank first; route navigation through the bridge so its registered owner remains authoritative. - if (url && url !== 'about:blank') { - const navigate = async (): Promise => { - const result = await bridge.goto(url, worktreeId, browserPageId) - this.notifyRendererNavigation(browserPageId, result.url, result.title) - if (!this.host.getAvailableAuthoritativeWindow() && worktreeId) { - this.host.notifyHeadlessBrowserSessionTabsChanged?.(worktreeId) - } - } - if (params.waitForRegistration === true) { - void navigate().catch(() => {}) - return { browserPageId } - } - try { - await navigate() - } catch { - // Tab exists but navigation failed — caller can retry with explicit goto - } - } - - return { browserPageId } - } - - async browserOpenUrlOnClient(params: { - url: string - worktree: string - }): Promise<{ browserPageId: string }> { - const protocol = new URL(params.url).protocol - if (protocol !== 'http:' && protocol !== 'https:') { - throw new BrowserError('invalid_argument', 'Only http(s) URLs can be opened on the client.') - } - const lease = this.host - .getBrowserHostLeaseRegistry() - .select(undefined, [ - BROWSER_HOST_WEBVIEW_CAPABILITY, - BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY - ]) - return this.browserTabCreate( - { - url: params.url, - worktree: params.worktree, - activate: true, - navigation: 'caller', - placement: { kind: 'client', browserHostClientId: lease.browserHostClientId } - }, - { pairedDeviceId: lease.pairedDeviceId, clientKind: 'runtime' } - ) - } - - async browserTabSetProfile( - params: { - profileId: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const browserPageId = - target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) - if (!browserPageId) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - // Why: 'default' is a synthetic id; fall back to the registry's default profile when not registered. - const profile = - browserSessionRegistry.getProfile(params.profileId) ?? - (params.profileId === 'default' ? browserSessionRegistry.getDefaultProfile() : null) - if (!profile) { - throw new BrowserError( - 'invalid_argument', - `Browser profile ${params.profileId} was not found` - ) - } - - // Why: short-circuit no-op switches so the renderer doesn't needlessly tear down and remount the webview. - const currentProfileId = browserManager.getSessionProfileIdForTab(browserPageId) ?? 'default' - if (currentProfileId === profile.id) { - return { - browserPageId, - profileId: profile.id, - profileLabel: profile.label - } - } - - const win = this.host.getAuthoritativeWindow() - const requestId = randomUUID() - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - ipcMain.removeListener('browser:tabSetProfileReply', handler) - reject(new Error('Tab profile update timed out')) - }, 10_000) - - const handler = ( - _event: Electron.IpcMainEvent, - reply: { requestId: string; error?: string } - ): void => { - if (reply.requestId !== requestId) { - return - } - clearTimeout(timer) - ipcMain.removeListener('browser:tabSetProfileReply', handler) - if (reply.error) { - reject(new Error(reply.error)) - } else { - resolve() - } - } - ipcMain.on('browser:tabSetProfileReply', handler) - win.webContents.send('browser:requestTabSetProfile', { - requestId, - browserPageId, - profileId: profile.id, - sessionPartition: profile.partition - }) - }) - - // Why: profile change remounts the webview; wait for re-register so follow-up commands see the new profile and an attached guest. - try { - await waitForTabRegistration(browserPageId) - } catch { - // Best-effort: re-register won't fire while the worktree is hidden; downstream commands retry once the pane re-mounts. - } - - return { - browserPageId, - profileId: profile.id, - profileLabel: profile.label - } - } - - async browserTabProfileShow(params: { - page: string - worktree?: string - }): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const tab = this.describeBrowserTab(params.page, target.worktreeId) - return { - browserPageId: tab.browserPageId, - worktreeId: tab.worktreeId ?? null, - profileId: tab.profileId ?? null, - profileLabel: tab.profileLabel ?? null - } - } - - async browserTabProfileClone( - params: { - profileId: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const sourceBrowserPageId = - target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) - if (!sourceBrowserPageId) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - const sourceTab = this.describeBrowserTab(sourceBrowserPageId, target.worktreeId) - const profile = browserSessionRegistry.getProfile(params.profileId) - if (!profile) { - throw new BrowserError( - 'invalid_argument', - `Browser profile ${params.profileId} was not found` - ) - } - const created = await this.createBrowserTabInRenderer( - sourceTab.url, - sourceTab.worktreeId ?? target.worktreeId, - profile.id, - profile.partition - ) - // Why: wait for the cloned tab's webview to register so the returned browserPageId is operable by the next CLI call. - try { - await waitForTabRegistration(created.browserPageId) - } catch { - // Best-effort: registration may not fire if the worktree is hidden. - } - return { - browserPageId: created.browserPageId, - sourceBrowserPageId, - profileId: profile.id, - profileLabel: profile.label - } - } - - async browserProfileList(): Promise { - return { profiles: browserSessionRegistry.listProfiles() } - } - - async browserProfileCreate(params: { - label: string - scope: 'isolated' | 'imported' - userAgentMode?: BrowserSessionUserAgentMode - }): Promise { - return { - profile: await browserSessionRegistry.createProfile(params.scope, params.label, { - userAgentMode: params.userAgentMode - }) - } - } - - async browserProfileDelete(params: { profileId: string }): Promise { - return { - deleted: await browserSessionRegistry.deleteProfile(params.profileId), - profileId: params.profileId - } - } - - async browserProfileDetectBrowsers(): Promise { - return { - // Why: expose only display metadata; filesystem paths and keychain identifiers stay on the runtime server. - browsers: detectInstalledBrowsers().map((browser) => ({ - family: browser.family, - label: browser.label, - profiles: browser.profiles, - selectedProfile: browser.selectedProfile - })) - } - } - - async browserProfileImportFromBrowser(params: { - profileId: string - browserFamily: string - browserProfile?: string - supportsPartitionSkippedCookies?: true - }): Promise { - const profile = browserSessionRegistry.getProfile(params.profileId) - if (!profile) { - return { ok: false, reason: 'Session profile not found.' } - } - if ( - params.browserProfile && - (/[/\\]/.test(params.browserProfile) || params.browserProfile.includes('..')) - ) { - return { ok: false, reason: 'Invalid browser profile name.' } - } - - const browsers = detectInstalledBrowsers() - let browser = browsers.find((candidate) => candidate.family === params.browserFamily) - if (!browser) { - return { ok: false, reason: 'Browser not found on this system.' } - } - - if (params.browserProfile && params.browserProfile !== browser.selectedProfile) { - const reselected = selectBrowserProfile(browser, params.browserProfile) - if (!reselected) { - return { - ok: false, - reason: `No cookies database found for profile "${params.browserProfile}".` - } - } - browser = reselected - } - - const result = await importCookiesFromBrowser(browser, profile.partition, { - canReportPartitionSkippedCookies: params.supportsPartitionSkippedCookies === true - }) - if (!result.ok) { - return result - } - - const profileName = - browser.profiles.find((candidate) => candidate.directory === browser.selectedProfile)?.name ?? - browser.selectedProfile - browserSessionRegistry.updateProfileSource(params.profileId, { - browserFamily: browser.family, - profileName, - importedAt: Date.now() - }) - return { ...result, profileId: params.profileId } - } - - async browserProfileClearDefaultCookies(): Promise { - return { cleared: await browserSessionRegistry.clearDefaultSessionCookies() } - } - - async browserTabClose(params: { - index?: number - page?: string - worktree?: string - }): Promise<{ closed: boolean }> { - const pages = this.host.getRuntimeBrowserPageRegistry() - let clientPage = params.page ? pages.getPage(params.page) : undefined - if (clientPage) { - await this.assertClientPageWorkspace(clientPage, params.worktree) - } else if (!params.page) { - const workspaceId = params.worktree - ? (await this.host.resolveBrowserWorkspace(params.worktree)).id - : undefined - if (pages.listPages(workspaceId).length > 0) { - const tab = - params.index !== undefined - ? (await this.browserTabList({ worktree: params.worktree })).tabs[params.index] - : (await this.browserTabCurrent({ worktree: params.worktree })).tab - clientPage = tab ? pages.getPage(tab.browserPageId) : undefined - } - } - if (clientPage) { - const authority = this.host.getBrowserHostLeaseRegistry() - // Why: a retained page whose host quit has no placement left to command, and asking the - // absent host first would refuse the close and strand the tab with no way to dismiss it. - if (authority.getPlacement(clientPage.browserPageId)) { - await closeRuntimeBrowserClientPage(authority, { - browserPageId: clientPage.browserPageId, - placement: clientPage.placement - }) - } - if (!pages.retirePage(clientPage.browserPageId, clientPage.placement)) { - throw new Error('browser_page_placement_stale') - } - if (this.host.retireRuntimeOwnedBrowserSessionTab) { - this.host.retireRuntimeOwnedBrowserSessionTab( - clientPage.workspaceId, - clientPage.browserPageId - ) - } else { - this.host.notifyHeadlessBrowserSessionTabsChanged?.(clientPage.workspaceId) - } - return { closed: true } - } - const namedPageId = - typeof params.page === 'string' && params.page.length > 0 ? params.page : null - const explicitPage = namedPageId !== null - const bridge = this.host.getAgentBrowserBridge() - if (!bridge) { - // Why before the refusal: a runtime with no browser session cannot be holding this page - // either, but it can still be carrying the session row that names it. - if ( - namedPageId && - params.worktree && - this.retireGhostBrowserSessionRow( - (await this.host.resolveWorktreeSelector(params.worktree)).id, - namedPageId - ) - ) { - return { closed: true } - } - throw new BrowserError('browser_no_tab', 'No browser session is active') - } - const worktreeId = explicitPage - ? params.worktree - ? (await this.host.resolveWorktreeSelector(params.worktree)).id - : undefined - : await this.resolveBrowserWorktreeId(params.worktree) - - let tabId: string | null = null - if (namedPageId !== null) { - tabId = namedPageId - } else if (params.index !== undefined) { - const tabs = bridge.getRegisteredTabs(worktreeId) - const entries = [...tabs.entries()] - if (params.index < 0 || params.index >= entries.length) { - throw new Error(`Tab index ${params.index} out of range (0-${entries.length - 1})`) - } - tabId = entries[params.index][0] - } else { - // Why: try the bridge first; fall back to the renderer for tabs whose webview hasn't mounted yet (e.g. just created). - const tabs = bridge.getRegisteredTabs(worktreeId) - const entries = [...tabs.entries()] - const activeEntry = entries.find(([, wcId]) => wcId === bridge.getActiveWebContentsId()) - if (activeEntry) { - tabId = activeEntry[0] - } - } - - // Why: headless serve has no renderer to ask, so destroy the offscreen page directly. - const authoritativeWindow = this.host.getAvailableAuthoritativeWindow() - const offscreen = authoritativeWindow ? null : this.host.getOffscreenBrowserBackend() - if (offscreen) { - // Why: resolve the active page for implicit close so we don't report success while closing nothing. - const resolvedTabId = tabId ?? bridge.getActivePageId(worktreeId) - if (!resolvedTabId) { - return { closed: false } - } - if (explicitPage && !bridge.getRegisteredTabs(worktreeId).has(resolvedTabId)) { - if (this.retireGhostBrowserSessionRow(worktreeId, resolvedTabId)) { - return { closed: true } - } - const scope = worktreeId ? ' in this worktree' : '' - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${resolvedTabId} was not found${scope}` - ) - } - await offscreen.closeTab(resolvedTabId) - // Why: closeTab only destroys the guest; without retirement, paired clients keep a - // dead session tab until an unrelated republish (closeMobileSessionTab already retires). - if (worktreeId) { - if (this.host.retireRuntimeOwnedBrowserSessionTab) { - this.host.retireRuntimeOwnedBrowserSessionTab(worktreeId, resolvedTabId) - } else { - this.host.notifyHeadlessBrowserSessionTabsChanged?.(worktreeId) - } - } - return { closed: true } - } - - if (!authoritativeWindow && tabId && !bridge.getRegisteredTabs(worktreeId).has(tabId)) { - if (this.retireGhostBrowserSessionRow(worktreeId, tabId)) { - return { closed: true } - } - const scope = worktreeId ? ' in this worktree' : '' - throw new BrowserError('browser_tab_not_found', `Browser page ${tabId} was not found${scope}`) - } - - const win = authoritativeWindow ?? this.host.getAuthoritativeWindow() - const requestId = randomUUID() - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - ipcMain.removeListener('browser:tabCloseReply', handler) - reject(new Error('Tab close timed out')) - }, 10_000) - - const handler = ( - _event: Electron.IpcMainEvent, - reply: { requestId: string; error?: string; code?: 'browser_tab_not_found' } - ): void => { - if (reply.requestId !== requestId) { - return - } - clearTimeout(timer) - ipcMain.removeListener('browser:tabCloseReply', handler) - if (reply.error) { - reject( - reply.code === 'browser_tab_not_found' - ? new BrowserError('browser_tab_not_found', reply.error) - : new Error(reply.error) - ) - } else { - resolve() - } - } - ipcMain.on('browser:tabCloseReply', handler) - // Why: pass worktreeId so the renderer scopes the close correctly instead of falling back to the globally active tab in the wrong worktree. - win.webContents.send('browser:requestTabClose', { requestId, tabId, worktreeId }) - }) - - return { closed: true } - } - - private enrichBrowserTabInfo( - tab: BrowserTabListResult['tabs'][number] - ): BrowserTabListResult['tabs'][number] { - const rawProfileId = browserManager.getSessionProfileIdForTab(tab.browserPageId) - const profile = - browserSessionRegistry.getProfile(rawProfileId ?? 'default') ?? - browserSessionRegistry.getDefaultProfile() - return { - ...tab, - worktreeId: browserManager.getWorktreeIdForTab(tab.browserPageId) ?? null, - profileId: profile.id, - profileLabel: profile.label - } - } - - private listLogicalBrowserTabs( - worktreeId: string | undefined, - clientPages: readonly RuntimeBrowserClientPage[] - ): BrowserTabListResult['tabs'] { - const clientPageActive = clientPages.some((page) => page.active) - const bridge = this.host.getAgentBrowserBridge() - const serverTabs = - bridge && typeof bridge.tabList === 'function' - ? bridge.tabList(worktreeId).tabs.map((tab) => ({ - ...this.enrichBrowserTabInfo(tab), - active: clientPageActive ? false : tab.active - })) - : [] - const clientTabs = clientPages.map((page, offset) => { - const profile = - browserSessionRegistry.getProfile(page.browserProfileId) ?? - browserSessionRegistry.getDefaultProfile() - return { - browserPageId: page.browserPageId, - index: serverTabs.length + offset, - url: page.url, - title: page.title, - active: page.active, - loadError: null, - certificateFailure: null, - worktreeId: page.workspaceId, - profileId: profile.id, - profileLabel: profile.label - } - }) - const tabs = [...serverTabs, ...clientTabs] - if (tabs.length > 0 && !tabs.some((tab) => tab.active)) { - tabs[0] = { ...tabs[0]!, active: true } - } - return tabs.map((tab, index) => ({ ...tab, index })) - } - - private async assertClientPageWorkspace( - page: RuntimeBrowserClientPage, - selector: string | undefined - ): Promise { - if (!selector) { - return - } - const workspace = await this.host.resolveBrowserWorkspace(selector) - if (workspace.id !== page.workspaceId) { - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${page.browserPageId} was not found in this worktree` - ) - } - } - - private async resolveClientHostedBrowserPage( - params: BrowserCommandTargetParams - ): Promise { - const pages = this.host.getRuntimeBrowserPageRegistry() - if (params.page) { - const page = pages.getPage(params.page) - if (page) { - await this.assertClientPageWorkspace(page, params.worktree) - } - return page - } - const workspaceId = params.worktree - ? (await this.host.resolveBrowserWorkspace(params.worktree)).id - : undefined - return pages.listPages(workspaceId).find((page) => page.active) - } - - private describeBrowserTab( - browserPageId: string, - explicitWorktreeId?: string - ): BrowserTabListResult['tabs'][number] { - const worktreeId = explicitWorktreeId ?? browserManager.getWorktreeIdForTab(browserPageId) - const tab = this.requireAgentBrowserBridge() - .tabList(worktreeId) - .tabs.find((entry) => entry.browserPageId === browserPageId) - if (!tab) { - const scope = worktreeId ? ' in this worktree' : '' - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${browserPageId} was not found${scope}` - ) - } - return this.enrichBrowserTabInfo(tab) - } - - private async createBrowserTabInRenderer( - url: string, - worktreeId: string | undefined, - profileId: string | undefined, - sessionPartition: string | undefined, - activate?: boolean, - requestedPageId?: string - ): Promise<{ browserPageId: string }> { - const win = this.host.getAuthoritativeWindow() - const requestId = randomUUID() - - const browserPageId = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - ipcMain.removeListener('browser:tabCreateReply', handler) - reject(new Error('Tab creation timed out')) - }, 10_000) - - const handler = ( - event: Electron.IpcMainEvent, - reply: { requestId: string; browserPageId?: string; error?: string } - ): void => { - if (event.sender !== win.webContents || reply.requestId !== requestId) { - return - } - clearTimeout(timer) - ipcMain.removeListener('browser:tabCreateReply', handler) - if (reply.error) { - reject(new Error(reply.error)) - } else { - resolve(reply.browserPageId!) - } - } - ipcMain.on('browser:tabCreateReply', handler) - win.webContents.send('browser:requestTabCreate', { - requestId, - url, - worktreeId, - ...(requestedPageId ? { browserPageId: requestedPageId } : {}), - // Why: keep these undefined (not null) when no profile is chosen so the renderer still applies default-profile inheritance. - sessionProfileId: profileId, - sessionPartition, - activate - }) - }) - - return { browserPageId } - } -} +export class RuntimeBrowserCommands extends RuntimeBrowserCommandsWithListLogicalBrowserTabs {} +export type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params' +export type { RuntimeBrowserCommandHost } from './runtime-browser-commands-browser-command-target-params' diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index eb8f7d67167..78cee692a65 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -1,2948 +1,13 @@ -/* eslint-disable max-lines -- Why: filesystem, editor-file, and search commands share the same local/SSH path authorization rules. Keeping that IO adapter together prevents separate command paths from drifting on safety checks. */ -import type { ChildProcess } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import { watch as watchFs } from 'node:fs' -import type { FileHandle } from 'node:fs/promises' -import { - chmod, - constants, - copyFile, - lstat, - mkdir, - open, - readdir, - rename, - realpath, - rm, - stat, - writeFile -} from 'node:fs/promises' -import { homedir, tmpdir } from 'node:os' -import { basename, dirname, extname, join } from 'node:path' -import type { SearchOptions, SearchResult } from '../../shared/code-search-types' -import type { DirEntry, FsChangeEvent, MarkdownDocument } from '../../shared/filesystem-entry-types' -import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' -import { - isPathInsideOrEqual, - isRuntimePathAbsolute, - isWindowsAbsolutePathLike, - normalizeRuntimePathForComparison, - relativePathInsideRoot, - resolveRuntimePath -} from '../../shared/cross-platform-path' -import { - REMOTE_RPC_MAX_CONTENT_BYTES, - remoteRpcResultExceedsContentBudget -} from '../../shared/remote-rpc-content-budget' -import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' -import { sortDirEntries } from '../../shared/file-name-sort' -import type { - RuntimeFileListResult, - RuntimeFileOpenResult, - RuntimeFileReadChunkResult, - RuntimeFilePreviewResult, - RuntimeFileReadResult, - RuntimeNativeChatFileContext, - RuntimeTerminalPathResolution -} from '../../shared/runtime-types' -import { - closeFileExplorerWatcherInWatcherProcess, - watchFileExplorerInWatcherProcess -} from './file-watcher-host' -import { wslAwareSpawn } from '../git/runner' -import { parseWslPath, toWindowsWslPath } from '../wsl' -import { resolveAuthorizedPath } from '../ipc/filesystem-auth' -import { isENOENT } from '../ipc/filesystem-path-containment' -import { listQuickOpenFiles } from '../ipc/filesystem-list-files' -import { searchQuickOpenFilePaths as searchHostQuickOpenFilePaths } from '../ipc/filesystem-search-file-paths' -import { isQuickOpenQueryTooLarge, QuickOpenPathRanker } from '../../shared/quick-open-path-search' -import { limitQuickOpenFilesBySerializedBytes } from '../../shared/quick-open-transport-budget' -import { searchWithGitGrep } from '../ipc/filesystem-search-git' -import { getLocalGitOptionsForRegisteredWorktree } from '../ipc/local-worktree-runtime-options' -import { checkRgAvailable } from '../ipc/rg-availability' -import { - absorbPendingRipgrepSpawnError, - isRipgrepUnavailableExit, - killSpawnedRipgrepProcess -} from '../../shared/ripgrep-process-availability' -import { - listMarkdownDocuments, - markdownDocumentsFromRelativePaths -} from '../ipc/markdown-documents' -import { - buildRgArgs, - createAccumulator, - DEFAULT_SEARCH_MAX_RESULTS, - finalize, - ingestRgJsonLine, - SEARCH_TIMEOUT_MS -} from '../../shared/text-search' -import type { Store } from '../persistence' -import { - getSshFilesystemProvider, - onSshFilesystemProviderRegistered, - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE -} from '../providers/ssh-filesystem-dispatch' -import type { FileReadLimits, FileStat, IFilesystemProvider } from '../providers/types' -import { readSshFileExplorerChunk } from './ssh-file-explorer-chunk-read' -import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' -import { - isWatcherProcessFailure, - WatcherProcessFailure -} from '../ipc/parcel-watcher-process-failure' -import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' -import { - rankRuntimeMobileFilePaths, - RuntimeMobileFilePathSearchCache -} from './runtime-mobile-file-path-search' -import { beginWatcherInstall } from '../ipc/watcher-removal-gate' -import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' -import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' -import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename' -import { - NodeFileReadTooLargeError, - readNodeFileWithinLimit -} from '../../shared/node-bounded-file-reader' -import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../../shared/quick-open-listing-limits' -import { - readAuthorizedDocPreviewFile, - type DocPreviewFileAccessRequest, - type DocPreviewFileAccessResult -} from '../../shared/doc-preview-file-access' - -const MOBILE_FILE_LIST_LIMIT = 5000 -// Legacy SSH relays cannot enforce a byte budget; 32 max-length paths stay under one 4 MiB frame. -const QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT = 32 -const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000 -const MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES = 8 -const MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS = 30_000 -const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 -const LOCAL_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024 -const PREVIEWABLE_BINARY_EMPTY_RESULT_BYTES = Buffer.byteLength( - JSON.stringify({ - content: '', - isBinary: true, - isImage: true, - mimeType: 'application/octet-stream' - }), - 'utf8' -) -const PREVIEW_CONTENT_FIELDS = ['content'] as const - -function previewableBinaryByteLimit(maxContentBytes: number): number { - const base64Bytes = Math.max(0, maxContentBytes - PREVIEWABLE_BINARY_EMPTY_RESULT_BYTES) - return Math.floor(base64Bytes / 4) * 3 -} - -// Why: the stream reader aborts an over-cap read with a raw protocol message; clients key on -// `file_too_large`, so translate it here rather than surfacing internal stream wording. -async function readPreviewFileWithinCap( - provider: IFilesystemProvider, - filePath: string, - limits: FileReadLimits -): Promise { - try { - return await provider.readFile(filePath, limits) - } catch (error) { - if (error instanceof FileReadCapExceededError) { - throw new Error('file_too_large') - } - throw error - } -} - -function assertPreviewWithinTransportBudget( - result: RuntimeFilePreviewResult, - maxContentBytes: number | undefined -): RuntimeFilePreviewResult { - if ( - maxContentBytes !== undefined && - remoteRpcResultExceedsContentBudget(result, maxContentBytes, PREVIEW_CONTENT_FIELDS) - ) { - throw new Error('file_too_large') - } - return result -} - -// Why: previews are reachable only over RPC and base64 inflates them 4/3, so derive the cap from the -// transport ceiling — a hardcoded 10 MiB serializes past the outbound envelope and kills the socket. -export const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = previewableBinaryByteLimit( - REMOTE_RPC_MAX_CONTENT_BYTES -) -const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150 -export const WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS = 10_000 -const TERMINAL_FILE_GRANT_TTL_MS = 10 * 60 * 1000 -const OPEN_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0 -const RUNTIME_FILE_MUTATION_UPDATE_REQUIRED = - 'Remote file changes require a newer Orca client. Update the paired client and try again.' - -function assertRuntimeFileMutationExpectation( - connectionId: string | undefined, - expectedExecutionHostId: string | undefined, - expectedSshTargetId: string | undefined, - expectedSshConnectionGeneration: number | undefined -): void { - if (!expectedExecutionHostId) { - throw new Error(RUNTIME_FILE_MUTATION_UPDATE_REQUIRED) - } - const actualExecutionHostId = connectionId ? toSshExecutionHostId(connectionId) : 'local' - if (expectedExecutionHostId !== actualExecutionHostId) { - throw new Error('Workspace host changed; refresh and try again') - } - assertSshMutationExpectation(connectionId, expectedSshTargetId, expectedSshConnectionGeneration) -} -// Why: files.watch cleanup is synchronous RPC; track native Parcel unsubscribes so shutdown can drain them. -const pendingRuntimeFileWatcherUnsubscribes = new Set>() - -type RuntimeFileWatcherLease = { - suspend(): Promise - resume(): Promise - forget(): void -} -const runtimeFileWatcherLeasesByOwnerAndRoot = new Map>() -// Why: the provider's dispose() stops each watch registration without firing its terminal callback, -// so a dropped SSH transport leaves this watch silently dead — a reconnect's fresh provider is the -// only signal it can be rebuilt from. Keyed like the leases so worktree removal can drop it. -const sshFileExplorerWatchRearms = new Map void>>() -const MOBILE_BINARY_EXTENSIONS = new Set([ - '.avif', - '.bmp', - '.gif', - '.heic', - '.ico', - '.jpeg', - '.jpg', - '.mov', - '.mp3', - '.mp4', - '.pdf', - '.png', - '.webp', - '.zip' -]) -// Mirror of mobile classifyMobileArtifact's image set; SVG/PDF excluded because RN can't decode those data URIs. -const MOBILE_PREVIEWABLE_IMAGE_EXTENSIONS = new Set([ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.bmp', - '.ico' -]) - -type RuntimeFileStatLike = { - size?: number - dev?: number - ino?: number - nlink?: number - mtime?: number | Date - mtimeMs?: number - isDirectory?: () => boolean -} - -type TerminalFileGrant = { - id: string - worktreeId: string - absolutePath: string - provider: 'local' | 'ssh' - connectionId?: string - clientId?: string - expiresAt: number - statIdentity: string | null - readOnly: boolean - provenance: 'terminal-output' | 'native-chat' - expiryTimer?: ReturnType -} - -function isMobilePreviewableImagePath(relativePath: string): boolean { - const basename = basenameFromRelativePath(relativePath) - const dotIndex = basename.lastIndexOf('.') - if (dotIndex <= 0) { - return false - } - return MOBILE_PREVIEWABLE_IMAGE_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) -} - -const RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.svg': 'image/svg+xml', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', - '.pdf': 'application/pdf' -} - -function trackRuntimeFileWatcherUnsubscribe( - rootPath: string, - unsubscribe: () => Promise -): Promise { - const promise = Promise.resolve() - .then(unsubscribe) - .finally(() => { - pendingRuntimeFileWatcherUnsubscribes.delete(promise) - }) - pendingRuntimeFileWatcherUnsubscribes.add(promise) - void promise.catch((err: unknown) => { - console.error('[runtime-files.watch] unsubscribe error', { rootPath, err }) - }) - return promise -} - -function normalizeRuntimeWatcherRoot(rootPath: string): string { - return normalizeRuntimePathForComparison(rootPath) -} - -function runtimeWatcherReleaseKey( - runtimeId: string, - connectionId: string | undefined, - rootPath: string -): string { - // Why: identical absolute paths exist on local and multiple SSH hosts; scope teardown to the host that owns it. - return JSON.stringify([runtimeId, connectionId ?? null, normalizeRuntimeWatcherRoot(rootPath)]) -} - -/** - * Keep an SSH file-explorer watch alive across reconnects. - * - * Why: the previous provider's unwatch handle belongs to the dead transport, so reinstalling on the - * fresh provider is the only way the subscription comes back. Callers get an overflow because the - * events lost while the watch was down can't be replayed. - */ -function armSshFileExplorerWatchRearm(args: { - runtimeId: string - connectionId: string - rootPath: string - callback: (events: FsChangeEvent[]) => void - onTerminalError: (error: Error) => void - signal?: AbortSignal - initialUnwatch: () => void -}): { unsubscribe: () => Promise } { - const key = runtimeWatcherReleaseKey(args.runtimeId, args.connectionId, args.rootPath) - let currentUnwatch = args.initialUnwatch - let stopped = false - let reinstalling: Promise | null = null - - const reinstall = async (): Promise => { - const provider = getSshFilesystemProvider(args.connectionId) - if (stopped || !provider) { - return - } - // Why: the old handle is scoped to the dead transport; closing it here would only risk - // unwatching the root we just re-registered on the new one. - const nextUnwatch = await provider.watch(args.rootPath, args.callback, { - signal: args.signal, - onTerminalError: args.onTerminalError - }) - if (stopped) { - nextUnwatch() - return - } - currentUnwatch = nextUnwatch - args.callback([{ kind: 'overflow', absolutePath: args.rootPath }]) - } - - const unsubscribeRearm = onSshFilesystemProviderRegistered((registeredId) => { - if (registeredId !== args.connectionId || stopped) { - return - } - // Why: reconnect storms can register repeatedly; chain so a second one can't double-install. - const attempt = (reinstalling ?? Promise.resolve()) - .then(reinstall) - .catch((error: unknown) => { - args.onTerminalError(error instanceof Error ? error : new Error(String(error))) - }) - .finally(() => { - if (reinstalling === attempt) { - reinstalling = null - } - }) - reinstalling = attempt - }) - - const stop = (): void => { - stopped = true - unsubscribeRearm() - const rearms = sshFileExplorerWatchRearms.get(key) - rearms?.delete(stop) - if (rearms?.size === 0) { - sshFileExplorerWatchRearms.delete(key) - } - } - const rearms = sshFileExplorerWatchRearms.get(key) ?? new Set<() => void>() - rearms.add(stop) - sshFileExplorerWatchRearms.set(key, rearms) - - return { - unsubscribe: () => { - stop() - const close = async (): Promise => currentUnwatch() - // Why: awaiting an absent reinstall costs a microtask, and removal gating relies on the - // unwatch being issued on the same turn the lease releases it. - return reinstalling ? reinstalling.catch(() => undefined).then(close) : close() - } - } -} - -function stopSshFileExplorerWatchRearms(key: string): void { - for (const stop of Array.from(sshFileExplorerWatchRearms.get(key) ?? [])) { - stop() - } -} - -function registerRuntimeFileWatcherRelease( - runtimeId: string, - connectionId: string | undefined, - rootPaths: string[], - unsubscribe: () => Promise, - restart: () => Promise<() => Promise>, - onRestoreError: (error: Error) => void -): () => Promise { - const keys = Array.from( - new Set( - rootPaths.map((rootPath) => runtimeWatcherReleaseKey(runtimeId, connectionId, rootPath)) - ) - ) - let currentUnsubscribe: (() => Promise) | null = unsubscribe - let releasePromise: Promise | null = null - let physicalExitPromise: Promise | null = null - let resumePromise: Promise | null = null - let stopPromise: Promise | null = null - let logicallyStopped = false - const removeLease = (): void => { - for (const key of keys) { - const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) - leases?.delete(lease) - if (leases?.size === 0) { - runtimeFileWatcherLeasesByOwnerAndRoot.delete(key) - } - } - } - const suspend = (): Promise => { - if (releasePromise) { - return releasePromise - } - const release = currentUnsubscribe - if (!release) { - return Promise.resolve() - } - const attempt = trackRuntimeFileWatcherUnsubscribe(rootPaths[0], release) - releasePromise = attempt - void attempt.then( - () => { - if (currentUnsubscribe === release) { - currentUnsubscribe = null - } - releasePromise = null - }, - (error: unknown) => { - if (isWatcherProcessFailure(error) && error.physicalExit) { - const physicalExit = error.physicalExit.then(() => { - if (currentUnsubscribe === release) { - currentUnsubscribe = null - } - releasePromise = null - if (physicalExitPromise === physicalExit) { - physicalExitPromise = null - } - if (logicallyStopped) { - removeLease() - } - }) - physicalExitPromise = physicalExit - } else { - // Why: a synchronous close failure retains the native owner so a later removal or unsubscribe can retry the same handle. - releasePromise = null - } - } - ) - return attempt - } - const lease: RuntimeFileWatcherLease = { - suspend, - resume: () => { - if (logicallyStopped || (currentUnsubscribe && !physicalExitPromise)) { - return Promise.resolve() - } - if (resumePromise) { - return physicalExitPromise ? Promise.resolve() : resumePromise - } - // Why: a timed-out child still owns native handles until physical exit; join that owner before starting a replacement. - const resumesAfterPhysicalExit = physicalExitPromise !== null - const attempt = Promise.resolve(physicalExitPromise ?? releasePromise) - .then(async () => { - if (logicallyStopped) { - return - } - const nextUnsubscribe = await restart() - if (logicallyStopped) { - await nextUnsubscribe() - return - } - currentUnsubscribe = nextUnsubscribe - }) - .catch((error: unknown) => { - const restoreError = error instanceof Error ? error : new Error(String(error)) - queueMicrotask(() => onRestoreError(restoreError)) - throw restoreError - }) - .finally(() => { - resumePromise = null - }) - resumePromise = attempt - if (resumesAfterPhysicalExit) { - void attempt.catch(() => {}) - return Promise.resolve() - } - return attempt - }, - forget: () => { - logicallyStopped = true - removeLease() - } - } - for (const key of keys) { - const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) ?? new Set() - leases.add(lease) - runtimeFileWatcherLeasesByOwnerAndRoot.set(key, leases) - } - return () => { - if (stopPromise) { - return stopPromise - } - logicallyStopped = true - const release = - resumePromise && !physicalExitPromise - ? Promise.resolve(resumePromise) - .catch(() => undefined) - .then(suspend) - : suspend() - const attempt = release.then(removeLease).catch((error: unknown) => { - stopPromise = null - throw error - }) - stopPromise = attempt - return attempt - } -} - -export async function awaitRuntimeFileWatcherUnsubscribes(): Promise { - await Promise.allSettled(Array.from(pendingRuntimeFileWatcherUnsubscribes)) -} - -export function _getRuntimeFileWatcherReleaseCountForTests(): number { - const leases = new Set() - for (const rootLeases of runtimeFileWatcherLeasesByOwnerAndRoot.values()) { - for (const lease of rootLeases) { - leases.add(lease) - } - } - return leases.size -} - -export function _resetRuntimeFileWatcherLeasesForTests(): void { - const leases = new Set() - for (const rootLeases of runtimeFileWatcherLeasesByOwnerAndRoot.values()) { - for (const lease of rootLeases) { - leases.add(lease) - } - } - for (const lease of leases) { - lease.forget() - } - for (const key of Array.from(sshFileExplorerWatchRearms.keys())) { - stopSshFileExplorerWatchRearms(key) - } - runtimeFileWatcherLeasesByOwnerAndRoot.clear() -} - -export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo } -export type ResolvedRuntimeFileTarget = { - worktree: ResolvedRuntimeFileWorktree - connectionId?: string -} - -export function getRuntimeFileTargetExecutionHostId( - target: ResolvedRuntimeFileTarget -): ExecutionHostId { - return ( - target.worktree.hostId ?? - (target.connectionId ? toSshExecutionHostId(target.connectionId) : 'local') - ) -} - -export type RuntimeFileCommandHost = { - getRuntimeId(): string - requireStore(): Store - resolveWorktreeSelector(selector: string): Promise - resolveRuntimeFileTarget(selector: string): Promise - resolveKnownWorkspaceFileTarget?( - absolutePath: string, - executionHostId: ExecutionHostId - ): Promise<(ResolvedRuntimeFileTarget & { relativePath: string }) | null> - resolveTerminalCwd?(terminalHandle: string): string | null | Promise - resolveTerminalContext?( - terminalHandle: string - ): { worktreeId: string; connectionId: string | null } | null - resolveTerminalFileUriHostname?(terminalHandle: string): string | null | Promise - hasRecentTerminalOutputPath?( - terminalHandle: string, - pathText: string, - absolutePath: string - ): boolean | Promise - hasRecentNativeChatOutputPath?( - worktreeId: string, - context: RuntimeNativeChatFileContext, - pathText: string, - absolutePath: string - ): boolean | Promise - resolveRuntimeGitTarget( - selector: string - ): Promise<{ worktree: ResolvedRuntimeFileWorktree; connectionId?: string }> - openFile( - worktreeId: string, - filePath: string, - relativePath: string, - runtimeEnvironmentId?: string | null - ): void - openDiff( - worktreeId: string, - filePath: string, - relativePath: string, - staged: boolean, - runtimeEnvironmentId?: string | null - ): void -} - -export class RuntimeFileCommands { - private activeRuntimeTextSearches = new Map() - private terminalFileGrants = new Map() - private mobileFilePathSearchCache = new RuntimeMobileFilePathSearchCache( - MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES, - MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS - ) - - constructor(private readonly host: RuntimeFileCommandHost) {} - - async listMobileFiles( - worktreeSelector: string, - options: { signal?: AbortSignal } = {} - ): Promise { - const store = this.host.requireStore() - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const { worktree, connectionId } = target - const files = connectionId - ? await this.listRemoteMobileFiles(worktree.path, connectionId, undefined, options.signal) - : await listQuickOpenFiles(worktree.path, store, undefined, options.signal) - const entries = files - .filter((relativePath) => isSafeMobileRelativePath(relativePath)) - .sort((a, b) => a.localeCompare(b)) - .slice(0, MOBILE_FILE_LIST_LIMIT) - .map((relativePath) => ({ - relativePath, - basename: basenameFromRelativePath(relativePath), - kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) - })) - - return { - worktree: worktree.id, - rootPath: worktree.path, - files: entries, - totalCount: files.length, - truncated: files.length > MOBILE_FILE_LIST_LIMIT - } - } - - async searchMobileFilePaths( - worktreeSelector: string, - query: string, - limit: number - ): Promise { - const store = this.host.requireStore() - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const { worktree, connectionId } = target - const cacheKey = `${connectionId ?? 'local'}:${worktree.id}:${worktree.path}` - const inventory = await this.mobileFilePathSearchCache.get(cacheKey, async () => { - const listed = connectionId - ? await this.listRemoteMobileFiles( - worktree.path, - connectionId, - MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1 - ) - : await listQuickOpenFiles( - worktree.path, - store, - undefined, - undefined, - MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1 - ) - const safePaths = listed - .filter((relativePath) => isSafeMobileRelativePath(relativePath)) - .sort((a, b) => a.localeCompare(b)) - return { - paths: safePaths.slice(0, MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT), - totalCount: safePaths.length, - truncated: safePaths.length > MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT - } - }) - const matches = rankRuntimeMobileFilePaths(inventory.paths, query, limit) - return { - worktree: worktree.id, - rootPath: worktree.path, - files: matches.paths.map((relativePath) => ({ - relativePath, - basename: basenameFromRelativePath(relativePath), - kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) - })), - totalCount: matches.totalCount, - truncated: inventory.truncated || matches.totalCount > limit - } - } - - async searchQuickOpenFilePaths( - worktreeSelector: string, - query: string, - limit: number, - excludePaths?: string[], - signal?: AbortSignal - ): Promise { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const { worktree, connectionId } = target - const result = - !query.trim() || isQuickOpenQueryTooLarge(query) - ? { paths: [], totalCount: 0, truncated: false } - : connectionId - ? await this.searchRemoteQuickOpenFilePaths( - worktree.path, - connectionId, - query, - limit, - excludePaths, - signal - ) - : await searchHostQuickOpenFilePaths(worktree.path, this.host.requireStore(), { - query, - limit, - excludePaths, - signal - }) - return { - worktree: worktree.id, - rootPath: worktree.path, - files: result.paths.map((relativePath) => ({ - relativePath, - basename: basenameFromRelativePath(relativePath), - kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) - })), - totalCount: result.totalCount, - truncated: result.truncated - } - } - - async openMobileFile( - worktreeSelector: string, - relativePath: string - ): Promise { - const { worktree, connectionId } = await this.host.resolveRuntimeFileTarget(worktreeSelector) - if (!isSafeMobileRelativePath(relativePath)) { - throw new Error('invalid_relative_path') - } - // Previewable images open like text (mobile renders via files.readPreview); other binaries stay unavailable on mobile. - const kind = isMobilePreviewableImagePath(relativePath) - ? 'image' - : isMobileBinaryPath(relativePath) - ? 'binary' - : isMobileMarkdownPath(relativePath) - ? 'markdown' - : 'text' - if (kind === 'binary') { - return { worktree: worktree.id, relativePath, kind, opened: false } - } - const filePath = joinWorktreeRelativePath(worktree.path, relativePath) - // Why: CLI/agents treat opened:true as success; stat first so missing paths fail the RPC instead of opening a ghost tab. - await this.assertMobileOpenTargetExists(filePath, connectionId) - // Why: the internal runtimeId isn't a valid env selector; pass undefined so openFile falls back to activeRuntimeEnvironmentId. - this.host.openFile(worktree.id, filePath, relativePath, undefined) - return { worktree: worktree.id, relativePath, kind, opened: true } - } - - private async assertMobileOpenTargetExists( - filePath: string, - connectionId?: string - ): Promise { - try { - await (connectionId - ? this.statRemoteTerminalPath(filePath, connectionId) - : stat(await resolveAuthorizedPath(filePath, this.host.requireStore()))) - } catch (error) { - if ( - isENOENT(error) || - (connectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error)) - ) { - throw new Error(`ENOENT: no such file or directory, open '${filePath}'`) - } - throw error - } - } - - async openMobileDiff( - worktreeSelector: string, - relativePath: string, - staged: boolean - ): Promise { - const { worktree } = await this.host.resolveRuntimeFileTarget(worktreeSelector) - if (!isSafeMobileRelativePath(relativePath)) { - throw new Error('invalid_relative_path') - } - const kind = isMobileBinaryPath(relativePath) - ? 'binary' - : isMobileMarkdownPath(relativePath) - ? 'markdown' - : 'text' - const filePath = joinWorktreeRelativePath(worktree.path, relativePath) - // Why: see openMobileFile; avoid stamping internal runtimeId as runtimeEnvironmentId. - this.host.openDiff(worktree.id, filePath, relativePath, staged, undefined) - return { worktree: worktree.id, relativePath, kind, opened: true } - } - - async readMobileFile( - worktreeSelector: string, - relativePath: string - ): Promise { - const store = this.host.requireStore() - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const { worktree, connectionId } = target - if (!isSafeMobileRelativePath(relativePath)) { - throw new Error('invalid_relative_path') - } - if (isMobileBinaryPath(relativePath)) { - throw new Error('binary_file') - } - - const filePath = joinWorktreeRelativePath(worktree.path, relativePath) - const content = connectionId - ? await this.readRemoteMobileFile(filePath, connectionId) - : await readLocalMobileFile(filePath, store) - const truncated = truncateMobileFilePreview(content) - - return { - worktree: worktree.id, - relativePath, - content: truncated.content, - truncated: truncated.truncated, - byteLength: truncated.byteLength - } - } - - // Resolves a mobile terminal tap to a worktree-relative path; relatives resolve against cwd, else the worktree root. - async resolveTerminalPath( - worktreeSelector: string, - pathText: string, - cwd?: string | null, - clientId?: string, - terminalHandle?: string | null, - crossWorkspace?: boolean, - nativeChatContext?: RuntimeNativeChatFileContext | null - ): Promise { - const store = this.host.requireStore() - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const { worktree, connectionId } = target - // Why: mobile may attach after OSC7 cwd was emitted; the runtime still owns the terminal's latest cwd to resolve the tap. - const normalizedTerminalHandle = - terminalHandle && terminalHandle.trim().length > 0 ? terminalHandle.trim() : null - const terminalCwd = normalizedTerminalHandle - ? await this.host.resolveTerminalCwd?.(normalizedTerminalHandle) - : null - const terminalFileUriHostname = normalizedTerminalHandle - ? await this.host.resolveTerminalFileUriHostname?.(normalizedTerminalHandle) - : null - const base = terminalCwd || (cwd && cwd.trim().length > 0 ? cwd : worktree.path) - - const empty: RuntimeTerminalPathResolution = { - worktree: worktree.id, - relativePath: null, - absolutePath: null, - exists: false, - isDirectory: false - } - - // Why: SSH/WSL homes are unknown here; native-chat grants must not expand their ~/… paths against the local host home. - const isTilde = pathText.startsWith('~/') || pathText.startsWith('~\\') - if (isTilde && (connectionId || (nativeChatContext && parseWslPath(worktree.path)))) { - return empty - } - const expanded = isTilde ? resolveRuntimePath(homedir(), pathText.slice(2)) : pathText - const absolutePath = resolveTerminalAbsolutePath({ - base, - expanded, - worktreePath: worktree.path, - connectionId, - terminalFileUriHostname - }) - const relativePath = relativePathInsideRoot(worktree.path, absolutePath) - // Why: clients that predate crossWorkspace reuse their own worktree id for the - // follow-up files.open, so retargeting to a sibling workspace must be opt-in. - const knownWorkspaceTarget = - crossWorkspace && relativePath === null - ? await this.host.resolveKnownWorkspaceFileTarget?.( - absolutePath, - getRuntimeFileTargetExecutionHostId(target) - ) - : null - const ownedWorktree = knownWorkspaceTarget?.worktree ?? worktree - const ownedConnectionId = knownWorkspaceTarget?.connectionId ?? connectionId - const ownedRelativePath = knownWorkspaceTarget?.relativePath ?? relativePath - - try { - if ( - ownedRelativePath !== null && - (ownedRelativePath === '' || isSafeMobileRelativePath(ownedRelativePath)) - ) { - const stats = ownedConnectionId - ? await this.statRemoteTerminalPath(absolutePath, ownedConnectionId) - : await stat(await resolveAuthorizedPath(absolutePath, store)) - return { - worktree: ownedWorktree.id, - relativePath: ownedRelativePath, - absolutePath, - exists: true, - isDirectory: stats.isDirectory(), - openTarget: stats.isDirectory() - ? undefined - : { - kind: 'worktree-file', - provider: ownedConnectionId ? 'ssh' : 'local', - relativePath: ownedRelativePath, - absolutePath - } - } - } - - if ( - nativeChatContext && - (await this.host.hasRecentNativeChatOutputPath?.( - worktree.id, - nativeChatContext, - pathText, - absolutePath - )) - ) { - const artifactPath = await this.resolveNativeChatArtifactPath(absolutePath, connectionId) - return await this.resolveAbsoluteFileGrant({ - worktreeId: worktree.id, - artifactPath, - connectionId, - clientId, - readOnly: true, - provenance: 'native-chat' - }) - } - - // Why: mobile taps may hit agent artifacts outside the worktree; grant the exact path, not arbitrary absolute paths. - if (!normalizedTerminalHandle || !terminalCwd) { - return { ...empty, relativePath, absolutePath } - } - const terminalContext = this.host.resolveTerminalContext?.(normalizedTerminalHandle) - if ( - !terminalContext || - terminalContext.worktreeId !== worktree.id || - (terminalContext.connectionId ?? undefined) !== connectionId - ) { - return { ...empty, relativePath, absolutePath } - } - const artifactPath = await this.resolveAllowedTerminalArtifactPath({ - absolutePath, - connectionId, - worktreePath: worktree.path - }) - if (!artifactPath) { - return { ...empty, relativePath, absolutePath } - } - if ( - !(await this.host.hasRecentTerminalOutputPath?.( - normalizedTerminalHandle, - provenancePathCandidate(pathText, absolutePath), - artifactPath - )) - ) { - return { ...empty, relativePath, absolutePath } - } - return await this.resolveAbsoluteFileGrant({ - worktreeId: worktree.id, - artifactPath, - rejectedAbsolutePath: absolutePath, - connectionId, - clientId - }) - } catch (error) { - // Report genuine not-found as missing; let transport/permission errors surface so remote taps aren't all reported missing. - if ( - isENOENT(error) || - (ownedConnectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error)) - ) { - return { - ...empty, - worktree: ownedWorktree.id, - relativePath: ownedRelativePath, - absolutePath - } - } - throw error - } - } - - // The mux drops ErrnoException.code, so match not-found by message shape (vs transport/permission/provider errors). - private static isRemoteNotFoundErrorMessage(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return /\bENOENT\b|no such file|not found|does not exist/i.test(message) - } - - private async statRemoteTerminalPath( - absolutePath: string, - connectionId: string - ): Promise boolean }> { - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - const stats = await provider.stat(absolutePath) - return { ...stats, isDirectory: () => stats.type === 'directory' } - } - - private async resolveAllowedTerminalArtifactPath(args: { - absolutePath: string - connectionId?: string - worktreePath: string - }): Promise { - if (args.connectionId) { - return this.resolveAllowedRemoteTerminalArtifactPath(args.absolutePath, args.connectionId) - } - return resolveAllowedLocalTerminalArtifactPath(args.absolutePath, args.worktreePath) - } - - private async resolveNativeChatArtifactPath( - absolutePath: string, - connectionId?: string - ): Promise { - if (!connectionId) { - return canonicalPathForArtifactComparison(absolutePath) - } - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.realpath(absolutePath) - } - - private async resolveAbsoluteFileGrant(args: { - worktreeId: string - artifactPath: string - rejectedAbsolutePath?: string - connectionId?: string - clientId?: string - readOnly?: boolean - provenance?: TerminalFileGrant['provenance'] - }): Promise { - const stats = args.connectionId - ? await this.statRemoteTerminalPath(args.artifactPath, args.connectionId) - : await this.statLocalTerminalPath(args.artifactPath) - const isDirectory = stats.isDirectory() - if (!isDirectory && isTerminalArtifactHardLinked(stats)) { - return { - worktree: args.worktreeId, - relativePath: null, - absolutePath: args.rejectedAbsolutePath ?? args.artifactPath, - exists: false, - isDirectory: false - } - } - const grant = isDirectory - ? null - : this.createTerminalFileGrant({ - worktreeId: args.worktreeId, - absolutePath: args.artifactPath, - provider: args.connectionId ? 'ssh' : 'local', - connectionId: args.connectionId, - clientId: args.clientId, - readOnly: args.readOnly === true, - provenance: args.provenance ?? 'terminal-output', - stats - }) - return { - worktree: args.worktreeId, - relativePath: null, - absolutePath: args.artifactPath, - exists: true, - isDirectory, - openTarget: grant - ? { - kind: 'absolute-file', - provider: grant.provider, - absolutePath: args.artifactPath, - grantId: grant.id, - ...(grant.readOnly ? { readOnly: true } : {}) - } - : undefined - } - } - - private async resolveAllowedRemoteTerminalArtifactPath( - absolutePath: string, - connectionId: string - ): Promise { - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - const roots = ['/tmp', '/private/tmp'] - const providerTempDir = await provider.getTempDir?.().catch(() => null) - if (providerTempDir) { - roots.push(providerTempDir) - } - if (!roots.some((root) => isPathInsideOrEqual(root, absolutePath))) { - return null - } - const [realArtifactPath, ...realRoots] = await Promise.all([ - provider.realpath(absolutePath), - ...roots.map((root) => provider.realpath(root).catch(() => root)) - ]) - // Why: SSH I/O follows symlinks on the relay; grant the canonical target so a /tmp link can't escape the temp boundary. - return realRoots.some((root) => isPathInsideOrEqual(root, realArtifactPath)) - ? realArtifactPath - : null - } - - private async statLocalTerminalPath( - absolutePath: string - ): Promise boolean }> { - await assertLocalTerminalArtifactPathStillCanonical(absolutePath) - const handle = await open(absolutePath, 'r') - try { - return handle.stat() - } finally { - await handle.close() - } - } - - private createTerminalFileGrant(args: { - worktreeId: string - absolutePath: string - provider: 'local' | 'ssh' - connectionId?: string - clientId?: string - readOnly?: boolean - provenance: TerminalFileGrant['provenance'] - stats: RuntimeFileStatLike - }): TerminalFileGrant { - assertTerminalArtifactNotHardLinked(args.stats) - const grant: TerminalFileGrant = { - id: randomUUID(), - worktreeId: args.worktreeId, - absolutePath: args.absolutePath, - provider: args.provider, - ...(args.connectionId ? { connectionId: args.connectionId } : {}), - ...(args.clientId ? { clientId: args.clientId } : {}), - expiresAt: Date.now() + TERMINAL_FILE_GRANT_TTL_MS, - statIdentity: terminalFileStatIdentity(args.stats), - readOnly: args.readOnly === true, - provenance: args.provenance - } - this.terminalFileGrants.set(grant.id, grant) - this.scheduleTerminalFileGrantExpiry(grant) - return grant - } - - private async requireTerminalFileGrant( - worktreeSelector: string, - grantId: string, - absolutePath: string, - clientId?: string - ): Promise<{ grant: TerminalFileGrant; target: ResolvedRuntimeFileTarget }> { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - this.pruneExpiredTerminalFileGrants() - const grant = this.terminalFileGrants.get(grantId) - if (!grant) { - throw new Error('terminal_file_grant_expired') - } - if (grant.expiresAt <= Date.now()) { - this.releaseTerminalFileGrant(grantId, grant) - throw new Error('terminal_file_grant_expired') - } - if ( - grant.worktreeId !== target.worktree.id || - grant.absolutePath !== absolutePath || - grant.connectionId !== target.connectionId || - grant.clientId !== clientId - ) { - throw new Error('terminal_file_grant_mismatch') - } - return { grant, target } - } - - private refreshTerminalFileGrant(grant: TerminalFileGrant): void { - grant.expiresAt = Date.now() + TERMINAL_FILE_GRANT_TTL_MS - this.scheduleTerminalFileGrantExpiry(grant) - } - - private pruneExpiredTerminalFileGrants(): void { - const now = Date.now() - for (const [id, grant] of this.terminalFileGrants) { - if (grant.expiresAt <= now) { - this.releaseTerminalFileGrant(id, grant) - } - } - } - - revokeTerminalFileGrantsForClient(clientId: string): void { - for (const [id, grant] of this.terminalFileGrants) { - if (grant.clientId === clientId) { - this.releaseTerminalFileGrant(id, grant) - } - } - } - - private releaseTerminalFileGrant(id: string, grant: TerminalFileGrant): void { - this.terminalFileGrants.delete(id) - if (grant.expiryTimer) { - clearTimeout(grant.expiryTimer) - grant.expiryTimer = undefined - } - } - - private scheduleTerminalFileGrantExpiry(grant: TerminalFileGrant): void { - if (grant.expiryTimer) { - clearTimeout(grant.expiryTimer) - } - grant.expiryTimer = setTimeout( - () => { - if (this.terminalFileGrants.get(grant.id) === grant && grant.expiresAt <= Date.now()) { - this.releaseTerminalFileGrant(grant.id, grant) - } - }, - Math.max(1, grant.expiresAt - Date.now()) - ) - grant.expiryTimer.unref?.() - } - - async readTerminalArtifactFile( - worktreeSelector: string, - grantId: string, - absolutePath: string, - clientId?: string - ): Promise { - const { grant, target } = await this.requireTerminalFileGrant( - worktreeSelector, - grantId, - absolutePath, - clientId - ) - if (isMobileBinaryPath(grant.absolutePath)) { - throw new Error('binary_file') - } - let content: string - if (grant.connectionId) { - const provider = await this.assertRemoteTerminalFileGrantFreshForRead(grant) - content = await this.readRemoteTerminalArtifactFile( - provider, - grant, - MOBILE_FILE_READ_MAX_BYTES - ) - } else { - const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) - try { - content = await readLocalTerminalArtifactFileFromHandle(handle, grant) - } finally { - await handle.close() - } - } - this.refreshTerminalFileGrant(grant) - const truncated = truncateMobileFilePreview(content) - - return { - worktree: target.worktree.id, - relativePath: grant.absolutePath, - content: truncated.content, - truncated: truncated.truncated, - byteLength: truncated.byteLength - } - } - - async readTerminalArtifactPreview( - worktreeSelector: string, - grantId: string, - absolutePath: string, - clientId?: string, - maxContentBytes?: number - ): Promise { - const { grant } = await this.requireTerminalFileGrant( - worktreeSelector, - grantId, - absolutePath, - clientId - ) - if (grant.connectionId) { - const provider = await this.assertRemoteTerminalFileGrantFreshForRead(grant) - this.refreshTerminalFileGrant(grant) - return assertPreviewWithinTransportBudget( - await this.readRemoteTerminalArtifactPreview(provider, grant, maxContentBytes), - maxContentBytes - ) - } - const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) - try { - const preview = await readLocalTerminalArtifactPreviewFromHandle( - handle, - grant, - maxContentBytes - ) - this.refreshTerminalFileGrant(grant) - return assertPreviewWithinTransportBudget(preview, maxContentBytes) - } finally { - await handle.close() - } - } - - async readTerminalArtifactChunk( - worktreeSelector: string, - grantId: string, - absolutePath: string, - offset: number, - length: number, - maxBytes: number, - clientId?: string - ): Promise { - if ( - !Number.isSafeInteger(offset) || - offset < 0 || - !Number.isSafeInteger(length) || - length < 1 || - length > 512 * 1024 || - !Number.isSafeInteger(maxBytes) || - maxBytes < 1 || - maxBytes > RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES - ) { - throw new Error('invalid_terminal_artifact_chunk') - } - const { grant } = await this.requireTerminalFileGrant( - worktreeSelector, - grantId, - absolutePath, - clientId - ) - let result: RuntimeFileReadChunkResult - if (grant.connectionId) { - const provider = await this.assertRemoteTerminalFileGrantPathStillCanonical(grant) - if (!provider.readTerminalArtifactChunk) { - throw new Error('terminal_file_grant_unavailable') - } - result = await provider.readTerminalArtifactChunk( - grant.absolutePath, - offset, - length, - this.terminalArtifactAccessOptions(grant, maxBytes) - ) - } else { - const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) - try { - const fileStats = await handle.stat() - if (fileStats.isDirectory()) { - throw new Error('Cannot read a directory') - } - if (fileStats.size > maxBytes) { - throw new Error('file_too_large') - } - assertTerminalFileGrantFresh(grant, fileStats) - const buffer = Buffer.alloc(Math.min(length, Math.max(0, fileStats.size - offset))) - const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset) - result = { - contentBase64: buffer.subarray(0, bytesRead).toString('base64'), - bytesRead, - eof: offset + bytesRead >= fileStats.size - } - } finally { - await handle.close() - } - } - const decoded = Buffer.from(result.contentBase64, 'base64') - if ( - decoded.toString('base64') !== result.contentBase64 || - result.bytesRead !== decoded.byteLength || - result.bytesRead > length || - (result.bytesRead === 0 && !result.eof) - ) { - throw new Error('invalid_terminal_artifact_chunk') - } - this.refreshTerminalFileGrant(grant) - return result - } - - async writeTerminalArtifactFile( - worktreeSelector: string, - grantId: string, - absolutePath: string, - content: string, - clientId?: string - ): Promise<{ ok: true }> { - if (Buffer.byteLength(content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES) { - throw new Error('file_too_large') - } - const { grant } = await this.requireTerminalFileGrant( - worktreeSelector, - grantId, - absolutePath, - clientId - ) - if (grant.readOnly) { - throw new Error('terminal_file_grant_read_only') - } - if (isMobileBinaryPath(grant.absolutePath)) { - throw new Error('binary_file') - } - if (grant.connectionId) { - const { provider, fileStat } = await this.assertRemoteTerminalFileGrantFresh(grant) - if (fileStat.type === 'directory') { - throw new Error('Cannot write to a directory') - } - if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { - throw new Error('file_too_large') - } - if (!provider.writeTerminalArtifact) { - throw new Error('terminal_file_grant_unavailable') - } - const nextStat = await provider.writeTerminalArtifact( - grant.absolutePath, - content, - this.terminalArtifactAccessOptions(grant, MOBILE_FILE_READ_MAX_BYTES) - ) - grant.statIdentity = terminalFileStatIdentity(nextStat) - this.refreshTerminalFileGrant(grant) - return { ok: true } - } - - let originalMode: number | null = null - const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) - try { - const fileStats = await handle.stat() - originalMode = fileStats.mode - if (fileStats.isDirectory()) { - throw new Error('Cannot write to a directory') - } - if (fileStats.size > MOBILE_FILE_READ_MAX_BYTES) { - throw new Error('file_too_large') - } - assertTerminalFileGrantFresh(grant, fileStats) - if ( - isBinaryBuffer(await readFileHandleBufferBounded(handle, MOBILE_FILE_READ_MAX_BYTES + 1)) - ) { - throw new Error('binary_file') - } - } finally { - await handle.close() - } - const tempPath = join( - dirname(grant.absolutePath), - `.${basename(grant.absolutePath)}.${randomUUID()}.tmp` - ) - try { - await writeFile(tempPath, content, { encoding: 'utf-8', flag: 'wx' }) - if (typeof originalMode === 'number') { - await chmod(tempPath, originalMode & 0o7777) - } - const freshHandle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) - try { - assertTerminalFileGrantFresh(grant, await freshHandle.stat()) - } finally { - await freshHandle.close() - } - await rename(tempPath, grant.absolutePath) - grant.statIdentity = terminalFileStatIdentity( - await this.statLocalTerminalPath(grant.absolutePath) - ) - this.refreshTerminalFileGrant(grant) - return { ok: true } - } finally { - await rm(tempPath, { force: true }).catch(() => {}) - } - } - - private async readRemoteTerminalArtifactPreview( - provider: IFilesystemProvider, - grant: TerminalFileGrant, - maxContentBytes: number | undefined - ): Promise { - const binaryMaxBytes = - maxContentBytes === undefined - ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES - : previewableBinaryByteLimit(maxContentBytes) - const preview = await this.readRemoteTerminalArtifact(provider, grant, binaryMaxBytes) - if ( - !preview.isBinary && - Buffer.byteLength(preview.content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES - ) { - throw new Error('file_too_large') - } - if ( - preview.isBinary && - maxContentBytes !== undefined && - Buffer.byteLength(preview.content, 'utf8') > maxContentBytes - ) { - throw new Error('file_too_large') - } - return preview - } - - private async readRemoteTerminalArtifactFile( - provider: IFilesystemProvider, - grant: TerminalFileGrant, - maxBytes: number - ): Promise { - const result = await this.readRemoteTerminalArtifact(provider, grant, maxBytes) - if (result.isBinary) { - throw new Error('binary_file') - } - return result.content - } - - private async readRemoteTerminalArtifact( - provider: IFilesystemProvider, - grant: TerminalFileGrant, - maxBytes: number - ): Promise { - if (!provider.readTerminalArtifact) { - throw new Error('terminal_file_grant_unavailable') - } - return provider.readTerminalArtifact( - grant.absolutePath, - this.terminalArtifactAccessOptions(grant, maxBytes) - ) - } - - private terminalArtifactAccessOptions( - grant: TerminalFileGrant, - maxBytes: number - ): { expectedRealPath: string; expectedStatIdentity: string | null; maxBytes: number } { - return { - expectedRealPath: grant.absolutePath, - expectedStatIdentity: grant.statIdentity, - maxBytes - } - } - - private async assertRemoteTerminalFileGrantFreshForRead( - grant: TerminalFileGrant - ): Promise { - const { provider } = await this.assertRemoteTerminalFileGrantFresh(grant) - return provider - } - - private async assertRemoteTerminalFileGrantFresh( - grant: TerminalFileGrant - ): Promise<{ provider: IFilesystemProvider; fileStat: FileStat }> { - const provider = await this.assertRemoteTerminalFileGrantPathStillCanonical(grant) - const fileStat = await provider.stat(grant.absolutePath) - assertTerminalFileGrantFresh(grant, fileStat) - return { provider, fileStat } - } - - private async assertRemoteTerminalFileGrantPathStillCanonical( - grant: TerminalFileGrant - ): Promise { - if (!grant.connectionId) { - throw new Error('terminal_file_grant_mismatch') - } - const provider = getSshFilesystemProvider(grant.connectionId) - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - const canonicalPath = - grant.provenance === 'native-chat' - ? await provider.realpath(grant.absolutePath) - : await this.resolveAllowedRemoteTerminalArtifactPath( - grant.absolutePath, - grant.connectionId - ) - // Why: relay I/O follows symlinks, so re-canonicalize after the remote process can mutate the path. - if (canonicalPath !== grant.absolutePath) { - throw new Error('terminal_file_grant_stale') - } - return provider - } - - async readFileExplorerDir(worktreeSelector: string, relativePath: string): Promise { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - // Why: re-sort locally — the remote relay may be an older build with - // lexicographic ordering. - return sortDirEntries(await provider.readDir(target.path)) - } - - const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - const entries = await readdir(dirPath, { withFileTypes: true }) - const mapped = entries.map((entry) => ({ - name: entry.name, - isDirectory: isRuntimeDirectoryEntry(entry), - isSymlink: entry.isSymbolicLink() - })) - return sortDirEntries(mapped) - } - - async watchFileExplorer( - worktreeSelector: string, - callback: (events: FsChangeEvent[]) => void, - onTerminalError: (error: Error) => void = () => undefined, - signal?: AbortSignal - ): Promise<() => Promise> { - const target = await this.resolveFileExplorerPath(worktreeSelector, '') - const open = async (): Promise<{ - unsubscribe: () => Promise - rootPaths: string[] - }> => { - const finishInstall = beginWatcherInstall(target.path, target.connectionId) - try { - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - // Why: the RPC layer already threads AbortSignal for local watches; SSH must cancel the remote fs.watch, not wait it out. - const close = await provider.watch(target.path, callback, { signal, onTerminalError }) - const rearm = armSshFileExplorerWatchRearm({ - runtimeId: this.host.getRuntimeId(), - connectionId: target.connectionId, - rootPath: target.path, - callback, - onTerminalError, - signal, - initialUnwatch: close - }) - return { unsubscribe: rearm.unsubscribe, rootPaths: [target.path] } - } - - const rootPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - const rootStats = await stat(rootPath) - if (!rootStats.isDirectory()) { - throw new Error('not_a_directory') - } - if (process.platform === 'win32') { - const close = watchWindowsRuntimeFileExplorer(rootPath, callback, onTerminalError) - return { unsubscribe: close, rootPaths: [target.path, rootPath] } - } - // Why: the forked watcher keeps the blocking crawl and native faults out of the main/`serve` process (issues #5308, #8212). - const dispose = await watchFileExplorerInWatcherProcess( - rootPath, - callback, - onTerminalError, - signal - ) - return { unsubscribe: dispose, rootPaths: [target.path, rootPath] } - } finally { - finishInstall() - } - } - const initial = await open() - return registerRuntimeFileWatcherRelease( - this.host.getRuntimeId(), - target.connectionId, - initial.rootPaths, - initial.unsubscribe, - async () => (await open()).unsubscribe, - onTerminalError - ) - } - - async closeFileExplorerWatchersForPath(rootPath: string, connectionId?: string): Promise { - const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath) - const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) - if (leases) { - await Promise.all(Array.from(leases, (lease) => lease.suspend())) - } - if (!connectionId) { - // Why: setup can fail before registerRuntimeFileWatcherRelease publishes its callback while the child owner still lives. - const resolvedRootPath = await resolveAuthorizedPath(rootPath, this.host.requireStore()) - await closeFileExplorerWatcherInWatcherProcess(resolvedRootPath) - } - } - - async restoreFileExplorerWatchersAfterFailedRemoval( - rootPath: string, - connectionId?: string - ): Promise { - const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath) - const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) - if (leases) { - await Promise.all(Array.from(leases, (lease) => lease.resume())) - } - } - - forgetFileExplorerWatchersAfterRemoval(rootPath: string, connectionId?: string): void { - const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath) - // Why: forget() never runs the lease's unsubscribe, so the re-arm would outlive a deleted - // worktree and re-watch it on the next reconnect. - stopSshFileExplorerWatchRearms(key) - const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) - if (leases) { - for (const lease of Array.from(leases)) { - lease.forget() - } - } - } - - async readFileExplorerPreview( - worktreeSelector: string, - relativePath: string, - maxContentBytes?: number - ): Promise { - const binaryMaxBytes = - maxContentBytes === undefined - ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES - : previewableBinaryByteLimit(maxContentBytes) - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - const fileStats = await provider.stat(target.path) - if (fileStats.size > binaryMaxBytes) { - throw new Error('file_too_large') - } - const result = await readPreviewFileWithinCap(provider, target.path, { - maxBinaryBytes: binaryMaxBytes, - maxTextBytes: MOBILE_FILE_READ_MAX_BYTES - }) - // Why: the stat gate sizes base64 binaries; text crosses the wire JSON-escaped (up to 6x), so - // hold it to the same decoded limit the local branch enforces before reading. - if ( - !result.isBinary && - Buffer.byteLength(result.content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES - ) { - throw new Error('file_too_large') - } - if ( - result.isBinary && - maxContentBytes !== undefined && - Buffer.byteLength(result.content, 'utf8') > maxContentBytes - ) { - throw new Error('file_too_large') - } - return assertPreviewWithinTransportBudget(result, maxContentBytes) - } - - const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] - const maxBytes = mimeType ? binaryMaxBytes : MOBILE_FILE_READ_MAX_BYTES - let buffer: Buffer - try { - buffer = (await readNodeFileWithinLimit(filePath, maxBytes)).buffer - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw new Error('file_too_large') - } - throw error - } - if (mimeType) { - return assertPreviewWithinTransportBudget( - { - content: buffer.toString('base64'), - isBinary: true, - isImage: true, - mimeType - }, - maxContentBytes - ) - } - - if (isBinaryBuffer(buffer)) { - return assertPreviewWithinTransportBudget({ content: '', isBinary: true }, maxContentBytes) - } - return assertPreviewWithinTransportBudget( - { content: buffer.toString('utf-8'), isBinary: false }, - maxContentBytes - ) - } - - async readDocPreviewFile( - worktreeSelector: string, - relativePath: string, - entryRelativePath: string, - implicitRootRelativePath: string | null, - authorizedRootRelativePaths: string[], - maxContentBytes?: number - ): Promise { - const relativePaths = [ - '', - entryRelativePath, - relativePath, - ...(implicitRootRelativePath === null ? [] : [implicitRootRelativePath]), - ...authorizedRootRelativePaths - ] - const [boundary, entry, target, ...authorityRoots] = await this.resolveFileExplorerPaths( - worktreeSelector, - relativePaths - ) - const implicitRoot = implicitRootRelativePath === null ? null : authorityRoots[0] - const authorizedRoots = authorityRoots.slice(implicitRoot === null ? 0 : 1) - const binaryMaxBytes = - maxContentBytes === undefined - ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES - : previewableBinaryByteLimit(maxContentBytes) - const request: DocPreviewFileAccessRequest = { - boundaryPath: boundary.path, - entryPath: entry.path, - implicitRootPath: implicitRoot?.path ?? null, - authorizedRootPaths: authorizedRoots.map((root) => root.path), - targetPath: target.path, - maxTextBytes: MOBILE_FILE_READ_MAX_BYTES, - maxBinaryBytes: binaryMaxBytes - } - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId && !provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - if (target.connectionId && !provider?.readDocPreviewFile) { - throw new Error('Secure document previews require a newer SSH relay') - } - const result = provider?.readDocPreviewFile - ? await provider.readDocPreviewFile(request) - : await readAuthorizedDocPreviewFile(request) - return assertPreviewWithinTransportBudget(result, maxContentBytes) - } - - async readFileExplorerChunk( - worktreeSelector: string, - relativePath: string, - offset: number, - length: number - ): Promise { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - const fileStat = await provider.stat(target.path) - if (fileStat.type === 'directory') { - throw new Error('Cannot download a directory') - } - return provider.readFileChunk - ? provider.readFileChunk(target.path, offset, length) - : readSshFileExplorerChunk(provider, target.path, fileStat.size, offset, length) - } - - const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - const fileStats = await stat(filePath) - if (fileStats.isDirectory()) { - throw new Error('Cannot download a directory') - } - const handle = await open(filePath, 'r') - try { - const buffer = Buffer.alloc(Math.min(length, Math.max(0, fileStats.size - offset))) - const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset) - const chunk = buffer.subarray(0, bytesRead) - return { - contentBase64: chunk.toString('base64'), - bytesRead, - eof: offset + bytesRead >= fileStats.size - } - } finally { - await handle.close() - } - } - - async writeFileExplorerFile( - worktreeSelector: string, - relativePath: string, - content: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - assertRuntimeFileMutationExpectation( - target.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.writeFile(target.path, content) - return { ok: true } - } - - const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - try { - const fileStats = await lstat(filePath) - if (fileStats.isDirectory()) { - throw new Error('Cannot write to a directory') - } - } catch (error) { - if (!isENOENT(error)) { - throw error - } - } - await writeFile(filePath, content, 'utf-8') - return { ok: true } - } - - async writeFileExplorerFileBase64( - worktreeSelector: string, - relativePath: string, - contentBase64: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - assertRuntimeFileMutationExpectation( - target.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - const content = Buffer.from(contentBase64, 'base64') - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.writeFileBase64(target.path, contentBase64) - return { ok: true } - } - - const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - await mkdir(dirname(filePath), { recursive: true }) - await writeFile(filePath, content, { flag: 'wx' }) - return { ok: true } - } - - async writeFileExplorerFileBase64Chunk( - worktreeSelector: string, - relativePath: string, - contentBase64: string, - append: boolean, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - assertRuntimeFileMutationExpectation( - target.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - const content = Buffer.from(contentBase64, 'base64') - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.writeFileBase64Chunk(target.path, contentBase64, append) - return { ok: true } - } - - const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - await mkdir(dirname(filePath), { recursive: true }) - await writeFile(filePath, content, { flag: append ? 'a' : 'wx' }) - return { ok: true } - } - - async createFileExplorerFile( - worktreeSelector: string, - relativePath: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - assertRuntimeFileMutationExpectation( - target.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.createFile(target.path) - return { ok: true } - } - - const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - await mkdir(dirname(filePath), { recursive: true }) - try { - await writeFile(filePath, '', { encoding: 'utf-8', flag: 'wx' }) - } catch (error) { - rethrowRuntimeFileCreateError(error, filePath) - } - return { ok: true } - } - - async createFileExplorerDir( - worktreeSelector: string, - relativePath: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - assertRuntimeFileMutationExpectation( - target.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.createDir(target.path) - return { ok: true } - } - - const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - await assertRuntimePathDoesNotExist(dirPath) - await mkdir(dirPath, { recursive: false }) - return { ok: true } - } - - async createFileExplorerDirNoClobber( - worktreeSelector: string, - relativePath: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - assertRuntimeFileMutationExpectation( - target.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.createDirNoClobber(target.path) - return { ok: true } - } - - const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - await mkdir(dirPath, { recursive: false }) - return { ok: true } - } - - async commitFileExplorerUpload( - worktreeSelector: string, - tempRelativePath: string, - finalRelativePath: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const [tempTarget, finalTarget] = await this.resolveFileExplorerPaths(worktreeSelector, [ - tempRelativePath, - finalRelativePath - ]) - assertRuntimeFileMutationExpectation( - tempTarget.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = tempTarget.connectionId - ? getSshFilesystemProvider(tempTarget.connectionId) - : null - if (tempTarget.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.copy(tempTarget.path, finalTarget.path) - await provider.deletePath(tempTarget.path, false).catch(() => {}) - return { ok: true } - } - - const store = this.host.requireStore() - const tempPath = await resolveAuthorizedPath(tempTarget.path, store) - const finalPath = await resolveAuthorizedPath(finalTarget.path, store) - await mkdir(dirname(finalPath), { recursive: true }) - await copyFile(tempPath, finalPath, constants.COPYFILE_EXCL) - await rm(tempPath, { force: true }) - return { ok: true } - } - - async renameFileExplorerPath( - worktreeSelector: string, - oldRelativePath: string, - newRelativePath: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const [oldTarget, newTarget] = await this.resolveFileExplorerPaths(worktreeSelector, [ - oldRelativePath, - newRelativePath - ]) - assertRuntimeFileMutationExpectation( - oldTarget.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = oldTarget.connectionId - ? getSshFilesystemProvider(oldTarget.connectionId) - : null - if (oldTarget.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.renameNoClobber(oldTarget.path, newTarget.path) - return { ok: true } - } - - const store = this.host.requireStore() - const oldPath = await resolveAuthorizedPath(oldTarget.path, store, { preserveSymlink: true }) - const newPath = await resolveAuthorizedPath(newTarget.path, store, { preserveSymlink: true }) - await renameLocalPathSerializedByDestination(oldPath, newPath) - return { ok: true } - } - - async copyFileExplorerPath( - worktreeSelector: string, - sourceRelativePath: string, - destinationRelativePath: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const [sourceTarget, destinationTarget] = await this.resolveFileExplorerPaths( - worktreeSelector, - [sourceRelativePath, destinationRelativePath] - ) - assertRuntimeFileMutationExpectation( - sourceTarget.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = sourceTarget.connectionId - ? getSshFilesystemProvider(sourceTarget.connectionId) - : null - if (sourceTarget.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.copy(sourceTarget.path, destinationTarget.path) - return { ok: true } - } - - const store = this.host.requireStore() - const sourcePath = await resolveAuthorizedPath(sourceTarget.path, store, { - preserveSymlink: true - }) - const destinationPath = await resolveAuthorizedPath(destinationTarget.path, store, { - preserveSymlink: true - }) - await mkdir(dirname(destinationPath), { recursive: true }) - // Why: COPYFILE_EXCL preserves the no-clobber invariant of the local shell copy IPC (caller already deconflicts names). - await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL) - return { ok: true } - } - - async deleteFileExplorerPath( - worktreeSelector: string, - relativePath: string, - recursive?: boolean, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - assertRuntimeFileMutationExpectation( - target.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.deletePath(target.path, recursive) - return { ok: true } - } - - const targetPath = await resolveAuthorizedPath(target.path, this.host.requireStore(), { - preserveSymlink: true - }) - // Why: a non-local runtime has no client Trash; this delete is permanent, so the renderer confirms before calling. - await rm(targetPath, { recursive: recursive === true, force: true }) - return { ok: true } - } - - async searchRuntimeFiles( - worktreeSelector: string, - options: Omit - ): Promise { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - const rootPath = target.worktree.path - const searchOptions = { ...options, rootPath } - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.search(searchOptions) - } - return this.searchLocalRuntimeFiles(rootPath, searchOptions) - } - - async listRuntimeFiles( - worktreeSelector: string, - options: { - excludePaths?: string[] - maxContentBytes?: number - maxResults?: number - signal?: AbortSignal - } = {} - ): Promise { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - return [] - } - const maxResults = - options.maxResults ?? - (options.maxContentBytes === undefined ? undefined : QUICK_OPEN_LISTING_MAX_RESULTS) - const files = await provider.listFiles(target.worktree.path, { - excludePaths: options.excludePaths, - maxResults, - signal: options.signal - }) - return options.maxContentBytes === undefined - ? files - : limitQuickOpenFilesBySerializedBytes(files, options.maxContentBytes) - } - return listQuickOpenFiles( - target.worktree.path, - this.host.requireStore(), - options.excludePaths, - options.signal, - options.maxResults, - options.maxContentBytes - ) - } - - async listRuntimeMarkdownDocuments(worktreeSelector: string): Promise { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - const relativePaths = provider.listMarkdownDocuments - ? await provider.listMarkdownDocuments(target.worktree.path) - : await provider.listFiles(target.worktree.path) - return markdownDocumentsFromRelativePaths(target.worktree.path, relativePaths) - } - return listMarkdownDocuments(target.worktree.path) - } - - async statRuntimeFile( - worktreeSelector: string, - relativePath: string - ): Promise<{ size: number; isDirectory: boolean; mtime: number }> { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - const fileStat = await provider.stat(target.path) - return { - size: fileStat.size, - isDirectory: fileStat.type === 'directory', - mtime: fileStat.mtime - } - } - const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - const stats = await stat(filePath) - return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs } - } - - private async searchLocalRuntimeFiles( - rootPath: string, - options: SearchOptions - ): Promise { - const store = this.host.requireStore() - const authorizedRootPath = await resolveAuthorizedPath(rootPath, store) - const localGitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - rootPath, - authorizedRootPath - ) - const maxResults = Math.max( - 1, - Math.min(options.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) - ) - const wslInfo = parseWslPath(authorizedRootPath) - if ( - (wslInfo || localGitOptions.wslDistro) && - !(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)) - ) { - return searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions) - } - - return new Promise((resolvePromise) => { - const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}` - const rgArgs = buildRgArgs(options.query, authorizedRootPath, options) - const previousChild = this.activeRuntimeTextSearches.get(searchKey) - if (previousChild) { - killSpawnedRipgrepProcess(previousChild) - } - - const acc = createAccumulator() - let stdoutBuffer = '' - let resolved = false - let processErrorObserved = false - let unavailableExitObserved = false - let child: ChildProcess | null = null - const transformAbsPath = wslInfo - ? (p: string): string => toWindowsWslPath(p, wslInfo.distro) - : undefined - - const finish = (result: SearchResult | PromiseLike): void => { - if (resolved) { - return - } - resolved = true - if (this.activeRuntimeTextSearches.get(searchKey) === child) { - this.activeRuntimeTextSearches.delete(searchKey) - } - cleanupListeners() - resolvePromise(result) - } - const resolveOnce = (): void => finish(finalize(acc)) - const resolveWithoutRipgrep = (): void => - finish(searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions)) - - let killTimeout: ReturnType | null = null - const cleanupListeners = (): void => { - if (killTimeout) { - clearTimeout(killTimeout) - killTimeout = null - } - child?.stdout?.off('data', onStdoutData) - child?.stderr?.off('data', onStderrData) - child?.off('error', onError) - child?.off('close', onClose) - if (child) { - absorbPendingRipgrepSpawnError(child, { - errorObserved: processErrorObserved, - unavailableExitObserved - }) - } - } - - const processLine = (line: string): void => { - const verdict = ingestRgJsonLine( - line, - authorizedRootPath, - acc, - maxResults, - transformAbsPath - ) - if (verdict === 'stop' && child) { - killSpawnedRipgrepProcess(child) - } - } - - const nextChild = wslAwareSpawn('rg', rgArgs, { - cwd: authorizedRootPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - stdio: ['ignore', 'pipe', 'pipe'] - }) - child = nextChild - this.activeRuntimeTextSearches.set(searchKey, nextChild) - - nextChild.stdout!.setEncoding('utf-8') - const onStdoutData = (chunk: string): void => { - stdoutBuffer += chunk - const lines = stdoutBuffer.split('\n') - stdoutBuffer = lines.pop() ?? '' - for (const line of lines) { - processLine(line) - } - } - const onStderrData = (): void => { - // Drain stderr so rg cannot block on a full pipe. - } - const onError = (): void => { - processErrorObserved = true - if (child && isRipgrepUnavailableExit(child, null, null)) { - resolveWithoutRipgrep() - return - } - resolveOnce() - } - const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { - if ( - child && - isRipgrepUnavailableExit(child, code, signal, { - classifyNativeLauncherExit: !(wslInfo || localGitOptions.wslDistro) - }) - ) { - unavailableExitObserved = true - resolveWithoutRipgrep() - return - } - if (stdoutBuffer) { - processLine(stdoutBuffer) - } - resolveOnce() - } - - nextChild.stdout!.on('data', onStdoutData) - nextChild.stderr!.on('data', onStderrData) - nextChild.once('error', onError) - nextChild.once('close', onClose) - - killTimeout = setTimeout(() => { - acc.truncated = true - if (child) { - killSpawnedRipgrepProcess(child) - } - resolveOnce() - }, SEARCH_TIMEOUT_MS) - }) - } - - private async resolveFileExplorerPath( - worktreeSelector: string, - relativePath: string - ): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }> { - const [target] = await this.resolveFileExplorerPaths(worktreeSelector, [relativePath]) - return target - } - - private async resolveFileExplorerPaths( - worktreeSelector: string, - relativePaths: readonly string[] - ): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }[]> { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - return relativePaths.map((relativePath) => ({ - worktree: target.worktree, - path: joinWorktreeRelativePath( - target.worktree.path, - normalizeRuntimeRelativePath(relativePath) - ), - connectionId: target.connectionId - })) - } - - private async listRemoteMobileFiles( - rootPath: string, - connectionId: string, - maxResults?: number, - signal?: AbortSignal - ): Promise { - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - return [] - } - return provider.listFiles(rootPath, { maxResults, signal }) - } - - private async searchRemoteQuickOpenFilePaths( - rootPath: string, - connectionId: string, - query: string, - limit: number, - excludePaths?: string[], - signal?: AbortSignal - ): Promise<{ paths: string[]; totalCount: number; truncated: boolean }> { - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - return { paths: [], totalCount: 0, truncated: false } - } - if (!(await provider.supportsQuickOpenSearch?.({ signal }))) { - // Old relays ignore searchQuery. Keep the compatibility request below the - // 4 MiB frame ceiling even when legacy paths are near the 64 KiB path cap. - const legacyFiles = await provider.listFiles(rootPath, { - excludePaths, - maxResults: QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT, - signal - }) - const ranker = new QuickOpenPathRanker(query, limit) - for (const file of legacyFiles) { - ranker.consider(file) - } - const result = ranker.result() - return { - ...result, - truncated: - legacyFiles.length >= QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT || result.totalCount > limit - } - } - const files = await provider.listFiles(rootPath, { - excludePaths, - maxResults: limit + 1, - searchQuery: query, - signal - }) - return { - paths: files.slice(0, limit), - totalCount: files.length, - truncated: files.length > limit - } - } - - private async readRemoteMobileFile(filePath: string, connectionId: string): Promise { - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - const fileStat = await provider.stat(filePath) - // Why: no ranged reads over SSH here, so reject oversized previews instead of streaming a whole file just to trim it. - if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { - throw new Error('file_too_large') - } - const result = await provider.readFile(filePath) - if (result.isBinary) { - throw new Error('binary_file') - } - return result.content - } -} - -function watchWindowsRuntimeFileExplorer( - rootPath: string, - callback: (events: FsChangeEvent[]) => void, - onTerminalError: (error: Error) => void -): () => Promise { - let disposed = false - let timer: ReturnType | null = null - let closeStarted = false - const physicalClose = new PhysicalExitTracker() - - const emitOverflow = (): void => { - timer = null - if (disposed) { - return - } - callback([{ kind: 'overflow', absolutePath: rootPath }]) - } - - const scheduleOverflow = (): void => { - if (disposed) { - return - } - if (timer) { - clearTimeout(timer) - } - timer = setTimeout(emitOverflow, WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS) - } - - // Why: Parcel's Watchman probe can crash the headless server on Windows; use a conservative overflow refresh instead. - const watcher = watchFs(rootPath, { recursive: true }, scheduleOverflow) - const onClose = (): void => { - watcher.removeListener('error', onError) - physicalClose.markExited() - } - const onError = (err: Error): void => { - console.error('[runtime-files.watch] Windows watcher error', { rootPath, err }) - if (timer) { - clearTimeout(timer) - timer = null - } - watcher.removeListener('close', onClose) - watcher.removeListener('error', onError) - // Why: Node nulls FSWatcher's native handle on error without a close event; treat the error as physical-exit proof. - physicalClose.markExited() - if (!disposed) { - try { - callback([{ kind: 'overflow', absolutePath: rootPath }]) - } finally { - onTerminalError(err) - } - } - } - watcher.once('close', onClose) - watcher.on('error', onError) - - return async () => { - disposed = true - if (timer) { - clearTimeout(timer) - timer = null - } - if (!closeStarted) { - try { - watcher.close() - } catch (err) { - console.error('[runtime-files.watch] Windows watcher close error', { rootPath, err }) - throw err - } - closeStarted = true - } - try { - await physicalClose.waitForExit( - WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS, - () => new Error('Windows watcher did not close before deletion deadline') - ) - } catch (error) { - // Why: late Windows close still owns native dir handles; expose its completion so cleanup retains then clears the root. - throw new WatcherProcessFailure( - error instanceof Error ? error.message : String(error), - 'supervisor', - 'process_unavailable', - physicalClose.exitedPromise - ) - } - } -} - -export function isSafeMobileRelativePath(relativePath: string): boolean { - if (!relativePath || relativePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(relativePath)) { - return false - } - const parts = relativePath.replace(/\\/g, '/').split('/') - return parts.every((part) => part !== '' && part !== '.' && part !== '..') -} - -function isMobileMarkdownPath(relativePath: string): boolean { - return /\.(md|mdx|markdown)$/i.test(relativePath) -} - -function isMobileBinaryPath(relativePath: string): boolean { - const basename = basenameFromRelativePath(relativePath) - const dotIndex = basename.lastIndexOf('.') - if (dotIndex <= 0) { - return false - } - return MOBILE_BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) -} - -function basenameFromRelativePath(relativePath: string): string { - const normalized = relativePath.replace(/\\/g, '/') - return normalized.slice(normalized.lastIndexOf('/') + 1) -} - -function isRuntimeDirectoryEntry(entry: { - isDirectory(): boolean - isSymbolicLink(): boolean -}): boolean { - // Why: listings are passive UI reads; don't stat symlink targets here (explicit open/expand resolves them). - if (entry.isSymbolicLink()) { - return false - } - if (entry.isDirectory()) { - return true - } - return false -} - -function isBinaryBuffer(buffer: Buffer): boolean { - const len = Math.min(buffer.length, 8192) - for (let i = 0; i < len; i += 1) { - if (buffer[i] === 0) { - return true - } - } - return false -} - -async function assertRuntimePathDoesNotExist(targetPath: string): Promise { - try { - await lstat(targetPath) - throw new Error( - `A file or folder named '${basename(targetPath)}' already exists in this location` - ) - } catch (error) { - if (!isENOENT(error)) { - throw error - } - } -} - -function rethrowRuntimeFileCreateError(error: unknown, targetPath: string): never { - const name = basename(targetPath) - if (error instanceof Error && 'code' in error) { - const code = (error as NodeJS.ErrnoException).code - if (code === 'EEXIST') { - throw new Error(`A file or folder named '${name}' already exists in this location`) - } - if (code === 'EACCES' || code === 'EPERM') { - throw new Error(`Permission denied: unable to create '${name}'`) - } - } - throw error -} - -async function readLocalMobileFile(filePath: string, store: Store): Promise { - const authorizedPath = await resolveAuthorizedPath(filePath, store) - const fileStat = await stat(authorizedPath) - // Why: cap the read so opening a large file can't block the WebSocket (previews are read-only convenience views). - const readLimit = Math.min(fileStat.size, MOBILE_FILE_READ_MAX_BYTES + 1) - const handle = await open(authorizedPath, 'r') - try { - const buffer = Buffer.alloc(readLimit) - const { bytesRead } = await handle.read(buffer, 0, readLimit, 0) - return buffer.subarray(0, bytesRead).toString('utf8') - } finally { - await handle.close() - } -} - -async function readLocalTerminalArtifactFileFromHandle( - handle: FileHandle, - grant: TerminalFileGrant -): Promise { - const fileStat = await handle.stat() - if (fileStat.isDirectory()) { - throw new Error('Cannot read a directory') - } - if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { - throw new Error('file_too_large') - } - assertTerminalFileGrantFresh(grant, fileStat) - const buffer = await readFileHandleBufferBounded(handle, MOBILE_FILE_READ_MAX_BYTES + 1) - if (isBinaryBuffer(buffer)) { - throw new Error('binary_file') - } - return buffer.toString('utf8') -} - -async function readLocalTerminalArtifactPreviewFromHandle( - handle: FileHandle, - grant: TerminalFileGrant, - maxContentBytes: number | undefined -): Promise { - const fileStats = await handle.stat() - if (fileStats.isDirectory()) { - throw new Error('Cannot preview a directory') - } - assertTerminalFileGrantFresh(grant, fileStats) - const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(grant.absolutePath).toLowerCase()] - if (mimeType) { - const binaryMaxBytes = - maxContentBytes === undefined - ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES - : previewableBinaryByteLimit(maxContentBytes) - if (fileStats.size > binaryMaxBytes) { - throw new Error('file_too_large') - } - const buffer = await readFileHandleBufferBounded(handle, binaryMaxBytes + 1) - if (buffer.byteLength > binaryMaxBytes) { - throw new Error('file_too_large') - } - return { - content: buffer.toString('base64'), - isBinary: true, - isImage: true, - mimeType - } - } - - const content = await readLocalTerminalArtifactFileFromHandle(handle, grant) - return { content, isBinary: false } -} - -async function assertLocalTerminalArtifactPathStillCanonical(filePath: string): Promise { - const currentPath = await canonicalPathForArtifactComparison(filePath) - if (currentPath !== filePath) { - throw new Error('terminal_file_grant_stale') - } -} - -async function openLocalTerminalArtifactGrant( - grant: TerminalFileGrant, - flags: number -): Promise { - await assertLocalTerminalArtifactPathStillCanonical(grant.absolutePath) - try { - return await open(grant.absolutePath, flags | OPEN_NOFOLLOW) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ELOOP') { - throw new Error('terminal_file_grant_stale') - } - throw error - } -} - -function resolveTerminalAbsolutePath(args: { - base: string - expanded: string - worktreePath: string - connectionId?: string - terminalFileUriHostname?: string | null -}): string { - const expanded = normalizeTerminalFileUriAuthorityPath( - args.expanded, - args.connectionId, - args.terminalFileUriHostname, - args.worktreePath - ) - const absolutePath = isRuntimePathAbsolute(expanded) - ? expanded - : resolveRuntimePath(args.base, expanded) - if (args.connectionId) { - return normalizeLeadingSlashDrivePath(absolutePath, args.worktreePath) - } - const wsl = parseWslPath(args.worktreePath) - if (wsl && absolutePath.startsWith('/') && !absolutePath.startsWith('//')) { - return toWindowsWslPath(absolutePath, wsl.distro) - } - return absolutePath -} - -function normalizeTerminalFileUriAuthorityPath( - pathText: string, - connectionId?: string, - terminalFileUriHostname?: string | null, - worktreePath?: string -): string { - if (!pathText.startsWith('//')) { - return pathText - } - const match = /^\/\/([^/\\]+)([/\\].*)$/.exec(pathText) - if (!match) { - return pathText - } - const host = match[1]!.toLowerCase() - if (terminalFileUriHostname && host === terminalFileUriHostname.toLowerCase() && connectionId) { - return normalizeLeadingSlashDrivePath(match[2]!, worktreePath) - } - if (isLoopbackFileUriHostname(host) && (connectionId || process.platform !== 'win32')) { - return normalizeLeadingSlashDrivePath(match[2]!, worktreePath) - } - // Why: without a verified host match, stripping the file-URI authority could open a same-path artifact on the wrong machine. - return pathText -} - -function provenancePathCandidate(pathText: string, absolutePath: string): string { - return pathText.startsWith('//') ? pathText : absolutePath -} - -function isLoopbackFileUriHostname(hostname: string): boolean { - return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' -} - -function normalizeLeadingSlashDrivePath(pathText: string, worktreePath?: string): string { - return worktreePath && - isWindowsAbsolutePathLike(worktreePath) && - /^\/[A-Za-z]:[\\/]/.test(pathText) - ? pathText.slice(1) - : pathText -} - -async function resolveAllowedLocalTerminalArtifactPath( - absolutePath: string, - worktreePath: string -): Promise { - const roots = await localTerminalArtifactRoots(worktreePath) - const canonicalPath = await canonicalPathForArtifactComparison(absolutePath) - return roots.some((root) => isPathInsideOrEqual(root, canonicalPath)) ? canonicalPath : null -} - -async function localTerminalArtifactRoots(worktreePath: string): Promise { - const roots = new Set([tmpdir()]) - if (process.platform !== 'win32') { - roots.add('/tmp') - roots.add('/private/tmp') - } - const wsl = parseWslPath(worktreePath) - if (wsl) { - roots.add(toWindowsWslPath('/tmp', wsl.distro)) - } - const canonicalRoots = await Promise.all( - Array.from(roots).map((root) => canonicalPathForArtifactComparison(root)) - ) - return Array.from(new Set([...roots, ...canonicalRoots])) -} - -async function canonicalPathForArtifactComparison(path: string): Promise { - try { - return await realpath(path) - } catch { - return path - } -} - -async function readFileHandleBufferBounded(handle: FileHandle, limit: number): Promise { - const buffer = Buffer.alloc(limit) - const { bytesRead } = await handle.read(buffer, 0, limit, 0) - return buffer.subarray(0, bytesRead) -} - -function terminalFileStatIdentity(stats: RuntimeFileStatLike): string | null { - const dev = typeof stats.dev === 'number' ? stats.dev : null - const ino = typeof stats.ino === 'number' ? stats.ino : null - const nlink = typeof stats.nlink === 'number' ? stats.nlink : null - const size = typeof stats.size === 'number' ? stats.size : null - const mtimeMs = - typeof stats.mtimeMs === 'number' - ? stats.mtimeMs - : typeof stats.mtime === 'number' - ? stats.mtime - : null - if (dev !== null && ino !== null && size !== null && mtimeMs !== null) { - return `${dev}:${ino}:${nlink ?? 'unknown'}:${size}:${mtimeMs}` - } - if (size !== null && mtimeMs !== null) { - return `${size}:${mtimeMs}` - } - return null -} - -function assertTerminalFileGrantFresh(grant: TerminalFileGrant, stats: RuntimeFileStatLike): void { - assertTerminalArtifactNotHardLinked(stats) - const nextIdentity = terminalFileStatIdentity(stats) - if (grant.statIdentity !== null && nextIdentity !== null && grant.statIdentity !== nextIdentity) { - throw new Error('terminal_file_grant_stale') - } -} - -function assertTerminalArtifactNotHardLinked(stats: RuntimeFileStatLike): void { - if (isTerminalArtifactHardLinked(stats)) { - throw new Error('terminal_file_grant_stale') - } -} - -function isTerminalArtifactHardLinked(stats: RuntimeFileStatLike): boolean { - return typeof stats.nlink === 'number' && stats.nlink > 1 -} - -function truncateMobileFilePreview(content: string): { - content: string - truncated: boolean - byteLength: number -} { - const buffer = Buffer.from(content, 'utf8') - if (buffer.byteLength <= MOBILE_FILE_READ_MAX_BYTES) { - return { content, truncated: false, byteLength: buffer.byteLength } - } - return { - content: buffer.subarray(0, MOBILE_FILE_READ_MAX_BYTES).toString('utf8'), - truncated: true, - byteLength: buffer.byteLength - } -} +import { RuntimeFileCommandsWithSearchRemoteQuickOpenFilePaths } from './runtime-file-commands-search-remote-quick-open-file-paths' + +export class RuntimeFileCommands extends RuntimeFileCommandsWithSearchRemoteQuickOpenFilePaths {} +export { RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES } from './runtime-file-commands-mobile-file-list-limit' +export { WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS } from './runtime-file-commands-mobile-file-list-limit' +export { awaitRuntimeFileWatcherUnsubscribes } from './runtime-file-watcher-leases' +export { _getRuntimeFileWatcherReleaseCountForTests } from './runtime-file-watcher-leases' +export { _resetRuntimeFileWatcherLeasesForTests } from './runtime-file-watcher-leases' +export type { ResolvedRuntimeFileWorktree } from './runtime-file-watcher-leases' +export type { ResolvedRuntimeFileTarget } from './runtime-file-watcher-leases' +export { getRuntimeFileTargetExecutionHostId } from './runtime-file-watcher-leases' +export type { RuntimeFileCommandHost } from './runtime-file-command-host' +export { isSafeMobileRelativePath } from './runtime-file-command-host' diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index 770ce0517a8..1c649b930d1 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -12,6 +12,7 @@ import type { AiVaultPrepareSessionResumeResult } from '../../shared/ai-vault-resume-preparation' import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types' +import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' import type { AgentSessionClaimSigner } from './agent-session-claim-identity' import type { OrchestrationEnvironmentTransport } from './orchestration/environment-transport' import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' @@ -34,7 +35,48 @@ import { createEphemeralAgentSessionClaimSigner } from './agent-session-claim-id import { registerConptyDa1OverrideInstaller } from './terminal-model-query-authority' import { registerTerminalViewAttributesApplier } from './terminal-view-attribute-store' +export type RuntimeNativeChatTranscriptBinding = { + worktreeId: string + connectionId: string | null + agent: string | null + providerSession: AgentProviderSessionMetadata | null +} + export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { + resolveNativeChatTranscriptBinding(handle: string): RuntimeNativeChatTranscriptBinding | null { + const terminalContext = this.resolveTerminalContext(handle) + const snapshot = terminalContext + ? this.mobileSessionTabsByWorktree.get(terminalContext.worktreeId) + : null + if (!terminalContext || !snapshot) { + return null + } + const terminal = this.toMobileSessionTabsResult(snapshot).tabs.find( + (tab) => tab.type === 'terminal' && tab.status === 'ready' && tab.terminal === handle + ) + if (!terminal || terminal.type !== 'terminal') { + return null + } + return { + ...terminalContext, + agent: terminal.agentStatus?.agentType ?? terminal.launchAgent ?? null, + providerSession: terminal.agentStatus?.providerSession ?? null + } + } + + notifySshRelayUnavailable(targetId: string): void { + this.bumpSshRelayRecoveryGeneration(targetId) + this.invalidateSshWorktreeScanCache(targetId) + const worktreeIds = new Set( + [...this.ptysById.values()] + .filter((pty) => pty.connectionId === targetId) + .map((pty) => pty.worktreeId) + ) + for (const worktreeId of worktreeIds) { + this.notifyMobileSessionTabsChangedNow(worktreeId, ++this.mobileSessionTabsChangeSequence) + } + } + constructor( store: RuntimeStore | null = null, stats?: StatsCollector, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 979c321b7e2..c62dbf1988f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1,44543 +1,59 @@ -/* eslint-disable max-lines -- Why: OrcaRuntimeService still owns the mutable live graph, PTY handles, waiters, mobile floor/layout state, and managed-worktree reconciliation. Stateless browser and file command adapters live beside it; the remaining split points need state-owner extraction before enforcing max-lines. */ -/* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */ -/* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */ -import { - detectAgentStatusFromTitle, - extractLastOscTitle, - isClaudeManagementTitle, - isCursorNativeAgentTitle, - isOpenCodeNativeTitle, - isQuarterCircleSpinnerOnlyAgentTitle, - isShellProcess, - normalizeTerminalTitle -} from '../../shared/agent-detection' -import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' -import { planWorktreeSortOrderUpdates } from '../../shared/worktree/sort-order-update' -import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate' -import { - assertAgentSkillSharingAllowed, - isAgentSkillSharingEnabled -} from '../../shared/agent-skill-sharing-gate' -import { resolveNestedWorkerMaxDepth } from '../../shared/nested-worker-depth' -import { - clampRepoSearchRefsLimit, - REPO_SEARCH_REFS_DEFAULT_LIMIT, - getRepoSearchRefsProbeLimit, - isRepoSearchRefsRequestLimit -} from '../../shared/repo-search-limits' -import { sortDirEntries } from '../../shared/file-name-sort' -import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing' -import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction' -import { parseFileUriPathParts } from '../daemon/osc7-file-uri' -import type { AgentStatus } from '../../shared/agent-detection' -import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' -import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' -import type { TerminalOutputSourceRange } from '../../shared/terminal-output-source-range' -import { detectTerminalComposerDraft } from '../../shared/terminal-composer-draft' -import type { - RemoteTerminalSourceRangeConsumerHooks, - RemoteTerminalSourceRangeReplacementPublication, - RemoteTerminalSourceRangeReplacementReservation, - RemoteTerminalSourceRangeStreamIdentity -} from './remote-terminal-source-range-consumer' -import { - createTerminalTitleTracker, - type TerminalTitleFactMeta, - type TerminalTitleTracker -} from '../../shared/terminal-output-side-effects' -import { getDecorativeAgentTitleSignature } from '../../shared/agent-decorative-title-signature' -import { createCommandCodeOutputStatusDetector } from '../../shared/command-code-output-status' -import type { - TerminalSideEffectBatch, - TerminalSideEffectFact -} from '../../shared/terminal-side-effect-facts' -import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' -import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker' -import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' -import { - AGENT_STATUS_STALE_AFTER_MS, - isFreshNonDoneAgentStatus, - pickParsedAgentStatusPayload, - type AgentStatusIpcPayload, - type ParsedAgentStatusPayload, - 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 { - AgentSessionClaimedSpawnResult, - AgentSessionExecutionClaim, - AgentSessionOwnerBinding, - AgentSessionSurfaceBinding, - AgentLaunchPreferences, - RuntimeAgentSessionRpcCaller, - RuntimeCreateAgentSessionRequest, - RuntimeCreateAgentSessionResult, - RuntimeEnsureAgentSessionRequest, - RuntimeEnsureAgentSessionResult -} from '../../shared/agent-session-host-authority' -import { - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, - AGENT_SESSION_OPERATION_FUTURE_SKEW_MS, - parseAgentSessionOperationTimestamp -} from '../../shared/agent-session-host-authority' -import { - canonicalizeAgentSessionIdentity, - createEphemeralAgentSessionClaimSigner, - type AgentSessionClaimSigner -} from './agent-session-claim-identity' -import { - ensureStructuredAgentSessionHost as installStructuredAgentSessionHost, - hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk -} from './structured-agent-session-runtime' -import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' -import { - StructuredTuiLaunchCleanupError, - type StructuredAgentSessionHandoffTransport, - type StructuredTuiOwner -} from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { - agentSessionProviderHandleRoot, - agentSessionProviderHandlesEqual -} from '../../shared/agent-session-provider-handle' -import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close' -import { - agentSessionOwnerBindingsEqual, - cloneAgentSessionOwnerBinding, - scopedAgentSessionClaimsEqual -} from '../../shared/claimed-agent-pty-owner-snapshot' -import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity' -import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity' -import { readCodexResumeProcessIdentity } from '../codex/codex-resume-process-proof' -import { - proveCodexTuiRollout, - resolvePinnedCodexRolloutProof -} from '../codex/codex-tui-rollout-proof' -import { - PROCESS_START_TIME_TOLERANCE_MS, - probeAgentSessionProcessIdentity -} from './agent-session-process-identity-probe' -import { waitForStructuredTuiExitProof } from './structured-tui-exit-proof' -import { readStructuredTuiProcessIdentity } from './structured-tui-process-identity' -import { - readClaudeTranscriptLeafUuid, - resolveSessionFilePath -} from '../native-chat/session-file-resolver' -import { ClaudeTranscriptTailIncompleteError } from '../claude/claude-transcript-branch-proof' -import { hasStructuredTuiIdleEvidence } from './structured-tui-idle-evidence' -import { evaluateStructuredTuiRecoveryClaim } from './structured-tui-recovery-claim-match' -import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' -import { getSystemCodexHomePath } from '../codex/codex-home-paths' -import { - agentSessionPtyWriteGate, - type AgentSessionPtyWriteAdmittance -} from './agent-session-pty-write-gate' -import { - hasCompatibleAgentTitleIdentity, - normalizeCompatibleAgentStatusEntryForOwner, - normalizeCompatibleAgentTitleForOwner, - resolveCompatibleAgentTypeForOwner -} from '../../shared/agent-title-owner' -import { resolvePaneAgentOwnerRecord } from '../../shared/pane-agent-owner' -import { - createAgentStatusOscProcessor, - type ProcessedAgentStatusChunk -} from '../../shared/agent-status-osc' -import { buildOrchestrationTaskDisplayMetadata } from '../../shared/orchestration-task-display' -import { - isTerminalInputTooLargeWithYield, - TERMINAL_INPUT_TOO_LARGE_ERROR, - iterateTerminalInputChunks -} from '../../shared/terminal-input' -import { - AGENT_PROMPT_SUBMIT, - buildAgentPromptPasteBytes, - getAgentPromptSubmitDelayMs, - getTerminalPasteIngestMs -} from '../../shared/agent-prompt-injection' -import { - type AgentPromptActivity, - type AgentPromptWaitTextCache, - readAgentPromptWaitText, - resolveAgentPromptEffectTimeoutMs, - verifyAgentPromptSubmission -} from './agent-prompt-submission-verification' -import { - awaitWindowsHostGitEnvironmentReady, - gitExecFileAsync, - gitSpawnAfterWindowsEnvironmentReady, - nonInteractiveGitEnv -} from '../git/runner' -import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' -import { runWithGitReadCacheInvalidation } from '../git/status' -import { wakeFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade-wake' -import { - cleanupClaimedCloneTarget, - claimCloneTarget, - deriveValidatedClonePath, - getClonePathComparisonKey -} from '../git/repo-clone-path' -import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message' -import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fetch-auto-maintenance' -import { createHash, randomUUID } from 'node:crypto' -import { homedir, hostname } from 'node:os' -import { dirname, isAbsolute, join, relative, resolve } from 'node:path' -import { readFileSync, statSync } from 'node:fs' -import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' -import { resolveWorktreeCreateBase } from '../worktree-create-base' -import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' -import { OrchestrationDb } from './orchestration/db' -import type { DispatchStatus } from './orchestration/types' -import { reconcileRequestedWorkerTerminalReleases } from './orchestration/worker-terminal-release-reconciliation' -import { - classifyWorkerTerminalProcessIncarnation, - parseWorkerTerminalHostScope, - type WorkerTerminalHostScope -} from './orchestration/worker-terminal-process-liveness' -import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' -import { OrchestrationError } from './orchestration/orchestration-error' -import { - planLegacyWorkerTerminalRecovery, - type LegacyWorkerTerminalRecoveryPlan -} from './orchestration/orchestration-legacy-worker-terminal-recovery' -import { - buildObservedSetupCommand, - createSetupCompletionScanner -} from './orchestration/setup-completion-signal' -import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' -import type { - ArtifactCloudOperation, - ArtifactCloudOptions, - ArtifactListOptions, - ArtifactListPage, - ArtifactListItem, - ArtifactPublishedLink, - ArtifactPublishResult, - ArtifactWriteRequest -} from '../../shared/artifacts' -import type { ArtifactCloudService } from '../artifacts/artifact-cloud-service' -import type { - SkillCloudDownloadGrant, - SkillCloudOperation, - SkillCloudOptions, - SkillCloudPackageDetails, - SkillCloudPublishRequest, - SkillCloudPublishResult, - SkillCloudVersion -} from '../../shared/skill-cloud-contract' -import type { SkillCloudService } from '../skills/skill-cloud-service' -import { - AGENT_SKILL_NOT_SHAREABLE_CODE, - AGENT_SKILL_SHARING_BUSY_CODE, - AgentSkillSharingError, - type AgentSkillShareOperation, - type AgentSkillShareRequest -} from '../../shared/agent-skill-sharing-contract' -import type { DiscoveredSkill } from '../../shared/skills' -import { selectDiscoveredSkills } from '../skills/agent-skill-selection' -import { SkillSharePreparationService } from '../skills/skill-share-preparation-service' -import type { - SkillInstallPreview, - SkillInstallPreviewRequest, - SkillInstallRequest, - SkillInstallResult, - ManagedSkillInstall, - SkillRemoveRequest -} from '../../shared/skill-install-contract' -import type { - SkillBundleInstallPreview, - SkillBundleInstallPreviewRequest, - SkillBundleInstallProgress, - SkillBundleInstallRequest, - SkillBundleInstallResult -} from '../../shared/skill-bundle-install-contract' -import { executeSkillInstallRequest } from '../skills/skill-install-request-service' -import { executeSkillBundleInstallRequest } from '../skills/skill-bundle-install-request-service' -import type { SkillInstallDestinationAuthority } from '../skills/skill-install-destinations' -import { - previewSharedSkillBundleInstall, - previewSharedSkillInstall, - removeSharedSkillInstall -} from '../skills/skill-install-management-service' -import { listManagedSkillInstalls } from '../skills/skill-install-provenance' -import { getWslHome, toLinuxPath } from '../wsl' -import { WslSkillInstallFilesystem } from '../skills/skill-wsl-install-filesystem' -import { nativeSkillInstallFilesystem } from '../skills/skill-install-filesystem' -import type { SkillProviderRootOverrides } from '../skills/skill-provider-destinations' -import { - resolveEnvironmentSkillProviderRoots, - resolveWslGrokSkillProviderRoot, - withClaudeSkillProviderRoot -} from '../skills/skill-provider-runtime-roots' -import type { - SkillUploadBeginRequest, - SkillUploadChunkRequest -} from '../../shared/skill-upload-session-contract' -import { SkillUploadSessionService } from '../skills/skill-upload-session-service' -import { SKILL_UPLOAD_STAGING_ROOT_NAME } from '../skills/skill-upload-staging-ownership' -import type { SkillSshWorkspaceAuthority } from '../../shared/skill-ssh-relay-contract' -import { - installSkillBundleOnSshHost, - previewSkillBundleInstallOnSshHost -} from '../skills/skill-bundle-ssh-relay-service' -import { - installSkillOnSshHost, - listSkillInstallsOnSshHost, - previewSkillInstallOnSshHost, - removeSkillInstallOnSshHost -} from '../skills/skill-ssh-relay-service' -import { ORCHESTRATION_MESSAGE_WAIT_DEFAULT_TIMEOUT_MS } from '../../shared/orchestration-message-wait-timeout' -import { shouldForwardHeadlessTerminalQueryReply } from './headless-terminal-query-reply-policy' -import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity' -import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' -import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' -import type { - OrchestrationCompatibilityEvidence, - OrchestrationCompatibilityHostStamp -} from '../../shared/orchestration-compatibility-evidence' -import { - isOrchestrationMutation, - orchestrationMigrationData -} from '../../shared/orchestration-rpc-contract' -import type { - OrchestrationEnvironmentTransport, - OrchestrationWorkerServer -} from './orchestration/environment-transport' -import { - clearFederationAckCheckpoints, - releaseFederationAckCheckpoint -} from './orchestration/federation-ack-checkpoints' -import { syncFederatedDispatch } from './orchestration/federation-sync' -import { MailPointerRepointScheduler } from './orchestration/mail-pointer-repoint-scheduler' -import { OrchestrationMailboxOwner } from './orchestration/mailbox-owner' -import { OrchestrationMailboxNotificationCoordinator } from './orchestration/mailbox-notification-coordinator' -import { OrchestrationMailboxDeliveryTarget } from './orchestration/mailbox-delivery-target' -import { - OrchestrationMailboxPointerDelivery, - type OrchestrationMessageWaiter -} from './orchestration/mailbox-pointer-delivery' -import { selectExactWorkerProviderSession } from './orchestration/worker-provider-session' -import type { - Automation, - AutomationCreateInput, - AutomationRun, - AutomationUpdateInput, - AutomationWorkspaceMode -} from '../../shared/automations-types' -import { - automationChangePublications, - type AutomationChangeSelector, - type AutomationListParams, - type AutomationListResult -} from '../../shared/automation-list-scope' -import type { - AutomationDestination, - AutomationOwnerFenceOperation, - AutomationOwnerPrecondition -} from '../../shared/automation-owner-precondition' -import type { DirEntry, FilesystemPathFlavor } from '../../shared/filesystem-entry-types' -import type { FolderWorkspace, WorkspaceKey } from '../../shared/folder-workspace-types' -import type { - GitHubPRReviewCommentInput, - GitHubReactionContent -} from '../../shared/github/comment-types' -import type { - GitHubPRRefreshReason, - PRRefreshOutcome -} from '../../shared/github/pull-request-refresh-types' -import { admissionTierForRefreshReason } from '../github/pr-refresh-candidate-policy' -import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-request-types' -import type { ListWorkItemsResult } from '../../shared/github/work-item-types' -import type { - GitLabIssueUpdate, - GitLabMRInlineCommentInput, - GitLabMRUpdate, - GitLabProjectRef, - GitLabWorkItem, - MRListState -} from '../../shared/gitlab-types' -import type { GlobalSettings } from '../../shared/global-settings-types' -import type { - GitHubCreateIssueFields, - GitHubIssueUpdate, - GitHubPullRequestStateUpdate, - LinearIssueUpdate -} from '../../shared/issue-mutation-types' -import type { - JiraConnectArgs, - JiraCreateIssueArgs, - JiraIssueFilter, - JiraIssueUpdate, - JiraSiteSelection -} from '../../shared/jira-types' -import type { LinearCustomViewModel, LinearProjectSummary } from '../../shared/linear/project-types' -import type { LinearWorkspaceSelection } from '../../shared/linear/workspace-types' -import type { - ClaudeRateLimitAccountsState, - CodexRateLimitAccountsState -} from '../../shared/managed-account-types' -import type { PersistedUIState } from '../../shared/persisted-ui-state-types' -import type { MemorySnapshot, StatsSummary } from '../../shared/process-stats-types' -import type { - NestedRepoScanResult, - ProjectGroup, - ProjectGroupImportMode, - ProjectGroupImportResult -} from '../../shared/project-group-types' -import type { - Project, - ProjectHostSetup, - ProjectHostSetupCloneArgs, - ProjectHostSetupCreateArgs, - ProjectHostSetupCreateResult, - ProjectHostSetupDeleteArgs, - ProjectHostSetupDeleteResult, - ProjectHostSetupExistingFolderArgs, - ProjectHostSetupResult, - ProjectHostSetupUpdateArgs, - ProjectHostSetupUpdateResult, - ProjectUpdateArgs -} from '../../shared/project-types' -import type { BaseRefSearchResult, Repo } from '../../shared/repo-types' -import type { Tab, TabGroupLayoutNode } from '../../shared/tab-types' -import type { TerminalQuickCommand } from '../../shared/terminal-quick-command-types' -import type { - TerminalLayoutSnapshot, - TerminalPaneLayoutNode, - TerminalTab -} from '../../shared/terminal-tab-types' -import { resolvePublishedPaneAgentIdentity } from '../../shared/published-pane-agent-identity' -import type { TuiAgent } from '../../shared/tui-agent' -import type { BranchPrefixStrategy } from '../../shared/ui-chrome-types' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../shared/workspace-source' -import type { - WorktreeBaseStatusEvent, - WorktreeRemoteBranchConflictEvent -} from '../../shared/worktree/base-ref-drift-types' -import type { - CreateWorktreeArgs, - CreateWorktreeResult, - ForceDeleteWorktreeBranchResult, - RemoveWorktreeResult -} from '../../shared/worktree/create-types' -import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' -import type { - WorkspaceLineage, - WorktreeLineage, - WorktreeLineageWarning -} from '../../shared/worktree/lineage-types' -import type { WorktreeMeta } from '../../shared/worktree/meta-types' -import type { - AutomationWorkspaceProvenance, - CliWorkspaceProvenance, - DetectedWorktree, - DetectedWorktreeListResult, - GitHubPrStartPoint, - GitPushTarget, - GitWorktreeInfo, - WorkspaceLinkedItem, - Worktree -} from '../../shared/worktree/types' -import type { TaskSourceContext } from '../../shared/task-source-context' -import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' -import { - LOCAL_EXECUTION_HOST_ID, - getRepoExecutionHostId, - getWorktreeExecutionHostId, - parseExecutionHostId, - toSshExecutionHostId, - type ExecutionHostId -} from '../../shared/execution-host' -import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' -import { getRegisteredSshState } from '../ssh/ssh-target-registry' -import type { - AgentProviderSessionMetadata, - SleepingAgentLaunchConfig -} from '../../shared/agent-session-resume' -import type { ExactWorkerProviderSession } from '../../shared/orchestration-worker-output' -import { applyBrowserSessionTabSelection } from './browser-session-tab-selection-snapshot' -import type { BrowserSessionTabSelectionOptions } from './browser-tab-create-publication' -import { - resolveBrowserDriverAfterMobileRelease, - screencastSubscriberDrivesAsMobile, - type BrowserScreencastSubscriber -} from './browser-screencast-driver-scope' -import type { - AutomationsChangedPayload, - RuntimeClientEvent -} from '../../shared/runtime-client-events' -import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' -import { - navigationTargetsClients, - navigationTargetsHost, - type RuntimeNavigationTarget -} from '../../shared/runtime-navigation' -import { - isAutomaticTabActivation, - type TabActivationIntent -} from '../../shared/tab-activation-intent' -import type { SshConnectionState } from '../../shared/ssh-types' -import { getPublicSshState } from './public-ssh-state' -import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' -import { - describeTerminalExitCause, - isDeliberateTerminalExit, - OPERATOR_CLOSE_EXIT_CAUSE, - resolveUnreportedExitCause, - type TerminalExitCause -} from '../../shared/terminal-exit-cause' -import type { - LinearCurrentIssueContextHints, - LinearAttachResult, - LinearCommentAddResult, - LinearCreateResult, - LinearErrorCode, - LinearIssueListFilter, - LinearIssueListResult, - LinearProjectListResult, - LinearIssueSummary, - LinearIssueRequest, - LinearIssueTaskUpdateRequest, - LinearIssueTaskUpdateResult, - LinearMcpIssueListRequest, - LinearMcpIssueListResult, - LinearIssueRelationWriteRequest, - LinearIssueRelationWriteResult, - LinearSaveIssueRequest, - LinearSaveIssueResult, - LinearTeamLabelsResult, - LinearTeamListResult, - LinearTeamMembersResult, - LinearTeamStatesResult, - LinearStatusSetResult -} from '../../shared/linear/agent-access' -import { runtimeTerminalDegradation } from './native-terminal-availability' -import { - BROWSER_UNAVAILABLE_ERROR_CODE, - browserUnavailableMessage, - HEADLESS_RUNTIME_WINDOW_ID, - type RuntimeDegradation, - type RuntimeDesktopWindowStatus, - type RuntimeGraphStatus, - type RuntimeRepoSearchRefs, - type RuntimeTerminalRead, - type RuntimeTerminalRename, - type RuntimeTerminalAgentStatus, - type RuntimeTerminalSend, - type RuntimeTerminalCreate, - type RuntimeTerminalPresentation, - type RuntimeTerminalSplit, - type RuntimeTerminalFocus, - type RuntimeTerminalClose, - type RuntimeTerminalListHostScope, - type RuntimeTerminalListResult, - type RuntimeTerminalOrphanAdoptionRequest, - type RuntimeTerminalOrphanAdoptionResult, - type RuntimeWorktreeTerminalSleepResult, - type RuntimeTerminalResolvePane, - type RuntimeTerminalState, - type RuntimeStatus, - type RuntimeSyncWindowGraphResult, - type RuntimeTerminalWait, - type RuntimeTerminalWaitBlockedReason, - type RuntimeTerminalWaitCondition, - type RuntimeWorktreePsSummary, - type RuntimeWorktreeAgentRow, - type RuntimeWorktreeStatus, - type RuntimeSpeechModelSummary, - type RuntimeSpeechSetupState, - type RuntimeTerminalInteractiveWait, - type RuntimeTerminalShow, - type RuntimeTerminalSummary, - type RuntimeTerminalVisualGroupNode, - type RuntimeTerminalVisualLayout, - type RuntimeTerminalVisualLayoutNode, - type RuntimeTerminalVisualPaneNode, - type RuntimeTerminalVisualTab, - type RuntimeSyncedLeaf, - type RuntimeSyncedTab, - type RuntimeMarkdownReadTabResult, - type RuntimeMarkdownSaveTabResult, - type RuntimeMobileSessionCreateTerminalResult, - type RuntimeMobileSessionAgentTab, - type RuntimeMobileSessionClientTab, - type RuntimeMobileSessionMarkdownTab, - type RuntimeMobileSessionRetiredTerminalSurface, - type RuntimeMobileSessionTabMove, - type RuntimeMobileSessionTabMoveResult, - type RuntimeMobileSessionTabGroup, - type RuntimeMobileSessionSnapshotTab, - type RuntimeMobileSessionTerminalClientTab, - type RuntimeMobileSessionTerminalTab, - type RuntimeMobileSessionBrowserTab, - type RuntimeMobileSessionTabsRemovedResult, - type RuntimeMobileSessionTabsResult, - type RuntimeMobileSessionTabsSnapshot, - type RuntimeNativeChatLaunchDraftResolution, - type RuntimeSessionTabCloseReason, - type RuntimeBrowserDriverState, - type RuntimeTerminalDriverState, - type RuntimeRendererSyncWindowGraph, - type RuntimeSyncWindowGraph, - type RuntimeWorktreeListResult, - type BrowserTabInfo, - type BrowserScreencastResult, - UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH -} from '../../shared/runtime-types' -import { - RUNTIME_GRAPH_RELOAD_TIMEOUT_MS, - RuntimeGraphReloadLifecycle -} from './runtime-graph-reload-lifecycle' -import { - LINEAR_SEARCH_MAX_LIMIT, - LINEAR_WRITE_BODY_CAP, - clampLinearSearchLimit -} from '../../shared/linear/agent-access' -import { isLinearUuid } from '../../shared/linear/uuid' -import type { FeatureInteractionId } from '../../shared/feature-interactions' -import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' -import { - FOLDER_WORKSPACE_INSTANCE_SEPARATOR, - WORKTREE_ID_SEPARATOR, - getRepoIdFromWorktreeId, - splitWorktreeId, - splitWorktreeIdForFilesystem, - worktreeIdComparisonKey, - worktreeIdsEqual -} from '../../shared/worktree/id' -import { getProjectIdForProviderIdentity } from '../../shared/project-host-setup-projection' -import { - getProjectHostSetupForRepo, - getProjectHostSetupWorktreeMeta -} from '../../shared/project-host-setup-lookup' -import { parsePtySessionId } from '../../shared/pty-session-id-format' -import { clampLinearIssueListLimit } from '../../shared/linear/issue-read-limits' -import { isFolderRepo } from '../../shared/repo-kind' -import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' -import { - buildSetupRunnerCommand, - getSetupRunnerCommandPlatformForPath -} from '../../shared/setup-runner-command' -import { - createSequencedSetupAgentCommands, - SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV -} from '../../shared/setup-agent-sequencing' -import { TASK_PROVIDERS } from '../../shared/task-providers' -import { FIRST_PANE_ID } from '../../shared/pane-key' -import { - isTerminalLeafId, - makePaneKey, - parseLegacyNumericPaneKey, - parsePaneKey -} from '../../shared/stable-pane-id' -import { parseAppSshPtyId } from '../../shared/ssh-pty-id' -import { getPtyExecutionHost } from '../../shared/terminal-execution-host' -import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../shared/terminal-tab-id' -import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' -import { - applyTerminalQuickCommandMutation, - MAX_QUICK_COMMANDS, - type TerminalQuickCommandMutation -} from '../../shared/terminal-quick-commands' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' -import { - buildAgentDraftLaunchPlan, - buildAgentResumeStartupPlan, - buildAgentStartupPlan -} from '../../shared/tui-agent-startup' -import { repoIsRemote } from '../../shared/agent-launch-remote' -import { - isAgentForegroundWrapperProcess, - isExpectedAgentProcess, - recognizeAgentProcess -} from '../../shared/agent-process-recognition' -import { - haveSameDisabledTuiAgents, - isTuiAgentEnabled, - pickTuiAgent -} from '../../shared/tui-agent-selection' -import { - resolveTuiAgentLaunchArgs, - resolveTuiAgentLaunchEnv -} from '../../shared/tui-agent-launch-defaults' -import { resolveCodexStructuredAppServerArgs } from '../codex/codex-structured-app-server-args' -import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' -import { - getTuiAgentLaunchCommand, - isTuiAgent, - TUI_AGENT_CONFIG -} from '../../shared/tui-agent-config' -import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout' -import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' -import { - detectInstalledAgentsWithShellPathHydration, - detectRemoteAgents -} from '../preflight/agent-detection' -import { - markCodexProjectTrusted, - markCopilotFolderTrusted, - markCursorWorkspaceTrusted -} from '../agent-trust-presets' -import { markRemoteAgentWorkspaceTrusted } from '../remote-agent-trust-presets' -import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' -import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry' -import { - isWindowsAbsolutePathLike, - isPathInsideOrEqual, - normalizeRuntimePathForComparison -} from '../../shared/cross-platform-path' -import { findRuntimeWorkspaceFileOwner } from '../../shared/runtime-workspace-file-owner' -import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' -import { isWslUncPath, parseWslUncPath } from '../../shared/wsl-paths' -import { - folderWorkspaceKey, - parseWorkspaceKey, - worktreeWorkspaceKey -} from '../../shared/workspace-scope' -import { - projectResolvedWorktreeLineage, - sharesResolvedWorktreeLineageBoundary -} from '../../shared/resolved-worktree-lineage' -import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree' -import type { - FolderWorkspacePathStatus, - FolderWorkspacePathStatusRequest -} from '../../shared/folder-workspace-path-status' -import { - applyMetadataFallbackVisibility, - buildKnownOrcaWorkspaceLayouts, - isLegacyRepoForExternalWorktreeVisibility, - toDetectedWorktree -} from '../../shared/worktree/ownership' -import { isAgentScratchRepoRootPath } from '../../shared/agent-scratch-worktrees' -import { - createWorktreeVisibilitySourceMatcher, - resolveCustomWorktreeVisibilitySources, - type WorktreeVisibilitySourceMatcher -} from '../../shared/worktree/visibility-sources' -import { resolveConfiguredWorktreeBasePaths } from '../../shared/worktree/configured-worktree-base-path' -import { - BROWSER_HEADLESS_RUNTIME_CAPABILITY, - BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY, - MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, - ORCHESTRATION_CONTRACT_VERSION, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, - RUNTIME_CAPABILITIES, - RUNTIME_PROTOCOL_VERSION, - SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, - TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY, - type RuntimeCapability -} from '../../shared/protocol-version' -import { - configureAiVaultSessionSources, - listAiVaultSessions -} from '../ai-vault/cached-session-list' -import { resolveMobileWebPackageRoot } from './rpc/mobile-web-package-root' -import { MobileWebManifestSchema } from '../../shared/mobile-web/manifest-contract' -import { configureHostReadableTranscriptPathSources } from '../native-chat/host-readable-transcript-path' -import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver' -import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' -import type { - AiVaultSessionTitleRequest, - AiVaultSessionTitlesResult -} from '../../shared/ai-vault-session-title' -import type { - AiVaultPrepareSessionResumeArgs, - AiVaultPrepareSessionResumeResult -} from '../../shared/ai-vault-resume-preparation' -import type { - WorkspacePortKillRequest, - WorkspacePortKillResult, - WorkspacePortProbe, - WorkspacePortScanResult -} from '../../shared/workspace-ports' -import { - filterWorkspacePortProbes, - killWorkspacePort, - scanWorkspacePortProbes -} from '../ports/workspace-port-ownership' -import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' -import type { AutomationService } from '../automations/service' -import { runAutomationNowFenced } from '../automations/refused-manual-run' -import type { RuntimeBrowserCommands } from './orca-runtime-browser' -import { - createRuntimeBrowserCommands, - runtimeBrowserCommandsFactoryIsHeadless, - runtimeBrowserUnavailableCause -} from './runtime-browser-commands-factory' -import { getBrowserHostLeaseRegistry } from './browser-host-lease-registry-instance' -import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' -import { ClientHostedBrowserRowPublisher } from './client-hosted-browser-row-publication' -import { - persistClientHostedBrowserPages, - rehydrateClientHostedBrowserPages -} from './client-hosted-browser-page-persistence' -import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' -import { closeClientHostedBrowserPagesForWorktree } from './worktree-browser-client-page-close' -import { - routeRuntimeBrowserClientAutomation, - type ClientHostedBrowserRpcRoute -} from './runtime-browser-client-automation' -import { resolveRuntimeBrowserNetworkExecutionHost } from './runtime-browser-network-execution-host' -import { ClientHostedPageReconciliationWindow } from './client-hosted-page-reconciliation-window' -import type { BrowserExecutionHostKeyResolution } from './runtime-browser-client-page-adoption' -import { browserNetworkExecutionHostKey } from '../browser/browser-network-execution-route' -import type { BrowserNetworkExecutionHost } from '../../shared/browser-client-host-protocol' -import { sameRuntimeBrowserPlacement } from '../../shared/runtime-browser-placement' -import { - WorktreeTerminalMutationLock, - type WorktreeTerminalMutationKind -} from './worktree-terminal-mutation-lock' -import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency' -import { deriveRemoteRuntimeTerminalCreateHandle } from './remote-runtime-terminal-create-identity' -import { - buildHeadlessTerminalSplitLayout, - countTerminalLayoutLeaves, - terminalLayoutContainsLeaf -} from './headless-terminal-split-layout' -import { RECENT_PTY_OUTPUT_LIMIT, RecentPtyOutputBuffer } from './recent-pty-output-buffer' -import { - buildHeadlessTabGroupMove, - buildHeadlessTabGroupSplit -} from './headless-tab-group-split-layout' -import { - hasExactTerminalOrphanGroupLayout, - mergeTerminalOrphanGroupLayout -} from './terminal-orphan-topology' -import { terminalOrphanExecutionOwnersEqual } from './terminal-orphan-owner' -import { - retireTerminalSurfacesFromSnapshot, - type RetiredTerminalSurface -} from './mobile-session-terminal-retirement' -import { appendRetiredTerminalSurfaceProofs } from './mobile-session-terminal-retirement-proof' -import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' -import { - NO_OBSERVING_PROVIDER_REASON, - SSH_EXIT_UNCONFIRMED_REASON, - SSH_PROVIDER_UNREGISTERED_REASON, - type PtyLivenessVerdict -} from '../../shared/pty-liveness-verdict' -import { - advanceTerminalTopologyRevision, - hasHostAuthoritativeTerminalMembership -} from './workspace-session-terminal-membership-authority' -import { RuntimeEmulatorCommands } from './orca-runtime-emulator' -import type { EmulatorBridge } from '../emulator/emulator-bridge' -import { getRuntimeFileTargetExecutionHostId, RuntimeFileCommands } from './orca-runtime-files' -import { RuntimeGitCommands } from './orca-runtime-git' -import { - activateClientSessionTabSelection, - ClientSessionTabSelectionStore, - deriveClientSessionTabSelection, - projectClientSessionTabSelection -} from './client-session-tab-selection' -import { - committedMobileSessionTabClose, - delegatedMobileSessionTabClose, - refusedMobileSessionTabClose, - type MobileSessionTabCloseOutcome -} from './mobile-session-tab-close-outcome' -import type { - PtyProviderBufferSnapshot, - IFilesystemProvider, - IPtyProvider, - PtyProcessInfo, - PtySpawnResult, - PtyTransientFact -} from '../providers/types' -import { ClaudeAgentTeamsService } from './claude-agent-teams-service' -import type { - AgentTeamsTmuxCompatRequest, - AgentTeamsTmuxCompatResponse -} from './claude-agent-teams-service' -import { - buildClaudeAgentTeamsLaunchPlan, - ensureClaudeAgentTeamsShimDir, - resolveClaudeAgentTeamsShimBin -} from './claude-agent-teams-shim-env' -import { - addClaudeTeammateModeAuto, - addClaudeTeammateModeInProcess, - type ClaudeAgentTeamsMode -} from '../../shared/claude-agent-teams-tmux-compat' -import { joinWorktreeRelativePath } from './runtime-relative-paths' -import { collectMemorySnapshot } from '../memory/collector' -import type { BrowserWindow } from 'electron' -import { getAppEnvironment } from '../../shared/app-environment' -import { getRuntimeDesktopSurface } from './runtime-desktop-surface' -import { RendererPublicationThrottle } from '../window/renderer-publication-throttle' -import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' -import type { BrowserBackend } from '../browser/browser-backend' -import { BrowserError } from '../browser/browser-error' -import { - getPRForBranch, - getPRForBranchOutcome, - getRepoSlug, - getRepoUpstream, - getWorkItem, - listIssues as listGitHubIssues, - listWorkItems, - countWorkItems, - getPRChecks, - getPRCheckDetails, - rerunPRChecks, - getPRComments, - setPRCommentReaction, - getIssue, - resolveReviewThread, - setPRFileViewed, - getWorkItemByOwnerRepo, - updatePRTitle, - updatePRDetails, - mergePR, - markPRReadyForReview, - setPRAutoMerge, - updatePRState, - requestPRReviewers, - removePRReviewers, - createIssue, - updateIssue, - addIssueComment, - addPRReviewComment, - addPRReviewCommentReply, - listLabels, - listAssignableUsers, - type MainWorkItem, - type GitHubPRBranchLookupOptions -} from '../github/client' -import { resolveGitHubPrStartPoint } from '../github/pr-start-point' -import { - fetchGitHubPullRequestHeadRef, - fetchPrHeadTrackingRef -} from '../github/pr-head-tracking-ref' -import { - gitlabMergeRequestHeadLocalRef, - reviewHeadRemoteRefComponent -} from '../../shared/review-head-tracking-ref' -import { fetchGitLabMergeRequestHeadRef } from '../gitlab/mr-head-tracking-ref' -import { isTransientReviewHeadFetchError } from '../git/fetch-error-classification' -import { resolveGitHubReviewHeadRemote } from '../github/review-head-remote' -import { fetchCompareBaseRefWithLocalFallback } from '../git/compare-base-ref-fetch' -import { pickPreferredGitRemote } from '../../shared/preferred-git-remote' -import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' -import { getRateLimit } from '../github/rate-limit' -import { - closeMR as closeGitLabMR, - createIssue as createGitLabIssue, - diagnoseAuth as diagnoseGitLabAuthClient, - getJobTrace as getGitLabJobTrace, - getProjectRefForRemote as getGitLabProjectRefForRemote, - getRateLimit as getGitLabRateLimit, - getWorkItemByProjectRef as getGitLabWorkItemByProjectRef, - addIssueComment as addGitLabIssueComment, - addMRInlineComment as addGitLabMRInlineComment, - addMRComment as addGitLabMRComment, - listTodos as listGitLabTodos, - listIssues as listGitLabIssues, - listLabels as listGitLabLabels, - listMergeRequests as listGitLabMergeRequests, - listWorkItems as listGitLabWorkItems, - mergeMR as mergeGitLabMR, - reopenMR as reopenGitLabMR, - resolveMRDiscussion as resolveGitLabMRDiscussion, - retryJob as retryGitLabJob, - updateMR as updateGitLabMR, - updateMRReviewers as updateGitLabMRReviewers, - updateIssue as updateGitLabIssue -} from '../gitlab/client' -import { getGlabKnownHosts } from '../gitlab/gl-utils' -import { getWorkItemDetails as getGitLabWorkItemDetails } from '../gitlab/work-item-details' -import { - normalizeGitLabIssueListArgs, - normalizeGitLabMRListState, - normalizeGitLabPositiveInteger, - type GitLabIssueListState -} from '../gitlab/gitlab-preload-args' -import { recordGitLabProjectRecent } from '../gitlab/gitlab-project-recents' -import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' -import type { - CreateHostedReviewInput, - CreateHostedReviewResult, - CreateStackedHostedReviewInput, - CreateStackedHostedReviewResult, - HostedReviewCreationEligibility, - HostedReviewCreationEligibilityArgs, - HostedReviewInfo -} from '../../shared/hosted-review' -import type { - HostedReviewSubmissionInput, - HostedReviewSubmissionResult -} from '../../shared/hosted-review-submission' -import { getHostedReviewForBranch as getHostedReviewForBranchFromRepo } from '../source-control/hosted-review' -import { - createHostedReview as createHostedReviewFromRepo, - getHostedReviewCreationEligibility as getHostedReviewCreationEligibilityFromRepo -} from '../source-control/hosted-review-creation' -import { createStackedHostedReview as createStackedHostedReviewFromRepo } from '../source-control/stacked-hosted-review-creation' -import { submitHostedReview as submitHostedReviewFromRepo } from '../source-control/hosted-review-submission' -import { - getLocalProjectGitExecOptions, - getLocalProjectWorktreeGitOptions, - getWorktreeMirrorDistro, - getLocalProjectWorktreeGitOptionsForRuntime, - getWorktreeCreatePrefetchGitOptions, - resolveLocalProjectRuntimeForRepo, - resolveLocalProjectRuntimesForRepos -} from '../project-runtime-git-options' -import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' -import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' -import { resolveTerminalOrchestrationCliCommand } from './orchestration/cli-command' -import { - scanLocalRepoWorktreesForResolution, - type RuntimeWorktreeScanResult -} from './repo-worktree-resolution-scan' -import { readRepoWorktreeAdminFingerprint } from './repo-worktree-admin-fingerprint' -import { - listStoredWorktreeRowsForRepo, - resolveRepoWorktreeRows, - resolveScopedWorktreeIdRow, - RESOLVED_WORKTREE_REPO_TIMEOUT_MS, - type RepoWorktreeRowDeps -} from './repo-worktree-row-resolution' -import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership' -import { readWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta' -import { withTimeout } from '../../shared/promise-timeout-fallback' -import { - getLocalWorktreePathAccess, - removeLocalWorktreePath, - toLocalWorktreeRuntimePath -} from '../local-worktree-filesystem' -import { - removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval, - recoverLocalWindowsWorktreeRemoval -} from '../local-worktree-removal-recovery' -import { - connect as connectLinear, - disconnect as disconnectLinear, - getStatus as getLinearStatus, - isAuthError as isLinearAuthError, - selectWorkspace as selectLinearWorkspace, - testConnection as testLinearConnection -} from '../linear/client' -import { - getAttachmentByUuidForAgent as getLinearAttachmentByUuidForAgent, - getCommentByUuidForAgent as getLinearCommentByUuidForAgent, - getIssue as getLinearIssue, - getIssueByUuidForAgent as getLinearIssueByUuidForAgent, - getIssueCommentThreadRoot as getLinearIssueCommentThreadRoot, - searchIssues as searchLinearIssues -} from '../linear/linear-issue-lookups' -import { - listIssues as listLinearIssues, - type LinearListFilter -} from '../linear/linear-issue-listing' -import { - createIssueForAgent as createLinearIssueForAgent, - createIssue as createLinearIssue, - updateIssueForAgent as updateLinearIssueForAgent, - updateIssue as updateLinearIssue -} from '../linear/linear-issue-mutations' -import { - addIssueComment as addLinearIssueComment, - addIssueCommentForAgent as addLinearIssueCommentForAgent, - createIssueAttachment as createLinearIssueAttachment, - getIssueComments as getLinearIssueComments -} from '../linear/linear-issue-comments' -import { LinearWriteFailure } from '../linear/linear-issue-write-support' -import type { LinearIssueListOptions } from '../linear/linear-issue-query-documents' -import { - LinearAgentAccessError, - getLinearCurrentIssueFromWorktree, - readLinearIssueContext, - resolveLegacyLinearLinkWorkspace, - searchLinearIssuesForAgents -} from '../linear/issue-context' -import { - classifyLinearError, - linearError, - linearMessage, - sanitizeLinearErrorMessage -} from '../linear/issue-context-errors' -import { listMcpIssues } from '../linear/mcp-issue-list' -import { linearPriorityLabel } from '../../shared/linear/priority-label' -import { writeIssueRelation } from '../linear/issue-relation-write' -import { - createProject as createLinearProject, - getCustomView as getLinearCustomView, - getProject as getLinearProject, - listCustomViewIssues as listLinearCustomViewIssues, - listCustomViewProjects as listLinearCustomViewProjects, - listCustomViews as listLinearCustomViews, - listProjectsByExactName as listLinearProjectsByExactName, - listProjectIssues as listLinearProjectIssues, - listProjectTeams as listLinearProjectTeams, - listProjects as listLinearProjects, - type LinearProjectCreateInput -} from '../linear/projects' -import { - getTeamLabels as getLinearTeamLabels, - getTeamLabelsOrThrow as getLinearTeamLabelsOrThrow, - getTeamMembers as getLinearTeamMembers, - getTeamMembersOrThrow as getLinearTeamMembersOrThrow, - getTeamStates as getLinearTeamStates, - getTeamStatesOrThrow as getLinearTeamStatesOrThrow, - getViewerForWorkspaceOrThrow as getLinearViewerForWorkspaceOrThrow, - listTeamsForAgent as listLinearTeamsForAgent, - listTeams as listLinearTeams, - listTeamsOrThrow as listLinearTeamsOrThrow -} from '../linear/teams' -import { - connect as connectJira, - disconnect as disconnectJira, - getStatus as getJiraStatus, - selectSite as selectJiraSite, - testConnection as testJiraConnection -} from '../jira/client' -import { - addIssueComment as addJiraIssueComment, - createIssue as createJiraIssue, - getIssue as getJiraIssue, - getIssueSummary as getJiraIssueSummary, - getIssueComments as getJiraIssueComments, - getProjectStatusOrder as getJiraProjectStatusOrder, - listAssignableUsers as listJiraAssignableUsers, - listCreateFields as listJiraCreateFields, - listIssueTypes as listJiraIssueTypes, - listIssues as listJiraIssues, - listPriorities as listJiraPriorities, - listProjects as listJiraProjects, - listTransitions as listJiraTransitions, - searchIssues as searchJiraIssues, - searchUsers as searchJiraUsers, - updateIssue as updateJiraIssue -} from '../jira/issues' -import { - clearProjectItemFieldValue, - getProjectViewTable, - getWorkItemDetailsBySlug, - listAccessibleProjects, - listProjectViews, - resolveProjectRef, - addIssueCommentBySlug, - deleteIssueCommentBySlug, - listAssignableUsersBySlug, - listIssueTypesBySlug, - listLabelsBySlug, - updateIssueCommentBySlug, - updateIssueBySlug, - updateIssueTypeBySlug, - updateProjectItemFieldValue, - updatePullRequestBySlug -} from '../github/project-view' -import type { - ClearProjectItemFieldArgs, - GetProjectViewTableArgs, - ListAccessibleProjectsArgs, - ListAssignableUsersBySlugArgs, - ListIssueTypesBySlugArgs, - ListLabelsBySlugArgs, - ListProjectViewsArgs, - ProjectWorkItemDetailsBySlugArgs, - ResolveProjectRefArgs, - AddIssueCommentBySlugArgs, - DeleteIssueCommentBySlugArgs, - UpdateIssueBySlugArgs, - UpdateIssueCommentBySlugArgs, - UpdateIssueTypeBySlugArgs, - UpdateProjectItemFieldArgs, - UpdatePullRequestBySlugArgs -} from '../../shared/github/project-request-types' -import { - getBaseRefDefault, - getDefaultRemote, - getBranchConflictKind, - isGitRepo, - getRepoName, - searchBaseRefDetails, - getRemoteCount, - normalizeRefSearchQuery, - parseAndFilterSearchRefDetails, - parseRemoteCount, - resolveDefaultBaseRefViaExec, - resolveDefaultBaseRefWithLocalGit, - buildSearchBaseRefsArgv, - isForEachRefExcludeUnsupportedError, - mergeBaseRefSearchResultGroups, - getRemoteDrift, - getRecentDriftSubjects -} from '../git/repo' -import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' -import { hasWorktreeBaseCommitRef } from '../git/worktree-base-ref-probe' -import { resolveLocalGitUsername } from '../git/git-username' -import { getSshGitCapabilityCache } from '../git/git-capability-state' -import { - listWorktrees, - listWorktreesStrict, - addWorktree, - addSparseWorktree, - assertWorktreeCleanForRemoval, - forceDeleteLocalBranch, - removeWorktree -} from '../git/worktree' -import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree' -import { isENOENT } from '../ipc/filesystem-path-containment' -import { invalidateAuthorizedRootsCache } from '../ipc/registered-worktree-roots-cache' -import { - getEffectiveHooks, - hasUnrecognizedOrcaYamlKeys, - hasHooksFile, - loadHooks, - parseOrcaYaml, - runHook -} from '../hooks' -import { createSetupRunnerScript, resolveSetupRunnerShell } from '../worktree-runner-script' -import { - getDefaultTabCommandTrustContent, - getDefaultTabsLaunch, - getEffectiveSetupRunPolicy, - shouldRunSetupForCreate -} from '../effective-hook-config' -import { readIssueCommand, writeIssueCommand } from '../issue-command-file' -import { - DEFAULT_REPO_BADGE_COLOR, - FLOATING_TERMINAL_WORKTREE_ID, - getDefaultVoiceSettings -} from '../../shared/constants' -import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning' -import { - createWorktreeCopiedPaths, - createWorktreeLinkedPaths, - createWorktreeSharedPaths, - findExistingWorktreeSymlinkPaths, - removeWorktreeLinkedPaths -} from '../ipc/worktree-symlinks' -import { formatWorktreeIncludeCopyWarning } from '../ipc/worktree-include-copy-budget' -import { resolveWorktreeIncludePaths } from '../git/worktree-include-file' -import { - getWorktreeSharedLinkPaths, - resolveWorktreeSharedDirectories -} from '../git/worktree-shared-directories' -import { deleteWorktreeHistoryDir } from '../terminal-history-deletion' -import { deleteRemoteWorktreeHistory } from '../remote-worktree-history-cleanup' -import { - cleanupUnusedWorktreePushTargetRemote, - cleanupUnusedWorktreePushTargetRemoteSsh, - createRemoteWorktree, - configureCreatedWorktreePushTarget, - prepareWorktreePushTarget -} from '../ipc/worktree-remote' -import { - getBranchNameOverrideCandidate, - getGeneratedWorktreeCreateCandidate, - getWorktreeCreateCandidate, - isGeneratedWorktreeCreateName, - WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS -} from '../worktree-create-candidates' -import { - failedWorktreeCreationNeedsRetirement, - getRetiredNameRegistryForRepo, - retireGeneratedWorktreeName -} from '../worktree-name-retirement' -import { - createRetiredNameLookup, - type RetiredNameRegistry -} from '../../shared/worktree/retired-name-registry' -import { normalizeSparseDirectories } from '../ipc/sparse-checkout-directories' -import type { PtyBindingSourceExpectation, Store } from '../persistence' -import { collectLayoutLeafIdsInOrder } from '../persistence/restoring-sessions/terminal-layout-normalization' -import type { StatsCollector } from '../stats/collector' -import { - computeValidatedBranchName, - computeWorktreePath, - computeWorkspaceRoot, - ensurePathWithinWorkspace, - formatWorktreeRemovalError, - getWorktreeCreationLayout, - getWorktreePathSettings, - isOrphanCompatiblePreflightError, - isOrphanedWorktreeError, - mergeWorktree, - sanitizeWorktreeName, - resolveWorktreeCreateDisplayNameRequest, - resolveWorktreeCreateDisplayNameMeta, - areWorktreePathsEqual -} from '../ipc/worktree-logic' -import { resolveCreatedWorktree } from '../ipc/created-worktree-reconciliation' -import { worktreePathComparisonKey } from '../ipc/worktree-path-comparison' -import { - assertWorktreeDoesNotContainRegisteredWorktree, - canCleanupUnregisteredOrcaLeftoverDirectory, - canCleanupUnregisteredOrcaWorktreeDirectory, - canSafelyRemoveOrphanedWorktreeDirectory, - findRegisteredDeletableWorktree, - isDangerousWorktreeRemovalPath, - isWorktreePathMissing, - ORPHANED_WORKTREE_DIRECTORY_MESSAGE, - stripOrcaProvenanceMetaUpdates, - UNREGISTERED_MISSING_WORKTREE_MESSAGE -} from '../worktree-removal-safety' -import { - hasWorktreeRemovalRepoOwnerOnOtherHost, - resolveWorktreeRemovalMetadata, - resolveWorktreeRemovalRepoOwner -} from '../worktree-removal-repo-owner' -import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' -import { - consumePreparedWorktreeCreate, - prepareWorktreeCreateForRepo -} from '../worktree-create-preparation' -import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' -import { getWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal' -import { acquireWatcherRemovalGate } from '../ipc/watcher-removal-gate' -import { - createWatcherRemovalDeadline, - drainBeforeWatcherRemoval, - type WatcherRemovalDeadline -} from '../ipc/watcher-removal-drain' -import { withWorktreeSpan } from '../observability/instrumentation' -import { HeadlessEmulator } from '../daemon/headless-emulator' -import { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror' -import { - isNativeWindowsConptyPty, - registerConptyDa1OverrideInstaller, - shouldModelAnswerHiddenPtyQueries -} from './terminal-model-query-authority' -import { - getTerminalViewAttributes, - getTerminalViewColorQueryReplyColors, - registerTerminalViewAttributesApplier -} from './terminal-view-attribute-store' -import { killAllProcessesForWorktree, teardownRpcDeadline } from './worktree-teardown' -import { stopMissingWorktreeTerminals } from './missing-worktree-terminal-reconciliation' -import { - MobileNotificationReplayBuffer, - type ReplayableMobileNotification -} from './mobile-notification-replay' -import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' -import { - createMobileSessionTabsNotifyCoalescer, - type MobileSessionTabsNotifyCoalescer -} from './mobile-session-tabs-notify-coalescer' -import { - createMobileSessionTabsAgentStatusHeartbeat, - type MobileSessionTabsAgentStatusHeartbeat -} from './mobile-session-tabs-agent-status-heartbeat' -import { TerminalFocusNavigationCoalescer } from './terminal-focus-navigation-coalescer' -import { - appendRecentPtyPathCandidates, - recentTerminalOutputIncludesPath, - recentTerminalPathCandidatesIncludePath -} from './terminal-output-path-candidates' -import { nativeChatTranscriptIncludesPath } from '../native-chat/native-chat-file-provenance' -import { - getSelectedReviewBranch, - getSelectedReviewLookupHints, - isAllowedPushTargetRemoteConflict, - isMatchingSelectedGitHubPr, - type SelectedReviewBranchInput -} from './selected-review-branch' -import { - getRuntimeFolderWorkspaceInstanceId, - getRuntimeFolderWorkspaceRootId, - isRuntimeFolderWorkspaceIdForRepo, - mergeRuntimeFolderWorkspace -} from './runtime-folder-workspace' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { - assertFolderWorkspacePathUsable, - getFolderWorkspacePathStatus, - getFolderWorkspacePathStatusForPath, - inferFolderWorkspacePathConnection -} from '../project-groups/folder-workspace-path-status' -import { - getSshGitProvider, - getSshGitProviderGeneration, - requireSshGitProvider -} from '../providers/ssh-git-dispatch' -import { detectGitHubAvatarIcon, detectRepoIconAndUpstream } from '../repo-icon-autodetect' -import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment' -import type { ClaudeAccountService } from '../claude-accounts/service' -import type { - CodexAccountService, - CodexResetCreditRejectedBeforeProviderReason -} from '../codex-accounts/service' -import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' -import type { RateLimitService } from '../rate-limits/service' -import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides' -import type { CodexRateLimitResetOutcome, RateLimitState } from '../../shared/rate-limit-types' -import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' -import type { VoiceSettings } from '../../shared/speech-types' -import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service' -import { getCatalogModel, isLocalSpeechModel, SPEECH_MODEL_CATALOG } from '../speech/model-catalog' -import { - deleteLocalSpeechModel, - getSpeechModelDeletionErrorCode -} from '../speech/speech-model-deletion' -import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' -import { scanNestedRepos } from '../project-groups/nested-repo-discovery' -import { - createNestedProjectGroupResolver, - resolveNestedRepoSelection -} from '../project-groups/nested-repo-import' -import { createNestedRepoImportTargetResolver } from '../project-groups/nested-repo-import-target' - -function sanitizeNestedRepoRuntimeImportError(context: string, error: unknown): string { - console.warn(`[project-groups] ${context}`, error) - return 'Repository could not be imported' -} - -function isPathWithinDirectory(directory: string, candidate: string): boolean { - const relativePath = relative(resolve(directory), resolve(candidate)) - return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) -} - -type RuntimeAccountServices = { - claudeAccounts: ClaudeAccountService - codexAccounts: CodexAccountService - rateLimits: RateLimitService -} - -export type RemoteFetchResult = { ok: true } | { ok: false; errorKind: 'git_error' } - -export type RemoteTrackingBase = { - remote: string - branch: string - ref: string - base: string -} - -export type AccountsSnapshot = { - claude: ClaudeRateLimitAccountsState - codex: CodexRateLimitAccountsState - rateLimits: RateLimitState -} - -export type CodexRateLimitResetRpcResult = { - scope: CodexResetCreditExpectedScope - snapshot: AccountsSnapshot -} & ( - | { outcome: CodexRateLimitResetOutcome } - | { - status: 'rejectedBeforeProvider' - retryDisposition: 'discardAttempt' - reason: CodexResetCreditRejectedBeforeProviderReason - } -) - -type RuntimeStore = { - getRepos: Store['getRepos'] - getRepo: Store['getRepo'] - addRetiredWorktreeName: Store['addRetiredWorktreeName'] - getRetiredWorktreeNameRegistry: Store['getRetiredWorktreeNameRegistry'] - mergeRetiredWorktreeNames: Store['mergeRetiredWorktreeNames'] - addRepo: Store['addRepo'] - updateRepo: Store['updateRepo'] - getProjects?: Store['getProjects'] - updateProject?: Store['updateProject'] - getProjectHostSetups?: Store['getProjectHostSetups'] - createProjectHostSetup?: Store['createProjectHostSetup'] - updateProjectHostSetup?: Store['updateProjectHostSetup'] - deleteProjectHostSetup?: Store['deleteProjectHostSetup'] - getProjectGroups?: Store['getProjectGroups'] - createProjectGroup?: Store['createProjectGroup'] - updateProjectGroup?: Store['updateProjectGroup'] - deleteProjectGroup?: Store['deleteProjectGroup'] - moveProjectToGroup?: Store['moveProjectToGroup'] - getFolderWorkspaces?: Store['getFolderWorkspaces'] - createFolderWorkspace?: Store['createFolderWorkspace'] - updateFolderWorkspace?: Store['updateFolderWorkspace'] - removeFolderWorkspace?: Store['removeFolderWorkspace'] - removeProject?: Store['removeProject'] - removeProjectForHost?: Store['removeProjectForHost'] - reorderRepos?: Store['reorderRepos'] - getAllWorktreeMeta: Store['getAllWorktreeMeta'] - getWorktreeMeta: Store['getWorktreeMeta'] - setWorktreeMeta: Store['setWorktreeMeta'] - setWorktreeMetaForHost?: Store['setWorktreeMetaForHost'] - removeWorktreeMeta: Store['removeWorktreeMeta'] - getWorktreeLineage?: Store['getWorktreeLineage'] - getAllWorktreeLineage?: Store['getAllWorktreeLineage'] - setWorktreeLineage?: Store['setWorktreeLineage'] - removeWorktreeLineage?: Store['removeWorktreeLineage'] - getAllWorkspaceLineage?: Store['getAllWorkspaceLineage'] - setWorkspaceLineage?: Store['setWorkspaceLineage'] - removeWorkspaceLineage?: Store['removeWorkspaceLineage'] - getGitHubCache: Store['getGitHubCache'] - getWorkspaceSession?: Store['getWorkspaceSession'] - getWorkspaceSessionHostIds?: Store['getWorkspaceSessionHostIds'] - setWorkspaceSession?: Store['setWorkspaceSession'] - flushOrThrow?: Store['flushOrThrow'] - flushPendingOrThrowAsync?: Store['flushPendingOrThrowAsync'] - persistPtyBinding?: Store['persistPtyBinding'] - getSshRemotePtyLeases?: Store['getSshRemotePtyLeases'] - getUI?: Store['getUI'] - updateUI?: Store['updateUI'] - recordFeatureInteraction?: Store['recordFeatureInteraction'] - listAutomations?: Store['listAutomations'] - listAutomationsForScope?: Store['listAutomationsForScope'] - assertAutomationOwnerFence?: Store['assertAutomationOwnerFence'] - automationOwnerPrecondition?: Store['automationOwnerPrecondition'] - automationChangeSelector?: Store['automationChangeSelector'] - listAutomationRuns?: Store['listAutomationRuns'] - createAutomation?: Store['createAutomation'] - updateAutomation?: Store['updateAutomation'] - deleteAutomation?: Store['deleteAutomation'] - getSparsePresets?: Store['getSparsePresets'] - saveSparsePreset?: Store['saveSparsePreset'] - getMobileClientTabSelections?: Store['getMobileClientTabSelections'] - setMobileClientTabSelections?: Store['setMobileClientTabSelections'] - getSettings(): { - workspaceDir: string - nestWorkspaces: boolean - // Read by worktree placement: decides whether this project's worktrees - // mirror into a WSL distro instead of the Windows drive. - localWindowsRuntimeDefault?: GlobalSettings['localWindowsRuntimeDefault'] - refreshLocalBaseRefOnWorktreeCreate: boolean - localBaseRefSuggestionDismissed?: boolean - branchPrefix: string - branchPrefixCustom: string - worktreeVisibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults'] - defaultTuiAgent?: GlobalSettings['defaultTuiAgent'] - disabledTuiAgents?: GlobalSettings['disabledTuiAgents'] - agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] - agentDefaultArgs?: GlobalSettings['agentDefaultArgs'] - agentDefaultEnv?: GlobalSettings['agentDefaultEnv'] - terminalWindowsShell?: GlobalSettings['terminalWindowsShell'] - floatingTerminalEnabled?: GlobalSettings['floatingTerminalEnabled'] - agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled'] - defaultTaskSource?: GlobalSettings['defaultTaskSource'] - defaultTaskViewPreset?: GlobalSettings['defaultTaskViewPreset'] - visibleTaskProviders?: GlobalSettings['visibleTaskProviders'] - defaultRepoSelection?: GlobalSettings['defaultRepoSelection'] - defaultLinearTeamSelection?: GlobalSettings['defaultLinearTeamSelection'] - githubProjects?: GlobalSettings['githubProjects'] - experimentalNewWorktreeCardStyle?: GlobalSettings['experimentalNewWorktreeCardStyle'] - compactWorktreeCards?: GlobalSettings['compactWorktreeCards'] - minimaxGroupId?: GlobalSettings['minimaxGroupId'] - minimaxUsageModels?: GlobalSettings['minimaxUsageModels'] - prBotAuthorOverrides?: GlobalSettings['prBotAuthorOverrides'] - artifactSharingEnabled?: GlobalSettings['artifactSharingEnabled'] - agentSkillSharingEnabled?: GlobalSettings['agentSkillSharingEnabled'] - nestedWorkerMaxDepth?: GlobalSettings['nestedWorkerMaxDepth'] - terminalQuickCommands?: GlobalSettings['terminalQuickCommands'] - gitlabProjects?: GlobalSettings['gitlabProjects'] - mobileAutoRestoreFitMs?: number | null - mobileEmulatorEnabled?: boolean - mobileEmulatorDefaultDeviceUdid?: string | null - voice?: VoiceSettings - claudeAgentTeamsMode?: GlobalSettings['claudeAgentTeamsMode'] - // Why: Phase-5 query responder kill switches — read per chunk in - // onPtyData to capture reply ownership at ingestion. - terminalMainSideEffectAuthority?: GlobalSettings['terminalMainSideEffectAuthority'] - terminalHiddenDeliveryGate?: GlobalSettings['terminalHiddenDeliveryGate'] - terminalModelQueryAuthority?: GlobalSettings['terminalModelQueryAuthority'] - } - // Why: narrow to `unknown` return so test mocks can return void without - // a cast. The runtime never reads the return value — the persisted value - // is read back via getSettings() on the next access. - updateSettings?: ( - updates: Partial, - options?: { notifyListeners?: boolean; originWebContentsId?: number } - ) => unknown -} - -export type RuntimeAutomationCreateInput = Omit< - AutomationCreateInput, - 'projectId' | 'workspaceId' | 'workspaceMode' | 'timezone' -> & { - repo?: string - workspace?: string - workspaceMode?: AutomationWorkspaceMode - timezone?: string - destination?: AutomationDestination -} - -export type RuntimeAutomationUpdateInput = Omit< - AutomationUpdateInput, - 'projectId' | 'workspaceId' -> & { - repo?: string - workspace?: string -} - -function assertAutomationRunContextMatchesRepo( - runContext: AutomationCreateInput['runContext'], - repo: Repo | null -): void { - if (!runContext || !repo) { - return - } - if (runContext.repoId !== repo.id || runContext.path !== repo.path) { - throw new Error('Automation project does not match its run context.') - } -} - -function normalizeSparsePresetName(name: string): string { - const trimmed = name.trim() - if (!trimmed) { - throw new Error('Preset name is required.') - } - if (trimmed.length > 80) { - throw new Error('Preset name is too long.') - } - return trimmed -} - -function normalizeSparsePresetDirectoriesForSave(directories: string[]): string[] { - let normalized: string[] - try { - normalized = normalizeSparseDirectories(directories) - } catch (err) { - if ( - err instanceof Error && - err.message === 'Sparse checkout directories must be repo-relative paths.' - ) { - throw new Error('Preset directories must be repo-relative paths.') - } - throw err - } - if (normalized.length === 0) { - throw new Error('Preset must have at least one directory.') - } - return normalized -} - -function hasRuntimeAutomationUpdateValue( - updates: RuntimeAutomationUpdateInput, - key: K -): boolean { - return Object.hasOwn(updates, key) && updates[key] !== undefined -} - -/** The runtime indexes graph tabs by bare id, so duplicate ids cannot be routed safely. */ -function assertUniqueRuntimeGraphTabIds(tabs: readonly RuntimeSyncedTab[]): void { - const seen = new Set() - for (const tab of tabs) { - if (seen.has(tab.tabId)) { - throw new Error('duplicate_runtime_tab_id') - } - seen.add(tab.tabId) - } -} - -type RuntimeLeafRecord = RuntimeSyncedLeaf & { - ptyGeneration: number - connected: boolean - writable: boolean - lastOutputAt: number | null - lastExitCode: number | null - lastExitCause: TerminalExitCause | null - tailBuffer: string[] - tailTranscriptBuffer: string[] - tailTranscriptChars: number - tailPartialLine: string - tailPendingAnsi: string - tailRedrawCursor: RetainedTailRedrawCursor | null - tailTruncated: boolean - tailLinesTotal: number - preview: string - waitBlockedAt: number | null - // Why: memoized wait scan of the current retained tail so the next PTY chunk - // reuses it as its "previous" state instead of rebuilding + rescanning the - // full tail. See computeTerminalTailWaitState. - tailWaitState?: TerminalTailWaitState - lastAgentStatus: AgentStatus | null - // Why: seeded status is a historical title replayed on restore, so it cannot - // authorize a PTY write. Only a live OSC observation sets this true; push - // delivery reads it so a cold-restored `idle` never types into a working agent. - lastAgentStatusObservedLive: boolean - // Why: the most recent OSC title observed on this leaf's PTY data. Used by - // worktree.ps so daemon-hosted terminals (no renderer pushing pane titles) - // still recompute working/idle from the live title each call instead of - // serving a stale `lastAgentStatus` after the agent process exits and the - // shell takes over the title — the bug behind issue #1437. - lastOscTitle: string | null - lastOscTitleAt: number | null - paneTitleUpdatedAt: number | null -} - -type RuntimePtyWorktreeRecord = { - ptyId: string - incarnationId: PtyIncarnationId | null - worktreeId: string - connectionId: string | null - runtimeSessionOwned: boolean - // Why: a Windows host can own both native and WSL panes; preamble command - // selection must follow the pane that executes it, not process.platform. - isWsl: boolean | null - wslDistro: string | null - // Why: background CLI PTYs can outlive a failed renderer reveal. Preserve the - // spawn-time tab/pane identity so later reveals can adopt under the env key. - tabId: string | null - paneKey: string | null - launchConfig: SleepingAgentLaunchConfig | null - launchToken: string | null - // Why: provider PTY IDs can be reused; launch identity belongs only to the process that received the token. - launchIncarnationId: PtyIncarnationId | null - launchAgent: TuiAgent | null - agentSessionOwners: AgentSessionOwnerBinding[] - foregroundAgent: TuiAgent | null - connected: boolean - disconnectedAt: number | null - lastExitCode: number | null - lastExitCause: TerminalExitCause | null - lastAgentStatus: AgentStatus | null - /** False until a live OSC frame sets the status; restore seeds never set it. */ - lastAgentStatusObservedLive: boolean - lastAgentStatusStartedAtEpochMs: number | null - // A later semantic title interval cannot inherit rich fields from an earlier task. - lastAgentStatusRichInvalidatedAtEpochMs: number | null - lastOscTitle: string | null - lastOscTitleAt: number | null - // Why a second stamp: `lastOscTitleAt` is a title-observation sequence number, - // comparable only to other title stamps. Anything that must date a live title - // against an off-pane clock (hook `receivedAt`) needs wall-clock ms. - lastOscTitleEpochMs: number | null - managementTitle: string | null - managementTitleAt: number | null - controllerTitle: string | null - title: string | null - titleUpdatedAt: number | null - lastOutputAt: number | null - tailBuffer: string[] - tailTranscriptBuffer: string[] - tailTranscriptChars: number - tailPartialLine: string - tailPendingAnsi: string - tailRedrawCursor: RetainedTailRedrawCursor | null - tailTruncated: boolean - tailLinesTotal: number - preview: string - waitBlockedAt: number | null - // Why: memoized wait scan of the current retained tail (see RuntimeLeafRecord). - tailWaitState?: TerminalTailWaitState -} - -type RuntimePtyTabCloseAuthority = { - handle: string - ptyId: string - incarnationId: PtyIncarnationId | null - worktreeId: string -} - -type TerminalAgentStatusSnapshot = { - waitText: string - waitBlockedAt: number | null - title: string | null - titleStatus: AgentStatus | null - titleStatusIsLive: boolean -} - -type TerminalCreateOptions = { - command?: string - claudeAgentTeamsSourceCommand?: string - cwd?: string - env?: Record - envToDelete?: string[] - launchConfig?: WorktreeStartupLaunch['launchConfig'] - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - // Why: agent ids are not shell commands (`cursor` is the Cursor desktop app; its - // CLI is `cursor-agent`). Callers that know the agent name it here instead of - // guessing a command, and the runtime builds the configured launch. - startupAgent?: TuiAgent - launchPreferences?: AgentLaunchPreferences - terminalColorQueryReplies?: TerminalOscColorQueryReplyColors - viewMode?: 'terminal' | 'chat' - startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] - telemetry?: WorktreeStartupLaunch['telemetry'] - title?: string - focus?: boolean - rendererBacked?: boolean - activate?: boolean - presentation?: RuntimeTerminalPresentation - // Why: `false` adopts the terminal without pointing the user at it — no - // sidebar reveal, no tab focus. Distinct from 'background' presentation, - // which skips renderer adoption entirely. - surfaceOwner?: false - tabId?: string - leafId?: string - sessionId?: string - isNewSession?: boolean - preAllocatedHandle?: string - // Why: only the host-derived structured resume path may attach provider - // identity; opaque terminal.create commands remain ordinary shells. - agentSessionClaim?: AgentSessionExecutionClaim - structuredAgentSessionId?: string - agentSessionCreateOperationId?: string - signal?: AbortSignal - // Why: idempotent create operations must retain their fence after the PTY - // exists, even if later runtime publication fails. - onPtySpawnCommitted?: () => void - // Why: the headless mobile-session create publishes its own authoritative - // snapshot (with the correct target group) right after spawn. Skip the - // intermediate pty-backed publish so the new tab doesn't briefly flash in - // the wrong (active) group before the corrected snapshot lands. - deferMobileSessionPublish?: boolean -} - -function mergeTerminalEnvDeletionKeys( - first: readonly string[] | undefined, - second: readonly string[] | undefined -): string[] | undefined { - const merged = [...new Set([...(first ?? []), ...(second ?? [])])] - return merged.length > 0 ? merged : undefined -} - -type AgentSessionCreateOperation = { - fingerprint: string - promise: Promise -} - -function isAgentSessionOperationOutcomeUnknown(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'agentSessionOperationOutcome' in error && - error.agentSessionOperationOutcome === 'unknown' - ) -} - -// Orphaned verdicts are bounded; active PTYs retain theirs until new evidence resolves them. -const MAX_TRACKED_PTY_LIVENESS_VERDICTS = 256 - -type TrackedPtyLivenessVerdict = { - verdict: PtyLivenessVerdict - observedAt: number -} - -const AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT = 512 -const AGENT_SESSION_OPERATION_GLOBAL_LIMIT = 4_096 - -function deterministicAgentSessionUuid(seed: string): string { - const hex = createHash('sha256').update(seed).digest('hex').slice(0, 32).split('') - hex[12] = '4' - hex[16] = ((Number.parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16) - const value = hex.join('') - return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}` -} - -type PtyForegroundAgentRefresh = { - promise: Promise - startedAfterTitleObservation: number - requestedAfterTitleObservation: number -} - -type PtyForegroundProcessRead = { - controller: RuntimePtyController - process: string | null - available: boolean -} - -type PtyForegroundProcessReadEntry = { - controller: RuntimePtyController - startedAfterTitleObservation: number - promise: Promise -} - -export type RuntimeNativeChatTranscriptBinding = { - worktreeId: string - connectionId: string | null - agent: string | null - providerSession: AgentProviderSessionMetadata | null -} - -function copySleepingAgentLaunchConfig( - config: SleepingAgentLaunchConfig -): SleepingAgentLaunchConfig { - return { - ...(config.agentCommand ? { agentCommand: config.agentCommand } : {}), - agentArgs: config.agentArgs, - agentEnv: { ...config.agentEnv }, - ...(config.ompResumeFilePath ? { ompResumeFilePath: config.ompResumeFilePath } : {}) - } -} - -function normalizeAgentLaunchCommandForMatch(command: string): string { - return command.trim().replace(/\s+/g, ' ') -} - -function resolveBareAgentLaunchCommand(args: { - command: string | undefined - settings: { - agentCmdOverrides?: Partial> | null - disabledTuiAgents?: Iterable | null - } - platform: NodeJS.Platform - isRemote: boolean -}): TuiAgent | null { - const command = args.command ? normalizeAgentLaunchCommandForMatch(args.command) : '' - if (!command) { - return null - } - - const cmdOverrides = args.settings.agentCmdOverrides ?? {} - for (const agent of Object.keys(TUI_AGENT_CONFIG) as TuiAgent[]) { - if (!isTuiAgentEnabled(agent, args.settings.disabledTuiAgents)) { - continue - } - const override = cmdOverrides[agent]?.trim() - const defaultLaunchCommand = getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[agent], args.platform, { - isRemote: args.isRemote - }) - const launchCommands = override ? [defaultLaunchCommand, override] : [defaultLaunchCommand] - if ( - launchCommands.some((candidate) => command === normalizeAgentLaunchCommandForMatch(candidate)) - ) { - return agent - } - } - - return null -} - -function inferCapturedClaudeAgentTeamsMode( - launchConfig: SleepingAgentLaunchConfig | undefined, - command: string | undefined, - currentMode: ClaudeAgentTeamsMode | undefined -): ClaudeAgentTeamsMode | undefined { - const capturedCommand = launchConfig?.agentCommand?.trim() || command?.trim() || '' - const capturedArgs = launchConfig?.agentArgs?.trim() ?? '' - const capturedLaunch = `${capturedCommand} ${capturedArgs}`.trim() - if (/(^|\s)--teammate-mode(?:=|\s+)auto(?:\s|$)/.test(capturedLaunch)) { - return 'native-panes-shim' - } - if (/(^|\s)--teammate-mode(?:=|\s+)in-process(?:\s|$)/.test(capturedLaunch)) { - return 'in-process' - } - if (launchConfig && /(^|\s)--resume(?:\s|=|$)/.test(command?.trim() ?? '')) { - return 'off' - } - return currentMode -} - -export type RuntimeTerminalAgentStatusEvent = { - ptyId: string - source: 'mounted-leaf' | 'pty-record' - paneKey: string - tabId?: string - worktreeId?: string - connectionId?: string | null - payload: ParsedAgentStatusPayload -} - -type RuntimePtyTitleTrackerEntry = { - tracker: TerminalTitleTracker - // Why: onPtyData batches the mobile session-tab touch to once per chunk; - // the stale-working-title timer fires between chunks and must touch - // immediately. This flag routes the tracker callback to the right mode. - applyingChunk: boolean - lastMobileTitleGateKey: string | null - chunkTouchedSessionTabs: boolean - // Why: facts observed while applying a chunk are batched into one - // pty:sideEffect emission per chunk, preserving status/title/bell order. - // Timer-fired facts emit immediately between chunks. - pendingFacts: TerminalSideEffectFact[] - // Why: Command Code lacks hooks, so its working/done state is scraped from - // TUI output. Null when no side-effect consumer exists (headless serve) — - // the scrape produces facts only. - commandCodeDetector: { observe: (data: string) => boolean } | null -} - -// Why: the full OSC 9999 payload flows through emitTerminalAgentStatusEvents and -// is then forwarded to the renderer and dropped. Mobile is served by the main -// process and has no renderer store, so we retain the latest payload per pane -// here to feed worktree.ps's inline agent rows (1:1 with the desktop sidebar). -type RuntimeAgentRowSnapshot = { - paneKey: string - ptyId: string - worktreeId?: string - tabId?: string - // Transport of the pane's PTY at retain time; null for local. Without it, - // OSC rows for SSH panes would lose the remote exemption in worktree.ps. - connectionId: string | null - payload: ParsedAgentStatusPayload - // When the current payload.state was first observed for this pane (ms). - stateStartedAt: number - 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< - RuntimeAgentRowSnapshot, - 'payload' | 'updatedAt' | 'stateStartedAt' | 'worktreeId' -> - -type RuntimeHeadlessTerminal = { - emulator: HeadlessEmulator - // Why: serialize can race with newer writes appended to writeChain; return - // the seq actually painted into this emulator, not the latest PTY seq. - outputSequence: number - writeChain: Promise - ownership: PtyShellOwnershipMirror -} - -export type RuntimePtyDataAdmission = Readonly<{ - sequence: number - completion: Promise -}> - -// Why: a subscription id is stable across reconnects, so holding the string is not -// proof of ownership. This handle is the only safe way to tear down a registration. -export type SubscriptionRegistration = Readonly<{ - /** Tears down only if this registration still owns the id; otherwise a no-op. */ - releaseIfCurrent: () => void -}> - -export type RuntimeTerminalDataMeta = Readonly<{ - seq?: number - rawLength?: number - transformed?: boolean - cwd?: string - sourceRanges?: readonly TerminalOutputSourceRange[] -}> - -type RuntimeVisibleTerminalState = { - lines: string[] - draft?: string - isAlternateScreen: boolean - sequence: number - generation: number -} - -type RuntimeTerminalProjection = { - lines: string[] - draft?: string -} - -type ProviderBufferAcquisition = { - generation: number - scrollbackRows: number - promise: Promise - timedOut: boolean -} - -type RuntimeTerminalBufferSnapshot = { - data: string - /** Live state that can be restored without an alternate-screen frame. */ - frameRestoreAnsi?: string - cols: number - rows: number - seq?: number - cwd?: string | null - lastTitle?: string - source?: 'headless' | 'renderer' - oscLinks?: TerminalOscLinkRange[] - alternateScreen?: boolean - scrollbackAnsi?: string - pendingEscapeTailAnsi?: string - /** Effective kitty flags proven at this snapshot's own `seq`. Absent means - * the winning source could not prove them. */ - kittyKeyboardFlags?: number - terminalOwner?: 'shell' -} - -type HeadlessSeedMetadata = { - cwd?: string | null - oscLinks?: TerminalOscLinkRange[] - /** Cold restore history must outrank a model that only saw new-generation bytes. */ - preferProviderIfExisting?: boolean - /** Persisted kitty flags from the daemon snapshot, re-applied to the fresh - * emulator so hidden `CSI ? u` answers the real flags instead of ?0u - * (terminal-query-authority.md §kitty). */ - kittyKeyboardFlags?: number - terminalOwner?: 'shell' -} - -type RuntimePtyController = { - claimStablePaneCreate?(args: { - worktreeId: string - connectionId: string | null - tabId: string - leafId: string - }): () => void - adoptStablePane?(opts: { - cols: number - rows: number - cwd?: string - connectionId: string | null - worktreeId: string - preAllocatedHandle: string - tabId: string - leafId: string - }): Promise<{ - result: PtySpawnResult - owner: { - handle?: string - tabId: string - leafId: string - ptyId: string - incarnationId?: string - } - materialized?: true - } | null> - spawn?(opts: { - cols: number - rows: number - cwd?: string - command?: string - launchAgent?: TuiAgent - commandDelivery?: 'renderer' | 'provider' - startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] - env?: Record - envToDelete?: string[] - resumeProviderSession?: AgentProviderSessionMetadata - telemetry?: WorktreeStartupLaunch['telemetry'] - 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 - } - }): Promise<{ - id: string - pid?: number - incarnationId?: PtyIncarnationId - wslDistro?: string - stablePaneOwner?: { handle: string; tabId: string; leafId: string } - agentSessionEnsure?: AgentSessionClaimedSpawnResult - }> - write(ptyId: string, data: string): boolean - writeAgentSessionProof?( - ptyId: string, - data: string, - authority: { sessionId: string; spawnToken: string } - ): boolean - writeWithSettlement?(ptyId: string, data: string): Promise - /** Attach-only adoption of a live local daemon session so its output streams - * to main without a renderer pane; never creates, resizes, or focuses. - * False on doubt (absent session, SSH-scoped id, non-daemon provider). */ - attach?(ptyId: string): Promise - kill(ptyId: string): boolean - retireRejectedPty?(ptyId: string, stopConfirmed: boolean): void - stopAndWait?( - ptyId: string, - opts?: { keepHistory?: boolean; deadlineMs?: number } - ): Promise - markReversibleStops?(ptyIds: readonly string[]): () => void - getCwd?(ptyId: string): Promise - getForegroundProcess(ptyId: string): Promise - inspectProcess?( - ptyId: string - ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> - confirmForegroundProcess?(ptyId: string): Promise - confirmShellForeground?(ptyId: string): Promise - hasChildProcesses?(ptyId: string): Promise - clearBuffer?(ptyId: string): Promise - resize?(ptyId: string, cols: number, rows: number): boolean - // Why: exact-id mobile polls should not enumerate every local and SSH PTY. - hasPty?(ptyId: string): boolean | null - // Why: the caller's budget has to reach the relay. Without it an SSH list runs to - // the mux's own 30s default and blows every inventory refresh (STA-517). - listProcesses?( - connectionId?: string | null, - opts?: { deadlineMs?: number } - ): Promise - listProcessesWithHostScope?(opts?: { deadlineMs?: number }): Promise<{ - processes: PtyProcessInfo[] - hostIds: ExecutionHostId[] - }> - serializeBuffer?( - ptyId: string, - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - ): Promise<{ - data: string - cols: number - rows: number - seq?: number - lastTitle?: string - kittyKeyboardFlags?: number - } | null> - /** Authoritative provider-owned snapshot for restored PTYs with no mounted renderer. */ - serializeProviderBuffer?( - ptyId: string, - opts?: { scrollbackRows?: number } - ): Promise - // Why: synchronous probe used by maybeHydrateHeadlessFromRenderer to skip - // hydration when no renderer is authoritative for this PTY. See - // docs/mobile-prefer-renderer-scrollback.md. - hasRendererSerializer?(ptyId: string): boolean - getRendererSerializerGeneration?(ptyId: string): number - waitForRendererSerializer?( - ptyId: string, - afterGeneration: number, - timeoutMs?: number, - signal?: AbortSignal - ): Promise - getSize?(ptyId: string): { cols: number; rows: number } | null - /** False only when the owning provider proved the PTY absent; null = unknown (never a denial). */ - probePtyLiveness?(ptyId: string): Promise -} - -type PtyControllerTerminalIdentity = Readonly<{ - handle: string - incarnationId: string - wslDistro?: string | null -}> - -type PtyControllerInventory = Readonly<{ - livePtyIds: ReadonlySet - // Why: livePtyIds is worktree-scoped when a target is given; absence proofs - // must consult the unscoped inventory or a misattributed live PTY reads as dead. - allLivePtyIds: ReadonlySet - terminalIdentityByPtyId: ReadonlyMap - queriedHostIds: ReadonlySet -}> - -type WorktreeStartupDraftPaste = { - agent: TuiAgent - content: string -} - -type WorktreeStartupFollowup = { - expectedProcess: string - prompt: string -} - -function getAgentLaunchPlatformForRepo( - repo: Pick, - projectRuntime?: ProjectExecutionRuntimeResolution -): NodeJS.Platform { - if (!repo.connectionId) { - if (projectRuntime?.status === 'repair-required') { - return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : process.platform - } - if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { - return 'linux' - } - return process.platform - } - return isWindowsAbsolutePathLike(repo.path) ? 'win32' : 'linux' -} - -// Why: long enough for a phone to reconnect and retry a create whose response -// was lost, short enough that an intentional later re-resume forks fresh. -const MOBILE_TERMINAL_CREATE_RESULT_TTL_MS = 60_000 -// Why: a phone whose create was interrupted retries with the same clientMutationId -// and reuses the just-created worktree instead of spawning a duplicate. -const WORKTREE_CREATE_RESULT_TTL_MS = 60_000 -const FOREGROUND_AGENT_WRAPPER_RETRY_INTERVAL_MS = 150 -const FOREGROUND_AGENT_WRAPPER_RETRY_TIMEOUT_MS = 6_500 -const BRACKETED_PASTE_BEGIN = '\x1b[200~' -const BRACKETED_PASTE_END = '\x1b[201~' -const BRACKETED_PASTE_QUIET_MS = 1500 -// Why: both are windows *after* the paste is ingested, so each is added to the -// payload's ingest bound rather than standing in for it (see getTerminalPasteIngestMs). -// The quiet window stays at 1500: nothing measured describes an agent's post-paste -// redraw cadence, and a shorter window submits mid-redraw. -const AGENT_PROMPT_RENDER_TIMEOUT_MS = 8000 -const AGENT_PROMPT_RENDER_QUIET_MS = 1500 -// Why: Claude and Codex emit show-cursor after accepting bracketed paste. -const AGENT_PROMPT_RENDER_MARKER = '\x1b[?25h' - -function assertAgentPromptRequestActive(signal?: AbortSignal): void { - if (signal?.aborted) { - throw new Error('request_aborted') - } -} - -async function waitForAgentPromptPromise(promise: Promise, signal?: AbortSignal): Promise { - if (!signal) { - return await promise - } - assertAgentPromptRequestActive(signal) - return await new Promise((resolve, reject) => { - let settled = false - const finish = (result: { value: T } | { error: unknown }): void => { - if (settled) { - return - } - settled = true - signal.removeEventListener('abort', onAbort) - if ('error' in result) { - reject(result.error) - } else { - resolve(result.value) - } - } - const onAbort = (): void => finish({ error: new Error('request_aborted') }) - signal.addEventListener('abort', onAbort, { once: true }) - if (signal.aborted) { - onAbort() - return - } - promise.then( - (value) => finish({ value }), - (error: unknown) => finish({ error }) - ) - }) -} - -// Generic terminal.send uses setImmediate to let abort/permission/data callbacks run between -// chunks without paying a full Windows timer tick for every 16 KiB write. Agent prompts use an -// atomic bracketed-paste write below, so they do not rely on this scheduler. -// Why the global and not node:timers/promises: only the global is intercepted by fake timers, -// so a chunked paste stays observable on the test clock. -function yieldBetweenTerminalInputChunks(): Promise { - return new Promise((resolve) => { - setImmediate(resolve) - }) -} - -async function waitForAgentPromptDelay(delayMs: number, signal?: AbortSignal): Promise { - if (!signal) { - await new Promise((resolve) => setTimeout(resolve, delayMs)) - return - } - assertAgentPromptRequestActive(signal) - await new Promise((resolve, reject) => { - const onAbort = (): void => { - clearTimeout(timer) - reject(new Error('request_aborted')) - } - const timer = setTimeout(() => { - signal.removeEventListener('abort', onAbort) - resolve() - }, delayMs) - signal.addEventListener('abort', onAbort, { once: true }) - if (signal.aborted) { - onAbort() - } - }) -} - -const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000 -// Why: the split already failed; the caller waits on this teardown only to learn whether the -// fallback kill is needed, so keep it short — an unreachable host must not stall the rejection. -const REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS = 2_000 -const EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS = 2_000 -const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000 -const SSH_PANE_RECOVERY_GRACE_MS = 30_000 -// Why: long enough that a keystroke burst to a proven-dead leaf probes once, -// short enough that a recreated session id regains writability quickly even if -// its runtime record (which also invalidates the verdict) is late. -const PROVEN_ABSENT_LEAF_PTY_TTL_MS = 15_000 - -function isClientDisconnectedError(error: unknown): boolean { - return error instanceof Error && error.message === 'client_disconnected' -} - -function createTerminalRevealWarning(handle: string, error?: unknown): string { - const reason = - error instanceof Error && error.message.trim().length > 0 - ? ` Reason: ${error.message.trim()}.` - : '' - return [ - `Terminal ${handle} is running, but Orca could not make it discoverable.${reason}`, - `Run \`orca terminal focus --terminal ${handle}\` to reveal and focus it.` - ].join(' ') -} - -// Why: an absent `surfaceOwner` means "default", so surfacing callers must omit -// the key rather than send `true`. -function ownerSurfacing(shouldSurface: boolean): { surfaceOwner?: false } { - return shouldSurface ? {} : { surfaceOwner: false } -} - -function resolveTerminalPresentation(opts: { - presentation?: RuntimeTerminalPresentation - focus?: boolean - activate?: boolean -}): RuntimeTerminalPresentation | undefined { - if (opts.presentation) { - return opts.presentation - } - if (opts.focus === true || opts.activate === true) { - return 'focused' - } - return undefined -} - -type RuntimeNotifier = { - worktreesChanged(repoId: string, renamed?: { oldWorktreeId: string; newWorktreeId: string }): void - worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void - worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void - reposChanged(): void - automationsChanged?(payload: AutomationsChangedPayload): void - activateWorktree( - repoId: string, - worktreeId: string, - setup?: CreateWorktreeResult['setup'], - startup?: WorktreeStartupLaunch, - defaultTabs?: CreateWorktreeResult['defaultTabs'] - ): void - createTerminal( - worktreeId: string, - opts: { - command?: string - cwd?: string - env?: Record - title?: string - presentation?: RuntimeTerminalPresentation - } - ): void - revealTerminalSession?( - worktreeId: string, - opts: { - ptyId: string - title?: string | null - cwd?: string - launchConfig?: SleepingAgentLaunchConfig - launchToken?: string - launchAgent?: TuiAgent - viewMode?: 'terminal' | 'chat' - activate?: boolean - presentation?: RuntimeTerminalPresentation - surfaceOwner?: false - tabId?: string - leafId?: string - splitFromLeafId?: string - splitDirection?: 'horizontal' | 'vertical' - splitTelemetrySource?: TerminalPaneSplitSource - focus?: boolean - expectedProcessIdentity?: { - terminalHandle: string - incarnationId: string - } - } - ): - | Promise<{ tabId: string; title?: string | null; identity?: TerminalRevealIdentity }> - | { tabId: string; title?: string | null; identity?: TerminalRevealIdentity } - | void - resolveLegacyWorkerTerminalRecovery?( - paneKey: string, - resolution: 'adopted' | 'exited' | 'rolled_back', - ptyId?: string - ): void - splitTerminal( - tabId: string, - paneRuntimeId: number, - opts: { - direction: 'horizontal' | 'vertical' - command?: string - worktreeId?: string - sourceLeafId?: string - telemetrySource?: TerminalPaneSplitSource - newLeafId?: string - } - ): void - renameTerminal(tabId: string, title: string | null): void - focusTerminal(tabId: string, worktreeId: string, leafId?: string | null): void - focusEditorTab?(tabId: string, worktreeId: string): void - closeSessionTab?(tabId: string, worktreeId: string): void | Promise - moveSessionTab?(worktreeId: string, move: RuntimeMobileSessionTabMove): void - openFile?( - worktreeId: string, - filePath: string, - relativePath: string, - runtimeEnvironmentId?: string | null - ): void - openDiff?( - worktreeId: string, - filePath: string, - relativePath: string, - staged: boolean, - runtimeEnvironmentId?: string | null - ): void - readMobileMarkdownTab?(worktreeId: string, tabId: string): Promise - saveMobileMarkdownTab?( - worktreeId: string, - tabId: string, - baseVersion: string, - content: string - ): Promise - closeTerminal(tabId: string, paneRuntimeId?: number): void - closeTerminalTab?( - tabId: string, - options?: { localPtyTeardownOwnedExternally?: boolean } - ): Promise - sleepWorktree(worktreeId: string): void - // Why: a phone opening a worktree wakes its slept agents by asking the host - // renderer to run its own navigation-free wake (experimental agent sleep); - // the runtime has no in-memory sleeping records or wake authority. Optional to - // match the many renderer-backed notifier methods only the real bridge wires. - resumeSleepingAgents?(worktreeId: string): void - terminalFitOverrideChanged( - ptyId: string, - mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit', - cols: number, - rows: number - ): void - // Why: presence-based lock signal — desktop renderer mounts the lock - // banner when `driver.kind === 'mobile'` and unmounts otherwise. The - // structured payload (vs a `locked: boolean`) carries the active mobile - // actor's clientId so the renderer can disambiguate multi-phone scenarios - // and so a future write coordinator can use the same signal as scheduling - // input. See docs/mobile-presence-lock.md. - terminalDriverChanged(ptyId: string, driver: DriverState): void - nativeChatLaunchDraftResolved?( - tabId: string, - resolution: { text: string; createdAt: number } - ): void - browserDriverChanged?(browserPageId: string, driver: RuntimeBrowserDriverState): void - // Why: separate from the driver above because watching and driving are independent — a page can - // be watched by a desktop client with no driver at all, and that page must still paint. - browserRemoteViewersChanged?(browserPageId: string, hasRemoteViewers: boolean): void - // Why: pages placed on a paired client never reach the host renderer's tab model, so the host - // has no row for them unless main pushes one. Ephemeral and host-local — see - // src/shared/client-hosted-browser-rows.ts. - clientHostedBrowserRowsChanged?(event: ClientHostedBrowserRowsEvent): void -} - -type TerminalHandleRecord = { - handle: string - runtimeId: string - rendererGraphEpoch: number - worktreeId: string - tabId: string - leafId: string - ptyId: string | null - ptyGeneration: number -} - -type PtyIncarnationHandleRecord = { - handle: string - incarnationId: string - leafKey: string -} - -export type OrchestrationCompatibilityTerminalAuthority = { - runtimeId: string - terminalHandle: string - ptyId: string - worktreeId: string - processIncarnation: string | null - paneKey: string | null - launchTokenHash: string | null - hostScope: WorkerTerminalHostScope -} - -export type LegacyWorkerTerminalRecoveryResult = { - blockedPaneCount: number - adoptedDispatchIds: string[] - exitedDispatchIds: string[] - deferredDispatchIds: string[] -} - -type LegacyWorkerTerminalRecoveryResolution = { - candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] - resolution: 'adopted' | 'exited' -} - -export type OrchestrationCompatibilityCallerAuthority = Readonly<{ - hostScope: OrchestrationCompatibilityTerminalAuthority['hostScope'] - paneKey: string - terminalHandle: string - processIncarnation: string - launchTokenHash: string -}> - -type RestoredOrchestrationAuthorityReceipt = Readonly<{ - ptyId: string - worktreeId: string - terminalHandle: string - paneKey: string - processIncarnation: string - hostScope: OrchestrationCompatibilityTerminalAuthority['hostScope'] -}> - -type OrchestrationCompatibilitySshAttachmentAuthority = Extract< - OrchestrationCompatibilityHostStamp, - { kind: 'ssh' } -> - -type TerminalWaiter = { - handle: string - condition: RuntimeTerminalWaitCondition - resolve: (result: RuntimeTerminalWait) => void - reject: (error: Error) => void - timeout: NodeJS.Timeout | null - pollInterval: NodeJS.Timeout | null - abortCleanup: (() => void) | null -} - -type MessageWaiter = OrchestrationMessageWaiter & { - handle: string - resolve: (result: MessageWaitResult) => void - timeout: NodeJS.Timeout | null - abortCleanup: (() => void) | null -} - -export type MessageWaitResult = 'notified' | 'timed_out' | 'cancelled' | 'waiter_exists' - -function omitUndefinedProperties>(value: T): Partial { - return Object.fromEntries( - Object.entries(value).filter(([, entry]) => entry !== undefined) - ) as Partial -} - -async function isRuntimeWorktreePathMissing( - repo: Repo, - worktreePath: string, - localWorktreeGitOptions: { wslDistro?: string } = {} -): Promise { - if (!repo.connectionId) { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - return isWorktreePathMissing( - toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), - access.statPath - ) - } - - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return false - } - return isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) -} - -async function isLocalRuntimeGitRepository( - runtimeWorktreePath: string, - localWorktreeGitOptions: { wslDistro?: string } = {} -): Promise { - try { - await gitExecFileAsync(['status', '--short'], { - cwd: runtimeWorktreePath, - ...localWorktreeGitOptions - }) - return true - } catch (error) { - return !gitStatusErrorMeansNotRepository(error) - } -} - -function gitStatusErrorMeansNotRepository(error: unknown): boolean { - const message = - error instanceof Error - ? error.message - : error && typeof error === 'object' && 'message' in error - ? String((error as { message: unknown }).message) - : typeof error === 'string' - ? error - : '' - const stderr = - error && typeof error === 'object' && 'stderr' in error - ? String((error as { stderr: unknown }).stderr) - : '' - return /not a git repository/i.test(`${message}\n${stderr}`) -} - -type RuntimeWorktreeRemovalTarget = { - id: string - repoId: string - path: string - pushTarget?: GitPushTarget -} - -type RuntimeWorktreeRemovalInFlight = { - optionsKey: string - promise: Promise -} - -type PreservedBranchCleanupTarget = { - worktreeId: string - hostId?: ExecutionHostId - branchName: string - head: string - pushTarget?: GitPushTarget -} - -function getRuntimeWorktreeRemovalOptionsKey( - force: boolean, - runHooks: boolean, - allowUnverifiedPtyStop: boolean -): string { - // Why: a forced retry must not coalesce onto the in-flight attempt that just - // failed the PTY gate — it would inherit that failure instead of retrying. - const ptyKey = allowUnverifiedPtyStop ? 'allow-unverified-pty' : 'require-pty-stop' - return `${force ? 'force' : 'normal'}:${runHooks ? 'run-hooks' : 'skip-hooks'}:${ptyKey}` -} - -// Null executionHostId means host-unaware: path-only callers match any repo, and the first runtime -// host can adopt a legacy (unstamped) repo. But an unstamped repo with a connectionId is an SSH repo -// (resolves to ssh:), so it must not be adopted/matched by a runtime host at the same path. -function runtimeRepoMatchesExecutionHost( - repo: Pick, - executionHostId?: ExecutionHostId | null -): boolean { - if (executionHostId == null) { - return true - } - if (repo.executionHostId != null) { - return repo.executionHostId === executionHostId - } - return repo.connectionId == null -} - -// Why: this runtime only has local git and local fs, so an ssh: host here would clone and -// probe the wrong machine and then register the result as remote. SSH setup is owned by the -// desktop IPC path (addRemoteRepoFromPath / cloneRemoteRepo), which the renderer routes to; -// only `local` and `runtime:` legitimately reach these RPCs. -function assertProjectHostSetupHostIsSupported(hostId: ExecutionHostId | null | undefined): void { - if (parseExecutionHostId(hostId)?.kind !== 'ssh') { - return - } - throw new Error( - 'SSH hosts are not supported by this operation. Set the project up from the Orca desktop app, which owns the SSH connection.' - ) -} - -function getRuntimeFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string { - const prefix = `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` - return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID() -} - -function listRuntimeFolderWorkspaces( - store: Pick, - repo: Repo, - repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length -): Worktree[] { - const rootId = getRuntimeFolderWorkspaceRootId(repo) - const allMeta = store.getAllWorktreeMeta() - const expectedHostId = getRepoExecutionHostId(repo) - const ids = Object.keys(allMeta).filter( - (worktreeId) => - isRuntimeFolderWorkspaceIdForRepo(repo, worktreeId) && - (repoOwnerCount === 1 || allMeta[worktreeId]?.hostId === expectedHostId) - ) - if (!ids.includes(rootId)) { - ids.unshift(rootId) - } else { - ids.sort((left, right) => { - if (left === rootId) { - return -1 - } - if (right === rootId) { - return 1 - } - return 0 - }) - } - - return ids.map((worktreeId) => { - const existing = getRepoOwnedWorktreeMeta(repo, worktreeId, allMeta, repoOwnerCount) - const meta: Partial = existing?.instanceId - ? existing - : existing || repoOwnerCount === 1 - ? store.setWorktreeMeta(worktreeId, { - instanceId: getRuntimeFolderWorkspaceInstanceIdentity(repo, worktreeId), - ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) - }) - : {} - return { - ...mergeRuntimeFolderWorkspace(repo, worktreeId, meta), - hostId: repoOwnerCount === 1 ? (meta.hostId ?? expectedHostId) : expectedHostId - } - }) -} - -function parseExactWorktreeIdSelector(selector: string): RuntimeWorktreeRemovalTarget | null { - const worktreeId = selector.startsWith('id:') ? selector.slice(3) : selector - const parsed = splitWorktreeId(worktreeId) - if (!parsed || !parsed.repoId || !parsed.worktreePath) { - return null - } - return { - id: worktreeId, - repoId: parsed.repoId, - path: parsed.worktreePath - } -} - -async function resolveCreateBranchName( - repoPath: string, - branchNameOverride: string | undefined, - sanitizedName: string, - settings: { branchPrefix: string; branchPrefixCustom?: string }, - username: string | null, - gitOptions: { wslDistro?: string } = {} -): Promise { - if (!branchNameOverride) { - // The runtime store's getSettings() types branchPrefix loosely as string; - // it is always one of the BranchPrefixStrategy literals at runtime. - return computeValidatedBranchName( - sanitizedName, - { ...settings, branchPrefix: settings.branchPrefix as BranchPrefixStrategy }, - username - ) - } - if (branchNameOverride.startsWith('-')) { - throw new Error('Branch name must not start with "-"') - } - await gitExecFileAsync(['check-ref-format', '--branch', branchNameOverride], { - cwd: repoPath, - ...gitOptions - }) - return branchNameOverride -} - -function normalizeLocalBranchName(branchName: string | undefined): string { - return branchName?.replace(/^refs\/heads\//, '') ?? '' -} - -// Clamp terminal dimensions to the PTY's supported range (cols 20–240, rows 8–120). -function clampTerminalViewport(cols: number, rows: number): { cols: number; rows: number } { - return { - cols: Math.max(20, Math.min(240, Math.round(cols))), - rows: Math.max(8, Math.min(120, Math.round(rows))) - } -} - -// Subscribe a listener to a per-key Set, pruning the key's entry once its last -// listener unsubscribes. Returns the unsubscribe callback. -function addListenerToMap(map: Map>, key: string, listener: T): () => void { - let listeners = map.get(key) - if (!listeners) { - listeners = new Set() - map.set(key, listeners) - } - const set = listeners - set.add(listener) - return () => { - set.delete(listener) - if (set.size === 0) { - map.delete(key) - } - } -} - -async function canCheckoutExistingLocalBranch( - repoPath: string, - branchName: string, - baseBranch: string, - gitOptions: { wslDistro?: string } = {} -): Promise { - let localHead = '' - try { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}^{commit}`], - { - cwd: repoPath, - ...gitOptions - } - ) - localHead = stdout.trim() - } catch { - return false - } - if (normalizeLocalBranchName(baseBranch) !== branchName) { - if (!localHead) { - return false - } - try { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--verify', '--quiet', `${baseBranch}^{commit}`], - { cwd: repoPath, ...gitOptions } - ) - if (stdout.trim() !== localHead) { - return false - } - } catch { - return false - } - } - const worktrees = await listWorktrees(repoPath, gitOptions) - return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName) -} - -function hasLocalGitOptions(gitOptions: { wslDistro?: string }): boolean { - return Object.keys(gitOptions).length > 0 -} - -function getLocalGitHubPrForBranch( - repoPath: string, - branchName: string, - gitOptions: { wslDistro?: string } -): ReturnType { - return hasLocalGitOptions(gitOptions) - ? getPRForBranch(repoPath, branchName, null, null, null, { - localGitExecOptions: gitOptions - }) - : getPRForBranch(repoPath, branchName) -} - -async function getSelectedHostedReviewForBranch( - repo: Pick, - branchName: string, - args: SelectedReviewBranchInput, - executionOptions: { localGitExecOptions?: { wslDistro?: string } } = {} -): Promise<{ matchesSelected: boolean; number: number } | null> { - const selectedReview = getSelectedReviewBranch(args) - if (!selectedReview) { - return null - } - const review = await getHostedReviewForBranchFromRepo({ - repoPath: repo.path, - connectionId: repo.connectionId ?? null, - branch: branchName, - ...executionOptions, - ...getSelectedReviewLookupHints(args) - }) - if (!review) { - return null - } - return { - matchesSelected: - review.provider === selectedReview.provider && review.number === selectedReview.number, - number: review.number - } -} - -async function pathExists(pathValue: string): Promise { - try { - await stat(pathValue) - return true - } catch (error) { - if (isENOENT(error)) { - return false - } - throw error - } -} - -function resolveServerBrowsePath(pathValue: string): string { - const trimmed = pathValue.trim() || '~' - if (trimmed.includes('\0')) { - throw new Error('Path cannot contain null bytes') - } - if (trimmed === '~') { - return homedir() - } - if (/^~[\\/]/.test(trimmed)) { - return resolve(homedir(), trimmed.slice(2)) - } - if (isAbsolute(trimmed)) { - return resolve(trimmed) - } - // Why: remote clients do not share the server process cwd; relative browse - // inputs are anchored to the server user's home to match the `~` picker root. - return resolve(homedir(), trimmed) -} - -type ResolvedWorktree = Worktree & { - parentWorktreeId: string | null - childWorktreeIds: string[] - lineage: WorktreeLineage | null - git: GitWorktreeInfo -} - -type LinearAgentWriteTarget = { - issue: LinearIssueSummary - workspaceId: string -} - -type LinearCreateFieldIntent = { - stateId?: string - assigneeId?: string | null - priority?: number - estimate?: number | null - dueDate?: string | null - labelIds?: string[] - projectId?: string -} - -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_TRANSPORT', - 'ORCA_AGENT_HOOK_ENDPOINT' -] as const - -function sameStringSet(left: string[], right: string[]): boolean { - if (left.length !== right.length) { - return false - } - const rightSet = new Set(right) - return left.every((value) => rightSet.has(value)) -} - -function labelsForIds( - ids: string[], - labels: { id?: string | null; name?: string | null; color?: string | null }[] -): { id: string; name: string; color?: string | null }[] { - return ids.map((id) => { - const label = labels.find((candidate) => candidate.id === id) - return { - id, - name: label?.name ?? id, - ...(label?.color ? { color: label.color } : {}) - } - }) -} - -type TerminalWorkspaceLaunchScope = { - id: string - path: string - connectionId: string | null - repo: Repo | null - folderWorkspace: FolderWorkspace | null -} - -type ResolvedTerminalWorkspaceLaunchTarget = { - scope: TerminalWorkspaceLaunchScope - managedWorktree: ResolvedWorktree | null -} - -type WorktreeLineageInput = { - parentWorkspace?: string - /** Set by in-app parent pickers so the row is not recorded as a CLI flag. */ - parentWorkspaceOrigin?: 'manual' - envParentWorkspace?: string - parentWorktree?: string - cwdParentWorktree?: string - noParent?: boolean - callerTerminalHandle?: string - comment?: string - orchestrationContext?: { - parentWorktreeId?: string - orchestrationRunId?: string - taskId?: string - coordinatorHandle?: string - } -} - -type ResolvedWorkspaceParent = - | { - type: 'worktree' - workspaceKey: WorkspaceKey - worktree: ResolvedWorktree - instanceId: string | null - } - | { - type: 'folder' - workspaceKey: WorkspaceKey - folderWorkspace: FolderWorkspace - instanceId: string | null - } - -type WorktreeLineageResolution = - | { - kind: 'lineage' - parent: ResolvedWorkspaceParent - origin: WorktreeLineage['origin'] - capture: WorktreeLineage['capture'] - orchestrationRunId?: string - taskId?: string - coordinatorHandle?: string - createdByTerminalHandle?: string - } - | { - kind: 'none' - warnings: WorktreeLineageWarning[] - } - -type RuntimeWorktreeScanCache = { - generation: number - runtimeKey: string - result: RuntimeWorktreeScanResult - expiresAt: number - /** - * Git-admin state read as of the scan's start, written back once the probe settles. Never holds a - * pending promise: a wedged mount would otherwise poison every later refresh that awaits it. - * `null` means "cannot prove unchanged" (unreadable layout, still probing, probe timed out). - */ - adminFingerprint: string | null - /** When the Git scan behind `result` actually ran, so reconciliation can be bounded. */ - scannedAt: number -} - -type RuntimeWorktreeScanInFlight = { - generation: number - runtimeKey: string - promise: Promise -} - -type RuntimeWorktreeScanRefresh = { - result: RuntimeWorktreeScanResult - adminFingerprint: string | null - /** Still-running probe whose value the cache entry adopts if it settles; never awaited by a caller. */ - adminFingerprintProbe: Promise | null - scannedAt: number -} - -type WorktreeLineageCandidate = { - source: 'env-workspace' | 'cwd-context' | 'terminal-context' | 'orchestration-context' - parent: ResolvedWorkspaceParent - orchestrationRunId?: string - taskId?: string - coordinatorHandle?: string -} - -function extractOrchestrationTaskId(text?: string): string | undefined { - return text?.match(/\btask_[A-Za-z0-9]+\b/)?.[0] -} - -class RuntimeLineageError extends Error { - code: string - data?: unknown - - constructor(code: string, message: string, data?: unknown) { - super(message) - this.code = code - this.data = data - } -} - -class WorktreeIdRequiresFullPathError extends Error { - readonly code = 'worktree_id_requires_full_path' - - constructor() { - super( - 'Worktree id selectors must use the full :: value. Use the id from `orca worktree list --json`, or target by path:, branch:, or issue:.' - ) - } -} - -type ResolvedWorktreeSnapshot = { - worktrees: ResolvedWorktree[] - platformByRepoId: ReadonlyMap -} - -type ResolvedWorktreeCache = ResolvedWorktreeSnapshot & { - expiresAt: number -} - -type ResolvedWorktreeInFlight = { - generation: number - promise: Promise -} - -// Why: notificationSeq is the desktop-assigned monotonic sequence used for -// mobile reconnect catch-up (#8129). It is added on dispatch (and replay) so a -// client can watermark the last event it delivered and request exactly the -// events after it — idempotent, no duplicate local pushes. -export type MobileNotificationDispatchEvent = { - type: 'notification' - source: 'agent-task-complete' | 'terminal-bell' | 'test' | 'plugin' - title: string - body: string - worktreeId?: string - notificationId?: string - notificationSeq?: number - notificationEpoch?: string -} - -export type RuntimeWorktreeLifecycleEvent = - | { kind: 'created'; worktreeId: string; path: string; branch: string } - | { kind: 'removed'; worktreeId: string; path: string } - -export type MobileNotificationDismissEvent = { - type: 'dismiss' - notificationId: string - notificationSeq?: number - notificationEpoch?: string -} - -export type MobileNotificationEvent = - | MobileNotificationDispatchEvent - | MobileNotificationDismissEvent - -// Why: presence-based driver state for the mobile-presence lock. Exactly one -// driver per PTY at any moment. See docs/mobile-presence-lock.md. -// - `idle`: no mobile subscribers; desktop input flows freely -// - `desktop`: at least one mobile client subscribed but desktop reclaimed -// (or all mobile clients are passive `desktop`-mode watchers); desktop -// input flows freely -// - `mobile{clientId}`: a mobile client is the active driver; desktop -// input/resize are dropped server-side and the lock banner is mounted. -// `clientId` is the most recent mobile actor for this PTY. -export type DriverState = RuntimeTerminalDriverState - -// Why: per-PTY layout target — what the PTY *should* be at right now. -// `desktop` ⇒ runs at the desktop renderer's pane geometry; mobile passive -// watchers (mode='desktop') still receive scrollback. `phone` ⇒ runs at -// `ownerClientId`'s viewport; the desktop renderer's auto-fit is suppressed. -// See docs/mobile-terminal-layout-state-machine.md. -export type PtyLayoutTarget = - | { kind: 'desktop'; cols: number; rows: number } - | { kind: 'phone'; cols: number; rows: number; ownerClientId: string } - | { kind: 'remote-desktop'; cols: number; rows: number; ownerSubscriptionKey: string } - -// Why: authoritative layout state with monotonic seq. Bumped on every -// applyLayout success; emitted on mobile subscribe-stream events so clients -// drop stale events that arrive after a newer transition. -export type PtyLayoutState = PtyLayoutTarget & { - seq: number - appliedAt: number -} - -// Why: applyLayout result discriminator. Callers (especially RPC handlers) -// need to distinguish "shipped a new state at seq N" from "no-op — caller -// should not claim a seq it didn't produce." `pty-exited` is terminal; -// `resize-failed` is transient and the caller may retry. -export type ApplyLayoutResult = - | { ok: true; state: PtyLayoutState } - | { ok: false; reason: 'pty-exited' | 'resize-failed' } - -type LayoutQueueEntry = { - running: Promise | null - pending: { - target: PtyLayoutTarget - waiters: ((r: ApplyLayoutResult) => void)[] - }[] -} - -type NativeChatLaunchDraftResolutionTombstone = RuntimeNativeChatLaunchDraftResolution & { - worktreeId: string -} - -const MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES = 200 - -async function hasLocalWorktreeBaseRef( - repoPath: string, - baseRef: string, - options: { wslDistro?: string } = {} -): Promise { - const refExists = (qualifiedRef: string) => - hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) - const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists) - if (resolvedBaseRef !== baseRef) { - return true - } - if (baseRef.startsWith('refs/')) { - return refExists(baseRef) - } - return hasCommitObjectViaGitExec( - (gitArgs) => gitExecFileAsync(gitArgs, { cwd: repoPath, ...options }), - baseRef - ) -} - -function getSetupRunnerCommandPlatformForLaunch( - setup: CreateWorktreeResult['setup'], - fallbackPlatform: 'windows' | 'posix' -): 'windows' | 'posix' { - return getSetupRunnerCommandPlatformForPath(setup?.runnerScriptPath ?? '', fallbackPlatform) -} - -export type RuntimeRendererReloadFence = Readonly<{ - revision: number - recovery: 'renderer' | 'headless' | 'reloading' -}> - -function mobileWebPackageIsAvailable(): boolean { - try { - const root = resolveMobileWebPackageRoot() - const manifest = MobileWebManifestSchema.parse( - JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')) - ) - return manifest.assets.every((asset) => { - const path = resolve(root, ...asset.path.split('/')) - const child = relative(root, path) - if (child.startsWith('..') || isAbsolute(child)) { - return false - } - const info = statSync(path) - return info.isFile() && info.size === asset.byteLength - }) - } catch { - return false - } -} - -/** How a caller wants the provider-held screen fetched when the runtime has no - * bytes of its own. `visibleScreenOnly` narrows the result to the current grid: - * scrollback can still hold a ready banner a working agent printed minutes ago, - * which is history, not evidence of what the agent is doing now. */ -type ProviderSnapshotReadOptions = { - timeoutMs?: number - retireOnTimeout?: boolean - visibleScreenOnly?: boolean -} - -export class OrcaRuntimeService { - private readonly runtimeId = randomUUID() - private readonly startedAt = Date.now() - private readonly store: RuntimeStore | null - private managedHookReconciliationGeneration = 0 - private managedHookReconciliationTail: Promise = Promise.resolve() - private readonly orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport | null - private readonly orchestrationFederationTimers = new Map>() - private orchestrationTerminalHistoryRecoveryTimer: ReturnType | null = null - private orchestrationTerminalHistoryRecoveryInFlight: Promise | null = null - private orchestrationTerminalRecoveryRowId = 0 - private orchestrationFederationRelayGeneration = 0 - private readonly orchestrationFederationSyncs = new Map< - string, - { db: OrchestrationDb; promise: Promise } - >() - private readonly orchestrationFederationWarnings = new Set() - private rendererGraphEpoch = 0 - private graphStatus: RuntimeGraphStatus = 'unavailable' - private authoritativeWindowId: number | null = null - private headlessGraphFallbackAvailable = false - private pendingHeadlessPromotionWindowId: number | null = null - private rendererGeneration: string | null = null - private mobileSessionTabsChangeSequence = 0 - private pendingMobileSessionTabsChangeSequenceByWorktree = new Map() - private readonly graphReloadLifecycle = new RuntimeGraphReloadLifecycle({ - timeoutMs: RUNTIME_GRAPH_RELOAD_TIMEOUT_MS, - onSettled: ({ revision, windowId, outcome, durationMs }) => { - console.info( - `[runtime-graph] reload revision=${revision} window=${windowId} outcome=${outcome} durationMs=${durationMs}` - ) - }, - onTimeout: (_revision, windowId) => this.handleGraphReloadTimeout(windowId) - }) - // Why: paired graph transactions need foreground timer cadence only until their publication settles. - private readonly rendererPublicationThrottle = new RendererPublicationThrottle() - private tabs = new Map() - private mobileSessionTabsByWorktree = new Map() - private readonly clientHostedPageReconciliation = new ClientHostedPageReconciliationWindow( - Date.now() - ) - // Why: renderer publication ordering must be judged against the renderer's - // own last-accepted (epoch, version) — never against the stored snapshot's - // version, which main-local touches bump independently and can push - // permanently ahead of the renderer's counter. The renderer reuses one pair - // for byte-identical content, so a same-epoch version <= this one is a no-op - // resend (or stale) and is skipped without touching the stored entry. - private acceptedRendererMobileSnapshotByWorktree = new Map< - string, - { - publicationEpoch: string - rendererVersion: number - rendererTabCount: number - rendererTabIdentityKeys: ReadonlySet - } - >() - private clientSessionTabSelections = new ClientSessionTabSelectionStore() - // Why: idempotency map for mobile terminal creation — a retried create with the - // same clientMutationId returns the in-flight operation instead of duplicating. - private mobileTerminalCreateByMutationId = new Map< - string, - Promise - >() - private readonly terminalCreateIdempotency = new RemoteRuntimeTerminalCreateIdempotency() - // Why: concurrent clients sleeping one host workspace must share one physical teardown. - private terminalSleepByWorktreeId = new Map>() - private readonly terminalMutationLock = new WorktreeTerminalMutationLock() - private terminalSleepStateByWorktreeId = new Map< - string, - { - worktreeId: string - generation: number - phase: 'stopping' | 'partial' | 'sleeping' - ptyIds: string[] - terminalHandles: string[] - terminalHandlesByPtyId: Record - } - >() - private terminalSleepGeneration = 0 - private terminalPaneRecoveryByIdentity = new Map>() - // Why: idempotency map for worktree.create — a create interrupted by a mobile - // connection migration is retried with the same clientMutationId and returns - // the in-flight (or just-finished) operation instead of a duplicate worktree. - private worktreeCreateByMutationId = new Map>() - // Why: a mobile create waits for the renderer to publish the new tab's surface - // via graph-sync, but a throttled/hidden renderer can park that past the surface - // timeout and the create would then destroy the live PTY (#7587). This lets the - // renderer's own PTY spawn publish the surface main-side, scoped to in-flight - // creates so ordinary renderer spawns never publish here. - private pendingMobileTerminalCreatesByKey = new Map< - string, - { - activate: boolean - paired: boolean - selectIfNoActiveTab: boolean - viewMode?: 'terminal' | 'chat' - /** Resolved agent launch command, kept so a settle over a bare renderer - * PTY can still deliver the launch instead of succeeding silently (STA-3214). */ - startupCommand?: string - } - >() - private mobileSessionTabListeners = new Set<{ - listener: (snapshot: RuntimeMobileSessionTabsResult, changeSequence: number) => void - clientNavigationId?: string - }>() - // Why: one watermark per repo replaces per-closed-pane fences while preserving stale-write safety. - private terminalTopologyRevisionByRepoId = new Map() - // Why: provider exit can beat surface registration; that exact dead incarnation must never publish. - private earlyExitedPtyIncarnations = new Map() - private pendingPtyRegistrationIncarnations = new Map() - // Why: exact-stop is the current sleep transaction boundary; its exit must - // leave the renderer's intentional sleeping surface available for wake. - private intentionalHandlelessPtyStops = new Map() - // Why: coalesces title/status-driven session.tabs emits so spinner churn - // doesn't fan out (and per-client JSON.stringify) a snapshot several times a - // second. Emit reads the latest snapshot, so only the freshest version ships. - private readonly mobileSessionTabsNotifyCoalescer: MobileSessionTabsNotifyCoalescer = - createMobileSessionTabsNotifyCoalescer((worktreeId) => - this.flushScheduledMobileSessionTabsChanged(worktreeId) - ) - private readonly mobileSessionTabsAgentStatusHeartbeat: MobileSessionTabsAgentStatusHeartbeat = - createMobileSessionTabsAgentStatusHeartbeat( - (ptyId) => this.getMobileSessionWorktreeIdsForPty(ptyId), - (worktreeId) => this.touchMobileSessionTabsForWorktree(worktreeId) - ) - // Why: concurrent host terminal.focus storms (CLI switch fan-out / bulk open) - // each await a full host reveal; only one terminal can be focused, so latest-wins - // single-flight bounds host work. Does not replace cheaper activation or - // reconnect-scan bounding for sequential soft freezes. - private readonly terminalFocusNavigationCoalescer = - new TerminalFocusNavigationCoalescer() - private pendingMobileSessionPtyAggregateInventoryRefresh: Promise | null = - null - private structuredAgentSessionTabRestorePromise: Promise | null = null - private structuredAgentSessionStartupRestorePromise: Promise | null = null - private leaves = new Map() - // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by - // ptyId keeps active TUI redraws independent of the total open terminal count. - private leavesByPtyId = new Map() - private handles = new Map() - private handleByLeafKey = new Map() - private handleByPtyId = new Map() - private handleByPtyIncarnation = new Map() - // A provider announces a replacement before the spawn commit can bind its - // pane. Keep the predecessor aliases fenced during that hand-off window. - private pendingPtyHandleReplacementFences = new Map< - string, - { - incarnationId: PtyIncarnationId - staleHandles: Set - pendingRegistration: boolean - } - >() - private readonly mailPointerRepointScheduler = new MailPointerRepointScheduler((handle) => - this.repointPendingMessagesForHandle(handle) - ) - private syntheticTerminalHandles = new Set() - private detachedPreAllocatedLeaves = new Map() - private graphSyncCallbacks: (() => void)[] = [] - private sessionTabsInventoryPublicationEpoch: number | null = null - private sessionTabsInventoryWaiters = new Set<() => void>() - private waitersByHandle = new Map>() - private ptyExitListenersByPtyId = new Map void>>() - private ptyController: RuntimePtyController | null = null - private notifier: RuntimeNotifier | null = null - private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() - // Why: mobile subscribers discard terminalSideEffects; exclude them from batch delivery and production. - private terminalSideEffectExcludedClientEventListeners = new Set< - (event: RuntimeClientEvent) => void - >() - private terminalSideEffectTitleGateKeysByClientEventListener = new Map< - (event: RuntimeClientEvent) => void, - Map - >() - private nativeChatLaunchDraftResolutionByTabId = new Map< - string, - NativeChatLaunchDraftResolutionTombstone - >() - private worktreeLifecycleListeners = new Set<(event: RuntimeWorktreeLifecycleEvent) => void>() - private forkBackfillStarted = false - private agentBrowserBridge: AgentBrowserBridge | null = null - private offscreenBrowserBackend: BrowserBackend | null = null - private emulatorBridge: EmulatorBridge | null = null - private resolvedWorktreeCache: ResolvedWorktreeCache | null = null - private resolvedWorktreeInFlight: ResolvedWorktreeInFlight | null = null - private resolvedWorktreeGeneration = 0 - private worktreeScanGenerations = new Map() - private worktreeScanCache = new Map() - private worktreeScanInFlight = new Map() - /** Repos whose Git-admin probe has not settled yet; caps abandoned fs work at one per repo. */ - private worktreeAdminFingerprintProbes = new Set() - private cloneInFlightByPath = new Map>() - private ptyForegroundAgentRefreshes = new Map() - private ptyForegroundProcessReads = new Map() - private ptyDelayedForegroundSnapshotTitleObservations = new Map() - // Why a set and not a timer: the intent is retired by the exit it explains, or - // by the next lifecycle generation on that id (advancePtyLifecycleGeneration), - // so a stop that never produced an exit cannot outlive its process. - private readonly stopRequestedPtyIds = new Set() - private _orchestrationDb: OrchestrationDb | null = null - private messageWaitersByHandle = new Map>() - private readonly orchestrationMailboxOwner = new OrchestrationMailboxOwner({ - getDb: () => this._orchestrationDb, - getLeaf: (leafKey) => this.leaves.get(leafKey), - getLeafKey: (tabId, leafId) => this.getLeafKey(tabId, leafId), - getTerminalHandleForLeafKey: (leafKey) => this.handleByLeafKey.get(leafKey), - getTerminalProcessIncarnation: (handle) => this.getTerminalProcessIncarnation(handle), - onRoutedMessageTypes: (mailboxHandle, types) => - this.orchestrationMailboxNotifications.wakeRoutedMessageWaiters(mailboxHandle, types), - onForeignMailboxRouted: (mailboxHandle, messageType) => - this.notifyMessageArrived(mailboxHandle, messageType) - }) - private readonly orchestrationMailboxDeliveryTarget = new OrchestrationMailboxDeliveryTarget({ - getDb: () => this._orchestrationDb, - getTerminalHandleForPaneKey: (paneKey) => this.getTerminalHandleForPaneKey(paneKey), - hasTerminalHandle: (handle) => this.handles.has(handle), - canProbePtyLiveness: () => Boolean(this.ptyController?.probePtyLiveness), - controllerKnowsPtyIsLive: (ptyId) => this.controllerKnowsPtyIsLive(ptyId), - isLeafPtyProvenAbsent: (ptyId) => this.isLeafPtyProvenAbsent(ptyId) - }) - private readonly orchestrationMailboxPointerDelivery = - new OrchestrationMailboxPointerDelivery({ - mailboxOwner: this.orchestrationMailboxOwner, - deliveryTarget: this.orchestrationMailboxDeliveryTarget, - getDb: () => this._orchestrationDb, - getLeaf: (leafKey) => this.leaves.get(leafKey), - getLeafKey: (tabId, leafId) => this.getLeafKey(tabId, leafId), - getLiveLeafForHandle: (handle) => this.getLiveLeafForHandle(handle).leaf, - getMessageWaiters: (mailboxHandle) => this.messageWaitersByHandle.get(mailboxHandle), - getTabTitle: (tabId) => this.tabs.get(tabId)?.title, - getTerminalHandleForLeafKey: (leafKey) => this.handleByLeafKey.get(leafKey), - isLeafPtyProvenAbsent: (ptyId) => this.isLeafPtyProvenAbsent(ptyId), - redriveMailbox: (mailboxHandle, reservedTypes) => - this.deliverPendingMessagesForHandle(mailboxHandle, reservedTypes), - writePty: (ptyId, data) => this.writeOrchestrationPointerPty(ptyId, data) - }) - private readonly orchestrationMailboxNotifications = - new OrchestrationMailboxNotificationCoordinator({ - mailboxOwner: this.orchestrationMailboxOwner, - pointerDelivery: this.orchestrationMailboxPointerDelivery, - getDb: () => this._orchestrationDb, - getLiveLeafForHandle: (handle) => this.getLiveLeafForHandle(handle).leaf, - getPaneKeyForHandle: (handle) => { - const record = this.handles.get(handle) - return record ? `${record.tabId}:${record.leafId}` : undefined - }, - getMessageWaiters: (mailboxHandle) => this.messageWaitersByHandle.get(mailboxHandle), - hasTerminalHandle: (handle) => this.handles.has(handle), - deliverForHandle: (handle, reservedTypes) => - this.deliverPendingMessagesForHandle(handle, reservedTypes), - notifyMessageArrived: (handle, messageType) => this.notifyMessageArrived(handle, messageType), - resolveMessageWaiter: (waiter) => this.resolveMessageWaiter(waiter, 'notified') - }) - // Why: mobile clients subscribe to terminal output via terminal.subscribe. - // These listeners fire on every onPtyData call, enabling real-time streaming - // without polling. Keyed by ptyId for O(1) lookup per data event. - private dataListeners = new Map< - string, - Set<(data: string, meta?: RuntimeTerminalDataMeta) => void> - >() - private remoteTerminalSourceRangeConsumerHooks: RemoteTerminalSourceRangeConsumerHooks | null = - null - // Why: startup draft paste can subscribe after the agent already emitted its - // ready marker. Keep a bounded raw buffer so fast startup output is replayed. - private recentPtyOutputById = new Map() - private setupCompletionTokenByPtyId = new Map() - // Why: mobile clients need to know when the desktop restores a terminal - // from mobile-fit so they can update their UI. These listeners are - // invoked from resizeForClient and onClientDisconnected/onPtyExit. - private fitOverrideListeners = new Map< - string, - Set< - (event: { - mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' - cols: number - rows: number - }) => void - > - >() - private driverListeners = new Map void>>() - private subscriptionCleanups = new Map void | Promise>() - private subscriptionCleanupPromises = new Map< - string, - { cleanup: () => void | Promise; promise: Promise } - >() - // Why: index of subscriptionIds by per-WebSocket connectionId so the - // server can sweep all subscriptions for a closing socket without - // touching subscriptions on other live sockets that share the same - // deviceToken (multi-screen mobile). - private subscriptionsByConnection = new Map>() - private subscriptionConnectionByEntry = new Map() - // Why: a connection record replaces whatever else that socket was streaming regardless of who - // is driving, so it deliberately carries no pairing scope. - private activeBrowserScreencastsByConnection = new Map< - string, - Omit - >() - private activeBrowserScreencastsByPage = new Map>() - // Why: paint retention, not control — Chromium stops painting a display:none guest, so the host - // renderer must keep any page a remote client is watching mounted even when nobody drives it. - private browserRemoteViewerPages = new Set() - // Why: mobile clients subscribe to desktop notifications via - // notifications.subscribe. This set enables fan-out — each connected - // mobile client gets its own listener, and dispatchMobileNotification - // iterates them all. Listeners are cleaned up via subscriptionCleanups. - private notificationListeners = new Set<(event: MobileNotificationEvent) => void>() - private ptysById = new Map() - // Why a separate map: `connected` is a wire field that any inventory gap - // clears, so it cannot distinguish an observed exit from lost contact. This - // records the last liveness verdict we actually earned, and outlives the pty - // record so a close/stop receipt can still say the stop was unconfirmed. - private ptyLivenessVerdictByPtyId = new Map() - private ptyLivenessObservationSequence = 0 - private readonly pairedRendererSessionOwnedPtyIds = new Set() - private wslDistroByPtyId = new Map() - private titleObservationSequence = 0 - private headlessTerminals = new Map() - private ptyOutputSequenceById = new Map() - private agentPromptLifecycleByPtyId = new Map< - string, - { status: AgentStatus | null; workingSequence: number; updatedAt: number } - >() - private agentPromptPermissionSequenceByPtyId = new Map() - private agentPromptExplicitStatusFloorByPtyId = new Map() - private agentPromptSubmissionTailByPtyId = new Map>() - private providerSequenceInitializedPtys = new Set() - private providerSequenceOffsetByPtyId = new Map() - private providerSnapshotPreferredPtys = new Set() - private providerModeTrackersByPtyId = new Map() - private providerModeSnapshotScansByPtyId = new Map< - string, - Set - >() - private providerBufferAcquisitionsByPtyId = new Map() - private providerVisibleStateByPtyId = new Map() - private providerVisibleRetryAtByPtyId = new Map() - private providerSnapshotsWithLiveModeTransition = new WeakSet() - private ptyLifecycleGenerationById = new Map() - private nextPtyLifecycleGeneration = 1 - private recentPtyPathCandidatesById = new Map() - // Why: candidates only feed mobile file-tap provenance; desktop-only - // sessions skip the 3-regex extraction on every PTY chunk until a - // mobile/remote client authenticates (sticky, backfilled on activation). - private recentPtyPathCandidateTrackingActive = false - // Why: OSC 9999 status can span PTY chunks. Keeping parser state in the - // runtime lets hidden/model-owned terminals observe agent state without a - // mounted xterm view. - // Why a throttle: the blocked-reason check builds and scans two full wait - // texts (<=256KB each, lowercased) — measured at ~85% of onPtyData's cost - // under a TUI flood (findings log 2026-07-03). PTY chunk boundaries are - // arbitrary, so running the identical computation over coalesced chunks at - // a bounded cadence (plus a trailing-edge timer so burst-final state is - // always evaluated) preserves semantics while removing it from the hot path. - private waitBlockedCheckStateByPtyId = new Map< - string, - { - lastAt: number - lastWaitState: TerminalTailWaitState | null - appended: string - keywordCarry: string - timer: ReturnType | null - } - >() - - private agentStatusOscProcessorsByPtyId = new Map< - string, - ReturnType - >() - // Why: per-PTY shared title trackers (all-titles ordering + stale-working - // timer) replace last-title-per-chunk scanning so main observes the same - // intra-chunk working→idle transitions the renderer does (issue #1083). - // Lazily created like agentStatusOscProcessorsByPtyId; disposed on PTY exit. - private ptyTitleTrackersByPtyId = new Map() - // Why: the Command Code output detector arms early from the launch command - // when known (banner detection covers user-typed launches), mirroring the - // renderer detector's startupCommand seed. - private terminalSpawnCommandsByPtyId = new Map() - // Why: ordinary OSC 0/1/2 titles can split across PTY chunks, especially over - // SSH/relay buffering. Keep a small raw scan tail and feed reconstructed - // chunks into the title tracker instead of falling back to last-title scans. - private oscTitleScanTailByPtyId = new Map() - // Why: mobile file taps resolve relative paths on the host. OSC 7 is the - // terminal-owned cwd signal, and it can arrive in live output between snapshots. - private osc7ScanTailByPtyId = new Map() - private terminalCwdByPtyId = new Map() - private terminalFileUriHostnameByPtyId = new Map() - // Why: latest agent-status payload per pane, retained so worktree.ps can serve - // mobile the same inline agent rows the desktop sidebar renders. Cleared on pty - // teardown so dead agents don't linger. See RuntimeAgentRowSnapshot. - private latestAgentStatusByPaneKey = new Map() - // Why: per-PTY hydration state guards against double-hydration. Keys: - // 'pending' → maybeHydrateHeadlessFromRenderer is in flight - // 'done' → hydration completed (success or skip); never run again - // Absent → hydration has not been considered yet for this PTY. - // See docs/mobile-prefer-renderer-scrollback.md. - private headlessHydrationState = new Map() - // Why: mobile-fit overrides are keyed by ptyId (not terminal handle) because - // handles can be reissued while the PTY identity is stable. In-memory only — - // a stale phone override should not survive an app restart. - private terminalFitOverrides = new Map< - string, - { - mode: 'mobile-fit' - cols: number - rows: number - previousCols: number | null - previousRows: number | null - updatedAt: number - clientId: string - } - >() - - // Why: server-authoritative display mode per terminal. 'auto' (default) - // means phone-fit when mobile subscribes, desktop otherwise. 'desktop' - // locks to no-resize regardless of subscriber state. The third historical - // value ('phone' = sticky phone-fit after unsubscribe) was removed since - // the toggle UI never produced it and nothing in product depended on it. - // In-memory only — modes reset on restart. - private mobileDisplayModes = new Map() - - // Why: tracks active mobile subscribers per PTY so the runtime can restore - // desktop dimensions on unsubscribe and prevent orphaned overrides during - // rapid tab switches. Keyed by ptyId → inner map of clientId → subscriber. - // The two-level map preserves multi-mobile soundness: phone B subscribing - // does not silently overwrite phone A's record. See - // docs/mobile-presence-lock.md "Multi-mobile subscriber model". - // subscribedAt drives "earliest-by-subscribe-time" restore-target selection - // (only among subscribers with non-null previousCols/Rows; desktop-mode - // joins carry null and are skipped). lastActedAt drives "most-recent - // actor's viewport wins" for active phone-fit dims. - private mobileSubscribers = new Map< - string, - Map< - string, - { - clientId: string - viewport: { cols: number; rows: number } | null - wasResizedToPhone: boolean - previousCols: number | null - previousRows: number | null - subscribedAt: number - lastActedAt: number - } - > - >() - - // Why: Phase-5 query-responder suppression — a terminal-RPC subscribe - // stream feeds a remote xterm view (mobile/web/remote desktop) that answers - // queries with view authority, so main must yield while one is attached - // (terminal-query-authority.md). Ref-counted per PTY because multiple - // streams can attach concurrently; mobileSubscribers is consulted too so - // grace-window mobile records keep suppressing. - private remoteTerminalViewSubscriberCounts = new Map() - // Preview windows consume the raw stream but deliberately leave terminal - // query replies to main's headless emulator. - private rawTerminalViewSubscriberCounts = new Map() - // Why a sticky promise per PTY: the daemon only emits data for sessions this - // app has attached, so the first remote view subscriber of a never-attached - // local daemon session triggers a main-side attach. The map dedupes - // concurrent first-subscribes and keeps later subscribes no-ops; it is - // cleared per lifecycle generation (exit/respawn) and on failed attempts. - private subscriberDrivenProviderAttachesByPtyId = new Map>() - private subscriberDrivenProviderAttachInventoryWaiters = new Set() - // Why: a spawn through this app already attaches its provider stream, so - // subscriber-driven attach and the never-attached read fallback must target - // only inventory-discovered sessions no local spawn published this - // generation (a replacement spawn under a reused id starts clean). - private spawnPublishedPtys = new Set() - - // Why: per-PTY driver state. The "driver" is whoever currently owns the - // input/resize floor. While `kind === 'mobile'` the desktop renderer drops - // xterm.onData/onResize and shows the lock banner; `terminal.send` / - // `pty:write` and `pty:resize` IPC handlers also drop desktop-side calls - // server-side as defense-in-depth. The `clientId` carried on the mobile - // variant is the most recent mobile actor — used by - // `applyMobileDisplayMode` to pick the active phone-fit viewport. See - // docs/mobile-presence-lock.md. - private currentDriver = new Map() - private mobileInputFloorClaims = new Map< - string, - { - base: DriverState - generation: number - committedGeneration: number - pending: Map - } - >() - private currentBrowserDriver = new Map() - - // Why: remote (relay/shared-control) desktop viewers of a PTY are keyed by - // subscription, not client, because one client can open duplicate streams and - // each stream must release only the width floor it registered. - private remoteDesktopViewers = new Map< - string, - Map - >() - private remoteDesktopOwners = new Map() - private remoteDesktopActivity = 0 - private remoteDesktopHostReclaimTargets = new Map() - // Why: a completed host reclaim must not consume the cache if a newer - // viewer mutation landed while that serialized layout was in flight. - private remoteDesktopViewerRevisions = new Map() - - // Why: resubscribe-grace window. When the last mobile subscriber for a - // PTY unsubscribes, we hold the driver=mobile{clientId} state and the - // inner-map record open for ~250ms. If the same (ptyId, clientId) - // re-subscribes inside the window — typically because the mobile app - // tore down the stream to reconfigure (rare with the new - // updateMobileViewport path, but still possible on reconnects, network - // hiccups, or older client builds) — we cancel the deferred idle and - // restore-timer so the desktop banner doesn't flash and the new - // subscriber doesn't capture an already-phone-fitted PTY size as its - // restore baseline. Keyed by ptyId; carries the timer plus the snapshot - // of the leaving subscriber so we can re-insert it on cancel. See - // docs/mobile-presence-lock.md. - private pendingSoftLeavers = new Map< - string, - { - clientId: string - timer: ReturnType - record: { - clientId: string - viewport: { cols: number; rows: number } | null - wasResizedToPhone: boolean - previousCols: number | null - previousRows: number | null - subscribedAt: number - lastActedAt: number - } - } - >() - - // Why: tracks the last PTY size set by the desktop renderer (via pty:resize - // IPC). Unlike ptySizes (which is overwritten by server-side phone-fit - // resizes), this map preserves the actual pane geometry. Used as the - // preferred source for previousCols so desktop restore uses the correct - // split-pane width instead of a stale full-width value. - private lastRendererSizes = new Map() - - // Why: when a desktop-fit override change fires, the desktop renderer's - // re-render cascade (triggered by setOverrideTick) runs safeFit on ALL - // panes — not just the affected one. Background tab panes get measured at - // full-width (214) instead of their correct split width (105). The stale - // pty:resize IPCs overwrite both the actual PTY size and lastRendererSizes. - // This global window suppresses ALL pty:resize for 200ms after any - // desktop-fit notification. The server has already set the correct PTY - // size via ptyController.resize(), so desktop renderer resizes during - // this window are redundant (for the restored pane) or wrong (collateral). - private resizeSuppressedUntil = 0 - - // Why: delays PTY restore by 300ms after mobile unsubscribe so rapid tab - // switches don't cause unnecessary resize thrashing. Keyed by clientId - // Why: keyed by ptyId so each PTY gets its own independent restore timer. - // The old clientId-keyed design lost timers when two PTYs were unsubscribed - // back-to-back (only the last timer survived). - private pendingRestoreTimers = new Map< - string, - { timer: ReturnType; clientId: string } - >() - - // Why: inline resize events replace the unsubscribe→resubscribe pattern. - // Listeners are notified when mode changes or desktop restores, allowing - // the subscribe stream to emit a 'resized' event with fresh scrollback. - // `seq` is the layout state-machine sequence number bumped on every - // applyLayout success; mobile clients use it to drop stale events that - // arrive after a newer transition. See docs/mobile-terminal-layout-state-machine.md. - private resizeListeners = new Map< - string, - Set< - (event: { - cols: number - rows: number - displayMode: string - reason: string - seq?: number - }) => void - > - >() - - // Why: per-PTY layout state machine. `applyLayout` is the sole writer of - // `layouts`, `terminalFitOverrides`, and `ptyController.resize`; every - // trigger method routes through `enqueueLayout`. The monotonic `seq` is - // emitted on the mobile subscribe stream so clients can drop stale events. - // See docs/mobile-terminal-layout-state-machine.md. - private layouts = new Map() - - // Why: per-PTY async serialization queue for applyLayout. Without - // serialization, two concurrent triggers can interleave around the - // ptyController.resize await and bump seq in the wrong order, defeating - // seq-as-truth. Coalesces same-kind same-owner viewport ticks so the - // keyboard-show/hide animation doesn't queue 10+ resizes; mode flips, - // take-floor, and different-owner targets always append (preserves - // multi-mobile fairness). See docs/mobile-terminal-layout-state-machine.md - // "enqueueLayout coalescing". - private layoutQueues = new Map() - - // Why: gate so enqueueLayout's "no layouts entry" short-circuit doesn't - // fire on the very first transition for a PTY (where the entry doesn't - // exist yet *because* we're about to create it). `handleMobileSubscribe` - // adds the ptyId before calling enqueueLayout and removes it after the - // call resolves. - private freshSubscribeGuard = new Set() - - private stats: StatsCollector | null = null - // Why (§3.3 + §7.1): the renderer-create path and coordinator - // `probeWorktreeDrift` share this cache so a create that already fetched - // `origin` within the last 30s does not re-fetch during dispatch, and - // vice-versa. Keyed by `::` so multi-remote repos (even - // though v1 only uses `origin`) don't cross-contaminate. The in-flight Map - // also provides serialization — two concurrent callers share a single - // underlying `git fetch`. Full-remote fetch lifecycle rules: - // - entry inserted BEFORE await, - // - `.finally()` removes the entry on BOTH success and rejection, - // - timestamp written ONLY on success (rejection must not make the - // 30s freshness cache lie). - // A literal "insert before await / read-back after await" without these - // three rules wedges future fetches on the same repo after a single - // DNS hiccup until process restart (see §3.3 Lifecycle). Exact base-ref - // refreshes share the in-flight rule and maintain their own exact-base - // freshness entries; a full-remote fetch may be narrowed by repo refspecs, - // so it must not prove a specific branch for create. - private fetchInflight = new Map>() - // Why: `git fetch origin` and `git fetch origin ` contend for the - // same repo remote/ref locks. This queue serializes all fetch shapes for one - // canonical repo+remote while still letting same-shape callers share promises. - private remoteFetchQueueTail = new Map>() - private fetchLastCompletedAt = new Map() - // Why: `getCanonicalFetchKey` is awaited from every freshness probe and - // every getOrStartRemoteFetch call. Without memoization the warm-cache hot - // path spawns a `git rev-parse --git-common-dir` subprocess per touch - // (twice in createLocalWorktree). Cache by `::` so the - // canonical key is resolved at most once per repo+remote in the process. - private canonicalFetchKeyCache = new Map() - private optimisticReconcileTokens = new Map() - private removeManagedWorktreeInFlight = new Map() - private preservedBranchCleanupByScope = new Map() - private readonly getLocalProviderFn: (() => IPtyProvider) | null - private readonly getSshProviderFn: ((connectionId: string) => IPtyProvider | undefined) | null - private readonly onPtyStopped: ((ptyId: string) => void) | null - private readonly onTerminalAgentStatus: ((event: RuntimeTerminalAgentStatusEvent) => void) | null - private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null - private terminalSideEffectLocalConsumerAvailable = false - private terminalSideEffectConsumerAvailable = false - private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null - private readonly getAgentProviderSessionSnapshotFn: (() => AgentStatusIpcPayload[]) | null - private readonly getAgentProviderSessionRowsForPaneFn: - | ((paneKey: string) => AgentStatusIpcPayload[]) - | null - private readonly attestAgentHookCompatibilityAuthorityFn: - | ((candidate: { - paneKey: string - launchTokenHash: string - connectionId: string | null - terminalProvenance: 'current_runtime' | 'restored' - }) => AgentHookAuthorityAttestation | null) - | null - private readonly retireAgentHookCompatibilityAuthorityFn: ((paneKey: string) => void) | null - private readonly reconcileAgentStatusForEndedProcessFn: - | ((paneKeys: Iterable) => void) - | null - private readonly canRecoverPersistentLocalPtysFn: () => boolean - private readonly getPairedDeviceNameFn: (pairedDeviceId: string) => string | null - private readonly buildAgentHookPtyEnv: (() => Record) | null - private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus - private readonly prepareAiVaultSessionResumeFn: - | ((args: AiVaultPrepareSessionResumeArgs) => Promise) - | null - private readonly prepareCodexStructuredLaunchFn: - | ((input: { - workspacePath: string - launchEnv: NodeJS.ProcessEnv - }) => string | null | Promise) - | null - private readonly agentSessionClaimSigner: AgentSessionClaimSigner - private readonly agentSessionCreateOperations = new Map() - private readonly orchestrationCompatibilitySshAttachments = new Map< - string, - OrchestrationCompatibilitySshAttachmentAuthority - >() - private sshRelayRecoveryGenerationByTargetId = new Map() - private legacyWorkerTerminalRecoveryQueue: Promise = Promise.resolve() - private legacyWorkerTerminalRecoveryRetries = new Map< - string, - { - attempt: number - connectionId?: string - materializeRenderer: boolean - timer: ReturnType | null - } - >() - private legacyWorkerTerminalReceiptEpochByPane = new Map() - private legacyWorkerRecoveredPtys = new Set() - private restoredOrchestrationAuthorityByPtyId = new Map< - string, - RestoredOrchestrationAuthorityReceipt - >() - private ptyControllerInventorySequence = 0 - private ptyControllerAggregateInventoryGeneration = 0 - private ptyControllerInventoryGenerationByProvider = new Map() - private accountServices: RuntimeAccountServices | null = null - private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null - private automationService: AutomationService | null = null - private artifactService: ArtifactCloudService | null = null - private skillCloudService: SkillCloudService | null = null - private agentSkillShareInProgress = false - private skillUploadSessions: SkillUploadSessionService | null = null - private skillUploadSessionsDisposed = false - private readonly skillTransactionRecovery: Promise - private readonly skillInstallOperations = new Map() - private readonly skillInstallProgress = new Map() - private readonly claudeAgentTeams = new ClaudeAgentTeamsService() - private mobileDictation: { - id: string - owner: string - clientId?: string - connectionId?: string - state: 'starting' | 'active' | 'closing' - partialText: string - finalTexts: string[] - errors: string[] - } | null = null - - constructor( - store: RuntimeStore | null = null, - stats?: StatsCollector, - deps?: { - getLocalProvider?: () => IPtyProvider - getSshProvider?: (connectionId: string) => IPtyProvider | undefined - onPtyStopped?: (ptyId: string) => void - onTerminalAgentStatus?: (event: RuntimeTerminalAgentStatusEvent) => void - onTerminalSideEffects?: (batch: TerminalSideEffectBatch) => void - // Why: agent status mostly arrives via hooks (agent-hooks/server), not OSC - // terminal output. worktree.ps reads this at query time so mobile shows the - // same inline agent rows the desktop sidebar does — same source, 1:1. - getAgentStatusSnapshot?: () => AgentStatusIpcPayload[] - /** Same rows, but including the resume-identity-only ones `getAgentStatusSnapshot` - * filters out so they can't read as running agents. Mobile native chat needs - * them: for an agent that publishes identity separately (Pi), that row is the - * only carrier of the provider session a transcript is addressed by. */ - getAgentProviderSessionSnapshot?: () => AgentStatusIpcPayload[] - getAgentProviderSessionRowsForPane?: (paneKey: string) => AgentStatusIpcPayload[] - attestAgentHookCompatibilityAuthority?: (candidate: { - paneKey: string - launchTokenHash: string - connectionId: string | null - terminalProvenance: 'current_runtime' | 'restored' - }) => AgentHookAuthorityAttestation | null - retireAgentHookCompatibilityAuthority?: (paneKey: string) => void - reconcileAgentStatusForEndedProcess?: (paneKeys: Iterable) => void - canRecoverPersistentLocalPtys?: () => boolean - // Why: the device registry lives on the RPC server, which is constructed with this runtime; - // a closure defers the lookup past that ordering instead of inverting ownership. - getPairedDeviceName?: (pairedDeviceId: string) => string | null - // Why: codex-home paths for the Agent Session History scan must be sourced - // here, not via the window-only registerCoreHandlers path — that path never - // runs under `orca serve`, so remote/SSH hosts would silently drop - // managed-Codex sessions. The runtime ctor runs in BOTH window and serve. - getAdditionalAiVaultCodexHomePaths?: () => readonly string[] - prepareAiVaultSessionResume?: ( - args: AiVaultPrepareSessionResumeArgs - ) => Promise - prepareCodexStructuredLaunch?: (input: { - workspacePath: string - launchEnv: NodeJS.ProcessEnv - }) => string | null | Promise - buildAgentHookPtyEnv?: () => Record - getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus - agentSessionClaimSigner?: AgentSessionClaimSigner - orchestrationEnvironmentTransport?: OrchestrationEnvironmentTransport - skillTransactionRecovery?: Promise - } - ) { - this.store = store - // Why: per-device tab selections must survive host restarts, or every phone snaps back to the first tab on return. - const persistedClientTabSelections = store?.getMobileClientTabSelections?.() - if (persistedClientTabSelections) { - this.clientSessionTabSelections.hydrate(persistedClientTabSelections) - } - this.clientSessionTabSelections.setPersistListener((state) => { - this.store?.setMobileClientTabSelections?.(state) - }) - this.orchestrationEnvironmentTransport = deps?.orchestrationEnvironmentTransport ?? null - this.skillTransactionRecovery = (deps?.skillTransactionRecovery ?? Promise.resolve()).catch( - (error) => { - console.warn('[skills] startup transaction recovery failed:', error) - } - ) - if (stats) { - this.stats = stats - } - this.getAgentStatusSnapshotFn = deps?.getAgentStatusSnapshot ?? null - this.getAgentProviderSessionSnapshotFn = - deps?.getAgentProviderSessionSnapshot ?? deps?.getAgentStatusSnapshot ?? null - this.getAgentProviderSessionRowsForPaneFn = deps?.getAgentProviderSessionRowsForPane ?? null - this.attestAgentHookCompatibilityAuthorityFn = - deps?.attestAgentHookCompatibilityAuthority ?? null - this.retireAgentHookCompatibilityAuthorityFn = - deps?.retireAgentHookCompatibilityAuthority ?? null - this.reconcileAgentStatusForEndedProcessFn = deps?.reconcileAgentStatusForEndedProcess ?? null - this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true) - this.getPairedDeviceNameFn = deps?.getPairedDeviceName ?? (() => null) - // Why: configure the shared AiVault scan cache from a serve-mode-reachable - // seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions - // even on headless `orca serve` hosts where registerCoreHandlers never runs. - if (deps?.getAdditionalAiVaultCodexHomePaths) { - configureAiVaultSessionSources({ - getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths - }) - configureHostReadableTranscriptPathSources({ - getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths - }) - } - // Why: the daemon adapter is installed via `setLocalPtyProvider()` during - // attachMainWindowServices, AFTER this service is constructed. Capturing - // `getLocalPtyProvider()` at construction time would freeze a reference to - // the pre-daemon `LocalPtyProvider` and miss the routed adapter. Resolve - // lazily via thunk so teardown always sees the currently-installed - // provider (design §4.3 wire-up). - this.getLocalProviderFn = deps?.getLocalProvider ?? null - this.getSshProviderFn = deps?.getSshProvider ?? null - this.onPtyStopped = deps?.onPtyStopped ?? null - this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null - this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null - this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable') - this.prepareAiVaultSessionResumeFn = deps?.prepareAiVaultSessionResume ?? null - this.prepareCodexStructuredLaunchFn = deps?.prepareCodexStructuredLaunch ?? null - this.agentSessionClaimSigner = - deps?.agentSessionClaimSigner ?? createEphemeralAgentSessionClaimSigner(this.runtimeId) - this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null - // Why: the ConPTY spawn mark can land after daemon stream data already - // created this PTY's emulator; the mark retrofits the DA1 override here - // (terminal-query-authority.md §ConPTY DA1). - registerConptyDa1OverrideInstaller((ptyId) => this.ensureNativeWindowsConptyDa1Override(ptyId)) - // Why: a renderer attribute push must reach already-live emulators too — - // cursor options for DECRQSS/DECRQM parity plus the per-PTY OSC color - // override reset a theme apply implies (terminal-query-authority.md - // §View-attribute bridge). - registerTerminalViewAttributesApplier((attributes) => { - for (const state of this.headlessTerminals.values()) { - state.emulator.applyPushedViewAttributes(attributes) - } - }) - } - - /** - * Republishes persisted client-hosted pages as held rows, before any host can attach. - * - * Without this a runtime restart takes the only record of a client-hosted page with it. When the - * client restarted too -- a fleet update restarts both -- its guests died with it, so its - * inventory has nothing to adopt from and no participant can name the page any more. - * - * Called from each host's startup rather than the constructor so the ordering against attach is - * explicit, and so constructing a runtime stays free of persistence reads. - */ - rehydrateClientHostedBrowserPages(): void { - if (!this.store?.getWorkspaceSession) { - return - } - try { - const registry = getRuntimeBrowserPageRegistry(this) - const liveRepoIds = new Set((this.store.getRepos?.() ?? []).map((repo) => repo.id)) - rehydrateClientHostedBrowserPages(registry, { - listWorkspaceSessions: () => this.listWorkspaceSessionPartitions(), - // Why the same discriminant hydration uses: session keys are `${repoId}::${path}` and are - // not pruned when a repo leaves this client's view, so a row whose repo is gone would - // surface a tab with no live workspace behind it. Unparseable keys are left alone. - isKnownWorktree: (worktreeId) => { - const ownerRepoId = splitWorktreeIdForFilesystem(worktreeId)?.repoId - return !ownerRepoId || liveRepoIds.has(ownerRepoId) - } - }) - for (const page of registry.listPages()) { - this.persistedClientHostedBrowserWorktreeIds.add(page.workspaceId) - } - } catch (error) { - console.warn('[browser-host-lease] client page rehydration failed:', error) - } - } - - /** - * Rewrites one worktree's persisted client-hosted rows. - * - * Guarded because it hangs off the runtime's tab-change announcement, which also fires on - * terminal and editor churn: a workspace that has never had a client page must not pay a session - * read for every one of those. - */ - private persistClientHostedBrowserPagesForWorktree(worktreeId: string): void { - const registry = getRuntimeBrowserPageRegistry(this) - const hasPages = registry.listPages(worktreeId).length > 0 - if (!hasPages && !this.persistedClientHostedBrowserWorktreeIds.has(worktreeId)) { - return - } - if (hasPages) { - this.persistedClientHostedBrowserWorktreeIds.add(worktreeId) - } else { - this.persistedClientHostedBrowserWorktreeIds.delete(worktreeId) - } - persistClientHostedBrowserPages( - { - getWorkspaceSession: (id) => this.getWorkspaceSessionForWorktree(id), - setWorkspaceSession: (id, session) => this.setWorkspaceSessionForWorktree(id, session) - }, - registry, - worktreeId - ) - } - - private listWorkspaceSessionPartitions(): WorkspaceSessionState[] { - const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) - for (const repo of this.store?.getRepos?.() ?? []) { - hostIds.add(getRepoExecutionHostId(repo)) - } - return [...hostIds].flatMap((hostId) => { - const session = this.store?.getWorkspaceSession?.(hostId) - return session ? [session] : [] - }) - } - - getLocalProvider(): IPtyProvider | null { - return this.getLocalProviderFn ? this.getLocalProviderFn() : null - } - - private async stopPtysForDestructiveWorktreeRemoval( - worktreeId: string, - options: { connectionId?: string; allowUnverifiedStop?: boolean } = {} - ): Promise { - const { connectionId, allowUnverifiedStop } = options - const provider = connectionId ? this.getSshProviderFn?.(connectionId) : this.getLocalProvider() - if (!provider) { - throw new Error(`PTY provider unavailable for worktree deletion: ${worktreeId}`) - } - const teardownResult = await killAllProcessesForWorktree(worktreeId, { - runtime: this, - // Why: `repoId::path` ids repeat across hosts, so an unfenced sweep stops a same-id - // workspace's terminals on another connection (mirrors the IPC removal path). - resolvedWorktreeId: worktreeId, - ...(connectionId ? { resolvedConnectionId: connectionId } : {}), - localProvider: provider, - onPtyStopped: this.onPtyStopped ?? undefined, - requirePhysicalStop: true, - // Why (#11960): set only by an explicit Force Delete, never by the ordinary - // confirmation — otherwise the gate would be off on the primary delete path. - ...(allowUnverifiedStop ? { allowUnverifiedStop: true } : {}), - ...(connectionId ? { includeLocalRegistry: false } : {}) - }) - const total = - teardownResult.runtimeStopped + - teardownResult.providerStopped + - teardownResult.registryStopped - if (total > 0) { - console.info( - `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped}` - ) - } - } - - getStatsSummary(): StatsSummary | null { - return this.stats?.getSummary() ?? null - } - - getMemorySnapshot(): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - return collectMemorySnapshot(this.store) - } - - getUIState(): PersistedUIState { - if (!this.store?.getUI) { - throw new Error('runtime_unavailable') - } - return this.store.getUI() - } - - updateUIState(updates: Partial): PersistedUIState { - if (!this.store?.getUI || !this.store.updateUI) { - throw new Error('runtime_unavailable') - } - this.store.updateUI(updates) - return this.store.getUI() - } - - recordFeatureInteraction(id: FeatureInteractionId): PersistedUIState { - if (!this.store?.recordFeatureInteraction) { - throw new Error('runtime_unavailable') - } - return this.store.recordFeatureInteraction(id) - } - - getClientSettings(): Pick< - GlobalSettings, - | 'worktreeVisibilityDefaults' - | 'defaultTuiAgent' - | 'disabledTuiAgents' - | 'agentCmdOverrides' - | 'agentDefaultArgs' - | 'agentDefaultEnv' - | 'agentStatusHooksEnabled' - | 'defaultTaskSource' - | 'defaultTaskViewPreset' - | 'visibleTaskProviders' - | 'defaultRepoSelection' - | 'defaultLinearTeamSelection' - | 'githubProjects' - | 'experimentalNewWorktreeCardStyle' - | 'compactWorktreeCards' - | 'minimaxGroupId' - | 'minimaxUsageModels' - | 'prBotAuthorOverrides' - // Read-only on purpose: clients preflight the publish capability here, but SettingsUpdate - // still omits the key so no RPC caller can grant it to itself. - | 'artifactSharingEnabled' - | 'agentSkillSharingEnabled' - > { - if (!this.store?.getSettings) { - throw new Error('runtime_unavailable') - } - const settings = this.store.getSettings() - return { - worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' }, - defaultTuiAgent: settings.defaultTuiAgent ?? null, - disabledTuiAgents: settings.disabledTuiAgents ?? [], - agentCmdOverrides: settings.agentCmdOverrides ?? {}, - agentDefaultArgs: settings.agentDefaultArgs ?? {}, - agentDefaultEnv: settings.agentDefaultEnv ?? {}, - agentStatusHooksEnabled: settings.agentStatusHooksEnabled !== false, - defaultTaskSource: settings.defaultTaskSource ?? 'github', - defaultTaskViewPreset: settings.defaultTaskViewPreset ?? 'issues', - visibleTaskProviders: settings.visibleTaskProviders ?? [...TASK_PROVIDERS], - defaultRepoSelection: settings.defaultRepoSelection ?? null, - defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null, - githubProjects: settings.githubProjects, - experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true, - compactWorktreeCards: settings.compactWorktreeCards === true, - minimaxGroupId: settings.minimaxGroupId ?? '', - minimaxUsageModels: settings.minimaxUsageModels ?? 'general', - prBotAuthorOverrides: settings.prBotAuthorOverrides ?? [], - artifactSharingEnabled: isArtifactSharingEnabled(settings), - agentSkillSharingEnabled: isAgentSkillSharingEnabled(settings) - } - } - - private reconcileManagedAgentHooks(): Promise { - const generation = ++this.managedHookReconciliationGeneration - const reconciliation = this.managedHookReconciliationTail.then(async () => { - if (generation !== this.managedHookReconciliationGeneration) { - return - } - const settings = this.store?.getSettings() - if (!settings) { - return - } - await applyAgentStatusHooksEnabled(settings.agentStatusHooksEnabled !== false, settings, { - shouldHydrateShellPath: getAppEnvironment().isPackaged(), - onInstallError: recordManagedHookInstallFailure, - shouldContinue: (agent) => { - const current = this.store?.getSettings() - return ( - current !== undefined && - current.agentStatusHooksEnabled !== false && - !current.disabledTuiAgents?.includes(agent) - ) - } - }) - }) - this.managedHookReconciliationTail = reconciliation.catch(() => {}) - return reconciliation - } - - async updateClientSettings( - updates: Pick< - Partial, - | 'worktreeVisibilityDefaults' - | 'agentStatusHooksEnabled' - | 'defaultTuiAgent' - | 'disabledTuiAgents' - | 'agentDefaultArgs' - | 'agentDefaultEnv' - | 'defaultTaskSource' - | 'defaultTaskViewPreset' - | 'visibleTaskProviders' - | 'defaultRepoSelection' - | 'defaultLinearTeamSelection' - | 'githubProjects' - | 'experimentalNewWorktreeCardStyle' - | 'compactWorktreeCards' - | 'minimaxGroupId' - | 'minimaxUsageModels' - | 'prBotAuthorOverrides' - > - ): Promise< - Pick< - GlobalSettings, - | 'worktreeVisibilityDefaults' - | 'defaultTuiAgent' - | 'disabledTuiAgents' - | 'agentCmdOverrides' - | 'agentDefaultArgs' - | 'agentDefaultEnv' - | 'agentStatusHooksEnabled' - | 'defaultTaskSource' - | 'defaultTaskViewPreset' - | 'visibleTaskProviders' - | 'defaultRepoSelection' - | 'defaultLinearTeamSelection' - | 'githubProjects' - | 'experimentalNewWorktreeCardStyle' - | 'compactWorktreeCards' - | 'minimaxGroupId' - | 'minimaxUsageModels' - | 'prBotAuthorOverrides' - > - > { - if (!this.store?.getSettings || !this.store.updateSettings) { - throw new Error('runtime_unavailable') - } - const beforeSettings = this.store.getSettings() - const before = beforeSettings.agentStatusHooksEnabled !== false - this.store.updateSettings(updates, { notifyListeners: true }) - const settings = this.store.getSettings() - if (updates.worktreeVisibilityDefaults !== undefined) { - this.notifyReposChanged() - } - if ( - (typeof updates.agentStatusHooksEnabled === 'boolean' && - before !== updates.agentStatusHooksEnabled) || - (updates.disabledTuiAgents !== undefined && - !haveSameDisabledTuiAgents(beforeSettings.disabledTuiAgents, settings.disabledTuiAgents)) - ) { - await this.reconcileManagedAgentHooks() - } - return this.getClientSettings() - } - - getClientTerminalQuickCommands(): TerminalQuickCommand[] { - if (!this.store?.getSettings) { - throw new Error('runtime_unavailable') - } - return this.store.getSettings().terminalQuickCommands ?? [] - } - - updateClientTerminalQuickCommands( - mutation: TerminalQuickCommandMutation - ): TerminalQuickCommand[] { - if (!this.store?.getSettings || !this.store.updateSettings) { - throw new Error('runtime_unavailable') - } - const current = this.getClientTerminalQuickCommands() - if ( - mutation.type === 'upsert' && - !current.some((command) => command.id === mutation.command.id) && - current.length >= MAX_QUICK_COMMANDS - ) { - throw new Error('Quick command limit reached') - } - const next = applyTerminalQuickCommandMutation(current, mutation) - this.store.updateSettings({ terminalQuickCommands: next }, { notifyListeners: true }) - return this.getClientTerminalQuickCommands() - } - - updateClientPRBotAuthorOverride(args: { author: string; isBot: boolean }) { - if (!this.store?.getSettings || !this.store.updateSettings) { - throw new Error('runtime_unavailable') - } - const current = this.store.getSettings().prBotAuthorOverrides - this.store.updateSettings( - { prBotAuthorOverrides: applyPRBotAuthorOverride(current, args.author, args.isBot) }, - { notifyListeners: true } - ) - return this.getClientSettings() - } - - listAutomations(): Automation[] { - if (!this.store?.listAutomations) { - throw new Error('runtime_unavailable') - } - return this.store.listAutomations() - } - - // Why: Orca's own automation work holds the desktop probe scheduler's - // priority lease so queued external probes stay parked behind it; runtime - // servers install no lease and run directly. - private underExternalProbePriority(run: () => T): T { - const wrap = this.automationService?.externalProbePriority - return wrap ? wrap(run) : run() - } - - listAutomationsForScope(params: AutomationListParams): AutomationListResult { - const store = this.store - if (!store?.listAutomationsForScope) { - throw new Error('runtime_unavailable') - } - return this.underExternalProbePriority(() => store.listAutomationsForScope!(params)) - } - - // Why: a supplied precondition must never degrade to unfenced work, so a store that cannot check it fails the call. - private fenceAutomationOwner( - id: string, - expectedOwner: AutomationOwnerPrecondition | undefined, - operation: AutomationOwnerFenceOperation - ): void { - if (!this.store?.assertAutomationOwnerFence) { - if (expectedOwner) { - throw new Error('runtime_unavailable') - } - return - } - this.store.assertAutomationOwnerFence({ id, expectedOwner, operation }) - } - - listAutomationRuns( - automationId?: string, - expectedOwner?: AutomationOwnerPrecondition - ): AutomationRun[] { - const store = this.store - if (!store?.listAutomationRuns) { - throw new Error('runtime_unavailable') - } - if (expectedOwner && !automationId) { - throw new Error('An expected owner requires an automation id.') - } - return this.underExternalProbePriority(() => { - if (automationId) { - this.fenceAutomationOwner(automationId, expectedOwner, 'read') - } - return store.listAutomationRuns!(automationId) - }) - } - - /** Null when the store predates the projection: the caller then sends no precondition. */ - automationOwnerPrecondition(id: string): AutomationOwnerPrecondition | null { - return this.store?.automationOwnerPrecondition?.(id) ?? null - } - - showAutomation(id: string, expectedOwner?: AutomationOwnerPrecondition): Automation { - const automation = this.listAutomations().find((entry) => entry.id === id) - if (!automation) { - throw new Error('Automation not found.') - } - this.fenceAutomationOwner(id, expectedOwner, 'read') - return automation - } - - async createAutomation(input: RuntimeAutomationCreateInput): Promise { - if (!this.store?.createAutomation) { - throw new Error('runtime_unavailable') - } - const target = await this.resolveAutomationTarget(input) - assertAutomationRunContextMatchesRepo(input.runContext, target.repo) - if (input.reuseSession && target.workspaceMode !== 'existing') { - throw new Error('Session reuse requires an existing workspace target.') - } - const createInput: AutomationCreateInput = { - creationKey: input.creationKey, - name: input.name, - prompt: input.prompt, - precheck: input.precheck, - agentId: input.agentId, - runContext: input.runContext, - sourceContext: input.sourceContext, - projectId: target.projectId, - workspaceMode: target.workspaceMode, - workspaceId: target.workspaceId, - baseBranch: input.baseBranch, - setupDecision: input.setupDecision, - reuseSession: input.reuseSession, - timezone: input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone, - rrule: input.rrule, - dtstart: input.dtstart, - enabled: input.enabled, - missedRunGraceMinutes: input.missedRunGraceMinutes - } - return this.underExternalProbePriority(() => { - const created = this.store!.createAutomation!( - createInput, - input.destination ? { destination: input.destination } : undefined - ) - const selector = this.automationChangeSelector(created.id) - this.publishAutomationDefinitionChange(selector, selector) - return created - }) - } - - private automationChangeSelector(id: string): AutomationChangeSelector | null { - return this.store?.automationChangeSelector?.(id) ?? null - } - - /** A store that cannot name the affected host degrades to one unscoped authority event. */ - private publishAutomationDefinitionChange( - before: AutomationChangeSelector | null, - after: AutomationChangeSelector | null - ): void { - for (const selector of automationChangePublications(before, after)) { - this.notifyAutomationsChanged({ reason: 'definition', ...(selector ? { selector } : {}) }) - } - } - - async updateAutomation( - id: string, - updates: RuntimeAutomationUpdateInput, - options?: { expectedOwner?: AutomationOwnerPrecondition; destination?: AutomationDestination } - ): Promise { - if (!this.store?.updateAutomation) { - throw new Error('runtime_unavailable') - } - const current = this.showAutomation(id) - const patch: AutomationUpdateInput = {} - if (hasRuntimeAutomationUpdateValue(updates, 'name')) { - patch.name = updates.name - } - if (hasRuntimeAutomationUpdateValue(updates, 'prompt')) { - patch.prompt = updates.prompt - } - if (hasRuntimeAutomationUpdateValue(updates, 'precheck')) { - patch.precheck = updates.precheck - } - if (hasRuntimeAutomationUpdateValue(updates, 'agentId')) { - patch.agentId = updates.agentId - } - if (hasRuntimeAutomationUpdateValue(updates, 'runContext')) { - patch.runContext = updates.runContext - } - if (hasRuntimeAutomationUpdateValue(updates, 'sourceContext')) { - patch.sourceContext = updates.sourceContext - } - if (hasRuntimeAutomationUpdateValue(updates, 'baseBranch')) { - patch.baseBranch = updates.baseBranch - } - if (hasRuntimeAutomationUpdateValue(updates, 'setupDecision')) { - patch.setupDecision = updates.setupDecision - } - if (hasRuntimeAutomationUpdateValue(updates, 'reuseSession')) { - patch.reuseSession = updates.reuseSession - } - if (hasRuntimeAutomationUpdateValue(updates, 'timezone')) { - patch.timezone = updates.timezone - } - if (hasRuntimeAutomationUpdateValue(updates, 'rrule')) { - patch.rrule = updates.rrule - } - if (hasRuntimeAutomationUpdateValue(updates, 'dtstart')) { - patch.dtstart = updates.dtstart - } - if (hasRuntimeAutomationUpdateValue(updates, 'enabled')) { - patch.enabled = updates.enabled - } - if (hasRuntimeAutomationUpdateValue(updates, 'missedRunGraceMinutes')) { - patch.missedRunGraceMinutes = updates.missedRunGraceMinutes - } - const targetChanged = - hasRuntimeAutomationUpdateValue(updates, 'repo') || - hasRuntimeAutomationUpdateValue(updates, 'workspace') || - hasRuntimeAutomationUpdateValue(updates, 'workspaceMode') - if (targetChanged) { - const target = await this.resolveAutomationTarget(updates, current) - assertAutomationRunContextMatchesRepo(updates.runContext, target.repo) - if (patch.reuseSession === true && target.workspaceMode !== 'existing') { - throw new Error('Session reuse requires an existing workspace target.') - } - patch.projectId = target.projectId - patch.workspaceMode = target.workspaceMode - patch.workspaceId = target.workspaceId - if (target.workspaceMode !== 'existing') { - patch.reuseSession = false - } - } else if (hasRuntimeAutomationUpdateValue(updates, 'runContext') && current.projectId) { - const currentRepo = await this.showRepo(`id:${current.projectId}`) - assertAutomationRunContextMatchesRepo(updates.runContext, currentRepo) - } - if (!targetChanged && patch.reuseSession && current.workspaceMode !== 'existing') { - throw new Error('Session reuse requires an existing workspace target.') - } - return this.underExternalProbePriority(() => { - // Captured first: an update may move the record to another host. - const before = this.automationChangeSelector(id) - const updated = this.store!.updateAutomation!(id, patch, options) - this.publishAutomationDefinitionChange(before, this.automationChangeSelector(id)) - return updated - }) - } - - deleteAutomation( - id: string, - expectedOwner?: AutomationOwnerPrecondition - ): { removed: boolean; id: string } { - if (!this.store?.deleteAutomation) { - throw new Error('runtime_unavailable') - } - return this.underExternalProbePriority(() => { - this.showAutomation(id) - const before = this.automationChangeSelector(id) - this.store!.deleteAutomation!(id, expectedOwner ? { expectedOwner } : undefined) - this.publishAutomationDefinitionChange(before, before) - return { removed: true, id } - }) - } - - async runAutomationNow( - id: string, - expectedOwner?: AutomationOwnerPrecondition - ): Promise { - const service = this.automationService - if (!service) { - throw new Error('runtime_unavailable') - } - // Why: an orphan or re-registered host must be refused before dispatch, not - // after a session starts. The lease spans the whole dispatch promise, so - // queued external probes stay parked until the run the user is waiting on settles. - return await this.underExternalProbePriority(() => - runAutomationNowFenced({ - fence: () => this.fenceAutomationOwner(id, expectedOwner, 'execute'), - service, - automationId: id - }) - ) - } - - private async resolveAutomationTarget( - input: { - repo?: string - workspace?: string - workspaceMode?: AutomationWorkspaceMode - baseBranch?: string | null - }, - current?: Automation - ): Promise<{ - projectId: string - workspaceMode: AutomationWorkspaceMode - workspaceId?: string | null - repo: Repo | null - }> { - const hasRepo = input.repo !== undefined - const hasWorkspace = input.workspace !== undefined - if ( - current?.workspaceMode === 'existing' && - hasRepo && - !hasWorkspace && - input.workspaceMode !== 'new_per_run' - ) { - throw new Error( - 'Repo updates for existing-workspace automation require workspaceMode new_per_run.' - ) - } - const workspace = input.workspace ? await this.showManagedWorktree(input.workspace) : null - const repoSelector = - input.repo ?? - (workspace?.repoId - ? `id:${workspace.repoId}` - : current?.projectId - ? `id:${current.projectId}` - : null) - const repo = repoSelector ? await this.showRepo(repoSelector) : null - const workspaceMode = - input.workspaceMode ?? - (workspace - ? 'existing' - : input.repo && !current - ? 'new_per_run' - : (current?.workspaceMode ?? 'new_per_run')) - if (workspaceMode === 'existing') { - const workspaceId = workspace?.id ?? current?.workspaceId - const projectId = workspace?.repoId ?? current?.projectId - if (repo && repo.id !== projectId) { - throw new Error('Selected workspace belongs to a different repo.') - } - if (!workspaceId || !projectId) { - throw new Error('Existing-workspace automation requires --workspace.') - } - return { projectId, workspaceMode, workspaceId, repo } - } - const projectId = repo?.id ?? workspace?.repoId ?? current?.projectId - if (!projectId) { - throw new Error('Automation requires --repo or --workspace.') - } - return { projectId, workspaceMode: 'new_per_run', workspaceId: null, repo } - } - - // Why: lazy initialization — the DB path depends on userData, which on the desktop - // is not finalized until after app.ready. Also allows unit tests to inject an - // in-memory DB without touching the filesystem. - getOrchestrationDb(): OrchestrationDb { - if (!this._orchestrationDb) { - const dbPath = join(getAppEnvironment().getPath('userData'), 'orchestration.db') - this._orchestrationDb = new OrchestrationDb(dbPath) - this.ensureOrchestrationFederationRelay() - this.scheduleRestoredMessageRepoints() - } - return this._orchestrationDb - } - - setOrchestrationDb(db: OrchestrationDb): void { - this.stopOrchestrationFederationRelay() - this.mailPointerRepointScheduler.clear() - this._orchestrationDb = db - this.ensureOrchestrationFederationRelay() - this.scheduleRestoredMessageRepoints() - } - - private getLegacyWorkerTerminalRecoveryPlan(): LegacyWorkerTerminalRecoveryPlan { - try { - return planLegacyWorkerTerminalRecovery( - this.getOrchestrationDb().listLegacyWorkerTerminalRecoveryRows() - ) - } catch (error) { - console.warn('[orchestration] failed to plan legacy worker terminal recovery', error) - return { blockedPanes: [], candidates: [], ambiguousDispatchIds: [] } - } - } - - prepareLegacyWorkerTerminalRecovery(): LegacyWorkerTerminalRecoveryPlan { - const plan = this.getLegacyWorkerTerminalRecoveryPlan() - const store = this.store - if ( - !store?.getWorkspaceSession || - !store.setWorkspaceSession || - (!store.flushPendingOrThrowAsync && !store.flushOrThrow) - ) { - return plan - } - const sessions = new Map< - ExecutionHostId, - { current: WorkspaceSessionState; next: WorkspaceSessionState } - >() - const changedHostIds = new Set() - for (const blocked of plan.blockedPanes) { - let hostIds: ExecutionHostId[] - try { - hostIds = [this.getWorkspaceSessionHostIdForWorktree(blocked.worktreeId)] - } catch (error) { - console.warn('[orchestration] legacy worker resume fence owner is unavailable', { - worktreeId: blocked.worktreeId, - error - }) - hostIds = store.getWorkspaceSessionHostIds?.() ?? [LOCAL_EXECUTION_HOST_ID] - } - for (const hostId of hostIds) { - let state = sessions.get(hostId) - if (!state) { - const current = store.getWorkspaceSession(hostId) - if (!current) { - continue - } - state = { current, next: structuredClone(current) } - sessions.set(hostId, state) - } - const record = state.next.sleepingAgentSessionsByPaneKey?.[blocked.paneKey] - if ( - !record || - !worktreeIdsEqual(record.worktreeId, blocked.worktreeId) || - record.automaticResumeBlockedBy === 'legacy-orchestration-worker' - ) { - continue - } - state.next.sleepingAgentSessionsByPaneKey = { - ...state.next.sleepingAgentSessionsByPaneKey, - [blocked.paneKey]: { - ...record, - automaticResumeBlockedBy: 'legacy-orchestration-worker' - } - } - changedHostIds.add(hostId) - } - } - const changed = [...sessions].filter(([hostId]) => changedHostIds.has(hostId)) - if (changed.length === 0) { - return plan - } - try { - for (const [hostId, state] of changed) { - store.setWorkspaceSession(state.next, hostId) - } - } catch (error) { - console.warn('[orchestration] failed to stage legacy worker resume fence', error) - } - return plan - } - - private async flushWorkspaceSessionOrThrowAsync(): Promise { - const store = this.store - if (store?.flushPendingOrThrowAsync) { - await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - return - } - if (store?.flushOrThrow) { - store.flushOrThrow() - return - } - throw new Error('workspace_session_persistence_unavailable') - } - - async reconcileLegacyWorkerTerminals( - options: { connectionId?: string; materializeRenderer?: boolean } = {} - ): Promise { - let resolveResult!: (result: LegacyWorkerTerminalRecoveryResult) => void - let rejectResult!: (error: unknown) => void - const result = new Promise((resolve, reject) => { - resolveResult = resolve - rejectResult = reject - }) - const run = this.legacyWorkerTerminalRecoveryQueue.then(async () => { - try { - resolveResult(await this.reconcileLegacyWorkerTerminalsNow(options)) - } catch (error) { - rejectResult(error) - } - }) - this.legacyWorkerTerminalRecoveryQueue = run.catch(() => undefined) - return result - } - - async refreshRestoredOrchestrationAuthority(connectionId: string | null = null): Promise { - if (connectionId === null && !this.canRecoverPersistentLocalPtysFn()) { - return - } - const inventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( - [...(await this.getResolvedWorktreeMap()).values()], - null, - undefined, - connectionId - ) - if (!inventory) { - throw new Error('terminal_liveness_unavailable') - } - } - - private hasExactTerminalSurfaceIdentity(expected: { - worktreeId: string - tabId: string - leafId: string - ptyId: string - terminalHandle: string - incarnationId: string - }): boolean { - if (this.graphStatus !== 'ready') { - return false - } - const pty = this.ptysById.get(expected.ptyId) - if ( - !pty?.connected || - pty.incarnationId !== expected.incarnationId || - pty.tabId !== expected.tabId || - pty.paneKey !== makePaneKey(expected.tabId, expected.leafId) || - !worktreeIdsEqual(pty.worktreeId, expected.worktreeId) || - this.handleByPtyId.get(expected.ptyId) !== expected.terminalHandle - ) { - return false - } - const tab = this.tabs.get(expected.tabId) - const leaf = this.leaves.get(this.getLeafKey(expected.tabId, expected.leafId)) - const ptyLeaves = this.getLeavesForPty(expected.ptyId) - return ( - Boolean(tab && worktreeIdsEqual(tab.worktreeId, expected.worktreeId)) && - Boolean( - leaf && - leaf.ptyId === expected.ptyId && - worktreeIdsEqual(leaf.worktreeId, expected.worktreeId) - ) && - ptyLeaves.length === 1 && - ptyLeaves[0]?.tabId === expected.tabId && - ptyLeaves[0]?.leafId === expected.leafId - ) - } - - private hasExactPersistedTerminalSurfaceIdentity(expected: { - worktreeId: string - tabId: string - leafId: string - ptyId: string - incarnationId: string - }): boolean { - const session = this.getWorkspaceSessionForWorktree(expected.worktreeId) - const sessionWorktreeId = session - ? resolveTerminalSessionWorktreeId(session, expected.worktreeId) - : null - if (!session || !sessionWorktreeId) { - return false - } - const tab = session.tabsByWorktree[sessionWorktreeId]?.find( - (candidate) => candidate.id === expected.tabId - ) - const paneKey = makePaneKey(expected.tabId, expected.leafId) - return Boolean( - tab && - session.terminalLayoutsByTabId[expected.tabId]?.ptyIdsByLeafId?.[expected.leafId] === - expected.ptyId && - session.terminalPtyIncarnationsByPaneKey?.[paneKey] === expected.incarnationId - ) - } - - private async persistLegacyWorkerTerminalRecoveryBatch( - resolutions: readonly LegacyWorkerTerminalRecoveryResolution[] - ): Promise> { - const store = this.store - if ( - !store?.getWorkspaceSession || - !store.setWorkspaceSession || - (!store.flushPendingOrThrowAsync && !store.flushOrThrow) - ) { - return new Set() - } - const originalSessions = new Map() - const stagedSessions = new Map() - const stagedDispatchIds = new Set() - try { - for (const { candidate, resolution } of resolutions) { - const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(candidate.worktreeId) - const session = hostId ? store.getWorkspaceSession(hostId) : null - if (!hostId || !session) { - continue - } - originalSessions.set(hostId, originalSessions.get(hostId) ?? session) - let next = - resolution === 'exited' - ? retireTerminalSurfaceFromPersistence(session, { - worktreeId: candidate.worktreeId, - parentTabId: candidate.tabId, - leafId: candidate.leafId, - ptyId: candidate.ptyId, - incarnationId: candidate.incarnationId - }) - : session - const record = next.sleepingAgentSessionsByPaneKey?.[candidate.paneKey] - if (record && worktreeIdsEqual(record.worktreeId, candidate.worktreeId)) { - const sleepingAgentSessionsByPaneKey = { ...next.sleepingAgentSessionsByPaneKey } - delete sleepingAgentSessionsByPaneKey[candidate.paneKey] - next = { ...next, sleepingAgentSessionsByPaneKey } - } - if (next !== session) { - store.setWorkspaceSession(next, hostId) - } - stagedSessions.set(hostId, store.getWorkspaceSession(hostId)) - stagedDispatchIds.add(candidate.dispatchId) - } - if (stagedDispatchIds.size > 0) { - await this.flushWorkspaceSessionOrThrowAsync() - } - return stagedDispatchIds - } catch (error) { - for (const [hostId, original] of originalSessions) { - const staged = stagedSessions.get(hostId) - const current = store.getWorkspaceSession(hostId) - if (!staged || !current) { - continue - } - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(original, staged, current) - if (rolledBack !== current) { - store.setWorkspaceSession(rolledBack, hostId) - } - } - console.warn('[orchestration] failed to persist legacy worker recovery batch', { - dispatchIds: [...stagedDispatchIds], - error - }) - return new Set() - } - } - - private reconcileMissingLegacyWorkerTerminal( - candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] - ): boolean { - if (candidate.dispatchStatus !== 'pending' && candidate.dispatchStatus !== 'dispatched') { - return true - } - try { - this.getOrchestrationDb().reconcileMissingWorkerTerminal( - candidate.dispatchId, - 'The assigned worker terminal is no longer live after orchestration recovery.' - ) - return true - } catch (error) { - console.warn('[orchestration] failed to reconcile missing worker terminal', { - dispatchId: candidate.dispatchId, - error - }) - return false - } - } - - private rollbackLegacyWorkerTerminalSurface( - candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] - ): void { - const snapshot = this.mobileSessionTabsByWorktree.get(candidate.worktreeId) - if (snapshot) { - const retired = retireTerminalSurfacesFromSnapshot({ - snapshot, - ptyId: candidate.ptyId, - exactSurfaces: [{ parentTabId: candidate.tabId, leafId: candidate.leafId }], - exactOnly: true - }) - if (retired) { - this.mobileSessionTabsByWorktree.set(candidate.worktreeId, retired.snapshot) - this.notifyMobileSessionTabsChanged(candidate.worktreeId) - } - } - - const leafKey = this.getLeafKey(candidate.tabId, candidate.leafId) - const leaf = this.leaves.get(leafKey) - const pty = this.ptysById.get(candidate.ptyId) - if ( - leaf?.ptyId === candidate.ptyId && - worktreeIdsEqual(leaf.worktreeId, candidate.worktreeId) - ) { - this.leaves.delete(leafKey) - const surfaceHandle = this.handleByLeafKey.get(leafKey) - this.handleByLeafKey.delete(leafKey) - const handleRecord = surfaceHandle ? this.handles.get(surfaceHandle) : undefined - if ( - surfaceHandle && - handleRecord?.tabId === candidate.tabId && - handleRecord.leafId === candidate.leafId && - handleRecord.ptyId === candidate.ptyId - ) { - this.handles.delete(surfaceHandle) - } - this.rebuildLeafPtyIndex() - if (![...this.leaves.values()].some((entry) => entry.tabId === candidate.tabId)) { - this.tabs.delete(candidate.tabId) - } - } - if (pty?.tabId === candidate.tabId) { - pty.tabId = null - pty.paneKey = null - } - this.notifier?.resolveLegacyWorkerTerminalRecovery?.( - candidate.paneKey, - 'rolled_back', - candidate.ptyId - ) - } - - private updateLegacyWorkerTerminalRecoveryRetry( - plan: LegacyWorkerTerminalRecoveryPlan, - deferredDispatchIds: ReadonlySet, - options: { connectionId?: string; materializeRenderer?: boolean } - ): void { - const scopeKey = options.connectionId ? `ssh:${options.connectionId}` : 'local' - const hasDeferredWorker = plan.candidates.some((candidate) => { - const sshPty = parseAppSshPtyId(candidate.ptyId) - const inScope = options.connectionId - ? sshPty?.connectionId === options.connectionId - : sshPty === null - return inScope && deferredDispatchIds.has(candidate.dispatchId) - }) - if (!hasDeferredWorker) { - this.cancelLegacyWorkerTerminalRecoveryRetry(scopeKey) - return - } - const existing = this.legacyWorkerTerminalRecoveryRetries.get(scopeKey) - const retry = existing ?? { - attempt: 0, - ...(options.connectionId ? { connectionId: options.connectionId } : {}), - materializeRenderer: options.materializeRenderer === true, - timer: null - } - retry.materializeRenderer ||= options.materializeRenderer === true - this.legacyWorkerTerminalRecoveryRetries.set(scopeKey, retry) - this.armLegacyWorkerTerminalRecoveryRetry(scopeKey, retry) - } - - private cancelLegacyWorkerTerminalRecoveryRetry(scopeKey: string): void { - const retry = this.legacyWorkerTerminalRecoveryRetries.get(scopeKey) - if (retry?.timer) { - clearTimeout(retry.timer) - } - this.legacyWorkerTerminalRecoveryRetries.delete(scopeKey) - } - - private armLegacyWorkerTerminalRecoveryRetry( - scopeKey: string, - retry: { - attempt: number - connectionId?: string - materializeRenderer: boolean - timer: ReturnType | null - } - ): void { - if (retry.timer) { - return - } - const delayMs = Math.min(1_000 * 2 ** retry.attempt, 30_000) - retry.attempt += 1 - retry.timer = setTimeout(() => { - retry.timer = null - void this.reconcileLegacyWorkerTerminals({ - ...(retry.connectionId ? { connectionId: retry.connectionId } : {}), - materializeRenderer: retry.materializeRenderer - }).catch((error) => { - console.warn('[orchestration] worker terminal recovery retry failed', { - scope: scopeKey, - error - }) - if (this.legacyWorkerTerminalRecoveryRetries.get(scopeKey) === retry) { - this.armLegacyWorkerTerminalRecoveryRetry(scopeKey, retry) - } - }) - }, delayMs) - retry.timer.unref?.() - } - - private async reconcileLegacyWorkerTerminalsNow(options: { - connectionId?: string - materializeRenderer?: boolean - }): Promise { - const plan = this.prepareLegacyWorkerTerminalRecovery() - const adoptedDispatchIds: string[] = [] - const exitedDispatchIds: string[] = [] - const deferredDispatchIds = new Set(plan.ambiguousDispatchIds) - const pendingResolutions: LegacyWorkerTerminalRecoveryResolution[] = [] - const recoveryCandidatesByProvider = new Map< - string, - { - connectionId: string | null - entries: { - candidate: (typeof plan.candidates)[number] - workspace: TerminalWorkspaceLaunchScope - resolvedWorkspace: ResolvedWorktree - }[] - } - >() - for (const candidate of plan.candidates) { - try { - const workspace = await this.resolveTerminalWorkspaceLaunchScope( - `id:${candidate.worktreeId}` - ) - const sshPty = parseAppSshPtyId(candidate.ptyId) - if (workspace.connectionId) { - if ( - options.connectionId !== workspace.connectionId || - sshPty?.connectionId !== workspace.connectionId - ) { - deferredDispatchIds.add(candidate.dispatchId) - continue - } - } else if ( - options.connectionId !== undefined || - sshPty !== null || - !this.canRecoverPersistentLocalPtysFn() - ) { - deferredDispatchIds.add(candidate.dispatchId) - continue - } - const resolvedWorkspace = workspace.folderWorkspace - ? this.folderWorkspaceToResolvedWorktree(workspace.folderWorkspace) - : await this.resolveWorktreeSelector(`id:${workspace.id}`) - const connectionId = workspace.connectionId ?? null - const providerKey = connectionId === null ? 'local' : `ssh:${connectionId}` - const provider = recoveryCandidatesByProvider.get(providerKey) ?? { - connectionId, - entries: [] - } - provider.entries.push({ candidate, workspace, resolvedWorkspace }) - recoveryCandidatesByProvider.set(providerKey, provider) - } catch { - deferredDispatchIds.add(candidate.dispatchId) - } - } - for (const provider of recoveryCandidatesByProvider.values()) { - const resolvedWorktrees = [ - ...new Map( - provider.entries.map(({ resolvedWorkspace }) => [resolvedWorkspace.id, resolvedWorkspace]) - ).values() - ] - const inventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( - resolvedWorktrees, - null, - undefined, - provider.connectionId - ) - if (!inventory) { - provider.entries.forEach(({ candidate }) => deferredDispatchIds.add(candidate.dispatchId)) - continue - } - for (const { candidate, workspace } of provider.entries) { - if (!inventory.livePtyIds.has(candidate.ptyId)) { - pendingResolutions.push({ candidate, resolution: 'exited' }) - continue - } - const controllerIdentity = inventory.terminalIdentityByPtyId.get(candidate.ptyId) - if (!controllerIdentity) { - deferredDispatchIds.add(candidate.dispatchId) - continue - } - if ( - controllerIdentity.handle !== candidate.terminalHandle || - controllerIdentity.incarnationId !== candidate.incarnationId - ) { - pendingResolutions.push({ candidate, resolution: 'exited' }) - continue - } - let adoptionStatus: 'ready' | 'unverifiable' | 'exited' - try { - adoptionStatus = await this.runWorktreeTerminalMutation( - candidate.worktreeId, - async () => { - const preAdoptionInventory = - await this.refreshPtyWorktreeRecordsWithControllerInventory( - resolvedWorktrees, - null, - undefined, - provider.connectionId - ) - if (!preAdoptionInventory) { - return 'unverifiable' - } - if (!preAdoptionInventory.livePtyIds.has(candidate.ptyId)) { - return 'exited' - } - const preAdoptionIdentity = preAdoptionInventory.terminalIdentityByPtyId.get( - candidate.ptyId - ) - if (!preAdoptionIdentity) { - return 'unverifiable' - } - if ( - preAdoptionIdentity.handle !== candidate.terminalHandle || - preAdoptionIdentity.incarnationId !== candidate.incarnationId - ) { - return 'exited' - } - const session = this.getWorkspaceSessionForWorktree(candidate.worktreeId) - const sessionWorktreeId = session - ? resolveTerminalSessionWorktreeId(session, candidate.worktreeId) - : null - const activeTabId = sessionWorktreeId - ? session?.activeTabIdByWorktree?.[sessionWorktreeId] - : undefined - const activeGroupId = sessionWorktreeId - ? session?.activeGroupIdByWorktree?.[sessionWorktreeId] - : undefined - const exactSurfaceAlreadyPublished = - this.hasExactPersistedTerminalSurfaceIdentity(candidate) && - this.hasExactTerminalSurfaceIdentity(candidate) - if (!exactSurfaceAlreadyPublished) { - await this.adoptTerminalOrphansFromInventoryUnderMutation( - { - worktree: `id:${candidate.worktreeId}`, - expectedTopologyRevision: this.getTerminalTopologyRevision( - candidate.worktreeId - ), - ...(activeTabId ? { activeTabId } : {}), - ...(activeGroupId ? { activeGroupId } : {}), - claims: [ - { - terminal: candidate.terminalHandle, - ptyId: candidate.ptyId, - incarnationId: candidate.incarnationId, - tabId: candidate.tabId, - leafId: candidate.leafId - } - ] - }, - workspace, - preAdoptionInventory - ) - } - return 'ready' - } - ) - } catch (error) { - console.warn('[orchestration] legacy worker terminal adoption deferred', { - dispatchId: candidate.dispatchId, - error - }) - deferredDispatchIds.add(candidate.dispatchId) - continue - } - if (adoptionStatus === 'unverifiable') { - deferredDispatchIds.add(candidate.dispatchId) - continue - } - if (adoptionStatus === 'exited') { - pendingResolutions.push({ candidate, resolution: 'exited' }) - continue - } - let rendererMaterialized = - options.materializeRenderer !== true || - this.legacyWorkerTerminalReceiptEpochByPane.get(candidate.paneKey) === - this.rendererGraphEpoch - const pty = this.ptysById.get(candidate.ptyId) - if ( - options.materializeRenderer && - !rendererMaterialized && - pty && - this.notifier?.revealTerminalSession - ) { - for (let attempt = 0; attempt < 2 && !rendererMaterialized; attempt += 1) { - try { - const reveal = await this.notifier.revealTerminalSession(candidate.worktreeId, { - ptyId: candidate.ptyId, - title: getLatestPtyTitle(pty) ?? pty.controllerTitle, - activate: false, - presentation: 'background', - tabId: candidate.tabId, - leafId: candidate.leafId, - focus: false, - expectedProcessIdentity: { - terminalHandle: candidate.terminalHandle, - incarnationId: candidate.incarnationId - } - }) - const identity = reveal?.identity - if ( - !identity || - !worktreeIdsEqual(identity.worktreeId, candidate.worktreeId) || - identity.tabId !== candidate.tabId || - identity.leafId !== candidate.leafId || - identity.ptyId !== candidate.ptyId - ) { - throw new Error('terminal_reveal_identity_mismatch') - } - rendererMaterialized = true - this.legacyWorkerTerminalReceiptEpochByPane.set( - candidate.paneKey, - this.rendererGraphEpoch - ) - } catch (error) { - if (attempt === 0) { - await new Promise((resolve) => setTimeout(resolve, 100)) - continue - } - console.warn('[orchestration] adopted legacy worker was not revealed', { - dispatchId: candidate.dispatchId, - error - }) - } - } - } - if (!rendererMaterialized) { - this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey) - deferredDispatchIds.add(candidate.dispatchId) - continue - } - if ( - options.materializeRenderer === true && - !this.hasExactTerminalSurfaceIdentity({ - worktreeId: candidate.worktreeId, - tabId: candidate.tabId, - leafId: candidate.leafId, - ptyId: candidate.ptyId, - terminalHandle: candidate.terminalHandle, - incarnationId: candidate.incarnationId - }) - ) { - deferredDispatchIds.add(candidate.dispatchId) - continue - } - const finalInventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( - resolvedWorktrees, - null, - undefined, - provider.connectionId - ) - if (!finalInventory) { - deferredDispatchIds.add(candidate.dispatchId) - continue - } - if (!finalInventory.livePtyIds.has(candidate.ptyId)) { - this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey) - this.onPtyExit(candidate.ptyId, 0, candidate.incarnationId) - pendingResolutions.push({ candidate, resolution: 'exited' }) - continue - } - const finalIdentity = finalInventory.terminalIdentityByPtyId.get(candidate.ptyId) - if (!finalIdentity) { - this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey) - deferredDispatchIds.add(candidate.dispatchId) - continue - } - if ( - finalIdentity.handle !== candidate.terminalHandle || - finalIdentity.incarnationId !== candidate.incarnationId - ) { - this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey) - pendingResolutions.push({ candidate, resolution: 'exited' }) - continue - } - pendingResolutions.push({ candidate, resolution: 'adopted' }) - } - } - const persistedDispatchIds = - await this.persistLegacyWorkerTerminalRecoveryBatch(pendingResolutions) - for (const { candidate, resolution } of pendingResolutions) { - if (!persistedDispatchIds.has(candidate.dispatchId)) { - deferredDispatchIds.add(candidate.dispatchId) - continue - } - if (resolution === 'adopted') { - this.legacyWorkerRecoveredPtys.add(candidate.ptyId) - this.notifier?.resolveLegacyWorkerTerminalRecovery?.(candidate.paneKey, 'adopted') - adoptedDispatchIds.push(candidate.dispatchId) - continue - } - this.rollbackLegacyWorkerTerminalSurface(candidate) - if (!this.reconcileMissingLegacyWorkerTerminal(candidate)) { - deferredDispatchIds.add(candidate.dispatchId) - continue - } - this.notifier?.resolveLegacyWorkerTerminalRecovery?.(candidate.paneKey, 'exited') - exitedDispatchIds.push(candidate.dispatchId) - } - const result = { - blockedPaneCount: plan.blockedPanes.length, - adoptedDispatchIds, - exitedDispatchIds, - deferredDispatchIds: [...deferredDispatchIds] - } - this.updateLegacyWorkerTerminalRecoveryRetry(plan, deferredDispatchIds, options) - // Why: previously requested releases may only finish after the owning provider's terminals - // are rediscovered; this pass runs per scope (local and each reconnected provider). - void reconcileRequestedWorkerTerminalReleases(this).catch((error) => { - console.warn('[orchestration] worker terminal release reconciliation failed', { error }) - }) - return result - } - - setAutomationService(service: AutomationService): void { - this.automationService = service - } - - setArtifactService(service: ArtifactCloudService): void { - this.artifactService = service - } - - setSkillCloudService(service: SkillCloudService): void { - this.skillCloudService = service - } - - assertAgentSkillSharingAllowed(): void { - assertAgentSkillSharingAllowed(() => isAgentSkillSharingEnabled(this.store?.getSettings())) - } - - /** Renderer-owned; read here because dispatch enforcement lives in main. */ - getNestedWorkerMaxDepth(): number { - return resolveNestedWorkerMaxDepth(this.store?.getSettings()) - } - - async publishDiscoveredSkillsFromAgent( - request: AgentSkillShareRequest, - discoveredSkills: readonly DiscoveredSkill[], - signal?: AbortSignal - ): Promise { - this.assertAgentSkillSharingAllowed() - if (this.agentSkillShareInProgress) { - throw new AgentSkillSharingError( - AGENT_SKILL_SHARING_BUSY_CODE, - 'Another agent skill bundle is being published. Wait for it to finish and try again.' - ) - } - this.agentSkillShareInProgress = true - try { - return await this.executeAgentSkillShare(request, discoveredSkills, signal) - } finally { - this.agentSkillShareInProgress = false - } - } - - private async executeAgentSkillShare( - request: AgentSkillShareRequest, - discoveredSkills: readonly DiscoveredSkill[], - signal?: AbortSignal - ): Promise { - const selectedSkills = selectDiscoveredSkills(discoveredSkills, request.skillSelectors) - const operationRoot = join( - getAppEnvironment().getPath('userData'), - 'agent-skill-share-operations' - ) - const cloud = this.requireSkillCloudService() - const preparations = new SkillSharePreparationService( - operationRoot, - { - publishVersion: (input) => cloud.publishVersion(input), - createShare: (packageId, input) => cloud.createShare(packageId, input) - }, - { - installStateDirectory: join(getAppEnvironment().getPath('userData'), 'skill-installs') - } - ) - let preparationId: string | null = null - const cancel = (): void => { - if (preparationId) { - preparations.cancel(preparationId) - } - } - signal?.addEventListener('abort', cancel, { once: true }) - try { - if (signal?.aborted) { - throw signal.reason ?? new Error('skill-share-cancelled') - } - const preview = await preparations - .prepare({ - sources: selectedSkills.map((skill) => ({ - id: skill.name, - sourceDirectory: skill.directoryPath - })), - bundleName: request.bundleName, - description: - selectedSkills.length === 1 - ? (selectedSkills[0].description ?? '') - : `${selectedSkills.length} shared skills` - }) - .catch((error: unknown) => { - if ( - error instanceof Error && - ['skill-package-skill-name-required', 'skill-package-skill-name-invalid'].includes( - error.message - ) - ) { - throw new AgentSkillSharingError( - AGENT_SKILL_NOT_SHAREABLE_CODE, - 'A selected skill cannot be shared. Its SKILL.md must declare a lowercase name containing only letters, numbers, and hyphens.' - ) - } - throw error - }) - preparationId = preview.preparationId - if (signal?.aborted) { - throw signal.reason ?? new Error('skill-share-cancelled') - } - this.assertAgentSkillSharingAllowed() - const published = await preparations.publish({ - preparationId, - releaseNotes: request.releaseNotes - }) - return published.status === 'ok' - ? { - status: 'ok', - value: { - ...published.value, - selectedSkills: selectedSkills.map(({ id, name, description }) => ({ - id, - name, - description - })) - } - } - : published - } finally { - signal?.removeEventListener('abort', cancel) - await preparations.dispose() - } - } - - publishSkillPackage( - request: SkillCloudPublishRequest - ): Promise> { - return this.requireSkillCloudService().publish(request) - } - - publishSkillPackageVersion( - request: SkillCloudPublishRequest - ): Promise> { - return this.requireSkillCloudService().publishVersion(request) - } - - createSkillPackageShare( - packageId: string, - request: SkillCloudOptions & { - pinnedVersionId?: string - idempotencyKey?: string - } - ) { - return this.requireSkillCloudService().createShare(packageId, request) - } - - resolveSkillShare( - shareId: string, - options: SkillCloudOptions - ): Promise> { - return this.requireSkillCloudService().resolveShare(shareId, options) - } - - createSkillDownloadGrant( - shareId: string, - options: SkillCloudOptions & { - versionId?: string - installTarget?: 'local' | 'remote' - } - ): Promise> { - return this.requireSkillCloudService().createDownloadGrant(shareId, options) - } - - createSkillPackageVersionDownloadGrant( - packageId: string, - versionId: string, - options: SkillCloudOptions & { installTarget?: 'local' | 'remote' } - ): Promise> { - return this.requireSkillCloudService().createPackageVersionDownloadGrant( - packageId, - versionId, - options - ) - } - - getSkillPackage( - packageId: string, - options: SkillCloudOptions - ): Promise> { - return this.requireSkillCloudService().getPackage(packageId, options) - } - - listOwnedSkillShares(options: SkillCloudOptions) { - return this.requireSkillCloudService().listOwnedShares(options) - } - - revokeSkillShare( - shareId: string, - options: SkillCloudOptions - ): Promise> { - return this.requireSkillCloudService().revokeShare(shareId, options) - } - - deleteSkillPackageVersion( - packageId: string, - versionId: string, - options: SkillCloudOptions - ): Promise> { - return this.requireSkillCloudService().deleteVersion(packageId, versionId, options) - } - - deleteSkillPackage( - packageId: string, - options: SkillCloudOptions - ): Promise> { - return this.requireSkillCloudService().deletePackage(packageId, options) - } - - async installSharedSkillRequest( - request: SkillInstallRequest, - signal?: AbortSignal - ): Promise { - if (this.skillInstallOperations.has(request.operationId)) { - throw new Error('skill-install-operation-in-progress') - } - const controller = new AbortController() - const abort = (): void => controller.abort() - if (signal?.aborted) { - abort() - } else { - signal?.addEventListener('abort', abort, { once: true }) - } - this.skillInstallOperations.set(request.operationId, controller) - try { - return await this.executeSharedSkillInstall(request, controller.signal) - } finally { - signal?.removeEventListener('abort', abort) - if (this.skillInstallOperations.get(request.operationId) === controller) { - this.skillInstallOperations.delete(request.operationId) - } - } - } - - async installSharedSkillBundleRequest( - request: SkillBundleInstallRequest, - signal?: AbortSignal, - onProgress?: (progress: SkillBundleInstallProgress) => void - ): Promise { - if (this.skillInstallOperations.has(request.operationId)) { - throw new Error('skill-install-operation-in-progress') - } - const controller = new AbortController() - const abort = (): void => controller.abort() - if (signal?.aborted) { - abort() - } else { - signal?.addEventListener('abort', abort, { once: true }) - } - this.skillInstallOperations.set(request.operationId, controller) - const reportProgress = (progress: SkillBundleInstallProgress): void => { - this.skillInstallProgress.set(request.operationId, progress) - try { - onProgress?.(progress) - } catch { - // Why: renderer teardown must not change the host-owned install outcome. - } - } - try { - const runtimeId = this.getStatus().runtimeId - const sshTarget = await this.resolveSkillSshTarget(request.destination) - if (sshTarget) { - return installSkillBundleOnSshHost({ - provider: sshTarget.provider, - userDataPath: getAppEnvironment().getPath('userData'), - request: { - ...request, - destination: - request.destination.scope === 'global' - ? { scope: 'global', executionTarget: { kind: 'host' } } - : request.destination - }, - workspace: sshTarget.workspace, - requireHttps: getAppEnvironment().isPackaged(), - signal: controller.signal, - onProgress: reportProgress - }) - } - await this.skillTransactionRecovery - const allowedDownloadOrigins = ['https://storage.googleapis.com'] - if (!getAppEnvironment().isPackaged() && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) { - allowedDownloadOrigins.push( - ...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',') - .map((origin) => origin.trim()) - .filter(Boolean) - ) - } - return await executeSkillBundleInstallRequest(request, { - authority: this.skillInstallDestinationAuthority(runtimeId), - stateDirectory: getAppEnvironment().getPath('userData'), - allowedDownloadOrigins: [...new Set(allowedDownloadOrigins)], - requireHttps: getAppEnvironment().isPackaged(), - resolveStagedUpload: (uploadId, identity) => - this.requireSkillUploadSessions().take(uploadId, identity), - detectProviders: detectInstalledAgentsWithShellPathHydration, - resolveProviderRootOverrides: (destination) => - this.resolveSkillProviderRootOverrides(destination), - signal: controller.signal, - onProgress: reportProgress - }) - } finally { - signal?.removeEventListener('abort', abort) - if (this.skillInstallOperations.get(request.operationId) === controller) { - this.skillInstallOperations.delete(request.operationId) - } - this.skillInstallProgress.delete(request.operationId) - } - } - - getSharedSkillInstallProgress(operationId: string): SkillBundleInstallProgress | null { - return this.skillInstallProgress.get(operationId) ?? null - } - - cancelSharedSkillInstall(operationId: string): boolean { - const operation = this.skillInstallOperations.get(operationId) - operation?.abort() - return Boolean(operation) - } - - private async executeSharedSkillInstall( - request: SkillInstallRequest, - signal: AbortSignal - ): Promise { - const runtimeId = this.getStatus().runtimeId - const sshTarget = await this.resolveSkillSshTarget(request.destination) - if (sshTarget) { - return installSkillOnSshHost({ - provider: sshTarget.provider, - userDataPath: getAppEnvironment().getPath('userData'), - request: { - ...request, - destination: - request.destination.scope === 'global' - ? { scope: 'global', executionTarget: { kind: 'host' } } - : request.destination - }, - workspace: sshTarget.workspace, - requireHttps: getAppEnvironment().isPackaged(), - signal - }) - } - await this.skillTransactionRecovery - const allowedDownloadOrigins = ['https://storage.googleapis.com'] - if (!getAppEnvironment().isPackaged() && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) { - allowedDownloadOrigins.push( - ...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',') - .map((origin) => origin.trim()) - .filter(Boolean) - ) - } - return executeSkillInstallRequest(request, { - authority: this.skillInstallDestinationAuthority(runtimeId), - stateDirectory: getAppEnvironment().getPath('userData'), - allowedDownloadOrigins: [...new Set(allowedDownloadOrigins)], - requireHttps: getAppEnvironment().isPackaged(), - resolveStagedUpload: (uploadId, identity) => - this.requireSkillUploadSessions().take(uploadId, identity), - detectProviders: detectInstalledAgentsWithShellPathHydration, - resolveProviderRootOverrides: (destination) => - this.resolveSkillProviderRootOverrides(destination), - signal - }) - } - - async previewSharedSkillInstallRequest( - request: SkillInstallPreviewRequest - ): Promise { - const runtimeId = this.getStatus().runtimeId - const sshTarget = await this.resolveSkillSshTarget(request.destination) - if (sshTarget) { - return previewSkillInstallOnSshHost({ - provider: sshTarget.provider, - request: { - ...request, - destination: - request.destination.scope === 'global' - ? { scope: 'global', executionTarget: { kind: 'host' } } - : request.destination - }, - workspace: sshTarget.workspace - }) - } - await this.skillTransactionRecovery - return previewSharedSkillInstall(request, { - authority: this.skillInstallDestinationAuthority(runtimeId), - stateDirectory: getAppEnvironment().getPath('userData'), - detectProviders: detectInstalledAgentsWithShellPathHydration, - resolveProviderRootOverrides: (destination) => - this.resolveSkillProviderRootOverrides(destination) - }) - } - - async previewSharedSkillBundleInstallRequest( - request: SkillBundleInstallPreviewRequest - ): Promise { - const sshTarget = await this.resolveSkillSshTarget(request.destination) - if (sshTarget) { - return previewSkillBundleInstallOnSshHost({ - provider: sshTarget.provider, - request: { - ...request, - destination: - request.destination.scope === 'global' - ? { scope: 'global', executionTarget: { kind: 'host' } } - : request.destination - }, - workspace: sshTarget.workspace - }) - } - await this.skillTransactionRecovery - const runtimeId = this.getStatus().runtimeId - return previewSharedSkillBundleInstall(request, { - authority: this.skillInstallDestinationAuthority(runtimeId), - stateDirectory: getAppEnvironment().getPath('userData'), - detectProviders: detectInstalledAgentsWithShellPathHydration, - resolveProviderRootOverrides: (destination) => - this.resolveSkillProviderRootOverrides(destination) - }) - } - - async removeSharedSkillInstallRequest(request: SkillRemoveRequest): Promise { - const runtimeId = this.getStatus().runtimeId - const sshTarget = await this.resolveSkillSshTarget(request.destination) - if (sshTarget) { - return removeSkillInstallOnSshHost({ - provider: sshTarget.provider, - request: { - ...request, - destination: - request.destination.scope === 'global' - ? { scope: 'global', executionTarget: { kind: 'host' } } - : request.destination - }, - workspace: sshTarget.workspace - }) - } - await this.skillTransactionRecovery - return removeSharedSkillInstall(request, { - authority: this.skillInstallDestinationAuthority(runtimeId), - stateDirectory: getAppEnvironment().getPath('userData'), - detectProviders: detectInstalledAgentsWithShellPathHydration, - resolveProviderRootOverrides: (destination) => - this.resolveSkillProviderRootOverrides(destination) - }) - } - - async listManagedSkillInstalls(connectionId?: string): Promise { - if (connectionId) { - const provider = this.requireSkillSshProvider(connectionId) - return listSkillInstallsOnSshHost({ - provider, - connectionId, - workspaces: await this.listSkillSshWorkspaces(connectionId) - }) - } - await this.skillTransactionRecovery - const runtimeId = this.getStatus().runtimeId - const [installs, worktrees] = await Promise.all([ - listManagedSkillInstalls(join(getAppEnvironment().getPath('userData'), 'skill-installs'), { - observeReceipt: async (receipt) => { - if (!receipt.wslDistro) { - return nativeSkillInstallFilesystem.observeSkill( - receipt.canonicalPath, - receipt.fileModes - ) - } - const filesystem = new WslSkillInstallFilesystem(receipt.wslDistro, [ - dirname(receipt.canonicalPath) - ]) - return filesystem.observeSkill(receipt.canonicalPath, receipt.fileModes) - } - }), - this.listResolvedWorktrees() - ]) - const folderWorkspaces = this.listFolderWorkspaces() - return installs.flatMap((install): ManagedSkillInstall[] => { - if (install.scope === 'global') { - const wslPrefix = `global:${runtimeId}:wsl:` - return [ - { - ...install, - destination: install.destinationIdentity.startsWith(wslPrefix) - ? { - scope: 'global', - executionTarget: { - kind: 'wsl', - distro: install.destinationIdentity.slice(wslPrefix.length) - } - } - : { scope: 'global' } - } - ] - } - const worktree = worktrees.find( - (candidate) => install.destinationIdentity === `workspace:${runtimeId}:${candidate.id}` - ) - if (worktree) { - return [{ ...install, destination: { scope: 'workspace', worktreeId: worktree.id } }] - } - const folder = folderWorkspaces.find( - (candidate) => install.destinationIdentity === `workspace:${runtimeId}:${candidate.id}` - ) - return folder - ? [{ ...install, destination: { scope: 'workspace', folderWorkspaceId: folder.id } }] - : [] - }) - } - - async skillInstallDestinationUsesSsh( - destination: SkillInstallRequest['destination'] - ): Promise { - return Boolean(await this.resolveSkillSshTarget(destination)) - } - - async resolveSkillDiscoveryProviderRoots(target: { - kind: 'native-host' | 'wsl' - distro?: string - }): Promise { - const roots = await this.resolveSkillProviderRootOverrides({ - scope: 'global', - homeDirectory: homedir(), - ...(target.kind === 'wsl' && target.distro ? { wslDistro: target.distro } : {}) - }) - if (target.kind !== 'wsl') { - return roots - } - return Object.fromEntries( - Object.entries(roots).map(([provider, root]) => [provider, toLinuxPath(root)]) - ) - } - - private async resolveSkillProviderRootOverrides(destination: { - scope: 'global' | 'workspace' - homeDirectory: string - workspaceDirectory?: string - wslDistro?: string - }): Promise { - if (destination.scope !== 'global') { - return {} - } - const wslGrokRoot = destination.wslDistro - ? await resolveWslGrokSkillProviderRoot(destination.wslDistro) - : null - const roots: SkillProviderRootOverrides = destination.wslDistro - ? wslGrokRoot - ? { grok: wslGrokRoot } - : {} - : resolveEnvironmentSkillProviderRoots() - const claudeConfigDirectory = this.accountServices?.claudeAccounts.getRuntimeConfigDir( - destination.wslDistro - ? { runtime: 'wsl', wslDistro: destination.wslDistro } - : { runtime: 'host' } - ) - return withClaudeSkillProviderRoot(roots, claudeConfigDirectory) - } - - private skillInstallDestinationAuthority(runtimeId: string): SkillInstallDestinationAuthority { - return { - environmentId: runtimeId, - homeDirectory: homedir(), - resolveWorktree: async (id) => { - const repo = this.listRepos().find( - (candidate) => candidate.id === getRepoIdFromWorktreeId(id) - ) - if (repo?.connectionId) { - throw new Error('skill-install-ssh-dispatch-required') - } - const projectRuntime = this.resolveProjectRuntimeForWorktree(id) - const worktree = await this.showManagedWorktree(`id:${id}`) - if (worktree.id !== id) { - return null - } - return { - id, - path: worktree.path, - ...(projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl' - ? { wslDistro: projectRuntime.runtime.distro } - : {}) - } - }, - resolveFolderWorkspace: async (id) => { - const workspace = this.listFolderWorkspaces().find((candidate) => candidate.id === id) - if (!workspace || workspace.connectionId) { - return null - } - return { - id, - path: workspace.folderPath, - ...(parseWslUncPath(workspace.folderPath)?.distro - ? { wslDistro: parseWslUncPath(workspace.folderPath)!.distro } - : {}) - } - }, - resolveWsl: async (distro) => { - if (process.platform !== 'win32') { - return null - } - const homeDirectory = getWslHome(distro) - return homeDirectory ? { homeDirectory } : null - } - } - } - - private async resolveSkillSshTarget(destination: SkillInstallRequest['destination']): Promise<{ - provider: () => IPtyProvider - workspace?: SkillSshWorkspaceAuthority - } | null> { - if (destination.scope === 'global') { - if (destination.executionTarget?.kind !== 'ssh') { - return null - } - const connectionId = destination.executionTarget.connectionId - return { provider: () => this.requireSkillSshProvider(connectionId) } - } - if (destination.worktreeId) { - const repo = this.listRepos().find( - (candidate) => candidate.id === getRepoIdFromWorktreeId(destination.worktreeId!) - ) - if (!repo?.connectionId) { - return null - } - const worktree = await this.showManagedWorktree(`id:${destination.worktreeId}`) - if (worktree.id !== destination.worktreeId) { - throw new Error('skill-install-workspace-not-found') - } - return { - provider: () => this.requireSkillSshProvider(repo.connectionId!), - workspace: { kind: 'worktree', id: worktree.id, path: worktree.path } - } - } - const folder = this.listFolderWorkspaces().find( - (candidate) => candidate.id === destination.folderWorkspaceId - ) - if (!folder?.connectionId) { - return null - } - return { - provider: () => this.requireSkillSshProvider(folder.connectionId!), - workspace: { kind: 'folder', id: folder.id, path: folder.folderPath } - } - } - - private requireSkillSshProvider(connectionId: string): IPtyProvider { - const provider = this.getSshProviderFn?.(connectionId) - if (!provider?.requestHostRpc) { - throw new Error('skill-install-ssh-relay-unavailable') - } - return provider - } - - private async listSkillSshWorkspaces( - connectionId: string - ): Promise { - const repos = new Map( - this.listRepos() - .filter((repo) => repo.connectionId === connectionId) - .map((repo) => [repo.id, repo]) - ) - const worktrees = (await this.listResolvedWorktrees()) - .filter((worktree) => repos.has(getRepoIdFromWorktreeId(worktree.id))) - .map((worktree): SkillSshWorkspaceAuthority => ({ - kind: 'worktree', - id: worktree.id, - path: worktree.path - })) - const folders = this.listFolderWorkspaces() - .filter((folder) => folder.connectionId === connectionId) - .map((folder): SkillSshWorkspaceAuthority => ({ - kind: 'folder', - id: folder.id, - path: folder.folderPath - })) - return [...worktrees, ...folders] - } - - beginSkillUpload(request: SkillUploadBeginRequest): Promise<{ - uploadId: string - chunkBytes: number - acknowledgedOffset: number - }> { - return this.requireSkillUploadSessions().begin(request) - } - - appendSkillUploadChunk( - request: SkillUploadChunkRequest - ): Promise<{ acknowledgedOffset: number }> { - return this.requireSkillUploadSessions().append(request) - } - - commitSkillUpload(uploadId: string): Promise<{ uploadId: string }> { - return this.requireSkillUploadSessions().commit(uploadId) - } - - cancelSkillUpload(uploadId: string): Promise { - return this.requireSkillUploadSessions().cancel(uploadId) - } - - listArtifacts(options: ArtifactListOptions): Promise> { - return this.requireArtifactService().list(options) - } - - getPublishedArtifactLink( - request: ArtifactCloudOptions & { sourceKey: string } - ): Promise> { - return this.requireArtifactService().getPublishedLink(request) - } - - shareArtifact(request: ArtifactWriteRequest): Promise> { - return this.requireArtifactService().share(request) - } - - publishArtifact( - request: ArtifactWriteRequest - ): Promise> { - return this.requireArtifactService().publish(request) - } - - updateArtifact(request: ArtifactWriteRequest): Promise> { - return this.requireArtifactService().update(request) - } - - unshareArtifact( - request: ArtifactCloudOptions & { sourceKey: string } - ): Promise> { - return this.requireArtifactService().unshare(request) - } - - deleteArtifact(id: string, options: ArtifactCloudOptions): Promise> { - return this.requireArtifactService().delete(id, options) - } - - private requireArtifactService(): ArtifactCloudService { - if (!this.artifactService) { - throw new Error('Artifact service is unavailable.') - } - return this.artifactService - } - - private requireSkillCloudService(): SkillCloudService { - if (!this.skillCloudService) { - throw new Error('Skill Cloud service is unavailable.') - } - return this.skillCloudService - } - - private requireSkillUploadSessions(): SkillUploadSessionService { - if (this.skillUploadSessionsDisposed) { - throw new Error('skill-upload-service-disposed') - } - this.skillUploadSessions ??= new SkillUploadSessionService( - join( - getAppEnvironment().getPath('userData'), - 'skill-installs', - SKILL_UPLOAD_STAGING_ROOT_NAME - ) - ) - return this.skillUploadSessions - } - - async disposeSkillUploadSessions(): Promise { - this.skillUploadSessionsDisposed = true - const sessions = this.skillUploadSessions - this.skillUploadSessions = null - await sessions?.dispose() - } - - getRuntimeId(): string { - return this.runtimeId - } - - resolveOrchestrationWorkerServer(selector: string): OrchestrationWorkerServer { - if (!this.orchestrationEnvironmentTransport) { - throw new OrchestrationError( - 'server_required', - 'Connected-server orchestration is unavailable in this runtime.' - ) - } - return this.orchestrationEnvironmentTransport.resolve(selector) - } - - async callOrchestrationWorkerServer( - selector: string, - method: string, - params: unknown, - timeoutMs?: number, - envelope?: RuntimeOrchestrationEnvelope, - internal?: { contractVerified?: boolean } - ): Promise { - if (!this.orchestrationEnvironmentTransport) { - throw new OrchestrationError( - 'server_required', - 'Connected-server orchestration is unavailable in this runtime.' - ) - } - if (isOrchestrationMutation(method, params) && !internal?.contractVerified) { - const statusResponse = await this.orchestrationEnvironmentTransport.call( - selector, - 'status.get', - undefined, - timeoutMs - ) - if (statusResponse.ok === false) { - throw new OrchestrationError( - statusResponse.error.code, - statusResponse.error.message, - statusResponse.error.data - ) - } - const status = statusResponse.result as RuntimeStatus - if (!status.capabilities?.includes(ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY)) { - throw new OrchestrationError( - 'orchestration_migration_required', - 'The connected worker server does not support the current orchestration contract. No effects were applied.', - orchestrationMigrationData('runtime_capability_missing') - ) - } - } - const response = await this.orchestrationEnvironmentTransport.call( - selector, - method, - params, - timeoutMs, - method.startsWith('orchestration.') - ? { ...envelope, orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION } - : envelope - ) - if (response.ok === false) { - throw new OrchestrationError(response.error.code, response.error.message, response.error.data) - } - return response.result - } - - async syncOrchestrationFederation(runId?: string): Promise { - if (!this.orchestrationEnvironmentTransport) { - return - } - const dispatches = this.getOrchestrationDb().listActiveFederatedDispatches(runId) - await Promise.allSettled( - dispatches.map((dispatch) => this.syncOrchestrationFederatedDispatch(dispatch.dispatch_id)) - ) - } - - syncOrchestrationFederatedDispatch(dispatchId: string): Promise { - const db = this.getOrchestrationDb() - const current = this.orchestrationFederationSyncs.get(dispatchId) - if (current?.db === db) { - return current.promise - } - const sync = syncFederatedDispatch(this, dispatchId) - .then(() => { - if (this.orchestrationFederationSyncs.get(dispatchId)?.promise === sync) { - this.orchestrationFederationWarnings.delete(dispatchId) - } - }) - .catch((error: unknown) => { - if ( - this.orchestrationFederationSyncs.get(dispatchId)?.promise === sync && - !this.orchestrationFederationWarnings.has(dispatchId) - ) { - console.warn(`[orchestration] Federation sync failed for ${dispatchId}:`, error) - this.orchestrationFederationWarnings.add(dispatchId) - } - throw error - }) - .finally(() => { - if (this.orchestrationFederationSyncs.get(dispatchId)?.promise !== sync) { - return - } - this.orchestrationFederationSyncs.delete(dispatchId) - if (!db.isFederatedDispatchRelayEligible(dispatchId)) { - releaseFederationAckCheckpoint(this, dispatchId) - } - }) - this.orchestrationFederationSyncs.set(dispatchId, { db, promise: sync }) - return sync - } - - async syncOrchestrationFederatedDispatchAfterCurrent(dispatchId: string): Promise { - const db = this.getOrchestrationDb() - const current = this.orchestrationFederationSyncs.get(dispatchId) - if (current?.db === db) { - await current.promise.catch(() => undefined) - } - await this.syncOrchestrationFederatedDispatch(dispatchId) - } - - ensureOrchestrationFederationRelay(runId?: string): void { - if (!this.orchestrationEnvironmentTransport) { - return - } - for (const dispatch of this.getOrchestrationDb().listActiveFederatedDispatches(runId)) { - if (this.orchestrationFederationTimers.has(dispatch.dispatch_id)) { - continue - } - const tick = () => { - if (!this.getOrchestrationDb().isFederatedDispatchRelayEligible(dispatch.dispatch_id)) { - const activeTimer = this.orchestrationFederationTimers.get(dispatch.dispatch_id) - if (activeTimer) { - clearInterval(activeTimer) - } - this.orchestrationFederationTimers.delete(dispatch.dispatch_id) - this.orchestrationFederationWarnings.delete(dispatch.dispatch_id) - return - } - void this.syncOrchestrationFederatedDispatch(dispatch.dispatch_id).catch(() => undefined) - } - const timer = setInterval(tick, 1_000) - timer.unref?.() - this.orchestrationFederationTimers.set(dispatch.dispatch_id, timer) - tick() - } - this.ensureTerminalHistoryRecovery() - } - - private ensureTerminalHistoryRecovery(): void { - if ( - this.orchestrationTerminalHistoryRecoveryTimer || - this.orchestrationTerminalHistoryRecoveryInFlight - ) { - return - } - const generation = this.orchestrationFederationRelayGeneration - const recovery = this.recoverNextTerminalHistoryAcknowledgment(generation).catch((error) => { - console.warn('[orchestration] terminal federation acknowledgment recovery failed', error) - }) - this.orchestrationTerminalHistoryRecoveryInFlight = recovery - void recovery.finally(() => { - if (this.orchestrationTerminalHistoryRecoveryInFlight === recovery) { - this.orchestrationTerminalHistoryRecoveryInFlight = null - } - }) - } - - private async recoverNextTerminalHistoryAcknowledgment(generation: number): Promise { - const db = this.getOrchestrationDb() - let historical = db.findNextTerminalFederatedDispatchPendingAcknowledgment( - this.orchestrationTerminalRecoveryRowId - ) - if (!historical && this.orchestrationTerminalRecoveryRowId > 0) { - this.orchestrationTerminalRecoveryRowId = 0 - historical = db.findNextTerminalFederatedDispatchPendingAcknowledgment(0) - } - if (!historical) { - return - } - this.orchestrationTerminalRecoveryRowId = historical.rowId - await this.syncOrchestrationFederatedDispatch(historical.dispatchId).catch(() => undefined) - if (generation !== this.orchestrationFederationRelayGeneration) { - return - } - this.orchestrationTerminalHistoryRecoveryTimer = setTimeout(() => { - this.orchestrationTerminalHistoryRecoveryTimer = null - this.ensureTerminalHistoryRecovery() - }, 1_000) - this.orchestrationTerminalHistoryRecoveryTimer.unref?.() - } - - stopOrchestrationFederationRelay(): void { - this.orchestrationFederationRelayGeneration += 1 - for (const timer of this.orchestrationFederationTimers.values()) { - clearInterval(timer) - } - this.orchestrationFederationTimers.clear() - if (this.orchestrationTerminalHistoryRecoveryTimer) { - clearTimeout(this.orchestrationTerminalHistoryRecoveryTimer) - this.orchestrationTerminalHistoryRecoveryTimer = null - } - this.orchestrationTerminalHistoryRecoveryInFlight = null - this.orchestrationTerminalRecoveryRowId = 0 - this.orchestrationFederationWarnings.clear() - this.orchestrationFederationSyncs.clear() - clearFederationAckCheckpoints(this) - } - - getStartedAt(): number { - return this.startedAt - } - - private tryGetWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId | null { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - const workspace = this.store - ?.getFolderWorkspaces?.() - .find((entry) => entry.id === scope.folderWorkspaceId) - if (!workspace) { - return null - } - if (workspace.executionHostId != null) { - return parseExecutionHostId(workspace.executionHostId)?.id ?? null - } - const connectionId = this.resolveFolderWorkspaceConnectionId(workspace) - return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID - } - const resolvedWorktreeId = scope?.type === 'worktree' ? scope.worktreeId : worktreeId - const repo = this.store?.getRepo?.(getRepoIdFromWorktreeId(resolvedWorktreeId)) - return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID - } - - private getWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId { - const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) - if (!hostId) { - throw new Error('folder_workspace_not_found') - } - return hostId - } - - private getWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState | null { - const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) - return hostId ? (this.store?.getWorkspaceSession?.(hostId) ?? null) : null - } - - private setWorkspaceSessionForWorktree(worktreeId: string, session: WorkspaceSessionState): void { - this.store?.setWorkspaceSession?.( - session, - this.getWorkspaceSessionHostIdForWorktree(worktreeId) - ) - } - - private getKnownWorkspaceSessionWorktreeIds(): Set { - const repos = this.store?.getRepos?.() ?? [] - const repoIds = new Set(repos.map((repo) => repo.id)) - const hostIds = new Set(['local']) - for (const repo of repos) { - hostIds.add(getRepoExecutionHostId(repo)) - } - const worktreeIds = new Set() - for (const hostId of hostIds) { - const session = this.store?.getWorkspaceSession?.(hostId) - for (const worktreeId of Object.keys(session?.tabsByWorktree ?? {})) { - if (repoIds.has(getRepoIdFromWorktreeId(worktreeId))) { - worktreeIds.add(worktreeId) - } - } - } - return worktreeIds - } - - // Every execution host known to this runtime; knowledge is not coverage. - private listKnownExecutionHostIds( - additionalHostIds: Iterable = [], - includeConfiguredHosts = true - ): Set { - const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) - for (const hostId of this.store?.getWorkspaceSessionHostIds?.() ?? []) { - hostIds.add(hostId) - } - for (const hostId of additionalHostIds) { - hostIds.add(hostId) - } - if (!includeConfiguredHosts) { - return hostIds - } - const repos = this.store?.getRepos?.() ?? [] - for (const repo of repos) { - hostIds.add(getRepoExecutionHostId(repo)) - } - const projectGroups = this.store?.getProjectGroups?.() ?? [] - for (const workspace of this.store?.getFolderWorkspaces?.() ?? []) { - if (workspace.executionHostId != null) { - const explicitHostId = parseExecutionHostId(workspace.executionHostId)?.id - if (explicitHostId) { - hostIds.add(explicitHostId) - } - continue - } - const connection = inferFolderWorkspacePathConnection({ - folderPath: workspace.folderPath, - projectGroupId: workspace.projectGroupId, - connectionId: workspace.connectionId ?? null, - projectGroups, - repos - }) - if (connection.kind === 'ssh') { - hostIds.add(toSshExecutionHostId(connection.connectionId)) - } - } - return hostIds - } - - private getWorkspaceSessionHydrationTargets( - includeAllPersistedWorktrees: boolean - ): Map { - const repos = this.store?.getRepos?.() ?? [] - const repoHostIdByRepoId = new Map( - repos.map((repo) => [repo.id, getRepoExecutionHostId(repo)] as const) - ) - const folderHostIdByWorkspaceId = new Map( - (this.store?.getFolderWorkspaces?.() ?? []).map((workspace) => { - const connectionId = this.resolveFolderWorkspaceConnectionId(workspace) - return [ - workspace.id, - connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID - ] as const - }) - ) - const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) - for (const repo of repos) { - hostIds.add(getRepoExecutionHostId(repo)) - } - for (const hostId of this.store?.getWorkspaceSessionHostIds?.() ?? []) { - hostIds.add(hostId) - } - - const targets = new Map() - for (const hostId of hostIds) { - const session = this.store?.getWorkspaceSession?.(hostId) - if (!session) { - continue - } - for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { - const scope = parseWorkspaceKey(worktreeId) - const ownerHostId = - scope?.type === 'folder' - ? (folderHostIdByWorkspaceId.get(scope.folderWorkspaceId) ?? null) - : (repoHostIdByRepoId.get( - getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : worktreeId) - ) ?? LOCAL_EXECUTION_HOST_ID) - if ( - ownerHostId === hostId && - (includeAllPersistedWorktrees || - this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(session, worktreeId, tabs)) - ) { - targets.set(worktreeId, session) - } - } - } - return targets - } - - getStatus(): RuntimeStatus { - // Why: browser panes need a backend that can create and stream a page. A - // desktop renderer provides one via ; a headless serve provides one - // via the offscreen backend. Either way the same browser.screencast.v1 path - // works, so advertise it when either is present. browser.headless.v1 - // additionally tells clients this host owns browser pages with no renderer, - // so they must not fall back to a local desktop browser tab. - const hasRenderer = Boolean(this.getAvailableAuthoritativeWindow()) - const hasOffscreen = !hasRenderer && Boolean(this.offscreenBrowserBackend) - const hasHeadlessCommands = runtimeBrowserCommandsFactoryIsHeadless() - const canBrowse = hasRenderer || hasOffscreen - const hasMobileWebPackage = mobileWebPackageIsAvailable() - const capabilities: RuntimeCapability[] = RUNTIME_CAPABILITIES.filter( - (capability) => - (capability !== 'browser.screencast.v1' || canBrowse) && - // Why: the nested-runtime E2E needs a real legacy transport without maintaining an old binary fixture. - (process.env.ORCA_E2E_DISABLE_RUNTIME_SHARED_CONTROL !== '1' || - capability !== REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) && - (process.env.ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING !== '1' || - capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) && - (process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1' || - capability !== SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY) && - (hasMobileWebPackage || - (capability !== 'mobileWeb.package.v1' && capability !== 'mobileWeb.package.gzip.v1')) - ) - if (hasOffscreen || hasHeadlessCommands) { - capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) - } - // Why: certificate proceed is owned by the browser-hosting process for both - // desktop webviews and offscreen pages. Advertise whenever either backend - // can host a page so remote clients can surface Proceed Anyway (Unsafe). - if (canBrowse) { - capabilities.push(BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY) - } - // Why the cause and not one fixed sentence: the operator can only act on the reason - // that actually applies, and a host that says "set ORCA_BROWSER_EXECUTABLE" to someone - // who already set it sends them to fix a thing that is not broken. - const cause = canBrowse || hasHeadlessCommands ? null : runtimeBrowserUnavailableCause() - const degradations: RuntimeDegradation[] = cause - ? [ - { - code: BROWSER_UNAVAILABLE_ERROR_CODE, - capability: BROWSER_HEADLESS_RUNTIME_CAPABILITY, - message: browserUnavailableMessage(cause.reason, cause.detail), - reason: cause.reason, - ...(cause.detail ? { detail: cause.detail } : {}) - } - ] - : [] - // Why appended rather than merged into the ternary: PTY loss and browser loss are - // independent, and a host can be degraded on both at once. - const terminalDegradation = runtimeTerminalDegradation() - if (terminalDegradation) { - degradations.push(terminalDegradation) - } - return { - runtimeId: this.runtimeId, - rendererGraphEpoch: this.rendererGraphEpoch, - graphStatus: this.graphStatus, - authoritativeWindowId: this.authoritativeWindowId, - desktopWindowStatus: hasRenderer ? 'available' : this.getDesktopWindowStatusFn(), - liveTabCount: this.tabs.size, - liveLeafCount: this.leaves.size, - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - // Why: headless orca serve cannot create/stream BrowserViews, so clients - // must not treat browser panes as supported just because runtime RPC is up. - capabilities, - ...(degradations.length > 0 ? { degradations } : {}), - worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_RESULT_TTL_MS }, - hostPlatform: process.platform, - terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null, - floatingWorkspaceEnabled: this.store?.getSettings?.().floatingTerminalEnabled !== false, - protocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION - } - } - - shouldRelayTerminalBrowserOpens(): boolean { - return this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID - } - - // Why: scans the transcript-owning host's disk (correct by construction over - // RPC — a remote/SSH host scans its own disk). Delegates to the one shared - // cache so the desktop panel and the mobile screen never double-scan. - listAiVaultSessions(args?: AiVaultListArgs): Promise { - return listAiVaultSessions(args) - } - - resolveAiVaultSessionTitles( - requests: AiVaultSessionTitleRequest[], - signal?: AbortSignal - ): Promise { - return resolveLocalAiVaultSessionTitles(requests, signal) - } - - prepareAiVaultSessionResume( - args: AiVaultPrepareSessionResumeArgs - ): Promise { - return ( - this.prepareAiVaultSessionResumeFn?.(args) ?? Promise.resolve({ useRealCodexHome: false }) - ) - } - - setPtyController(controller: RuntimePtyController | null): void { - // Why: CLI terminal writes must go through the main-owned PTY registry - // instead of tunneling back through renderer IPC, or live handles could - // drift from the process they are supposed to control during reloads. - this.ptyController = controller - } - - setNotifier(notifier: RuntimeNotifier | null): void { - this.notifier = notifier - // Why: run the one-shot fork-upstream backfill once a renderer is attached, - // so existing forks self-correct on launch and the result can be broadcast. - if (notifier && !this.forkBackfillStarted) { - this.forkBackfillStarted = true - void this.backfillForkUpstreams() - } - } - - onClientEvent( - listener: (event: RuntimeClientEvent) => void, - options?: { consumesTerminalSideEffects?: boolean } - ): () => void { - this.clientEventListeners.add(listener) - if (options?.consumesTerminalSideEffects === false) { - this.terminalSideEffectExcludedClientEventListeners.add(listener) - } else { - this.terminalSideEffectTitleGateKeysByClientEventListener.set(listener, new Map()) - } - this.refreshTerminalSideEffectConsumerAvailability() - return () => { - this.clientEventListeners.delete(listener) - this.terminalSideEffectExcludedClientEventListeners.delete(listener) - this.terminalSideEffectTitleGateKeysByClientEventListener.delete(listener) - this.refreshTerminalSideEffectConsumerAvailability() - } - } - - private countTerminalSideEffectConsumingClientEventListeners(): number { - return this.clientEventListeners.size - this.terminalSideEffectExcludedClientEventListeners.size - } - - getTerminalSleepClientEventSnapshot(): RuntimeClientEvent[] { - const events: RuntimeClientEvent[] = [] - const sleepStates = [...this.terminalSleepStateByWorktreeId.values()].sort((a, b) => - a.worktreeId.localeCompare(b.worktreeId) - ) - for (const state of sleepStates) { - const committedPtyIds = new Set(state.ptyIds) - if (state.phase === 'stopping') { - const pendingPtyIds = Object.keys(state.terminalHandlesByPtyId) - .filter((ptyId) => !committedPtyIds.has(ptyId)) - .sort() - if (pendingPtyIds.length > 0) { - events.push({ - type: 'worktreeTerminalSleepState', - worktreeId: state.worktreeId, - generation: state.generation, - phase: 'started', - ptyIds: pendingPtyIds, - terminalHandles: this.getRecordedTerminalSleepHandles( - pendingPtyIds, - state.terminalHandlesByPtyId - ) - }) - } - } - if (state.ptyIds.length > 0) { - events.push({ - type: 'worktreeTerminalSleepState', - worktreeId: state.worktreeId, - generation: state.generation, - phase: 'committed', - ptyIds: [...state.ptyIds].sort(), - terminalHandles: this.getRecordedTerminalSleepHandles( - state.ptyIds, - state.terminalHandlesByPtyId - ) - }) - } - } - return events - } - - getNativeChatLaunchDraftResolutionClientEventSnapshot(): Extract< - RuntimeClientEvent, - { type: 'nativeChatLaunchDraftResolved' } - >[] { - return [...this.nativeChatLaunchDraftResolutionByTabId.values()] - .sort((a, b) => a.tabId.localeCompare(b.tabId)) - .map(({ tabId, text, createdAt }) => ({ - type: 'nativeChatLaunchDraftResolved', - tabId, - text, - createdAt - })) - } - - private emitClientEvent(event: RuntimeClientEvent): void { - // Why: filter inside live-Set delivery so a listener removed mid-fan-out - // receives nothing and each paired client gets one semantic title frame. - // Why: a throwing subscriber here once escaped acquireWorktreeTerminalSpawn after it took the - // per-worktree terminal mutation, leaking it and wedging that worktree's sleep until restart. - notifyRuntimeListeners( - this.clientEventListeners, - (listener) => { - if (event.type === 'terminalSideEffects') { - const filtered = this.filterTerminalSideEffectEventForClient(listener, event) - if (filtered) { - listener(filtered) - } - } else { - listener(event) - } - }, - 'client-event' - ) - } - - private filterTerminalSideEffectEventForClient( - listener: (event: RuntimeClientEvent) => void, - event: Extract - ): Extract | null { - const titleGateKeys = this.terminalSideEffectTitleGateKeysByClientEventListener.get(listener) - if (!titleGateKeys) { - return null - } - const facts = event.batch.facts.filter((fact) => { - if (fact.kind !== 'title') { - return true - } - const gateKey = this.makeDecorativeTitleGateKey(fact.rawTitle, fact.normalizedTitle) - if (titleGateKeys.get(event.batch.ptyId) === gateKey) { - return false - } - titleGateKeys.set(event.batch.ptyId, gateKey) - return true - }) - if (facts.length === 0) { - return null - } - return facts.length === event.batch.facts.length - ? event - : { ...event, batch: { ...event.batch, facts } } - } - - notifyNativeChatLaunchDraftResolved( - handle: string, - resolution: { text: string; createdAt: number } - ): void { - const owner = this.resolveNativeChatLaunchDraftOwner(handle) - if (!owner) { - return - } - const tombstone = { ...owner, ...resolution } - this.nativeChatLaunchDraftResolutionByTabId.delete(owner.tabId) - this.nativeChatLaunchDraftResolutionByTabId.set(owner.tabId, tombstone) - while ( - this.nativeChatLaunchDraftResolutionByTabId.size > - MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES - ) { - const oldestTabId = this.nativeChatLaunchDraftResolutionByTabId.keys().next().value - if (typeof oldestTabId !== 'string') { - break - } - this.nativeChatLaunchDraftResolutionByTabId.delete(oldestTabId) - } - this.retireResolvedNativeChatLaunchDraftFromMobileSnapshot(tombstone) - this.notifier?.nativeChatLaunchDraftResolved?.(owner.tabId, resolution) - this.emitClientEvent({ - type: 'nativeChatLaunchDraftResolved', - tabId: owner.tabId, - ...resolution - }) - } - - private resolveNativeChatLaunchDraftOwner( - handle: string - ): { tabId: string; worktreeId: string } | null { - const record = this.handles.get(handle) - if (!record) { - return null - } - if (!record.tabId.startsWith('pty:')) { - return { tabId: record.tabId, worktreeId: record.worktreeId } - } - const pty = record.ptyId ? this.ptysById.get(record.ptyId) : null - const tabId = - pty?.tabId && !pty.tabId.startsWith('pty:') - ? pty.tabId - : parsePaneKey(pty?.paneKey ?? '')?.tabId - if (!pty || !tabId || tabId.startsWith('pty:')) { - return null - } - return { tabId, worktreeId: pty.worktreeId } - } - - private retireResolvedNativeChatLaunchDraftFromMobileSnapshot( - resolution: NativeChatLaunchDraftResolutionTombstone - ): void { - for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { - if (!worktreeIdsEqual(worktreeId, resolution.worktreeId)) { - continue - } - const next = this.applyNativeChatLaunchDraftResolutionFence(snapshot) - if (next === snapshot) { - return - } - this.mobileSessionTabsByWorktree.set(worktreeId, { - ...next, - snapshotVersion: snapshot.snapshotVersion + 1 - }) - this.scheduleMobileSessionTabsChanged(worktreeId) - return - } - } - - private applyNativeChatLaunchDraftResolutionFence( - snapshot: RuntimeMobileSessionTabsSnapshot - ): RuntimeMobileSessionTabsSnapshot { - let changed = false - const tabs = snapshot.tabs.map((tab) => { - if (tab.type !== 'terminal') { - return tab - } - const resolution = this.nativeChatLaunchDraftResolutionByTabId.get(tab.parentTabId) - if ( - !resolution || - !worktreeIdsEqual(snapshot.worktree, resolution.worktreeId) || - tab.launchDraft !== resolution.text || - tab.launchDraftCreatedAt !== resolution.createdAt - ) { - return tab - } - changed = true - const next = { ...tab } - delete next.launchDraft - delete next.launchDraftCreatedAt - return next - }) - return changed ? { ...snapshot, tabs } : snapshot - } - - private reconcileNativeChatLaunchDraftResolutionTombstones( - snapshot: RuntimeMobileSessionTabsSnapshot - ): void { - for (const [tabId, resolution] of this.nativeChatLaunchDraftResolutionByTabId) { - if (!worktreeIdsEqual(snapshot.worktree, resolution.worktreeId)) { - continue - } - const surfaces = snapshot.tabs.filter( - (tab): tab is RuntimeMobileSessionTerminalTab => - tab.type === 'terminal' && tab.parentTabId === tabId - ) - if ( - surfaces.length === 0 || - !surfaces.some( - (tab) => - tab.launchDraft === resolution.text && tab.launchDraftCreatedAt === resolution.createdAt - ) - ) { - this.nativeChatLaunchDraftResolutionByTabId.delete(tabId) - } - } - } - - private notifyWorktreesChanged(repoId: string): void { - this.notifier?.worktreesChanged(repoId) - this.emitClientEvent({ type: 'worktreesChanged', repoId }) - } - - /** Detail-level worktree lifecycle tap (plugin event bus). The coarse - * worktreesChanged client event carries only repoId, which is not enough - * for subscribers that need the affected worktree's identity. - * Removal payloads carry no branch: the removal target resolves before - * the git worktree is torn down and only pins id + path. */ - onWorktreeLifecycle(listener: (event: RuntimeWorktreeLifecycleEvent) => void): () => void { - this.worktreeLifecycleListeners.add(listener) - return () => { - this.worktreeLifecycleListeners.delete(listener) - } - } - - private emitWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { - for (const listener of this.worktreeLifecycleListeners) { - try { - listener(event) - } catch (err) { - console.error('[runtime] worktree lifecycle listener threw', err) - } - } - } - - private notifyReposChanged(): void { - wakeFolderRepoGitUpgradeWatch() - this.notifier?.reposChanged() - this.emitClientEvent({ type: 'reposChanged' }) - } - - // Why: automation writes land in the automation service and IPC handlers, so - // like SSH state they need a public entry point onto the client-event stream. - // Old clients drop the unknown event type; nothing is negotiated for it. - notifyAutomationsChanged(payload: AutomationsChangedPayload = {}): void { - this.notifier?.automationsChanged?.(payload) - this.emitClientEvent({ type: 'automationsChanged', ...payload }) - } - - // Why: SSH state changes originate in main's ssh handlers, not in runtime - // methods, so they need a public entry point onto the client-event stream. - notifySshStateChanged(targetId: string, state: SshConnectionState): void { - this.bumpSshRelayRecoveryGeneration(targetId) - this.invalidateSshWorktreeScanCache(targetId) - if (state.status !== 'connected') { - this.cancelLegacyWorkerTerminalRecoveryRetry(`ssh:${targetId}`) - } - this.emitClientEvent({ type: 'sshStateChanged', targetId, state: getPublicSshState(state)! }) - } - - notifySshRelayUnavailable(targetId: string): void { - this.bumpSshRelayRecoveryGeneration(targetId) - for (const worktreeId of this.collectMobileSessionWorktreeIdsForSshTarget(targetId)) { - this.notifyMobileSessionTabsChangedNow(worktreeId, ++this.mobileSessionTabsChangeSequence) - } - } - - notifySshRelayReady(targetId: string): void { - const generation = this.bumpSshRelayRecoveryGeneration(targetId) - const publish = async (): Promise => { - try { - await this.publishRecoveredSshMobileSessionTabs(targetId, generation) - } catch (error) { - if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) === generation) { - console.warn('[runtime] failed to publish recovered SSH session tabs', { - targetId, - error - }) - } - } - } - const initialPublication = publish() - void initialPublication - void this.refreshRestoredOrchestrationAuthority(targetId) - .then(() => - this.reconcileLegacyWorkerTerminals({ - connectionId: targetId, - materializeRenderer: this.notifier !== null - }) - ) - .then(async () => { - await initialPublication - await publish() - }) - .catch((error) => { - if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) !== generation) { - return - } - console.warn('[orchestration] legacy worker reconcile failed on relay ready', { - targetId, - error - }) - }) - } - - private bumpSshRelayRecoveryGeneration(targetId: string): number { - const generation = (this.sshRelayRecoveryGenerationByTargetId.get(targetId) ?? 0) + 1 - this.sshRelayRecoveryGenerationByTargetId.set(targetId, generation) - return generation - } - - private collectMobileSessionWorktreeIdsForSshTarget(targetId: string): Set { - const worktreeIds = new Set() - const executionHostId = toSshExecutionHostId(targetId) - for (const worktreeId of [ - ...this.getKnownWorkspaceSessionWorktreeIds(), - ...this.mobileSessionTabsByWorktree.keys() - ]) { - if (this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) === executionHostId) { - worktreeIds.add(worktreeId) - } - } - return worktreeIds - } - - private async publishRecoveredSshMobileSessionTabs( - targetId: string, - generation: number - ): Promise { - const worktreeIds = new Set() - const executionHostId = toSshExecutionHostId(targetId) - for (const worktreeId of [ - ...this.getKnownWorkspaceSessionWorktreeIds(), - ...this.mobileSessionTabsByWorktree.keys() - ]) { - if (this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) === executionHostId) { - worktreeIds.add(worktreeId) - } - } - if (worktreeIds.size === 0) { - return - } - - // Why: relay readiness follows PTY reattach; rebuild the HUB-owned panes before paired clients consume the connected event. - for (const worktreeId of worktreeIds) { - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { - allowAttachedWindow: true, - onlyRuntimeOwnedTerminals: true - }) - } - await this.refreshMobileSessionPtyRecords() - if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) !== generation) { - return - } - for (const worktreeId of worktreeIds) { - this.notifyMobileSessionTabsChangedNow(worktreeId, ++this.mobileSessionTabsChangeSequence) - } - } - - invalidateSshWorktreeScanCache(targetId: string): void { - this.invalidateSshWorktreeScanCacheInternal(targetId) - } - - // Why: renderer-initiated meta updates intentionally skip the renderer - // notifier (the renderer already applied them optimistically), but remote - // clients hold no optimistic copy and need the invalidation event. - notifyWorktreesChangedForRemoteClients(repoId: string): void { - this.invalidateResolvedWorktreeCache() - this.emitClientEvent({ type: 'worktreesChanged', repoId }) - } - - // Why: structural catalog changes require a fresh Git scan; renderer metadata edits do not. - notifyWorktreeCatalogChangedForRemoteClients(repoId: string): void { - this.invalidateWorktreeScanCacheForRepo(repoId) - const matchingRepos = this.store?.getRepos().filter((repo) => repo.id === repoId) ?? [] - if (matchingRepos.length !== 1 || matchingRepos[0]?.connectionId) { - return - } - this.notifyWorktreesChangedForRemoteClients(repoId) - } - - // Why: host-local repo IPC mutations never enter runtime methods, so paired - // clients need an explicit catalog invalidation; the local renderer already - // got its own repos:changed and must not be re-notified (#11994). - notifyReposChangedForRemoteClients(): void { - this.emitClientEvent({ type: 'reposChanged' }) - } - - /** Why an object with a required `navigationTarget`: 'caller' means the requester already owns - * the navigation, so a site that forgets to forward it silently reverts to broadcasting at - * every viewer (STA-2802). A required key makes omission a compile error, and naming it makes - * a hardcoded target visible at the call site instead of hiding in a positional tail. */ - private notifyActivateWorktree( - repoId: string, - worktreeId: string, - launch: { - setup?: CreateWorktreeResult['setup'] - startup?: WorktreeStartupLaunch - defaultTabs?: CreateWorktreeResult['defaultTabs'] - navigationTarget: RuntimeNavigationTarget | undefined - } - ): void { - const { setup, startup, defaultTabs } = launch - const navigation = launch.navigationTarget ?? 'all' - if (navigationTargetsHost(navigation)) { - this.notifyHostActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs) - } - if (navigationTargetsClients(navigation)) { - this.notifyClientsActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs) - } - } - - private notifyHostActivateWorktree( - repoId: string, - worktreeId: string, - setup?: CreateWorktreeResult['setup'], - startup?: WorktreeStartupLaunch, - defaultTabs?: CreateWorktreeResult['defaultTabs'] - ): void { - this.notifier?.activateWorktree(repoId, worktreeId, setup, startup, defaultTabs) - } - - private notifyClientsActivateWorktree( - repoId: string, - worktreeId: string, - setup?: CreateWorktreeResult['setup'], - startup?: WorktreeStartupLaunch, - defaultTabs?: CreateWorktreeResult['defaultTabs'] - ): void { - this.emitClientEvent( - toRuntimeActivateWorktreeEvent(repoId, worktreeId, setup, startup, defaultTabs) - ) - } - - setAgentBrowserBridge(bridge: AgentBrowserBridge | null): void { - this.agentBrowserBridge = bridge - } - - getAgentBrowserBridge(): AgentBrowserBridge | null { - return this.agentBrowserBridge - } - - setOffscreenBrowserBackend(backend: BrowserBackend | null): void { - this.offscreenBrowserBackend = backend - } - - getOffscreenBrowserBackend(): BrowserBackend | null { - return this.offscreenBrowserBackend - } - - setEmulatorBridge(bridge: EmulatorBridge | null): void { - this.emulatorBridge = bridge - } - - getEmulatorBridge(): EmulatorBridge | null { - return this.emulatorBridge - } - - attachWindow(windowId: number): void { - if (this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID) { - if ( - this.pendingHeadlessPromotionWindowId !== null && - windowId !== this.pendingHeadlessPromotionWindowId - ) { - return - } - // Why: promotion is a renderer reload of the same graph owner, not a new - // runtime; stale handles must transition before the real window publishes. - this.persistWindowlessPtyBindingsForDesktopAttach() - this.pendingHeadlessPromotionWindowId = windowId - this.authoritativeWindowId = windowId - this.beginGraphReload(windowId) - return - } - if (this.authoritativeWindowId === null) { - // Why: a promoted serve can close and later reopen its window while new - // background PTYs keep arriving; every windowless gap needs this handoff. - this.persistWindowlessPtyBindingsForDesktopAttach() - this.authoritativeWindowId = windowId - } - } - - private persistWindowlessPtyBindingsForDesktopAttach(): void { - if (!this.store?.getWorkspaceSession || !this.store.setWorkspaceSession) { - return - } - const partitions = new Map< - ExecutionHostId, - { session: WorkspaceSessionState; ptys: RuntimePtyWorktreeRecord[] } - >() - for (const pty of this.ptysById.values()) { - if (!pty.connected || !pty.tabId) { - continue - } - const hostId = this.getWorkspaceSessionHostIdForWorktree(pty.worktreeId) - const session = this.store.getWorkspaceSession(hostId) - const tab = session.tabsByWorktree[pty.worktreeId]?.find( - (candidate) => candidate.id === pty.tabId - ) - if (!tab) { - continue - } - const layoutPtyIds = Object.values( - session.terminalLayoutsByTabId[pty.tabId]?.ptyIdsByLeafId ?? {} - ) - if (tab.ptyId !== pty.ptyId && !layoutPtyIds.includes(pty.ptyId)) { - continue - } - const partition = partitions.get(hostId) ?? { session, ptys: [] } - partition.ptys.push(pty) - partitions.set(hostId, partition) - } - - for (const [hostId, { session, ptys }] of partitions) { - // Why: windowless SSH PTYs must be handed to the desktop through their SSH partition, never the local session. - const activeWorktreeIdsOnShutdown = [ - ...new Set([ - ...(session.activeWorktreeIdsOnShutdown ?? []), - ...ptys.map((pty) => pty.worktreeId) - ]) - ] - const activeConnectionIdsAtShutdown = [ - ...new Set([ - ...(session.activeConnectionIdsAtShutdown ?? []), - ...ptys - .map((pty) => pty.connectionId) - .filter((connectionId): connectionId is string => connectionId !== null) - ]) - ] - const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId } - for (const pty of ptys) { - if (pty.connectionId && pty.tabId) { - remoteSessionIdsByTabId[pty.tabId] = pty.ptyId - } - } - - this.store.setWorkspaceSession( - { - ...session, - activeWorktreeIdsOnShutdown, - ...(activeConnectionIdsAtShutdown.length > 0 ? { activeConnectionIdsAtShutdown } : {}), - ...(Object.keys(remoteSessionIdsByTabId).length > 0 ? { remoteSessionIdsByTabId } : {}) - }, - hostId - ) - } - } - - syncWindowGraph( - windowId: number, - graph: RuntimeSyncWindowGraph | RuntimeRendererSyncWindowGraph - ): RuntimeSyncWindowGraphResult { - // `tabs` and several downstream indexes are keyed only by tab id. Reject - // malformed persisted/mirrored graphs before authority or graph state is - // changed; choosing a winner would route PTYs to the wrong worktree. - assertUniqueRuntimeGraphTabIds(graph.tabs) - if ( - windowId !== HEADLESS_RUNTIME_WINDOW_ID && - this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID && - this.headlessGraphFallbackAvailable - ) { - if (windowId !== this.pendingHeadlessPromotionWindowId) { - throw new Error('Runtime graph publisher does not match the pending desktop promotion') - } - // Why: a renderer may publish after a failed promotion was restored to - // headless authority; accepting that late healthy graph is self-healing. - this.attachWindow(windowId) - } - if (this.authoritativeWindowId === null) { - this.authoritativeWindowId = windowId - } - if (windowId !== this.authoritativeWindowId) { - throw new Error('Runtime graph publisher does not match the authoritative window') - } - const rendererGeneration = - windowId === HEADLESS_RUNTIME_WINDOW_ID - ? null - : 'rendererGeneration' in graph && typeof graph.rendererGeneration === 'string' - ? graph.rendererGeneration - : undefined - if ( - typeof rendererGeneration === 'string' && - rendererGeneration === this.rendererGeneration && - this.graphStatus !== 'ready' - ) { - throw new Error('Runtime graph publisher belongs to a superseded renderer generation') - } - if (windowId === HEADLESS_RUNTIME_WINDOW_ID) { - this.headlessGraphFallbackAvailable = true - this.rendererGeneration = null - } - - const graphWasReady = this.graphStatus === 'ready' - const previousTabs = this.tabs - const previousLeaves = this.leaves - this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab])) - const lifecycleLeaves = this.reconcileMobileSessionRetirementFences(graph.leaves) - const mobileSessionResyncWorktrees = new Set() - const changedMobileWorktrees = this.syncMobileSessionTabs( - graph.mobileSessionTabs, - graph.unchangedMobileSessionWorktrees, - mobileSessionResyncWorktrees - ) - const nextLeaves = new Map() - const graphSyncedAt = this.nextTitleObservationSequence() - - // Why: renderer reloads can briefly republish the same leaf with no ptyId; - // keep live CLI handles usable while the UI graph rebuilds. - const preserveLivePtysDuringReload = this.graphStatus === 'reloading' - for (const leaf of lifecycleLeaves) { - const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) - const existing = this.leaves.get(leafKey) - const ptyId = - preserveLivePtysDuringReload && leaf.ptyId === null && existing?.ptyId - ? existing.ptyId - : leaf.ptyId - const ptyGeneration = - existing && existing.ptyId !== ptyId - ? existing.ptyGeneration + 1 - : (existing?.ptyGeneration ?? 0) - const existingPty = ptyId ? this.ptysById.get(ptyId) : undefined - const tailSource = existing?.ptyId === ptyId ? existing : existingPty - - nextLeaves.set(leafKey, { - ...leaf, - ptyId, - ptyGeneration, - connected: ptyId !== null, - writable: this.graphStatus === 'ready' && ptyId !== null, - lastOutputAt: tailSource?.lastOutputAt ?? null, - lastExitCode: tailSource?.lastExitCode ?? null, - lastExitCause: tailSource?.lastExitCause ?? null, - tailBuffer: tailSource?.tailBuffer ?? [], - tailTranscriptBuffer: tailSource?.tailTranscriptBuffer ?? [], - tailTranscriptChars: tailSource?.tailTranscriptChars ?? 0, - tailPartialLine: tailSource?.tailPartialLine ?? '', - tailPendingAnsi: tailSource?.tailPendingAnsi ?? '', - tailRedrawCursor: tailSource?.tailRedrawCursor ?? null, - tailTruncated: tailSource?.tailTruncated ?? false, - tailLinesTotal: tailSource?.tailLinesTotal ?? 0, - preview: tailSource?.preview ?? '', - waitBlockedAt: tailSource?.waitBlockedAt ?? null, - lastAgentStatus: tailSource?.lastAgentStatus ?? null, - lastAgentStatusObservedLive: tailSource?.lastAgentStatusObservedLive ?? false, - lastOscTitle: tailSource?.lastOscTitle ?? null, - lastOscTitleAt: tailSource?.lastOscTitleAt ?? null, - paneTitleUpdatedAt: - existing?.ptyId === ptyId && existing.paneTitle === leaf.paneTitle - ? existing.paneTitleUpdatedAt - : graphSyncedAt - }) - - if (leaf.ptyId) { - this.recordPtyWorktree(leaf.ptyId, leaf.worktreeId, { - connected: true, - lastOutputAt: existing?.ptyId === leaf.ptyId ? existing.lastOutputAt : null, - preview: existing?.ptyId === leaf.ptyId ? existing.preview : '', - tabId: leaf.tabId, - paneKey: this.makeRuntimePaneKey(leaf) - }) - } - - if (existing && (existing.ptyId !== ptyId || existing.ptyGeneration !== ptyGeneration)) { - // Why: mobile can subscribe while the pane is waiting for its first PTY. - // Keep that handle usable after the recovery mount binds it. - const adoptedFirstPty = - existing.ptyId === null && this.adoptFirstPtyForLeafHandle(leafKey, ptyId, ptyGeneration) - if (!adoptedFirstPty) { - this.invalidateLeafHandle(leafKey) - } - } - } - - // Why: computed BEFORE preserving stale leaves so preservation can refuse a - // leaf whose PTY the incoming graph already rebound to a live leaf. Two - // leaves on one PTY resolve to the same handle (handles are ptyId-keyed) and - // crash paired clients with a duplicate React key. - const nextPtyIds = new Set( - [...nextLeaves.values()].map((leaf) => leaf.ptyId).filter((ptyId): ptyId is string => !!ptyId) - ) - for (const oldLeafKey of this.leaves.keys()) { - if (!nextLeaves.has(oldLeafKey)) { - const oldLeaf = this.leaves.get(oldLeafKey) - const retainedIncarnation = oldLeaf?.ptyId - ? this.handleByPtyIncarnation.get(oldLeaf.ptyId) - : undefined - if ( - preserveLivePtysDuringReload && - oldLeaf?.ptyId && - (this.handleByPtyId.has(oldLeaf.ptyId) || - (retainedIncarnation && - retainedIncarnation.incarnationId === - this.ptysById.get(oldLeaf.ptyId)?.incarnationId)) && - !nextPtyIds.has(oldLeaf.ptyId) - ) { - // Why: the first reload graph can precede pane rebinding; the live PTY incarnation still owns its handle. - nextLeaves.set(oldLeafKey, oldLeaf) - nextPtyIds.add(oldLeaf.ptyId) - } else if (oldLeaf?.ptyId && nextPtyIds.has(oldLeaf.ptyId)) { - // Why: the incoming graph already rebound this PTY to a live leaf (e.g. - // a woken agent re-keyed to a new leaf during renderer reload). Keeping - // the old leaf too would put two leaves on ONE PTY, which emit the same - // terminal handle and crash paired clients. Drop the stale leaf; if its - // handle is the shared ptyId-keyed one it belongs to the live leaf now, - // so release only this dead leaf key's alias. A leaf-unique handle has - // no next owner — invalidate it so in-flight CLI waiters fail fast - // instead of hanging on a dead leaf. - const oldHandle = this.handleByLeafKey.get(oldLeafKey) - const incarnationHandle = retainedIncarnation?.handle - if ( - oldHandle !== undefined && - (oldHandle === this.handleByPtyId.get(oldLeaf.ptyId) || oldHandle === incarnationHandle) - ) { - this.handleByLeafKey.delete(oldLeafKey) - } else { - this.invalidateLeafHandle(oldLeafKey) - } - } else { - this.invalidateLeafHandle(oldLeafKey) - } - } - } - - for (const [ptyId, leaf] of this.detachedPreAllocatedLeaves) { - if (nextPtyIds.has(ptyId) || !this.handleByPtyId.has(ptyId)) { - this.detachedPreAllocatedLeaves.delete(ptyId) - continue - } - nextLeaves.set(this.getLeafKey(leaf.tabId, leaf.leafId), leaf) - nextPtyIds.add(ptyId) - } - - this.leaves = nextLeaves - this.rebuildLeafPtyIndex() - this.reconcilePtyIncarnationHandles() - // Why: the emitted client payload is a function of the stored snapshot AND - // the tab/leaf graph (handles/titles/connected resolve from leaf state), so - // a graph-only change — e.g. a restored leaf binding its ptyId while the - // snapshot pair is unchanged — must also fan out, or a paired client stays - // on pending-handle forever. Schedule the union on the same 50ms trailing - // edge as the OSC-title path; the coalescer emit reads the latest state at - // fire time so no final version is ever lost. - for (const worktreeId of this.collectMobileVisibleGraphChangedWorktrees( - previousTabs, - previousLeaves - )) { - if (changedMobileWorktrees.has(worktreeId)) { - continue - } - const stored = this.mobileSessionTabsByWorktree.get(worktreeId) - if (!stored) { - continue - } - // Why: web clients drop same-epoch frames whose version isn't strictly - // newer, so a graph-only change must mint a fresh stored version (like - // the PTY touch path does) or the re-emitted payload — e.g. the - // pending-handle → ready flip — is discarded and the client stays stale. - // The accepted-renderer tracking is untouched: this is a main-local bump. - this.mobileSessionTabsByWorktree.set(worktreeId, { - ...stored, - snapshotVersion: stored.snapshotVersion + 1 - }) - changedMobileWorktrees.add(worktreeId) - } - for (const worktreeId of changedMobileWorktrees) { - if (this.mobileSessionTabsByWorktree.has(worktreeId)) { - this.scheduleMobileSessionTabsChanged(worktreeId) - } - } - // Why: only the authoritative window grants inventory authority; headless qualifies because it becomes authoritative before its next sync. - const isAuthoritativeGraphPublisher = windowId === this.authoritativeWindowId - this.markGraphReady(windowId) - if ( - isAuthoritativeGraphPublisher && - (windowId === HEADLESS_RUNTIME_WINDOW_ID || graph.mobileSessionTabs !== undefined) - ) { - if (mobileSessionResyncWorktrees.size === 0) { - this.markSessionTabsInventoryPublished() - } else { - this.sessionTabsInventoryPublicationEpoch = null - } - } - if (rendererGeneration !== undefined) { - this.rendererGeneration = rendererGeneration - } - for (const leaf of this.leaves.values()) { - this.adoptPreAllocatedHandle(leaf) - const previousLeaf = previousLeaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) - if ( - this._orchestrationDb && - leaf.lastAgentStatus === 'idle' && - leaf.lastAgentStatusObservedLive && - leaf.writable && - (!graphWasReady || - previousLeaf?.ptyId !== leaf.ptyId || - !previousLeaf.writable || - previousLeaf.lastAgentStatus !== 'idle' || - !previousLeaf.lastAgentStatusObservedLive) - ) { - this.deliverPendingMessagesForLeaf(leaf) - } - } - - // Why: createTerminal waits for the renderer's graph sync to populate the - // new leaf so it can return a handle. Drain callbacks after leaves update. - for (const cb of [...this.graphSyncCallbacks]) { - cb() - } - - const agentOrchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey() - const nativeChatLaunchDraftResolutions = - this.getNativeChatLaunchDraftResolutionClientEventSnapshot().map( - ({ tabId, text, createdAt }) => ({ tabId, text, createdAt }) - ) - return { - ...this.getStatus(), - ...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {}), - ...(nativeChatLaunchDraftResolutions.length > 0 ? { nativeChatLaunchDraftResolutions } : {}), - ...(mobileSessionResyncWorktrees.size > 0 - ? { mobileSessionResyncWorktrees: [...mobileSessionResyncWorktrees] } - : {}) - } - } - - // Why: toMobileSessionTabsResult resolves handles/titles from this.tabs and - // this.leaves, so any tab/leaf delta a graph sync installs can flip the - // client payload (pending-handle → ready, tab title) with zero change to the - // stored snapshot. Compare exactly the projection-relevant fields and report - // the affected worktrees; false positives only cost a coalesced no-op emit. - private collectMobileVisibleGraphChangedWorktrees( - previousTabs: Map, - previousLeaves: Map - ): Set { - const changed = new Set() - for (const [tabId, tab] of this.tabs) { - const prev = previousTabs.get(tabId) - if (!prev || prev.title !== tab.title) { - changed.add(tab.worktreeId) - } - } - for (const [tabId, tab] of previousTabs) { - if (!this.tabs.has(tabId)) { - changed.add(tab.worktreeId) - } - } - for (const [leafKey, leaf] of this.leaves) { - const prev = previousLeaves.get(leafKey) - if ( - !prev || - prev.ptyId !== leaf.ptyId || - prev.connected !== leaf.connected || - prev.paneTitle !== leaf.paneTitle - ) { - changed.add(leaf.worktreeId) - } - } - for (const [leafKey, leaf] of previousLeaves) { - if (!this.leaves.has(leafKey)) { - changed.add(leaf.worktreeId) - } - } - return changed - } - - async listMobileSessionTabs( - worktreeSelector: string, - clientNavigationId?: string - ): Promise { - const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) - if (explicitWorktreeId) { - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId, { - allowAttachedWindow: true, - onlyRuntimeOwnedTerminals: true - }) - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId) - await this.refreshMobileSessionPtyRecords(explicitWorktreeId) - this.restoreLivePairedRendererSessionOwnedMobileTerminals(explicitWorktreeId) - return this.getMobileSessionTabsForWorktree(explicitWorktreeId, clientNavigationId) - } - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, { - allowAttachedWindow: true, - onlyRuntimeOwnedTerminals: true - }) - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id) - await this.refreshMobileSessionPtyRecords() - this.restoreLivePairedRendererSessionOwnedMobileTerminals(worktree.id) - return this.getMobileSessionTabsForWorktree(worktree.id, clientNavigationId) - } - - async listAllMobileSessionTabs( - clientNavigationId?: string - ): Promise { - return (await this.listAllMobileSessionTabsWithChangeSequence(clientNavigationId)).snapshots - } - - async listAllMobileSessionTabsWithChangeSequence(clientNavigationId?: string): Promise<{ - snapshots: RuntimeMobileSessionTabsResult[] - changeSequence: number - }> { - const inventory = await this.collectAllMobileSessionTabs(clientNavigationId) - return { snapshots: inventory.snapshots, changeSequence: inventory.changeSequence } - } - - private async collectAllMobileSessionTabs(clientNavigationId?: string): Promise<{ - snapshots: RuntimeMobileSessionTabsResult[] - ptyInventory: PtyControllerInventory | null - changeSequence: number - }> { - for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { - allowAttachedWindow: true, - onlyRuntimeOwnedTerminals: true - }) - } - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() - const ptyInventory = await this.refreshMobileSessionPtyInventory() - this.restoreLivePairedRendererSessionOwnedMobileTerminals(null) - const snapshots = [...this.mobileSessionTabsByWorktree.values()].map((snapshot) => - this.projectMobileSessionTabsForClient( - this.toMobileSessionTabsResult(snapshot), - clientNavigationId - ) - ) - return { snapshots, ptyInventory, changeSequence: this.mobileSessionTabsChangeSequence } - } - - async listAllMobileSessionTabsInventory( - clientNavigationId?: string, - signal?: AbortSignal - ): Promise<{ snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: true }> { - const { snapshots, authoritative } = - await this.listAllMobileSessionTabsInventoryWithChangeSequence(clientNavigationId, signal) - return { snapshots, ...(authoritative ? { authoritative } : {}) } - } - - async listAllMobileSessionTabsInventoryWithChangeSequence( - clientNavigationId?: string, - signal?: AbortSignal - ): Promise<{ - snapshots: RuntimeMobileSessionTabsResult[] - authoritative?: true - changeSequence: number - }> { - this.assertSessionTabsInventoryRequestActive(signal) - const primedPublicationEpoch = this.getAuthoritativeSessionTabsInventoryEpoch() - const primed = await this.collectAllMobileSessionTabs(clientNavigationId) - this.assertSessionTabsInventoryRequestActive(signal) - if ( - primedPublicationEpoch !== null && - this.getAuthoritativeSessionTabsInventoryEpoch() === primedPublicationEpoch - ) { - return await this.settleSessionTabsInventory(primed, clientNavigationId, signal) - } - while (true) { - const publicationEpoch = this.getAuthoritativeSessionTabsInventoryEpoch() - if (publicationEpoch === null) { - await this.waitForSessionTabsInventoryPublication(signal) - continue - } - const inventory = await this.collectAllMobileSessionTabs(clientNavigationId) - this.assertSessionTabsInventoryRequestActive(signal) - if (this.getAuthoritativeSessionTabsInventoryEpoch() === publicationEpoch) { - return await this.settleSessionTabsInventory(inventory, clientNavigationId, signal) - } - } - } - - // Why: a failed census only invalidates the emptiness verdict, never the - // list. An incomplete census usually means a concurrent scan invalidated - // this one mid-relaunch and daemon-backed tabs could not be restored yet, so - // retry the collection once; a still-incomplete retry serves its snapshots - // unlabeled rather than erroring the request. - private async settleSessionTabsInventory( - inventory: { - snapshots: RuntimeMobileSessionTabsResult[] - ptyInventory: PtyControllerInventory | null - changeSequence: number - }, - clientNavigationId?: string, - signal?: AbortSignal - ): Promise<{ - snapshots: RuntimeMobileSessionTabsResult[] - authoritative?: true - changeSequence: number - }> { - if (this.isCompleteSessionTabsPtyCensus(inventory.ptyInventory)) { - return { - snapshots: inventory.snapshots, - authoritative: true, - changeSequence: inventory.changeSequence - } - } - const retried = await this.collectAllMobileSessionTabs(clientNavigationId) - this.assertSessionTabsInventoryRequestActive(signal) - // Why: the retry ran outside the epoch fence, so it never claims authority. - return { snapshots: retried.snapshots, changeSequence: retried.changeSequence } - } - - supportsAuthoritativeSessionTabsInventory(): boolean { - return process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1' - } - - private assertSessionTabsInventoryRequestActive(signal?: AbortSignal): void { - if (signal?.aborted) { - throw new Error('client_disconnected') - } - } - - private isCompleteSessionTabsPtyCensus(inventory: PtyControllerInventory | null): boolean { - if (!inventory) { - return false - } - const knownHostIds = this.listKnownExecutionHostIds(inventory.queriedHostIds) - return ![...knownHostIds].some((hostId) => { - const parsed = parseExecutionHostId(hostId) - return parsed?.kind !== 'runtime' && !inventory.queriedHostIds.has(hostId) - }) - } - - private waitForSessionTabsInventoryPublication(signal?: AbortSignal): Promise { - if (this.getAuthoritativeSessionTabsInventoryEpoch() !== null) { - return Promise.resolve() - } - return new Promise((resolve, reject) => { - const cleanup = (): void => { - this.sessionTabsInventoryWaiters.delete(onPublished) - signal?.removeEventListener('abort', onAbort) - } - const onPublished = (): void => { - cleanup() - resolve() - } - const onAbort = (): void => { - cleanup() - reject(new Error('client_disconnected')) - } - this.sessionTabsInventoryWaiters.add(onPublished) - signal?.addEventListener('abort', onAbort, { once: true }) - if (signal?.aborted) { - onAbort() - } else if (this.getAuthoritativeSessionTabsInventoryEpoch() !== null) { - onPublished() - } - }) - } - - private getAuthoritativeSessionTabsInventoryEpoch(): number | null { - return this.graphStatus === 'ready' && - this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch - ? this.rendererGraphEpoch - : null - } - - private markSessionTabsInventoryPublished(): void { - if (this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch) { - return - } - this.sessionTabsInventoryPublicationEpoch = this.rendererGraphEpoch - for (const publish of [...this.sessionTabsInventoryWaiters]) { - publish() - } - } - - private hydrateHeadlessMobileSessionTabsFromWorkspaceSession( - worktreeId?: string, - options: { - force?: boolean - allowAttachedWindow?: boolean - onlyRuntimeOwnedTerminals?: boolean - runtimeOwnedTerminalCandidateKnown?: boolean - workspaceSession?: WorkspaceSessionState - } = {} - ): Set { - // Why: report which worktrees were reconciled in place so callers don't - // reconcile them a second time (see notifyMobileSessionTabsChanged). - const reconciledWorktreeIds = new Set() - if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) { - return reconciledWorktreeIds - } - const session = - options.workspaceSession ?? - (worktreeId - ? this.getWorkspaceSessionForWorktree(worktreeId) - : this.store?.getWorkspaceSession?.()) - if (!session) { - return reconciledWorktreeIds - } - // Why: with no runtime-owned candidate in the session and no offscreen - // browser backend, this hydrate provably builds zero tabs for - // every worktree — skip the per-worktree rebuild entirely (hot on every - // graph sync). Scoped to onlyRuntimeOwnedTerminals so full hydrates are - // untouched. - if ( - options.onlyRuntimeOwnedTerminals === true && - !this.offscreenBrowserBackend && - getRuntimeBrowserPageRegistry(this).listPages(worktreeId ?? '').length === 0 && - options.runtimeOwnedTerminalCandidateKnown !== true && - !(worktreeId - ? this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate( - session, - worktreeId, - session.tabsByWorktree[worktreeId] ?? [] - ) - : this.workspaceSessionHasRuntimeOwnedPtyCandidate(session)) - ) { - return reconciledWorktreeIds - } - const entries = - worktreeId !== undefined - ? ([[worktreeId, session.tabsByWorktree[worktreeId] ?? []]] as const) - : Object.entries(session.tabsByWorktree ?? {}) - // Why: workspaceSession keys are `${repoId}::${path}` and are not pruned when - // a repo disappears from this client's view (e.g. removed on another client, - // or a stale browser-persisted session). Hydrating such a key would surface a - // phantom "unknown"/duplicate workspace with no live repo behind it. Only - // hydrate sessions whose repo still exists; leave unparseable keys alone. - // Resolved lazily so unparseable keys (floating terminals) never pay for a - // repo inventory on the hot poll path, and `null` when the store cannot - // report repos — an unavailable list must not read as "every repo is gone". - let liveRepoIds: Set | null | undefined - for (const [entryWorktreeId, persistedTabs] of entries) { - const ownerRepoId = splitWorktreeIdForFilesystem(entryWorktreeId)?.repoId - if (ownerRepoId) { - if (liveRepoIds === undefined) { - const knownRepos = this.store?.getRepos?.() - liveRepoIds = knownRepos ? new Set(knownRepos.map((repo) => repo.id)) : null - } - if (liveRepoIds && !liveRepoIds.has(ownerRepoId)) { - continue - } - } - const existing = this.mobileSessionTabsByWorktree.get(entryWorktreeId) - if ( - existing && - existing.tabs.length > 0 && - options.force !== true && - options.onlyRuntimeOwnedTerminals !== true - ) { - // Why: terminals are stable/persisted so we normally skip a rebuild, but - // offscreen browser tabs are live and may have been created/closed since. - // Reconcile just the browser tabs against the live bridge instead of - // leaving a stale snapshot that omits a freshly-opened browser tab. - this.reconcileHeadlessMobileSessionBrowserTabs(entryWorktreeId, existing) - reconciledWorktreeIds.add(entryWorktreeId) - continue - } - const terminalTabs = this.buildHeadlessMobileSessionTerminalTabs( - entryWorktreeId, - persistedTabs, - session - ).filter( - (tab) => - options.onlyRuntimeOwnedTerminals !== true || - this.hasServeOrSshOwnedBinding(tab) || - this.hasRecentExpiredSshLeasePane(entryWorktreeId, tab) - ) - // Why: offscreen browser panes are live-only (no persisted session entry), - // so include them on every hydrate regardless of the onlyRuntimeOwnedTerminals - // filter, which is about terminal PTY ownership and never applies to browsers. - const browserTabs = this.buildHeadlessMobileSessionBrowserTabs(entryWorktreeId) - const tabs: RuntimeMobileSessionSnapshotTab[] = [...terminalTabs, ...browserTabs] - if (tabs.length === 0) { - continue - } - const activeTab = this.pickHeadlessActiveTerminalTab(terminalTabs) - const tabOrder = [ - ...this.collectHeadlessParentTabOrder(terminalTabs), - ...browserTabs.map((tab) => tab.id) - ] - const groupId = this.getHeadlessMobileSessionGroupId(entryWorktreeId) - const mergedTabs = - options.onlyRuntimeOwnedTerminals === true && existing - ? this.mergeMobileSessionSnapshotTabs(existing.tabs, tabs) - : tabs - const mergedActiveTab = - existing?.tabs.find((tab) => tab.id === existing.activeTabId) ?? - activeTab ?? - mergedTabs[0] ?? - null - const mergedTerminalTabs = mergedTabs.filter( - (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' - ) - const mergedBrowserOrder = mergedTabs - .filter((tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser') - .map((tab) => tab.id) - // Why: a persisted multi-group split must be restored on cold rebuild, or - // the headless serve coalesces the user's group layout back into one group - // (the persisted tabGroups/tabGroupLayouts would otherwise be write-only). - const persistedGroups = session.tabGroups?.[entryWorktreeId] - const persistedLayout = session.tabGroupLayouts?.[entryWorktreeId] - const hasPersistedSplit = - options.onlyRuntimeOwnedTerminals !== true && - persistedGroups !== undefined && - persistedGroups.length > 1 - const activeTopLevelId = mergedActiveTab - ? mergedActiveTab.type === 'terminal' - ? mergedActiveTab.parentTabId - : mergedActiveTab.id - : null - const nextTabGroups: RuntimeMobileSessionTabGroup[] = hasPersistedSplit - ? this.appendBrowserTabOrder( - this.distributeHeadlessTabsAcrossGroups( - persistedGroups.map((group) => ({ - id: group.id, - activeTabId: group.activeTabId, - tabOrder: [...group.tabOrder], - ...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {}) - })), - this.collectHeadlessParentTabOrder(mergedTerminalTabs), - activeTopLevelId - ), - mergedBrowserOrder, - undefined, - // Why: distribute drops browser ids (terminal-only), so carry each - // browser's persisted group forward instead of coalescing left. - this.collectBrowserGroupAssignment(persistedGroups, mergedBrowserOrder) - ) - : options.onlyRuntimeOwnedTerminals === true && existing?.tabGroups - ? this.appendBrowserTabOrder( - this.mergeMobileSessionTabGroups( - entryWorktreeId, - existing.tabGroups, - mergedTerminalTabs, - mergedActiveTab?.type === 'terminal' ? mergedActiveTab : null - ), - mergedBrowserOrder - ) - : [ - { - id: groupId, - activeTabId: mergedActiveTab?.id - ? (activeTab?.parentTabId ?? mergedActiveTab.id) - : (tabOrder[0] ?? null), - tabOrder - } - ] - // Why: merging runtime tabs INTO a renderer publication must not reclass - // the snapshot as headless-built — the preservation predicate would then - // treat the renderer's own tabs as runtime-owned and resurrect tabs the - // renderer later closes. Keep the renderer base epoch with a merge suffix - // (idempotent) so ownership stays derivable from the epoch. - const mergedIntoRendererPublication = - options.onlyRuntimeOwnedTerminals === true && - existing !== undefined && - !this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch) - const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { - worktree: existing?.worktree ?? entryWorktreeId, - publicationEpoch: mergedIntoRendererPublication - ? this.getMergedMobileSessionPublicationEpoch(existing, tabs) - : `headless-hydrated:${Date.now().toString(36)}`, - snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, - activeGroupId: existing?.activeGroupId ?? groupId, - activeTabId: mergedActiveTab?.id ?? null, - activeTabType: mergedActiveTab?.type ?? null, - tabGroups: nextTabGroups, - // Why: the runtime-owned rebuild runs on every graph sync — carry the - // existing split layout forward or each sync drops it and fans out. - ...(hasPersistedSplit && persistedLayout - ? { tabGroupLayout: persistedLayout } - : options.onlyRuntimeOwnedTerminals === true && existing?.tabGroupLayout - ? { tabGroupLayout: existing.tabGroupLayout } - : {}), - tabs: mergedTabs - } - // Why: the runtime-owned hydrate runs on EVERY graph sync; when the rebuilt - // projection matches the existing snapshot, keep the existing object and - // (epoch, version) untouched so identity-based change detection stays a - // pure no-op and unchanged runtime/browser worktrees never fan out. - if (existing && this.headlessMobileSnapshotContentUnchanged(existing, nextSnapshot)) { - continue - } - this.mobileSessionTabsByWorktree.set(entryWorktreeId, nextSnapshot) - } - return reconciledWorktreeIds - } - - // Why: content equality for the hydrate's idempotence check — compares every - // client-visible field EXCEPT publicationEpoch/snapshotVersion (both are - // freshly minted on each rebuild and would defeat the comparison). Tab and - // group objects are rebuilt each hydrate, so compare by value, not identity. - private headlessMobileSnapshotContentUnchanged( - existing: RuntimeMobileSessionTabsSnapshot, - next: RuntimeMobileSessionTabsSnapshot - ): boolean { - if ( - existing.worktree !== next.worktree || - existing.activeGroupId !== next.activeGroupId || - existing.activeTabId !== next.activeTabId || - existing.activeTabType !== next.activeTabType - ) { - return false - } - // Why: this runs per persisted worktree on EVERY graph sync whenever a - // serve PTY exists, so compare structurally instead of stable-stringifying - // both sides (which allocated six full serialized trees per worktree). - return ( - this.mobileSnapshotValueEqual(existing.tabs, next.tabs) && - this.mobileSnapshotValueEqual(existing.tabGroups ?? null, next.tabGroups ?? null) && - this.mobileSnapshotValueEqual(existing.tabGroupLayout ?? null, next.tabGroupLayout ?? null) - ) - } - - // Deep structural equality over plain snapshot JSON (objects/arrays/scalars). - // Key order is irrelevant; a mismatch only costs a coalesced no-op emit. - private mobileSnapshotValueEqual(a: unknown, b: unknown): boolean { - if (a === b) { - return true - } - if (Array.isArray(a) || Array.isArray(b)) { - if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { - return false - } - for (let index = 0; index < a.length; index++) { - if (!this.mobileSnapshotValueEqual(a[index], b[index])) { - return false - } - } - return true - } - if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') { - const aRecord = a as Record - const bRecord = b as Record - const aKeys = Object.keys(aRecord) - if (aKeys.length !== Object.keys(bRecord).length) { - return false - } - for (const key of aKeys) { - if ( - !Object.hasOwn(bRecord, key) || - !this.mobileSnapshotValueEqual(aRecord[key], bRecord[key]) - ) { - return false - } - } - return true - } - return false - } - - // Why: keep an existing snapshot's browser tabs in sync with the live bridge - // without rebuilding stable terminal state. Replaces browser entries with the - // current live set and rewrites the browser portion of the primary group order. - private reconcileHeadlessMobileSessionBrowserTabs( - worktreeId: string, - existing: RuntimeMobileSessionTabsSnapshot - ): void { - const liveBrowserTabs = this.buildHeadlessMobileSessionBrowserTabs(worktreeId) - const liveIds = liveBrowserTabs.map((tab) => tab.id) - const existingBrowserTabs = existing.tabs.filter( - (tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser' - ) - const existingBrowserIds = existingBrowserTabs.map((tab) => tab.id) - if (this.headlessBrowserTabsUnchanged(liveBrowserTabs, existingBrowserTabs)) { - return - } - const nonBrowserTabs = existing.tabs.filter((tab) => tab.type !== 'browser') - const nextTabs: RuntimeMobileSessionSnapshotTab[] = [...nonBrowserTabs, ...liveBrowserTabs] - const liveIdSet = new Set(liveIds) - const tabGroups = this.appendBrowserTabOrder( - (existing.tabGroups ?? []).map((group) => ({ - ...group, - // Drop closed browser ids; appendBrowserTabOrder re-adds the live ones. - tabOrder: group.tabOrder.filter( - (id) => liveIdSet.has(id) || !existingBrowserIds.includes(id) - ) - })), - liveIds - ) - const activeStillPresent = nextTabs.some((tab) => tab.id === existing.activeTabId) - const active = activeStillPresent - ? null - : (nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null) - this.mobileSessionTabsByWorktree.set(worktreeId, { - ...existing, - publicationEpoch: `headless-hydrated:${Date.now().toString(36)}`, - snapshotVersion: existing.snapshotVersion + 1, - ...(activeStillPresent - ? {} - : { activeTabId: active?.id ?? null, activeTabType: active?.type ?? null }), - tabGroups, - tabs: nextTabs - }) - } - - // Why: browser session tabs have no parentTabId so the terminal-only group - // builder drops them from tabOrder; this re-adds their ids to a group. - // Browser tabs are live-only (no persisted session entry), but their GROUP - // membership must still survive snapshot rebuilds like terminals'. The - // passed-in groups already encode each browser's group (carried from the prior - // snapshot / persisted tabGroups), so keep each existing browser id where it - // is; only a genuinely-new browser id goes to its create-target group (when - // that group exists) and otherwise to the first group. Previously every - // browser was force-pushed into group[0], so opening a browser in the right - // split group always snapped it back to the left on the next rebuild. - private appendBrowserTabOrder( - groups: readonly RuntimeMobileSessionTabGroup[], - browserTabIds: readonly string[], - newTabAssignment?: { tabId: string; groupId: string }, - // browserPageId -> groupId from the prior/persisted groups. The terminal - // distributor rebuilds tabOrder from terminal ids only and drops browser - // ids, so this carries each browser's group across rebuilds. - priorGroupByBrowserId?: ReadonlyMap - ): RuntimeMobileSessionTabGroup[] { - if (browserTabIds.length === 0) { - return [...groups] - } - const next = groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] })) - if (next.length === 0) { - return next - } - const groupById = new Map(next.map((group) => [group.id, group])) - const ownerGroupByTabId = new Map() - for (const group of next) { - for (const id of group.tabOrder) { - ownerGroupByTabId.set(id, group) - } - } - for (const id of browserTabIds) { - if (ownerGroupByTabId.has(id)) { - continue - } - const priorGroupId = priorGroupByBrowserId?.get(id) - const targetGroup = - (newTabAssignment?.tabId === id ? groupById.get(newTabAssignment.groupId) : undefined) ?? - (priorGroupId ? groupById.get(priorGroupId) : undefined) ?? - next[0]! - targetGroup.tabOrder.push(id) - } - return next - } - - // browserPageId -> groupId from a set of groups (the persisted/prior layout), - // so a browser stays in its group across rebuilds that drop browser ids. - private collectBrowserGroupAssignment( - groups: readonly RuntimeMobileSessionTabGroup[] | undefined, - browserTabIds: readonly string[] - ): Map { - const browserIdSet = new Set(browserTabIds) - const assignment = new Map() - for (const group of groups ?? []) { - for (const id of group.tabOrder) { - if (browserIdSet.has(id)) { - assignment.set(id, group.id) - } - } - } - return assignment - } - - private isServeOwnedPtyId(ptyId: string | null | undefined): boolean { - return typeof ptyId === 'string' && ptyId.startsWith('serve-') - } - - private isSshOwnedPtyId(ptyId: string | null | undefined): boolean { - return typeof ptyId === 'string' && parseAppSshPtyId(ptyId) !== null - } - - private workspaceSessionHasRuntimeOwnedPtyCandidate(session: WorkspaceSessionState): boolean { - return Object.entries(session.tabsByWorktree ?? {}).some(([worktreeId, tabs]) => - this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(session, worktreeId, tabs) - ) - } - - private workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate( - session: WorkspaceSessionState, - worktreeId: string, - tabs: WorkspaceSessionState['tabsByWorktree'][string] - ): boolean { - return tabs.some((tab) => { - if (this.isServeOrSshOwnedPtyId(tab.ptyId)) { - return true - } - const leafPtyIds = session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId - return ( - (leafPtyIds && - Object.values(leafPtyIds).some((ptyId) => this.isServeOrSshOwnedPtyId(ptyId))) || - // Why: expiry keeps pane coordinates so paired viewers can request a fresh shell. - this.getRecentExpiredSshLease(worktreeId, tab.id, undefined) !== null - ) - }) - } - - private getRecentExpiredSshLease( - worktreeId: string, - tabId: string, - leafId: string | undefined, - ptyId?: string - ): ReturnType>[number] | null { - const now = Date.now() - return ( - this.store - ?.getSshRemotePtyLeases?.() - .find( - (lease) => - lease.state === 'expired' && - lease.worktreeId === worktreeId && - lease.tabId === tabId && - (ptyId === undefined || lease.ptyId === ptyId) && - (leafId === undefined || lease.leafId === undefined || lease.leafId === leafId) && - lease.updatedAt <= now && - now - lease.updatedAt <= SSH_PANE_RECOVERY_GRACE_MS - ) ?? null - ) - } - - private hasRecentExpiredSshLeasePane( - worktreeId: string, - tab: RuntimeMobileSessionTerminalTab - ): boolean { - return this.getRecentExpiredSshLease(worktreeId, tab.parentTabId, tab.leafId) !== null - } - - // Why: serve-* (local serve) and ssh:@@ (SSH relay) ids are minted - // ONLY for runtime-owned terminals and are preserved/re-hydrated, so tear them - // down even if the renderer adopted a view (else they resurrect). The daemon - // session form @@ is deliberately NOT here: the daemon - // mints it for ordinary renderer-owned local terminals too, so id shape can't - // classify ownership for that form — renderer-graph membership does (below). - private isServeOrSshOwnedPtyId(ptyId: string | null | undefined): boolean { - return this.isServeOwnedPtyId(ptyId) || this.isSshOwnedPtyId(ptyId) - } - - private hasServeOrSshOwnedBinding(tab: RuntimeMobileSessionTerminalTab): boolean { - if (this.isServeOrSshOwnedPtyId(tab.ptyId)) { - return true - } - return Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {}).some((ptyId) => - this.isServeOrSshOwnedPtyId(ptyId) - ) - } - - // Why: a snapshot tab can keep a serve/SSH-owned ptyId after the runtime - // terminal died and was de-persisted, so id shape alone must not preserve it - // against a renderer publication. Require the binding to be backed by a live - // PTY or by the persisted workspace session (a dormant persisted serve/SSH - // binding is still re-hydratable, so it stays preserved). - private hasLiveOrPersistedServeOrSshOwnedPtyBinding( - worktreeId: string, - tab: RuntimeMobileSessionTerminalTab - ): boolean { - const boundPtyIds = [ - tab.ptyId, - ...Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {}) - ].filter((ptyId): ptyId is string => this.isServeOrSshOwnedPtyId(ptyId)) - const boundSshPtyIds = boundPtyIds.filter((ptyId) => this.isSshOwnedPtyId(ptyId)) - if (boundPtyIds.length === 0) { - return this.hasRecentExpiredSshLeasePane(worktreeId, tab) - } - // Why: exited PTY records are archived in ptysById, so require a connected - // record — a dead serve shell whose persisted binding is also gone must - // stop being preserved. - if (boundPtyIds.some((ptyId) => this.ptysById.get(ptyId)?.connected === true)) { - return true - } - const now = Date.now() - if ( - boundPtyIds.some((ptyId) => { - const pty = this.ptysById.get(ptyId) - return ( - pty?.connectionId != null && - pty.lastExitCode != null && - pty.lastExitCode < 0 && - pty.disconnectedAt != null && - now - pty.disconnectedAt <= SSH_PANE_RECOVERY_GRACE_MS - ) - }) - ) { - // Why: an abnormal SSH transport exit can beat paired-viewer recovery; retain its pane briefly so the HUB remains addressable. - return true - } - if ( - now - this.startedAt <= SSH_PANE_RECOVERY_GRACE_MS && - boundSshPtyIds.some((ptyId) => { - const pty = this.ptysById.get(ptyId) - return !pty || (!pty.connected && pty.lastExitCode === null) - }) - ) { - // Why: after a HUB restart, failed SSH reattach can remove persistence before the fresh runtime records an exit; keep the pane reachable for ensure. - return true - } - const session = this.getWorkspaceSessionForWorktree(worktreeId) - if (!session) { - return false - } - const persistedTab = (session.tabsByWorktree?.[worktreeId] ?? []).find( - (candidate) => candidate.id === tab.parentTabId - ) - if (!persistedTab) { - return false - } - const persistedPtyIds = new Set( - [ - persistedTab.ptyId, - ...Object.values(session.terminalLayoutsByTabId?.[persistedTab.id]?.ptyIdsByLeafId ?? {}) - ].filter((ptyId): ptyId is string => typeof ptyId === 'string') - ) - return boundPtyIds.some((ptyId) => persistedPtyIds.has(ptyId)) - } - - private hasLiveRuntimeSessionOwnedPtyBinding( - worktreeId: string, - tab: RuntimeMobileSessionTerminalTab - ): boolean { - const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) - return pty?.connected === true && pty.runtimeSessionOwned - } - - private clearRuntimeSessionOwnershipForMobileTab( - worktreeId: string, - snapshot: RuntimeMobileSessionTabsSnapshot, - parentTabId: string - ): void { - for (const tab of snapshot.tabs) { - if (tab.type !== 'terminal' || tab.parentTabId !== parentTabId) { - continue - } - const ptyIds = [tab.ptyId, ...Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {})].filter( - (ptyId): ptyId is string => typeof ptyId === 'string' - ) - for (const ptyId of ptyIds) { - const pty = this.ptysById.get(ptyId) - if (pty?.worktreeId === worktreeId && pty.tabId === parentTabId) { - pty.runtimeSessionOwned = false - this.setPairedRendererSessionOwnership(pty.ptyId, false) - } - } - } - } - - private getMobileTerminalLeafPtyIds(tab: RuntimeMobileSessionTerminalTab): string[] { - return [tab.ptyId, tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId]].filter( - (ptyId): ptyId is string => typeof ptyId === 'string' && ptyId.length > 0 - ) - } - - private clearRuntimeSessionOwnershipForMobileTerminalLeaf( - worktreeId: string, - tab: RuntimeMobileSessionTerminalTab - ): void { - for (const ptyId of this.getMobileTerminalLeafPtyIds(tab)) { - const pty = this.ptysById.get(ptyId) - if (pty?.worktreeId === worktreeId && pty.tabId === tab.parentTabId) { - pty.runtimeSessionOwned = false - this.setPairedRendererSessionOwnership(pty.ptyId, false) - } - } - } - - // Why: only positive evidence that the persisted parent dropped this leaf may - // release it — a parent with no persisted layout is no evidence of a split. - private persistedParentStillBindsMobileTerminalLeaf( - session: WorkspaceSessionState, - persistedParent: TerminalTab, - tab: RuntimeMobileSessionTerminalTab - ): boolean { - const layout = session.terminalLayoutsByTabId?.[tab.parentTabId] - if (!layout) { - return true - } - if ( - typeof layout.ptyIdsByLeafId?.[tab.leafId] === 'string' || - collectLayoutLeafIdsInOrder(layout.root).includes(tab.leafId) - ) { - return true - } - // Why: renderer and headless sources can derive different leafIds for one - // surface, so a still-bound PTY id outranks a leafId that no longer matches. - const leafPtyIds = new Set(this.getMobileTerminalLeafPtyIds(tab)) - if (leafPtyIds.size === 0) { - return true - } - return [persistedParent.ptyId, ...Object.values(layout.ptyIdsByLeafId ?? {})].some( - (ptyId) => typeof ptyId === 'string' && leafPtyIds.has(ptyId) - ) - } - - // Why: omitted from the publication AND from persistence = durably closed, so release - // ownership — else a lagging or failed kill preserves the tab back into every merge. - // A create still in flight has not been retired, only not published yet. - private releaseRuntimeSessionOwnershipForRendererRetiredTabs( - incoming: RuntimeMobileSessionTabsSnapshot, - existing: RuntimeMobileSessionTabsSnapshot | undefined - ): void { - if (!existing || this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch)) { - return - } - const worktreeId = existing.worktree - const session = this.getWorkspaceSessionForWorktree(worktreeId) - const persistedTabs = session?.tabsByWorktree?.[worktreeId] - if (!session || !persistedTabs) { - return - } - const persistedTabsById = new Map(persistedTabs.map((tab) => [tab.id, tab])) - const incomingIdentityKeys = new Set( - incoming.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) - ) - for (const tab of existing.tabs) { - if (tab.type !== 'terminal') { - continue - } - if ( - this.pendingMobileTerminalCreatesByKey.has(`${worktreeId}::${tab.parentTabId}`) || - this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => - incomingIdentityKeys.has(id) - ) || - !this.hasLiveRuntimeSessionOwnedPtyBinding(worktreeId, tab) - ) { - continue - } - const persistedParent = persistedTabsById.get(tab.parentTabId) - if (!persistedParent) { - this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, existing, tab.parentTabId) - continue - } - // Why: a split parent outlives its retired leaf, so releasing the parent's - // PTYs would retire the surviving sibling with it. - if (!this.persistedParentStillBindsMobileTerminalLeaf(session, persistedParent, tab)) { - this.clearRuntimeSessionOwnershipForMobileTerminalLeaf(worktreeId, tab) - } - } - } - - // Why: a tab needs authoritative runtime teardown (kill + de-persist + prune) - // only when the renderer can't durably tear it down: either it's serve/SSH - // (preserved + re-hydrated, would resurrect) or the renderer graph never - // published it (a leaked/unadopted shell — incl. daemon-session `@@` tabs the - // host materialized but the renderer never showed). A tab the renderer graph - // DOES list — including an ordinary daemon-backed local terminal or a pending - // tab whose PTY hasn't bound — is renderer-owned: delegate, do not de-persist. - private isRuntimeOwnedHeadlessMobileTab( - worktreeId: string, - tab: RuntimeMobileSessionTerminalTab - ): boolean { - if (this.hasServeOrSshOwnedBinding(tab)) { - return true - } - const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) - if (pty && this.isServeOrSshOwnedPtyId(pty.ptyId)) { - return true - } - return !this.tabs.has(tab.parentTabId) - } - - private mergeMobileSessionSnapshotTabs( - baseTabs: readonly RuntimeMobileSessionSnapshotTab[], - extraTabs: readonly RuntimeMobileSessionSnapshotTab[] - ): RuntimeMobileSessionSnapshotTab[] { - const seenIds = new Set() - const merged: RuntimeMobileSessionSnapshotTab[] = [] - const add = (tab: RuntimeMobileSessionSnapshotTab): void => { - const ids = this.getMobileSessionSnapshotTabIdentityKeys(tab) - if (ids.some((id) => seenIds.has(id))) { - return - } - for (const id of ids) { - seenIds.add(id) - } - merged.push(tab) - } - for (const tab of baseTabs) { - add(tab) - } - for (const tab of extraTabs) { - add(tab) - } - return merged - } - - private getMobileSessionSnapshotTabIdentityKeys(tab: RuntimeMobileSessionSnapshotTab): string[] { - if (tab.type === 'terminal') { - // Why: split terminal leaves share one parent tab; merge dedup must stay - // leaf-scoped or preserved siblings collapse into a single surface. - const keys = [tab.id, `${tab.parentTabId}::${tab.leafId}`] - if (typeof tab.ptyId === 'string' && tab.ptyId.length > 0) { - // Why: renderer and headless sources can derive different leafIds for the same - // terminal; real PTYs collapse those duplicates without merging pending splits. - keys.push(`${tab.parentTabId}::pty:${tab.ptyId}`) - } - return keys - } - if (tab.type === 'browser') { - return [tab.id, tab.browserWorkspaceId] - } - return [tab.id] - } - - private mergeMobileSessionTabGroups( - worktreeId: string, - groups: readonly RuntimeMobileSessionTabGroup[], - terminalTabs: readonly RuntimeMobileSessionTerminalTab[], - activeTab: RuntimeMobileSessionTerminalTab | null - ): RuntimeMobileSessionTabGroup[] { - const parentTabOrder = this.collectHeadlessParentTabOrder(terminalTabs) - if (parentTabOrder.length === 0) { - return [...groups] - } - const targetGroupId = groups[0]?.id ?? this.getHeadlessMobileSessionGroupId(worktreeId) - const nextGroups = - groups.length > 0 - ? groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] })) - : [ - { - id: targetGroupId, - activeTabId: null, - tabOrder: [] - } - ] - // Why: keep each tab in the group that already owns it (a multi-group split - // must survive the merge), drop tabs no longer present, and route only - // genuinely-new tabs into the active group — never funnel everything into - // group[0], which duplicated/coalesced tabs that lived in other groups. - const ownerGroupId = new Map() - for (const group of nextGroups) { - for (const tabId of group.tabOrder) { - ownerGroupId.set(tabId, group.id) - } - } - const liveTabIds = new Set(parentTabOrder) - const activeParentId = activeTab?.parentTabId ?? null - const activeGroupId = - (activeParentId ? ownerGroupId.get(activeParentId) : undefined) ?? nextGroups[0]!.id - const retainedOrder = new Map(nextGroups.map((group) => [group.id, []])) - // Why: tabOrder is the canonical user-visible order, so it must survive a republish. - // A materialized idle surface can move to the end of terminalTabs; retaining the - // stored order prevents activation from rotating the tab bar. - const placed = new Set() - for (const group of nextGroups) { - for (const tabId of group.tabOrder) { - if (liveTabIds.has(tabId) && !placed.has(tabId)) { - retainedOrder.get(group.id)?.push(tabId) - placed.add(tabId) - } - } - } - for (const tabId of parentTabOrder) { - if (placed.has(tabId)) { - continue - } - const groupId = ownerGroupId.get(tabId) ?? activeGroupId - retainedOrder.get(groupId)?.push(tabId) - placed.add(tabId) - } - return nextGroups - .map((group) => { - const tabOrder = retainedOrder.get(group.id) ?? [] - const keptActive = - group.activeTabId && - tabOrder.includes(group.activeTabId) && - liveTabIds.has(group.activeTabId) - ? group.activeTabId - : null - return { - ...group, - tabOrder, - activeTabId: - activeParentId && tabOrder.includes(activeParentId) - ? activeParentId - : (keptActive ?? tabOrder[0] ?? null) - } - }) - .filter((group) => group.tabOrder.length > 0) - } - - /** - * Publishes a PTY-backed terminal tab snapshot to the synced mobile session, - * normalizing Pi-compatible titles based on launch or foreground ownership. - */ - private publishPtyBackedMobileSessionTerminal( - worktreeId: string, - pty: RuntimePtyWorktreeRecord, - args: { - tabId: string - leafId: string - title: string | null - activate: boolean - selectIfNoActiveTab?: boolean - startupCwd?: string - viewMode?: 'terminal' | 'chat' - split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' } - notify?: boolean - } - ): void { - if ( - !this.isMobileSessionSurfaceMembershipAllowed(worktreeId, args.tabId, args.leafId, pty.ptyId) - ) { - return - } - const existing = this.mobileSessionTabsByWorktree.get(worktreeId) - const ownerAgent = pty.launchAgent ?? pty.foregroundAgent - const title = normalizeCompatibleAgentTitleForOwner( - args.title ?? getLatestPtyTitle(pty) ?? 'Terminal', - ownerAgent, - { ownerIsLaunch: Boolean(pty.launchAgent) } - ) - const existingTab = existing?.tabs.find( - (candidate): candidate is RuntimeMobileSessionTerminalTab => - candidate.type === 'terminal' && - candidate.parentTabId === args.tabId && - candidate.leafId === args.leafId - ) - // Why: a split inserts into the parent tab's layout, which lives on the - // sibling surface, not this new leaf's (empty) existing surface. - const baseLayout = args.split - ? (existing?.tabs.find( - (candidate): candidate is RuntimeMobileSessionTerminalTab => - candidate.type === 'terminal' && - candidate.parentTabId === args.tabId && - candidate.leafId === args.split!.splitFromLeafId - )?.parentLayout ?? existingTab?.parentLayout) - : existingTab?.parentLayout - const parentLayout = this.buildMaterializedHeadlessParentLayout( - args.leafId, - pty.ptyId, - baseLayout, - args.split - ) - // Why: a main-side PTY rescue or split publication must not erase the - // host's explicit tab mode before the renderer graph catches up. - const viewMode = - args.viewMode ?? - existingTab?.viewMode ?? - existing?.tabs.find( - (candidate): candidate is RuntimeMobileSessionTerminalTab => - candidate.type === 'terminal' && - candidate.parentTabId === args.tabId && - candidate.viewMode !== undefined - )?.viewMode - const tab: RuntimeMobileSessionTerminalTab = { - type: 'terminal', - id: `${args.tabId}::${args.leafId}`, - parentTabId: args.tabId, - leafId: args.leafId, - ptyId: pty.ptyId, - title, - ...(pty.launchAgent ? { launchAgent: pty.launchAgent } : {}), - ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), - ...(viewMode ? { viewMode } : {}), - parentLayout, - isActive: - args.activate || (args.selectIfNoActiveTab !== false && existing?.activeTabId == null) - } - const existingTabs = (existing?.tabs ?? []).filter( - (candidate) => - !( - candidate.type === 'terminal' && - candidate.parentTabId === args.tabId && - candidate.leafId === args.leafId - ) - ) - const tabs = this.mergeMobileSessionSnapshotTabs( - existingTabs.map((candidate) => ({ - ...candidate, - // Why: the client picks one sibling's parentLayout to render the whole - // tab; a split must update every sibling surface to the new tree, or a - // stale single-leaf sibling makes the client fall back to a default - // direction ("Split Right" renders as down). - ...(args.split && candidate.type === 'terminal' && candidate.parentTabId === args.tabId - ? { parentLayout } - : {}), - isActive: tab.isActive ? false : candidate.isActive - })), - [tab] - ) - const activeTab = - (tab.isActive ? tab : tabs.find((candidate) => candidate.id === existing?.activeTabId)) ?? - tabs.find((candidate) => candidate.isActive) ?? - (args.selectIfNoActiveTab !== false ? tabs[0] : null) ?? - null - const terminalTabs = tabs.filter( - (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal' - ) - const next: RuntimeMobileSessionTabsSnapshot = { - worktree: worktreeId, - publicationEpoch: - existing?.publicationEpoch ?? `headless:pty-backed:${Date.now().toString(36)}`, - snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, - activeGroupId: existing?.activeGroupId ?? this.getHeadlessMobileSessionGroupId(worktreeId), - activeTabId: activeTab?.id ?? null, - activeTabType: activeTab?.type ?? null, - tabGroups: this.mergeMobileSessionTabGroups( - worktreeId, - existing?.tabGroups ?? [], - terminalTabs, - activeTab?.type === 'terminal' ? activeTab : null - ), - ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), - tabs - } - this.mobileSessionTabsByWorktree.set(worktreeId, next) - if (args.notify !== false) { - this.notifyMobileSessionTabsChanged(worktreeId) - } - } - - private touchMobileSessionSnapshotsForPty( - ptyId: string, - options: { immediate?: boolean } = {} - ): void { - for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { - const hasPtyBackedTab = snapshot.tabs.some( - (tab) => - tab.type === 'terminal' && - (tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] === ptyId) - ) - if (!hasPtyBackedTab) { - continue - } - this.touchMobileSessionTabsForWorktree(worktreeId, options) - } - } - - private getMobileSessionWorktreeIdsForPty(ptyId: string): string[] { - const worktreeIds: string[] = [] - for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { - const hasPtyBackedTab = snapshot.tabs.some( - (tab) => - tab.type === 'terminal' && - (tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] === ptyId) - ) - if (hasPtyBackedTab) { - worktreeIds.push(worktreeId) - } - } - return worktreeIds - } - - /** Bump the snapshot version and emit, coalesced unless `immediate`. - * Why the bump: clients gate mirrored snapshots on a strictly increasing - * `snapshotVersion`, so a re-emit at the same version is silently dropped. */ - touchMobileSessionTabsForWorktree( - worktreeId: string, - options: { immediate?: boolean } = {} - ): void { - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - if (!snapshot) { - return - } - this.mobileSessionTabsByWorktree.set(worktreeId, { - ...snapshot, - snapshotVersion: snapshot.snapshotVersion + 1 - }) - if (options.immediate) { - // Why: readiness/lifecycle changes are structural and must not wait - // behind the title/status coalescing window. - this.notifyMobileSessionTabsChanged(worktreeId) - return - } - // Why: title/status flips several times a second under spinner-in-title - // agents. Coalesce the emit instead of fanning out every version. - this.scheduleMobileSessionTabsChanged(worktreeId) - } - - /** Republish the workspace snapshot after a pane's hook status changed. - * Hook rows feed the headless `agentStatus` projection, which nothing else touches. */ - touchMobileSessionTabsForPane(paneKey: string, worktreeId?: string | null): void { - const resolved = worktreeId ?? this.getTerminalWorktreeIdForPaneKey(paneKey) - if (!resolved) { - return - } - this.touchMobileSessionTabsForWorktree(resolved) - } - - private mobileSessionSnapshotHasSurface( - worktreeId: string, - parentTabId: string, - leafId: string - ): boolean { - return Boolean( - this.mobileSessionTabsByWorktree - .get(worktreeId) - ?.tabs.some( - (tab) => - tab.type === 'terminal' && tab.parentTabId === parentTabId && tab.leafId === leafId - ) - ) - } - - private isMobileSessionSurfaceMembershipAllowed( - worktreeId: string, - parentTabId: string, - leafId: string, - candidatePtyId: string | null | undefined - ): boolean { - const session = this.store?.getWorkspaceSession?.() - const repoId = getRepoIdFromWorktreeId(worktreeId) - if ( - !hasHostAuthoritativeTerminalMembership(session, worktreeId) && - (session !== undefined || !this.terminalTopologyRevisionByRepoId.has(repoId)) - ) { - return true - } - if (this.mobileSessionSnapshotHasSurface(worktreeId, parentTabId, leafId)) { - return true - } - if (!candidatePtyId) { - return false - } - const pty = this.ptysById.get(candidatePtyId) - const pane = parsePaneKey(pty?.paneKey ?? '') - return Boolean( - pty?.connected && - pty.worktreeId === worktreeId && - pty.tabId === parentTabId && - pane?.leafId === leafId - ) - } - - private reconcileMobileSessionRetirementFences( - leaves: readonly RuntimeSyncedLeaf[] - ): RuntimeSyncedLeaf[] { - return leaves.filter((leaf) => - this.isMobileSessionSurfaceMembershipAllowed( - leaf.worktreeId, - leaf.tabId, - leaf.leafId, - leaf.ptyId - ) - ) - } - - private applyMobileSessionRetirementFences( - snapshot: RuntimeMobileSessionTabsSnapshot - ): RuntimeMobileSessionTabsSnapshot { - let next = snapshot - for (const tab of snapshot.tabs) { - if ( - tab.type !== 'terminal' || - this.isMobileSessionSurfaceMembershipAllowed( - snapshot.worktree, - tab.parentTabId, - tab.leafId, - tab.ptyId - ) - ) { - continue - } - const retired = retireTerminalSurfacesFromSnapshot({ - snapshot: next, - ptyId: tab.ptyId ?? '', - exactSurfaces: [{ parentTabId: tab.parentTabId, leafId: tab.leafId }], - exactOnly: true - }) - if (retired) { - next = retired.snapshot - } - } - return next - } - - /** - * Retires each surface in the session partition of the host that owns its worktree. - * Why: an SSH pane's durable surface lives in that connection's partition; retiring it - * against the local partition strands the real ghost and bumps a foreign host's epoch. - * Returns null when nothing may be published because persistence is unavailable or failed. - */ - private persistTerminalSurfaceRetirements( - retiredSurfaces: readonly RetiredTerminalSurface[] - ): { accepted: RetiredTerminalSurface[]; unpersisted: RetiredTerminalSurface[] } | null { - const surfacesByHostId = new Map() - for (const surface of retiredSurfaces) { - const hostId = - this.tryGetWorkspaceSessionHostIdForWorktree(surface.worktreeId) ?? LOCAL_EXECUTION_HOST_ID - const bucket = surfacesByHostId.get(hostId) - if (bucket) { - bucket.push(surface) - } else { - surfacesByHostId.set(hostId, [surface]) - } - } - const accepted: RetiredTerminalSurface[] = [] - const unpersisted: RetiredTerminalSurface[] = [] - const pendingWrites: { hostId: ExecutionHostId; session: WorkspaceSessionState }[] = [] - const originalSessions = new Map() - const stagedSessions = new Map() - for (const [hostId, surfaces] of surfacesByHostId) { - const session = this.store?.getWorkspaceSession?.(hostId) - if (!session) { - unpersisted.push(...surfaces) - continue - } - // Why: publishing absence before its host membership fence is durable lets a crash or - // stale renderer write resurrect the retired surface. - if (!this.store?.setWorkspaceSession || !this.store.flushOrThrow) { - return null - } - originalSessions.set(hostId, session) - let nextSession = session - const acceptedForHost: RetiredTerminalSurface[] = [] - for (const surface of surfaces) { - const candidate = retireTerminalSurfaceFromPersistence(nextSession, surface) - if (candidate !== nextSession) { - acceptedForHost.push(surface) - nextSession = candidate - } - } - if (acceptedForHost.length === 0) { - continue - } - accepted.push(...acceptedForHost) - pendingWrites.push({ hostId, session: nextSession }) - } - if (pendingWrites.length > 0) { - try { - for (const write of pendingWrites) { - this.store?.setWorkspaceSession?.(write.session, write.hostId) - const staged = this.store?.getWorkspaceSession?.(write.hostId) - if (staged) { - stagedSessions.set(write.hostId, staged) - } - } - this.store?.flushOrThrow?.() - } catch (error) { - // setWorkspaceSession mutates the in-memory partition before the flush. Restore only - // fields still equal to our staged write so concurrent renderer updates survive. - for (const [hostId, original] of originalSessions) { - const staged = stagedSessions.get(hostId) - const current = this.store?.getWorkspaceSession?.(hostId) - if (!staged || !current) { - continue - } - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite( - original, - staged, - current - ) - if (rolledBack !== current) { - this.store?.setWorkspaceSession?.(rolledBack, hostId) - } - } - console.error('[runtime] failed to persist terminal retirement:', error) - return null - } - } - return { accepted, unpersisted } - } - - private retireMobileSessionSurfacesForPty( - ptyId: string, - incarnationId: string, - exactSurfaces: readonly Pick[] - ): void { - const terminalHandle = - this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId) ?? undefined - const retiredSurfaceByKey = new Map() - for (const surface of exactSurfaces) { - retiredSurfaceByKey.set(`${surface.worktreeId}\0${surface.parentTabId}\0${surface.leafId}`, { - ...surface, - ptyId, - incarnationId - }) - } - for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { - const retired = retireTerminalSurfacesFromSnapshot({ - snapshot, - ptyId, - exactSurfaces: exactSurfaces.filter((surface) => surface.worktreeId === worktreeId) - }) - if (!retired) { - continue - } - for (const surface of retired.retired) { - retiredSurfaceByKey.set( - `${surface.worktreeId}\0${surface.parentTabId}\0${surface.leafId}`, - { ...surface, incarnationId } - ) - } - } - const retiredSurfaces = [...retiredSurfaceByKey.values()] - if (retiredSurfaces.length === 0) { - return - } - const persisted = this.persistTerminalSurfaceRetirements(retiredSurfaces) - if (!persisted) { - return - } - for (const surface of persisted.unpersisted) { - const repoId = getRepoIdFromWorktreeId(surface.worktreeId) - this.terminalTopologyRevisionByRepoId.set( - repoId, - (this.terminalTopologyRevisionByRepoId.get(repoId) ?? 0) + 1 - ) - } - // Why: one repo epoch can cover multiple exits, but only surfaces individually accepted by persistence may disappear. - const publishableRetiredSurfaces = [...persisted.accepted, ...persisted.unpersisted] - if (publishableRetiredSurfaces.length === 0) { - return - } - for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { - const retired = retireTerminalSurfacesFromSnapshot({ - snapshot, - ptyId, - exactSurfaces: publishableRetiredSurfaces.filter( - (surface) => surface.worktreeId === worktreeId - ), - // Why: discovery is broad by PTY id, but publication may remove only surfaces whose durable retirement was accepted. - exactOnly: true, - ...(terminalHandle - ? { - retirementProofs: publishableRetiredSurfaces - .filter((surface) => surface.worktreeId === worktreeId) - .map((surface) => ({ - parentTabId: surface.parentTabId, - leafId: surface.leafId, - ptyId: surface.ptyId, - terminal: terminalHandle, - incarnationId - })) - } - : {}) - }) - if (retired) { - this.mobileSessionTabsByWorktree.set(worktreeId, retired.snapshot) - this.notifyMobileSessionTabsChanged(worktreeId) - } - } - } - - private buildHeadlessMobileSessionTerminalTabs( - worktreeId: string, - persistedTabs: readonly TerminalTab[], - session: WorkspaceSessionState - ): RuntimeMobileSessionTerminalTab[] { - return [...persistedTabs] - .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) - .flatMap((tab, index) => { - const layout = session.terminalLayoutsByTabId?.[tab.id] - const leafIds = this.collectPersistedTerminalLeafIds(layout) - if (leafIds.length === 0) { - leafIds.push(this.deriveHeadlessLegacyTerminalLeafId(tab.id)) - } - return leafIds.flatMap((leafId) => { - const ptyId = - layout?.ptyIdsByLeafId?.[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null) - const title = - tab.customTitle?.trim() || - tab.generatedTitle?.trim() || - tab.title?.trim() || - tab.defaultTitle?.trim() || - `Terminal ${index + 1}` - return [ - { - type: 'terminal' as const, - id: `${tab.id}::${leafId}`, - parentTabId: tab.id, - leafId, - title, - ...(ptyId ? { ptyId } : {}), - ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), - ...(tab.launchAgent ? { launchAgent: tab.launchAgent } : {}), - ...(layout ? { parentLayout: this.cloneTerminalLayoutSnapshot(layout) } : {}), - ...(tab.color != null ? { color: tab.color } : {}), - ...(tab.isPinned ? { isPinned: true } : {}), - ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), - isActive: this.isPersistedTerminalLeafActive( - session, - worktreeId, - tab.id, - leafId, - layout - ) - } - ] - }) - }) - } - - // Why: headless serve backs browser panes with offscreen WebContents that live - // only in the BrowserManager, never in a renderer graph. Without surfacing them - // as session tabs, a session.tabs snapshot (e.g. on terminal open) prunes the - // paired browser tab and closing it fails with tab_not_found. Synthesize browser - // session tabs from the live bridge so they are first-class alongside terminals. - private buildHeadlessMobileSessionBrowserTabs( - worktreeId: string - ): RuntimeMobileSessionBrowserTab[] { - const serverTabs = - this.offscreenBrowserBackend && this.agentBrowserBridge?.tabList - ? this.agentBrowserBridge.tabList(worktreeId).tabs - : [] - const publishedServerTabs = serverTabs.map((tab) => { - const persistedProps = this.getPersistedUnifiedSessionTabProps(worktreeId, tab.browserPageId) - return { - type: 'browser' as const, - // Why: an offscreen page has no separate workspace identity, so the page id - // is its own workspace id (matches the server's browserWorkspaceId fallback). - id: tab.browserPageId, - title: tab.title || tab.url || 'Browser', - browserWorkspaceId: tab.browserPageId, - browserPageId: tab.browserPageId, - url: tab.url || 'about:blank', - loading: false, - canGoBack: false, - canGoForward: false, - loadError: tab.loadError ?? undefined, - certificateFailure: tab.certificateFailure ?? undefined, - ...(persistedProps ? { color: persistedProps.color } : {}), - ...(persistedProps ? { isPinned: persistedProps.isPinned === true } : {}), - isActive: tab.active === true - } - }) - const publishedClientTabs = getRuntimeBrowserPageRegistry(this) - .listPages(worktreeId) - .map((page) => ({ - type: 'browser' as const, - id: page.browserPageId, - title: page.title || page.url || 'Browser', - browserWorkspaceId: page.browserPageId, - browserPageId: page.browserPageId, - browserProfileId: page.browserProfileId, - executionHostKey: page.executionHostKey, - placement: page.placement, - url: page.url, - loading: page.loading, - canGoBack: page.canGoBack, - canGoForward: page.canGoForward, - isActive: page.active - })) - return [...publishedServerTabs, ...publishedClientTabs] - } - - // Why: change detection for headless browser tabs. Compares the fields that - // actually vary (a JSON.stringify equality was order-sensitive and silently - // dropped `undefined` keys, so it only worked while both sides shared one - // construction path). - private headlessBrowserTabsUnchanged( - live: RuntimeMobileSessionBrowserTab[], - existing: RuntimeMobileSessionBrowserTab[] - ): boolean { - if (live.length !== existing.length) { - return false - } - return live.every((tab, index) => { - const prev = existing[index] - return ( - tab.id === prev.id && - tab.title === prev.title && - tab.url === prev.url && - tab.loading === prev.loading && - tab.canGoBack === prev.canGoBack && - tab.canGoForward === prev.canGoForward && - tab.browserProfileId === prev.browserProfileId && - tab.executionHostKey === prev.executionHostKey && - ((tab.placement === undefined && prev.placement === undefined) || - (tab.placement !== undefined && - prev.placement !== undefined && - sameRuntimeBrowserPlacement(tab.placement, prev.placement))) && - tab.isActive === prev.isActive && - (tab.isPinned ?? false) === (prev.isPinned ?? false) && - (tab.color ?? null) === (prev.color ?? null) && - this.browserLoadErrorsEqual(tab.loadError, prev.loadError) && - this.browserCertificateFailuresEqual(tab.certificateFailure, prev.certificateFailure) - ) - }) - } - - private browserLoadErrorsEqual( - a: RuntimeMobileSessionBrowserTab['loadError'], - b: RuntimeMobileSessionBrowserTab['loadError'] - ): boolean { - const left = a ?? null - const right = b ?? null - if (left === right) { - return true - } - if (!left || !right) { - return false - } - return ( - left.code === right.code && - left.description === right.description && - left.validatedUrl === right.validatedUrl - ) - } - - private browserCertificateFailuresEqual( - a: RuntimeMobileSessionBrowserTab['certificateFailure'], - b: RuntimeMobileSessionBrowserTab['certificateFailure'] - ): boolean { - const left = a ?? null - const right = b ?? null - if (left === right) { - return true - } - if (!left || !right) { - return false - } - return ( - left.challengeId === right.challengeId && - left.browserPageId === right.browserPageId && - left.errorCode === right.errorCode && - left.error === right.error && - left.origin === right.origin && - left.displayHost === right.displayHost && - left.canProceed === right.canProceed && - left.observedAt === right.observedAt - ) - } - - private getPersistedUnifiedSessionTabProps( - worktreeId: string, - tabId: string - ): Pick | null { - const tab = - this.getWorkspaceSessionForWorktree(worktreeId)?.unifiedTabs?.[worktreeId]?.find( - (candidate) => candidate.id === tabId || candidate.entityId === tabId - ) ?? null - return tab ? { color: tab.color, isPinned: tab.isPinned } : null - } - - private collectPersistedTerminalLeafIds(layout: TerminalLayoutSnapshot | undefined): string[] { - if (!layout) { - return [] - } - const leafIds = new Set() - const visit = (node: TerminalLayoutSnapshot['root']): void => { - if (!node) { - return - } - if (node.type === 'leaf') { - if (isTerminalLeafId(node.leafId)) { - leafIds.add(node.leafId) - } - return - } - visit(node.first) - visit(node.second) - } - visit(layout.root) - if (layout.activeLeafId && isTerminalLeafId(layout.activeLeafId)) { - leafIds.add(layout.activeLeafId) - } - for (const leafId of Object.keys(layout.ptyIdsByLeafId ?? {})) { - if (isTerminalLeafId(leafId)) { - leafIds.add(leafId) - } - } - return [...leafIds] - } - - private deriveHeadlessLegacyTerminalLeafId(tabId: string): string { - const hash = createHash('sha256').update(`headless-terminal-leaf:${tabId}`).digest('hex') - const variant = ((Number.parseInt(hash.slice(16, 17), 16) & 0x3) | 0x8).toString(16) - const leafId = [ - hash.slice(0, 8), - hash.slice(8, 12), - `4${hash.slice(13, 16)}`, - `${variant}${hash.slice(17, 20)}`, - hash.slice(20, 32) - ].join('-') - if (!isTerminalLeafId(leafId)) { - return randomUUID() - } - return leafId - } - - private cloneTerminalLayoutSnapshot(layout: TerminalLayoutSnapshot): TerminalLayoutSnapshot { - const cloned: TerminalLayoutSnapshot = { - root: layout.root, - activeLeafId: layout.activeLeafId, - expandedLeafId: layout.expandedLeafId - } - if (layout.ptyIdsByLeafId) { - cloned.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId } - } - if (layout.buffersByLeafId) { - cloned.buffersByLeafId = { ...layout.buffersByLeafId } - } - if (layout.scrollbackRefsByLeafId) { - cloned.scrollbackRefsByLeafId = { ...layout.scrollbackRefsByLeafId } - } - if (layout.titlesByLeafId) { - cloned.titlesByLeafId = { ...layout.titlesByLeafId } - } - return cloned - } - - private isPersistedTerminalLeafActive( - session: WorkspaceSessionState, - worktreeId: string, - tabId: string, - leafId: string, - layout: TerminalLayoutSnapshot | undefined - ): boolean { - const activeTabId = session.activeTabIdByWorktree?.[worktreeId] ?? session.activeTabId - return activeTabId === tabId && (!layout?.activeLeafId || layout.activeLeafId === leafId) - } - - private pickHeadlessActiveTerminalTab( - tabs: readonly RuntimeMobileSessionTerminalTab[] - ): RuntimeMobileSessionTerminalTab | null { - return tabs.find((tab) => tab.isActive) ?? tabs.find((tab) => tab.parentTabId) ?? null - } - - private collectHeadlessParentTabOrder( - tabs: readonly RuntimeMobileSessionTerminalTab[] - ): string[] { - const order: string[] = [] - const seen = new Set() - for (const tab of tabs) { - if (!seen.has(tab.parentTabId)) { - seen.add(tab.parentTabId) - order.push(tab.parentTabId) - } - } - return order - } - - // Why: the group tab order must follow actual creation/insertion order across - // both terminals and browsers, not list terminals first. A terminal's top-level - // id is its parentTabId (split leaves share one); a browser's is its own id. - private collectHeadlessTopLevelTabOrder( - tabs: readonly RuntimeMobileSessionSnapshotTab[] - ): string[] { - const order: string[] = [] - const seen = new Set() - for (const tab of tabs) { - const topLevelId = tab.type === 'terminal' ? tab.parentTabId : tab.id - if (!seen.has(topLevelId)) { - seen.add(topLevelId) - order.push(topLevelId) - } - } - return order - } - - private getHeadlessMobileSessionGroupId(worktreeId: string): string { - return `headless-terminals:${worktreeId}` - } - - private buildHeadlessMobileSessionTabGroups( - worktreeId: string, - tabs: readonly RuntimeMobileSessionSnapshotTab[], - activeTab: RuntimeMobileSessionSnapshotTab | null, - existingGroups?: readonly RuntimeMobileSessionTabGroup[], - // Why: a new tab created via a specific group's "+" must land in THAT group, - // not the active one — otherwise every "+" in a split funnels to one group. - newTabAssignment?: { tabId: string; groupId: string } - ): RuntimeMobileSessionTabGroup[] { - // Why: order across terminals and browsers in their actual array order so a - // tab opened after a browser tab lands to its right, not regrouped before it. - const arrivalOrder = this.collectHeadlessTopLevelTabOrder(tabs) - // Why: tabOrder is the user-visible order and must survive a republish. A - // materialized idle surface can move to the end of the incoming array, so - // retain stored positions and append only genuinely new ids. - const liveTopLevelIds = new Set(arrivalOrder) - const tabOrder: string[] = [] - const placed = new Set() - for (const group of existingGroups ?? []) { - for (const tabId of group.tabOrder) { - if (liveTopLevelIds.has(tabId) && !placed.has(tabId)) { - tabOrder.push(tabId) - placed.add(tabId) - } - } - } - for (const tabId of arrivalOrder) { - if (!placed.has(tabId)) { - tabOrder.push(tabId) - placed.add(tabId) - } - } - const topLevelOf = (tab: RuntimeMobileSessionSnapshotTab): string => - tab.type === 'terminal' ? tab.parentTabId : tab.id - const activeTopLevelId = - (activeTab ? topLevelOf(activeTab) : null) ?? - existingGroups?.[0]?.activeTabId ?? - (() => { - const active = tabs.find((tab) => tab.isActive) - return active ? topLevelOf(active) : null - })() ?? - tabOrder[0] ?? - null - - // Why: when the user has split tabs into multiple groups, preserve that - // assignment across rebuilds instead of coalescing back to one group. - if (existingGroups && existingGroups.length > 1) { - return this.distributeHeadlessTabsAcrossGroups( - existingGroups, - tabOrder, - activeTopLevelId, - newTabAssignment - ) - } - - const groupId = existingGroups?.[0]?.id ?? this.getHeadlessMobileSessionGroupId(worktreeId) - return [ - { - id: groupId, - activeTabId: - activeTopLevelId && tabOrder.includes(activeTopLevelId) - ? activeTopLevelId - : (tabOrder[0] ?? null), - tabOrder - } - ] - } - - // Distribute live top-level tabs into the existing multi-group structure, - // keeping each tab in its group; tabs new since the last snapshot join the - // active group. Emptied groups are dropped so a closed split collapses. - private distributeHeadlessTabsAcrossGroups( - existingGroups: readonly RuntimeMobileSessionTabGroup[], - tabOrder: readonly string[], - activeTopLevelId: string | null, - newTabAssignment?: { tabId: string; groupId: string } - ): RuntimeMobileSessionTabGroup[] { - const groupIdByTabId = new Map() - for (const group of existingGroups) { - for (const tabId of group.tabOrder) { - groupIdByTabId.set(tabId, group.id) - } - } - // Why: route a freshly-created tab to the group its "+" was clicked in, - // when that group still exists; otherwise fall through to the active group. - const hasTargetGroup = - newTabAssignment !== undefined && - existingGroups.some((group) => group.id === newTabAssignment.groupId) - if (hasTargetGroup) { - groupIdByTabId.set(newTabAssignment!.tabId, newTabAssignment!.groupId) - } - const activeGroupId = - (activeTopLevelId ? groupIdByTabId.get(activeTopLevelId) : undefined) ?? existingGroups[0]!.id - const orderByGroup = new Map(existingGroups.map((group) => [group.id, []])) - for (const tabId of tabOrder) { - const groupId = groupIdByTabId.get(tabId) ?? activeGroupId - orderByGroup.get(groupId)?.push(tabId) - } - return existingGroups - .map((group) => { - const nextOrder = orderByGroup.get(group.id) ?? [] - return { - ...group, - tabOrder: nextOrder, - activeTabId: - activeTopLevelId && nextOrder.includes(activeTopLevelId) - ? activeTopLevelId - : group.activeTabId && nextOrder.includes(group.activeTabId) - ? group.activeTabId - : (nextOrder[0] ?? null) - } - }) - .filter((group) => group.tabOrder.length > 0) - } - - private buildMaterializedHeadlessParentLayout( - leafId: string, - ptyId: string, - existingLayout: TerminalLayoutSnapshot | undefined, - split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' } - ): TerminalLayoutSnapshot { - if (!existingLayout) { - return { - root: { type: 'leaf', leafId }, - activeLeafId: leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [leafId]: ptyId } - } - } - // Why: a split must insert the new leaf into the live layout tree with the - // requested direction, or the published snapshot keeps the old single-leaf - // root and the split renders with a fallback direction ("Split Right" lands - // as a top/bottom split). Reuse the persisted-split builder for parity. - if (split) { - return buildHeadlessTerminalSplitLayout(this.cloneTerminalLayoutSnapshot(existingLayout), { - leafId, - ptyId, - splitFromLeafId: split.splitFromLeafId, - direction: split.direction - }) - } - return { - ...this.cloneTerminalLayoutSnapshot(existingLayout), - ptyIdsByLeafId: { - ...existingLayout.ptyIdsByLeafId, - [leafId]: ptyId - } - } - } - - private commitHeadlessTerminalTabRetirement( - worktreeId: string, - parentTabId: string, - options: { allowMissing?: boolean } = {} - ): string[] { - const session = this.getWorkspaceSessionForWorktree(worktreeId) - if (!session || !this.store?.setWorkspaceSession || !this.store.flushOrThrow) { - throw new Error('workspace_session_unavailable') - } - const result = closeTerminalTabInWorkspaceSession(session, worktreeId, parentTabId) - if (result.pinned) { - throw new Error('terminal_tab_pinned') - } - if (!result.closed) { - if (!options.allowMissing) { - throw new Error('tab_not_found') - } - } - const persisted = result.closed - ? advanceTerminalTopologyRevision(result.session, worktreeId) - : session - this.setWorkspaceSessionForWorktree(worktreeId, persisted) - const staged = this.getWorkspaceSessionForWorktree(worktreeId) - try { - this.store.flushOrThrow() - } catch (error) { - const current = this.getWorkspaceSessionForWorktree(worktreeId) - if (staged && current) { - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) - if (rolledBack !== current) { - this.setWorkspaceSessionForWorktree(worktreeId, rolledBack) - } - } - throw error - } - return result.ptyIdsToKill - } - - private persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void { - const session = this.getWorkspaceSessionForWorktree(worktreeId) - if (!session || !this.store?.setWorkspaceSession) { - return - } - const orderIndexByTabId = new Map(tabOrder.map((tabId, index) => [tabId, index])) - const tabs = session.tabsByWorktree[worktreeId] ?? [] - const reordered = [...tabs] - .sort((a, b) => { - const aIndex = orderIndexByTabId.get(a.id) ?? Number.MAX_SAFE_INTEGER - const bIndex = orderIndexByTabId.get(b.id) ?? Number.MAX_SAFE_INTEGER - return aIndex - bIndex || a.sortOrder - b.sortOrder || a.createdAt - b.createdAt - }) - .map((tab, index) => ({ - ...tab, - sortOrder: index - })) - this.setWorkspaceSessionForWorktree(worktreeId, { - ...session, - tabsByWorktree: { - ...session.tabsByWorktree, - [worktreeId]: reordered - } - }) - } - - private emitMobileSessionTabsSnapshot(snapshot: RuntimeMobileSessionTabsSnapshot): void { - if (this.mobileSessionTabListeners.size === 0) { - return - } - const result = this.toMobileSessionTabsResult(snapshot) - const changeSequence = ++this.mobileSessionTabsChangeSequence - for (const subscription of this.mobileSessionTabListeners) { - subscription.listener( - this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId), - changeSequence - ) - } - } - - /** - * Answers one client's session-tabs question: whether this runtime has taken back *that* client's - * client-hosted pages yet, then that client's own tab selection. - * - * The hold is decided here and nowhere else, and it is set or cleared rather than only set, so a - * frame built for one client can never carry another client's answer. - */ - private projectMobileSessionTabsForClient( - result: RuntimeMobileSessionTabsResult, - clientNavigationId?: string - ): RuntimeMobileSessionTabsResult { - return this.clientSessionTabSelections.project( - this.withClientHostedPagesHold(result, clientNavigationId), - clientNavigationId - ) - } - - private withClientHostedPagesHold( - result: RuntimeMobileSessionTabsResult, - clientNavigationId: string | undefined - ): RuntimeMobileSessionTabsResult { - return this.clientHostedPageReconciliation.holdFor(result, clientNavigationId, Date.now()) - } - - private async refreshMobileSessionPtyRecords( - targetWorktreeId: string | null = null - ): Promise | null> { - const inventory = await this.refreshMobileSessionPtyInventory(targetWorktreeId) - return inventory ? new Set(inventory.livePtyIds) : null - } - - private async refreshMobileSessionPtyInventory( - targetWorktreeId: string | null = null - ): Promise { - // Targeted mobile polls must not queue behind an aggregate census that may - // be waiting on an unrelated SSH provider. - if (targetWorktreeId !== null && targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { - return this.performMobileSessionPtyRecordsRefresh(targetWorktreeId) - } - if (targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { - // Fleet-wide refreshes share one aggregate controller inventory. - const pending = this.pendingMobileSessionPtyAggregateInventoryRefresh - if (pending) { - return pending - } - // Why: reconnect exit bursts share one authoritative daemon inventory - // instead of multiplying a full cross-generation list RPC per stale tab. - const refresh = this.performMobileSessionPtyRecordsRefresh(targetWorktreeId).finally(() => { - if (this.pendingMobileSessionPtyAggregateInventoryRefresh === refresh) { - this.pendingMobileSessionPtyAggregateInventoryRefresh = null - } - }) - this.pendingMobileSessionPtyAggregateInventoryRefresh = refresh - return refresh - } - return await this.performMobileSessionPtyRecordsRefresh(targetWorktreeId) - } - - private async performMobileSessionPtyRecordsRefresh( - targetWorktreeId: string | null - ): Promise { - if (!this.ptyController?.listProcesses && !this.ptyController?.hasPty) { - return null - } - // Why: floating PTY identity is explicit, so polling must not resolve every Git/SSH worktree. - const isFloatingWorkspace = targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID - const resolvedWorktrees = isFloatingWorkspace - ? [] - : targetWorktreeId - ? this.listResolvedWorktreesForExplicitTarget(targetWorktreeId) - : await this.listResolvedWorktrees() - // An explicit mobile worktree belongs to one execution host. Query only - // that provider; aggregate inventory would wait on unrelated SSH hosts. - const targetExecutionHost = targetWorktreeId - ? (resolvedWorktrees.find((worktree) => worktree.id === targetWorktreeId)?.hostId ?? - this.tryGetWorkspaceSessionHostIdForWorktree(targetWorktreeId)) - : null - const parsedTargetHost = targetExecutionHost ? parseExecutionHostId(targetExecutionHost) : null - // Paired/runtime-owned workspaces have a separate controller; this runtime - // cannot inspect them and must not silently query its local PTY provider. - if (parsedTargetHost?.kind === 'runtime') { - return null - } - const targetConnectionId = - parsedTargetHost?.kind === 'ssh' - ? parsedTargetHost.targetId - : targetWorktreeId - ? null - : undefined - return await this.refreshPtyWorktreeRecordsWithControllerInventory( - resolvedWorktrees, - targetWorktreeId, - undefined, - targetConnectionId - ) - } - - /** Targeted mobile opens must not wait for an unrelated SSH/Git worktree scan. */ - private listResolvedWorktreesForExplicitTarget(targetWorktreeId: string): ResolvedWorktree[] { - const cached = - this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > Date.now() - ? this.resolvedWorktreeCache.worktrees - : null - const targetWorktree = - cached?.find((worktree) => worktree.id === targetWorktreeId) ?? - (() => { - const scope = parseWorkspaceKey(targetWorktreeId) - if (scope?.type === 'folder') { - const folder = this.store - ?.getFolderWorkspaces?.() - .find((workspace) => workspace.id === scope.folderWorkspaceId) - return folder ? this.folderWorkspaceToResolvedWorktree(folder) : null - } - return this.buildResolvedWorktreeFromId(targetWorktreeId) - })() - if (!targetWorktree) { - return [] - } - return cached - ? includeTargetResolvedWorktree(cached, targetWorktree) - : this.listKnownResolvedWorktreesForExplicitTarget(targetWorktreeId, targetWorktree) - } - - async activateMobileSessionTab( - worktreeSelector: string, - tabId: string, - leafId?: string, - opts: { - notifyClients?: boolean - clientNavigationId?: string - navigation?: RuntimeNavigationTarget - intent?: TabActivationIntent - } = {} - ): Promise { - const navigation = opts.navigation ?? (opts.notifyClients === false ? 'caller' : 'all') - const targetsHost = navigationTargetsHost(navigation) - const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) - const worktreeId = - explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) - await this.refreshMobileSessionPtyRecords(worktreeId) - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - const directTab = snapshot?.tabs.find((candidate) => candidate.id === tabId) - const tab = leafId - ? ((directTab?.type === 'terminal' && directTab.leafId === leafId ? directTab : undefined) ?? - snapshot?.tabs.find( - (candidate) => - candidate.type === 'terminal' && - candidate.parentTabId === tabId && - candidate.leafId === leafId - )) - : (directTab ?? - snapshot?.tabs.find( - (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tabId - ) ?? - snapshot?.tabs.find( - (candidate) => candidate.type === 'browser' && candidate.browserWorkspaceId === tabId - )) - if (!snapshot || !tab) { - throw new Error('tab_not_found') - } - - if (tab.type === 'terminal') { - const publicTab = this.toMobileSessionTabsResult(snapshot).tabs.find( - (candidate) => candidate.type === 'terminal' && candidate.id === tab.id - ) - // Why: serve-created tabs can be visible before any renderer has adopted - // their tab id, so focusing the renderer would silently no-op. - // Phone-local activation also needs this path for inactive restored tabs: - // desktop focus is intentionally suppressed, but the PTY still must exist. - const shouldMaterializePendingTerminal = - publicTab?.type === 'terminal' && - publicTab.status !== 'ready' && - // Why: opening a tab is the documented wake gesture for a slept pane - // (#11598), so only a background probe may be refused for one. - (!isAutomaticTabActivation(opts.intent) || - !this.isDeliberatelyParkedPane(worktreeId, tab)) && - (!targetsHost || - !this.notifier?.focusTerminal || - this.shouldMaterializeHeadlessMobileSessionTab(snapshot, tab)) - if (shouldMaterializePendingTerminal) { - const sessionId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? undefined - const targetGroupId = snapshot?.tabGroups?.find((group) => - group.tabOrder.includes(tab.parentTabId) - )?.id - // Why: a pending agent tab may exist without its startup command ever - // having been delivered (the create's renderer stalled, #7587), so a - // bare materialize would put a plain shell under the agent icon. - // Re-resolve the launch like the create path; providers skip startup - // commands when attaching to live sessions, so this cannot double-launch. - let agentStartup: Awaited< - ReturnType - > = {} - if (tab.launchAgent) { - try { - const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) - agentStartup = await this.resolveMobileSessionTerminalCommand(workspace, { - agent: tab.launchAgent - }) - } catch { - // Why: a disabled or unresolvable agent must not make the tab - // untappable; fall back to the plain-shell materialize. - } - } - try { - await this.createRuntimeOwnedMobileSessionTerminal(worktreeId, targetsHost, undefined, { - identity: { - tabId: tab.parentTabId, - leafId: tab.leafId, - sessionId - }, - cwd: tab.startupCwd, - command: agentStartup.command, - env: agentStartup.env, - startupCommandDelivery: agentStartup.startupCommandDelivery, - launchConfig: agentStartup.launchConfig, - launchAgent: tab.launchAgent, - targetGroupId - }) - } catch (err) { - if (sessionId && parseAppSshPtyId(sessionId)) { - // Why: an expired SSH reattach clears durable bindings in the store, - // but this in-memory headless snapshot can still carry the old id. - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { force: true }) - } - throw err - } - return this.applyMobileSessionTabNavigation( - this.getMobileSessionTabsForWorktree(worktreeId), - tab.id, - navigation, - opts.clientNavigationId - ) - } - const callerSnapshot = this.getMobileSessionTabsForWorktree( - worktreeId, - opts.clientNavigationId - ) - const activeSibling = - tab.id === tabId || leafId - ? null - : (callerSnapshot.tabs.find( - (candidate) => - candidate.type === 'terminal' && - candidate.parentTabId === tab.parentTabId && - candidate.isActive - ) as RuntimeMobileSessionTerminalTab | undefined) - const targetTab = activeSibling ?? tab - if (targetsHost && !this.notifier?.focusTerminal) { - if ( - !targetTab.isActive && - this.shouldPersistHeadlessMobileSessionActivation(snapshot, targetTab) - ) { - this.activateHeadlessMobileSessionTerminalTab(worktreeId, snapshot, targetTab) - } - } else if (targetsHost) { - this.notifier?.focusTerminal?.(targetTab.parentTabId, worktreeId, targetTab.leafId) - } - return this.applyMobileSessionTabNavigation( - this.getMobileSessionTabsForWorktree(worktreeId), - targetTab.id, - navigation, - opts.clientNavigationId - ) - } else if (tab.type === 'browser') { - // Why: browser mobile tabs are renderer-owned unified tabs; focusing the - // session tab keeps desktop tab order/group state authoritative. - if (targetsHost) { - this.notifier?.focusEditorTab?.(tab.id, worktreeId) - } - } else { - if (targetsHost) { - this.notifier?.focusEditorTab?.(tab.id, worktreeId) - } - } - return this.applyMobileSessionTabNavigation( - this.getMobileSessionTabsForWorktree(worktreeId), - tab.id, - navigation, - opts.clientNavigationId - ) - } - - private applyMobileSessionTabNavigation( - snapshot: RuntimeMobileSessionTabsResult, - activeTabId: string, - navigation: RuntimeNavigationTarget, - clientNavigationId?: string - ): RuntimeMobileSessionTabsResult { - let callerSnapshot: RuntimeMobileSessionTabsResult | null = null - if (navigationTargetsClients(navigation)) { - // Why: follow is live intent; disconnected devices must not inherit stale navigation on reconnect. - const ids = new Set( - [...this.mobileSessionTabListeners] - .map((subscription) => subscription.clientNavigationId) - .filter((id): id is string => Boolean(id)) - ) - if (clientNavigationId) { - ids.add(clientNavigationId) - } - for (const id of ids) { - const projected = this.clientSessionTabSelections.activate( - this.withClientHostedPagesHold(snapshot, id), - id, - activeTabId - ) - this.emitMobileSessionTabsSnapshotToClient(projected, id, true) - if (id === clientNavigationId) { - callerSnapshot = projected - } - } - } else if (clientNavigationId) { - // Why: follow-host still starts as caller navigation; the host is an additional target, not a replacement owner. - callerSnapshot = this.clientSessionTabSelections.activate( - this.withClientHostedPagesHold(snapshot, clientNavigationId), - clientNavigationId, - activeTabId - ) - this.emitMobileSessionTabsSnapshotToClient(callerSnapshot, clientNavigationId) - } - if (clientNavigationId) { - return callerSnapshot ?? this.projectMobileSessionTabsForClient(snapshot, clientNavigationId) - } - if (navigation === 'caller') { - const selection = activateClientSessionTabSelection( - snapshot, - deriveClientSessionTabSelection(snapshot), - activeTabId - ) - return projectClientSessionTabSelection(snapshot, selection).snapshot - } - return snapshot - } - - /** - * Whether persistence proves this pane's PTY was deliberately taken down and parked - * (workspace sleep or completed-agent hibernation) rather than lost and awaiting reconnect. - * Why: `pending-handle` alone cannot tell those apart — a parked pane publishes it - * indefinitely — and respawning a parked pane re-launches its agent behind the user. - * Only an automatic activation consults this; a user opening the tab is the wake gesture. - */ - private isDeliberatelyParkedPane( - worktreeId: string, - tab: RuntimeMobileSessionTerminalTab - ): boolean { - const record = - this.getWorkspaceSessionForWorktree(worktreeId)?.sleepingAgentSessionsByPaneKey?.[ - makePaneKey(tab.parentTabId, tab.leafId) - ] - // Why: 'live'/'quit' captures describe a pane that was still running, so a reconnect - // must still mint its replacement PTY (#11542). Only a worktree-owned capture records - // a deliberate takedown the user did not ask to undo. - return record?.origin === 'worktree-sleep' && worktreeIdsEqual(record.worktreeId, worktreeId) - } - - private shouldMaterializeHeadlessMobileSessionTab( - snapshot: RuntimeMobileSessionTabsSnapshot, - tab: RuntimeMobileSessionTerminalTab - ): boolean { - return ( - this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) || - this.hasServeOrSshOwnedBinding(tab) - ) - } - - private shouldPersistHeadlessMobileSessionActivation( - snapshot: RuntimeMobileSessionTabsSnapshot, - tab: RuntimeMobileSessionTerminalTab - ): boolean { - if (snapshot.publicationEpoch.includes(':headless-merge:')) { - return false - } - if (this.authoritativeWindowId !== null && this.graphStatus === 'ready') { - return false - } - return this.shouldMaterializeHeadlessMobileSessionTab(snapshot, tab) - } - - private activateHeadlessMobileSessionTerminalTab( - worktreeId: string, - snapshot: RuntimeMobileSessionTabsSnapshot, - activeTab: RuntimeMobileSessionTerminalTab - ): void { - const tabs = snapshot.tabs.map((candidate) => ({ - ...candidate, - isActive: candidate.id === activeTab.id - })) - const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { - ...snapshot, - publicationEpoch: `headless:${Date.now().toString(36)}`, - snapshotVersion: snapshot.snapshotVersion + 1, - activeTabId: activeTab.id, - activeTabType: 'terminal', - tabGroups: this.buildHeadlessMobileSessionTabGroups( - worktreeId, - tabs, - activeTab, - snapshot.tabGroups - ), - tabs - } - this.persistHeadlessTerminalActiveLeaf(worktreeId, activeTab) - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) - this.emitMobileSessionTabsSnapshot(nextSnapshot) - } - - // Why: a headless split only updated the LIVE session snapshot, never the - // persisted workspace session layout. So a later snapshot rebuild (e.g. on the - // next terminal create) re-derived from the stale single-leaf persisted layout - // and collapsed the split. Persist the new split leaf into the workspace - // session's terminalLayoutsByTabId so the split survives rebuilds. - private persistHeadlessTerminalSplit(args: { - worktreeId: string - tabId: string - leafId: string - ptyId: string - splitFromLeafId: string - direction: 'horizontal' | 'vertical' - }): boolean { - const session = this.getWorkspaceSessionForWorktree(args.worktreeId) - if (!session || !this.store?.setWorkspaceSession) { - return false - } - const existing = session.terminalLayoutsByTabId?.[args.tabId] - const nextLayout = buildHeadlessTerminalSplitLayout( - existing ? this.cloneTerminalLayoutSnapshot(existing) : undefined, - args - ) - this.setWorkspaceSessionForWorktree(args.worktreeId, { - ...session, - terminalLayoutsByTabId: { - ...session.terminalLayoutsByTabId, - [args.tabId]: nextLayout - } - }) - return true - } - - private persistHeadlessTerminalActiveLeaf( - worktreeId: string, - tab: RuntimeMobileSessionTerminalTab - ): void { - const session = this.getWorkspaceSessionForWorktree(worktreeId) - if (!session || !this.store?.setWorkspaceSession) { - return - } - const existingLayout = session.terminalLayoutsByTabId?.[tab.parentTabId] - const nextLayouts = existingLayout - ? { - ...session.terminalLayoutsByTabId, - [tab.parentTabId]: { - ...this.cloneTerminalLayoutSnapshot(existingLayout), - activeLeafId: tab.leafId - } - } - : session.terminalLayoutsByTabId - this.setWorkspaceSessionForWorktree(worktreeId, { - ...session, - activeTabId: tab.parentTabId, - activeTabIdByWorktree: { - ...session.activeTabIdByWorktree, - [worktreeId]: tab.parentTabId - }, - terminalLayoutsByTabId: nextLayouts - }) - } - - async refuseUnattributedMobileSessionTabClose( - worktreeSelector: string, - tabId: string - ): Promise { - const snapshot = await this.listMobileSessionTabs(worktreeSelector) - const tabExists = snapshot.tabs.some( - (candidate) => - candidate.id === tabId || - (candidate.type === 'terminal' && candidate.parentTabId === tabId) || - (candidate.type === 'browser' && candidate.browserWorkspaceId === tabId) - ) - if (!tabExists) { - throw new Error('tab_not_found') - } - // Why: a legacy client may already have hidden its mirror; a new snapshot - // restores it without granting an unattributed request destructive authority. - this.republishMobileSessionTabsSnapshot(snapshot.worktree) - return refusedMobileSessionTabClose('missing-intent', { - snapshotRepublished: true - }) - } - - async closeMobileSessionTab( - worktreeSelector: string, - tabId: string, - options: { - reason?: RuntimeSessionTabCloseReason - expectedPublicationEpoch?: string - expectedTerminalHandle?: string - clientNavigationId?: string - localPtyTeardownOwnedExternally?: boolean - expectedPtyCloseAuthority?: RuntimePtyTabCloseAuthority - } = {} - ): Promise { - const graphEpoch = options.clientNavigationId ? this.captureReadyGraphEpoch() : null - const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) - const worktreeId = - explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) - const observedPtyIds = await this.refreshMobileSessionPtyRecords() - if (graphEpoch !== null) { - this.assertStableReadyGraph(graphEpoch) - } - this.restoreLivePairedRendererSessionOwnedMobileTerminals(worktreeId) - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - if (options.reason !== undefined && options.reason !== 'user' && observedPtyIds === null) { - // Why: keep-on-unknown must also restore the mirror the caller already pruned. - this.republishMobileSessionTabsSnapshot(worktreeId) - return refusedMobileSessionTabClose('unknown-liveness', { - snapshotRepublished: Boolean(snapshot) - }) - } - if ( - options.expectedPublicationEpoch !== undefined && - snapshot?.publicationEpoch !== options.expectedPublicationEpoch - ) { - this.republishMobileSessionTabsSnapshot(worktreeId) - return refusedMobileSessionTabClose('stale-publication', { - snapshotRepublished: Boolean(snapshot) - }) - } - const ptyCloseAuthority = options.expectedPtyCloseAuthority - ? this.resolvePtyTabCloseSurfaceAuthority(options.expectedPtyCloseAuthority) - : null - const tab = options.expectedPtyCloseAuthority - ? ptyCloseAuthority?.surface.tab - : (snapshot?.tabs.find((candidate) => candidate.id === tabId) ?? - snapshot?.tabs.find( - (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tabId - ) ?? - snapshot?.tabs.find( - (candidate) => candidate.type === 'browser' && candidate.browserWorkspaceId === tabId - )) - const lifecycleCloseParentTabId = - tab?.type === 'terminal' - ? tab.parentTabId - : ptyCloseAuthority?.surface.tab.type === 'terminal' - ? ptyCloseAuthority.surface.tab.parentTabId - : this.tabs.has(tabId) - ? tabId - : ([...this.tabs.keys()] - .filter((parentTabId) => tabId.startsWith(`${parentTabId}::`)) - .sort((a, b) => b.length - a.length)[0] ?? - ( - snapshot?.tabs.find( - (candidate) => - candidate.type === 'terminal' && tabId.startsWith(`${candidate.parentTabId}::`) - ) as RuntimeMobileSessionTerminalTab | undefined - )?.parentTabId ?? - null) - const lifecycleParentLeaves = lifecycleCloseParentTabId - ? (snapshot?.tabs.filter( - (candidate): candidate is RuntimeMobileSessionTerminalTab => - candidate.type === 'terminal' && candidate.parentTabId === lifecycleCloseParentTabId - ) ?? []) - : [] - const lifecycleRendererLeaves = lifecycleCloseParentTabId - ? [...this.leaves.values()].filter( - (leaf) => - leaf.tabId === lifecycleCloseParentTabId && - worktreeIdsEqual(leaf.worktreeId, worktreeId) - ) - : [] - const lifecycleLeafHasConnectedPty = (leaf: RuntimeMobileSessionTerminalTab): boolean => { - const snapshotPtyIds = [leaf.ptyId, leaf.parentLayout?.ptyIdsByLeafId?.[leaf.leafId]].filter( - (ptyId): ptyId is string => Boolean(ptyId) - ) - return ( - this.findPtyForMobileTerminalTab(worktreeId, leaf)?.connected === true || - snapshotPtyIds.some((ptyId) => observedPtyIds?.has(ptyId) === true) - ) - } - const lifecycleRendererLeafHasConnectedPty = (leaf: RuntimeLeafRecord): boolean => { - const ptyId = leaf.ptyId - return Boolean( - ptyId && - (this.ptysById.get(ptyId)?.connected === true || observedPtyIds?.has(ptyId) === true) - ) - } - const lifecycleCloseLeafId = - lifecycleCloseParentTabId && tabId.startsWith(`${lifecycleCloseParentTabId}::`) - ? tabId.slice(lifecycleCloseParentTabId.length + 2) - : null - if (!snapshot || !tab) { - // Lifecycle echoes are idempotent: a provider exit may have already - // retired the surface before the viewer reports its stale close. A user - // close still fails closed so an unknown target cannot be hidden. - if (options.reason !== undefined && options.reason !== 'user') { - // A missing leaf can still be part of a live split parent. Closing that - // parent would take the surviving sibling down, so retain the refusal - // even though the addressed leaf has already been retired. - const hasLiveRendererParentLeaf = lifecycleRendererLeaves.some( - lifecycleRendererLeafHasConnectedPty - ) - if (lifecycleParentLeaves.some(lifecycleLeafHasConnectedPty) || hasLiveRendererParentLeaf) { - const addressedDeadRendererLeaf = - lifecycleCloseLeafId !== null && - !lifecycleRendererLeaves.some( - (leaf) => - leaf.leafId === lifecycleCloseLeafId && lifecycleRendererLeafHasConnectedPty(leaf) - ) - if (addressedDeadRendererLeaf) { - return refusedMobileSessionTabClose('live-host-pty') - } - if (snapshot) { - this.republishMobileSessionTabsSnapshot(worktreeId) - } - return refusedMobileSessionTabClose('live-host-pty') - } - // The renderer owns a graph-visible parent, including a dead leaf whose - // lifecycle echo arrived after main retired its mirror. Leave retirement - // to that renderer instead of acknowledging a host-side close. - if (lifecycleCloseParentTabId && this.tabs.has(lifecycleCloseParentTabId)) { - return refusedMobileSessionTabClose('retirement-owner') - } - return delegatedMobileSessionTabClose() - } - throw new Error(options.expectedPtyCloseAuthority ? 'terminal_handle_stale' : 'tab_not_found') - } - if (options.expectedTerminalHandle !== undefined) { - const terminalIncarnationMatches = - tab.type === 'terminal' && - snapshot.tabs.some( - (candidate) => - candidate.type === 'terminal' && - candidate.parentTabId === tab.parentTabId && - this.getMobileSessionTerminalHandle(worktreeId, candidate) === - options.expectedTerminalHandle - ) - if (!terminalIncarnationMatches) { - this.republishMobileSessionTabsSnapshot(worktreeId) - return refusedMobileSessionTabClose('stale-terminal', { - snapshotRepublished: true - }) - } - } - let closedSelectionTabIds = [tab.id] - const finishCommittedClose = (): MobileSessionTabCloseOutcome => - committedMobileSessionTabClose( - this.clientSessionTabSelections, - worktreeId, - closedSelectionTabIds - ) - if (tab.type === 'terminal') { - const parentLeafCount = snapshot.tabs.filter( - (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId - ).length - const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1 - if (closingWholeParent) { - closedSelectionTabIds = snapshot.tabs.flatMap((candidate) => - candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId - ? [candidate.id, candidate.parentTabId] - : [] - ) - } - // Why: a non-'user' reason is a client-lifecycle echo ("terminal gone"), - // not authorization to kill. Every destructive branch below can take the - // whole parent down, so any live PTY under the parent means the echo is a - // transport artifact: refuse the close and republish the snapshot so the - // echoing client re-syncs and re-attaches. A reasonless close keeps - // legacy behavior — old clients send user closes without the field. - if (options.reason !== undefined && options.reason !== 'user') { - const parentLeaves = snapshot.tabs.filter( - (candidate): candidate is RuntimeMobileSessionTerminalTab => - candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId - ) - // Why: exited PTYs keep a disconnected record in ptysById for status - // reads (and a still-synced leaf retains its record), so record - // presence is not liveness — only `connected` counts, or a genuinely - // dead tab never retires and the echo loops forever. - const leafHasConnectedPty = (leaf: RuntimeMobileSessionTerminalTab): boolean => { - return lifecycleLeafHasConnectedPty(leaf) - } - if (parentLeaves.some(leafHasConnectedPty)) { - // Why: when the echo addresses a dead leaf under a live sibling we - // still refuse (every reachable close path below destroys the whole - // parent, live sibling included) but skip the republish — re-adding - // the dead leaf on the echoing client would feed an endless - // refuse→republish→re-echo cycle. - const addressedDeadLeaf = tab.id === tabId && !leafHasConnectedPty(tab) - if (!addressedDeadLeaf) { - this.republishMobileSessionTabsSnapshot(worktreeId) - } - // Why: both markers are skew-safe; clients must restore a mirror only - // when the host actually republished it, not for a dead leaf. - return refusedMobileSessionTabClose('live-host-pty', { - snapshotRepublished: !addressedDeadLeaf - }) - } - if (!closingWholeParent || this.tabs.has(tab.parentTabId)) { - // Why: only the renderer may retire its own tab or split leaf; a - // remote lifecycle echo must never cross that boundary into a kill. - return refusedMobileSessionTabClose('retirement-owner') - } - } - // Why: a runtime-owned headless tab is absent from renderer state, so the - // closeTerminalTab relay below would ack success without killing its PTY, - // and syncMobileSessionTabs would republish the "closed" tab. Only bypass - // the relay when no renderer owns the parent: an adopted tab needs the - // renderer's live pin guard and durable close transaction. - if (closingWholeParent && !this.tabs.has(tab.parentTabId)) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { - allowMissingPersistedTab: Boolean(ptyCloseAuthority), - killPtys: - options.localPtyTeardownOwnedExternally !== true && - (options.reason === undefined || options.reason === 'user'), - ...(ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}) - }) - this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) - return finishCommittedClose() - } - if (closingWholeParent && this.notifier?.closeTerminalTab) { - // Why: whole-tab close is a lifecycle transaction. The renderer reply - // arrives only after canonical retirement and a forced session flush. - const win = this.getAvailableAuthoritativeWindow() - if (win?.webContents.isDestroyed?.()) { - throw new Error('runtime_unavailable') - } - const releasePublicationThrottle = - options.clientNavigationId && win - ? this.rendererPublicationThrottle.acquire(win.webContents) - : () => {} - try { - await (options.localPtyTeardownOwnedExternally - ? this.notifier.closeTerminalTab(tab.parentTabId, { - localPtyTeardownOwnedExternally: true - }) - : this.notifier.closeTerminalTab(tab.parentTabId)) - } finally { - releasePublicationThrottle() - } - const remainingSnapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - const remainingTab = remainingSnapshot?.tabs.find( - (candidate): candidate is RuntimeMobileSessionTerminalTab => - candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId - ) - if ( - remainingSnapshot && - remainingTab && - this.isRuntimeOwnedHeadlessMobileTab(worktreeId, remainingTab) - ) { - const remainingPtyCloseAuthority = options.expectedPtyCloseAuthority - ? this.resolvePtyTabCloseSurfaceAuthority(options.expectedPtyCloseAuthority) - : null - // Why: after relay recovery the renderer can acknowledge a tab it no longer mirrors; the HUB must still retire its SSH-owned surface. - this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab, { - // Why: the renderer may already have durably removed the tab before acknowledging. - allowMissingPersistedTab: true, - ...(remainingPtyCloseAuthority ? { authorizedPty: remainingPtyCloseAuthority.pty } : {}) - }) - this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) - } - this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId) - return finishCommittedClose() - } - // Why: notifier implementations without the acknowledged relay may expose - // only raw pane close. Runtime-owned parents still need de-persist + kill. - if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { - ...(ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}) - }) - this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) - return finishCommittedClose() - } - if (!this.notifier?.closeTerminal) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { - ...(ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}) - }) - return finishCommittedClose() - } - if (tab.id === tabId) { - const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) - if (pty) { - if (this.ptyController?.kill(pty.ptyId) !== true) { - throw new Error('terminal_close_failed') - } - return finishCommittedClose() - } - this.notifier.closeTerminal(tab.parentTabId) - return delegatedMobileSessionTabClose() - } - // Why: paired web tab bars represent a split terminal with one local - // parent tab id. Closing that parent should close the desktop tab, not - // just whichever leaf happened to be first in the session snapshot. - this.notifier.closeTerminal(tab.parentTabId) - this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId) - return delegatedMobileSessionTabClose() - } else if (tab.type === 'browser') { - // Why: a browser tab can be hosted by a client, by the offscreen backend, - // or by the renderer; each surface owns a different retirement path. - const clientPage = tab.browserPageId - ? getRuntimeBrowserPageRegistry(this).getPage(tab.browserPageId) - : undefined - if (clientPage) { - await this.browserTabClose({ - worktree: `id:${worktreeId}`, - page: clientPage.browserPageId - }) - } else if (this.isOffscreenMobileSessionBrowserTab(snapshot, tab)) { - await this.offscreenBrowserBackend!.closeTab(tab.browserPageId!).catch(() => {}) - this.retireRuntimeOwnedBrowserSessionTab(worktreeId, tab.browserPageId!) - } else { - if (!this.notifier?.closeSessionTab) { - throw new Error('runtime_unavailable') - } - await this.notifier.closeSessionTab(tab.id, worktreeId) - } - } else if (tab.type === 'agent-session') { - if (this.notifier?.closeSessionTab) { - try { - await this.notifier.closeSessionTab( - structuredAgentSessionTabId(tab.sessionId), - worktreeId - ) - } catch (error) { - // The renderer already having removed the tab is an idempotent close, not a veto. - if (!(error instanceof Error && error.message === SESSION_TAB_NOT_FOUND_ERROR)) { - throw error - } - } - } - await this.closeStructuredAgentSessionTab(worktreeId, snapshot, tab) - } else { - if (!this.notifier?.closeSessionTab) { - throw new Error('runtime_unavailable') - } - await this.notifier.closeSessionTab(tab.id, worktreeId) - } - return finishCommittedClose() - } - - // Why: a refused echoed close means the echoing client already pruned its - // local mirror. Bump the version and emit the unchanged snapshot so clients - // that dedupe by snapshotVersion re-add and re-attach the still-live tab. - private republishMobileSessionTabsSnapshot(worktreeId: string): void { - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - if (snapshot) { - this.mobileSessionTabsByWorktree.set(worktreeId, { - ...snapshot, - snapshotVersion: snapshot.snapshotVersion + 1 - }) - } - this.notifyMobileSessionTabsChanged(worktreeId) - } - - private getMobileSessionTerminalHandle( - worktreeId: string, - tab: RuntimeMobileSessionTerminalTab - ): string | null { - const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) - if (!pty) { - return null - } - return this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId) - } - - private getMobileSessionTerminalRetirementProof( - worktreeId: string, - tab: RuntimeMobileSessionTerminalTab, - authorizedPty?: RuntimePtyWorktreeRecord - ): RuntimeMobileSessionRetiredTerminalSurface | null { - const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) ?? authorizedPty ?? null - if (!pty || !this.getMobileTerminalLeafPtyIds(tab).includes(pty.ptyId)) { - return null - } - const terminal = this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId) - if (!terminal) { - return null - } - const incarnationId = - pty.incarnationId ?? - this.getWorkspaceSessionForWorktree(worktreeId)?.terminalPtyIncarnationsByPaneKey?.[ - this.getMobileTerminalPaneKey(tab) - ] - return { - parentTabId: tab.parentTabId, - leafId: tab.leafId, - ptyId: pty.ptyId, - terminal, - ...(incarnationId ? { incarnationId } : {}) - } - } - - private notifyRendererOfHeadlessTerminalClose(parentTabId: string): void { - // Why: this relay is advisory after main owns teardown; renderer failure must - // not prevent the authoritative session flush or turn the close into failure. - try { - this.notifier?.closeTerminal(parentTabId) - } catch (error) { - console.warn('[runtime] failed to notify renderer after headless terminal close', { - parentTabId, - error - }) - } - } - - private isOffscreenMobileSessionBrowserTab( - snapshot: RuntimeMobileSessionTabsSnapshot, - tab: RuntimeMobileSessionBrowserTab - ): boolean { - if (!this.offscreenBrowserBackend || !tab.browserPageId) { - return false - } - if (this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch)) { - return true - } - const accepted = this.acceptedRendererMobileSnapshotByWorktree.get(snapshot.worktree) - return ( - snapshot.publicationEpoch.includes(':headless-merge:') && - accepted !== undefined && - !this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => - accepted.rendererTabIdentityKeys.has(id) - ) && - this.getLiveBrowserTabsByPageId(snapshot.worktree).has(tab.browserPageId) - ) - } - - // Public so runtime-side page release (lease fencing) can prune a tab whose page is gone. - retireRuntimeOwnedBrowserSessionTab(worktreeId: string, browserPageId: string): boolean { - // Why: before the snapshot guard — worktree removal drops the snapshot first, and the host - // rows for its client pages would otherwise be stranded on screen with nothing to retract them. - this.clientHostedBrowserRows.publish(worktreeId) - this.persistClientHostedBrowserPagesForWorktree(worktreeId) - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - if (!snapshot) { - return false - } - const retiredTab = snapshot.tabs.find( - (candidate): candidate is RuntimeMobileSessionBrowserTab => - candidate.type === 'browser' && candidate.browserPageId === browserPageId - ) - if (!retiredTab) { - return false - } - const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== retiredTab.id) - const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null - const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { - ...snapshot, - publicationEpoch: `headless:${Date.now().toString(36)}`, - snapshotVersion: snapshot.snapshotVersion + 1, - activeTabId: active?.id ?? null, - activeTabType: active?.type ?? null, - tabGroups: (snapshot.tabGroups ?? []).map((group) => ({ - ...group, - tabOrder: group.tabOrder.filter((id) => id !== retiredTab.id), - activeTabId: group.activeTabId === retiredTab.id ? null : group.activeTabId - })), - tabs: nextTabs - } - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) - this.emitMobileSessionTabsSnapshot(nextSnapshot) - return true - } - - private async closeStructuredAgentSessionTab( - worktreeId: string, - snapshot: RuntimeMobileSessionTabsSnapshot, - tab: RuntimeMobileSessionAgentTab - ): Promise { - const host = getStructuredAgentSessionHost() - if (typeof host?.setSessionTabVisibility === 'function') { - await host.setSessionTabVisibility(tab.sessionId, false) - } - const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== tab.id) - const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null - const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { - ...snapshot, - snapshotVersion: snapshot.snapshotVersion + 1, - activeTabId: active?.id ?? null, - activeTabType: active?.type ?? null, - tabGroups: (snapshot.tabGroups ?? []).map((group) => ({ - ...group, - tabOrder: group.tabOrder.filter((id) => id !== tab.id), - activeTabId: group.activeTabId === tab.id ? null : group.activeTabId, - recentTabIds: group.recentTabIds?.filter((id) => id !== tab.id) - })), - tabs: nextTabs - } - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) - this.emitMobileSessionTabsSnapshot(nextSnapshot) - } - - private markHeadlessBrowserSessionTabActive( - worktreeId: string | undefined, - browserPageId: string, - options: BrowserSessionTabSelectionOptions - ): void { - if (!worktreeId) { - return - } - const { targetGroupId, focusesHost } = options - // Why: client-placed pages publish through the page registry and need no offscreen backing. - if ( - !this.offscreenBrowserBackend && - !getRuntimeBrowserPageRegistry(this).getPage(browserPageId) - ) { - return - } - // Hydrate first so the freshly created browser tab is present in the snapshot. - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - const tab = snapshot?.tabs.find( - (candidate): candidate is RuntimeMobileSessionBrowserTab => - candidate.type === 'browser' && candidate.browserPageId === browserPageId - ) - if (!snapshot || !tab) { - return - } - const { - snapshot: nextSnapshot, - groups: nextGroups, - placedInTargetGroup - } = applyBrowserSessionTabSelection({ - snapshot, - tabId: tab.id, - ...(targetGroupId !== undefined ? { targetGroupId } : {}), - focusesHost, - publicationEpoch: `headless:${Date.now().toString(36)}` - }) - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) - // Why: browser group membership is otherwise live-only; persist it so a - // later rebuild keeps the browser in its group instead of coalescing left. - if (placedInTargetGroup && nextSnapshot.tabGroupLayout) { - this.persistHeadlessTabGroups(worktreeId, nextGroups, nextSnapshot.tabGroupLayout) - } - this.emitMobileSessionTabsSnapshot(nextSnapshot) - if (options.caller) { - // Why: the originating device still lands on the tab it just created; only the shared - // snapshot stayed put. Local creates keep the pre-navigation shape by having no caller. - this.applyMobileSessionTabNavigation( - this.getMobileSessionTabsForWorktree(worktreeId), - tab.id, - options.caller.navigation, - options.caller.clientNavigationId - ) - } - } - - private closeHeadlessMobileTerminalTab( - worktreeId: string, - snapshot: RuntimeMobileSessionTabsSnapshot, - tab: RuntimeMobileSessionTerminalTab, - options: { - allowMissingPersistedTab?: boolean - killPtys?: boolean - authorizedPty?: RuntimePtyWorktreeRecord - } = {} - ): void { - const closedParentTabId = tab.parentTabId - const retirementProofs = snapshot.tabs.flatMap((candidate) => { - if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { - return [] - } - const proof = this.getMobileSessionTerminalRetirementProof( - worktreeId, - candidate, - options.authorizedPty - ) - return proof ? [proof] : [] - }) - const projectedPtyIds = this.commitHeadlessTerminalTabRetirement( - worktreeId, - closedParentTabId, - { allowMissing: options.allowMissingPersistedTab } - ) - this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, closedParentTabId) - if (options.authorizedPty) { - options.authorizedPty.runtimeSessionOwned = false - this.setPairedRendererSessionOwnership(options.authorizedPty.ptyId, false) - } - // Why: local provider ids can be reused after restart, so a dormant - // persisted id is not kill authority. SSH relay ids remain durable exact - // identities even before pane metadata reconnects. - const ptyIdsToKill = new Set(projectedPtyIds.filter((ptyId) => parseAppSshPtyId(ptyId))) - for (const candidate of snapshot.tabs) { - if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { - continue - } - const authorizedPty = - options.authorizedPty && - this.getMobileTerminalLeafPtyIds(candidate).includes(options.authorizedPty.ptyId) - ? options.authorizedPty - : null - const livePty = this.findPtyForMobileTerminalTab(worktreeId, candidate) ?? authorizedPty - const ptyId = livePty?.ptyId ?? candidate.ptyId - const hasOtherOwner = snapshot.tabs.some( - (other) => - other.type === 'terminal' && - other.parentTabId !== closedParentTabId && - other.ptyId === ptyId - ) - if (ptyId && !hasOtherOwner && (livePty || parseAppSshPtyId(ptyId))) { - // Why: a live serve leaf can exist before its debounced binding reaches - // persistence. Include it from the authoritative snapshot so split - // close cannot leave a provider process behind. - ptyIdsToKill.add(ptyId) - } - } - if (options.killPtys !== false) { - for (const ptyId of ptyIdsToKill) { - this.ptyController?.kill(ptyId) - } - } - const nextTabs = snapshot.tabs.filter((candidate) => { - if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { - return true - } - return false - }) - const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null - const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { - ...snapshot, - publicationEpoch: `headless:${Date.now().toString(36)}`, - snapshotVersion: snapshot.snapshotVersion + 1, - activeTabId: active?.id ?? null, - activeTabType: active?.type ?? null, - tabGroups: this.buildHeadlessMobileSessionTabGroups( - worktreeId, - nextTabs, - active, - snapshot.tabGroups - ), - ...(retirementProofs.length > 0 - ? { - retiredTerminalSurfaces: appendRetiredTerminalSurfaceProofs( - snapshot.retiredTerminalSurfaces, - retirementProofs - ) - } - : {}), - tabs: nextTabs - } - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) - this.emitMobileSessionTabsSnapshot(nextSnapshot) - } - - async moveMobileSessionTab( - worktreeSelector: string, - move: RuntimeMobileSessionTabMove - ): Promise { - const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) - const worktreeId = - explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - if (!snapshot) { - throw new Error('tab_not_found') - } - if (!this.notifier?.moveSessionTab) { - return this.moveHeadlessMobileSessionTab(worktreeId, snapshot, move) - } - const hostTabId = this.resolveMobileSessionHostTabId(snapshot, move.tabId) - if (!hostTabId) { - throw new Error('tab_not_found') - } - const publicSnapshot = this.toMobileSessionTabsResult(snapshot) - const targetGroup = publicSnapshot.tabGroups?.find((group) => group.id === move.targetGroupId) - if (!targetGroup) { - throw new Error('target_group_not_found') - } - - // Why: web clients address terminal surfaces as tab::leaf, while desktop - // tab grouping is owned by the outer terminal tab id. - if (move.kind === 'reorder') { - const tabOrder = this.normalizeMobileSessionTabOrder(snapshot, targetGroup, move.tabOrder) - if (!tabOrder.includes(hostTabId)) { - throw new Error('invalid_tab_order') - } - this.notifier.moveSessionTab(worktreeId, { - ...move, - tabId: hostTabId, - tabOrder - }) - return { moved: true } - } - this.notifier.moveSessionTab(worktreeId, { - ...move, - tabId: hostTabId - }) - return { moved: true } - } - - // Why: pane geometry inside a tab (split ratios, expanded pane, pane titles) - // is host-authoritative for remote-server tabs but had no push path, so a - // client divider-drag / expand / pane-rename reverted on the next snapshot. - // Persist the structural fields onto the tab's layout, keeping host-owned - // pty bindings and active leaf. - async updateMobileSessionPaneLayout( - worktreeSelector: string, - args: { - tabId: string - root: TerminalPaneLayoutNode | null - expandedLeafId: string | null - titlesByLeafId?: Record - } - ): Promise<{ updated: true }> { - const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) - const worktreeId = - explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id - // Why: when a renderer is authoritative (desktop host reached via shared - // control), it owns pane geometry and republishes it — a headless write here - // would be overwritten and could fight the renderer. Persist only headlessly. - if (this.getAvailableAuthoritativeWindow()) { - return { updated: true } - } - // Why: resolve to the host tab id (older/raw-id clients) so the persisted - // layout entry matches, matching setMobileSessionTabProps. - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - const hostTabId = snapshot - ? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId) - : args.tabId - const resolvedArgs = { ...args, tabId: hostTabId } - const acceptedLayout = this.persistHeadlessTerminalPaneLayout(worktreeId, resolvedArgs) - if (acceptedLayout) { - this.applyHeadlessTerminalPaneLayoutToSnapshot(worktreeId, { - tabId: hostTabId, - root: acceptedLayout.root, - expandedLeafId: acceptedLayout.expandedLeafId, - ...(acceptedLayout.titlesByLeafId ? { titlesByLeafId: acceptedLayout.titlesByLeafId } : {}) - }) - } - return { updated: true } - } - - // Why: tab color/pin are host-authoritative for remote-server tabs but had no - // push path, so pinning or coloring a tab reverted on the next snapshot and - // was never persisted. Persist to the workspace session + live snapshot. - async setMobileSessionTabProps( - worktreeSelector: string, - args: { - tabId: string - color?: string | null - isPinned?: boolean - viewMode?: 'terminal' | 'chat' - } - ): Promise<{ updated: true }> { - const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) - const worktreeId = - explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id - // Why: a renderer-authoritative host owns + republishes tab props, so a - // headless write would be overwritten. Persist only when headless. - if (this.getAvailableAuthoritativeWindow()) { - return { updated: true } - } - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - const hostTabId = snapshot - ? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId) - : args.tabId - this.persistHeadlessSessionTabProps(worktreeId, hostTabId, args) - this.applyHeadlessSessionTabPropsToSnapshot(worktreeId, hostTabId, args) - return { updated: true } - } - - private persistHeadlessSessionTabProps( - worktreeId: string, - tabId: string, - props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' } - ): void { - const session = this.getWorkspaceSessionForWorktree(worktreeId) - if (!session || !this.store?.setWorkspaceSession) { - return - } - const tabs = session.tabsByWorktree[worktreeId] - const nextSession: WorkspaceSessionState = { ...session } - let changed = false - if (tabs?.some((tab) => tab.id === tabId)) { - changed = true - nextSession.tabsByWorktree = { - ...session.tabsByWorktree, - [worktreeId]: tabs.map((tab) => - tab.id === tabId - ? { - ...tab, - ...(props.color !== undefined ? { color: props.color } : {}), - ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}), - ...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {}) - } - : tab - ) - } - } - - const unifiedTabs = session.unifiedTabs?.[worktreeId] - if (unifiedTabs?.some((tab) => tab.id === tabId || tab.entityId === tabId)) { - changed = true - nextSession.unifiedTabs = { - ...session.unifiedTabs, - [worktreeId]: unifiedTabs.map((tab) => - tab.id === tabId || tab.entityId === tabId - ? { - ...tab, - ...(props.color !== undefined ? { color: props.color } : {}), - ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}) - } - : tab - ) - } - } - - if (!changed) { - return - } - this.setWorkspaceSessionForWorktree(worktreeId, nextSession) - } - - private applyHeadlessSessionTabPropsToSnapshot( - worktreeId: string, - tabId: string, - props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' } - ): void { - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - if (!snapshot) { - return - } - let changed = false - const tabs = snapshot.tabs.map((tab) => { - if (this.getMobileSessionTopLevelTabId(tab) !== tabId) { - return tab - } - changed = true - return { - ...tab, - ...(props.color !== undefined ? { color: props.color } : {}), - ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}), - ...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {}) - } - }) - if (!changed) { - return - } - const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { - ...snapshot, - publicationEpoch: `headless:${Date.now().toString(36)}`, - snapshotVersion: snapshot.snapshotVersion + 1, - tabs - } - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) - this.emitMobileSessionTabsSnapshot(nextSnapshot) - } - - private getMobileSessionTopLevelTabId(tab: RuntimeMobileSessionSnapshotTab): string { - return tab.type === 'terminal' ? tab.parentTabId : tab.id - } - - // Merge the client's pane structure into the persisted tab layout. PTY - // bindings and active leaf stay host-owned; only ratios/expand/titles change. - // terminalLayoutsByTabId is keyed by tab id (worktree-independent). - private persistHeadlessTerminalPaneLayout( - worktreeId: string, - args: { - tabId: string - root: TerminalPaneLayoutNode | null - expandedLeafId: string | null - titlesByLeafId?: Record - } - ): TerminalLayoutSnapshot | undefined { - const session = this.getWorkspaceSessionForWorktree(worktreeId) - if (!session || !this.store?.setWorkspaceSession) { - return undefined - } - const existing = session.terminalLayoutsByTabId?.[args.tabId] - if (!existing) { - return undefined - } - const candidate = { - ...session, - terminalLayoutsByTabId: { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - ...this.cloneTerminalLayoutSnapshot(existing), - root: args.root ?? existing.root, - expandedLeafId: args.expandedLeafId, - ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) - } - } - } - this.setWorkspaceSessionForWorktree(worktreeId, candidate) - // Why: persistence may reject stale membership while accepting its metadata; publish only that rebased layout. - return ( - this.getWorkspaceSessionForWorktree(worktreeId)?.terminalLayoutsByTabId[args.tabId] ?? - candidate.terminalLayoutsByTabId[args.tabId] - ) - } - - private applyHeadlessTerminalPaneLayoutToSnapshot( - worktreeId: string, - args: { - tabId: string - root: TerminalPaneLayoutNode | null - expandedLeafId: string | null - titlesByLeafId?: Record - } - ): void { - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - if (!snapshot) { - return - } - let changed = false - const tabs = snapshot.tabs.map((tab) => { - if (tab.type !== 'terminal' || tab.parentTabId !== args.tabId || !tab.parentLayout) { - return tab - } - changed = true - return { - ...tab, - parentLayout: { - ...tab.parentLayout, - root: args.root ?? tab.parentLayout.root, - expandedLeafId: args.expandedLeafId, - ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) - } - } - }) - if (!changed) { - return - } - const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { - ...snapshot, - publicationEpoch: `headless:${Date.now().toString(36)}`, - snapshotVersion: snapshot.snapshotVersion + 1, - tabs - } - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) - this.emitMobileSessionTabsSnapshot(nextSnapshot) - } - - private moveHeadlessMobileSessionTab( - worktreeId: string, - snapshot: RuntimeMobileSessionTabsSnapshot, - move: RuntimeMobileSessionTabMove - ): RuntimeMobileSessionTabMoveResult { - if (move.kind === 'split') { - return this.splitHeadlessMobileSessionTabGroup(worktreeId, snapshot, move) - } - if (move.kind === 'move-to-group') { - return this.moveHeadlessMobileSessionTabToGroup(worktreeId, snapshot, move) - } - if (move.kind !== 'reorder') { - throw new Error('renderer_unavailable') - } - const hostTabId = this.resolveMobileSessionHostTabId(snapshot, move.tabId) - if (!hostTabId) { - throw new Error('tab_not_found') - } - const publicSnapshot = this.toMobileSessionTabsResult(snapshot) - const targetGroup = publicSnapshot.tabGroups?.find((group) => group.id === move.targetGroupId) - if (!targetGroup) { - throw new Error('target_group_not_found') - } - const tabOrder = this.normalizeMobileSessionTabOrder(snapshot, targetGroup, move.tabOrder) - const orderIndexByParentTabId = new Map(tabOrder.map((tabId, index) => [tabId, index])) - const nextTabs = [...snapshot.tabs].sort((a, b) => { - const aParent = a.type === 'terminal' ? a.parentTabId : a.id - const bParent = b.type === 'terminal' ? b.parentTabId : b.id - const aIndex = orderIndexByParentTabId.get(aParent) ?? Number.MAX_SAFE_INTEGER - const bIndex = orderIndexByParentTabId.get(bParent) ?? Number.MAX_SAFE_INTEGER - return aIndex - bIndex - }) - const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null - const reorderedTargetActiveTabId = - active?.type === 'terminal' ? active.parentTabId : active ? active.id : (tabOrder[0] ?? null) - // Why: reorder only changes ONE group's order. Preserve every other group so - // a multi-group split isn't deleted by re-sorting tabs in one of its groups. - const existingGroups = snapshot.tabGroups ?? [] - const nextGroups = existingGroups.some((group) => group.id === targetGroup.id) - ? existingGroups.map((group) => - group.id === targetGroup.id - ? { ...group, tabOrder, activeTabId: reorderedTargetActiveTabId } - : group - ) - : [{ ...targetGroup, tabOrder, activeTabId: reorderedTargetActiveTabId }] - const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { - ...snapshot, - publicationEpoch: `headless:${Date.now().toString(36)}`, - snapshotVersion: snapshot.snapshotVersion + 1, - activeTabId: active?.id ?? null, - activeTabType: active?.type ?? null, - tabGroups: nextGroups, - tabs: nextTabs - } - this.persistHeadlessTerminalTabOrder(worktreeId, tabOrder) - if (nextGroups.length > 1 && snapshot.tabGroupLayout) { - this.persistHeadlessTabGroups(worktreeId, nextGroups, snapshot.tabGroupLayout) - } - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) - this.emitMobileSessionTabsSnapshot(nextSnapshot) - return { moved: true } - } - - // Why: a drag-to-split-group used to be a client-only change the headless host - // never modeled, so the next snapshot coalesced every tab back into one group. - // Model + persist the multi-group layout so the split survives rebuilds. - private splitHeadlessMobileSessionTabGroup( - worktreeId: string, - snapshot: RuntimeMobileSessionTabsSnapshot, - move: Extract - ): RuntimeMobileSessionTabMoveResult { - const hostTabId = this.resolveMobileSessionHostTabId(snapshot, move.tabId) - if (!hostTabId) { - throw new Error('tab_not_found') - } - const split = buildHeadlessTabGroupSplit({ - groups: snapshot.tabGroups ?? [], - layout: snapshot.tabGroupLayout, - tabId: hostTabId, - targetGroupId: move.targetGroupId, - splitDirection: move.splitDirection, - newGroupId: randomUUID() - }) - if (!split) { - // Renderer treats an unsplittable drop (e.g. last tab onto its own group) - // as a no-op; mirror that instead of churning the snapshot. - return { moved: true } - } - const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { - ...snapshot, - publicationEpoch: `headless:${Date.now().toString(36)}`, - snapshotVersion: snapshot.snapshotVersion + 1, - activeGroupId: split.newGroupId, - tabGroups: split.groups, - tabGroupLayout: split.layout - } - this.persistHeadlessTabGroups(worktreeId, split.groups, split.layout) - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) - this.emitMobileSessionTabsSnapshot(nextSnapshot) - return { moved: true } - } - - // Move a tab into an existing group on a headless serve (non-split drop). - private moveHeadlessMobileSessionTabToGroup( - worktreeId: string, - snapshot: RuntimeMobileSessionTabsSnapshot, - move: Extract - ): RuntimeMobileSessionTabMoveResult { - const hostTabId = this.resolveMobileSessionHostTabId(snapshot, move.tabId) - if (!hostTabId) { - throw new Error('tab_not_found') - } - const moved = buildHeadlessTabGroupMove({ - groups: snapshot.tabGroups ?? [], - layout: snapshot.tabGroupLayout, - tabId: hostTabId, - targetGroupId: move.targetGroupId, - index: move.index - }) - if (!moved) { - // Same-group / missing-target drop is a renderer no-op; mirror that. - return { moved: true } - } - const layout = moved.layout ?? { type: 'leaf' as const, groupId: move.targetGroupId } - const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { - ...snapshot, - publicationEpoch: `headless:${Date.now().toString(36)}`, - snapshotVersion: snapshot.snapshotVersion + 1, - activeGroupId: move.targetGroupId, - tabGroups: moved.groups, - tabGroupLayout: layout - } - this.persistHeadlessTabGroups(worktreeId, moved.groups, layout) - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) - this.emitMobileSessionTabsSnapshot(nextSnapshot) - return { moved: true } - } - - // Persist the headless tab-GROUP layout so snapshot rebuilds keep the split. - private persistHeadlessTabGroups( - worktreeId: string, - groups: readonly RuntimeMobileSessionTabGroup[], - layout: TabGroupLayoutNode - ): void { - const session = this.getWorkspaceSessionForWorktree(worktreeId) - if (!session || !this.store?.setWorkspaceSession) { - return - } - this.setWorkspaceSessionForWorktree(worktreeId, { - ...session, - tabGroups: { - ...session.tabGroups, - [worktreeId]: groups.map((group) => ({ - id: group.id, - worktreeId, - activeTabId: group.activeTabId, - tabOrder: [...group.tabOrder], - ...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {}) - })) - }, - tabGroupLayouts: { - ...session.tabGroupLayouts, - [worktreeId]: layout - } - }) - } - - // Persist a manual terminal rename so a headless rebuild keeps the title - // instead of reverting to the generated/default one. - private persistHeadlessTerminalTitle( - worktreeId: string, - tabId: string, - title: string | null - ): void { - const session = this.getWorkspaceSessionForWorktree(worktreeId) - if (!session || !this.store?.setWorkspaceSession) { - return - } - const tabs = session.tabsByWorktree[worktreeId] - if (!tabs?.some((tab) => tab.id === tabId)) { - return - } - this.setWorkspaceSessionForWorktree(worktreeId, { - ...session, - tabsByWorktree: { - ...session.tabsByWorktree, - [worktreeId]: tabs.map((tab) => (tab.id === tabId ? { ...tab, customTitle: title } : tab)) - } - }) - } - - private normalizeMobileSessionTabOrder( - snapshot: RuntimeMobileSessionTabsSnapshot | undefined, - targetGroup: RuntimeMobileSessionTabGroup, - tabOrder: readonly string[] - ): string[] { - const normalized: string[] = [] - const seen = new Set() - for (const tabId of tabOrder) { - const hostTabId = this.resolveMobileSessionHostTabId(snapshot, tabId) - if (!hostTabId) { - throw new Error('invalid_tab_order') - } - if (seen.has(hostTabId)) { - throw new Error('duplicate_tab_order') - } - seen.add(hostTabId) - normalized.push(hostTabId) - } - - const returnedIds = this.collectPublicMobileSessionTabIds(snapshot) - const expected = targetGroup.tabOrder - .map((tabId) => this.resolveMobileSessionHostTabId(snapshot, tabId) ?? tabId) - // Why: clients reorder the sanitized session.tabs.list model; raw groups - // can still contain stale browser ids hidden from paired web clients. - .filter((tabId) => returnedIds.has(tabId)) - const structuredIds = expected.filter((tabId) => - snapshot?.tabs.some((tab) => tab.type === 'agent-session' && tab.id === tabId) - ) - if (structuredIds.some((tabId) => !seen.has(tabId))) { - if (structuredIds.some((tabId) => seen.has(tabId))) { - throw new Error('invalid_tab_order') - } - const visibleExpected = expected.filter((tabId) => !structuredIds.includes(tabId)) - if ( - normalized.length !== visibleExpected.length || - visibleExpected.some((tabId) => !seen.has(tabId)) - ) { - throw new Error('invalid_tab_order') - } - for (const tabId of structuredIds) { - normalized.splice(Math.min(expected.indexOf(tabId), normalized.length), 0, tabId) - } - return normalized - } - // Why: reorder is a pure permutation of one existing group. Missing or - // extra ids would let a paired web client silently move/lose host tabs. - if (normalized.length !== expected.length || expected.some((tabId) => !seen.has(tabId))) { - throw new Error('invalid_tab_order') - } - return normalized - } - - private collectPublicMobileSessionTabIds( - snapshot: RuntimeMobileSessionTabsSnapshot | undefined - ): Set { - const ids = new Set() - if (!snapshot) { - return ids - } - const liveBrowserTabsByPageId = this.getLiveBrowserTabsByPageId(snapshot.worktree) - for (const tab of snapshot.tabs) { - if (tab.type === 'browser') { - const liveTab = tab.browserPageId - ? liveBrowserTabsByPageId.get(tab.browserPageId) - : undefined - if (!liveTab) { - continue - } - ids.add(tab.id) - ids.add(tab.browserWorkspaceId) - continue - } - ids.add(tab.id) - if (tab.type === 'terminal') { - ids.add(tab.parentTabId) - } - } - return ids - } - - private resolveMobileSessionHostTabId( - snapshot: RuntimeMobileSessionTabsSnapshot | undefined, - tabId: string - ): string | null { - const tab = - snapshot?.tabs.find((candidate) => candidate.id === tabId) ?? - snapshot?.tabs.find( - (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tabId - ) ?? - snapshot?.tabs.find( - (candidate) => candidate.type === 'browser' && candidate.browserWorkspaceId === tabId - ) - if (!tab) { - return null - } - return tab.type === 'terminal' ? tab.parentTabId : tab.id - } - - async readMobileMarkdownTab( - worktreeSelector: string, - tabId: string - ): Promise { - const worktreeId = await this.resolveMobileMarkdownWorktreeId(worktreeSelector, tabId) - if (!this.notifier?.readMobileMarkdownTab) { - throw new Error('renderer_unavailable') - } - return await this.notifier.readMobileMarkdownTab(worktreeId, tabId) - } - - async saveMobileMarkdownTab( - worktreeSelector: string, - tabId: string, - baseVersion: string, - content: string - ): Promise { - const worktreeId = await this.resolveMobileMarkdownWorktreeId(worktreeSelector, tabId) - if (!this.notifier?.saveMobileMarkdownTab) { - throw new Error('renderer_unavailable') - } - return await this.notifier.saveMobileMarkdownTab(worktreeId, tabId, baseVersion, content) - } - - private readonly fileCommands = new RuntimeFileCommands({ - getRuntimeId: () => this.runtimeId, - requireStore: () => this.requireStore(), - resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), - resolveRuntimeFileTarget: (selector) => this.resolveRuntimeFileTarget(selector), - resolveKnownWorkspaceFileTarget: (absolutePath, connectionId) => - this.resolveKnownWorkspaceFileTarget(absolutePath, connectionId), - resolveTerminalCwd: (terminalHandle) => this.resolveTerminalCwd(terminalHandle), - resolveTerminalContext: (terminalHandle) => this.resolveTerminalContext(terminalHandle), - resolveTerminalFileUriHostname: (terminalHandle) => - this.resolveTerminalFileUriHostname(terminalHandle), - hasRecentTerminalOutputPath: (terminalHandle, pathText, absolutePath) => - this.hasRecentTerminalOutputPath(terminalHandle, pathText, absolutePath), - hasRecentNativeChatOutputPath: (worktreeId, context, pathText, absolutePath) => - nativeChatTranscriptIncludesPath({ - tabs: this.getMobileSessionTabsForWorktree(worktreeId).tabs, - context, - pathText, - absolutePath - }), - resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector), - openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId) => { - if (!this.notifier?.openFile) { - throw new Error('renderer_unavailable') - } - this.notifier.openFile(worktreeId, filePath, relativePath, runtimeEnvironmentId) - }, - openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId) => { - if (!this.notifier?.openDiff) { - throw new Error('renderer_unavailable') - } - this.notifier.openDiff(worktreeId, filePath, relativePath, staged, runtimeEnvironmentId) - } - }) - - listMobileFiles: RuntimeFileCommands['listMobileFiles'] = this.fileCommands.listMobileFiles.bind( - this.fileCommands - ) - searchMobileFilePaths: RuntimeFileCommands['searchMobileFilePaths'] = - this.fileCommands.searchMobileFilePaths.bind(this.fileCommands) - searchQuickOpenFilePaths: RuntimeFileCommands['searchQuickOpenFilePaths'] = - this.fileCommands.searchQuickOpenFilePaths.bind(this.fileCommands) - openMobileFile: RuntimeFileCommands['openMobileFile'] = this.fileCommands.openMobileFile.bind( - this.fileCommands - ) - openMobileDiff: RuntimeFileCommands['openMobileDiff'] = this.fileCommands.openMobileDiff.bind( - this.fileCommands - ) - readMobileFile: RuntimeFileCommands['readMobileFile'] = this.fileCommands.readMobileFile.bind( - this.fileCommands - ) - resolveTerminalPath: RuntimeFileCommands['resolveTerminalPath'] = - this.fileCommands.resolveTerminalPath.bind(this.fileCommands) - readTerminalArtifactFile: RuntimeFileCommands['readTerminalArtifactFile'] = - this.fileCommands.readTerminalArtifactFile.bind(this.fileCommands) - readTerminalArtifactPreview: RuntimeFileCommands['readTerminalArtifactPreview'] = - this.fileCommands.readTerminalArtifactPreview.bind(this.fileCommands) - readTerminalArtifactChunk: RuntimeFileCommands['readTerminalArtifactChunk'] = - this.fileCommands.readTerminalArtifactChunk.bind(this.fileCommands) - writeTerminalArtifactFile: RuntimeFileCommands['writeTerminalArtifactFile'] = - this.fileCommands.writeTerminalArtifactFile.bind(this.fileCommands) - revokeTerminalFileGrantsForClient: RuntimeFileCommands['revokeTerminalFileGrantsForClient'] = - this.fileCommands.revokeTerminalFileGrantsForClient.bind(this.fileCommands) - readFileExplorerDir: RuntimeFileCommands['readFileExplorerDir'] = - this.fileCommands.readFileExplorerDir.bind(this.fileCommands) - watchFileExplorer: RuntimeFileCommands['watchFileExplorer'] = - this.fileCommands.watchFileExplorer.bind(this.fileCommands) - closeFileWatchersForRemoval = async ( - worktreePath: string, - connectionId?: string, - deadline: WatcherRemovalDeadline = createWatcherRemovalDeadline() - ): Promise => { - // Why drain the remote/explorer closes: they await SSH round trips and lease suspends that a dead - // link never answers. The local close bounds its own awaits against the same deadline internally. - const results = await Promise.allSettled([ - connectionId - ? drainBeforeWatcherRemoval( - getWorktreeWatcherRemoval().closeRemote(connectionId, worktreePath), - deadline, - `remote watcher close for ${worktreePath}` - ) - : getWorktreeWatcherRemoval().closeLocal(worktreePath, deadline), - drainBeforeWatcherRemoval( - this.fileCommands.closeFileExplorerWatchersForPath(worktreePath, connectionId), - deadline, - `file explorer watcher close for ${worktreePath}` - ) - ]) - const failure = results.find((result): result is PromiseRejectedResult => { - return result.status === 'rejected' - }) - if (failure) { - // Why: restoration must start only after every bounded teardown settles; - // otherwise a late close can stale a just-restored logical subscription. - throw failure.reason - } - } - restoreFileWatchersAfterFailedRemoval = async ( - worktreePath: string, - connectionId?: string - ): Promise => { - await Promise.all([ - connectionId - ? getWorktreeWatcherRemoval().restoreRemote(connectionId, worktreePath) - : getWorktreeWatcherRemoval().restoreLocal(worktreePath), - this.fileCommands.restoreFileExplorerWatchersAfterFailedRemoval(worktreePath, connectionId) - ]) - } - forgetFileWatchersAfterRemoval = (worktreePath: string, connectionId?: string): void => { - if (connectionId) { - getWorktreeWatcherRemoval().forgetRemote(connectionId, worktreePath) - } else { - getWorktreeWatcherRemoval().forgetLocal(worktreePath) - } - this.fileCommands.forgetFileExplorerWatchersAfterRemoval(worktreePath, connectionId) - } - acquireFileWatcherRemoval = async ( - worktreePath: string, - connectionId?: string - ): Promise<{ finish(removed: boolean): Promise }> => { - const gate = acquireWatcherRemovalGate(worktreePath, connectionId) - // Why: one budget for the whole preparation — independent per-await timeouts would compose into minutes. - const deadline = createWatcherRemovalDeadline() - try { - // Why: the first pass aborts desktop setup immediately; the second catches - // any pre-gate runtime install that published after the first snapshot. - await this.closeFileWatchersForRemoval(worktreePath, connectionId, deadline) - // Why: a wedged install never releases its fence slot, so gate.ready can hang forever; the delete - // must proceed instead, or the gate stays held and every later install under this root is rejected. - const fenceDrain = await drainBeforeWatcherRemoval( - gate.ready, - deadline, - `watcher install fence for ${worktreePath}` - ) - if (fenceDrain === 'timeout') { - // Why: a wedged install holds its fence slot for the process lifetime, so leaving it counted - // makes every later removal of this root burn the whole drain budget again. - gate.abandonPendingInstalls() - } - await this.closeFileWatchersForRemoval(worktreePath, connectionId, deadline) - let finished = false - return { - finish: async (removed) => { - if (finished) { - return - } - finished = true - if (removed) { - this.forgetFileWatchersAfterRemoval(worktreePath, connectionId) - } - gate.release() - if (!removed) { - await this.restoreFileWatchersAfterFailedRemoval(worktreePath, connectionId).catch( - (restoreError: unknown) => { - console.error('[worktrees] failed to restore watchers after removal failed', { - worktreePath, - restoreError - }) - } - ) - } - } - } - } catch (error) { - gate.release() - await this.restoreFileWatchersAfterFailedRemoval(worktreePath, connectionId).catch( - (restoreError: unknown) => { - console.error('[worktrees] failed to restore watchers after removal setup failed', { - worktreePath, - restoreError - }) - } - ) - throw error - } - } - readFileExplorerPreview: RuntimeFileCommands['readFileExplorerPreview'] = - this.fileCommands.readFileExplorerPreview.bind(this.fileCommands) - readDocPreviewFile: RuntimeFileCommands['readDocPreviewFile'] = - this.fileCommands.readDocPreviewFile.bind(this.fileCommands) - readFileExplorerChunk: RuntimeFileCommands['readFileExplorerChunk'] = - this.fileCommands.readFileExplorerChunk.bind(this.fileCommands) - writeFileExplorerFile: RuntimeFileCommands['writeFileExplorerFile'] = - this.fileCommands.writeFileExplorerFile.bind(this.fileCommands) - writeFileExplorerFileBase64: RuntimeFileCommands['writeFileExplorerFileBase64'] = - this.fileCommands.writeFileExplorerFileBase64.bind(this.fileCommands) - writeFileExplorerFileBase64Chunk: RuntimeFileCommands['writeFileExplorerFileBase64Chunk'] = - this.fileCommands.writeFileExplorerFileBase64Chunk.bind(this.fileCommands) - createFileExplorerFile: RuntimeFileCommands['createFileExplorerFile'] = - this.fileCommands.createFileExplorerFile.bind(this.fileCommands) - createFileExplorerDir: RuntimeFileCommands['createFileExplorerDir'] = - this.fileCommands.createFileExplorerDir.bind(this.fileCommands) - createFileExplorerDirNoClobber: RuntimeFileCommands['createFileExplorerDirNoClobber'] = - this.fileCommands.createFileExplorerDirNoClobber.bind(this.fileCommands) - commitFileExplorerUpload: RuntimeFileCommands['commitFileExplorerUpload'] = - this.fileCommands.commitFileExplorerUpload.bind(this.fileCommands) - renameFileExplorerPath: RuntimeFileCommands['renameFileExplorerPath'] = - this.fileCommands.renameFileExplorerPath.bind(this.fileCommands) - copyFileExplorerPath: RuntimeFileCommands['copyFileExplorerPath'] = - this.fileCommands.copyFileExplorerPath.bind(this.fileCommands) - deleteFileExplorerPath: RuntimeFileCommands['deleteFileExplorerPath'] = - this.fileCommands.deleteFileExplorerPath.bind(this.fileCommands) - searchRuntimeFiles: RuntimeFileCommands['searchRuntimeFiles'] = - this.fileCommands.searchRuntimeFiles.bind(this.fileCommands) - listRuntimeFiles: RuntimeFileCommands['listRuntimeFiles'] = - this.fileCommands.listRuntimeFiles.bind(this.fileCommands) - listRuntimeMarkdownDocuments: RuntimeFileCommands['listRuntimeMarkdownDocuments'] = - this.fileCommands.listRuntimeMarkdownDocuments.bind(this.fileCommands) - statRuntimeFile: RuntimeFileCommands['statRuntimeFile'] = this.fileCommands.statRuntimeFile.bind( - this.fileCommands - ) - - private readonly gitCommands = new RuntimeGitCommands({ - resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector), - getRuntimeSettings: () => this.requireStore().getSettings() as GlobalSettings, - getCommitMessageAgentEnvironment: () => this.commitMessageAgentEnv ?? undefined, - // Why: resolved worktrees are cached for a second, so link/unlink would lag - // generation; meta is keyed by the same id the resolver returns. - getWorktreeLinkedIssue: (worktreeId) => { - const store = this.store - // Why: an unreadable store is "unknown", not "unlinked" — undefined keeps - // the resolver's cached linkedIssue instead of suppressing {linkedIssue}. - if (!store?.getWorktreeMeta) { - return undefined - } - return store.getWorktreeMeta(worktreeId)?.linkedIssue ?? null - }, - getWorktreeLinkedIssueMeta: (worktreeId) => { - const store = this.store - if (!store?.getWorktreeMeta) { - return undefined - } - const meta = store.getWorktreeMeta(worktreeId) - return meta - ? { - linkedIssue: meta.linkedIssue, - linkedGitLabIssue: meta.linkedGitLabIssue, - linkedWorkItem: meta.linkedWorkItem - } - : null - } - }) - - getRuntimeGitStatus: RuntimeGitCommands['getRuntimeGitStatus'] = - this.gitCommands.getRuntimeGitStatus.bind(this.gitCommands) - getRuntimeGitSubmoduleStatus: RuntimeGitCommands['getRuntimeGitSubmoduleStatus'] = - this.gitCommands.getRuntimeGitSubmoduleStatus.bind(this.gitCommands) - checkRuntimeGitIgnoredPaths: RuntimeGitCommands['checkRuntimeGitIgnoredPaths'] = - this.gitCommands.checkRuntimeGitIgnoredPaths.bind(this.gitCommands) - getRuntimeGitHistory: RuntimeGitCommands['getRuntimeGitHistory'] = - this.gitCommands.getRuntimeGitHistory.bind(this.gitCommands) - getRuntimeGitConflictOperation: RuntimeGitCommands['getRuntimeGitConflictOperation'] = - this.gitCommands.getRuntimeGitConflictOperation.bind(this.gitCommands) - abortRuntimeGitMerge: RuntimeGitCommands['abortRuntimeGitMerge'] = - this.gitCommands.abortRuntimeGitMerge.bind(this.gitCommands) - abortRuntimeGitRebase: RuntimeGitCommands['abortRuntimeGitRebase'] = - this.gitCommands.abortRuntimeGitRebase.bind(this.gitCommands) - checkoutRuntimeGitBranch: RuntimeGitCommands['checkoutRuntimeGitBranch'] = - this.gitCommands.checkoutRuntimeGitBranch.bind(this.gitCommands) - listRuntimeGitLocalBranches: RuntimeGitCommands['listRuntimeGitLocalBranches'] = - this.gitCommands.listRuntimeGitLocalBranches.bind(this.gitCommands) - getRuntimeGitDiff: RuntimeGitCommands['getRuntimeGitDiff'] = - this.gitCommands.getRuntimeGitDiff.bind(this.gitCommands) - getRuntimeGitBranchCompare: RuntimeGitCommands['getRuntimeGitBranchCompare'] = - this.gitCommands.getRuntimeGitBranchCompare.bind(this.gitCommands) - getRuntimeGitCommitCompare: RuntimeGitCommands['getRuntimeGitCommitCompare'] = - this.gitCommands.getRuntimeGitCommitCompare.bind(this.gitCommands) - getRuntimeGitUpstreamStatus: RuntimeGitCommands['getRuntimeGitUpstreamStatus'] = - this.gitCommands.getRuntimeGitUpstreamStatus.bind(this.gitCommands) - fetchRuntimeGit: RuntimeGitCommands['fetchRuntimeGit'] = this.gitCommands.fetchRuntimeGit.bind( - this.gitCommands - ) - syncRuntimeGitForkDefaultBranch: RuntimeGitCommands['syncRuntimeGitForkDefaultBranch'] = - this.gitCommands.syncRuntimeGitForkDefaultBranch.bind(this.gitCommands) - pullRuntimeGit: RuntimeGitCommands['pullRuntimeGit'] = this.gitCommands.pullRuntimeGit.bind( - this.gitCommands - ) - fastForwardRuntimeGit: RuntimeGitCommands['fastForwardRuntimeGit'] = - this.gitCommands.fastForwardRuntimeGit.bind(this.gitCommands) - rebaseRuntimeGitFromBase: RuntimeGitCommands['rebaseRuntimeGitFromBase'] = - this.gitCommands.rebaseRuntimeGitFromBase.bind(this.gitCommands) - pushRuntimeGit: RuntimeGitCommands['pushRuntimeGit'] = this.gitCommands.pushRuntimeGit.bind( - this.gitCommands - ) - getRuntimeGitBranchDiff: RuntimeGitCommands['getRuntimeGitBranchDiff'] = - this.gitCommands.getRuntimeGitBranchDiff.bind(this.gitCommands) - getRuntimeGitCommitDiff: RuntimeGitCommands['getRuntimeGitCommitDiff'] = - this.gitCommands.getRuntimeGitCommitDiff.bind(this.gitCommands) - commitRuntimeGit: RuntimeGitCommands['commitRuntimeGit'] = this.gitCommands.commitRuntimeGit.bind( - this.gitCommands - ) - generateRuntimeCommitMessage: RuntimeGitCommands['generateRuntimeCommitMessage'] = - this.gitCommands.generateRuntimeCommitMessage.bind(this.gitCommands) - discoverRuntimeCommitMessageModels: RuntimeGitCommands['discoverRuntimeCommitMessageModels'] = - this.gitCommands.discoverRuntimeCommitMessageModels.bind(this.gitCommands) - cancelRuntimeGenerateCommitMessage: RuntimeGitCommands['cancelRuntimeGenerateCommitMessage'] = - this.gitCommands.cancelRuntimeGenerateCommitMessage.bind(this.gitCommands) - generateRuntimePullRequestFields: RuntimeGitCommands['generateRuntimePullRequestFields'] = - this.gitCommands.generateRuntimePullRequestFields.bind(this.gitCommands) - cancelRuntimeGeneratePullRequestFields: RuntimeGitCommands['cancelRuntimeGeneratePullRequestFields'] = - this.gitCommands.cancelRuntimeGeneratePullRequestFields.bind(this.gitCommands) - stageRuntimeGitPath: RuntimeGitCommands['stageRuntimeGitPath'] = - this.gitCommands.stageRuntimeGitPath.bind(this.gitCommands) - unstageRuntimeGitPath: RuntimeGitCommands['unstageRuntimeGitPath'] = - this.gitCommands.unstageRuntimeGitPath.bind(this.gitCommands) - bulkStageRuntimeGitPaths: RuntimeGitCommands['bulkStageRuntimeGitPaths'] = - this.gitCommands.bulkStageRuntimeGitPaths.bind(this.gitCommands) - bulkUnstageRuntimeGitPaths: RuntimeGitCommands['bulkUnstageRuntimeGitPaths'] = - this.gitCommands.bulkUnstageRuntimeGitPaths.bind(this.gitCommands) - bulkDiscardRuntimeGitPaths: RuntimeGitCommands['bulkDiscardRuntimeGitPaths'] = - this.gitCommands.bulkDiscardRuntimeGitPaths.bind(this.gitCommands) - discardRuntimeGitPath: RuntimeGitCommands['discardRuntimeGitPath'] = - this.gitCommands.discardRuntimeGitPath.bind(this.gitCommands) - getRuntimeGitRemoteFileUrl: RuntimeGitCommands['getRuntimeGitRemoteFileUrl'] = - this.gitCommands.getRuntimeGitRemoteFileUrl.bind(this.gitCommands) - getRuntimeGitRemoteCommitUrl: RuntimeGitCommands['getRuntimeGitRemoteCommitUrl'] = - this.gitCommands.getRuntimeGitRemoteCommitUrl.bind(this.gitCommands) - - /** - * Installs the structured agent-session host on first use. Lazy for the same - * reason the orchestration DB is: the profile's user-data path is not final - * until the app is ready, and a runtime nobody drives a chat session on - * should never open the record store. - */ - async ensureStructuredAgentSessionHost(): Promise { - await installStructuredAgentSessionHost({ - stateDirectory: getProfileUserDataPath(), - hostId: LOCAL_EXECUTION_HOST_ID, - claimKeyId: this.agentSessionClaimSigner.keyId, - // Resolves folder workspaces as well as git worktrees, so a chat session - // in a plain folder lands in the folder rather than failing to resolve. - resolveWorkspacePath: async (workspaceId) => - (await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path, - resolveLaunchArgs: () => this.resolveConfiguredCodexStructuredArgs(), - resolveLaunchEnvOverlay: () => - resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv), - handoffTransport: this.createStructuredAgentSessionHandoffTransport() - }) - } - - private resolveConfiguredCodexStructuredArgs(): string[] { - const settings = this.requireStore().getSettings() - const shell = resolveLocalWindowsAgentStartupShell({ - platform: process.platform, - isRemote: false, - terminalWindowsShell: settings.terminalWindowsShell - }) - return resolveCodexStructuredAppServerArgs( - resolveTuiAgentLaunchArgs('codex', settings.agentDefaultArgs), - shell ?? 'posix' - ) - } - - private createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport { - return { - hostLabel: hostname(), - launchTui: async ({ record, fence, spawnToken, onSpawned }) => { - const head = record.providerHandleChain.at(-1) - if (!head || (head.handle.provider !== 'codex' && head.handle.provider !== 'claude')) { - throw new Error('agent_session_identity_required') - } - const provider = head.handle.provider - const providerSessionId = - provider === 'claude' ? head.handle.sessionId : head.handle.threadId - const launchStartedAt = Date.now() - const launched = await this.ensureAgentSession( - { - kind: 'explicit', - worktree: `id:${record.location.workspaceId}`, - agent: provider, - providerSession: { key: 'session_id', id: providerSessionId }, - ...(record.options ? { launchPreferences: record.options } : {}), - presentation: 'background' - }, - {}, - { spawnToken, providerRoot: record.accountHome.path, sessionId: record.sessionId } - ) - const terminal = launched.terminal - let spawnedOwner: StructuredTuiOwner | null = null - let ptyId: string | undefined - try { - if (!terminal.processId || !terminal.paneKey || !terminal.tabId || !terminal.ptyId) { - throw new Error('The resumed terminal did not publish a process identity.') - } - ptyId = terminal.ptyId - spawnedOwner = this.refreshStructuredTuiOwnerBinding({ - terminal: { - handle: terminal.handle, - tabId: terminal.tabId, - paneKey: terminal.paneKey, - ptyId: terminal.ptyId - }, - process: - provider === 'codex' - ? await readCodexResumeProcessIdentity({ - hostId: record.location.executionHostId, - rootPid: terminal.processId, - spawnToken, - threadId: head.handle.threadId - }) - : await readStructuredTuiProcessIdentity({ - hostId: record.location.executionHostId, - rootPid: terminal.processId, - spawnToken, - agent: provider - }), - link: - provider === 'codex' - ? codexProviderHandleLink({ - threadId: head.handle.threadId, - resumed: true, - fence, - observedAt: Date.now() - }) - : claudeProviderHandleLink({ - sessionId: head.handle.sessionId, - leafUuid: head.handle.leafUuid, - resumed: true, - fence, - observedAt: Date.now() - }) - }) - await onSpawned?.(spawnedOwner) - await this.waitForTerminal(terminal.handle, { - condition: 'tui-idle', - timeoutMs: 30_000 - }) - const proof = - provider === 'codex' - ? await this.waitForAdoptedStructuredTuiProof({ - owner: spawnedOwner, - threadId: head.handle.threadId, - codexHome: record.accountHome.path - }) - : await this.waitForStructuredClaudeTuiProof({ - handle: terminal.handle, - paneKey: terminal.paneKey, - sessionId: head.handle.sessionId, - previousLeafUuid: head.handle.leafUuid, - projectsDir: join(record.accountHome.path, 'projects'), - spawnToken, - minimumProviderSessionReceivedAt: launchStartedAt - }) - const revealed = await this.focusTerminal(terminal.handle) - return this.refreshStructuredTuiOwnerBinding({ - ...spawnedOwner, - link: - provider === 'claude' - ? claudeProviderHandleLink({ - sessionId: head.handle.sessionId, - leafUuid: proof.leafUuid ?? head.handle.leafUuid, - resumed: true, - fence, - observedAt: Date.now() - }) - : spawnedOwner.link, - terminal: { - handle: terminal.handle, - tabId: revealed.tabId, - paneKey: terminal.paneKey, - ptyId: terminal.ptyId - }, - process: spawnedOwner.process, - ...(proof.transcriptPath ? { transcriptPath: proof.transcriptPath } : {}), - historySource: 'provider-resume' - }) - } catch (error) { - let closeError: unknown = null - try { - await this.closeTerminal(terminal.handle) - } catch (cleanupFailure) { - closeError = cleanupFailure - } - try { - // closeTerminal may retire the renderer handle before the PTY exit is - // observed. Prove the provider child (or, before identity publication, - // the PTY) through the same exit path used by handoff recovery. - if (spawnedOwner) { - await this.waitForStructuredTuiOwnerExit(spawnedOwner) - } else if (ptyId) { - await this.waitForStructuredTuiPtyExit(ptyId) - } else { - throw new Error('The failed terminal did not publish a PTY identity.') - } - } catch (exitFailure) { - throw new StructuredTuiLaunchCleanupError( - error, - closeError === null - ? exitFailure - : new AggregateError( - [closeError, exitFailure], - 'Structured TUI cleanup could not prove process exit.' - ) - ) - } - throw error - } - }, - waitForTuiExit: async (owner) => { - await this.waitForStructuredTuiOwnerExit(owner) - return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} - }, - waitForTuiIdleOrExit: async (owner, signal) => { - return this.waitForStructuredTuiIdleOrExit(owner, signal) - }, - reproveTuiOwner: async ({ record, owner }) => { - const current = this.refreshStructuredTuiOwnerBinding(owner) - const persisted = record.lease.ownerProcess - if ( - !persisted || - persisted.hostId !== current.process.hostId || - persisted.pid !== current.process.pid || - persisted.processStartTimeMs !== current.process.processStartTimeMs || - persisted.spawnToken !== current.process.spawnToken - ) { - throw new Error('The owning terminal does not match the persisted launch identity.') - } - const proof = await probeAgentSessionProcessIdentity({ identity: current.process }) - if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) { - throw new Error( - `The owning ${current.link.handle.provider} child process could not be re-proved.` - ) - } - const head = record.providerHandleChain.at(-1) - const sameProviderIdentity = - head && - (current.link.handle.provider === 'claude' - ? agentSessionProviderHandleRoot(current.link.handle) === - agentSessionProviderHandleRoot(head.handle) - : (record.lease.provenHandleLinkId === null || - current.link.linkId === record.lease.provenHandleLinkId) && - agentSessionProviderHandlesEqual(current.link.handle, head.handle)) - if (!sameProviderIdentity) { - throw new Error('agent_session_identity_required') - } - if (current.link.handle.provider === 'claude' && head.handle.provider === 'claude') { - const proof = await this.waitForStructuredClaudeTuiProof({ - handle: current.terminal.handle, - paneKey: current.terminal.paneKey, - sessionId: head.handle.sessionId, - previousLeafUuid: head.handle.leafUuid, - projectsDir: join(record.accountHome.path, 'projects') - }) - return { - ...current, - link: claudeProviderHandleLink({ - sessionId: head.handle.sessionId, - leafUuid: proof.leafUuid, - resumed: true, - fence: record.lease.runtimeFence, - observedAt: Date.now() - }), - transcriptPath: proof.transcriptPath - } - } - if (current.transcriptPath || current.link.handle.provider !== 'codex') { - return current - } - if (head.handle.provider !== 'codex') { - return current - } - const threadId = head.handle.threadId - const transcriptPath = await resolvePinnedCodexRolloutProof( - record.accountHome.path, - threadId - ) - return transcriptPath ? { ...current, transcriptPath } : current - }, - recoverTuiOwner: async (record) => { - const identity = record.lease.ownerProcess - const head = record.providerHandleChain.at(-1) - if ( - !identity || - !head || - (head.handle.provider !== 'codex' && head.handle.provider !== 'claude') - ) { - throw new Error('agent_session_identity_required') - } - const provider = head.handle.provider - const providerSessionId = - provider === 'claude' ? head.handle.sessionId : head.handle.threadId - let candidate = [...this.ptysById.values()].find( - (pty) => - pty.connected && - pty.launchToken === identity.spawnToken && - pty.launchAgent === provider && - pty.tabId && - pty.paneKey - ) - let handle = candidate ? this.issueStructuredTuiPtyHandle(candidate) : null - let durableOwner: { binding: AgentSessionOwnerBinding; incarnationId: string } | undefined - if (!candidate) { - const workspace = await this.resolveTerminalWorkspaceLaunchScope( - `id:${record.location.workspaceId}` - ) - const baseNamespace = this.getAgentSessionExecutionNamespace(workspace, provider) - if (!baseNamespace || !worktreeIdsEqual(workspace.id, record.location.workspaceId)) { - throw new Error('agent_session_identity_required') - } - const claim = this.agentSessionClaimSigner.createClaim({ - namespace: { ...baseNamespace, providerRoot: record.accountHome.path }, - identity: canonicalizeAgentSessionIdentity(provider, { - key: 'session_id', - id: providerSessionId - }), - canonicalWorktreeId: workspace.id - }) - const candidateEvaluations = [...this.ptysById.values()].flatMap((pty) => - pty.agentSessionOwners.map((owner) => { - const session = this.getWorkspaceSessionForWorktree(owner.surface.worktreeId) - const sessionWorktreeId = session - ? resolveTerminalSessionWorktreeId(session, owner.surface.worktreeId) - : null - const persistedTab = sessionWorktreeId - ? session?.tabsByWorktree[sessionWorktreeId]?.find( - (candidate) => candidate.id === owner.surface.tabId - ) - : null - const paneKey = makePaneKey(owner.surface.tabId, owner.surface.leafId) - const persisted = { - sessionResolved: Boolean(session && sessionWorktreeId), - tabPresent: Boolean(persistedTab), - ptyId: - session?.terminalLayoutsByTabId[owner.surface.tabId]?.ptyIdsByLeafId?.[ - owner.surface.leafId - ] ?? null, - incarnationId: session?.terminalPtyIncarnationsByPaneKey?.[paneKey] ?? null - } - const evaluation = evaluateStructuredTuiRecoveryClaim( - { - expectedWorkspaceId: workspace.id, - claimMatches: scopedAgentSessionClaimsEqual(owner.claim, claim), - pty: { - connected: pty.connected, - ptyId: pty.ptyId, - incarnationId: pty.incarnationId, - worktreeId: pty.worktreeId - }, - owner: { - phase: owner.phase, - ptyId: owner.ptyId, - surface: owner.surface - }, - persisted - }, - worktreeIdsEqual - ) - return { pty, owner, persisted, evaluation } - }) - ) - const recoveredCandidates = candidateEvaluations - .filter(({ evaluation }) => evaluation.matches) - .map(({ pty, owner }) => ({ pty, owner })) - const recovered = recoveredCandidates.length === 1 ? recoveredCandidates[0] : null - if (!recovered) { - console.warn('[structured-tui-recovery] claim mismatch', { - sessionId: record.sessionId, - expectedWorkspaceId: workspace.id, - persistedOwnerProcess: { - hostId: identity.hostId, - pid: identity.pid, - processStartTimeMs: identity.processStartTimeMs, - spawnTokenPresent: identity.spawnToken.length > 0 - }, - candidates: candidateEvaluations.map(({ pty, owner, persisted, evaluation }) => ({ - ptyId: pty.ptyId, - incarnationId: pty.incarnationId, - worktreeId: pty.worktreeId, - ownerSurface: owner.surface, - persisted, - mismatchedFields: evaluation.mismatchedFields - })) - }) - } - if ( - !recovered || - !(await this.proveRecoveredStructuredTuiPtyProcess(recovered.pty, identity, provider)) - ) { - throw new Error('The owning agent terminal could not be recovered.') - } - candidate = recovered.pty - candidate.tabId = recovered.owner.surface.tabId - candidate.paneKey = makePaneKey( - recovered.owner.surface.tabId, - recovered.owner.surface.leafId - ) - // Runtime handles rotate on packaged relaunch; claim, incarnation, and process proof are durable. - handle = this.issuePtyHandle(candidate) - const recoveredIncarnationId = candidate.incarnationId - if (handle && recoveredIncarnationId) { - durableOwner = { - binding: cloneAgentSessionOwnerBinding(recovered.owner), - incarnationId: recoveredIncarnationId - } - } - } - if (!candidate?.tabId || !candidate.paneKey || !handle) { - throw new Error('The owning agent terminal could not be recovered.') - } - agentSessionPtyWriteGate.bindPty(candidate.ptyId, record.sessionId) - const proof = - provider === 'codex' - ? durableOwner - ? await this.resolveRecoveredStructuredTuiTranscript({ - handle, - paneKey: candidate.paneKey, - threadId: head.handle.threadId, - codexHome: record.accountHome.path, - durableOwner - }) - : await this.waitForStructuredTuiProof({ - handle, - paneKey: candidate.paneKey, - threadId: head.handle.threadId, - spawnToken: identity.spawnToken, - codexHome: record.accountHome.path, - sessionId: record.sessionId - }) - : await this.waitForStructuredClaudeTuiProof({ - handle, - paneKey: candidate.paneKey, - sessionId: head.handle.sessionId, - previousLeafUuid: head.handle.leafUuid, - projectsDir: join(record.accountHome.path, 'projects') - }) - return { - terminal: { - handle, - tabId: candidate.tabId, - paneKey: candidate.paneKey, - ptyId: candidate.ptyId - }, - process: identity, - link: - provider === 'codex' - ? codexProviderHandleLink({ - threadId: head.handle.threadId, - resumed: true, - fence: record.lease.runtimeFence, - observedAt: Date.now() - }) - : claudeProviderHandleLink({ - sessionId: head.handle.sessionId, - leafUuid: proof.leafUuid ?? head.handle.leafUuid, - resumed: true, - fence: record.lease.runtimeFence, - observedAt: Date.now() - }), - transcriptPath: proof.transcriptPath - } - }, - probeRecoveredOwner: async (record) => { - const identity = record.lease.ownerProcess - if (!identity) { - return 'dead' - } - const proof = await probeAgentSessionProcessIdentity({ identity }) - if (proof.outcome === 'identity-matched' && proof.matchedOn.length > 0) { - return 'live' - } - if (proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch') { - return 'dead' - } - return 'unknown' - }, - stopRecoveredOwner: (record) => this.stopStructuredSessionProcess(record), - tuiStatus: (owner) => this.structuredTuiStatus(owner), - closeTuiOwner: (owner) => this.closeStructuredTuiOwner(owner), - revealNativeSession: async ({ workspaceId, sessionId, agent = 'codex', adoptedTerminal }) => { - if (adoptedTerminal || agent !== 'codex') { - return - } - await this.publishStructuredAgentSessionTab({ - workspaceId, - sessionId, - agent, - activate: false - }) - this.notifier?.focusEditorTab?.(structuredAgentSessionTabId(sessionId), workspaceId) - }, - stopFailedTuiLaunch: async (owner) => void (await this.closeStructuredTuiOwner(owner)) - } - } - - private async proveRecoveredStructuredTuiPtyProcess( - pty: RuntimePtyWorktreeRecord, - identity: NonNullable, - provider: 'codex' | 'claude' = 'codex' - ): Promise { - const listings = await this.ptyController?.listProcesses?.(pty.connectionId) - const listed = listings?.find( - (candidate) => candidate.id === pty.ptyId && candidate.incarnationId === pty.incarnationId - ) - if (!listed?.rootProcessId || identity.processStartTimeMs === null) { - console.warn('[structured-tui-recovery] claimed PTY process mismatch', { - ptyId: pty.ptyId, - incarnationId: pty.incarnationId, - rootProcessId: listed?.rootProcessId ?? null, - mismatchedFields: [ - ...(!listed?.rootProcessId ? ['root-process-id'] : []), - ...(identity.processStartTimeMs === null ? ['persisted-process-start-time'] : []) - ] - }) - return false - } - try { - const observed = await readStructuredTuiProcessIdentity({ - hostId: identity.hostId, - rootPid: listed.rootProcessId, - spawnToken: identity.spawnToken, - agent: provider - }) - const matched = { - hostId: observed.hostId === identity.hostId, - pid: observed.pid === identity.pid, - processStartTime: - observed.processStartTimeMs !== null && - Math.abs(observed.processStartTimeMs - identity.processStartTimeMs) <= - PROCESS_START_TIME_TOLERANCE_MS - } - if (!Object.values(matched).every(Boolean)) { - console.warn('[structured-tui-recovery] claimed PTY process mismatch', { - ptyId: pty.ptyId, - incarnationId: pty.incarnationId, - rootProcessId: listed.rootProcessId, - persisted: { - hostId: identity.hostId, - pid: identity.pid, - processStartTimeMs: identity.processStartTimeMs - }, - observed: { - hostId: observed.hostId, - pid: observed.pid, - processStartTimeMs: observed.processStartTimeMs - }, - mismatchedFields: Object.entries(matched) - .filter(([, matches]) => !matches) - .map(([field]) => field) - }) - } - return Object.values(matched).every(Boolean) - } catch (error) { - console.warn('[structured-tui-recovery] claimed PTY process mismatch', { - ptyId: pty.ptyId, - incarnationId: pty.incarnationId, - rootProcessId: listed.rootProcessId, - mismatchedFields: [`${provider}-child-proof`], - error: error instanceof Error ? error.message : String(error) - }) - return false - } - } - - private async closeStructuredTuiOwner( - owner: StructuredTuiOwner - ): Promise<{ transcriptPath?: string }> { - if (this.ptysById.get(owner.terminal.ptyId)?.connected) { - const current = this.refreshStructuredTuiOwnerBinding(owner) - try { - await this.closeTerminal(current.terminal.handle) - } catch (error) { - if (this.ptysById.get(owner.terminal.ptyId)?.connected) { - throw error - } - } - } - await this.waitForStructuredTuiOwnerExit(owner) - return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} - } - - // The new exact `codex resume ` child proves the resumed owner without - // a first turn; the pinned rollout then binds its durable transcript. - private async waitForAdoptedStructuredTuiProof(input: { - owner: StructuredTuiOwner - threadId: string - codexHome: string - }): Promise<{ transcriptPath: string; leafUuid?: never }> { - const assertPaneIdentity = (): void => { - const pty = this.ptysById.get(input.owner.terminal.ptyId) - if (!pty?.connected || pty.paneKey !== input.owner.terminal.paneKey) { - throw new Error('The adopted terminal lost its pane identity.') - } - } - assertPaneIdentity() - const transcriptPath = await resolvePinnedCodexRolloutProof(input.codexHome, input.threadId) - if (!transcriptPath) { - throw new Error('The agent terminal did not prove the expected Codex rollout.') - } - assertPaneIdentity() - const processProof = await probeAgentSessionProcessIdentity({ identity: input.owner.process }) - if (processProof.outcome !== 'identity-matched' || processProof.matchedOn.length === 0) { - throw new Error('The resumed Codex process could not be re-proved.') - } - return { transcriptPath } - } - - private refreshStructuredTuiOwnerBinding(owner: StructuredTuiOwner): StructuredTuiOwner { - const pty = this.ptysById.get(owner.terminal.ptyId) - if (!pty?.connected) { - throw new Error('The owning agent terminal lost its launch identity.') - } - const handle = this.issueStructuredTuiPtyHandle(pty) - if (handle === owner.terminal.handle) { - return owner - } - return { ...owner, terminal: { ...owner.terminal, handle } } - } - - private issueStructuredTuiPtyHandle(pty: RuntimePtyWorktreeRecord): string { - const existingHandle = this.findHandleForPtyRecord(pty.ptyId) - if (existingHandle) { - this.handleByPtyId.set(pty.ptyId, existingHandle) - return existingHandle - } - const handle = `term_${randomUUID()}` - const syntheticId = `pty:${pty.ptyId}` - this.syntheticTerminalHandles.add(handle) - this.handles.set(handle, { - handle, - runtimeId: this.runtimeId, - rendererGraphEpoch: this.rendererGraphEpoch, - worktreeId: pty.worktreeId, - tabId: syntheticId, - leafId: syntheticId, - ptyId: pty.ptyId, - ptyGeneration: 0 - }) - this.handleByPtyId.set(pty.ptyId, handle) - return handle - } - - private async waitForStructuredTuiPtyExit(ptyId: string): Promise { - const deadline = Date.now() + 5_000 - while (this.ptysById.get(ptyId)?.connected === true) { - if (Date.now() >= deadline) { - throw new Error('terminal_handle_stale') - } - await new Promise((resolve) => setTimeout(resolve, 50)) - } - } - - private async waitForStructuredTuiOwnerExit(owner: StructuredTuiOwner): Promise { - await waitForStructuredTuiExitProof({ - identity: owner.process, - waitForExit: () => this.waitForStructuredTuiPtyExit(owner.terminal.ptyId) - }) - } - - private async waitForStructuredTuiIdleOrExit( - owner: StructuredTuiOwner, - signal: AbortSignal - ): Promise<'idle' | 'exited' | null> { - const deadline = Date.now() + 250 - while (!signal.aborted && Date.now() < deadline) { - if (!this.ptysById.get(owner.terminal.ptyId)?.connected) { - await this.waitForStructuredTuiOwnerExit(owner) - return 'exited' - } - if (this.structuredTuiStatus(owner) === 'idle') { - return 'idle' - } - await new Promise((resolve) => setTimeout(resolve, 50)) - } - return null - } - - private async stopStructuredSessionProcess(record: AgentSessionRecord): Promise { - const identity = record.lease.ownerProcess - if (!identity) { - return - } - const proof = await probeAgentSessionProcessIdentity({ identity }) - if (proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch') { - return - } - if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) { - throw new Error('The recovered owner process could not be stopped safely.') - } - try { - process.kill(identity.pid, 'SIGTERM') - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { - throw error - } - return - } - const deadline = Date.now() + 15_000 - while (Date.now() < deadline) { - const current = await probeAgentSessionProcessIdentity({ identity }) - if (current.outcome === 'pid-absent' || current.outcome === 'identity-mismatch') { - return - } - await new Promise((resolve) => setTimeout(resolve, 100)) - } - // SIGTERM is only a request. Escalate once, then require an independent - // absence probe before allowing the lease transition to proceed. - try { - process.kill(identity.pid, 'SIGKILL') - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { - throw error - } - return - } - const forcedDeadline = Date.now() + 5_000 - while (Date.now() < forcedDeadline) { - const current = await probeAgentSessionProcessIdentity({ identity }) - if (current.outcome === 'pid-absent' || current.outcome === 'identity-mismatch') { - return - } - await new Promise((resolve) => setTimeout(resolve, 100)) - } - throw new Error('The recovered owner process did not exit after forced termination.') - } - - private structuredTuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy' { - const pty = this.ptysById.get(owner.terminal.ptyId) - const paneKey = pty?.paneKey ?? owner.terminal.paneKey - const explicit = this.getFreshExplicitAgentStatusForHandle(owner.terminal.handle, paneKey) - if (explicit) { - return explicit.status === 'idle' ? 'idle' : 'busy' - } - if (pty?.connected) { - const text = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) - const blocked = detectTerminalWaitBlockedReason(text) !== null - if (!blocked && isKnownReadyPromptPreview(text)) { - return 'idle' - } - return hasStructuredTuiIdleEvidence({ - blocked, - status: pty.lastAgentStatus, - statusObservedLive: pty.lastAgentStatusObservedLive - }) - ? 'idle' - : 'busy' - } - return 'busy' - } - - private async waitForStructuredTuiProof(input: { - handle: string - paneKey: string - threadId: string - spawnToken: string - codexHome: string - sessionId: string - }): Promise<{ transcriptPath?: string; leafUuid?: never }> { - const readBoundPty = (): RuntimePtyWorktreeRecord => { - const pty = this.getLivePtyForHandle(input.handle)?.pty - if ( - !pty?.connected || - pty.paneKey !== input.paneKey || - pty.launchAgent !== 'codex' || - pty.launchToken !== input.spawnToken - ) { - throw new Error('The resumed terminal lost its launch identity.') - } - return pty - } - const initialPty = readBoundPty() - const kittyKeyboardFlags = this.providerModeTrackersByPtyId.get(initialPty.ptyId)?.flags ?? 0 - return proveCodexTuiRollout({ - codexHome: input.codexHome, - threadId: input.threadId, - kittyKeyboardFlags, - readOutput: () => { - const pty = readBoundPty() - return { - text: buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview), - lastOutputAt: pty.lastOutputAt - } - }, - write: (data) => { - const pty = readBoundPty() - return ( - this.ptyController?.writeAgentSessionProof?.(pty.ptyId, data, { - sessionId: input.sessionId, - spawnToken: input.spawnToken - }) ?? false - ) - } - }) - } - - private async waitForStructuredClaudeTuiProof(input: { - handle: string - paneKey: string - sessionId: string - previousLeafUuid: string | null - projectsDir: string - /** Set when this call launched a new Claude process; a cached transcript marker is not enough. */ - spawnToken?: string - minimumProviderSessionReceivedAt?: number - }): Promise<{ transcriptPath: string; leafUuid: string }> { - const deadline = Date.now() + 15_000 - let incompleteTail: ClaudeTranscriptTailIncompleteError | null = null - while (Date.now() < deadline) { - const pty = this.getLivePtyForHandle(input.handle)?.pty - if (!pty?.connected || pty.paneKey !== input.paneKey || pty.launchAgent !== 'claude') { - throw new Error('The resumed Claude terminal lost its launch identity.') - } - if (input.spawnToken) { - if (!this.hasProviderSessionObservationSource()) { - throw new Error('The Claude terminal could not prove its fresh provider session.') - } - const observedProviderRow = this.findAdoptedProviderSession( - input.paneKey, - 'claude', - input.sessionId - ) - if ( - !observedProviderRow || - observedProviderRow.launchToken !== input.spawnToken || - (input.minimumProviderSessionReceivedAt !== undefined && - observedProviderRow.receivedAt < input.minimumProviderSessionReceivedAt) - ) { - await new Promise((resolve) => setTimeout(resolve, 100)) - continue - } - } - const transcriptPath = await resolveSessionFilePath('claude', input.sessionId, { - claudeProjectsDir: input.projectsDir - }) - if (transcriptPath) { - if (!isPathWithinDirectory(input.projectsDir, transcriptPath)) { - throw new Error('The Claude terminal reported a transcript outside its account root.') - } - try { - const leafUuid = await readClaudeTranscriptLeafUuid( - transcriptPath, - input.sessionId, - input.previousLeafUuid - ) - return { transcriptPath, leafUuid } - } catch (error) { - if (!(error instanceof ClaudeTranscriptTailIncompleteError)) { - throw error - } - incompleteTail = error - } - } - await new Promise((resolve) => setTimeout(resolve, 100)) - } - if (incompleteTail) { - throw incompleteTail - } - throw new Error('The agent terminal did not prove the expected Claude session.') - } - - private async resolveRecoveredStructuredTuiTranscript(input: { - handle: string - paneKey: string - threadId: string - codexHome: string - durableOwner: { binding: AgentSessionOwnerBinding; incarnationId: string } - }): Promise<{ transcriptPath: string; leafUuid?: never }> { - const assertDurableOwner = (): void => { - const pty = this.getLivePtyForHandle(input.handle)?.pty - if ( - !pty?.connected || - pty.paneKey !== input.paneKey || - pty.incarnationId !== input.durableOwner.incarnationId || - !pty.agentSessionOwners.some((owner) => - agentSessionOwnerBindingsEqual(owner, input.durableOwner.binding) - ) - ) { - throw new Error('The resumed terminal lost its durable owner identity.') - } - } - assertDurableOwner() - const transcriptPath = await resolvePinnedCodexRolloutProof(input.codexHome, input.threadId) - assertDurableOwner() - if (!transcriptPath) { - throw new Error('The agent terminal did not prove the expected Codex rollout.') - } - return { transcriptPath } - } - - async getStructuredAgentSessionCreateSupport( - worktreeSelector: string, - agent: 'codex' - ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { - const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) - await this.ensureStructuredAgentSessionHost() - if (getStructuredAgentSessionHost()?.supportsCreate(location, agent)) { - return { supported: true } - } - return { - supported: false, - reason: - location.executionHostId !== LOCAL_EXECUTION_HOST_ID - ? 'remote' - : location.wslDistro - ? 'wsl' - : 'agent' - } - } - - private hasProviderSessionObservationSource(): boolean { - return ( - this.getAgentProviderSessionRowsForPaneFn !== null || - this.getAgentProviderSessionSnapshotFn !== null - ) - } - - private findAdoptedProviderSession( - paneKey: string, - provider: 'claude' | 'codex', - providerSessionId: string - ): AgentStatusIpcPayload | undefined { - const rows = - this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? - (this.getAgentProviderSessionSnapshotFn?.() ?? []).filter((row) => row.paneKey === paneKey) - return rows - .filter((row) => row.agentType === provider && row.providerSession?.id === providerSessionId) - .reduce( - (latest, row) => (!latest || row.receivedAt > latest.receivedAt ? row : latest), - undefined - ) - } - - private async resolveStructuredAgentSessionLocation(worktreeSelector: string) { - const target = await this.resolveRuntimeFileTarget(worktreeSelector) - const repo = this.store?.getRepo(target.worktree.repoId) - const wslDistro = - repo && !target.connectionId - ? (getLocalProjectWorktreeGitOptions(this.requireStore(), repo).wslDistro ?? null) - : null - const folderWorkspace = this.store - ?.getFolderWorkspaces?.() - .some((workspace) => workspace.id === target.worktree.id) - return { - executionHostId: getRuntimeFileTargetExecutionHostId({ - worktree: target.worktree, - connectionId: target.connectionId - }), - wslDistro, - workspaceId: target.worktree.id, - workspaceKind: folderWorkspace ? ('folder' as const) : ('git-worktree' as const) - } - } - - async resolveStructuredAgentSessionCreateIntent(input: { - envelope: { sessionId: string; clientOperationId: string } - worktree: string - agent: 'codex' - }): Promise { - return this.resolveStructuredAgentSessionIntent(input, async ({ workspacePath, launchEnv }) => { - // A create has no process yet, so the current selection is what it must follow. - const preparedHome = await this.prepareCodexStructuredLaunchFn?.({ workspacePath, launchEnv }) - const configuredHome = launchEnv.CODEX_HOME - return ( - preparedHome?.trim() || - (this.prepareCodexStructuredLaunchFn ? getSystemCodexHomePath() : configuredHome?.trim()) || - getSystemCodexHomePath() - ) - }) - } - - private async resolveStructuredAgentSessionIntent( - input: { - envelope: { sessionId: string; clientOperationId: string } - worktree: string - agent: 'codex' - }, - resolveAccountHomePath: (context: { - workspacePath: string - launchEnv: NodeJS.ProcessEnv - }) => string | Promise - ): Promise { - const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) - if (!support.supported) { - throw new Error('structured_agent_session_unsupported') - } - const settings = this.requireStore().getSettings() - const launchEnv = resolveTuiAgentLaunchEnv(input.agent, settings.agentDefaultEnv) - const location = await this.resolveStructuredAgentSessionLocation(input.worktree) - const workspacePath = (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path - return { - envelope: { - sessionId: input.envelope.sessionId, - clientOperationId: input.envelope.clientOperationId, - expectedRuntimeFence: null, - payloadFingerprint: '' - }, - location, - provider: input.agent, - agent: input.agent, - accountHome: { - variable: 'CODEX_HOME', - path: await resolveAccountHomePath({ workspacePath, launchEnv }) - }, - runtimeKind: 'native' - } - } - - restoreStructuredAgentSessionTabs(): Promise { - this.structuredAgentSessionTabRestorePromise ??= - this.restoreStructuredAgentSessionTabsOnce().catch((error) => { - this.structuredAgentSessionTabRestorePromise = null - throw error - }) - return this.structuredAgentSessionTabRestorePromise - } - - prepareStructuredAgentSessionStartupRestoration(): Promise { - this.structuredAgentSessionStartupRestorePromise ??= - this.prepareStructuredAgentSessionStartupRestorationOnce().catch((error) => { - this.structuredAgentSessionStartupRestorePromise = null - throw error - }) - return this.structuredAgentSessionStartupRestorePromise - } - - private async prepareStructuredAgentSessionStartupRestorationOnce(): Promise { - if (!this.hasPersistedStructuredAgentSessionStore()) { - return - } - // Durable agent records must exist before daemon inventory can be reconciled against them. - await this.ensureStructuredAgentSessionHost() - await this.refreshMobileSessionPtyRecords() - await getStructuredAgentSessionHost()?.reconcileRestartLeases() - } - - private hasPersistedStructuredAgentSessionStore(): boolean { - return hasPersistedStructuredAgentSessionStoreOnDisk(getProfileUserDataPath()) - } - - private async restoreStructuredAgentSessionTabsOnce(): Promise { - await this.prepareStructuredAgentSessionStartupRestoration() - const host = getStructuredAgentSessionHost() - const persistedVisibleIndex = - typeof host?.getPersistedVisibleSessionTabIndex === 'function' - ? host.getPersistedVisibleSessionTabIndex() - : { present: false, sessionIds: [] } - const profileIds = collectSavedStructuredAgentSessionIds( - this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null - ) - await host?.restoreReadableSessions( - persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds - ) - for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { - allowAttachedWindow: true, - onlyRuntimeOwnedTerminals: true - }) - } - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() - for (const session of host?.listSessionTabs() ?? []) { - if (session.agent !== 'codex') { - continue - } - let sessionId = session.sessionId - while (sessionId.startsWith('agent-session:')) { - sessionId = sessionId.slice('agent-session:'.length) - } - await this.publishStructuredAgentSessionTab({ - ...session, - agent: 'codex', - sessionId, - activate: false, - notify: false - }) - } - } - - async publishStructuredAgentSessionTab(input: { - workspaceId: string - sessionId: string - agent: 'codex' - activate: boolean - notify?: boolean - }): Promise { - const host = getStructuredAgentSessionHost() - if (typeof host?.setSessionTabVisibility === 'function') { - await host.setSessionTabVisibility(input.sessionId, true) - } - const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId) - const id = `agent-session:${input.sessionId}` - if (existing?.tabs.some((tab) => tab.id === id)) { - return - } - const tab: RuntimeMobileSessionAgentTab = { - type: 'agent-session', - id, - title: 'Codex Chat', - sessionId: input.sessionId, - agent: input.agent, - isActive: input.activate - } - const tabs = [...(existing?.tabs ?? [])].map((candidate) => ({ - ...candidate, - isActive: input.activate ? false : candidate.isActive - })) - tabs.push(tab) - const priorGroups = existing?.tabGroups ?? [ - { - id: this.getHeadlessMobileSessionGroupId(input.workspaceId), - activeTabId: existing?.activeTabId ?? null, - tabOrder: [] - } - ] - const groupId = priorGroups.some((group) => group.id === existing?.activeGroupId) - ? existing!.activeGroupId! - : priorGroups[0]!.id - const tabGroups = priorGroups.map((group) => - group.id === groupId - ? { - ...group, - activeTabId: input.activate ? id : group.activeTabId, - tabOrder: [...group.tabOrder, id] - } - : group - ) - const snapshot: RuntimeMobileSessionTabsSnapshot = { - worktree: input.workspaceId, - publicationEpoch: existing?.publicationEpoch ?? `structured:${Date.now().toString(36)}`, - snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, - activeGroupId: input.activate ? groupId : (existing?.activeGroupId ?? groupId), - activeTabId: input.activate ? id : (existing?.activeTabId ?? null), - activeTabType: input.activate ? 'agent-session' : (existing?.activeTabType ?? null), - tabGroups, - ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), - tabs - } - this.mobileSessionTabsByWorktree.set(input.workspaceId, snapshot) - if (input.notify !== false) { - this.emitMobileSessionTabsSnapshot(snapshot) - } - } - - private async resolveRuntimeGitTarget(worktreeSelector: string): Promise<{ - worktree: ResolvedWorktree - repo?: Repo - connectionId?: string - localGitOptions?: { wslDistro?: string } - }> { - const store = this.requireStore() - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - const repo = store.getRepo(worktree.repoId) - const connectionId = repo?.connectionId ?? undefined - const localGitOptions = - repo && !connectionId ? getLocalProjectWorktreeGitOptions(store, repo) : {} - return { worktree, repo, connectionId, localGitOptions } - } - - private async resolveRuntimeFileTarget(worktreeSelector: string): Promise<{ - worktree: ResolvedWorktree - connectionId?: string - }> { - const folderScope = await this.resolveFolderWorkspaceLaunchScope(worktreeSelector) - if (folderScope?.folderWorkspace) { - return { - worktree: this.folderWorkspaceToResolvedWorktree(folderScope.folderWorkspace), - connectionId: folderScope.connectionId ?? undefined - } - } - - const store = this.requireStore() - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - const repo = store.getRepo(worktree.repoId) - return { worktree, connectionId: repo?.connectionId ?? undefined } - } - - private async resolveKnownWorkspaceFileTarget( - absolutePath: string, - executionHostId: ExecutionHostId - ): Promise<{ - worktree: ResolvedWorktree - connectionId?: string - relativePath: string - } | null> { - const targets = new Map< - string, - { - worktree: ResolvedWorktree - connectionId?: string - executionHostId: ExecutionHostId - } - >() - const resolvedWorktrees = await this.listResolvedWorktrees() - const settings = this.store?.getSettings() - const visibilitySourceMatchersByRepoId = this.buildRuntimeVisibilitySourceMatchersByRepoId( - resolvedWorktrees, - settings?.worktreeVisibilityDefaults - ) - for (const worktree of resolvedWorktrees) { - if ( - !this.isRuntimeWorktreeVisible( - worktree, - visibilitySourceMatchersByRepoId.get(worktree.repoId), - settings - ) - ) { - continue - } - const candidateConnectionId = this.store?.getRepo(worktree.repoId)?.connectionId ?? undefined - const target = { - worktree, - executionHostId: getRuntimeFileTargetExecutionHostId({ - worktree, - connectionId: candidateConnectionId - }), - ...(candidateConnectionId ? { connectionId: candidateConnectionId } : {}) - } - targets.set(`${target.executionHostId}\0${worktree.id}`, target) - } - for (const folderWorkspace of this.store?.getFolderWorkspaces?.() ?? []) { - try { - const candidateConnectionId = - this.resolveFolderWorkspaceConnectionId(folderWorkspace) ?? undefined - const worktree = this.folderWorkspaceToResolvedWorktree(folderWorkspace) - const target = { - worktree, - executionHostId: getRuntimeFileTargetExecutionHostId({ - worktree, - connectionId: candidateConnectionId - }), - ...(candidateConnectionId ? { connectionId: candidateConnectionId } : {}) - } - targets.set(`${target.executionHostId}\0${worktree.id}`, target) - } catch { - // An ambiguous folder workspace has no single filesystem authority. - } - } - - const owner = findRuntimeWorkspaceFileOwner( - [...targets.values()].map((target) => ({ - workspaceId: target.worktree.id, - rootPath: target.worktree.path, - executionHostId: target.executionHostId - })), - absolutePath, - executionHostId - ) - if (!owner) { - return null - } - const target = targets.get(`${owner.executionHostId}\0${owner.workspaceId}`) - return target ? { ...target, relativePath: owner.relativePath } : null - } - - onMobileSessionTabsChanged( - listener: (snapshot: RuntimeMobileSessionTabsResult, changeSequence: number) => void, - clientNavigationId?: string - ): () => void { - // Why: a notify coalesced before this subscriber existed is already folded - // into the initial snapshot it was just sent. Draining it here — before the - // listener joins — keeps that pending timer from landing as a redundant - // `updated` frame carrying pre-subscribe state. Mirrors the unsubscribe flush. - this.mobileSessionTabsNotifyCoalescer.flushAll() - const subscription = { listener, clientNavigationId } - this.mobileSessionTabListeners.add(subscription) - return () => { - // Why: flush pending coalesced notifies before dropping this listener so a - // subscriber closing mid-window still receives the latest settled state. - this.mobileSessionTabsNotifyCoalescer.flushAll() - this.mobileSessionTabListeners.delete(subscription) - if (this.mobileSessionTabListeners.size === 0) { - this.mobileSessionTabsAgentStatusHeartbeat.cancelPending() - } - } - } - - forgetClientNavigationState(clientNavigationId: string): void { - this.clientSessionTabSelections.forgetClient(clientNavigationId) - } - - // Why: terminal handles are normally created lazily when first referenced via - // RPC, but agents need their own handle at spawn time (via ORCA_TERMINAL_HANDLE - // env var) so they can self-identify in orchestration messages without an - // extra RPC round-trip. Pre-allocating by ptyId lets issueHandle reuse it. - preAllocateHandleForPty(ptyId: string): string { - const existing = this.handleByPtyId.get(ptyId) - if (existing) { - return existing - } - const handle = this.createPreAllocatedTerminalHandle() - this.handleByPtyId.set(ptyId, handle) - return handle - } - - createPreAllocatedTerminalHandle(): string { - return `term_${randomUUID()}` - } - - private rememberPtyHandleReplacementFence( - ptyId: string, - incarnationId: PtyIncarnationId, - staleHandles: Iterable, - pendingRegistration: boolean - ): void { - const previous = this.pendingPtyHandleReplacementFences.get(ptyId) - const merged = new Set(previous?.staleHandles) - for (const handle of staleHandles) { - merged.add(handle) - } - // A PTY normally has one direct and one renderer alias. Keep a small bound - // in case a malformed provider emits an unbounded alias stream. - while (merged.size > 16) { - const oldest = merged.values().next().value - if (typeof oldest !== 'string') { - break - } - merged.delete(oldest) - } - this.pendingPtyHandleReplacementFences.set(ptyId, { - incarnationId, - staleHandles: merged, - pendingRegistration - }) - } - - registerPreAllocatedHandleForPty(ptyId: string, handle: string): void { - if (this.pendingPtyHandleReplacementFences.get(ptyId)?.staleHandles.has(handle)) { - // The provider can replay the old env handle after announcing a new - // incarnation. Never let that predecessor alias be reintroduced. - return - } - const retained = this.handleByPtyIncarnation.get(ptyId) - if (retained?.handle === handle) { - this.handleByPtyIncarnation.delete(ptyId) - } else { - this.invalidatePtyIncarnationHandle(ptyId) - } - this.handleByPtyId.set(ptyId, handle) - for (const leaf of this.getLeavesForPty(ptyId)) { - this.adoptPreAllocatedHandle(leaf) - } - } - - private adoptControllerTerminalHandle( - ptyId: string, - handle: string | undefined, - incarnationId?: string, - options: { exactRestoredSurface?: boolean } = {} - ): void { - const trimmed = handle?.trim() - if (!trimmed || !trimmed.startsWith('term_')) { - return - } - const pty = this.ptysById.get(ptyId) - const changedIncarnation = Boolean( - incarnationId && pty?.incarnationId && incarnationId !== pty.incarnationId - ) - if (changedIncarnation) { - const priorHandle = this.handleByPtyId.get(ptyId) - this.invalidateAllHandlesForPty(ptyId) - pty!.tabId = null - pty!.paneKey = null - // Reusing an exported handle would make stale client metadata name the replacement process. - if (priorHandle === trimmed) { - return - } - } - if (this.isTerminalHandleAdoptionBlocked(ptyId, trimmed)) { - if ( - !options.exactRestoredSurface || - !this.replaceSyntheticTerminalHandlesForRestoredPty(ptyId, trimmed) || - this.isTerminalHandleAdoptionBlocked(ptyId, trimmed) - ) { - return - } - } - // Why: after an app/runtime restart, the live PTY child still has its - // original ORCA_TERMINAL_HANDLE, but the runtime's in-memory map is gone. - this.registerPreAllocatedHandleForPty(ptyId, trimmed) - } - - private invalidateAllHandlesForPty(ptyId: string, preserveHandle?: string): Set { - const incarnationHandle = this.handleByPtyIncarnation.get(ptyId)?.handle - const preallocatedHandle = this.handleByPtyId.get(ptyId) - const invalidated = new Set() - if (incarnationHandle && incarnationHandle !== preserveHandle) { - this.handleByPtyIncarnation.delete(ptyId) - invalidated.add(incarnationHandle) - } else if (incarnationHandle) { - // The retained handle no longer describes the old incarnation. Keep its direct alias, - // when requested, but discard the incarnation-specific leaf record. - this.handleByPtyIncarnation.delete(ptyId) - } - if (preallocatedHandle && preallocatedHandle !== preserveHandle) { - this.handleByPtyId.delete(ptyId) - invalidated.add(preallocatedHandle) - } - for (const [handle, record] of this.handles) { - if (record.ptyId === ptyId && handle !== preserveHandle) { - invalidated.add(handle) - } - } - for (const handle of invalidated) { - this.handles.delete(handle) - this.syntheticTerminalHandles.delete(handle) - this.rejectWaitersForHandle(handle, 'terminal_handle_stale') - } - for (const [leafKey, handle] of this.handleByLeafKey) { - if (invalidated.has(handle) || (preserveHandle !== undefined && handle === preserveHandle)) { - this.handleByLeafKey.delete(leafKey) - } - } - if (preserveHandle !== undefined) { - // The direct alias is the only identity retained across an incarnation - // change. Renderer records point at the predecessor pane generation and - // must be rebuilt by graph sync (or issuePtyHandle) before use. - this.handles.delete(preserveHandle) - this.syntheticTerminalHandles.delete(preserveHandle) - } - return invalidated - } - - private replaceSyntheticTerminalHandlesForRestoredPty( - ptyId: string, - controllerHandle: string - ): boolean { - const boundHandles = new Set() - const directHandle = this.handleByPtyId.get(ptyId) - if (directHandle) { - boundHandles.add(directHandle) - } - for (const [handle, record] of this.handles) { - if (record.ptyId === ptyId) { - boundHandles.add(handle) - } else if (handle === controllerHandle) { - return false - } - } - for (const [otherPtyId, handle] of this.handleByPtyId) { - if (otherPtyId !== ptyId && handle === controllerHandle) { - return false - } - } - for (const leaf of this.getLeavesForPty(ptyId)) { - const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) - if (handle) { - boundHandles.add(handle) - } - } - if ( - boundHandles.size === 0 || - [...boundHandles].some( - (handle) => handle === controllerHandle || !this.syntheticTerminalHandles.has(handle) - ) - ) { - return false - } - this.invalidateAllHandlesForPty(ptyId) - return true - } - - // Why: adoption is best-effort restart recovery and must be first-wins. - // Re-keying a pty that already has a handle this session would strand - // waiters registered under the old handle, and provider-reported values - // are not trusted to be collision-free — a handle bound to a different - // pty must never be stolen by a later report. - private isTerminalHandleAdoptionBlocked(ptyId: string, handle: string): boolean { - if (this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId)) { - return true - } - for (const leaf of this.getLeavesForPty(ptyId)) { - const issued = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) - if (issued && issued !== handle) { - return true - } - } - const existingRecord = this.handles.get(handle) - if (existingRecord && existingRecord.ptyId !== ptyId) { - return true - } - for (const [otherPtyId, otherHandle] of this.handleByPtyId) { - if (otherHandle === handle && otherPtyId !== ptyId) { - return true - } - } - return false - } - - onPtySpawned( - ptyId: string, - incarnationId?: PtyIncarnationId, - options: { awaitsRegistration?: boolean } = {} - ): void { - const existingPty = this.ptysById.get(ptyId) - if ( - existingPty && - incarnationId !== undefined && - existingPty.incarnationId !== null && - existingPty.incarnationId !== incarnationId - ) { - // Providers announce a child before the commit binds its pane. Fence the - // predecessor now so a reused id cannot route through its old handle in - // that gap. - this.rememberPtyHandleReplacementFence( - ptyId, - incarnationId, - this.invalidateAllHandlesForPty(ptyId), - true - ) - } - this.forgetPtyLivenessVerdict(ptyId) - if (options.awaitsRegistration !== false) { - // Why: surface absence cannot distinguish an in-flight admission from a completed headless lifecycle. - this.pendingPtyRegistrationIncarnations.set(ptyId, incarnationId ?? null) - } - this.spawnPublishedPtys.add(ptyId) - const pty = this.getOrCreatePtyWorktreeRecord(ptyId) - if (pty) { - if (incarnationId) { - pty.incarnationId = incarnationId - } - pty.connected = true - pty.disconnectedAt = null - } - for (const leaf of this.getLeavesForPty(ptyId)) { - leaf.connected = true - leaf.writable = this.graphStatus === 'ready' - this.adoptPreAllocatedHandle(leaf) - } - } - - registerPty( - ptyId: string, - worktreeId: string, - connectionId: string | null = null, - binding?: { - tabId: string - leafId: string - incarnationId?: PtyIncarnationId - /** Handle allocated for the replacement incarnation, when one is known. */ - terminalHandle?: string - agentLaunchAuthority?: { launchToken: string; launchAgent: TuiAgent } - providerReattachLaunchIdentity?: { - incarnationId: PtyIncarnationId - launchAgent: TuiAgent - } - }, - isWsl?: boolean - ): void { - this.assertPtyDidNotExitBeforeRegistration(ptyId, binding?.incarnationId) - const existingPty = this.ptysById.get(ptyId) - const replacementHandle = binding?.terminalHandle?.trim() - const pendingReplacement = this.pendingPtyHandleReplacementFences.get(ptyId) - const pendingReplacementMatches = - pendingReplacement !== undefined && - pendingReplacement.pendingRegistration && - binding?.incarnationId !== undefined && - pendingReplacement.incarnationId === binding.incarnationId - const incarnationChanged = - existingPty !== undefined && - binding?.incarnationId !== undefined && - existingPty.incarnationId !== null && - existingPty.incarnationId !== binding.incarnationId - if (incarnationChanged || pendingReplacementMatches) { - // A reconnect can register a replacement before inventory reports its exported handle. - // Drop every alias for the predecessor; a newly preallocated handle is retained only when - // the caller can prove it is the replacement's handle. - const directHandle = this.handleByPtyId.get(ptyId) - const canPreserveReplacementHandle = - replacementHandle !== undefined && - replacementHandle.startsWith('term_') && - directHandle === replacementHandle && - !pendingReplacement?.staleHandles.has(replacementHandle) - const invalidated = this.invalidateAllHandlesForPty( - ptyId, - canPreserveReplacementHandle ? replacementHandle : undefined - ) - if (binding?.incarnationId) { - this.rememberPtyHandleReplacementFence( - ptyId, - binding.incarnationId, - invalidated, - pendingReplacementMatches - ) - } - } - this.forgetPtyLivenessVerdict(ptyId) - this.spawnPublishedPtys.add(ptyId) - // Why: record the renderer pane identity at spawn time so a stalled graph - // sync can't hide that a live PTY already backs a pending mobile create. - const paneKey = - binding && isValidTerminalTabId(binding.tabId) && isTerminalLeafId(binding.leafId) - ? makePaneKey(binding.tabId, binding.leafId) - : null - const pty = this.recordPtyWorktree(ptyId, worktreeId, { - connected: true, - connectionId, - ...(binding && this.pendingMobileTerminalCreatesByKey.has(`${worktreeId}::${binding.tabId}`) - ? { runtimeSessionOwned: true } - : {}), - ...(isWsl !== undefined ? { isWsl } : {}), - ...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}), - ...(binding?.incarnationId ? { incarnationId: binding.incarnationId } : {}) - }) - const agentLaunchAuthority = binding?.agentLaunchAuthority - if ( - agentLaunchAuthority && - paneKey && - binding.incarnationId && - pty.incarnationId === binding.incarnationId && - pty.paneKey === paneKey && - pty.launchToken === null && - agentLaunchAuthority.launchToken.length > 0 && - agentLaunchAuthority.launchToken.length <= 128 && - isTuiAgent(agentLaunchAuthority.launchAgent) - ) { - pty.launchToken = agentLaunchAuthority.launchToken - pty.launchIncarnationId = binding.incarnationId - pty.launchAgent = agentLaunchAuthority.launchAgent - } - const providerReattachLaunchIdentity = binding?.providerReattachLaunchIdentity - if ( - providerReattachLaunchIdentity && - paneKey && - binding.incarnationId === providerReattachLaunchIdentity.incarnationId && - pty.incarnationId === providerReattachLaunchIdentity.incarnationId && - pty.paneKey === paneKey && - isTuiAgent(providerReattachLaunchIdentity.launchAgent) - ) { - // Why: daemon metadata owns the surviving process; its incarnation fence restores identity without minting renderer launch authority. - pty.launchAgent = providerReattachLaunchIdentity.launchAgent - } - const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId) - if ( - pendingIncarnation === null || - pendingIncarnation === undefined || - binding?.incarnationId === undefined || - pendingIncarnation === binding.incarnationId - ) { - this.pendingPtyRegistrationIncarnations.delete(ptyId) - } - if (pendingReplacement !== undefined) { - const currentFence = this.pendingPtyHandleReplacementFences.get(ptyId) - if (currentFence && (pendingReplacementMatches || !binding?.incarnationId)) { - currentFence.pendingRegistration = false - } - } - // Why: the renderer's own PTY spawn is the reliable signal that the pending - // mobile create's tab is live; publish its surface main-side (#7587). - if (binding && paneKey) { - this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, binding.tabId) - } - } - - assertPtyRegistrationAllowed(ptyId: string, incarnationId?: PtyIncarnationId): void { - // Why: the controller must reject an early exit before persisting bindings or handles. - this.assertPtyDidNotExitBeforeRegistration(ptyId, incarnationId) - } - - releaseRejectedPtyRegistrationFence( - ptyId: string, - candidateIncarnation?: PtyIncarnationId - ): void { - if (!this.earlyExitedPtyIncarnations.has(ptyId)) { - return - } - const exitedIncarnation = this.earlyExitedPtyIncarnations.get(ptyId) ?? null - if ( - exitedIncarnation === null || - candidateIncarnation === undefined || - exitedIncarnation === candidateIncarnation - ) { - // Why: the rejected spawn call was the fence's sole late publisher; retaining it leaks fresh PTY ids. - this.earlyExitedPtyIncarnations.delete(ptyId) - this.pendingPtyRegistrationIncarnations.delete(ptyId) - } - } - - beginPtyRegistration(ptyId: string, incarnationId?: PtyIncarnationId): void { - this.pendingPtyRegistrationIncarnations.set(ptyId, incarnationId ?? null) - } - - acceptPtyIncarnationForExit(ptyId: string, incarnationId: PtyIncarnationId): void { - const pty = this.ptysById.get(ptyId) - if (pty) { - // Why: a reconnect attach reply can prove the exit generation after stale local proof was cleared. - pty.incarnationId = incarnationId - } - } - - cancelPendingPtyRegistration(ptyId: string, incarnationId?: PtyIncarnationId): void { - const pending = this.pendingPtyRegistrationIncarnations.get(ptyId) - if ( - !this.pendingPtyRegistrationIncarnations.has(ptyId) || - (pending !== null && incarnationId !== undefined && pending !== incarnationId) - ) { - return - } - this.pendingPtyRegistrationIncarnations.delete(ptyId) - const exited = this.earlyExitedPtyIncarnations.get(ptyId) - if ( - exited === null || - exited === undefined || - incarnationId === undefined || - exited === incarnationId - ) { - this.earlyExitedPtyIncarnations.delete(ptyId) - } - } - - private assertPtyDidNotExitBeforeRegistration( - ptyId: string, - candidateIncarnation?: PtyIncarnationId - ): void { - if (this.earlyExitedPtyIncarnations.has(ptyId)) { - const exitedIncarnation = this.earlyExitedPtyIncarnations.get(ptyId) ?? null - const nextIncarnation = candidateIncarnation ?? null - if ( - exitedIncarnation === null || - nextIncarnation === null || - exitedIncarnation === nextIncarnation - ) { - throw new Error('agent_session_exited_during_start') - } - this.earlyExitedPtyIncarnations.delete(ptyId) - } - } - - preparePtyExecutionContext( - ptyId: string, - wslDistro: string | null, - options: { resetIncarnation?: boolean; preserveExisting?: boolean } = {} - ): boolean { - const pty = this.ptysById.get(ptyId) - const hadExistingContext = this.wslDistroByPtyId.has(ptyId) || pty !== undefined - if (options.preserveExisting && hadExistingContext) { - // Why: attach-time settings are only a fallback; a live PTY's recorded - // execution namespace remains authoritative until its provider replies. - return false - } - - if (options.resetIncarnation) { - // Why: an explicit new lifecycle supersedes an unidentifiable exit from the reused PTY id. - this.earlyExitedPtyIncarnations.delete(ptyId) - this.disposeHeadlessTerminal(ptyId) - this.osc7ScanTailByPtyId.delete(ptyId) - this.terminalCwdByPtyId.delete(ptyId) - this.terminalFileUriHostnameByPtyId.delete(ptyId) - this.wslDistroByPtyId.delete(ptyId) - } - - const previous = this.wslDistroByPtyId.get(ptyId) ?? null - if (wslDistro) { - this.wslDistroByPtyId.set(ptyId, wslDistro) - } else { - this.wslDistroByPtyId.delete(ptyId) - } - if (pty) { - pty.wslDistro = wslDistro - } - if (!options.resetIncarnation && previous !== wslDistro && this.headlessTerminals.has(ptyId)) { - // Why: bytes parsed with two distro namespaces would leave an internally - // inconsistent CWD; rebuild from the provider's authoritative snapshot. - this.terminalCwdByPtyId.delete(ptyId) - this.replaceHeadlessTerminalAfterExecutionContextChange(ptyId) - } - return options.resetIncarnation === true || !hadExistingContext || previous !== wslDistro - } - - /** Record the spawn launch command so the per-PTY Command Code detector can - * arm from it (renderer startupCommand parity). Best-effort: a chunk that - * beats this call falls back to the detector's banner arming. */ - noteTerminalSpawnCommand(ptyId: string, command: string | null | undefined): void { - const trimmed = typeof command === 'string' ? command.trim() : '' - if (trimmed.length > 0) { - this.terminalSpawnCommandsByPtyId.set(ptyId, trimmed) - } - } - - resetPtyModelAfterMigrationFailure(ptyId: string): void { - this.providerSnapshotPreferredPtys.add(ptyId) - this.disposeHeadlessTerminal(ptyId) - } - - /** - * Handles incoming data from a PTY process, running agent detection, - * updating terminal tail buffers, and triggering foreground agent refreshes. - */ - acceptPtyDataBounded( - ptyId: string, - data: string, - at: number, - sequenceChars = data.length, - transformed = false, - sourceRanges?: readonly TerminalOutputSourceRange[] - ): RuntimePtyDataAdmission { - let completion: Promise | null = null - const sequence = this.onPtyData( - ptyId, - data, - at, - sequenceChars, - transformed, - (receipt) => { - completion = receipt - }, - sourceRanges - ) - if (!completion) { - throw new Error('PTY model admission receipt was not captured') - } - return Object.freeze({ sequence, completion }) - } - - onPtyData( - ptyId: string, - data: string, - at: number, - sequenceChars = data.length, - transformed = false, - captureModelReceipt?: (completion: Promise) => void, - sourceRanges?: readonly TerminalOutputSourceRange[] - ): number { - const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars - this.ptyOutputSequenceById.set(ptyId, outputSequence) - this.providerModeTrackersByPtyId.get(ptyId)?.scan(data) - for (const tracker of this.providerModeSnapshotScansByPtyId.get(ptyId) ?? []) { - tracker.scan(data) - } - const osc7Metadata = this.recordOsc7MetadataForPty(ptyId, data) - const cwd = osc7Metadata.cwd - const cwdChanged = osc7Metadata.cwdChanged - const agentStatusChunk = this.processAgentStatusOscForPty(ptyId, data) - this.recordRecentPtyOutputForPathProvenance(ptyId, data) - // Why: watch terminal output for advertised dev-server URLs (e.g. Vite's - // `Network: https://local.example.com:3001/`) so the workspace ports - // panel can surface them in place of the kernel bind address. - advertisedUrlWatcher.ingest(ptyId, data, at) - // Why: reply ownership is captured per chunk, here at ingestion — the - // same module state and tick as the hidden-gate drop sites — and rides - // the writeChain link. A mark/setting/subscriber flip before the queued - // emulator write runs must not change who answers (terminal-query- - // authority.md invariant 1). - const forwardQueryReplies = this.shouldAnswerQueriesForLiveChunk(ptyId) - // Ordering invariant (DO NOT REORDER): maybeHydrateHeadlessFromRenderer - // MUST run before trackHeadlessTerminalData so the eager-state pattern - // (set headlessTerminals + writeChain head = seedPromise) is in place - // before the live byte's chain link is queued. Without this ordering, - // trackHeadlessTerminalData would lazy-create a fresh state at PTY dims - // that the later seed-resolve would overwrite, dropping the live byte. - // See docs/mobile-prefer-renderer-scrollback.md. - this.maybeHydrateHeadlessFromRenderer(ptyId) - // Our structure wins: OSC title/agent-status extraction runs through the - // shared per-PTY title tracker below (getOrCreatePtyTitleTrackerEntry → - // applyTrackedPtyTitle) in byte order, superseding main's inline - // extractLastOscTitleForPty block (#7880/#7852 title/status semantics are - // preserved via the tracker + detectAgentStatusFromTitle path). - const modelCompletion = this.trackHeadlessTerminalData( - ptyId, - data, - outputSequence, - forwardQueryReplies - ) - captureModelReceipt?.(modelCompletion) - - const pty = this.getOrCreatePtyWorktreeRecord(ptyId) - const ptyTailBefore = pty - ? { - lines: pty.tailBuffer, - transcriptLines: pty.tailTranscriptBuffer, - partialLine: pty.tailPartialLine, - pendingAnsi: pty.tailPendingAnsi, - redrawCursor: pty.tailRedrawCursor, - truncated: pty.tailTruncated, - linesTotal: pty.tailLinesTotal - } - : null - let ptyTailAfter: ReturnType | null = null - if (pty) { - pty.connected = true - pty.disconnectedAt = null - pty.lastOutputAt = at - const normalized = normalizeTerminalChunk(data, pty.tailPendingAnsi) - pty.tailPendingAnsi = normalized.pendingAnsi - const nextTail = appendNormalizedToTailBuffer( - pty.tailBuffer, - pty.tailPartialLine, - normalized.text, - pty.tailRedrawCursor - ) - ptyTailAfter = nextTail - const nextTranscript = appendCompletedTerminalTranscript( - pty.tailTranscriptBuffer, - pty.tailTranscriptChars, - nextTail.newlyCompletedLines, - nextTail.newCompleteLines - ) - pty.tailBuffer = nextTail.lines - pty.tailTranscriptBuffer = nextTranscript.lines - pty.tailTranscriptChars = nextTranscript.characters - pty.tailPartialLine = nextTail.partialLine - pty.tailRedrawCursor = nextTail.redrawCursor - pty.tailTruncated = pty.tailTruncated || nextTail.truncated || nextTranscript.truncated - pty.tailLinesTotal += nextTail.newCompleteLines - pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine) - this.scheduleWaitBlockedCheck(ptyId, normalized.text, at) - } - - for (const leaf of this.getLeavesForPty(ptyId)) { - this.recordPtyWorktree(ptyId, leaf.worktreeId, { - connected: true, - lastOutputAt: pty?.lastOutputAt ?? at, - preview: pty?.preview ?? leaf.preview, - tabId: leaf.tabId, - paneKey: this.makeRuntimePaneKey(leaf) - }) - leaf.connected = true - leaf.writable = this.graphStatus === 'ready' - leaf.lastOutputAt = at - if ( - pty && - ptyTailBefore && - ptyTailAfter && - tailStateMatches( - leaf.tailBuffer, - leaf.tailTranscriptBuffer, - leaf.tailPartialLine, - leaf.tailPendingAnsi, - leaf.tailRedrawCursor, - leaf.tailTruncated, - leaf.tailLinesTotal, - ptyTailBefore - ) - ) { - // Why: the leaf and PTY record usually mirror the same terminal. Reuse - // the PTY tail update instead of splitting large output twice. - leaf.tailBuffer = pty.tailBuffer - leaf.tailTranscriptBuffer = pty.tailTranscriptBuffer - leaf.tailTranscriptChars = pty.tailTranscriptChars - leaf.tailPartialLine = pty.tailPartialLine - leaf.tailPendingAnsi = pty.tailPendingAnsi - leaf.tailRedrawCursor = pty.tailRedrawCursor - leaf.tailTruncated = pty.tailTruncated - leaf.tailLinesTotal = pty.tailLinesTotal - leaf.preview = pty.preview - leaf.waitBlockedAt = pty.waitBlockedAt - // Why undefined on this branch: the PTY record's wait scan is throttled - // (scheduleWaitBlockedCheck), so pty.tailWaitState is never populated; - // copying it here intentionally invalidates the leaf cache and the - // mismatch branch below recomputes an exact state on its next chunk. - leaf.tailWaitState = pty.tailWaitState - } else { - const normalized = normalizeTerminalChunk(data, leaf.tailPendingAnsi) - leaf.tailPendingAnsi = normalized.pendingAnsi - const previousWaitState = - leaf.tailWaitState?.fromTail === true - ? leaf.tailWaitState - : computeTerminalTailWaitState(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) - const nextTail = appendNormalizedToTailBuffer( - leaf.tailBuffer, - leaf.tailPartialLine, - normalized.text, - leaf.tailRedrawCursor - ) - const nextTranscript = appendCompletedTerminalTranscript( - leaf.tailTranscriptBuffer, - leaf.tailTranscriptChars, - nextTail.newlyCompletedLines, - nextTail.newCompleteLines - ) - const nextWaitState = computeTerminalTailWaitState( - nextTail.lines, - nextTail.partialLine, - leaf.preview - ) - if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, normalized.text)) { - leaf.waitBlockedAt = at - } - leaf.tailWaitState = nextWaitState - leaf.tailBuffer = nextTail.lines - leaf.tailTranscriptBuffer = nextTranscript.lines - leaf.tailTranscriptChars = nextTranscript.characters - leaf.tailPartialLine = nextTail.partialLine - leaf.tailRedrawCursor = nextTail.redrawCursor - leaf.tailTruncated = leaf.tailTruncated || nextTail.truncated || nextTranscript.truncated - leaf.tailLinesTotal += nextTail.newCompleteLines - leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine) - } - } - - // Why: feed the chunk's OSC titles through the shared per-PTY tracker in - // byte order — the same ordering the renderer transport uses — so - // coalesced working→idle transitions reach tui-idle waiters and - // pending-message delivery instead of being masked by the chunk's last - // title (issue #1083). Uses the OSC 9999-stripped cleanData like the - // renderer, so pure status chunks don't perturb the stale-title probe. - const titleTrackerEntry = this.getOrCreatePtyTitleTrackerEntry(ptyId) - const previousTitleScanTail = this.oscTitleScanTailByPtyId.get(ptyId) - const titleInput = previousTitleScanTail - ? `${previousTitleScanTail}${agentStatusChunk.cleanData}` - : agentStatusChunk.cleanData - const nextTitleScanTail = extractOscTitleScanTail(titleInput) - if (nextTitleScanTail.length > 0) { - this.oscTitleScanTailByPtyId.set(ptyId, nextTitleScanTail) - } else { - this.oscTitleScanTailByPtyId.delete(ptyId) - } - titleTrackerEntry.applyingChunk = true - titleTrackerEntry.chunkTouchedSessionTabs = false - let retainedAgentStatusChanged = false - try { - for (const payload of agentStatusChunk.payloads) { - titleTrackerEntry.pendingFacts.push({ kind: 'agent-status', payload }) - } - titleTrackerEntry.tracker.handleChunk(agentStatusChunk.cleanData, { - titleScanData: titleInput - }) - // Why: the Command Code scrape rides the same per-chunk batch (its facts - // trail the tracker's). cleanData keeps OSC 9999 payloads out of the - // detector's bounded recent-text window; the detector strips remaining - // control sequences itself, exactly like the renderer byte path. - titleTrackerEntry.commandCodeDetector?.observe(agentStatusChunk.cleanData) - } finally { - titleTrackerEntry.applyingChunk = false - try { - // Why: per-chunk cross-channel contract order is status → titles → - // bell — the chunk's agentStatus:set events must reach the renderer - // before its pty:sideEffect batch. - retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) - const lastPayloadTitleOffset = - agentStatusChunk.lastPayloadCleanOffset === null - ? null - : (previousTitleScanTail?.length ?? 0) + agentStatusChunk.lastPayloadCleanOffset - this.restoreAgentPromptLifecycleByteOrder(ptyId, titleInput, lastPayloadTitleOffset) - } finally { - // Why: flushed in the finally so a throwing tracker callback cannot - // strand this chunk's facts to be emitted under the next chunk's seq. - this.flushPendingTerminalSideEffectFacts(ptyId, titleTrackerEntry) - } - } - // Why: hook (OSC 9999) transitions often arrive without a title change, so - // headless-serve snapshots would never republish and paired remote clients - // kept the stale agent state until the next title change (#7970). - if (titleTrackerEntry.chunkTouchedSessionTabs || retainedAgentStatusChanged) { - this.touchMobileSessionSnapshotsForPty(ptyId) - } - - const listeners = this.dataListeners.get(ptyId) - if (listeners) { - const meta = { - seq: outputSequence, - rawLength: sequenceChars, - ...(transformed ? { transformed: true } : {}), - ...(cwdChanged && cwd !== null ? { cwd } : {}), - ...(sourceRanges && sourceRanges.length > 0 ? { sourceRanges } : {}) - } - for (const listener of listeners) { - try { - listener(data, meta) - } catch (error) { - // Why: inlined rather than via notifyRuntimeListeners to avoid a per-chunk closure - // allocation on the terminal-output hot path; isolation semantics match the helper. - console.error('[runtime] pty-data listener threw', error) - } - } - } - return outputSequence - } - - private scheduleWaitBlockedCheck(ptyId: string, appendedText: string, at: number): void { - let state = this.waitBlockedCheckStateByPtyId.get(ptyId) - if (!state) { - state = { lastAt: 0, lastWaitState: null, appended: '', keywordCarry: '', timer: null } - this.waitBlockedCheckStateByPtyId.set(ptyId, state) - } - const appendedLower = appendedText.toLowerCase() - const keywordHit = WAIT_BLOCKED_KEYWORD_PATTERN.test(`${state.keywordCarry}${appendedLower}`) - state.keywordCarry = appendedLower.slice(-WAIT_BLOCKED_KEYWORD_CARRY_CHARS) - // Why the cap keeps the tail: the accumulated text only anchors boundary- - // spanning prompt detection; anything past the tail cap has scrolled out - // of the retained tail the check reads anyway. - state.appended = - state.appended.length + appendedText.length > MAX_TAIL_CHARS - ? `${state.appended}${appendedText}`.slice(-MAX_TAIL_CHARS) - : `${state.appended}${appendedText}` - const elapsed = at - state.lastAt - if (keywordHit || elapsed >= WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS || elapsed < 0) { - this.runWaitBlockedCheck(ptyId, state, at) - return - } - if (!state.timer) { - // Why trailing edge: the final chunks of a burst must still be - // evaluated or a prompt arriving right after a flood would go - // unstamped until the next output. - state.timer = setTimeout(() => { - state.timer = null - this.runWaitBlockedCheck(ptyId, state, Date.now()) - }, WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS - elapsed) - } - } - - private runWaitBlockedCheck( - ptyId: string, - state: { - lastAt: number - lastWaitState: TerminalTailWaitState | null - appended: string - keywordCarry: string - timer: ReturnType | null - }, - at: number - ): void { - const pty = this.ptysById.get(ptyId) - if (!pty) { - state.appended = '' - return - } - const nextWaitState = computeTerminalTailWaitState( - pty.tailBuffer, - pty.tailPartialLine, - pty.preview - ) - const previousWaitState = state.lastWaitState ?? { - waitText: '', - signal: null, - fromTail: false - } - if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, state.appended)) { - pty.waitBlockedAt = at - this.recordAgentPromptPermissionObservation(ptyId) - } - state.lastAt = at - state.lastWaitState = nextWaitState - state.appended = '' - } - - // Why: the scanner's first run after a restore seed compares against a null - // baseline, so a permission prompt visible only in seeded HISTORY would read - // as newly gained and stamp waitBlockedAt "now" on the next benign chunk. - // Store the seeded tail's wait state as the baseline WITHOUT stamping; only - // a signal that appears in genuinely new output counts as gained. - private primeWaitBlockedBaselineFromSeededTail(ptyId: string): void { - const pty = this.ptysById.get(ptyId) - if (!pty) { - return - } - let state = this.waitBlockedCheckStateByPtyId.get(ptyId) - if (!state) { - state = { lastAt: 0, lastWaitState: null, appended: '', keywordCarry: '', timer: null } - this.waitBlockedCheckStateByPtyId.set(ptyId, state) - } - if (state.lastWaitState === null) { - state.lastWaitState = computeTerminalTailWaitState( - pty.tailBuffer, - pty.tailPartialLine, - pty.preview - ) - } - } - - private clearWaitBlockedCheckState(ptyId: string): void { - const state = this.waitBlockedCheckStateByPtyId.get(ptyId) - if (state?.timer) { - clearTimeout(state.timer) - } - this.waitBlockedCheckStateByPtyId.delete(ptyId) - } - - private processAgentStatusOscForPty(ptyId: string, data: string): ProcessedAgentStatusChunk { - let processor = this.agentStatusOscProcessorsByPtyId.get(ptyId) - if (!processor) { - processor = createAgentStatusOscProcessor() - this.agentStatusOscProcessorsByPtyId.set(ptyId, processor) - } - return processor(data) - } - - /** Emit the facts batched while applying one chunk/frame as a single - * pty:sideEffect batch, preserving byte order. */ - private flushPendingTerminalSideEffectFacts( - ptyId: string, - entry: RuntimePtyTitleTrackerEntry - ): void { - if (entry.pendingFacts.length === 0) { - return - } - const facts = entry.pendingFacts - entry.pendingFacts = [] - this.emitTerminalSideEffectBatch(ptyId, facts) - } - - /** Feed a main-fabricated OSC title/BEL frame (agent hook spinners) through - * the per-PTY tracker — NOT onPtyData, so emulator state, tails, - * transcripts, and stats never see synthetic bytes. Parsed via the - * tracker's stateless synthetic path: the shared chunk bell detector must - * never observe fabricated bytes, or a tick interleaved with a split real - * OSC corrupts its escape state (phantom/swallowed bells). While the - * side-effect kill switch is off the legacy pty:data copy still drives - * renderer parsers; this ingest keeps main's facts and records - * authoritative. */ - ingestSyntheticTitleFrame(ptyId: string, data: string): void { - const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) - entry.applyingChunk = true - entry.chunkTouchedSessionTabs = false - try { - entry.tracker.applySyntheticTitleFrame(data) - } finally { - entry.applyingChunk = false - this.flushPendingTerminalSideEffectFacts(ptyId, entry) - } - if (entry.chunkTouchedSessionTabs) { - this.touchMobileSessionSnapshotsForPty(ptyId) - } - } - - /** Scan-authority handoff for a backgrounded PTY (daemon keep-tail - * thinning): while delegated, the daemon relays bell/133/pr-link/2031 - * facts itself and the delivered bytes may be gapped — feeding them to - * main's transient scanners would mint phantom or duplicate facts. Title - * processing stays main-side either way. */ - setPtyTransientFactDelegation( - ptyId: string, - delegated: boolean, - scanSeedAnsi?: string, - mode2031PendingSubscribe?: true - ): void { - const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) - entry.tracker.setTransientFactScanningSuppressed(delegated) - if (!delegated && scanSeedAnsi) { - // Prime the freshly reset scanner carry with the emulator's dangling - // incomplete escape at the handoff position — a sequence split across - // the un-background toggle must not mint a phantom bell or lose its - // fact. titleScanData:'' keeps titles out (they were never suppressed). - entry.tracker.handleChunk(scanSeedAnsi, { - titleScanData: '', - mode2031PendingSubscribe - }) - } - } - - /** A transient fact the daemon detected while it held scan authority — - * emitted through the same fact channel as byte-scanned facts. Arrives - * between chunks, so recordTerminalSideEffectFact emits it immediately. */ - emitDaemonPtyTransientFact(ptyId: string, fact: PtyTransientFact): void { - switch (fact.kind) { - case 'bell': - this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) - return - case 'command-finished': - this.retirePtyAgentLaunchAuthority(ptyId) - this.recordTerminalSideEffectFact(ptyId, { - kind: 'command-finished', - exitCode: fact.exitCode - }) - return - case 'pr-link': - this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link: fact.link }) - return - case '2031-subscribe': - this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) - return - case '2031-unsubscribe': - this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }) - } - } - - /** The daemon keep-tail dropped this PTY's oldest undelivered output; the - * next delivered chunk is discontinuous. Reset every cross-chunk parse - * carry so a half-open escape from before the gap cannot corrupt what - * follows, and drop the mobile headless mirror — it rebuilds from the - * delivered tail / snapshot seeds instead of parsing a gapped stream. */ - notePtyDataGap(ptyId: string, droppedChars = 0): void { - if (droppedChars > 0) { - // Why: the daemon snapshot's seq counts bytes its monitoring stream - // dropped. Advancing without parsing preserves that absolute domain so - // post-snapshot live chunks can be reconciled instead of duplicated. - const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + droppedChars - this.ptyOutputSequenceById.set(ptyId, outputSequence) - } - const pty = this.getOrCreatePtyWorktreeRecord(ptyId) - if (pty) { - pty.tailPendingAnsi = '' - } - for (const leaf of this.getLeavesForPty(ptyId)) { - leaf.tailPendingAnsi = '' - } - this.oscTitleScanTailByPtyId.delete(ptyId) - this.osc7ScanTailByPtyId.delete(ptyId) - this.agentStatusOscProcessorsByPtyId.delete(ptyId) - this.disposeHeadlessTerminal(ptyId) - } - - /** Record one derived side-effect fact: batched per chunk while applying - * bytes, emitted immediately for between-chunk facts (stale-title timer). */ - private recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void { - if (!this.terminalSideEffectConsumerAvailable) { - return - } - const entry = this.ptyTitleTrackersByPtyId.get(ptyId) - if (entry?.applyingChunk) { - entry.pendingFacts.push(fact) - return - } - this.emitTerminalSideEffectBatch(ptyId, [fact]) - } - - private emitTerminalSideEffectBatch( - ptyId: string, - facts: TerminalSideEffectFact[], - options: { replay?: boolean } = {} - ): void { - if (!this.terminalSideEffectConsumerAvailable || facts.length === 0) { - return - } - const batch: TerminalSideEffectBatch = { - ptyId, - seq: this.ptyOutputSequenceById.get(ptyId) ?? 0, - facts, - ...(options.replay ? { replay: true } : {}), - ...this.resolveTerminalSideEffectAttribution(ptyId) - } - if (this.terminalSideEffectLocalConsumerAvailable) { - try { - this.onTerminalSideEffects?.(batch) - } catch (err) { - console.error('[runtime] terminal side-effect listener threw', { ptyId, err }) - } - } - if (this.countTerminalSideEffectConsumingClientEventListeners() > 0) { - this.emitClientEvent({ type: 'terminalSideEffects', batch }) - } - } - - /** Same attribution resolution as emitTerminalAgentStatusEvents: prefer the - * first mounted leaf, fall back to the spawn-time PTY record binding. */ - private resolveTerminalSideEffectAttribution(ptyId: string): { - worktreeId?: string - tabId?: string - paneKey?: string - connectionId?: string | null - } { - const pty = this.ptysById.get(ptyId) - const connectionId = pty?.connectionId ?? null - for (const leaf of this.getLeavesForPty(ptyId)) { - return { - worktreeId: leaf.worktreeId, - tabId: leaf.tabId, - paneKey: this.makeRuntimePaneKey(leaf), - connectionId - } - } - if (pty?.paneKey) { - return { - worktreeId: pty.worktreeId, - ...(pty.tabId ? { tabId: pty.tabId } : {}), - paneKey: pty.paneKey, - connectionId - } - } - return {} - } - - /** Title-only replay batch for renderer (re)attach — the no-attention-replay - * rule: snapshots restore title state, never historical bells/completions. */ - getTerminalSideEffectSnapshot(ptyId: string): TerminalSideEffectBatch | null { - const tracker = this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker - const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle - const normalizedTitle = tracker?.getLastNormalizedTitle() ?? null - // Why: a record-fallback snapshot must not replay the bare cursor-agent literal over a - // tracker title Orca synthesized from hooks — but with no tracker title it is the pane's - // only Cursor identity, so restored/mobile tabs keep it (#10258). - const rawTitle = - recordTitle && (normalizedTitle === null || !isCursorNativeAgentTitle(recordTitle)) - ? recordTitle - : null - if (normalizedTitle === null && !rawTitle) { - return null - } - return { - ptyId, - seq: this.ptyOutputSequenceById.get(ptyId) ?? 0, - replay: true, - facts: [ - { - kind: 'title', - normalizedTitle: normalizedTitle ?? normalizeTerminalTitle(rawTitle!), - rawTitle: rawTitle ?? normalizedTitle! - } - ], - ...this.resolveTerminalSideEffectAttribution(ptyId) - } - } - - /** Raw last title from main's tracked PTY/leaf records — the title surface - * the tracker (live bytes + synthetic frames) keeps current. */ - private getTrackedRawTitleForPty(ptyId: string): string | null { - const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle - if (recordTitle) { - return recordTitle - } - for (const leaf of this.getLeavesForPty(ptyId)) { - if (leaf.lastOscTitle) { - return leaf.lastOscTitle - } - } - return null - } - - private isLiveCursorNativeTitle(rawTitle: string, meta?: TerminalTitleFactMeta): boolean { - return isCursorNativeAgentTitle(rawTitle) && meta?.staleWorkingTitleClear !== true - } - - /** Display fallback for identities intentionally omitted from liveness records. */ - private getTrackedDisplayTitleForPty(ptyId: string): string | null { - return ( - this.getTrackedRawTitleForPty(ptyId) ?? - this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.getLastNormalizedTitle() ?? - null - ) - } - - private getUnpersistedTrackedTitleForPty(ptyId: string | null): string | null { - if (!ptyId || this.getTrackedRawTitleForPty(ptyId) !== null) { - return null - } - // Why: a manual title is authoritative until explicitly cleared with null. - const pty = this.ptysById.get(ptyId) - if (pty && pty.title !== null) { - return null - } - return this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.getLastNormalizedTitle() ?? null - } - - /** Why: synthetic agent title frames no longer ride pty:data, so neither - * renderer xterm nor the headless emulator observes them. Mobile-parity - * snapshot titles must prefer main's tracker over snapshot lastTitle, or - * hook-driven spinner/idle titles vanish from mobile tabs. */ - private preferTrackedLastTitle(ptyId: string, snapshot: T): T { - const tracked = this.getTrackedDisplayTitleForPty(ptyId) - if (!tracked) { - return snapshot - } - return { ...snapshot, lastTitle: tracked } - } - - /** Decorative comparison key: only recognized agent titles fold leading spinner frames. */ - private makeDecorativeTitleGateKey(rawTitle: string, normalizedTitle: string): string { - // Stable Pi/Gemini/Grok display normalization also defines their semantic gate. - const normalizedSignature = - rawTitle === normalizedTitle ? null : getDecorativeAgentTitleSignature(normalizedTitle) - const signature = normalizedSignature ?? getDecorativeAgentTitleSignature(rawTitle) - return signature === null ? `literal\u0000${normalizedTitle}` : `agent\u0000${signature}` - } - - private getOrCreatePtyTitleTrackerEntry(ptyId: string): RuntimePtyTitleTrackerEntry { - const existing = this.ptyTitleTrackersByPtyId.get(ptyId) - if (existing) { - return existing - } - // Why: trackers are created lazily on the first observed chunk. After an - // app relaunch the PTY/leaf records can already hold a persisted title; a - // cold tracker would miss the parked working→idle completion and never - // arm the stale-title timer for a persisted 'working' title. - let initialTitle = this.ptysById.get(ptyId)?.lastOscTitle ?? null - if (initialTitle === null) { - for (const leaf of this.getLeavesForPty(ptyId)) { - if (leaf.lastOscTitle) { - initialTitle = leaf.lastOscTitle - break - } - } - } - const tracker = createTerminalTitleTracker( - { - onTitle: (normalizedTitle, rawTitle, meta) => { - this.recordTerminalSideEffectFact(ptyId, { - kind: 'title', - normalizedTitle, - rawTitle, - ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) - }) - const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta) - const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) - const live = this.ptyTitleTrackersByPtyId.get(ptyId) - const gateKey = this.makeDecorativeTitleGateKey(rawTitle, normalizedTitle) - const decorativeOnly = live?.lastMobileTitleGateKey === gateKey - if (live) { - live.lastMobileTitleGateKey = gateKey - } - const tracksReplicatedStatus = - live?.applyingChunk === true && this.mobileSessionTabListeners.size > 0 - const titleStatus = tracksReplicatedStatus ? detectAgentStatusFromTitle(rawTitle) : null - if ( - tracksReplicatedStatus && - decorativeOnly && - !this.ptyDelayedForegroundSnapshotTitleObservations.has(ptyId) && - (titleStatus === 'working' || titleStatus === 'permission') - ) { - // Normalized Pi/Gemini/Grok frames still renew the replicated status lease. - this.mobileSessionTabsAgentStatusHeartbeat.scheduleDecorativeHeartbeat(ptyId) - } - // Why: an identity-only cursor title records nothing, but the tracker - // title is that pane's only Cursor identity and must still fan out (#10258). - if (!changed && !identityOnlyTitle) { - return - } - if (live?.applyingChunk) { - // Why: synthetic spinner ticks change only the braille glyph - // ~12.5x/sec; fanning out full mobile session snapshots per frame - // is pure churn. Raw lastOscTitle updates above stay cheap. - if (!decorativeOnly) { - this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId) - live.chunkTouchedSessionTabs = true - } - } else { - // Stale-working-title timer path — fires between chunks, so the - // per-chunk batching in onPtyData cannot pick it up. - this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId) - this.touchMobileSessionSnapshotsForPty(ptyId) - } - }, - // Why: agent transitions and bells become pty:sideEffect facts — - // main is the single byte parser for local/SSH PTYs; the renderer - // store handler decides what the facts mean (notification policy). - onAgentBecameWorking: () => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-working' }) - }, - onAgentBecameIdle: (title, meta) => { - this.recordTerminalSideEffectFact(ptyId, { - kind: 'agent-idle', - title, - ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) - }) - }, - onAgentExited: () => { - this.confirmPtyAgentExit(ptyId) - }, - onCommandFinished: (exitCode: number | null) => { - this.retirePtyAgentLaunchAuthority(ptyId) - this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) - }, - onBell: () => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) - }, - onPrLink: (link: TerminalGitHubPRLink) => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link }) - }, - // Why: hidden-delivery-gated views never see 2031 bytes; facts keep their theme registry truthful. - onMode2031Subscribe: () => { - this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) - }, - onMode2031Unsubscribe: () => { - this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }) - } - }, - initialTitle !== null ? { initialTitle } : {} - ) - tracker.setTransientSideEffectScanningEnabled(this.terminalSideEffectConsumerAvailable) - const entry: RuntimePtyTitleTrackerEntry = { - tracker, - applyingChunk: false, - lastMobileTitleGateKey: null, - chunkTouchedSessionTabs: false, - pendingFacts: [], - // Why: command-code facts exist only for the pty:sideEffect channel — - // headless serve skips the per-chunk scrape entirely. The detector - // self-arms on the Command Code banner; the spawn command (when main - // saw one) mirrors the renderer detector's startupCommand fast-arm. - commandCodeDetector: this.terminalSideEffectConsumerAvailable - ? this.createTerminalSideEffectCommandCodeDetector(ptyId) - : null - } - this.ptyTitleTrackersByPtyId.set(ptyId, entry) - return entry - } - - /** Apply one observed OSC title (raw form) to the PTY and leaf records. - * Returns true when the PTY record's title or status changed. */ - private applyTrackedPtyTitle( - ptyId: string, - rawTitle: string, - normalizedTitle: string, - meta?: TerminalTitleFactMeta - ): boolean { - // Why: status is detected from the RAW title (mirrors the renderer tracker), - // so working/idle transitions are unaffected by normalization; the records - // store the NORMALIZED title so rotating Grok/Pi/Gemini frames collapse to - // one stable stored label (#7880) instead of churning `ps`/mobile tabs. - // - // Why the identity-only case: the bare cursor-agent literal identifies the pane without - // asserting activity, so it records NO title/status evidence — only the tracker keeps it, - // for display (#10258). Nulling the status here rather than trusting the detector keeps - // that contract local, since every activity-gated effect below is keyed on status. - const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) - const recordedTitle = identityOnlyTitle ? null : normalizedTitle - const agentStatus = identityOnlyTitle ? null : detectAgentStatusFromTitle(rawTitle) - this.recordAgentPromptLifecycleState(ptyId, agentStatus) - let ptyRecordChanged = false - const pty = this.ptysById.get(ptyId) - if (pty) { - const prevStatus = pty.lastAgentStatus - const prevTitle = pty.lastOscTitle - const observedAt = this.nextTitleObservationSequence() - const observedAtEpochMs = identityOnlyTitle ? null : Date.now() - pty.lastOscTitle = recordedTitle - pty.lastOscTitleAt = identityOnlyTitle ? null : observedAt - pty.lastOscTitleEpochMs = observedAtEpochMs - pty.lastAgentStatus = agentStatus - pty.lastAgentStatusObservedLive = true - if (prevStatus !== agentStatus) { - pty.lastAgentStatusStartedAtEpochMs = observedAtEpochMs - } - if ( - identityOnlyTitle || - terminalTitleBlocksExplicitAgentStatus(recordedTitle) || - (prevStatus !== null && agentStatus !== null && prevStatus !== agentStatus) - ) { - pty.lastAgentStatusRichInvalidatedAtEpochMs = observedAtEpochMs ?? Date.now() - } - if (identityOnlyTitle) { - pty.managementTitle = null - pty.managementTitleAt = null - } else { - this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt) - } - ptyRecordChanged = prevTitle !== recordedTitle || prevStatus !== agentStatus - if (agentStatus === 'idle' && prevStatus !== 'idle') { - this.resolvePtyTuiIdleWaiters(pty, ptyId) - } - const shouldDelayMobileSnapshot = - ptyRecordChanged && - this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, normalizedTitle) - let foregroundRefresh: Promise | undefined - // Why: gate on an actual status transition — braille spinner frames - // mutate the title every tick, so probing per-title-change would stream - // a foreground query per frame during active work. - if (prevStatus !== agentStatus) { - foregroundRefresh = this.refreshPtyForegroundAgentFromController(ptyId, { - afterTitleObservation: observedAt - }) - } else if (shouldDelayMobileSnapshot) { - // Why: same-status compatible title changes can arrive before the - // foreground owner probe settles; publishing them would flicker. - foregroundRefresh = this.getPendingForegroundAgentRefreshForTitle(ptyId, observedAt) - } - if (foregroundRefresh && shouldDelayMobileSnapshot) { - // Why: report "unchanged" so the per-chunk batch skips the mobile - // snapshot fan-out; the delayed publish fires when the probe settles. - ptyRecordChanged = false - this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh) - } - } - for (const leaf of this.getLeavesForPty(ptyId)) { - // Why: keep the latest OSC title on the leaf so worktree.ps can - // recompute status from the live title each call. Without this, - // daemon-hosted terminals (no renderer pushing pane titles) had no - // way to clear a stale 'working' status after the agent exited and - // the shell took over the title — the stuck-spinner bug in #1437. - const prevStatus = leaf.lastAgentStatus - const prevObservedLive = leaf.lastAgentStatusObservedLive - leaf.lastOscTitle = recordedTitle - leaf.lastOscTitleAt = identityOnlyTitle ? null : this.nextTitleObservationSequence() - // Why: when a new OSC title doesn't classify as an agent state (e.g. - // bare shell title after the agent exits), clear lastAgentStatus so - // it is no longer sticky. Tui-idle waiters that needed the previous - // 'idle' transition were already resolved at the moment of the - // transition below; only fresh waiters registered after the agent - // exits would observe the cleared value, and they correctly fall - // back to title-based detection / polling. - leaf.lastAgentStatus = agentStatus - leaf.lastAgentStatusObservedLive = true - // Why: resolve tui-idle on any transition TO idle (not just working→idle). - // Claude Code may skip "working" entirely on fast tasks, going null→idle, - // and the coordinator's tui-idle waiter would hang forever waiting for a - // working→idle transition that never comes. Permission→idle is excluded: - // it means the agent was blocked on user approval and the user said no, - // which isn't a task-completion signal. - if (agentStatus === 'idle' && prevStatus !== 'idle') { - this.resolveTuiIdleWaiters(leaf) - } - // Why the second condition: push delivery is gated on LIVE idle, so its - // authorizing edge is liveness as well as status. A restore seed or a - // status kept across a same-id respawn leaves a stale 'idle' behind, and - // an agent whose first live title is already idle (claude --resume at its - // prompt) then shows no transition — the row would strand, which is - // exactly #12536. Waiter semantics stay transition-only above. - if (agentStatus === 'idle' && (prevStatus !== 'idle' || !prevObservedLive)) { - this.deliverPendingMessagesForLeaf(leaf) - } - } - return ptyRecordChanged - } - - /** Cancel the per-PTY title tracker (stale-title timer included) on PTY - * teardown so it cannot fire into pruned records. */ - private disposePtyTitleTracker(ptyId: string): void { - this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose() - this.ptyTitleTrackersByPtyId.delete(ptyId) - this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId) - this.mobileSessionTabsAgentStatusHeartbeat.removePty(ptyId) - for (const titleGateKeys of this.terminalSideEffectTitleGateKeysByClientEventListener.values()) { - titleGateKeys.delete(ptyId) - } - } - - private resetTrackedTerminalStateForProviderGeneration(ptyId: string): void { - // Why: a replacement daemon session can reuse the PTY id, but title/parser - // state from the prior process must not bleed into its snapshots or chunks. - this.disposePtyTitleTracker(ptyId) - this.oscTitleScanTailByPtyId.delete(ptyId) - this.osc7ScanTailByPtyId.delete(ptyId) - this.agentStatusOscProcessorsByPtyId.delete(ptyId) - this.agentPromptLifecycleByPtyId.delete(ptyId) - this.agentPromptPermissionSequenceByPtyId.delete(ptyId) - this.clearWaitBlockedCheckState(ptyId) - const pty = this.ptysById.get(ptyId) - if (pty) { - pty.lastOscTitle = null - pty.lastOscTitleAt = null - pty.lastOscTitleEpochMs = null - pty.lastAgentStatus = null - // Why: the prior process's live frames say nothing about the replacement, - // so the seed a same-id restore applies must not inherit its authority. - pty.lastAgentStatusObservedLive = false - pty.lastAgentStatusStartedAtEpochMs = null - pty.lastAgentStatusRichInvalidatedAtEpochMs = Date.now() - pty.managementTitle = null - pty.managementTitleAt = null - pty.waitBlockedAt = null - pty.tailWaitState = undefined - } - for (const leaf of this.getLeavesForPty(ptyId)) { - leaf.lastOscTitle = null - leaf.lastOscTitleAt = null - leaf.lastAgentStatus = null - leaf.lastAgentStatusObservedLive = false - leaf.waitBlockedAt = null - leaf.tailWaitState = undefined - } - this.primeWaitBlockedBaselineFromSeededTail(ptyId) - this.clearAgentRowSnapshotsForPty(ptyId) - } - - private setTerminalSideEffectConsumerAvailable(available: boolean): void { - this.terminalSideEffectLocalConsumerAvailable = available && this.onTerminalSideEffects !== null - this.refreshTerminalSideEffectConsumerAvailability() - } - - private refreshTerminalSideEffectConsumerAvailability(): void { - const nextAvailable = - this.terminalSideEffectLocalConsumerAvailable || - this.countTerminalSideEffectConsumingClientEventListeners() > 0 - if (nextAvailable === this.terminalSideEffectConsumerAvailable) { - return - } - this.terminalSideEffectConsumerAvailable = nextAvailable - for (const [ptyId, entry] of this.ptyTitleTrackersByPtyId) { - entry.tracker.setTransientSideEffectScanningEnabled(nextAvailable) - entry.commandCodeDetector = nextAvailable - ? this.createTerminalSideEffectCommandCodeDetector(ptyId) - : null - } - } - - private createTerminalSideEffectCommandCodeDetector( - ptyId: string - ): NonNullable { - return createCommandCodeOutputStatusDetector({ - startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, - onWorking: (prompt) => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt }) - }, - onDone: (prompt) => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt }) - } - }) - } - - private extractLastOsc7CwdForPty( - ptyId: string, - data: string - ): { path: string; hostname: string } | null { - const previousTail = this.osc7ScanTailByPtyId.get(ptyId) - if (!previousTail && !data.includes('\x1b]7;')) { - return null - } - const input = `${previousTail ?? ''}${data}` - const scanTail = extractOscScanTail(input, 4096) - if (scanTail.length > 0) { - this.osc7ScanTailByPtyId.set(ptyId, scanTail) - } else { - this.osc7ScanTailByPtyId.delete(ptyId) - } - const uri = extractLastOsc7Uri(input) - const pty = this.ptysById.get(ptyId) - const pathFlavor = this.pathFlavorForPty(pty) - return uri - ? parseFileUriPathParts(uri, { - pathFlavor, - remotePosixAuthority: !!pty?.connectionId && pathFlavor !== 'win32', - wslDistro: pty?.connectionId - ? undefined - : (this.wslDistroByPtyId.get(ptyId) ?? pty?.wslDistro ?? undefined) - }) - : null - } - - private recordOsc7MetadataForPty( - ptyId: string, - data: string - ): { cwd: string | null; cwdChanged: boolean } { - const osc7 = this.extractLastOsc7CwdForPty(ptyId, data) - const cwd = osc7?.path ?? null - const cwdChanged = - cwd !== null && cwd.trim().length > 0 && this.terminalCwdByPtyId.get(ptyId) !== cwd - if (cwdChanged) { - this.terminalCwdByPtyId.set(ptyId, cwd) - } - if (osc7) { - if (osc7.hostname) { - this.terminalFileUriHostnameByPtyId.set(ptyId, osc7.hostname) - } else { - this.terminalFileUriHostnameByPtyId.delete(ptyId) - } - } - return { cwd, cwdChanged } - } - - private pathFlavorForPty(pty?: RuntimePtyWorktreeRecord | null): 'posix' | 'win32' { - if (!pty?.connectionId) { - return process.platform === 'win32' ? 'win32' : 'posix' - } - const worktreePath = splitWorktreeIdForFilesystem(pty.worktreeId)?.worktreePath - return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'posix' - } - - /** Returns true when any retained agent-row snapshot changed in a - * client-visible way, so the caller can republish session snapshots. */ - private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): boolean { - // Why: snapshot retention (for mobile worktree.ps) must run even when no - // renderer listener is attached, so we don't early-return on a missing - // onTerminalAgentStatus — only the per-target emit below is gated on it. - if (chunk.payloads.length === 0) { - return false - } - const targets = new Map< - string, - { - source: 'mounted-leaf' | 'pty-record' - paneKey: string - tabId?: string - worktreeId?: string - connectionId?: string | null - } - >() - const pty = this.ptysById.get(ptyId) - const connectionId = pty?.connectionId ?? null - for (const leaf of this.getLeavesForPty(ptyId)) { - const paneKey = this.makeRuntimePaneKey(leaf) - targets.set(paneKey, { - source: 'mounted-leaf', - paneKey, - tabId: leaf.tabId, - worktreeId: leaf.worktreeId, - connectionId - }) - } - if (targets.size === 0 && pty?.paneKey) { - targets.set(pty.paneKey, { - source: 'pty-record', - paneKey: pty.paneKey, - tabId: pty.tabId ?? undefined, - worktreeId: pty.worktreeId, - connectionId - }) - } - let retainedChanged = false - for (const payload of chunk.payloads) { - this.recordAgentPromptLifecycleState( - ptyId, - mapExplicitAgentStateToRuntimeTerminalStatus(payload.state) - ) - for (const target of targets.values()) { - retainedChanged = - this.retainAgentRowSnapshot( - ptyId, - target.paneKey, - target.worktreeId, - target.tabId, - target.connectionId ?? null, - payload - ) || retainedChanged - if (!this.onTerminalAgentStatus) { - continue - } - try { - this.onTerminalAgentStatus({ - ptyId, - ...target, - payload - }) - } catch (err) { - console.error('[runtime] terminal agent status listener threw', { - ptyId, - paneKey: target.paneKey, - state: payload.state, - agentType: payload.agentType, - err - }) - } - } - } - return retainedChanged - } - - private retainAgentRowSnapshot( - ptyId: string, - paneKey: string, - worktreeId: string | undefined, - tabId: string | undefined, - connectionId: string | null, - payload: ParsedAgentStatusPayload - ): boolean { - const now = Date.now() - const previous = this.latestAgentStatusByPaneKey.get(paneKey) - // Why: stateStartedAt must mark the transition into the current state, not - // every within-state ping (tool/prompt updates keep the state but refresh - // updatedAt) — mirrors AgentStatusEntry.stateStartedAt on the desktop side. - const stateStartedAt = - previous && previous.payload.state === payload.state ? previous.stateStartedAt : now - this.latestAgentStatusByPaneKey.set(paneKey, { - paneKey, - ptyId, - worktreeId, - tabId, - connectionId, - payload, - stateStartedAt, - updatedAt: now - }) - // Client-visible change detection: snapshot republish is gated on this so - // repeated same-state hook pings don't fan a rebuild out to every client. - 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) || - (previous.payload.interactivePrompt ?? null) !== (payload.interactivePrompt ?? null) || - (previous.payload.interrupted ?? false) !== (payload.interrupted ?? false) || - (previous.payload.turnCompletedAt ?? null) !== (payload.turnCompletedAt ?? null) || - (previous.payload.lastAssistantMessage ?? null) !== (payload.lastAssistantMessage ?? null) - ) - } - - private clearAgentRowSnapshotsForPty(ptyId: string): void { - for (const [paneKey, snapshot] of this.latestAgentStatusByPaneKey) { - if (snapshot.ptyId === ptyId) { - this.latestAgentStatusByPaneKey.delete(paneKey) - } - } - } - - getPtyOutputSequence(ptyId: string): number { - return this.ptyOutputSequenceById.get(ptyId) ?? 0 - } - - private recordAgentPromptLifecycleState(ptyId: string, status: AgentStatus | null): void { - if (status === 'permission') { - this.recordAgentPromptPermissionObservation(ptyId) - } - const current = this.agentPromptLifecycleByPtyId.get(ptyId) - const updatedAt = Date.now() - if (!current) { - this.agentPromptLifecycleByPtyId.set(ptyId, { - status, - workingSequence: status === 'working' ? 1 : 0, - updatedAt - }) - return - } - this.agentPromptLifecycleByPtyId.set(ptyId, { - status, - workingSequence: - current.workingSequence + (status === 'working' && current.status !== 'working' ? 1 : 0), - updatedAt - }) - } - - private recordAgentPromptPermissionObservation(ptyId: string): void { - this.agentPromptPermissionSequenceByPtyId.set( - ptyId, - (this.agentPromptPermissionSequenceByPtyId.get(ptyId) ?? 0) + 1 - ) - } - - private restoreAgentPromptLifecycleByteOrder( - ptyId: string, - titleInput: string, - lastPayloadTitleOffset: number | null - ): void { - if (lastPayloadTitleOffset === null) { - return - } - const titleRange = findLastCompleteOscTitleRange(titleInput) - if (!titleRange || titleRange.end <= lastPayloadTitleOffset) { - return - } - const title = extractLastOscTitle(titleInput) - if (title === null) { - return - } - const status = detectAgentStatusFromTitle(title) - const current = this.agentPromptLifecycleByPtyId.get(ptyId) - if (!current || current.status === status) { - return - } - this.agentPromptLifecycleByPtyId.set(ptyId, { - status, - workingSequence: - current.workingSequence + (status === 'working' && current.status !== 'working' ? 1 : 0), - updatedAt: Date.now() - }) - } - - private getPtyLifecycleGeneration(ptyId: string): number { - const existing = this.ptyLifecycleGenerationById.get(ptyId) - if (existing !== undefined) { - return existing - } - const generation = this.nextPtyLifecycleGeneration++ - this.ptyLifecycleGenerationById.set(ptyId, generation) - return generation - } - - private advancePtyLifecycleGeneration(ptyId: string): void { - this.ptyLifecycleGenerationById.set(ptyId, this.nextPtyLifecycleGeneration++) - // Why: a stop whose exit never arrived would otherwise stay armed across a - // same-id respawn and label the NEXT process's crash an operator close — - // the exact lie this cause model exists to remove. - this.stopRequestedPtyIds.delete(ptyId) - this.agentPromptLifecycleByPtyId.delete(ptyId) - this.agentPromptPermissionSequenceByPtyId.delete(ptyId) - this.agentPromptExplicitStatusFloorByPtyId.set(ptyId, Date.now()) - this.legacyWorkerRecoveredPtys.delete(ptyId) - // Why: a respawn under the same session id needs its own subscriber-driven attach. - this.subscriberDrivenProviderAttachesByPtyId.delete(ptyId) - this.subscriberDrivenProviderAttachInventoryWaiters.delete(ptyId) - this.spawnPublishedPtys.delete(ptyId) - // Why: a provider response belongs to the process generation that issued - // it; a respawn must neither reuse its frame nor join its in-flight call. - this.providerBufferAcquisitionsByPtyId.delete(ptyId) - this.providerVisibleStateByPtyId.delete(ptyId) - this.providerVisibleRetryAtByPtyId.delete(ptyId) - } - - synchronizePtyOutputSequenceFromProvider( - ptyId: string, - providerSequence: { value: number; generation: 'continued' | 'reset' }, - runtimeSequenceAtSpawnStart = 0 - ): number { - if ( - !Number.isFinite(providerSequence.value) || - providerSequence.value < 0 || - !Number.isFinite(runtimeSequenceAtSpawnStart) || - runtimeSequenceAtSpawnStart < 0 - ) { - return this.getPtyOutputSequence(ptyId) - } - const baseline = Math.floor(providerSequence.value) - const currentSequence = this.getPtyOutputSequence(ptyId) - const sequenceAtSpawnStart = Math.min(currentSequence, Math.floor(runtimeSequenceAtSpawnStart)) - const postSpawnSequence = currentSequence - sequenceAtSpawnStart - const wasInitialized = this.providerSequenceInitializedPtys.has(ptyId) - const replacesExistingRuntimeGeneration = wasInitialized || sequenceAtSpawnStart > 0 - const providerOffset = - providerSequence.generation === 'reset' - ? sequenceAtSpawnStart - : (this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0) - const providerBaseline = providerOffset + baseline - - if (providerSequence.generation === 'reset') { - this.advancePtyLifecycleGeneration(ptyId) - // Why: daemon respawn/cold restore starts a new absolute domain. Old - // emulator state cannot remain authoritative over the replacement. - if (replacesExistingRuntimeGeneration) { - this.disposeHeadlessTerminal(ptyId) - } - this.providerModeTrackersByPtyId.delete(ptyId) - this.wslDistroByPtyId.delete(ptyId) - this.terminalCwdByPtyId.delete(ptyId) - this.terminalFileUriHostnameByPtyId.delete(ptyId) - const pty = this.ptysById.get(ptyId) - if (pty) { - pty.wslDistro = null - } - // Why: raced post-spawn bytes may already contain the replacement's permission state. - if (replacesExistingRuntimeGeneration && postSpawnSequence === 0) { - this.resetTrackedTerminalStateForProviderGeneration(ptyId) - } - } - - const synchronizedSequence = - providerSequence.generation === 'reset' - ? currentSequence - : wasInitialized - ? currentSequence - : providerBaseline + postSpawnSequence - this.ptyOutputSequenceById.set(ptyId, synchronizedSequence) - this.providerSequenceInitializedPtys.add(ptyId) - this.providerSequenceOffsetByPtyId.set(ptyId, providerOffset) - - const snapshotMayCoverMissingState = - (providerSequence.generation === 'continued' && !wasInitialized) || - (postSpawnSequence > 0 && - providerSequence.generation === 'reset' && - replacesExistingRuntimeGeneration) || - (providerSequence.generation === 'continued' && - wasInitialized && - providerBaseline > currentSequence) - if (snapshotMayCoverMissingState) { - // Why: bytes can cross the control/stream sockets around attach. Until a - // full renderer/provider snapshot is available, a partial model is unsafe. - this.providerSnapshotPreferredPtys.add(ptyId) - } else if (providerSequence.generation === 'reset') { - this.providerSnapshotPreferredPtys.delete(ptyId) - } - - const headless = this.headlessTerminals.get(ptyId) - if (headless && !wasInitialized && providerSequence.generation === 'continued') { - // Why: daemon bytes can reach main just before spawn resolves. Queue the - // baseline behind those writes so their emulator sequence is rebased too. - headless.writeChain = headless.writeChain.then(() => { - headless.outputSequence = synchronizedSequence - }) - } - return synchronizedSequence - } - - subscribeToTerminalData( - ptyId: string, - listener: (data: string, meta?: RuntimeTerminalDataMeta) => void - ): () => void { - return addListenerToMap(this.dataListeners, ptyId, listener) - } - - setRemoteTerminalSourceRangeConsumerHooks( - hooks: RemoteTerminalSourceRangeConsumerHooks | null - ): void { - this.remoteTerminalSourceRangeConsumerHooks = hooks - } - - attachRemoteTerminalSourceRangeConsumer( - identity: RemoteTerminalSourceRangeStreamIdentity - ): boolean { - return this.remoteTerminalSourceRangeConsumerHooks?.attach(identity) ?? false - } - - settleRemoteTerminalSourceRanges( - identity: RemoteTerminalSourceRangeStreamIdentity, - ranges: readonly TerminalOutputSourceRange[] - ): void { - this.remoteTerminalSourceRangeConsumerHooks?.settle(identity, ranges) - } - - reserveRemoteTerminalSourceRangeReplacement( - identity: RemoteTerminalSourceRangeStreamIdentity, - requiredSeq: number, - reason: string - ): RemoteTerminalSourceRangeReplacementReservation | null { - return ( - this.remoteTerminalSourceRangeConsumerHooks?.reserveReplacement( - identity, - requiredSeq, - reason - ) ?? null - ) - } - - commitRemoteTerminalSourceRangeReplacement( - reservation: RemoteTerminalSourceRangeReplacementReservation, - publication: RemoteTerminalSourceRangeReplacementPublication - ): boolean { - return ( - this.remoteTerminalSourceRangeConsumerHooks?.commitReplacement(reservation, publication) ?? - false - ) - } - - rollbackRemoteTerminalSourceRangeReplacement( - reservation: RemoteTerminalSourceRangeReplacementReservation, - reason: string - ): boolean { - return ( - this.remoteTerminalSourceRangeConsumerHooks?.rollbackReplacement(reservation, reason) ?? false - ) - } - - cancelRemoteTerminalSourceRanges( - identity: RemoteTerminalSourceRangeStreamIdentity, - ranges: readonly TerminalOutputSourceRange[], - reason: string - ): void { - this.remoteTerminalSourceRangeConsumerHooks?.cancel(identity, ranges, reason) - } - - /** Set by pty IPC: fires when a PTY gains/loses remote view subscribers so - * the daemon background mark (keep-tail stream thinning) can resync — a - * live mobile/web view consumes raw bytes and must never be thinned, even - * while the desktop pane is hidden. */ - onRemoteTerminalViewPresenceChanged: ((ptyId: string) => void) | null = null - - private notifyRemoteTerminalViewPresenceChanged(ptyId: string): void { - try { - this.onRemoteTerminalViewPresenceChanged?.(ptyId) - } catch (err) { - console.error('[runtime] remote view presence listener threw', { ptyId, err }) - } - } - - /** Registered by terminal-RPC subscribe/multiplex streams: while a remote - * view subscriber is attached its xterm answers queries with view - * authority and the model responder must stay silent. Returns an - * idempotent release. */ - registerRemoteTerminalViewSubscriber(ptyId: string): () => void { - this.remoteTerminalViewSubscriberCounts.set( - ptyId, - (this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) + 1 - ) - this.ensureSubscriberDrivenProviderAttach(ptyId) - this.notifyRemoteTerminalViewPresenceChanged(ptyId) - let released = false - return () => { - if (released) { - return - } - released = true - const next = (this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 1) - 1 - if (next <= 0) { - this.remoteTerminalViewSubscriberCounts.delete(ptyId) - } else { - this.remoteTerminalViewSubscriberCounts.set(ptyId, next) - } - this.notifyRemoteTerminalViewPresenceChanged(ptyId) - } - } - - /** A local daemon session main knows is live but has never ingested a byte - * from — i.e. no pane ever attached it, so the daemon is not emitting. - * Headless state exists only after the first ingested byte; a snapshot - * reconcile in flight implies a spawn-path attach already happened. */ - private isKnownUnattachedLocalDaemonPty(ptyId: string): boolean { - if (this.headlessTerminals.has(ptyId) || this.providerSnapshotPreferredPtys.has(ptyId)) { - return false - } - // A spawn published (or admission pending) this generation already - // attaches the provider stream; a replacement under a reused id must not - // read as the discovered never-attached session it replaced. - if (this.spawnPublishedPtys.has(ptyId) || this.pendingPtyRegistrationIncarnations.has(ptyId)) { - return false - } - // SSH panes have their own lease/reattach machinery. - if (parseAppSshPtyId(ptyId)) { - return false - } - const pty = this.ptysById.get(ptyId) - return pty !== undefined && pty.connectionId === null && pty.connected - } - - /** First remote view subscriber of a never-attached local daemon session: - * have main attach so output starts flowing. Attach-only (never spawns), - * no resize, no renderer mount/focus — works headless. Releases never - * detach: continued ingestion is the point, and daemon detach is stubbed. */ - private ensureSubscriberDrivenProviderAttach(ptyId: string): void { - const controller = this.ptyController - if ( - !controller?.attach || - this.subscriberDrivenProviderAttachesByPtyId.has(ptyId) || - !this.isKnownUnattachedLocalDaemonPty(ptyId) - ) { - return - } - const attach = controller.attach - // Async wrapper: a synchronous controller throw must not break subscribe. - const attempt = (async () => attach(ptyId))().catch(() => false) - this.subscriberDrivenProviderAttachesByPtyId.set(ptyId, attempt) - void attempt.then((attached) => { - // Why: an unprovable session must not be pinned as attached; a later - // subscriber may retry once the daemon can prove it. - if (!attached && this.subscriberDrivenProviderAttachesByPtyId.get(ptyId) === attempt) { - this.subscriberDrivenProviderAttachesByPtyId.delete(ptyId) - } - }) - } - - private reconcileSubscriberDrivenProviderAttach(ptyId: string): void { - if (!this.hasRemoteTerminalViewSubscriber(ptyId)) { - return - } - const pending = this.subscriberDrivenProviderAttachesByPtyId.get(ptyId) - if (!pending) { - this.ensureSubscriberDrivenProviderAttach(ptyId) - return - } - if (this.subscriberDrivenProviderAttachInventoryWaiters.has(ptyId)) { - return - } - this.subscriberDrivenProviderAttachInventoryWaiters.add(ptyId) - void pending.then((attached) => { - this.subscriberDrivenProviderAttachInventoryWaiters.delete(ptyId) - if (attached || !this.hasRemoteTerminalViewSubscriber(ptyId)) { - return - } - if (this.subscriberDrivenProviderAttachesByPtyId.get(ptyId) === pending) { - this.subscriberDrivenProviderAttachesByPtyId.delete(ptyId) - } - this.ensureSubscriberDrivenProviderAttach(ptyId) - }) - } - - /** Mark a raw-output viewer without transferring terminal query authority. */ - registerRawTerminalViewSubscriber(ptyId: string): () => void { - this.rawTerminalViewSubscriberCounts.set( - ptyId, - (this.rawTerminalViewSubscriberCounts.get(ptyId) ?? 0) + 1 - ) - this.notifyRemoteTerminalViewPresenceChanged(ptyId) - let released = false - return () => { - if (released) { - return - } - released = true - const next = (this.rawTerminalViewSubscriberCounts.get(ptyId) ?? 1) - 1 - if (next <= 0) { - this.rawTerminalViewSubscriberCounts.delete(ptyId) - } else { - this.rawTerminalViewSubscriberCounts.set(ptyId, next) - } - this.notifyRemoteTerminalViewPresenceChanged(ptyId) - } - } - - /** Raw stream presence prevents provider thinning without changing reply ownership. */ - hasRawTerminalViewSubscriber(ptyId: string): boolean { - return ( - (this.rawTerminalViewSubscriberCounts.get(ptyId) ?? 0) > 0 || - this.hasRemoteTerminalViewSubscriber(ptyId) - ) - } - - hasRemoteTerminalViewSubscriber(ptyId: string): boolean { - if ((this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) > 0) { - return true - } - return (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0 - } - - isMobileTerminalQueryReplyAuthority(ptyId: string, clientId: string): boolean { - // Why: a passive phone watching desktop-sized output must not race the - // desktop xterm. Mobile becomes reply authority only with the mobile floor. - if (this.getDriver(ptyId).kind !== 'mobile') { - return false - } - const subscribers = this.mobileSubscribers.get(ptyId) - if (!subscribers) { - return false - } - // Why: soft-leave resubscribe preserves the original subscription time but - // reinserts the record. Elect fitted responders from that stable age, not - // mutable Map order or passive desktop-mode watchers. - let earliest: { clientId: string; subscribedAt: number } | null = null - for (const subscriber of subscribers.values()) { - if (!subscriber.wasResizedToPhone) { - continue - } - if (earliest === null || subscriber.subscribedAt < earliest.subscribedAt) { - earliest = subscriber - } - } - return earliest?.clientId === clientId - } - - subscribeToFitOverrideChanges( - ptyId: string, - listener: (event: { - mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' - cols: number - rows: number - }) => void - ): () => void { - return addListenerToMap(this.fitOverrideListeners, ptyId, listener) - } - - subscribeToDriverChanges(ptyId: string, listener: (driver: DriverState) => void): () => void { - return addListenerToMap(this.driverListeners, ptyId, listener) - } - - private notifyFitOverrideListeners( - ptyId: string, - mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit', - cols: number, - rows: number - ): void { - const listeners = this.fitOverrideListeners.get(ptyId) - if (!listeners) { - return - } - notifyRuntimeListeners(listeners, (listener) => listener({ mode, cols, rows }), 'fit-override') - } - - serializeTerminalBuffer( - ptyId: string, - opts: { scrollbackRows?: number } = {} - ): Promise { - return this.serializeTerminalBufferFromAvailableState(ptyId, opts) - } - - async serializeAuthoritativeTerminalBuffer( - ptyId: string, - opts: { scrollbackRows?: number } = {} - ): Promise { - const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts, { - timeoutMs: AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS, - retireOnTimeout: true - }) - if (providerSnapshot) { - return providerSnapshot - } - return this.serializeTerminalBufferFromAvailableState(ptyId, opts) - } - - /** Raw keystroke pass-through for the pop-out dashboard's terminal preview. - * Honors the mobile-presence lock like the main window's pty:write path. */ - async writeTerminalPreviewInput(ptyId: string, data: string): Promise { - if (data.length === 0 || this.getDriver(ptyId).kind === 'mobile') { - return false - } - try { - await assertTerminalInputWithinLimitWithYield(data) - const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId) - await this.writeTerminalInputChunks( - ptyId, - data, - { - // Why: a phone can claim the floor while a paste yields between chunks. - beforeWrite: () => { - if (this.getDriver(ptyId).kind === 'mobile') { - throw new Error('terminal_mobile_driver_active') - } - } - }, - admitted - ) - return true - } catch { - return false - } - } - - hasHeadlessTerminalState(ptyId: string): boolean { - return this.headlessTerminals.has(ptyId) - } - - serializeMainTerminalBuffer( - ptyId: string, - opts: { scrollbackRows?: number } = {} - ): Promise<{ - data: string - frameRestoreAnsi?: string - cols: number - rows: number - seq?: number - cwd?: string | null - lastTitle?: string - source?: 'headless' | 'renderer' - oscLinks?: TerminalOscLinkRange[] - alternateScreen?: boolean - scrollbackAnsi?: string - terminalOwner?: 'shell' - } | null> { - return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true }) - } - - async serializeHiddenOutputRecoveryBuffer( - ptyId: string, - opts: { scrollbackRows?: number } = {} - ): Promise<{ - data: string - frameRestoreAnsi?: string - cols: number - rows: number - cwd?: string | null - lastTitle?: string - seq?: number - source?: 'headless' | 'renderer' - oscLinks?: TerminalOscLinkRange[] - alternateScreen?: boolean - scrollbackAnsi?: string - pendingEscapeTailAnsi?: string - terminalOwner?: 'shell' - } | null> { - const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, { - ...opts, - includeEmpty: true - }) - if (headlessSnapshot) { - return headlessSnapshot - } - // Why: hidden-output recovery is initiated by the desktop renderer. If the - // runtime has not built headless state yet, the mounted xterm is still the - // best available state and avoids a false "snapshot unavailable" result. - const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts) - return rendererSnapshot ?? this.serializeProviderTerminalBuffer(ptyId, opts) - } - - async clearTerminalBuffer(handle: string): Promise<{ handle: string; cleared: boolean }> { - const leaf = this.resolveLeafForHandle(handle) - if (!leaf?.ptyId) { - throw new Error('terminal_not_found') - } - // Why: clear is a terminal UI action (Cmd+K on desktop), not shell input. - // Route through the controller so renderer-owned xterm buffers, daemon - // sessions, and SSH relay sessions all drop scrollback before the next - // mobile snapshot. - await this.ptyController?.clearBuffer?.(leaf.ptyId) - await this.clearHeadlessTerminalBuffer(leaf.ptyId) - return { handle, cleared: true } - } - - getTerminalSize(ptyId: string): { cols: number; rows: number } | null { - return this.ptyController?.getSize?.(ptyId) ?? null - } - - // Why: a width reflow on a normal-buffer PTY must re-stream the full - // scrollback to mobile so it rewraps at the new cols, but alternate-screen - // TUIs (vim, Claude Code) own their repaint and have no scrollback — for - // those the mobile client just resizes xterm geometry and consumes the - // TUI's own redraw, so the resize re-stream must be skipped. Provider state - // covers restored PTYs whose main-side emulator is only a partial suffix. - isTerminalAlternateScreen(ptyId: string): boolean { - if (this.providerSnapshotPreferredPtys.has(ptyId)) { - return this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ?? false - } - return ( - this.headlessTerminals.get(ptyId)?.emulator.isAlternateScreen ?? - this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ?? - false - ) - } - - // Why: daemon-backed PTYs that the runtime adopted after an Orca relaunch - // start with a fresh headless emulator that has zero scrollback, even though - // the daemon's on-disk checkpoint and the desktop xterm both contain the - // full prior history. Without this hydration, mobile subscribers see only - // the bare current prompt because serializeHeadlessTerminalBuffer always - // wins over the renderer-path fallback. Seeding the emulator with the - // adapter's snapshot/cold-restore data makes mobile and desktop agree on - // what scrollback is available. - seedHeadlessTerminal( - ptyId: string, - data: string, - size?: { cols: number; rows: number }, - metadata: HeadlessSeedMetadata = {} - ): void { - if (!data) { - return - } - const existing = this.headlessTerminals.get(ptyId) - if (existing) { - // Why: emulator already has live data — re-seeding would duplicate - // every byte. The seed is only valid when the emulator is fresh. - if (metadata.preferProviderIfExisting) { - this.providerSnapshotPreferredPtys.add(ptyId) - } - return - } - const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } - const state = this.createPtyHeadlessTerminalState(ptyId, dims) - state.outputSequence = this.getPtyOutputSequence(ptyId) - this.headlessTerminals.set(ptyId, state) - this.recordOsc7MetadataForPty(ptyId, data) - this.recordRecentPtyOutputForPathProvenance(ptyId, data) - state.writeChain = state.writeChain - .then(async () => { - // Why: seed writes never set forwardQueryReplies — the main-side - // replay guard. A snapshot containing old queries must answer no one. - await state.emulator.write(data) - // Why AFTER the seed write: the snapshot payload cannot carry kitty - // pushes (rehydrateSequences deliberately omits them), but ordering - // behind it keeps the parse deterministic. Unflagged like the seed — - // re-applying flags must answer no one. - if (typeof metadata.kittyKeyboardFlags === 'number') { - await state.emulator.applyKittyKeyboardFlags(metadata.kittyKeyboardFlags) - } - if (metadata.cwd !== undefined) { - state.emulator.setCwd(metadata.cwd) - } - if (metadata.oscLinks !== undefined) { - state.emulator.setRestoredOscLinks(metadata.oscLinks) - } - // Why derived from the emulator: the seed bytes bypass ownership.scan, - // so the scanner must inherit the restored alternate-screen state or a - // pane seeded mid-TUI never arms its recovery trigger. - state.ownership.seedOwner(metadata.terminalOwner, { - alternateScreen: state.emulator.isAlternateScreen - }) - this.providerSnapshotPreferredPtys.delete(ptyId) - }) - .catch(() => { - // Seeding is best-effort; live data will continue to populate the - // emulator even if the snapshot replay fails. - }) - } - - // Why: reattach/cold-restore/replay payloads arrive as spawn RPC results and - // never pass through onPtyData, so after a relaunch the records backing - // `terminal list`/`terminal read` stayed blank while the session was alive. - // Seed semantics (applySeededAgentStatus precedent): write state only — no - // waiters, no orchestration events, and no lastOutputAt, because restored - // bytes are historical output, not fresh activity. - seedTerminalRestoreTail(ptyId: string, restore: { text?: string; lastTitle?: string }): void { - const seed = restore.text ? buildRestoredTerminalTailSeed(restore.text) : null - if (seed) { - const pty = this.getOrCreatePtyWorktreeRecord(ptyId) - // Why: live bytes outrank the seed — only never-written records take it, - // so a same-run remount reattach cannot re-apply history it already has. - if (pty && restoredTerminalTailSeedAllowed(pty)) { - applyRestoredTerminalTailSeed(pty, seed) - this.primeWaitBlockedBaselineFromSeededTail(ptyId) - } - for (const leaf of this.getLeavesForPty(ptyId)) { - if (restoredTerminalTailSeedAllowed(leaf)) { - applyRestoredTerminalTailSeed(leaf, seed) - } - } - } - if (restore.lastTitle) { - // Why: mirror renderer hydration — a title main already tracked live outranks the payload's persisted one. - this.applySeededAgentStatus(ptyId, this.getTrackedRawTitleForPty(ptyId) ?? restore.lastTitle) - } - } - - // Why: hydrate the runtime headless emulator from the desktop renderer's - // xterm buffer on the first onPtyData byte after a PTY is taken over by a - // pane. Eager-state pattern matches seedHeadlessTerminal: headlessTerminals - // is populated synchronously so concurrent live writes from - // trackHeadlessTerminalData chain after the seed via the same writeChain. - // See docs/mobile-prefer-renderer-scrollback.md. - private maybeHydrateHeadlessFromRenderer(ptyId: string): void { - if (this.headlessHydrationState.has(ptyId)) { - return - } - const providerSnapshotPreferred = this.providerSnapshotPreferredPtys.has(ptyId) - if (this.headlessTerminals.has(ptyId) && !providerSnapshotPreferred) { - // Daemon-snapshot seed already populated the emulator — skip hydration. - this.headlessHydrationState.set(ptyId, 'done') - return - } - const controller = this.ptyController - if (!controller?.serializeBuffer || !controller.hasRendererSerializer) { - return - } - if (!controller.hasRendererSerializer(ptyId)) { - // Renderer hasn't registered yet (or never will). Live writes lazy- - // create the state via trackHeadlessTerminalData on this same tick. - return - } - - if (providerSnapshotPreferred) { - // Why: a stream byte can create a partial model before restored history - // arrives. A mounted renderer snapshot can safely replace that model. - this.disposeHeadlessTerminal(ptyId) - } - - this.headlessHydrationState.set(ptyId, 'pending') - const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } - // Why: hydration writes below never set forwardQueryReplies (main-side - // replay guard) — renderer-buffer snapshots can embed stale queries. - const state = this.createPtyHeadlessTerminalState(ptyId, dims) - state.outputSequence = this.getPtyOutputSequence(ptyId) - this.headlessTerminals.set(ptyId, state) - - // Why: append the seed work to writeChain so live writes queued by - // trackHeadlessTerminalData (after this method returns synchronously) - // execute AFTER the seed-write resolves. If we awaited inline before - // setting headlessTerminals, the live byte would lazy-create a separate - // state and the seed-resolve would overwrite it, dropping live bytes. - state.writeChain = state.writeChain.then(async () => { - try { - const rendered = await controller.serializeBuffer!(ptyId, { - scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, - altScreenForcesZeroRows: true - }) - if (!rendered || rendered.data.length === 0) { - return - } - this.recordOsc7MetadataForPty(ptyId, rendered.data) - this.recordRecentPtyOutputForPathProvenance(ptyId, rendered.data) - // Resize to renderer's dims so the seed reflows correctly into the - // emulator's grid, then resize back to PTY dims (if known) so live - // writes use the correct cell layout. - if (rendered.cols !== dims.cols || rendered.rows !== dims.rows) { - state.emulator.resize(rendered.cols, rendered.rows) - } - await state.emulator.write(rendered.data) - const ptyDims = this.getTerminalSize(ptyId) - if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) { - state.emulator.resize(ptyDims.cols, ptyDims.rows) - } - // Why: the renderer xterm no longer sees synthetic hook title frames - // (they feed main's tracker only), so its serializer lastTitle can be - // stale here. Prefer main's tracked title; the renderer's is only the - // seed when main has observed none (fresh relaunch, cold tracker). - state.ownership.seedOwner(undefined, { - alternateScreen: state.emulator.isAlternateScreen - }) - const seedTitle = this.getTrackedRawTitleForPty(ptyId) ?? rendered.lastTitle - if (seedTitle) { - state.emulator.setLastTitle(seedTitle) - this.applySeededAgentStatus(ptyId, seedTitle) - } - this.providerSnapshotPreferredPtys.delete(ptyId) - } catch { - // Hydration is best-effort. Live writes continue via the same - // writeChain that this catch-arm leaves intact. - } finally { - this.headlessHydrationState.set(ptyId, 'done') - } - }) - } - - // Why: seed-derived agent status reflects historical state. Orchestration - // waiters (resolveTuiIdleWaiters, deliverPendingMessages) must only react - // to LIVE transitions, so this helper writes leaf.lastAgentStatus only, - // leaves lastAgentStatusObservedLive untouched, and never resolves waiters. - // detectAgentStatusFromTitle wrap mirrors the live path so seeded and live - // values are the same union member, keeping downstream `=== 'idle'` checks - // correct. - private applySeededAgentStatus(ptyId: string, title: string): void { - if (!title) { - return - } - // Why: a relaunched main starts its per-PTY title tracker cold — without - // this seed it misses the parked working→idle completion and never arms - // the stale-title timer for a persisted 'working' title. Seeding no-ops - // once a live title was observed, so live state always wins. - this.getOrCreatePtyTitleTrackerEntry(ptyId).tracker.seedInitialTitle(title) - const status = detectAgentStatusFromTitle(title) - // Why: live observations store normalized titles, so seeds must match — - // otherwise the first live frame after hydration compares unequal and - // touches session tabs once for no visible change. - const seededTitle = normalizeTerminalTitle(title) - const pty = this.ptysById.get(ptyId) - if (pty) { - const observedAt = this.nextTitleObservationSequence() - pty.lastOscTitle = seededTitle - pty.lastOscTitleAt = observedAt - this.setPtyManagementTitleFromObservedTitle(pty, seededTitle, observedAt) - } - for (const leaf of this.getLeavesForPty(ptyId)) { - // Why: seed lastOscTitle even when the seeded title doesn't classify - // as an agent state, so worktree.ps recomputes status from the live - // title rather than treating the leaf as agentless. - leaf.lastOscTitle = seededTitle - leaf.lastOscTitleAt = this.nextTitleObservationSequence() - if (status !== null) { - leaf.lastAgentStatus = status - } - } - } - - /** Per-chunk reply-ownership capture (Phase 5). Evaluated synchronously at - * ingestion only — never re-read at reply time. */ - private shouldAnswerQueriesForLiveChunk(ptyId: string): boolean { - return shouldModelAnswerHiddenPtyQueries({ - ptyId, - settings: this.store?.getSettings(), - hasRemoteViewSubscriber: this.hasRemoteTerminalViewSubscriber(ptyId) - }) - } - - private trackHeadlessTerminalData( - ptyId: string, - data: string, - outputSequence: number, - forwardQueryReplies = false - ): Promise { - const state = this.getOrCreateHeadlessTerminal(ptyId) - const completion = state.writeChain.then(async () => { - // Why: the ingestion-time ownership decision is closed over this - // chain link; async scheduling cannot retroactively change it. - // Why inside the chain: the ownership mirror must observe live bytes in - // the same total order as seeds (seedOwner also runs on this chain). - state.ownership.scan(data) - await state.emulator.write(data, { forwardQueryReplies }) - state.outputSequence = outputSequence - }) - // Legacy callers remain best-effort; bounded SSH admission observes the raw receipt. - state.writeChain = completion.catch(() => {}) - return completion - } - - /** Shared factory for the per-PTY runtime emulators (seed, hydration, and - * lazy live-byte creation): wires the Phase-5 query-reply sink and the - * ConPTY DA1 override. The daemon emulator never goes through here. */ - private createPtyHeadlessTerminalState( - ptyId: string, - dims: { cols: number; rows: number } - ): RuntimeHeadlessTerminal { - let state: RuntimeHeadlessTerminal | null = null - const pathFlavor = this.pathFlavorForPty(this.ptysById.get(ptyId)) - const emulator = new HeadlessEmulator({ - cols: dims.cols, - rows: dims.rows, - pathFlavor, - remotePosixFileUriAuthority: - !!this.ptysById.get(ptyId)?.connectionId && pathFlavor !== 'win32', - wslDistro: this.ptysById.get(ptyId)?.connectionId - ? undefined - : (this.wslDistroByPtyId.get(ptyId) ?? this.ptysById.get(ptyId)?.wslDistro ?? undefined), - // Why: replies take the provider input path (same entry as pty:write — - // daemon shell-ready gating and the SSH relay write apply unchanged), - // NOT writePtyInput, so renderer interactive-output metering never - // counts responder traffic as user-input echo. - onQueryReply: (reply) => { - // Why the identity check: queued writeChain links can parse after - // disposeHeadlessTerminal, and daemon respawns reuse session ids — a - // stale link's reply must never reach a successor PTY under this id. - if (state !== null && this.headlessTerminals.get(ptyId) === state) { - if ( - !shouldForwardHeadlessTerminalQueryReply(this.ptysById.get(ptyId)?.launchAgent, reply) - ) { - return - } - // Why this write is safe pre-shell-ready: daemon Session.write - // QUEUES (never drops) input while the POSIX shell-ready gate is - // pending and flushes at the ready marker or the 15s - // SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query - // reply is delayed at most that bound, not lost. - this.ptyController?.write(ptyId, reply) - } - } - }) - if (isNativeWindowsConptyPty(ptyId)) { - emulator.installConptyPrimaryDeviceAttributesOverride() - } - // Why the lazy getter: replies must use the freshest renderer push at - // parse time, and stay silent (never default) before the first push. - emulator.installViewAttributeResponder(() => getTerminalViewAttributes()) - const viewAttributes = getTerminalViewAttributes() - if (viewAttributes) { - emulator.applyPushedViewAttributes(viewAttributes) - } - const constructed: RuntimeHeadlessTerminal = { - emulator, - outputSequence: 0, - writeChain: Promise.resolve(), - ownership: new PtyShellOwnershipMirror(async () => { - const controller = this.ptyController - const lifecycleGeneration = this.getPtyLifecycleGeneration(ptyId) - if ( - !controller?.confirmShellForeground || - this.headlessTerminals.get(ptyId) !== constructed - ) { - return false - } - const confirmed = await controller.confirmShellForeground(ptyId) - return ( - confirmed && - this.headlessTerminals.get(ptyId) === constructed && - this.getPtyLifecycleGeneration(ptyId) === lifecycleGeneration - ) - }) - } - state = constructed - return state - } - - /** Phase-5 ConPTY DA1 retrofit (terminal-query-authority.md): invoked via - * markNativeWindowsConptyPty when the spawn mark lands after daemon stream - * data already created this PTY's emulator. Idempotent emulator-side. */ - private ensureNativeWindowsConptyDa1Override(ptyId: string): void { - if (isNativeWindowsConptyPty(ptyId)) { - this.headlessTerminals.get(ptyId)?.emulator.installConptyPrimaryDeviceAttributesOverride() - } - } - - private getOrCreateHeadlessTerminal(ptyId: string): RuntimeHeadlessTerminal { - const existing = this.headlessTerminals.get(ptyId) - if (existing) { - return existing - } - const size = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } - const state = this.createPtyHeadlessTerminalState(ptyId, size) - this.headlessTerminals.set(ptyId, state) - return state - } - - private replaceHeadlessTerminalAfterExecutionContextChange(ptyId: string): void { - this.disposeHeadlessTerminal(ptyId) - this.providerSnapshotPreferredPtys.add(ptyId) - const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } - const state = this.createPtyHeadlessTerminalState(ptyId, dims) - this.headlessTerminals.set(ptyId, state) - state.writeChain = state.writeChain - .then(async () => { - const snapshot = await this.serializeProviderTerminalBuffer(ptyId) - if (!snapshot) { - return - } - const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` - // Why: a newer live OSC 7 can arrive while the snapshot is in flight; - // only seed metadata while no post-correction CWD has won the race. - if (!this.terminalCwdByPtyId.has(ptyId)) { - this.recordOsc7MetadataForPty(ptyId, data) - } - await state.emulator.write(data) - if (snapshot.cwd !== undefined) { - state.emulator.setCwd(snapshot.cwd) - if (!this.terminalCwdByPtyId.has(ptyId) && snapshot.cwd?.trim()) { - this.terminalCwdByPtyId.set(ptyId, snapshot.cwd) - } - } - if (snapshot.oscLinks !== undefined) { - state.emulator.setRestoredOscLinks(snapshot.oscLinks) - } - state.ownership.seedOwner(snapshot.terminalOwner, { - alternateScreen: state.emulator.isAlternateScreen - }) - state.outputSequence = snapshot.seq - }) - .catch(() => { - // Best-effort: live bytes already chain behind this replacement state. - }) - .finally(() => { - this.providerSnapshotPreferredPtys.delete(ptyId) - }) - } - - private resizeHeadlessTerminal(ptyId: string, cols: number, rows: number): void { - const state = this.headlessTerminals.get(ptyId) - if (!state) { - return - } - // Why: terminal reflow is a parser operation. It must sit in the same - // per-PTY stream as output bytes or restore snapshots can bake in wraps - // from the wrong terminal width. - state.writeChain = state.writeChain - .then(() => { - state.emulator.resize(cols, rows) - }) - .catch(() => { - // Best-effort mirror tracking; live PTY streaming must continue even - // if xterm rejects a raced resize during teardown. - }) - } - - // Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile - // mirror or a resubscribing mobile client resurrects the cleared scrollback. - async clearHeadlessTerminalBuffer(ptyId: string): Promise { - const state = this.headlessTerminals.get(ptyId) - if (!state) { - return - } - // Why: headless writes are queued to preserve xterm parser order. Clear - // must join that same chain or an earlier PTY chunk can finish after the - // clear request and repopulate mobile scrollback. - state.writeChain = state.writeChain.then(() => state.emulator.clearScrollback()) - await state.writeChain - } - - private async serializeTerminalBufferFromAvailableState( - ptyId: string, - opts: { scrollbackRows?: number } = {} - ): Promise<{ - data: string - frameRestoreAnsi?: string - cols: number - rows: number - cwd?: string | null - lastTitle?: string - seq?: number - source?: 'headless' | 'renderer' - oscLinks?: TerminalOscLinkRange[] - alternateScreen?: boolean - pendingEscapeTailAnsi?: string - kittyKeyboardFlags?: number - terminalOwner?: 'shell' - } | null> { - if (this.providerSnapshotPreferredPtys.has(ptyId)) { - // Why: pre-attach stream bytes only form a suffix of restored state. A - // sequenced provider snapshot safely reconciles live bytes; renderer is - // the fallback when an older provider cannot expose that boundary. - const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts) - if (providerSnapshot) { - return providerSnapshot - } - const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts) - if (rendererSnapshot) { - return rendererSnapshot - } - } - const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts) - if (headlessSnapshot) { - return headlessSnapshot - } - - const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts) - if (!rendererSnapshot) { - return this.serializeProviderTerminalBuffer(ptyId, opts) - } - if (rendererSnapshot.data.length > 0) { - return rendererSnapshot - } - // Why: parked desktop panes register serializers before their xterm has - // hydrated. Treat that empty shell as provisional so retained provider - // history can restore mobile without forcing the desktop pane to mount. - const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts) - return providerSnapshot && - (providerSnapshot.data.length > 0 || Boolean(providerSnapshot.scrollbackAnsi)) - ? providerSnapshot - : rendererSnapshot - } - - async serializeRendererTerminalBuffer( - ptyId: string, - opts: { scrollbackRows?: number } = {} - ): Promise<{ - data: string - frameRestoreAnsi?: string - cols: number - rows: number - seq?: number - cwd?: string | null - lastTitle?: string - source?: 'renderer' - oscLinks?: TerminalOscLinkRange[] - kittyKeyboardFlags?: number - } | null> { - if (this.ptyController?.hasRendererSerializer?.(ptyId) === false) { - return null - } - let rendererSnapshot: { - data: string - cols: number - rows: number - seq?: number - cwd?: string | null - lastTitle?: string - oscLinks?: TerminalOscLinkRange[] - kittyKeyboardFlags?: number - } | null = null - try { - // Why: recovery/read fallback wants visible alt-screen content (e.g. an - // active TUI), so altScreenForcesZeroRows is FALSE here. Hydration is - // the only path that suppresses alt-screen scrollback. - rendererSnapshot = await (this.ptyController?.serializeBuffer?.(ptyId, { - scrollbackRows: opts.scrollbackRows, - altScreenForcesZeroRows: false - }) ?? Promise.resolve(null)) - } catch { - // Why: terminal snapshots should not depend on a mounted renderer pane. - // If renderer serialization races reload/unmount, callers can still use - // their existing null fallback paths. - } - return rendererSnapshot - ? this.preferTrackedLastTitle(ptyId, { - ...rendererSnapshot, - cwd: rendererSnapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), - source: 'renderer' as const - }) - : null - } - - private async serializeProviderTerminalBuffer( - ptyId: string, - opts: { scrollbackRows?: number } = {}, - wait: { timeoutMs?: number; retireOnTimeout?: boolean } = {} - ): Promise { - const generation = this.getPtyLifecycleGeneration(ptyId) - const scrollbackRows = Math.max(0, Math.floor(opts.scrollbackRows ?? 0)) - let acquisition = this.providerBufferAcquisitionsByPtyId.get(ptyId) - // Why before the re-acquire branch: an unresponsive provider is a property of - // the process, not of the row count one caller asked for. Checking retirement - // only after re-acquiring let a wider request replace the retired entry and - // hang again; the hung call's own settle still clears it and allows recovery. - if (acquisition?.generation === generation && acquisition.timedOut) { - return null - } - if ( - !acquisition || - acquisition.generation !== generation || - acquisition.scrollbackRows < scrollbackRows - ) { - const promise = this.captureProviderTerminalBuffer(ptyId, opts, generation) - acquisition = { generation, scrollbackRows, promise, timedOut: false } - this.providerBufferAcquisitionsByPtyId.set(ptyId, acquisition) - void promise.finally(() => { - if (this.providerBufferAcquisitionsByPtyId.get(ptyId) === acquisition) { - this.providerBufferAcquisitionsByPtyId.delete(ptyId) - } - }) - } - if (acquisition.timedOut) { - return null - } - if (typeof wait.timeoutMs !== 'number') { - return acquisition.promise - } - const result = await withTimeout< - { settled: true; value: PtyProviderBufferSnapshot | null } | { settled: false } - >( - acquisition.promise.then((value) => ({ settled: true as const, value })), - wait.timeoutMs, - { settled: false as const } - ) - if (!result.settled) { - if (wait.retireOnTimeout) { - acquisition.timedOut = true - } - return null - } - return result.value - } - - private async captureProviderTerminalBuffer( - ptyId: string, - opts: { scrollbackRows?: number }, - generation: number - ): Promise { - const liveModeTracker = new TerminalKittyKeyboardModeTracker() - let liveModeTrackers = this.providerModeSnapshotScansByPtyId.get(ptyId) - if (!liveModeTrackers) { - liveModeTrackers = new Set() - this.providerModeSnapshotScansByPtyId.set(ptyId, liveModeTrackers) - } - liveModeTrackers.add(liveModeTracker) - try { - // Why: daemon PTYs survive an app relaunch before any renderer mounts. - // Mobile still needs their retained history without navigating desktop. - const snapshot = await this.ptyController?.serializeProviderBuffer?.(ptyId, opts) - if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) { - return null - } - const snapshotModeTracker = new TerminalKittyKeyboardModeTracker() - if (typeof snapshot.alternateScreen === 'boolean') { - snapshotModeTracker.scan(snapshot.alternateScreen ? '\x1b[?1049h' : '\x1b[?1049l') - } else { - // Why: older providers omit mode metadata, but their ANSI snapshot - // still carries the DECSET/DECRST needed to classify the active screen. - snapshotModeTracker.scanReplay(snapshot.data) - } - const observedSnapshotMode = snapshotModeTracker.hasObservedAlternateScreenSwitch - let effectiveAlternateScreen: boolean | undefined - if (observedSnapshotMode || liveModeTracker.hasObservedAlternateScreenSwitch) { - const modeTracker = new TerminalKittyKeyboardModeTracker() - if (observedSnapshotMode) { - modeTracker.scan(snapshotModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l') - } - // Why: stream bytes received after the request began can be newer - // than snapshot metadata, so an observed live transition wins. - if (liveModeTracker.hasObservedAlternateScreenSwitch) { - modeTracker.scan(liveModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l') - } - this.providerModeTrackersByPtyId.set(ptyId, modeTracker) - effectiveAlternateScreen = modeTracker.isAlternateScreen - } - const providerOffset = this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0 - const reconciledSnapshot = this.preferTrackedLastTitle(ptyId, { - ...snapshot, - seq: providerOffset + snapshot.seq, - ...(effectiveAlternateScreen !== undefined - ? { alternateScreen: effectiveAlternateScreen } - : {}) - }) - if (liveModeTracker.hasObservedAlternateScreenSwitch) { - this.providerSnapshotsWithLiveModeTransition.add(reconciledSnapshot) - } - return reconciledSnapshot - } catch { - return null - } finally { - liveModeTrackers.delete(liveModeTracker) - if (liveModeTrackers.size === 0) { - this.providerModeSnapshotScansByPtyId.delete(ptyId) - } - } - } - - private async withVisibleSnapshotFallback( - ptyId: string, - read: RuntimeTerminalRead, - opts: { cursor?: number; limit?: number } = {}, - providerSnapshot: ProviderSnapshotReadOptions = {} - ): Promise { - if (typeof opts.cursor === 'number') { - return read - } - const blankFallback = shouldFallbackToVisibleTerminalSnapshot(read, opts) - const recoveredWorkerFallback = - read.tail.length === 0 && this.legacyWorkerRecoveredPtys.has(ptyId) - // Why: a live daemon session no pane ever attached has ingested zero bytes, - // so only the provider holds its screen. Unprovable state stays empty. - const neverAttachedProviderFallback = - read.tail.length === 0 && - !recoveredWorkerFallback && - this.isKnownUnattachedLocalDaemonPty(ptyId) - if (recoveredWorkerFallback || neverAttachedProviderFallback) { - const providerProjection = await this.readProviderTerminalTailLines( - ptyId, - opts.limit, - providerSnapshot - ) - if (providerProjection.lines.length > 0) { - return buildVisibleSnapshotReadFallback( - read, - providerProjection.lines, - opts.limit, - providerProjection.draft - ) - } - } - const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId) - const providerModeUnknown = - this.providerSnapshotPreferredPtys.has(ptyId) && !this.providerModeTrackersByPtyId.has(ptyId) - if ( - !blankFallback && - !recoveredWorkerFallback && - !providerModeUnknown && - !knownAlternateScreen && - !this.headlessTerminals.has(ptyId) - ) { - return read - } - const visibleState = await this.readVisibleTerminalState(ptyId) - if ( - !blankFallback && - !recoveredWorkerFallback && - !knownAlternateScreen && - !visibleState?.isAlternateScreen - ) { - return read - } - let projection: RuntimeTerminalProjection = visibleState ?? { lines: [] } - if (projection.lines.length === 0) { - projection = await this.readRendererVisibleSnapshotLines(ptyId) - } - if (projection.lines.length === 0) { - return read - } - return buildVisibleSnapshotReadFallback(read, projection.lines, opts.limit, projection.draft) - } - - private async readProviderTerminalTailLines( - ptyId: string, - limit: number | undefined, - snapshotOptions: ProviderSnapshotReadOptions = {} - ): Promise { - const generation = this.getPtyLifecycleGeneration(ptyId) - const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT) - const snapshot = await this.serializeProviderTerminalBuffer( - ptyId, - { scrollbackRows: snapshotOptions.visibleScreenOnly ? 0 : lineLimit }, - snapshotOptions - ) - if (!snapshot) { - return { lines: [] } - } - // Why: a cached acquisition can carry scrollback this caller did not ask for, - // so visible-only reads parse the grid itself rather than trusting the request. - if (snapshotOptions.visibleScreenOnly) { - const projection = await this.parseVisibleSnapshot(snapshot) - // Live bytes ordered after the provider frame make that frame stale. - return this.getPtyLifecycleGeneration(ptyId) === generation && - this.getPtyOutputSequence(ptyId) <= snapshot.seq - ? projection - : { lines: [] } - } - const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` - if (data.length === 0) { - return { lines: [] } - } - const emulator = new HeadlessEmulator({ - cols: snapshot.cols, - rows: snapshot.rows, - scrollback: lineLimit - }) - try { - await emulator.write(data) - const projection = projectTerminalTailLines(emulator, lineLimit) - return this.getPtyLifecycleGeneration(ptyId) === generation && - this.getPtyOutputSequence(ptyId) <= snapshot.seq - ? projection - : { lines: [] } - } finally { - emulator.dispose() - } - } - - private async visibleSnapshotPreview(ptyId: string, preview: string): Promise { - const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId) - const providerModeUnknown = - this.providerSnapshotPreferredPtys.has(ptyId) && !this.providerModeTrackersByPtyId.has(ptyId) - if (!providerModeUnknown && !knownAlternateScreen && !this.headlessTerminals.has(ptyId)) { - return preview - } - const visibleState = await this.readVisibleTerminalState(ptyId) - if (!knownAlternateScreen && !visibleState?.isAlternateScreen) { - return preview - } - let projection: RuntimeTerminalProjection = visibleState ?? { lines: [] } - if (projection.lines.length === 0) { - projection = await this.readRendererVisibleSnapshotLines(ptyId) - } - return projection.lines.length > 0 ? buildPreview(projection.lines, '') : preview - } - - private async readVisibleTerminalState( - ptyId: string - ): Promise { - if (!this.providerSnapshotPreferredPtys.has(ptyId)) { - return this.readHeadlessVisibleTerminalState(ptyId) - } - - const generation = this.getPtyLifecycleGeneration(ptyId) - const outputSequence = this.getPtyOutputSequence(ptyId) - const cached = this.providerVisibleStateByPtyId.get(ptyId) - const trackedMode = this.providerModeTrackersByPtyId.get(ptyId) - if ( - cached?.generation === generation && - outputSequence <= cached.sequence && - (!trackedMode || trackedMode.isAlternateScreen === cached.isAlternateScreen) - ) { - return cached - } - if (trackedMode && !trackedMode.isAlternateScreen) { - const headlessState = await this.readHeadlessVisibleTerminalState(ptyId) - return headlessState - ? { ...headlessState, isAlternateScreen: false } - : { - lines: [], - isAlternateScreen: false, - sequence: outputSequence, - generation - } - } - if ((this.providerVisibleRetryAtByPtyId.get(ptyId) ?? 0) > Date.now()) { - return null - } - - const snapshot = await this.serializeProviderTerminalBuffer( - ptyId, - { scrollbackRows: 0 }, - { timeoutMs: VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS } - ) - if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) { - this.providerVisibleRetryAtByPtyId.set(ptyId, Date.now() + VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS) - return null - } - this.providerVisibleRetryAtByPtyId.delete(ptyId) - if (this.providerSnapshotsWithLiveModeTransition.has(snapshot)) { - // Why: the provider frame can predate a mode switch observed while its - // RPC was pending; the ordered live emulator owns the post-switch grid. - const liveState = await this.readHeadlessVisibleTerminalState(ptyId) - if (liveState && liveState.isAlternateScreen === (snapshot.alternateScreen ?? false)) { - return liveState - } - } - const projection = await this.parseVisibleSnapshot(snapshot) - if ( - this.getPtyLifecycleGeneration(ptyId) !== generation || - this.getPtyOutputSequence(ptyId) > snapshot.seq - ) { - return null - } - const visibleState: RuntimeVisibleTerminalState = { - lines: projection.lines, - ...(projection.draft ? { draft: projection.draft } : {}), - isAlternateScreen: snapshot.alternateScreen ?? false, - sequence: snapshot.seq, - generation - } - this.providerVisibleStateByPtyId.set(ptyId, visibleState) - return visibleState - } - - private async readHeadlessVisibleTerminalState( - ptyId: string - ): Promise { - const state = this.headlessTerminals.get(ptyId) - if (!state) { - return null - } - const generation = this.getPtyLifecycleGeneration(ptyId) - await state.writeChain - if ( - this.headlessTerminals.get(ptyId) !== state || - this.getPtyLifecycleGeneration(ptyId) !== generation - ) { - return null - } - const projection = projectVisibleTerminalLines(state.emulator) - return { - lines: projection.lines, - ...(projection.draft ? { draft: projection.draft } : {}), - isAlternateScreen: state.emulator.isAlternateScreen, - sequence: state.outputSequence, - generation - } - } - - private async parseVisibleSnapshot(snapshot: { - data: string - cols: number - rows: number - }): Promise<{ lines: string[]; draft?: string }> { - if (snapshot.data.length === 0) { - return { lines: [] } - } - const emulator = new HeadlessEmulator({ - cols: snapshot.cols, - rows: snapshot.rows, - scrollback: 0 - }) - try { - await emulator.write(`\x1b[2J\x1b[3J\x1b[H${snapshot.data}`) - return projectVisibleTerminalLines(emulator) - } finally { - emulator.dispose() - } - } - - private async readRendererVisibleSnapshotLines( - ptyId: string - ): Promise { - const controller = this.ptyController - if (!controller?.serializeBuffer) { - return { lines: [] } - } - if (controller.hasRendererSerializer && !controller.hasRendererSerializer(ptyId)) { - return { lines: [] } - } - try { - // Why: raw PTY tails can be whitespace-only while a full-screen TUI is - // visibly nonblank in renderer xterm. Ask the renderer for the active - // screen instead of reusing the headless transcript path. - const snapshot = await withTimeout( - controller.serializeBuffer(ptyId, { - scrollbackRows: 0, - altScreenForcesZeroRows: false - }), - VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS, - null - ) - if (!snapshot || snapshot.data.length === 0) { - return { lines: [] } - } - return this.parseVisibleSnapshot(snapshot) - } catch { - return { lines: [] } - } - } - - private async serializeHeadlessTerminalBuffer( - ptyId: string, - opts: { scrollbackRows?: number; includeEmpty?: boolean } = {} - ): Promise<{ - data: string - cols: number - rows: number - cwd?: string | null - lastTitle?: string - seq?: number - source?: 'headless' - oscLinks?: TerminalOscLinkRange[] - alternateScreen?: boolean - scrollbackAnsi?: string - kittyKeyboardFlags?: number - terminalOwner?: 'shell' - // Why: dangling mid-escape tail the restorer must write LAST, after any - // reset, so the next live chunk completes it instead of rendering it - // literally (Bug E / #7329). - pendingEscapeTailAnsi?: string - } | null> { - const state = this.headlessTerminals.get(ptyId) - if (!state) { - return null - } - await state.writeChain - await state.ownership.settle() - // Why: normal history is separated from an active alternate frame, so the - // caller's scrollback policy can be honored without painting it into alt. - const scrollbackRows = opts.scrollbackRows ?? 0 - const snapshot = state.emulator.getSnapshot({ scrollbackRows }) - const terminalOwner = state.ownership.owner - const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi - return data.length > 0 || opts.includeEmpty === true - ? this.preferTrackedLastTitle(ptyId, { - data, - frameRestoreAnsi: snapshot.frameRestoreAnsi, - cols: snapshot.cols, - rows: snapshot.rows, - cwd: snapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), - lastTitle: snapshot.lastTitle, - seq: state.outputSequence, - source: 'headless' as const, - oscLinks: snapshot.oscLinks, - scrollbackAnsi: snapshot.scrollbackAnsi, - // Why beside outputSequence and never re-read later: the flags must - // describe the same stream position as the image, or replay would - // apply push/pop transitions twice or out of order. - ...(parseTerminalKittyKeyboardFlags(snapshot.modes?.kittyKeyboardFlags) !== undefined - ? { kittyKeyboardFlags: snapshot.modes.kittyKeyboardFlags } - : {}), - ...(snapshot.pendingEscapeTailAnsi - ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } - : {}), - ...(terminalOwner ? { terminalOwner } : {}), - // Why: lets the renderer skip the destructive scrollback clear when - // restoring an alt-screen snapshot — clearing wipes xterm's own - // history that the TUI relies on for scroll-up after a tab return. - alternateScreen: snapshot.modes?.alternateScreen ?? state.emulator.isAlternateScreen, - // Why NOT folded into data: the renderer writes its post-replay - // reset after data, and any ESC after a dangling partial aborts it. - // The restorer writes this last (Bug E fix). - pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi - }) - : null - } - - private disposeHeadlessTerminal(ptyId: string): void { - this.headlessHydrationState.delete(ptyId) - const state = this.headlessTerminals.get(ptyId) - if (!state) { - return - } - this.headlessTerminals.delete(ptyId) - // Why: queued chain links still parse below before the emulator disposes; - // sever the reply sink now so they cannot write to a respawned PTY that - // reused this id (belt to the sink's state-identity check). - state.emulator.disableQueryReplyForwarding() - state.ownership.dispose() - state.writeChain.finally(() => state.emulator.dispose()).catch(() => state.emulator.dispose()) - } - - resolveLeafForHandle(handle: string): { ptyId: string | null } | null { - const record = this.handles.get(handle) - if (!record) { - return null - } - if (record.tabId.startsWith('pty:')) { - return { ptyId: record.ptyId } - } - const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId)) - if (!leaf) { - return null - } - return { ptyId: leaf.ptyId } - } - - // Why: remote clients hold handles across transport reconnects. A handle - // minted for a concrete PTY must never silently adopt a different PTY that - // later occupies the same pane — that misroutes keystrokes (#7718). Handles - // still awaiting their first PTY (ptyId null) may adopt it, which preserves - // the mobile pre-spawn subscribe flow. - resolveLiveLeafForHandle(handle: string): { ptyId: string | null } | null { - const record = this.handles.get(handle) - if (!record) { - return null - } - if (record.tabId.startsWith('pty:')) { - return { ptyId: record.ptyId } - } - const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId)) - if (!leaf) { - return null - } - if ( - record.ptyId !== null && - (leaf.ptyId !== record.ptyId || leaf.ptyGeneration !== record.ptyGeneration) - ) { - throw new Error('terminal_handle_stale') - } - return { ptyId: leaf.ptyId } - } - - getOrchestrationCompatibilityHostId(): 'local' { - return 'local' - } - - registerOrchestrationCompatibilitySshAttachment( - targetId: string, - connectionIncarnation: string - ): OrchestrationCompatibilitySshAttachmentAuthority { - const authority = Object.freeze({ - kind: 'ssh' as const, - targetId, - connectionIncarnation, - attachmentId: randomUUID() - }) - this.orchestrationCompatibilitySshAttachments.set(authority.attachmentId, authority) - return authority - } - - releaseOrchestrationCompatibilitySshAttachment(attachmentId: string): void { - this.orchestrationCompatibilitySshAttachments.delete(attachmentId) - } - - verifyOrchestrationCompatibilityCaller( - evidence: OrchestrationCompatibilityEvidence | null | undefined, - options?: { currentRuntimeLaunchSufficient?: boolean } - ): OrchestrationCompatibilityCallerAuthority | null { - const terminalHandle = - typeof evidence?.terminalHandle === 'string' ? evidence.terminalHandle.trim() : '' - const claimedPaneKey = typeof evidence?.paneKey === 'string' ? evidence.paneKey.trim() : '' - const launchToken = typeof evidence?.launchToken === 'string' ? evidence.launchToken.trim() : '' - const host = evidence?.host - if (!terminalHandle || !claimedPaneKey || !launchToken) { - return null - } - const terminal = this.getOrchestrationDispatchAuthority(terminalHandle) - if ( - !terminal?.processIncarnation || - !terminal.paneKey || - !this.orchestrationCompatibilityHostMatches(terminal.hostScope, host) - ) { - return null - } - const launchTokenHash = createHash('sha256').update(launchToken).digest('hex') - let terminalProvenance: 'current_runtime' | 'restored' - if (terminal.launchTokenHash) { - if (launchTokenHash !== terminal.launchTokenHash) { - return null - } - terminalProvenance = 'current_runtime' - } else { - const receipt = this.restoredOrchestrationAuthorityByPtyId.get(terminal.ptyId) - if ( - !receipt || - receipt.ptyId !== terminal.ptyId || - receipt.worktreeId !== terminal.worktreeId || - receipt.terminalHandle !== terminal.terminalHandle || - receipt.paneKey !== terminal.paneKey || - receipt.processIncarnation !== terminal.processIncarnation || - !this.orchestrationCompatibilityHostScopesEqual(receipt.hostScope, terminal.hostScope) - ) { - return null - } - terminalProvenance = 'restored' - } - if ( - options?.currentRuntimeLaunchSufficient && - terminalProvenance === 'current_runtime' && - claimedPaneKey === terminal.paneKey - ) { - // Why: the checks above bind a fresh launch to its live PTY, host, and - // launch secret. Only an exact live-pane match may skip hook attestation. - return this.freezeOrchestrationCompatibilityCallerAuthority( - terminal, - terminal.processIncarnation, - claimedPaneKey, - terminalHandle, - launchTokenHash - ) - } - const attestation = this.attestAgentHookCompatibilityAuthorityFn?.({ - paneKey: claimedPaneKey, - launchTokenHash, - connectionId: terminal.hostScope.kind === 'ssh' ? terminal.hostScope.targetId : null, - terminalProvenance - }) - if (!attestation || attestation.paneKey !== terminal.paneKey) { - return null - } - return this.freezeOrchestrationCompatibilityCallerAuthority( - terminal, - terminal.processIncarnation, - attestation.paneKey, - terminalHandle, - launchTokenHash - ) - } - - private freezeOrchestrationCompatibilityCallerAuthority( - terminal: OrchestrationCompatibilityTerminalAuthority, - processIncarnation: string, - paneKey: string, - terminalHandle: string, - launchTokenHash: string - ): OrchestrationCompatibilityCallerAuthority { - return Object.freeze({ - hostScope: Object.freeze({ ...terminal.hostScope }), - paneKey, - terminalHandle, - processIncarnation, - launchTokenHash - }) - } - - private orchestrationCompatibilityHostMatches( - hostScope: OrchestrationCompatibilityTerminalAuthority['hostScope'], - host: OrchestrationCompatibilityHostStamp | undefined - ): boolean { - if (hostScope.kind === 'local') { - return host === undefined - } - if (hostScope.kind === 'wsl') { - return ( - host?.kind === 'wsl' && host.hostId === hostScope.hostId && host.distro === hostScope.distro - ) - } - if (host?.kind !== 'ssh' || host.targetId !== hostScope.targetId) { - return false - } - const authority = this.orchestrationCompatibilitySshAttachments.get(host.attachmentId) - return ( - authority?.targetId === host.targetId && - authority.connectionIncarnation === host.connectionIncarnation - ) - } - - private orchestrationCompatibilityHostScopesEqual( - left: OrchestrationCompatibilityTerminalAuthority['hostScope'], - right: OrchestrationCompatibilityTerminalAuthority['hostScope'] - ): boolean { - if (left.kind !== right.kind) { - return false - } - if (left.kind === 'local' && right.kind === 'local') { - return left.hostId === right.hostId - } - if (left.kind === 'wsl' && right.kind === 'wsl') { - return left.hostId === right.hostId && left.distro === right.distro - } - return left.kind === 'ssh' && right.kind === 'ssh' && left.targetId === right.targetId - } - - private getOrchestrationCompatibilityHostScope( - pty: RuntimePtyWorktreeRecord - ): OrchestrationCompatibilityTerminalAuthority['hostScope'] | null { - if (pty.connectionId) { - return { kind: 'ssh', targetId: pty.connectionId } - } - if (pty.isWsl || pty.wslDistro) { - return pty.wslDistro ? { kind: 'wsl', hostId: 'local', distro: pty.wslDistro } : null - } - return { kind: 'local', hostId: 'local' } - } - - private rememberRestoredOrchestrationAuthority( - pty: RuntimePtyWorktreeRecord, - terminalHandle: string, - incarnationId: string - ): void { - const paneKey = pty.paneKey - const hostScope = this.getOrchestrationCompatibilityHostScope(pty) - if (!paneKey || !parsePaneKey(paneKey) || !hostScope) { - this.restoredOrchestrationAuthorityByPtyId.delete(pty.ptyId) - return - } - this.restoredOrchestrationAuthorityByPtyId.set( - pty.ptyId, - Object.freeze({ - ptyId: pty.ptyId, - worktreeId: pty.worktreeId, - terminalHandle, - paneKey, - processIncarnation: `${pty.ptyId}:${incarnationId}`, - hostScope: Object.freeze({ ...hostScope }) - }) - ) - } - - getOrchestrationDispatchAuthority( - terminalHandle: string - ): OrchestrationCompatibilityTerminalAuthority | null { - let ptyId: string | null - try { - ptyId = - this.getLivePtyForHandle(terminalHandle)?.pty.ptyId ?? - this.resolveLiveLeafForHandle(terminalHandle)?.ptyId ?? - null - } catch { - return null - } - if (!ptyId) { - return null - } - const pty = this.ptysById.get(ptyId) - if (!pty?.connected) { - return null - } - const hostScope = this.getOrchestrationCompatibilityHostScope(pty) - if (!hostScope) { - return null - } - return { - runtimeId: this.runtimeId, - terminalHandle, - ptyId, - worktreeId: pty.worktreeId, - processIncarnation: this.getTerminalProcessIncarnation(terminalHandle), - paneKey: pty.paneKey, - launchTokenHash: pty.launchToken - ? createHash('sha256').update(pty.launchToken).digest('hex') - : null, - hostScope - } - } - - /** Every pane key this PTY could be addressed by. Independent of launch authority: an ordinary - * restored PTY has neither a launch token nor a receipt, and those are exactly the panes whose - * spawn-time `ptyPaneKey` mapping teardown could not resolve. */ - private collectPaneKeysForPty(ptyId: string): Set { - const paneKeys = new Set() - const pty = this.ptysById.get(ptyId) - if (pty?.paneKey && parsePaneKey(pty.paneKey)) { - paneKeys.add(pty.paneKey) - } - const receipt = this.restoredOrchestrationAuthorityByPtyId.get(ptyId) - if (receipt?.paneKey && parsePaneKey(receipt.paneKey)) { - paneKeys.add(receipt.paneKey) - } - for (const leaf of this.getLeavesForPty(ptyId)) { - if (isValidTerminalTabId(leaf.tabId) && isTerminalLeafId(leaf.leafId)) { - paneKeys.add(makePaneKey(leaf.tabId, leaf.leafId)) - } - } - return paneKeys - } - - private retirePtyAgentLaunchAuthority(ptyId: string): void { - const pty = this.ptysById.get(ptyId) - if (!pty) { - return - } - const receipt = this.restoredOrchestrationAuthorityByPtyId.get(ptyId) - if (!pty.launchToken && !receipt && !pty.launchAgent) { - return - } - const paneKeys = this.collectPaneKeysForPty(ptyId) - this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) - pty.launchToken = null - pty.launchIncarnationId = null - pty.launchAgent = null - for (const paneKey of paneKeys) { - this.retireAgentHookCompatibilityAuthorityFn?.(paneKey) - } - } - - async resolveTerminalCwd(handle: string): Promise { - const ptyId = this.resolveLeafForHandle(handle)?.ptyId - if (!ptyId) { - return null - } - const tracked = this.terminalCwdByPtyId.get(ptyId) - if (tracked) { - return tracked - } - try { - const cwd = await this.ptyController?.getCwd?.(ptyId) - return cwd && cwd.trim().length > 0 ? cwd : null - } catch { - return null - } - } - - resolveTerminalFileUriHostname(handle: string): string | null { - const ptyId = this.resolveLeafForHandle(handle)?.ptyId - return ptyId ? (this.terminalFileUriHostnameByPtyId.get(ptyId) ?? null) : null - } - - private recordRecentPtyOutputForPathProvenance(ptyId: string, data: string): void { - let recentOutputBuffer = this.recentPtyOutputById.get(ptyId) - if (!recentOutputBuffer) { - // Boundaries are only owed to the one-time activation backfill; once - // tracking is live, new buffers keep the read-collapsing hot path. - recentOutputBuffer = new RecentPtyOutputBuffer({ - preserveChunkBoundaries: !this.recentPtyPathCandidateTrackingActive - }) - this.recentPtyOutputById.set(ptyId, recentOutputBuffer) - } - recentOutputBuffer.append(data) - if ( - this.recentPtyPathCandidateTrackingActive || - // Why: an over-window chunk is stored pre-sliced, so activation backfill - // could never replay its original text. Extract while intact; oversized - // chunks are rare, so the desktop-only gate still skips the hot path. - data.length > RECENT_PTY_OUTPUT_LIMIT - ) { - this.recentPtyPathCandidatesById.set( - ptyId, - appendRecentPtyPathCandidates(this.recentPtyPathCandidatesById.get(ptyId), data) - ) - } - } - - activateRecentPtyPathCandidateTracking(): void { - if (this.recentPtyPathCandidateTrackingActive) { - return - } - this.recentPtyPathCandidateTrackingActive = true - // Why: synchronous backfill from the retained raw windows so a file tap - // right after first mobile connect resolves exactly as before the gate. - // Replay each retained chunk in its original full form: joining or - // trimming chunks would change the candidate set (e.g. a window cut can - // shorten an over-4KiB line under the extractor's line guard, minting - // candidates the eager extractor rejected). - // Accepted best-effort loss: output that scrolled past the raw window - // before the first-ever connect no longer yields candidates. - for (const [ptyId, buffer] of this.recentPtyOutputById) { - let candidates = this.recentPtyPathCandidatesById.get(ptyId) - const { chunks, headChunkIsPartial } = buffer.retainedChunks() - for (let index = 0; index < chunks.length; index += 1) { - if (index === 0 && headChunkIsPartial) { - // A pre-sliced over-window chunk was already extracted eagerly at - // append time (while its original text was intact); replaying its - // truncated remainder would mint or drop candidates spuriously. - continue - } - candidates = appendRecentPtyPathCandidates(candidates, chunks[index]!) - } - if (candidates) { - this.recentPtyPathCandidatesById.set(ptyId, candidates) - } - // Chunk boundaries were owed only to this one-time backfill; return - // the buffer to the compact read-collapsing steady state. - buffer.compact() - } - } - - resolveTerminalContext( - handle: string - ): { worktreeId: string; connectionId: string | null } | null { - const ptyId = this.resolveLeafForHandle(handle)?.ptyId - const pty = ptyId ? this.ptysById.get(ptyId) : null - return pty ? { worktreeId: pty.worktreeId, connectionId: pty.connectionId } : null - } - - resolveNativeChatTranscriptBinding(handle: string): RuntimeNativeChatTranscriptBinding | null { - const terminalContext = this.resolveTerminalContext(handle) - const snapshot = terminalContext - ? this.mobileSessionTabsByWorktree.get(terminalContext.worktreeId) - : null - if (!terminalContext || !snapshot) { - return null - } - const terminal = this.toMobileSessionTabsResult(snapshot).tabs.find( - (tab) => tab.type === 'terminal' && tab.status === 'ready' && tab.terminal === handle - ) - if (!terminal || terminal.type !== 'terminal') { - return null - } - return { - ...terminalContext, - agent: terminal.agentStatus?.agentType ?? terminal.launchAgent ?? null, - providerSession: terminal.agentStatus?.providerSession ?? null - } - } - - // Why: remote clients cannot resolve this runtime's WSL project preference, - // so host-affecting RPCs (skill discovery) resolve it from the owning store. - resolveProjectRuntimeForWorktree( - worktreeId: string | null | undefined - ): ProjectExecutionRuntimeResolution | undefined { - return this.store && worktreeId - ? resolveLocalProjectRuntimeForWorktreeId(this.requireStore(), worktreeId) - : undefined - } - - getTerminalOrchestrationCliCommand(handle: string): 'orca' | 'orca-ide' { - let pty: RuntimePtyWorktreeRecord | null = null - try { - const ptyId = this.resolveLeafForHandle(handle)?.ptyId - pty = ptyId ? (this.ptysById.get(ptyId) ?? null) : null - } catch { - return 'orca' - } - if (!pty) { - return 'orca' - } - return resolveTerminalOrchestrationCliCommand({ - connectionId: pty.connectionId, - isWsl: pty.isWsl, - worktreeId: pty.worktreeId, - projectRuntime: this.store - ? resolveLocalProjectRuntimeForWorktreeId(this.requireStore(), pty.worktreeId) - : undefined - }) - } - - hasRecentTerminalOutputPath(handle: string, pathText: string, absolutePath: string): boolean { - // Why: safety net for any query path that never saw a mobile onReady — - // lazily backfill so the answer matches pre-gate behavior. - if (!this.recentPtyPathCandidateTrackingActive) { - this.activateRecentPtyPathCandidateTracking() - } - const ptyId = this.resolveLeafForHandle(handle)?.ptyId - const recentOutput = ptyId ? this.recentPtyOutputById.get(ptyId)?.read() : null - if (recentOutput && recentTerminalOutputIncludesPath(recentOutput, pathText, absolutePath)) { - return true - } - const candidates = ptyId ? this.recentPtyPathCandidatesById.get(ptyId) : null - return candidates - ? recentTerminalPathCandidatesIncludePath(candidates, pathText, absolutePath) - : false - } - - registerSubscriptionCleanup( - subscriptionId: string, - cleanup: () => void | Promise, - connectionId?: string - ): void { - // Why: mobile clients reconnect frequently (phone lock, network switch). - // The RPC client re-sends terminal.subscribe on reconnect, creating a new - // handler before the old one is cleaned up. Without this, the old data - // listener leaks in dataListeners and duplicates every PTY data event. - const existing = this.subscriptionCleanups.get(subscriptionId) - if (existing) { - // Why: the stable id is about to belong to a newer connection; detach - // the old owner before its asynchronous cleanup can overlap the rebind. - this.removeSubscriptionConnectionIndex(subscriptionId) - // Why: evict by the owner we captured, never by the key — a keyed evict - // would resolve to whoever holds the id at call time. - this.cleanupOwnedSubscription(subscriptionId, existing) - } - this.subscriptionCleanups.set(subscriptionId, cleanup) - if (connectionId) { - let set = this.subscriptionsByConnection.get(connectionId) - if (!set) { - set = new Set() - this.subscriptionsByConnection.set(connectionId, set) - } - set.add(subscriptionId) - this.subscriptionConnectionByEntry.set(subscriptionId, connectionId) - } - } - - // Why: teardown keyed only by a string tears down whoever owns that key *now*. - // A mobile reconnect rebinds the stable `${terminal}:${clientId}` id, so a late - // teardown from the dead connection would kill the replacement stream (STA-4510). - // Callers that own a registration must go through this handle instead. - registerOwnedSubscriptionCleanup( - subscriptionId: string, - cleanup: () => void | Promise, - connectionId?: string - ): SubscriptionRegistration { - this.registerSubscriptionCleanup(subscriptionId, cleanup, connectionId) - return { - releaseIfCurrent: () => this.cleanupOwnedSubscription(subscriptionId, cleanup) - } - } - - cleanupSubscription(subscriptionId: string): void { - void this.cleanupSubscriptionAndWait(subscriptionId).catch((error) => { - console.error(`[runtime] subscription cleanup failed for ${subscriptionId}:`, error) - }) - } - - // Why: a client-supplied unsubscribe names a stable id it may no longer own — a - // reconnect or make-before-break migration rebinds that id to a newer connection, - // and honoring the stale message would kill the live stream (STA-4510). - // Returns whether the subscription was actually torn down. - cleanupSubscriptionIfOwnedByConnection( - subscriptionId: string, - connectionId: string | undefined - ): boolean { - // Why: an absent connectionId means a connection-less caller — the local - // unix-socket dispatch path, gated by the 0o600 metadata token. That tier keeps - // unconditional teardown authority; this guard scopes socket clients only. - if (!connectionId) { - this.cleanupSubscription(subscriptionId) - return true - } - // Why: an id with no registration is already gone, not a refusal. Reporting - // false there would tell a retrying client to keep chasing a dead id. - if (!this.subscriptionCleanups.has(subscriptionId)) { - return true - } - if (this.subscriptionConnectionByEntry.get(subscriptionId) !== connectionId) { - return false - } - this.cleanupSubscription(subscriptionId) - return true - } - - private cleanupOwnedSubscription( - subscriptionId: string, - expectedCleanup: () => void | Promise - ): void { - // Why: the ownership check is synchronous and cleanupSubscriptionAndWait re-reads - // the map before its first await, so nothing can rebind in between. Delegating - // preserves the in-flight join, the retain-on-failure, and the retry contract. - if (this.subscriptionCleanups.get(subscriptionId) !== expectedCleanup) { - return - } - this.cleanupSubscription(subscriptionId) - } - - retrySubscriptionCleanupAfter( - subscriptionId: string, - cleanupOwner: () => void | Promise, - gate: Promise - ): void { - const failedGeneration = this.subscriptionCleanupPromises.get(subscriptionId) - void gate.then( - async () => { - await (failedGeneration?.cleanup === cleanupOwner - ? failedGeneration.promise.catch(() => undefined) - : undefined) - while (this.subscriptionCleanups.get(subscriptionId) === cleanupOwner) { - const newerGeneration = this.subscriptionCleanupPromises.get(subscriptionId) - if (newerGeneration?.cleanup === cleanupOwner) { - // Why: a caller may already be retrying this owner; wait for that - // exact generation so a rejected join cannot consume our retry. - await newerGeneration.promise.catch(() => undefined) - continue - } - this.cleanupSubscription(subscriptionId) - return - } - }, - () => undefined - ) - } - - async cleanupSubscriptionAndWait(subscriptionId: string): Promise { - const cleanup = this.subscriptionCleanups.get(subscriptionId) - if (!cleanup) { - return - } - const inFlight = this.subscriptionCleanupPromises.get(subscriptionId) - if (inFlight?.cleanup === cleanup) { - return inFlight.promise - } - let cleanupResult: void | Promise - try { - cleanupResult = cleanup() - } catch (error) { - cleanupResult = Promise.reject(error) - } - const promise = Promise.resolve(cleanupResult) - .then(() => { - // Why: a reconnect can replace this id while old async cleanup runs; - // only the generation that registered this callback may remove it. - if (this.subscriptionCleanups.get(subscriptionId) !== cleanup) { - return - } - this.subscriptionCleanups.delete(subscriptionId) - this.removeSubscriptionConnectionIndex(subscriptionId) - }) - .finally(() => { - if (this.subscriptionCleanupPromises.get(subscriptionId)?.promise === promise) { - this.subscriptionCleanupPromises.delete(subscriptionId) - } - }) - this.subscriptionCleanupPromises.set(subscriptionId, { cleanup, promise }) - return promise - } - - private removeSubscriptionConnectionIndex(subscriptionId: string): void { - const connectionId = this.subscriptionConnectionByEntry.get(subscriptionId) - if (connectionId) { - this.subscriptionConnectionByEntry.delete(subscriptionId) - const set = this.subscriptionsByConnection.get(connectionId) - if (set) { - set.delete(subscriptionId) - if (set.size === 0) { - this.subscriptionsByConnection.delete(connectionId) - } - } - } - } - - cleanupSubscriptionsByPrefix(prefix: string): void { - const ids = Array.from(this.subscriptionCleanups.keys()).filter((id) => id.startsWith(prefix)) - for (const id of ids) { - this.cleanupSubscription(id) - } - } - - // Why: invoked from the WebSocket transport's on-close hook so streaming - // listeners registered for this exact socket get torn down even when other - // sockets sharing the same deviceToken are still alive (multi-screen - // mobile). Without this sweep, listeners leak across every reconnect. - cleanupSubscriptionsForConnection(connectionId: string): void { - const set = this.subscriptionsByConnection.get(connectionId) - if (!set) { - return - } - // Why: snapshot the ids before iterating because cleanupSubscription - // mutates both the set and the index map. - const ids = Array.from(set) - for (const id of ids) { - if (this.subscriptionConnectionByEntry.get(id) !== connectionId) { - set.delete(id) - continue - } - this.cleanupSubscription(id) - } - if (set.size === 0) { - this.subscriptionsByConnection.delete(connectionId) - } - } - - // Why: mobile clients subscribe via notifications.subscribe streaming RPC. - // Each subscriber gets its own listener. Returns an unsubscribe function - // that the subscription cleanup mechanism calls on disconnect. - onNotificationDispatched(listener: (event: MobileNotificationEvent) => void): () => void { - this.notificationListeners.add(listener) - return () => { - this.notificationListeners.delete(listener) - } - } - - getMobileNotificationListenerCount(): number { - return this.notificationListeners.size - } - - // Why: bounded replay buffer for the mobile reconnect catch-up (#8129). - // Every dispatched notification is recorded with a monotonic seq so a - // reconnecting client can fetch exactly the events it missed. Kept on the - // service instance (not per-client) because the buffer is a global, - // idempotent-by-seq source of truth; clients watermark their own position. - private readonly mobileNotificationReplay = new MobileNotificationReplayBuffer() - - dispatchMobileNotification(event: MobileNotificationEvent): void { - const seq = this.mobileNotificationReplay.record(event) - // Why: surface the desktop-assigned seq to live listeners so they can watermark the last event - // delivered and feed it back to getMissedSince on reconnect (idempotent catch-up, no dupes). - notifyRuntimeListeners( - this.notificationListeners, - (listener) => - listener({ - ...event, - notificationSeq: seq, - notificationEpoch: this.mobileNotificationReplay.epoch - }), - 'mobile-notification' - ) - } - - // Returns notifications dispatched after lastSeenSeq. Idempotent: the same - // watermark always yields the same set, so a client cannot be re-pushed an - // already-delivered event (the adversarial-review gate for #8129). - getMissedNotificationsSince(lastSeenSeq: number, epoch?: string): ReplayableMobileNotification[] { - return this.mobileNotificationReplay.getMissedSince(lastSeenSeq, epoch) - } - - // Why (#8591): the seq counter is per-process and restarts at 0 on every desktop - // launch, but the client's watermark is persisted. Clients need the epoch to tell - // a stale watermark from a valid one — see MobileNotificationReplayBuffer. - getMobileNotificationEpoch(): string { - return this.mobileNotificationReplay.epoch - } - - dismissMobileNotification(notificationId: string): void { - this.dispatchMobileNotification({ type: 'dismiss', notificationId }) - } - - /** Plugin panel action notifications.show. Native on desktop, relayed to - * paired mobile clients either way (mirrors notifications:dispatch). */ - async dispatchPluginNotification(input: { - pluginId: string - title: string - body?: string - }): Promise<{ delivered: boolean }> { - // Why: prefix with the plugin id so a plugin cannot spoof an Orca system - // notification or impersonate another plugin. - const title = `${input.pluginId}: ${input.title}` - const body = input.body ?? '' - let delivered = false - try { - delivered = getRuntimeDesktopSurface().showNotification({ title, body }) - } catch { - // A host with no notification display still relays to paired clients below. - } - this.dispatchMobileNotification({ type: 'notification', source: 'plugin', title, body }) - return { delivered } - } - - // ─── Account Services (mobile RPC bridge) ───────────────────── - - setAccountServices(services: RuntimeAccountServices): void { - this.accountServices = services - } - - setCommitMessageAgentEnvironmentResolvers( - resolvers: CommitMessageAgentEnvironmentResolvers - ): void { - this.commitMessageAgentEnv = resolvers - } - - getCommitMessageAgentEnvironmentResolvers(): CommitMessageAgentEnvironmentResolvers | undefined { - return this.commitMessageAgentEnv ?? undefined - } - - // Lists the speech-model catalog joined with live download/ready state, plus - // the current enabled flag + selected model, so mobile can present a dictation - // setup sheet and drive remote enable/download. Always targets this (paired) - // desktop — speech never routes to a worktree's SSH host. - async listMobileSpeechModels(): Promise { - if (!this.store) { - throw new Error('voice_dictation_unavailable') - } - const voice = this.store.getSettings().voice ?? getDefaultVoiceSettings() - const states = await getSpeechModelManager(this.store).getModelStates() - const stateById = new Map(states.map((state) => [state.id, state])) - const models: RuntimeSpeechModelSummary[] = SPEECH_MODEL_CATALOG.map((manifest) => { - const state = stateById.get(manifest.id) - return { - id: manifest.id, - label: manifest.label, - provider: manifest.provider === 'openai' ? 'openai' : 'local', - sizeBytes: manifest.sizeBytes ?? null, - recommended: manifest.recommended === true, - status: state?.status ?? 'not-downloaded', - progress: state?.progress ?? null - } - }) - return { - enabled: voice.enabled === true, - selectedModelId: voice.sttModel ?? '', - dictationMode: voice.dictationMode === 'hold' ? 'hold' : 'toggle', - models - } - } - - // Fire-and-forget model download; the ModelManager writes progress into its - // per-model state, which mobile reads back via listMobileSpeechModels polling. - async downloadMobileSpeechModel(modelId: string): Promise<{ started: true }> { - if (!this.store) { - throw new Error('voice_dictation_unavailable') - } - const manifest = getCatalogModel(modelId) - if (!manifest || !isLocalSpeechModel(manifest)) { - throw new Error('voice_model_not_downloadable') - } - // Why: do not await — downloads run for tens of seconds; the call returns - // immediately and mobile polls for progress/ready. - void getSpeechModelManager(this.store) - .downloadModel(modelId) - .catch((err) => { - console.error('[runtime] mobile speech model download failed', { modelId, err }) - }) - return { started: true } - } - - async deleteMobileSpeechModel(modelId: string): Promise { - if (!this.store?.getSettings || !this.store.updateSettings) { - throw new Error('voice_dictation_unavailable') - } - const store = this.store - try { - // The runtime store is adapted to the minimal speech settings contract used by deletion. - await deleteLocalSpeechModel({ - store: { - getSettings: () => store.getSettings(), - updateSettings: (updates, options) => store.updateSettings?.(updates, options) - }, - modelManager: getSpeechModelManager(store), - sttService: getSpeechSttService(store), - modelId - }) - } catch (error) { - throw new Error(getSpeechModelDeletionErrorCode(error) ?? 'voice_model_delete_failed') - } - return this.listMobileSpeechModels() - } - - // Enables/disables dictation and/or selects the model, merging into the - // existing voice settings so other voice fields are preserved. - async configureMobileDictation(params: { - enabled?: boolean - modelId?: string - dictationMode?: 'toggle' | 'hold' - }): Promise { - if (!this.store?.getSettings || !this.store.updateSettings) { - throw new Error('voice_dictation_unavailable') - } - const current = this.store.getSettings().voice ?? getDefaultVoiceSettings() - // An explicit '' clears the selected model (the OptionalString RPC schema - // maps '' → undefined, so this only matters for direct callers); any other - // non-empty modelId must be a known catalog entry. - if (params.modelId !== undefined && params.modelId !== '' && !getCatalogModel(params.modelId)) { - throw new Error('voice_model_unknown') - } - const nextVoice: VoiceSettings = { - ...current, - ...(params.enabled !== undefined ? { enabled: params.enabled } : {}), - ...(params.modelId !== undefined ? { sttModel: params.modelId } : {}), - ...(params.dictationMode !== undefined ? { dictationMode: params.dictationMode } : {}) - } - this.store.updateSettings({ voice: nextVoice }, { notifyListeners: true }) - return this.listMobileSpeechModels() - } - - async startMobileDictation(params: { - dictationId: string - modelId?: string - clientId?: string - connectionId?: string - }): Promise<{ - dictationId: string - modelId: string - }> { - if (!this.store) { - throw new Error('voice_dictation_unavailable') - } - - const voice = this.store.getSettings().voice ?? getDefaultVoiceSettings() - if (!voice.enabled) { - throw new Error('voice_dictation_disabled') - } - - const modelId = params.modelId || voice.sttModel - if (!modelId) { - throw new Error('voice_model_not_selected') - } - - const modelState = await getSpeechModelManager(this.store).getModelState(modelId) - if (modelState.status !== 'ready') { - throw new Error(`voice_model_not_ready:${modelState.status}`) - } - - if (!params.clientId) { - throw new Error('dictation_requires_mobile_client') - } - - if (this.mobileDictation) { - throw new Error('dictation_already_active') - } - - const owner = `mobile:${params.dictationId}` - this.mobileDictation = { - id: params.dictationId, - owner, - clientId: params.clientId, - connectionId: params.connectionId, - state: 'starting', - partialText: '', - finalTexts: [], - errors: [] - } - - try { - await getSpeechSttService(this.store).startDictation( - modelId, - (event) => { - const session = this.mobileDictation - if (!session || session.id !== params.dictationId) { - return - } - if (event.type === 'partial') { - session.partialText = event.text ?? '' - } else if (event.type === 'final') { - const text = event.text?.trim() - if (text) { - session.finalTexts.push(text) - session.partialText = '' - } - } else if (event.type === 'error') { - session.errors.push(event.error ?? 'Speech worker error') - } - }, - undefined, - owner - ) - if (this.mobileDictation?.id !== params.dictationId) { - throw new Error('dictation_canceled') - } - this.mobileDictation.state = 'active' - } catch (error) { - if (this.mobileDictation?.id === params.dictationId) { - this.mobileDictation = null - } - throw error - } - - return { dictationId: params.dictationId, modelId } - } - - feedMobileDictation(params: { - dictationId: string - audioBase64: string - sampleRate: number - clientId?: string - connectionId?: string - }): { - dictationId: string - } { - const session = this.mobileDictation - if (!session || session.id !== params.dictationId) { - throw new Error('dictation_stream_not_started') - } - if (!params.clientId || session.clientId !== params.clientId) { - throw new Error('dictation_owner_mismatch') - } - if (session.connectionId && session.connectionId !== params.connectionId) { - throw new Error('dictation_owner_mismatch') - } - if (session.state !== 'active') { - throw new Error('dictation_stream_closing') - } - if (session.errors.length > 0) { - throw new Error(session.errors[0]) - } - - const pcm = Buffer.from(params.audioBase64, 'base64') - const samples = new Float32Array(Math.floor(pcm.length / 2)) - for (let i = 0; i < samples.length; i += 1) { - samples[i] = pcm.readInt16LE(i * 2) / 32768 - } - getSpeechSttService(this.store!).feedAudio(samples, params.sampleRate, session.owner) - return { dictationId: params.dictationId } - } - - async finishMobileDictation(params: { - dictationId: string - clientId?: string - connectionId?: string - }): Promise<{ - dictationId: string - text: string - }> { - const session = this.mobileDictation - if (!session || session.id !== params.dictationId) { - throw new Error('dictation_stream_not_started') - } - if (!params.clientId || session.clientId !== params.clientId) { - throw new Error('dictation_owner_mismatch') - } - if (session.connectionId && session.connectionId !== params.connectionId) { - throw new Error('dictation_owner_mismatch') - } - session.state = 'closing' - try { - await getSpeechSttService(this.store!).stopDictation(session.owner) - if (session.errors.length > 0) { - throw new Error(session.errors[0]) - } - const text = [...session.finalTexts, session.partialText].join(' ').trim() - return { dictationId: params.dictationId, text } - } finally { - if (this.mobileDictation?.id === session.id) { - this.mobileDictation = null - } - } - } - - async cancelMobileDictation(params: { - dictationId: string - clientId?: string - connectionId?: string - }): Promise<{ dictationId: string }> { - const session = this.mobileDictation - if ( - session?.id === params.dictationId && - params.clientId && - session.clientId === params.clientId && - (!session.connectionId || session.connectionId === params.connectionId) - ) { - session.state = 'closing' - try { - await getSpeechSttService(this.store!).stopDictation(session.owner) - } finally { - if (this.mobileDictation?.id === session.id) { - this.mobileDictation = null - } - } - } - return { dictationId: params.dictationId } - } - - private cancelMobileDictationSession(session: NonNullable): void { - if (session.state === 'closing') { - return - } - session.state = 'closing' - void getSpeechSttService(this.store!) - .stopDictation(session.owner) - .finally(() => { - if (this.mobileDictation?.id === session.id) { - this.mobileDictation = null - } - }) - } - - cancelMobileDictationForConnection(connectionId: string): void { - const session = this.mobileDictation - if (!session || session.connectionId !== connectionId) { - return - } - this.cancelMobileDictationSession(session) - } - - private cancelMobileDictationForClient(clientId: string): void { - const session = this.mobileDictation - if (!session || session.clientId !== clientId) { - return - } - this.cancelMobileDictationSession(session) - } - - private requireAccountServices(): RuntimeAccountServices { - if (!this.accountServices) { - throw new Error('Account services are not configured on this runtime') - } - return this.accountServices - } - - getAccountsSnapshot(): AccountsSnapshot { - const { claudeAccounts, codexAccounts, rateLimits } = this.requireAccountServices() - return { - claude: claudeAccounts.listAccounts(), - codex: codexAccounts.listAccounts(), - rateLimits: rateLimits.getState() - } - } - - // Why: RateLimitService polls only when the Electron window is visible AND - // focused, and the inactive-account caches fill lazily when the user opens - // the desktop AccountsPane. Mobile has neither trigger, so without this the - // phone shows 0% / "—" against a backgrounded desktop. Errors swallowed - // because partial usage is still useful for the rest of the snapshot. - async refreshAccountsForMobile(): Promise { - const { rateLimits } = this.requireAccountServices() - await Promise.allSettled([ - rateLimits.refresh(), - rateLimits.fetchInactiveClaudeAccountsOnOpen(), - rateLimits.fetchInactiveCodexAccountsOnOpen() - ]) - } - - // Why: connection migration replays subscriptions; use the stale-aware lane - // so a reconnect cannot turn one mobile viewer into continuous forced fetches. - async refreshAccountsForMobileSubscriber(): Promise { - const { rateLimits } = this.requireAccountServices() - await Promise.allSettled([ - rateLimits.refreshIfStale(), - rateLimits.fetchInactiveClaudeAccountsOnOpen(), - rateLimits.fetchInactiveCodexAccountsOnOpen() - ]) - } - - selectClaudeAccount(accountId: string | null): Promise { - return this.requireAccountServices().claudeAccounts.selectAccount(accountId) - } - - selectCodexAccount(accountId: string | null): Promise { - return this.requireAccountServices().codexAccounts.selectAccount(accountId) - } - - selectCodexAccountForTarget( - accountId: string | null, - target: CodexAccountSelectionTarget - ): Promise { - return this.requireAccountServices().codexAccounts.selectAccountForTarget(accountId, target) - } - - async consumeCodexRateLimitResetCredit( - idempotencyKey: string, - expectedScope: CodexResetCreditExpectedScope - ): Promise { - const { claudeAccounts, codexAccounts } = this.requireAccountServices() - const result = await codexAccounts.consumeRateLimitResetCredit(idempotencyKey, expectedScope) - // Why: Codex selection and usage were captured before its mutation queue - // advanced. Re-reading them here could pair scope A with queued selection B. - const snapshot = { - claude: claudeAccounts.listAccounts(), - codex: result.codex, - rateLimits: result.rateLimits - } - if ('status' in result) { - return { - status: result.status, - retryDisposition: result.retryDisposition, - reason: result.reason, - scope: result.scope, - snapshot - } - } - return { - outcome: result.outcome, - scope: result.scope, - snapshot - } - } - - removeClaudeAccount(accountId: string): Promise { - return this.requireAccountServices().claudeAccounts.removeAccount(accountId) - } - - // Why: register a managed Claude account from a CLAUDE_CONFIG_DIR the caller - // already logged into. Lets the `orca account add` CLI drive `claude login` in - // the user's terminal on a headless host, then capture the credentials here — - // the desktop GUI's interactive add flow is unreachable over a remote runtime. - addClaudeAccountFromConfigDir( - configDir: string, - options?: { - runtime?: 'host' | 'wsl' - wslDistro?: string | null - previousLegacyCredentialsSha256?: string | null - } - ): Promise { - return this.requireAccountServices().claudeAccounts.addAccountFromConfigDir(configDir, options) - } - - removeCodexAccount(accountId: string): Promise { - return this.requireAccountServices().codexAccounts.removeAccount(accountId) - } - - // Why: Codex counterpart of addClaudeAccountFromConfigDir — register a managed - // Codex account from a CODEX_HOME the caller already logged into, so headless - // hosts can add accounts via `orca account add --agent codex`. - addCodexAccountFromHome( - sourceHome: string, - target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null } - ): Promise { - return this.requireAccountServices().codexAccounts.addAccountFromHome(sourceHome, target) - } - - // Why: rate-limit polling fires every 5 minutes and on account switch. - // Mobile clients subscribe to receive a fresh AccountsSnapshot whenever - // RateLimitService pushes new usage data, mirroring the existing - // `rateLimits:update` IPC channel desktop already uses. - onAccountsChanged(listener: (snapshot: AccountsSnapshot) => void): () => void { - const services = this.requireAccountServices() - return services.rateLimits.onStateChange((rateLimits) => { - listener({ - claude: services.claudeAccounts.listAccounts(), - codex: services.codexAccounts.listAccounts(), - rateLimits - }) - }) - } - - // ─── Mobile Fit Override Management ───────────────────────── - - // Why: legacy mobile RPC entrypoint. After the state-machine rewrite this - // is a thin shim that computes a `PtyLayoutTarget` and routes through - // `enqueueLayout`. Keeps the same observable return shape so older mobile - // builds continue to work. See docs/mobile-terminal-layout-state-machine.md. - async resizeForClient( - ptyId: string, - mode: 'mobile-fit' | 'restore', - clientId: string, - cols?: number, - rows?: number - ): Promise<{ - cols: number - rows: number - previousCols: number | null - previousRows: number | null - mode: 'mobile-fit' | 'desktop-fit' - }> { - if (mode === 'mobile-fit') { - if (cols == null || rows == null || !Number.isFinite(cols) || !Number.isFinite(rows)) { - throw new Error('invalid_dimensions') - } - const { cols: clampedCols, rows: clampedRows } = clampTerminalViewport(cols, rows) - - const currentSize = this.getTerminalSize(ptyId) - const existing = this.terminalFitOverrides.get(ptyId) - // Capture baseline cols/rows for the return value (existing override's - // baseline wins over current size to preserve original desktop dims - // across multiple re-fits). - const previousCols = existing?.previousCols ?? currentSize?.cols ?? null - const previousRows = existing?.previousRows ?? currentSize?.rows ?? null - - // Why: legacy resizeForClient callers bypass handleMobileSubscribe, so - // mobileSubscribers stays empty and resolveDesktopRestoreTarget's step-1 - // (per-subscriber baseline) never matches. Stash the pre-fit PTY size - // into lastRendererSizes so restore lands on step 2 (renderer geometry) - // instead of step 3 (current phone-fit dims = no-op restore). - if (currentSize && !existing) { - this.lastRendererSizes.set(ptyId, { - cols: currentSize.cols, - rows: currentSize.rows - }) - } - - this.freshSubscribeGuard.add(ptyId) - let result: ApplyLayoutResult - try { - result = await this.enqueueLayout(ptyId, { - kind: 'phone', - cols: clampedCols, - rows: clampedRows, - ownerClientId: clientId - }) - } finally { - this.freshSubscribeGuard.delete(ptyId) - } - if (!result.ok) { - throw new Error('resize_failed') - } - - // Why: mobile-fit via resizeForClient is a deliberate mobile action; - // the actor takes the floor (updates lastActedAt; mode-flip case is - // already handled by enqueueLayout above). - await this.mobileTookFloor(ptyId, clientId) - - return { - cols: clampedCols, - rows: clampedRows, - previousCols, - previousRows, - mode: 'mobile-fit' - } - } - - // restore mode - const override = this.terminalFitOverrides.get(ptyId) - if (!override) { - throw new Error('no_active_override') - } - // Only the owning client can restore — prevents one phone from undoing - // another phone's active fit. - if (override.clientId !== clientId) { - throw new Error('not_override_owner') - } - - const restore = this.resolveDesktopRestoreTarget(ptyId) - const result = await this.enqueueLayout(ptyId, { - kind: 'desktop', - cols: restore.cols, - rows: restore.rows - }) - if (!result.ok) { - throw new Error('resize_failed') - } - - // Why: legacy mobile clients on the resizeForClient path also need a - // fit-override-listener notification (the renderer-side terminalFitOverrideChanged - // is already emitted by applyLayout's mode-flip path). - this.notifyFitOverrideListeners(ptyId, 'desktop-fit', restore.cols, restore.rows) - - return { - cols: restore.cols, - rows: restore.rows, - previousCols: null, - previousRows: null, - mode: 'desktop-fit' - } - } - - getTerminalFitOverride(ptyId: string) { - return this.terminalFitOverrides.get(ptyId) ?? null - } - - getAllTerminalFitOverrides(): Map< - string, - { mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number } - > { - const result = new Map< - string, - { mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number } - >() - for (const [ptyId, override] of this.terminalFitOverrides) { - result.set(ptyId, { mode: override.mode, cols: override.cols, rows: override.rows }) - } - for (const [ptyId] of this.remoteDesktopOwners) { - if (result.has(ptyId)) { - continue - } - const size = this.getTerminalSize(ptyId) - if (size) { - result.set(ptyId, { mode: 'remote-desktop-fit', ...size }) - } - } - return result - } - - getAllTerminalDrivers(): Map { - return new Map(this.currentDriver) - } - - getAllBrowserDrivers(): Map { - return new Map(this.currentBrowserDriver) - } - - private getBrowserDriver(browserPageId: string): RuntimeBrowserDriverState { - return this.currentBrowserDriver.get(browserPageId) ?? { kind: 'idle' } - } - - private setBrowserDriver(browserPageId: string, next: RuntimeBrowserDriverState): void { - const prev = this.getBrowserDriver(browserPageId) - if (prev.kind === next.kind) { - if (prev.kind === 'mobile' && next.kind === 'mobile' && prev.clientId === next.clientId) { - return - } - if (prev.kind !== 'mobile' && next.kind !== 'mobile') { - return - } - } - if (next.kind === 'idle') { - this.currentBrowserDriver.delete(browserPageId) - } else { - this.currentBrowserDriver.set(browserPageId, next) - } - this.notifier?.browserDriverChanged?.(browserPageId, next) - } - - getBrowserRemoteViewerPages(): string[] { - return Array.from(this.browserRemoteViewerPages) - } - - /** Republishes from the live subscriber set, so every add and remove has one settling point. */ - private publishBrowserRemoteViewers(browserPageId: string): void { - const watched = (this.activeBrowserScreencastsByPage.get(browserPageId)?.size ?? 0) > 0 - if (this.browserRemoteViewerPages.has(browserPageId) === watched) { - return - } - if (watched) { - this.browserRemoteViewerPages.add(browserPageId) - } else { - this.browserRemoteViewerPages.delete(browserPageId) - } - this.notifier?.browserRemoteViewersChanged?.(browserPageId, watched) - } - - reclaimBrowserForDesktop(browserPageId: string): boolean { - this.setBrowserDriver(browserPageId, { kind: 'desktop' }) - for (const stream of this.activeBrowserScreencastsByPage.get(browserPageId) ?? []) { - // Why: take-back revokes the phone's control, not a co-viewing desktop/web client's stream. - if (stream.drivesAsMobile) { - stream.cancel(true) - } - } - return true - } - - onClientDisconnected(clientId: string): void { - this.revokeTerminalFileGrantsForClient(clientId) - this.cancelMobileDictationForClient(clientId) - - // (1) Cancel pending restore-debounce timers owned by this client. - for (const [ptyId, entry] of this.pendingRestoreTimers) { - if (entry.clientId === clientId) { - clearTimeout(entry.timer) - this.pendingRestoreTimers.delete(ptyId) - } - } - - // (2) Promote any soft-leave grace owned by this client into immediate - // finalization. Grace existed to absorb a quick re-subscribe; a real - // disconnect kills any chance of re-subscribe. - // - // Note: this is mode-decoupled (matches docs/mobile-terminal-layout-state-machine.md - // sub-case 2). Today's pre-rewrite code only restored when - // `mode === 'auto' && wasResizedToPhone`; the new design restores - // whenever the layout is currently `phone`. This is an intentional - // behavior fix — `mode === 'phone'` with no subscribers is a degenerate - // state nothing in product depends on. - for (const [ptyId, soft] of this.pendingSoftLeavers) { - if (soft.clientId !== clientId) { - continue - } - clearTimeout(soft.timer) - this.pendingSoftLeavers.delete(ptyId) - - // Cancel any in-flight 300ms restore timer too — we'll handle it inline. - const pending = this.pendingRestoreTimers.get(ptyId) - if (pending) { - clearTimeout(pending.timer) - this.pendingRestoreTimers.delete(ptyId) - } - - const cur = this.layouts.get(ptyId) - // Why: Indefinite hold (mobileAutoRestoreFitMs == null) keeps the PTY - // at phone dims after the phone disconnects; the desktop banner's - // Restore button is the explicit return path. See - // docs/mobile-fit-hold.md. - if (this.hasRemoteDesktopViewers(ptyId)) { - this.setDriver(ptyId, { kind: 'idle' }) - void this.applyRemoteDesktopLayout(ptyId) - continue - } else if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) { - if (this.remoteDesktopHostReclaimTargets.has(ptyId)) { - this.setDriver(ptyId, { kind: 'idle' }) - void this.applyRemoteDesktopLayout(ptyId) - continue - } - // Use the soft-leaver's snapshot baseline as a hint, falling - // through to resolveDesktopRestoreTarget for missing values. - const fallback = this.resolveDesktopRestoreTarget(ptyId) - const cols = soft.record.previousCols ?? fallback.cols - const rows = soft.record.previousRows ?? fallback.rows - void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) - } - this.setDriver(ptyId, { kind: 'idle' }) - } - - // (3) Immediate restore for PTYs where this client was the last - // mobile subscriber. With multi-mobile, peer subscribers keep the - // floor; only when the inner map empties do we transition to desktop. - const ptysWithSurvivingPeers: string[] = [] - const ptysToRestore: { ptyId: string; baseline: { cols: number; rows: number } | null }[] = [] - for (const [ptyId, inner] of this.mobileSubscribers) { - const subscriber = inner.get(clientId) - if (!subscriber) { - continue - } - // Snapshot baseline before deleting — needed once mobileSubscribers - // entry is gone for the resolveDesktopRestoreTarget chain. - const baseline = - subscriber.previousCols != null && subscriber.previousRows != null - ? { cols: subscriber.previousCols, rows: subscriber.previousRows } - : null - inner.delete(clientId) - this.notifyRemoteTerminalViewPresenceChanged(ptyId) - if (inner.size > 0) { - ptysWithSurvivingPeers.push(ptyId) - } else { - this.mobileSubscribers.delete(ptyId) - ptysToRestore.push({ ptyId, baseline }) - } - } - for (const { ptyId, baseline } of ptysToRestore) { - const cur = this.layouts.get(ptyId) - // Why: Indefinite hold gate — see soft-leaver branch above. - if (this.hasRemoteDesktopViewers(ptyId)) { - this.setDriver(ptyId, { kind: 'idle' }) - void this.applyRemoteDesktopLayout(ptyId) - continue - } else if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) { - if (this.remoteDesktopHostReclaimTargets.has(ptyId)) { - this.setDriver(ptyId, { kind: 'idle' }) - void this.applyRemoteDesktopLayout(ptyId) - continue - } - const fallback = this.resolveDesktopRestoreTarget(ptyId) - const cols = baseline?.cols ?? fallback.cols - const rows = baseline?.rows ?? fallback.rows - void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) - } - this.setDriver(ptyId, { kind: 'idle' }) - } - - // (4) Driver re-election where peers survived. If the disconnecting - // client was the active driver, the most-recent surviving actor takes - // the floor. - for (const ptyId of ptysWithSurvivingPeers) { - const driver = this.getDriver(ptyId) - if (driver.kind !== 'mobile' || driver.clientId !== clientId) { - continue - } - const inner = this.mobileSubscribers.get(ptyId) - const next = inner ? this.pickMostRecentActor(inner) : null - if (!next) { - continue - } - this.setDriver(ptyId, { kind: 'mobile', clientId: next.clientId }) - - const mode = this.getMobileDisplayMode(ptyId) - if (mode === 'desktop') { - continue - } - const nextSub = inner!.get(next.clientId) - const nextViewport = nextSub?.viewport - if (!nextViewport) { - continue - } - void this.enqueueLayout(ptyId, { - kind: 'phone', - cols: nextViewport.cols, - rows: nextViewport.rows, - ownerClientId: next.clientId - }) - } - - // (5) Legacy-callers fallback. Older mobile builds use resizeForClient - // directly and never populate mobileSubscribers. For those PTYs the - // override carries the owning clientId; restore the layout when the - // owner disconnects. resolveDesktopRestoreTarget reads lastRendererSizes - // (which the legacy mobile-fit branch stashes the pre-fit size into). - for (const [ptyId, override] of this.terminalFitOverrides) { - if (override.clientId !== clientId) { - continue - } - if (this.mobileSubscribers.has(ptyId)) { - continue - } - const cur = this.layouts.get(ptyId) - if (cur?.kind !== 'phone') { - continue - } - // Why: Indefinite hold gate — see soft-leaver branch above. Legacy - // mobile clients (resizeForClient path) honor the same setting. - if (this.getAutoRestoreFitMs() == null) { - continue - } - const fallback = this.resolveDesktopRestoreTarget(ptyId) - const cols = override.previousCols ?? fallback.cols - const rows = override.previousRows ?? fallback.rows - void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) - } - } - - onPtyExit( - ptyId: string, - exitCode: number, - exitIncarnationId?: PtyIncarnationId, - options?: { - hostExitConfirmed?: boolean - cause?: TerminalExitCause - /** The provider's own physical-exit callback fired. Separate from `hostExitConfirmed`, which - * also drives the SSH surface decision and the liveness verdict: node-pty reports real exits - * as -1, so the numeric code alone cannot tell a dead process from a failed stop. */ - providerExitObserved?: boolean - } - ): void { - const pty = this.ptysById.get(ptyId) - if (exitIncarnationId && pty?.incarnationId && exitIncarnationId !== pty.incarnationId) { - return - } - // Why intent first: a requested stop can still be delivered by the provider's - // own exit event, whose status looks exactly like a natural finish. - // - // Why it yields to stop_unverified: a stop nobody confirmed is not a - // completed close. The process may still be running against a revoked - // dispatch — an incident a coordinator must hear about, not a routine - // teardown to be filed away and left unescalated. - const observedCause = options?.cause ?? resolveUnreportedExitCause(exitCode) - const stopNeverConfirmed = - observedCause.kind === 'unknown' && observedCause.reason === 'stop_unverified' - const exitCause: TerminalExitCause = - this.stopRequestedPtyIds.has(ptyId) && !stopNeverConfirmed - ? OPERATOR_CLOSE_EXIT_CAUSE - : observedCause - this.stopRequestedPtyIds.delete(ptyId) - const preservesAbnormalSshSurface = - this.isSshOwnedPtyId(ptyId) && - pty?.connectionId != null && - exitCode < 0 && - options?.hostExitConfirmed !== true - // Why: collect before retirePtyAgentLaunchAuthority, which deletes the restored-authority - // receipt a receipt-only pane's key comes from. - const exitPaneKeys = this.collectPaneKeysForPty(ptyId) - if (preservesAbnormalSshSurface) { - if (this.getPtyLivenessVerdict(ptyId)?.status !== 'unverifiable') { - this.markPtyLivenessUnverifiable(ptyId, SSH_EXIT_UNCONFIRMED_REASON) - } - this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) - } else { - this.retirePtyAgentLaunchAuthority(ptyId) - } - // Why: a synthetic -1 from a failed or unverified stop is not a death certificate (the PTY can - // have survived it), while a real exit can also report -1 — so neither the numeric code nor the - // SSH surface predicate is sufficient on its own. Decided separately from - // preservesAbnormalSshSurface, which a host-confirmed negative SSH exit would otherwise skip. - const processDeathCertified = - exitCode >= 0 || options?.hostExitConfirmed === true || options?.providerExitObserved === true - if (processDeathCertified && exitPaneKeys.size > 0) { - this.reconcileAgentStatusForEndedProcessFn?.(exitPaneKeys) - } - const incarnationId = - exitIncarnationId ?? - pty?.incarnationId ?? - `runtime:${this.runtimeId}:${this.getPtyLifecycleGeneration(ptyId)}` - this.advancePtyLifecycleGeneration(ptyId) - this.notifyPtyExitListeners(ptyId) - const exactSurfaceByKey = new Map< - string, - Pick - >() - for (const leaf of this.getLeavesForPty(ptyId)) { - exactSurfaceByKey.set(`${leaf.worktreeId}\0${leaf.tabId}\0${leaf.leafId}`, { - worktreeId: leaf.worktreeId, - parentTabId: leaf.tabId, - leafId: leaf.leafId - }) - } - const parsedPaneKey = parsePaneKey(pty?.paneKey ?? '') - if (pty?.tabId && parsedPaneKey) { - exactSurfaceByKey.set(`${pty.worktreeId}\0${pty.tabId}\0${parsedPaneKey.leafId}`, { - worktreeId: pty.worktreeId, - parentTabId: pty.tabId, - leafId: parsedPaneKey.leafId - }) - } - const exactSurfaces = [...exactSurfaceByKey.values()] - const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId) - const exitMatchesPendingRegistration = - this.pendingPtyRegistrationIncarnations.has(ptyId) && - (pendingIncarnation === null || - exitIncarnationId === null || - exitIncarnationId === undefined || - pendingIncarnation === exitIncarnationId) - if (exitMatchesPendingRegistration) { - // Why: reused surfaces can look registered while their replacement incarnation still awaits admission. - this.earlyExitedPtyIncarnations.set( - ptyId, - exitIncarnationId ?? pendingIncarnation ?? pty?.incarnationId ?? null - ) - } - const intentionalStopIncarnation = this.intentionalHandlelessPtyStops.get(ptyId) - const preservesIntentionalHandlelessSurface = - this.intentionalHandlelessPtyStops.has(ptyId) && - (intentionalStopIncarnation === null || intentionalStopIncarnation === incarnationId) - advertisedUrlWatcher.unbindPty(ptyId) - agentSessionPtyWriteGate.unbindPty(ptyId) - // Clean up new mobile state for this PTY - this.mobileSubscribers.delete(ptyId) - this.remoteTerminalViewSubscriberCounts.delete(ptyId) - this.rawTerminalViewSubscriberCounts.delete(ptyId) - this.mobileDisplayModes.delete(ptyId) - this.resizeListeners.delete(ptyId) - this.lastRendererSizes.delete(ptyId) - this.recentPtyOutputById.delete(ptyId) - this.setupCompletionTokenByPtyId.delete(ptyId) - this.clearWaitBlockedCheckState(ptyId) - this.recentPtyPathCandidatesById.delete(ptyId) - this.ptyOutputSequenceById.delete(ptyId) - this.providerSequenceInitializedPtys.delete(ptyId) - this.providerSequenceOffsetByPtyId.delete(ptyId) - this.providerSnapshotPreferredPtys.delete(ptyId) - this.providerModeTrackersByPtyId.delete(ptyId) - this.providerModeSnapshotScansByPtyId.delete(ptyId) - this.providerBufferAcquisitionsByPtyId.delete(ptyId) - this.providerVisibleStateByPtyId.delete(ptyId) - this.providerVisibleRetryAtByPtyId.delete(ptyId) - this.agentPromptExplicitStatusFloorByPtyId.delete(ptyId) - this.agentStatusOscProcessorsByPtyId.delete(ptyId) - this.terminalSpawnCommandsByPtyId.delete(ptyId) - this.disposePtyTitleTracker(ptyId) - this.oscTitleScanTailByPtyId.delete(ptyId) - this.osc7ScanTailByPtyId.delete(ptyId) - this.terminalCwdByPtyId.delete(ptyId) - this.terminalFileUriHostnameByPtyId.delete(ptyId) - this.wslDistroByPtyId.delete(ptyId) - this.clearAgentRowSnapshotsForPty(ptyId) - // Why: a Claude agent-team leader whose PTY exits naturally (agent finished, - // process died, renderer reload) must release its team + nested panes map. - // Previously only explicit closeTerminal evicted it, so natural exits leaked - // one team per never-reused teamId for the runtime's lifetime. - const exitedTeamLeaderHandle = this.handleByPtyId.get(ptyId) - if (exitedTeamLeaderHandle) { - this.claudeAgentTeams.removeTeamForLeaderHandle(exitedTeamLeaderHandle) - } - // Layout state machine: clear `layouts` and `layoutQueues`. Any - // already-queued applyLayout work for this ptyId will run, but every - // applyLayout re-checks `layouts.has(ptyId)` (or fresh-subscribe) and - // short-circuits with `pty-exited`. - this.layouts.delete(ptyId) - this.layoutQueues.delete(ptyId) - this.freshSubscribeGuard.delete(ptyId) - this.cancelPendingDriverMutations(ptyId) - // Why: a cold restore can respawn under the same session id within the - // delayed-Enter window; the armed Enter would inject \r into the - // replacement and stamp rows it never received. - this.retireOrchestrationMailboxDeliveryForPty(ptyId) - - if (this.terminalFitOverrides.has(ptyId)) { - this.terminalFitOverrides.delete(ptyId) - this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) - this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) - } - // Why: clear driver state and notify the renderer so any lock banner on - // this dead pane unmounts. Without this, the pane shows a stuck banner - // until tab teardown, and `getDriver(deadPtyId)` would keep returning a - // stale `mobile{X}` to any caller that hasn't yet seen the exit IPC. - if (this.currentDriver.has(ptyId)) { - this.currentDriver.delete(ptyId) - this.notifier?.terminalDriverChanged(ptyId, { kind: 'idle' }) - } - this.remoteDesktopViewers.delete(ptyId) - this.remoteDesktopOwners.delete(ptyId) - this.remoteDesktopHostReclaimTargets.delete(ptyId) - this.remoteDesktopViewerRevisions.delete(ptyId) - this.disposeHeadlessTerminal(ptyId) - if (pty) { - pty.connected = false - pty.runtimeSessionOwned = false - this.setPairedRendererSessionOwnership(pty.ptyId, false) - pty.disconnectedAt = Date.now() - pty.lastExitCode = exitCode - pty.lastExitCause = exitCause - if (exitCode >= 0 || options?.hostExitConfirmed === true) { - // A real wait status from the owning host is the death certificate; the - // synthetic -1 we emit on a failed/unroutable stop is not. - this.forgetPtyLivenessVerdict(ptyId) - } - // Why: the exited process's live frames say nothing about a replacement. - // A same-id respawn makes the leaf writable again before any new title, - // so leaving this true would let push delivery type into the new process - // on the dead one's idle. lastAgentStatus itself stays for `ps` display. - pty.lastAgentStatusObservedLive = false - this.resolvePtyExitWaiters(pty, ptyId) - this.pruneDisconnectedPtyTranscript(pty) - } - if (preservesIntentionalHandlelessSurface || preservesAbnormalSshSurface) { - // Why: relay loss is recoverable; keep the HUB-owned pane addressable through the bounded reconnect grace. - this.touchMobileSessionSnapshotsForPty(ptyId, { immediate: true }) - } else { - // Why: permanent process exit is absence, not a starting/sleeping tab. - // Retire before publishing so paired clients never persist a ghost. - this.retireMobileSessionSurfacesForPty(ptyId, incarnationId, exactSurfaces) - } - - const exitedSurfaces: { handle: string; paneKey: string | null }[] = [] - for (const leaf of this.getLeavesForPty(ptyId)) { - this.detachedPreAllocatedLeaves.delete(ptyId) - leaf.connected = false - leaf.writable = false - leaf.lastExitCode = exitCode - leaf.lastExitCause = exitCause - leaf.lastAgentStatusObservedLive = false - this.resolveExitWaiters(leaf) - const leafHandle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) - if (leafHandle) { - exitedSurfaces.push({ handle: leafHandle, paneKey: `${leaf.tabId}:${leaf.leafId}` }) - } - } - // Why: an explicit whole-tab close drops the leaf from the graph *before* - // this exit lands, so a leaf-only walk found nothing and left the dispatch - // reading 'dispatched' forever against a dead process. The PTY's own handle - // and pane key survive that teardown, so settle from them too (STA-4603). - const ptyHandle = this.handleByPtyId.get(ptyId) - if (ptyHandle && !exitedSurfaces.some((surface) => surface.handle === ptyHandle)) { - exitedSurfaces.push({ handle: ptyHandle, paneKey: pty?.paneKey ?? null }) - } - if (!preservesAbnormalSshSurface) { - for (const surface of exitedSurfaces) { - this.failActiveDispatchOnExit(surface.handle, surface.paneKey, exitCode, exitCause) - } - } - this.pruneDisconnectedPtyRecords() - } - - // ─── Driver state (mobile-presence lock) ────────────────────────── - // - // See docs/mobile-presence-lock.md. - - getDriver(ptyId: string): DriverState { - return this.currentDriver.get(ptyId) ?? { kind: 'idle' } - } - - private setDriver(ptyId: string, next: DriverState): void { - const prev = this.getDriver(ptyId) - if (prev.kind === next.kind) { - if (prev.kind === 'mobile' && next.kind === 'mobile' && prev.clientId === next.clientId) { - return - } - if (prev.kind !== 'mobile' && next.kind !== 'mobile') { - return - } - } - if (next.kind === 'idle') { - this.currentDriver.delete(ptyId) - } else { - this.currentDriver.set(ptyId, next) - } - this.notifier?.terminalDriverChanged(ptyId, next) - const listeners = this.driverListeners.get(ptyId) - if (listeners) { - notifyRuntimeListeners(listeners, (listener) => listener(next), 'pty-driver') - } - } - - // Why: the host's own fit cascade (window resize, split drag, tab reveal, - // "+"-new-tab re-render) must not resize a PTY whose width a remote client - // owns — that is the remote "porridge" bug. True while a phone (mobile driver) - // OR an active remote desktop viewer owns the PTY. Input is deliberately NOT gated - // here (see the `writePtyInput` mobile-only checks): shared-control desktop - // viewers may still type alongside the host. - // Note: this is intentionally NOT a driver kind. An active remote viewer needs - // only resize suppression, not the mobile driver machinery (input lock, - // phone-fit, driver-change banners), so it lives in its own registry and does - // not perturb the presence-lock state machine. It also coexists with mobile: - // while a phone drives, the registry still suppresses host resize, and when - // the phone leaves the surviving viewer keeps the PTY suppressed. - isPtyResizeDrivenRemotely(ptyId: string): boolean { - if (this.getDriver(ptyId).kind === 'mobile') { - return true - } - return this.isRemoteDesktopResizeDriven(ptyId) - } - - isRemoteDesktopResizeDriven(ptyId: string): boolean { - return this.remoteDesktopOwners.has(ptyId) - } - - isRemoteDesktopViewerOwner(ptyId: string, subscriptionKey: string): boolean { - return this.remoteDesktopOwners.get(ptyId) === subscriptionKey - } - - getRemoteDesktopFitHold( - ptyId: string, - subscriptionKey: string - ): { mode: 'remote-desktop-fit' | 'desktop-fit'; cols: number; rows: number } { - const size = this.getTerminalSize(ptyId) ?? { cols: 0, rows: 0 } - return { - mode: this.isRemoteDesktopViewerOwner(ptyId, subscriptionKey) - ? 'desktop-fit' - : 'remote-desktop-fit', - ...size - } - } - - private hasRemoteDesktopViewers(ptyId: string): boolean { - const viewers = this.remoteDesktopViewers.get(ptyId) - return viewers !== undefined && viewers.size > 0 - } - - private activeRemoteDesktopViewport(ptyId: string): { cols: number; rows: number } | null { - const owner = this.remoteDesktopOwners.get(ptyId) - return owner ? (this.remoteDesktopViewers.get(ptyId)?.get(owner) ?? null) : null - } - - private resolveRemoteDesktopHostReclaimTarget(ptyId: string): { cols: number; rows: number } { - const target = this.remoteDesktopHostReclaimTargets.get(ptyId) - if (target) { - return target - } - // Why: a viewer can join while a phone owns the actual PTY size. The - // mobile restore chain retains the pre-phone desktop geometry; current - // PTY size alone would incorrectly capture the phone grid as host truth. - return this.resolveDesktopRestoreTarget(ptyId) - } - - private ensureRemoteDesktopHostReclaimTarget(ptyId: string): void { - if (!this.remoteDesktopHostReclaimTargets.has(ptyId)) { - this.remoteDesktopHostReclaimTargets.set( - ptyId, - this.resolveRemoteDesktopHostReclaimTarget(ptyId) - ) - } - } - - recordRemoteDesktopHostReclaimTarget(ptyId: string, cols: number, rows: number): void { - // Why: phone presence also suppresses host resize, but must not seed the - // separate remote-viewer cache when no desktop stream owns a width floor. - if (!this.remoteDesktopOwners.has(ptyId) || cols <= 0 || rows <= 0) { - return - } - this.remoteDesktopHostReclaimTargets.set(ptyId, { cols, rows }) - } - - private hasRemoteDesktopLayoutState(ptyId: string): boolean { - return this.remoteDesktopOwners.has(ptyId) || this.remoteDesktopHostReclaimTargets.has(ptyId) - } - - private bumpRemoteDesktopViewerRevision(ptyId: string): number { - const revision = (this.remoteDesktopViewerRevisions.get(ptyId) ?? 0) + 1 - this.remoteDesktopViewerRevisions.set(ptyId, revision) - return revision - } - - async applyRemoteDesktopLayout(ptyId: string): Promise { - if (this.getDriver(ptyId).kind === 'mobile') { - return true - } - const target = this.activeRemoteDesktopViewport(ptyId) - const reclaimingHost = !target - const viewerRevision = this.remoteDesktopViewerRevisions.get(ptyId) ?? 0 - const layoutTarget: PtyLayoutTarget = target - ? { - kind: 'remote-desktop', - cols: target.cols, - rows: target.rows, - ownerSubscriptionKey: this.remoteDesktopOwners.get(ptyId)! - } - : { kind: 'desktop', ...this.resolveRemoteDesktopHostReclaimTarget(ptyId) } - this.freshSubscribeGuard.add(ptyId) - try { - const result = await this.enqueueLayout(ptyId, layoutTarget) - // Why: only drop the recorded host size once the reclaim resize actually - // landed. If it failed, the PTY is still at the remote-viewer width, so - // keep the target for the next reclaim (otherwise it resolves via the - // stale remote width and never restores true host geometry). - if ( - reclaimingHost && - result.ok && - !this.remoteDesktopOwners.has(ptyId) && - this.remoteDesktopViewerRevisions.get(ptyId) === viewerRevision - ) { - this.remoteDesktopHostReclaimTargets.delete(ptyId) - } - return result.ok - } finally { - this.freshSubscribeGuard.delete(ptyId) - } - } - - // Why: attachment only records geometry. Passive hydration/reconnect must not - // steal the shared PTY from the desktop where the user is actively working. - async updateRemoteDesktopViewer( - ptyId: string, - subscriptionKey: string, - clientId: string, - cols: number, - rows: number, - claim = true - ): Promise { - const viewport = clampTerminalViewport(cols, rows) - if (claim) { - this.ensureRemoteDesktopHostReclaimTarget(ptyId) - } - let viewers = this.remoteDesktopViewers.get(ptyId) - if (!viewers) { - viewers = new Map< - string, - { clientId: string; cols: number; rows: number; activity: number } - >() - this.remoteDesktopViewers.set(ptyId, viewers) - } - const prior = viewers.get(subscriptionKey) - if ( - prior && - prior.cols === viewport.cols && - prior.rows === viewport.rows && - (!claim || this.remoteDesktopOwners.get(ptyId) === subscriptionKey) - ) { - if (claim && this.remoteDesktopOwners.get(ptyId) === subscriptionKey) { - const size = this.getTerminalSize(ptyId) - if (size?.cols !== viewport.cols || size.rows !== viewport.rows) { - return this.applyRemoteDesktopLayout(ptyId) - } - } - return true - } - const activity = claim ? ++this.remoteDesktopActivity : (prior?.activity ?? 0) - viewers.set(subscriptionKey, { clientId, cols: viewport.cols, rows: viewport.rows, activity }) - this.bumpRemoteDesktopViewerRevision(ptyId) - if (claim) { - this.remoteDesktopOwners.set(ptyId, subscriptionKey) - return this.applyRemoteDesktopLayout(ptyId) - } - return true - } - - claimRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise { - const viewer = this.remoteDesktopViewers.get(ptyId)?.get(subscriptionKey) - if (!viewer) { - return Promise.resolve(false) - } - if (this.remoteDesktopOwners.get(ptyId) === subscriptionKey) { - const size = this.getTerminalSize(ptyId) - return size?.cols === viewer.cols && size.rows === viewer.rows - ? Promise.resolve(true) - : this.applyRemoteDesktopLayout(ptyId) - } - this.ensureRemoteDesktopHostReclaimTarget(ptyId) - viewer.activity = ++this.remoteDesktopActivity - this.remoteDesktopOwners.set(ptyId, subscriptionKey) - this.bumpRemoteDesktopViewerRevision(ptyId) - return this.applyRemoteDesktopLayout(ptyId) - } - - claimRemoteDesktopHost(ptyId: string, cols: number, rows: number): Promise { - if (!this.remoteDesktopOwners.has(ptyId)) { - // Why: disconnect can remove the owner before its queued host resize - // lands. A host input in that window must join the reclaim, not pass it. - return this.remoteDesktopHostReclaimTargets.has(ptyId) - ? this.applyRemoteDesktopLayout(ptyId) - : Promise.resolve(true) - } - const viewport = clampTerminalViewport(cols, rows) - this.remoteDesktopHostReclaimTargets.set(ptyId, viewport) - this.remoteDesktopOwners.delete(ptyId) - this.bumpRemoteDesktopViewerRevision(ptyId) - return this.applyRemoteDesktopLayout(ptyId) - } - - unregisterRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise { - return this.unregisterRemoteDesktopViewers(ptyId, [subscriptionKey]) - } - - unregisterRemoteDesktopViewers( - ptyId: string, - subscriptionKeys: Iterable - ): Promise { - const viewers = this.remoteDesktopViewers.get(ptyId) - if (!viewers) { - return Promise.resolve(false) - } - let changed = false - let removedOwner = false - for (const subscriptionKey of subscriptionKeys) { - removedOwner = this.remoteDesktopOwners.get(ptyId) === subscriptionKey || removedOwner - changed = viewers.delete(subscriptionKey) || changed - } - if (!changed) { - return Promise.resolve(false) - } - if (viewers.size === 0) { - this.remoteDesktopViewers.delete(ptyId) - } - if (removedOwner) { - let fallback: { key: string; activity: number } | null = null - for (const [key, viewer] of viewers) { - if (viewer.activity > 0 && (!fallback || viewer.activity > fallback.activity)) { - fallback = { key, activity: viewer.activity } - } - } - if (fallback) { - this.remoteDesktopOwners.set(ptyId, fallback.key) - } else { - this.remoteDesktopOwners.delete(ptyId) - } - } - this.bumpRemoteDesktopViewerRevision(ptyId) - return removedOwner ? this.applyRemoteDesktopLayout(ptyId) : Promise.resolve(true) - } - - // Why: the one-shot `terminal.updateViewport` RPC has no disconnect hook, so - // it must never *create* a width floor (that floor would leak — nothing - // releases it, pinning the host at a stale width after the viewer is gone). - // It only refreshes the floor(s) this client already owns via its stream - // subscription, keyed by clientId. Mirrors the mobile `updateMobileViewport` - // no-op-without-subscription invariant. Returns false when the client owns no - // floor (passive/stream-less viewer) — a stream-less viewer must not lock host - // resize. - refreshRemoteDesktopViewer( - ptyId: string, - clientId: string, - cols: number, - rows: number, - claim = false - ): Promise { - const viewers = this.remoteDesktopViewers.get(ptyId) - if (!viewers) { - return Promise.resolve(false) - } - const viewport = clampTerminalViewport(cols, rows) - if (claim) { - // Why: terminal.send may be the first activity while the stream is only - // passively registered. Snapshot host truth before this refresh owns it. - this.ensureRemoteDesktopHostReclaimTarget(ptyId) - } - let changed = false - for (const [subscriptionKey, viewer] of viewers) { - if (viewer.clientId === clientId) { - const activity = claim ? ++this.remoteDesktopActivity : viewer.activity - viewers.set(subscriptionKey, { - ...viewer, - cols: viewport.cols, - rows: viewport.rows, - activity - }) - if (claim) { - this.remoteDesktopOwners.set(ptyId, subscriptionKey) - } - changed = true - } - } - if (!changed) { - return Promise.resolve(false) - } - this.bumpRemoteDesktopViewerRevision(ptyId) - return this.remoteDesktopOwners.has(ptyId) - ? this.applyRemoteDesktopLayout(ptyId) - : Promise.resolve(true) - } - - async updateDesktopViewport( - ptyId: string, - viewport: { cols: number; rows: number } - ): Promise { - const { cols, rows } = clampTerminalViewport(viewport.cols, viewport.rows) - if (this.terminalFitOverrides.has(ptyId) || this.getDriver(ptyId).kind === 'mobile') { - this.recordRendererGeometry(ptyId, cols, rows) - return true - } - if (this.isResizeSuppressed()) { - return false - } - this.freshSubscribeGuard.add(ptyId) - try { - const result = await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) - if (result.ok) { - this.refreshRendererGeometry(ptyId, cols, rows) - } - return result.ok - } finally { - this.freshSubscribeGuard.delete(ptyId) - } - } - - markMobileActor(ptyId: string, clientId: string): void { - const inner = this.mobileSubscribers.get(ptyId) - const sub = inner?.get(clientId) - if (sub) { - sub.lastActedAt = Date.now() - } - this.setDriver(ptyId, { kind: 'mobile', clientId }) - } - - beginMobileInputFloor( - ptyId: string, - clientId: string - ): { commit: () => Promise; rollback: () => void } | null { - // Why: admit a client still inside its soft-leave grace (mirrors - // mobileTookFloor) so a write landing in that window reserves the floor - // instead of being dropped; post-grace/orphaned writers stay rejected. - const softLeaver = this.pendingSoftLeavers.get(ptyId) - if (!this.mobileSubscribers.get(ptyId)?.has(clientId) && softLeaver?.clientId !== clientId) { - return null - } - const state = this.mobileInputFloorClaims.get(ptyId) ?? { - base: this.getDriver(ptyId), - generation: 0, - committedGeneration: 0, - pending: new Map() - } - this.mobileInputFloorClaims.set(ptyId, state) - const token = Symbol('mobile-input-floor') - const generation = ++state.generation - state.pending.set(token, { clientId, generation }) - this.setDriver(ptyId, { kind: 'mobile', clientId }) - let settled = false - return { - commit: async () => { - if (settled) { - return - } - settled = true - state.pending.delete(token) - // Why: a newer accepted write owns the floor; an older claim that was - // delayed before commit must not replace its rollback baseline or driver. - if (generation < state.committedGeneration) { - if (state.pending.size === 0 && this.mobileInputFloorClaims.get(ptyId) === state) { - this.mobileInputFloorClaims.delete(ptyId) - } - return - } - const previousFloor = state.base - // Why: a successful write becomes the rollback baseline for any - // overlapping reservations that have not reached the PTY yet. - state.committedGeneration = generation - state.base = { kind: 'mobile', clientId } - await this.mobileTookFloor( - ptyId, - clientId, - previousFloor, - () => - this.mobileInputFloorClaims.get(ptyId) === state && - state.committedGeneration === generation - ) - if (state.pending.size === 0 && this.mobileInputFloorClaims.get(ptyId) === state) { - this.mobileInputFloorClaims.delete(ptyId) - } - }, - rollback: () => { - if (settled) { - return - } - settled = true - state.pending.delete(token) - if (this.mobileInputFloorClaims.get(ptyId) !== state) { - return - } - const current = this.getDriver(ptyId) - if (current.kind === 'mobile' && current.clientId === clientId) { - const pendingClientId = Array.from(state.pending.values()).at(-1)?.clientId - this.setDriver( - ptyId, - pendingClientId ? { kind: 'mobile', clientId: pendingClientId } : state.base - ) - } - if (state.pending.size === 0) { - this.mobileInputFloorClaims.delete(ptyId) - } - } - } - } - - // Why: invoked from mobile RPC method handlers (terminal.send / setDisplayMode / - // resizeForClient / fresh subscribe with auto). Records the actor as the - // most recent mobile driver and re-applies phone-fit if we were previously - // in `desktop` mode (mobile reclaims a take-back). Mobile-to-mobile hand-offs - // are no-ops for resize. - async mobileTookFloor( - ptyId: string, - clientId: string, - previousFloor?: DriverState, - isCurrent: () => boolean = () => true - ): Promise { - const inner = this.mobileSubscribers.get(ptyId) - const sub = inner?.get(clientId) - const softLeaver = this.pendingSoftLeavers.get(ptyId) - // Why: native chat pauses terminal output, so its later sends have no - // subscriber lifecycle that could release a newly-created desktop lock. - if (!sub && softLeaver?.clientId !== clientId) { - return - } - if (sub) { - sub.lastActedAt = Date.now() - } - const prev = previousFloor ?? this.getDriver(ptyId) - const currentMode = this.mobileDisplayModes.get(ptyId) - // Why: a deliberate mobile action implies mobile is resuming control. - // If the display mode is currently 'desktop' (set by an earlier - // take-back), flip it back to 'auto' (= map absence) and re-apply so - // phone-fit takes hold again. See docs/mobile-presence-lock.md. - if (prev.kind === 'desktop' || currentMode === 'desktop') { - if (currentMode === 'desktop') { - this.mobileDisplayModes.delete(ptyId) - } - await this.applyMobileDisplayMode(ptyId) - } - // Why: display changes are async; a later PTY write must keep the floor - // when an older phone-fit operation eventually completes. - if (!isCurrent()) { - return - } - this.setDriver(ptyId, { kind: 'mobile', clientId }) - } - - // Why: in-place viewport update on the existing mobile subscription — - // used when the mobile keyboard opens/closes and shrinks/grows the - // visible terminal area. We refresh the subscriber's viewport, re-fit - // the PTY to the new dims, and emit a 'resized' event so the mobile - // xterm reinits inline at the new dims without re-subscribing. This - // avoids the unsubscribe → resubscribe cycle which would (a) flash the - // desktop lock banner during the brief idle gap and (b) cause the new - // subscribe to capture the already-phone-fitted PTY size as its - // restore baseline (stuck-dim bug on later disconnect). - // No-op when the client isn't actually subscribed to this PTY. - async updateMobileViewport( - ptyId: string, - clientId: string, - viewport: { cols: number; rows: number } - ): Promise<{ updated: boolean; applied: boolean }> { - const inner = this.mobileSubscribers.get(ptyId) - const sub = inner?.get(clientId) - if (!sub) { - return { updated: false, applied: false } - } - sub.viewport = viewport - sub.lastActedAt = Date.now() - - const mode = this.getMobileDisplayMode(ptyId) - if (mode === 'desktop') { - // Watching at desktop dims — viewport is informational only. - return { updated: true, applied: false } - } - // Why: a desktop take-back is released only by a deliberate mobile gesture - // (mobileTookFloor / setDisplayMode / fresh subscribe). A passive viewport report - // — iOS resume and every reconnect force one — must not re-phone-fit and re-take - // the floor, or the take-back looks like a no-op to the desktop user. - if (this.getDriver(ptyId).kind === 'desktop') { - return { updated: true, applied: false } - } - // Drive PTY dims by the most-recent-actor (just updated to this client). - const winner = this.pickMostRecentActor(inner!) - if (!winner) { - return { updated: false, applied: false } - } - const winnerSub = inner!.get(winner.clientId) - const driveViewport = winnerSub?.viewport ?? viewport - const { cols: clampedCols, rows: clampedRows } = clampTerminalViewport( - driveViewport.cols, - driveViewport.rows - ) - - sub.wasResizedToPhone = true - // The driver is already mobile{this client} when we got here; refresh - // to update lastActedAt-based ordering on later actor selection. - this.setDriver(ptyId, { kind: 'mobile', clientId }) - - const needsFreshSubscribeGuard = !this.layouts.has(ptyId) - if (needsFreshSubscribeGuard) { - this.freshSubscribeGuard.add(ptyId) - } - let result: ApplyLayoutResult - try { - result = await this.enqueueLayout(ptyId, { - kind: 'phone', - cols: clampedCols, - rows: clampedRows, - ownerClientId: winner.clientId - }) - } finally { - if (needsFreshSubscribeGuard) { - this.freshSubscribeGuard.delete(ptyId) - } - } - return { updated: true, applied: result.ok } - } - - // Why: invoked from `runtime:restoreTerminalFit` IPC (the desktop "Take - // back" / "Restore" button). Forces the PTY back to desktop dims and flips - // the driver to `desktop`, suppressing further mobile-driven dim changes - // until a mobile actor takes the floor again. Three cases, each ending in - // releaseDesktopTakeBack: - // 1. Active mobile subscriber: route through applyMobileDisplayMode so the - // existing 'resized' event reaches the phone. - // 2. Held override, no subscriber (post-indefinite-hold): resolve the - // restore target and enqueueLayout directly. - // 3. Stale mobile driver, no subscriber and no override: nothing to resize, - // just drop the lock. See docs/mobile-fit-hold.md. - // - // Why: explicit desktop take-back is a user command to reclaim input control - // NOW. Unlike the auto-restore timer and phone-initiated setDisplayMode paths - // (which keep the lock when a resize can't converge, #7588), this gesture - // ALWAYS drops the presence lock and banner. "Take back all terminals" - // reclaims several PTYs at once; a background pane whose desktop resize can't - // converge must not strand its banner on the other terminals. The resize is - // best-effort — the desktop renderer refits the PTY on its next settled - // frame. Returns `true` whenever there was a lock to reclaim, `false` only - // when there was nothing to reclaim. - async reclaimTerminalForDesktop(ptyId: string): Promise { - this.cancelPendingDriverMutations(ptyId) - if (this.isMobileSubscriberActive(ptyId)) { - this.setMobileDisplayMode(ptyId, 'desktop') - await this.applyMobileDisplayMode(ptyId) - this.releaseDesktopTakeBack(ptyId) - // Why: a desktop-initiated reclaim is "I'm taking over right now", not a - // sticky preference. The next mobile subscribe (e.g. user switches back to - // the terminal tab on the phone) must default to phone-fit again, not stay - // in passive desktop-watch mode. - this.setMobileDisplayMode(ptyId, 'auto') - if (this.hasRemoteDesktopLayoutState(ptyId)) { - // Why: the lock is already released above, so this re-layout is - // best-effort. Reporting its `ok` would tell the desktop "nothing was - // reclaimed" and cost the caller its post-take-back refit and focus. - await this.applyRemoteDesktopLayout(ptyId) - } - return true - } - const heldOverride = this.terminalFitOverrides.get(ptyId) - if (heldOverride && this.hasRemoteDesktopLayoutState(ptyId)) { - // Why: applyRemoteDesktopLayout no-ops while the driver still reads mobile. - this.setDriver(ptyId, { kind: 'idle' }) - // Why: best-effort, like the local held branch below. A host whose resize - // keeps failing (dropped SSH/WSL provider, exited PTY) would otherwise - // roll the lock back and leave the banner stranded, making every retry a - // no-op — the one branch that broke this method's release guarantee. - await this.applyRemoteDesktopLayout(ptyId) - this.releaseDesktopTakeBack(ptyId) - this.setMobileDisplayMode(ptyId, 'auto') - return true - } - if (heldOverride) { - // Why: with no subscribers, resolveDesktopRestoreTarget can fall through - // to current PTY size — which is at phone dims (wrong). Prefer a fresh - // desktop renderer measurement when one exists; otherwise use the - // override's pre-fit baseline before falling back to current size. - const fallback = this.resolveDesktopRestoreTarget(ptyId) - const renderer = this.lastRendererSizes.get(ptyId) - const cols = renderer?.cols ?? heldOverride.previousCols ?? fallback.cols - const rows = renderer?.rows ?? heldOverride.previousRows ?? fallback.rows - await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) - this.releaseDesktopTakeBack(ptyId) - this.setMobileDisplayMode(ptyId, 'auto') - return true - } - // Why: a stale lock — driver still reads mobile with no active subscriber - // and no held override (e.g. reclaimed inside the soft-leave grace, or a - // subscriber that dropped without a clean unsubscribe). Release it so the - // banner can't linger; there is nothing to resize. - if (this.getDriver(ptyId).kind === 'mobile') { - this.releaseDesktopTakeBack(ptyId) - return true - } - return false - } - - // Why: teardown and desktop reclaim supersede delayed mobile mutations, - // revoking soft-leave grace admission for input floors. - private cancelPendingDriverMutations(ptyId: string): void { - const pendingRestore = this.pendingRestoreTimers.get(ptyId) - if (pendingRestore) { - clearTimeout(pendingRestore.timer) - this.pendingRestoreTimers.delete(ptyId) - } - const pendingSoft = this.pendingSoftLeavers.get(ptyId) - if (pendingSoft) { - clearTimeout(pendingSoft.timer) - this.pendingSoftLeavers.delete(ptyId) - } - } - - // Why: the shared "banner must be gone now" step for an explicit desktop - // take-back. Releases the presence lock (driver → desktop) and, if the - // best-effort resize left a fit-override held (resize didn't converge), - // clears it optimistically with a paired desktop-fit 0×0 — the same signal - // onPtyExit emits — so neither the presence-lock banner nor the held-fit - // banner can survive the reclaim. The desktop renderer refits the PTY to real - // dims on its next settled frame. - private releaseDesktopTakeBack(ptyId: string): void { - this.setDriver(ptyId, { kind: 'desktop' }) - if (this.terminalFitOverrides.has(ptyId)) { - this.terminalFitOverrides.delete(ptyId) - this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) - this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) - } - } - - // Why: read-side clamp for mobileAutoRestoreFitMs. `null` means - // indefinite hold (no auto-restore timer). A finite value is clamped - // to [MIN, MAX] to defend against bad config — the smallest useful - // value is a few seconds, the largest is one hour. See - // docs/mobile-fit-hold.md. - private getAutoRestoreFitMs(): number | null { - const raw = this.store?.getSettings().mobileAutoRestoreFitMs ?? null - if (raw == null) { - return null - } - if (typeof raw !== 'number' || !Number.isFinite(raw)) { - return null - } - return Math.min(Math.max(raw, MOBILE_AUTO_RESTORE_FIT_MIN_MS), MOBILE_AUTO_RESTORE_FIT_MAX_MS) - } - - // Why: invoked when the user changes mobileAutoRestoreFitMs to `null` - // (Indefinite). Clears every pending restore timer so the just-expressed - // preference "do not auto-restore" is honored for ALL currently-pending - // PTYs, not just one. See docs/mobile-fit-hold.md. - cancelAllPendingFitRestoreTimers(): void { - for (const [, entry] of this.pendingRestoreTimers) { - clearTimeout(entry.timer) - } - this.pendingRestoreTimers.clear() - } - - // Why: read the persisted user preference (clamped) for surfacing to UI - // callers (mobile RPC, desktop preferences). Returns null when the - // setting is unset or `null` ("Indefinite"). - getMobileAutoRestoreFitMs(): number | null { - return this.getAutoRestoreFitMs() - } - - // Why: persisted-preference setter routed through the same `Store` the - // desktop preferences UI writes to. Transitions to `null` (Indefinite) - // clear every pending restore timer to honor the preference change for - // already-held PTYs. Transitions to a finite value do NOT retroactively - // schedule timers for PTYs that are currently held — those PTYs were - // already-not-restored under the old preference, and silently scheduling - // a restore on a settings change would be surprising. The new value - // takes effect on the next unsubscribe. See docs/mobile-fit-hold.md. - setMobileAutoRestoreFitMs(ms: number | null): number | null { - if (!this.store?.updateSettings) { - return this.getAutoRestoreFitMs() - } - let normalized: number | null - if (ms == null) { - normalized = null - } else if (typeof ms !== 'number' || !Number.isFinite(ms)) { - normalized = null - } else { - normalized = Math.min( - Math.max(ms, MOBILE_AUTO_RESTORE_FIT_MIN_MS), - MOBILE_AUTO_RESTORE_FIT_MAX_MS - ) - } - this.store.updateSettings({ mobileAutoRestoreFitMs: normalized }, { notifyListeners: true }) - if (normalized == null) { - this.cancelAllPendingFitRestoreTimers() - } - return normalized - } - - // Why: with multiple subscribers, the active phone-fit dims follow the - // most recent mobile actor (argmax(lastActedAt)). See - // docs/mobile-presence-lock.md "Active phone-fit dim selection". - private pickMostRecentActor( - inner: Map - ): { clientId: string; lastActedAt: number } | null { - let best: { clientId: string; lastActedAt: number } | null = null - for (const sub of inner.values()) { - if (best === null || sub.lastActedAt > best.lastActedAt) { - best = sub - } - } - return best - } - - // Why: restore-target selection on last-subscriber-leaves picks the - // earliest-by-subscribe-time subscriber AMONG those with non-null - // previousCols/Rows. Desktop-mode joins carry null and are skipped — they - // never captured pre-fit dims by design. - private pickEarliestRestoreTarget( - inner: Map< - string, - { subscribedAt: number; previousCols: number | null; previousRows: number | null } - > - ): { previousCols: number; previousRows: number } | null { - let best: { subscribedAt: number; previousCols: number; previousRows: number } | null = null - for (const sub of inner.values()) { - if (sub.previousCols == null || sub.previousRows == null) { - continue - } - if (best === null || sub.subscribedAt < best.subscribedAt) { - best = { - subscribedAt: sub.subscribedAt, - previousCols: sub.previousCols, - previousRows: sub.previousRows - } - } - } - return best ? { previousCols: best.previousCols, previousRows: best.previousRows } : null - } - - // ─── Layout state machine ───────────────────────────────────────── - // - // See docs/mobile-terminal-layout-state-machine.md. - // - // applyLayout is the SOLE writer of: - // - this.layouts - // - this.terminalFitOverrides (except the sanctioned dead-pty cleanups in - // onPtyExit and reclaimTerminalForDesktop's orphan branch, which delete) - // - this.ptyController.resize (i.e. the actual PTY dims) - // - // Every trigger that wants to change PTY dims or flip mode goes through - // enqueueLayout, which serializes calls behind a per-PTY async queue - // (the await on ptyController.resize would otherwise let seq bumps reach - // the wire out of order). - - getLayout(ptyId: string): PtyLayoutState | null { - return this.layouts.get(ptyId) ?? null - } - - // Why: `enqueueLayout`'s "no layouts entry" short-circuit must not fire - // on the very first transition for a PTY (where the entry doesn't exist - // yet *because* we're about to create it). handleMobileSubscribe adds - // the ptyId to `freshSubscribeGuard` before calling enqueueLayout and - // removes it in a finally block. - private isFreshSubscribe(ptyId: string): boolean { - return this.freshSubscribeGuard.has(ptyId) - } - - // Why: four-step fallback chain for desktop-restore targets. Always - // returns a value; the terminal {80,24} branch is reached only under - // bug. Wrapping the chain as a single helper prevents callsite drift. - private resolveDesktopRestoreTarget(ptyId: string): { cols: number; rows: number } { - // 1. Earliest-by-subscribedAt subscriber with non-null baseline. - const inner = this.mobileSubscribers.get(ptyId) - if (inner) { - const earliest = this.pickEarliestRestoreTarget(inner) - if (earliest) { - return { cols: earliest.previousCols, rows: earliest.previousRows } - } - } - // 2. Most-recent desktop renderer geometry report. - const renderer = this.lastRendererSizes.get(ptyId) - if (renderer) { - return { cols: renderer.cols, rows: renderer.rows } - } - // 3. Current PTY size. - const size = this.getTerminalSize(ptyId) - if (size) { - return { cols: size.cols, rows: size.rows } - } - // 4. Hard default. - return { cols: 80, rows: 24 } - } - - // Why: a new viewport-only update from the same owner supersedes a - // queued same-shape tail. Mode flips, owner changes, and take-back - // append (losing a take-floor to a viewport tick would be a fairness - // hole — see "enqueueLayout coalescing" in the design doc). - private coalescesWith(prev: PtyLayoutTarget, next: PtyLayoutTarget): boolean { - if (prev.kind !== next.kind) { - return false - } - if (prev.kind === 'phone' && next.kind === 'phone') { - return prev.ownerClientId === next.ownerClientId - } - if (prev.kind === 'remote-desktop' && next.kind === 'remote-desktop') { - // Why: each owner's claim promise gates its following input. Sharing a - // waiter across owners could release A's input only after B's grid lands. - return prev.ownerSubscriptionKey === next.ownerSubscriptionKey - } - return true - } - - private enqueueLayout(ptyId: string, target: PtyLayoutTarget): Promise { - // Why: PTY-exit short-circuit. Fresh-subscribe gate lets the very first - // transition through even though `layouts` has no entry yet. - if (!this.layouts.has(ptyId) && !this.isFreshSubscribe(ptyId)) { - return Promise.resolve({ ok: false, reason: 'pty-exited' }) - } - - let entry = this.layoutQueues.get(ptyId) - if (!entry) { - entry = { running: null, pending: [] } - this.layoutQueues.set(ptyId, entry) - } - const queue = entry - - return new Promise((resolve) => { - if (!queue.running) { - queue.running = this.runLayoutSlot(ptyId, target, [resolve]) - return - } - const tail = queue.pending.at(-1) - if (tail && this.coalescesWith(tail.target, target)) { - tail.target = target - tail.waiters.push(resolve) - return - } - queue.pending.push({ target, waiters: [resolve] }) - }) - } - - private async runLayoutSlot( - ptyId: string, - target: PtyLayoutTarget, - waiters: ((r: ApplyLayoutResult) => void)[] - ): Promise { - let result: ApplyLayoutResult - try { - result = await this.applyLayout(ptyId, target) - } catch (err) { - // Why: defensive — applyLayout itself catches resize errors, but a - // throw from one of the synchronous map writes (e.g. notifier hook) - // must not jam the queue forever. - console.error('[layout] applyLayout threw', { ptyId, err }) - result = { ok: false, reason: 'resize-failed' } - } - for (const w of waiters) { - w(result) - } - - const queue = this.layoutQueues.get(ptyId) - if (!queue) { - return result - } - const next = queue.pending.shift() - if (next) { - queue.running = this.runLayoutSlot(ptyId, next.target, next.waiters) - } else { - queue.running = null - // Why: drop the entry once empty so the map doesn't grow without bound - // across short-lived PTYs. - this.layoutQueues.delete(ptyId) - } - return result - } - - private async applyLayout(ptyId: string, target: PtyLayoutTarget): Promise { - // Why: re-check pty-exit at the head of the slot — the queue may have - // accepted this target before onPtyExit ran. - if (!this.layouts.has(ptyId) && !this.isFreshSubscribe(ptyId)) { - return { ok: false, reason: 'pty-exited' } - } - - const prev = this.layouts.get(ptyId) ?? null - const seq = (prev?.seq ?? 0) + 1 - const next: PtyLayoutState = { ...target, seq, appliedAt: Date.now() } - - const currentSize = this.getTerminalSize(ptyId) - const dimsChanged = currentSize?.cols !== target.cols || currentSize?.rows !== target.rows - const modeChanged = (prev?.kind ?? 'desktop') !== target.kind - - // Snapshot for rollback. - const prevFitOverride = this.terminalFitOverrides.get(ptyId) ?? null - - // Tentative writes — the resize is the point of no return. - this.layouts.set(ptyId, next) - if (target.kind === 'phone') { - // Why: pull baseline cols+rows atomically from the same subscriber so - // they can't desync. - const baseline = (() => { - const inner = this.mobileSubscribers.get(ptyId) - if (!inner) { - return null - } - return this.pickEarliestRestoreTarget(inner) - })() - this.terminalFitOverrides.set(ptyId, { - mode: 'mobile-fit', - cols: target.cols, - rows: target.rows, - previousCols: baseline?.previousCols ?? null, - previousRows: baseline?.previousRows ?? null, - updatedAt: next.appliedAt, - clientId: target.ownerClientId - }) - } else { - this.terminalFitOverrides.delete(ptyId) - } - - if (dimsChanged) { - let ok = false - try { - const r = this.ptyController?.resize?.(ptyId, target.cols, target.rows) - ok = r ?? true - } catch (err) { - console.error('[layout] ptyController.resize threw', { ptyId, err }) - ok = false - } - if (!ok) { - // Roll back to pre-call snapshot. seq is NOT bumped on the wire - // because we never emit below. - if (prev) { - this.layouts.set(ptyId, prev) - } else { - this.layouts.delete(ptyId) - } - if (prevFitOverride) { - this.terminalFitOverrides.set(ptyId, prevFitOverride) - } else { - this.terminalFitOverrides.delete(ptyId) - } - return { ok: false, reason: 'resize-failed' } - } - this.resizeHeadlessTerminal(ptyId, target.cols, target.rows) - } - - // Why: remote desktop ownership is a fit hold for the host and passive - // peer viewers. Emit every remote layout so owner changes at equal geometry - // still park/release the correct clients without relying on resize deltas. - // Defense-in-depth (#7588): also emit when the override's presence - // changed even without a kind flip. applyLayout is the sole writer and - // keeps override presence in lockstep with layout kind, so overrideChanged - // ≡ modeChanged in every reachable state today; the extra clause fires - // only if that invariant is ever violated, repairing the renderer instead - // of stranding the held modal. - const overrideChanged = (prevFitOverride != null) !== (target.kind === 'phone') - if (target.kind === 'remote-desktop' || modeChanged || overrideChanged) { - // Why: phone→desktop arms the renderer-cascade suppress window - // before the collateral safeFit IPCs arrive. See "Renderer cascade - // suppression". - if (target.kind === 'desktop') { - this.lastRendererSizes.delete(ptyId) - this.suppressResizesForMs(500) - } - this.notifier?.terminalFitOverrideChanged( - ptyId, - target.kind === 'phone' - ? 'mobile-fit' - : target.kind === 'remote-desktop' - ? 'remote-desktop-fit' - : 'desktop-fit', - target.cols, - target.rows - ) - this.notifyFitOverrideListeners( - ptyId, - target.kind === 'phone' - ? 'mobile-fit' - : target.kind === 'remote-desktop' - ? 'remote-desktop-fit' - : 'desktop-fit', - target.cols, - target.rows - ) - } - - // Mobile-facing event always fires (phone clients need to re-fit on - // every dim change, not just mode flips). - this.notifyTerminalResize(ptyId, { - cols: target.cols, - rows: target.rows, - displayMode: target.kind === 'phone' ? 'phone' : 'desktop', - reason: 'apply-layout', - seq - }) - - return { ok: true, state: next } - } - - // ─── Server-Authoritative Mobile Display Mode ───────────────────── - - setMobileDisplayMode(ptyId: string, mode: 'auto' | 'desktop'): void { - if (mode === 'auto') { - this.mobileDisplayModes.delete(ptyId) - } else { - this.mobileDisplayModes.set(ptyId, mode) - } - } - - getMobileDisplayMode(ptyId: string): 'auto' | 'desktop' { - return this.mobileDisplayModes.get(ptyId) ?? 'auto' - } - - isMobileSubscriberActive(ptyId: string): boolean { - const inner = this.mobileSubscribers.get(ptyId) - return inner !== undefined && inner.size > 0 - } - - // Why: late-bind viewport on an existing subscriber record. Subscribers - // that registered before the mobile side measured (e.g. terminal first - // mounted while the WebView was still loading) have null viewport, and - // applyMobileDisplayMode's auto branch needs a viewport to phone-fit. - // The setDisplayMode RPC carries the latest viewport so we can patch it - // here just before applyMobileDisplayMode runs. - updateMobileSubscriberViewport( - ptyId: string, - clientId: string, - viewport: { cols: number; rows: number } - ): void { - const inner = this.mobileSubscribers.get(ptyId) - const record = inner?.get(clientId) - if (!record) { - return - } - record.viewport = viewport - } - - // Why: server-side auto-fit on mobile subscribe. The runtime is the single - // source of truth — the mobile client just passes its viewport and the runtime - // decides whether to resize. This eliminates the measure→RPC→resubscribe - // pipeline that caused race conditions. - // - // Multi-mobile keying: each subscriber lives in `mobileSubscribers[ptyId]`'s - // inner map under its own clientId. Phone B subscribing does not overwrite - // phone A's record — both stay until each unsubscribes. - // - // Subscribe-in-desktop-mode rule: a subscribe with displayMode='desktop' is - // a passive watch; it does NOT take the floor. The driver remains - // `idle`/`desktop`. The lock banner is reserved for actual mobile - // interaction (input/resize/setDisplayMode/auto-or-phone subscribe). - async handleMobileSubscribe( - ptyId: string, - clientId: string, - viewport?: { cols: number; rows: number } - ): Promise { - try { - return await this.handleMobileSubscribeInternal(ptyId, clientId, viewport) - } finally { - // Every subscribe path mutates mobileSubscribers — resync the daemon - // background mark once, whatever branch returned. - this.notifyRemoteTerminalViewPresenceChanged(ptyId) - } - } - - private async handleMobileSubscribeInternal( - ptyId: string, - clientId: string, - viewport?: { cols: number; rows: number } - ): Promise { - const mode = this.getMobileDisplayMode(ptyId) - - // Cancel pending restore timer for this ptyId — any new subscriber - // supersedes any old client's pending restore. - const pendingRestore = this.pendingRestoreTimers.get(ptyId) - if (pendingRestore) { - clearTimeout(pendingRestore.timer) - this.pendingRestoreTimers.delete(ptyId) - } - - // Resubscribe-grace honor: same client returning within soft-leave - // window restores prior record (preserving baseline so we don't capture - // phone-fitted dims as the new baseline). - const softLeaver = this.pendingSoftLeavers.get(ptyId) - if (softLeaver && softLeaver.clientId === clientId) { - clearTimeout(softLeaver.timer) - this.pendingSoftLeavers.delete(ptyId) - let inner = this.mobileSubscribers.get(ptyId) - if (!inner) { - inner = new Map() - this.mobileSubscribers.set(ptyId, inner) - } - inner.set(clientId, { - ...softLeaver.record, - viewport: viewport ?? null, - lastActedAt: Date.now() - }) - if (!viewport) { - return false - } - this.setDriver(ptyId, { kind: 'mobile', clientId }) - if (mode !== 'desktop') { - const { cols: clampedCols, rows: clampedRows } = clampTerminalViewport( - viewport.cols, - viewport.rows - ) - this.freshSubscribeGuard.add(ptyId) - try { - await this.enqueueLayout(ptyId, { - kind: 'phone', - cols: clampedCols, - rows: clampedRows, - ownerClientId: clientId - }) - } finally { - this.freshSubscribeGuard.delete(ptyId) - } - } - return true - } - - let inner = this.mobileSubscribers.get(ptyId) - if (!inner) { - inner = new Map() - this.mobileSubscribers.set(ptyId, inner) - } - - // Capture restore baseline BEFORE applyLayout writes the override. - // Multi-mobile: peer joiner against an already-fitted PTY captures null - // — the existing baseline-holder's snapshot remains canonical. See - // docs/mobile-presence-lock.md. - // - // Resubscribe-after-indefinite-hold: the held override carries the only - // authoritative pre-fit dims across the no-subscriber gap. Inherit it - // first; otherwise rendererSize/currentSize would be the held phone dims - // and applyLayout would clobber the override's previousCols with phone - // dims, making any subsequent Restore a no-op. - const heldOverride = this.terminalFitOverrides.get(ptyId) - const existing = inner.get(clientId) - const someoneAlreadyFitted = [...inner.values()].some((s) => s.wasResizedToPhone) - const currentSize = this.getTerminalSize(ptyId) - const rendererSize = this.lastRendererSizes.get(ptyId) - const previousCols = - existing?.previousCols ?? - heldOverride?.previousCols ?? - (someoneAlreadyFitted ? null : (rendererSize?.cols ?? currentSize?.cols ?? null)) - const previousRows = - existing?.previousRows ?? - heldOverride?.previousRows ?? - (someoneAlreadyFitted ? null : (rendererSize?.rows ?? currentSize?.rows ?? null)) - const now = Date.now() - const subscribedAt = existing?.subscribedAt ?? now - - if (!viewport) { - // Why: mobile can subscribe before its WebView has measured. Keep the - // subscriber + desktop baseline so updateViewport/setDisplayMode can - // late-bind the viewport without recapturing phone dims. - inner.set(clientId, { - clientId, - viewport: null, - wasResizedToPhone: false, - previousCols, - previousRows, - subscribedAt, - lastActedAt: now - }) - return false - } - - const { cols: clampedCols, rows: clampedRows } = clampTerminalViewport( - viewport.cols, - viewport.rows - ) - - if (mode === 'desktop') { - // Passive watch — null baseline (we'll capture later if user toggles - // to auto/phone, since safeFit will have converged by then). Do not - // flip driver. - inner.set(clientId, { - clientId, - viewport, - wasResizedToPhone: false, - previousCols: null, - previousRows: null, - subscribedAt, - lastActedAt: now - }) - return false - } - - inner.set(clientId, { - clientId, - viewport, - wasResizedToPhone: true, - previousCols, - previousRows, - subscribedAt, - lastActedAt: now - }) - - // Subscribe-fresh with auto/phone counts as "take the floor". - this.setDriver(ptyId, { kind: 'mobile', clientId }) - - // Route the actual resize through the state machine. The fresh-subscribe - // gate lets enqueueLayout's "no layouts entry" short-circuit pass on - // the very first transition for this PTY. - this.freshSubscribeGuard.add(ptyId) - try { - await this.enqueueLayout(ptyId, { - kind: 'phone', - cols: clampedCols, - rows: clampedRows, - ownerClientId: clientId - }) - } finally { - this.freshSubscribeGuard.delete(ptyId) - } - - return true - } - - // Why: delayed restore prevents resize thrashing during rapid tab switches. - // The 300ms debounce means only the final tab triggers a PTY restore; - // intermediate terminals keep their current dims harmlessly. - // - // Multi-mobile: only the last subscriber leaving for this ptyId triggers - // restore + driver=idle. Peer mobile clients still on the inner map keep - // the lock banner mounted; if the disconnecting client was the active - // driver, we re-elect the most-recent surviving subscriber. - handleMobileUnsubscribe(ptyId: string, clientId: string): void { - const inner = this.mobileSubscribers.get(ptyId) - if (!inner) { - return - } - const subscriber = inner.get(clientId) - if (!subscriber) { - return - } - const wasResizedToPhone = subscriber.wasResizedToPhone - - inner.delete(clientId) - this.notifyRemoteTerminalViewPresenceChanged(ptyId) - - if (inner.size > 0) { - // Why: if the leaving client was the only one with a non-null restore - // baseline (typical when peer joiners subscribed against an - // already-phone-fitted PTY and got null prevCols), donate the baseline - // to the earliest surviving subscriber so a future last-leaver can - // still restore correctly. See docs/mobile-presence-lock.md. - if ( - subscriber.previousCols != null && - subscriber.previousRows != null && - !this.pickEarliestRestoreTarget(inner) - ) { - let earliestSurvivor: { clientId: string; subscribedAt: number } | null = null - for (const sub of inner.values()) { - if (earliestSurvivor === null || sub.subscribedAt < earliestSurvivor.subscribedAt) { - earliestSurvivor = { clientId: sub.clientId, subscribedAt: sub.subscribedAt } - } - } - if (earliestSurvivor) { - const heir = inner.get(earliestSurvivor.clientId) - if (heir) { - heir.previousCols = subscriber.previousCols - heir.previousRows = subscriber.previousRows - } - } - } - // Peers still on the line. If the disconnecting client was the active - // mobile driver, re-elect the most-recent surviving subscriber so the - // banner remains correct and active phone-fit dims follow them. - const driver = this.getDriver(ptyId) - if (driver.kind === 'mobile' && driver.clientId === clientId) { - const next = this.pickMostRecentActor(inner) - if (next) { - this.setDriver(ptyId, { kind: 'mobile', clientId: next.clientId }) - // Fire-and-forget — handleMobileUnsubscribe stays sync; applyLayout - // failures self-recover on the next gesture. - void this.applyMobileDisplayMode(ptyId) - } - } - return - } - - // Last subscriber leaving — clean up. - this.mobileSubscribers.delete(ptyId) - const mode = this.getMobileDisplayMode(ptyId) - - // Resubscribe-grace: hold driver=mobile{clientId} for ~250ms so a quick - // re-subscribe (older clients without updateViewport) doesn't flash the - // desktop banner. See docs/mobile-presence-lock.md. - const SOFT_LEAVE_GRACE_MS = 250 - const existingSoft = this.pendingSoftLeavers.get(ptyId) - if (existingSoft) { - clearTimeout(existingSoft.timer) - this.pendingSoftLeavers.delete(ptyId) - } - const softTimer = setTimeout(() => { - this.pendingSoftLeavers.delete(ptyId) - if (!this.mobileSubscribers.has(ptyId)) { - this.setDriver(ptyId, { kind: 'idle' }) - if (this.hasRemoteDesktopViewers(ptyId)) { - void this.applyRemoteDesktopLayout(ptyId) - } - } - }, SOFT_LEAVE_GRACE_MS) - if (typeof softTimer.unref === 'function') { - softTimer.unref() - } - this.pendingSoftLeavers.set(ptyId, { - clientId, - timer: softTimer, - record: { - clientId: subscriber.clientId, - viewport: subscriber.viewport, - wasResizedToPhone: subscriber.wasResizedToPhone, - previousCols: subscriber.previousCols, - previousRows: subscriber.previousRows, - subscribedAt: subscriber.subscribedAt, - lastActedAt: subscriber.lastActedAt - } - }) - - if (mode === 'auto' && wasResizedToPhone) { - const existingTimer = this.pendingRestoreTimers.get(ptyId) - if (existingTimer) { - clearTimeout(existingTimer.timer) - this.pendingRestoreTimers.delete(ptyId) - } - // Why: scheduling is conditional on the user's mobileAutoRestoreFitMs - // preference. `null` (default, "Indefinite") leaves the PTY at phone - // dims until the user clicks Restore on the desktop banner — the - // central UX promise of docs/mobile-fit-hold.md. A finite value runs - // the restore that long after the last unsubscribe. - const autoRestoreMs = this.getAutoRestoreFitMs() - if (autoRestoreMs == null) { - // Indefinite hold: the fit override persists, the SOFT_LEAVE_GRACE - // driver-state grace above still releases the input lock, and the - // banner's Restore button is the explicit return path. - } else { - // Snapshot the disconnecting subscriber's baseline NOW, before the - // timer fires. By the time the timer runs, the subscriber map has - // been deleted; resolveDesktopRestoreTarget would fall through to - // lastRendererSizes → current PTY size (which is at phone dims, - // wrong). The disconnecting subscriber's baseline is the correct - // restore target. - const fallback = this.lastRendererSizes.get(ptyId) - const restoreCols = - subscriber.previousCols ?? fallback?.cols ?? this.getTerminalSize(ptyId)?.cols ?? 80 - const restoreRows = - subscriber.previousRows ?? fallback?.rows ?? this.getTerminalSize(ptyId)?.rows ?? 24 - const timer = setTimeout(() => { - this.pendingRestoreTimers.delete(ptyId) - if (this.isMobileSubscriberActive(ptyId)) { - return - } - if (this.hasRemoteDesktopLayoutState(ptyId)) { - void this.applyRemoteDesktopLayout(ptyId) - return - } - void this.enqueueLayout(ptyId, { - kind: 'desktop', - cols: restoreCols, - rows: restoreRows - }) - }, autoRestoreMs) - // Why: a delayed mobile restore should not keep Electron main alive - // after the last window/runtime transport has otherwise shut down. - if (typeof timer.unref === 'function') { - timer.unref() - } - - this.pendingRestoreTimers.set(ptyId, { timer, clientId }) - } - } - // 'desktop' mode: was never resized, nothing to restore. - } - - // Why: called when mode changes via terminal.setDisplayMode. Applies the - // mode change immediately if there's an active subscriber, and emits a - // 'resized' event so the mobile client can reinitialize xterm inline. - // - // Multi-mobile: the most recent mobile actor's viewport drives the active - // phone-fit dims. The earliest-by-subscribe-time subscriber's - // previousCols/Rows drive the desktop-restore target. - // - // Returns the post-condition "no fit-override remains held" (#7588): `true` - // when it cleared a held override OR nothing was held to begin with, `false` - // only when a restore was attempted and the resize failed (override rolled - // back, still held). Informational for every caller today — - // reclaimTerminalForDesktop deliberately does NOT gate on it, because an - // explicit take-back must drop the lock even when the resize cannot - // converge. Do not reinstate a convergence gate there. - async applyMobileDisplayMode(ptyId: string): Promise { - const mode = this.getMobileDisplayMode(ptyId) - const inner = this.mobileSubscribers.get(ptyId) - const subscriber = inner ? this.pickMostRecentActor(inner) : null - const subscriberRecord = subscriber && inner ? inner.get(subscriber.clientId) : null - - if (mode === 'desktop') { - // Reset wasResizedToPhone on every fitted subscriber so a future - // toggle back to auto re-issues the resize. applyLayout owns the - // actual PTY resize + override delete + renderer notify. Track which - // subscribers we cleared so a failed resize can re-arm them. - const clearedFitSubscribers = inner - ? [...inner.values()].filter((sub) => sub.wasResizedToPhone) - : [] - for (const sub of clearedFitSubscribers) { - sub.wasResizedToPhone = false - } - const anyWasResized = clearedFitSubscribers.length > 0 - // Why (#7588): also restore when a fit-override is still held but no - // subscriber carries wasResizedToPhone — e.g. a null-viewport resubscribe - // after an indefinite hold resets the flag yet leaves the override, - // stranding the desktop "phone size" modal. Reuse resolveDesktopRestoreTarget - // (the same resolver the anyWasResized branch uses) so the two adjacent - // restore paths can never resolve to different dims for the same state. - if (anyWasResized || this.terminalFitOverrides.has(ptyId)) { - const restore = this.resolveDesktopRestoreTarget(ptyId) - const result = await this.enqueueLayout(ptyId, { - kind: 'desktop', - cols: restore.cols, - rows: restore.rows - }) - // Why (#7588): a failed resize rolls the override back (still held), so - // re-arm the flags we cleared. Otherwise a later unsubscribe under a - // finite mobileAutoRestoreFitMs would see wasResizedToPhone=false, skip - // scheduling its auto-restore timer, and strand the held phone-fit. - if (!result.ok) { - for (const sub of clearedFitSubscribers) { - sub.wasResizedToPhone = true - } - } - } else { - // Nothing was fitted or held — emit a mode-change resize event so - // the mobile client still learns the toggle landed. - const size = this.getTerminalSize(ptyId) - this.notifyTerminalResize(ptyId, { - cols: size?.cols ?? 0, - rows: size?.rows ?? 0, - displayMode: 'desktop', - reason: 'mode-change', - seq: this.layouts.get(ptyId)?.seq - }) - } - } else { - // mode === 'auto' — the only non-desktop mode after the 'phone' - // (sticky-fit) collapse. Phone-fit if the active subscriber has a - // viewport and we haven't already applied it. - if (subscriberRecord && !subscriberRecord.wasResizedToPhone) { - const viewport = subscriberRecord.viewport - if (viewport) { - await this.handleMobileSubscribe(ptyId, subscriberRecord.clientId, viewport) - // After a phone-fit an override IS held, so this reports false. The - // auto branch is never reached from reclaim (it sets 'desktop' - // first); computed here only to keep the post-condition uniform. - return !this.terminalFitOverrides.has(ptyId) - } - } - // Why: always emit the mode change even when no resize occurred — the - // mobile client needs to learn the toggle landed even if dims didn't - // actually change. Carry the current seq (or undefined if no layout - // entry yet) so the mobile-side stale-event filter behaves correctly. - const size = this.getTerminalSize(ptyId) - this.notifyTerminalResize(ptyId, { - cols: size?.cols ?? 0, - rows: size?.rows ?? 0, - displayMode: 'auto', - reason: 'mode-change', - seq: this.layouts.get(ptyId)?.seq - }) - } - return !this.terminalFitOverrides.has(ptyId) - } - - // Why: called after a desktop renderer path has successfully resized the - // PTY (local IPC or remote desktop viewport). The runtime mirror must take - // the same accepted geometry so hidden-output restore parses at PTY width. - onExternalPtyResize(ptyId: string, cols: number, rows: number): void { - // The pty:resize IPC handler is supposed to gate via `isResizeSuppressed` - // before calling here, but defend against callers that don't. - if (this.isResizeSuppressed()) { - return - } - // Why: while a mobile-fit override is in place, the desktop renderer's - // safeFit echoes pty:resize(override.cols, override.rows). Treating that - // echo as legitimate geometry would overwrite each subscriber's - // previousCols/Rows baseline with phone dims, so the next take-back - // enqueues a no-op {kind:'desktop', cols:49, rows:40} and leaves xterm - // stuck. Only filter reports that EXACTLY match the override — a fresh - // measurement from a now-visible pane (e.g. user activated a previously - // hidden tab on desktop, container went 0×0 → 1782×1195) reports - // different dims and is the right baseline to remember. - const activeOverride = this.terminalFitOverrides.get(ptyId) - if (activeOverride && activeOverride.cols === cols && activeOverride.rows === rows) { - return - } - // Why: a successful host resize supersedes any target retained after a - // failed viewer reclaim; a later viewer cycle must capture this new truth. - if (!this.hasRemoteDesktopViewers(ptyId)) { - this.remoteDesktopHostReclaimTargets.delete(ptyId) - } - this.resizeHeadlessTerminal(ptyId, cols, rows) - this.refreshRendererGeometry(ptyId, cols, rows) - } - - // Why: pty:reportGeometry IPC sibling. The renderer calls this when a - // desktop pane container goes from 0×0 to a real size while a mobile-fit - // override is active (e.g. user activates a previously-hidden tab on - // desktop after the phone has already taken the floor). We need the - // restore-target baseline to track real desktop dims even during the - // fit period — otherwise resolveDesktopRestoreTarget falls back to the - // PTY's spawn default (typically 80×24) and Take Back leaves the - // terminal partially restored. This is a measurement-only channel: it - // refreshes lastRendererSizes and non-null subscriber baselines, never - // resizes the PTY, and bypasses both isResizeSuppressed and the - // override-echo gate by design — the renderer only fires it when it - // has just measured fresh real geometry. See docs/mobile-fit-hold.md. - recordRendererGeometry(ptyId: string, cols: number, rows: number): void { - if (cols <= 0 || rows <= 0) { - return - } - // Why: a viewer may leave while phone-fit still owns the PTY. Keep its - // deferred host reclaim cache aligned with later trusted pane measurements. - if (this.remoteDesktopHostReclaimTargets.has(ptyId)) { - this.remoteDesktopHostReclaimTargets.set(ptyId, { cols, rows }) - } - this.refreshRendererGeometry(ptyId, cols, rows) - } - - // Why: test seam — exposes lastRendererSizes for assertions about - // pty:reportGeometry / onExternalPtyResize side effects without making - // the underlying Map writable from the outside. - getLastRendererSize(ptyId: string): { cols: number; rows: number } | null { - return this.lastRendererSizes.get(ptyId) ?? null - } - - private refreshRendererGeometry(ptyId: string, cols: number, rows: number): void { - this.lastRendererSizes.set(ptyId, { cols, rows }) - const inner = this.mobileSubscribers.get(ptyId) - if (!inner) { - return - } - // Refresh the renderer-current size as the next-restore target on every - // subscriber that already has a non-null baseline. Subscribers with null - // baselines (joined while a peer had already phone-fitted) stay null. - for (const sub of inner.values()) { - if (sub.previousCols != null && sub.previousRows != null) { - sub.previousCols = cols - sub.previousRows = rows - } - } - } - - // Why: the pty:resize IPC handler calls this to check if the global - // suppress window is active. During this window, all desktop renderer - // pty:resize events are ignored to prevent collateral safeFit corruption. - isResizeSuppressed(): boolean { - return Date.now() < this.resizeSuppressedUntil - } - - private suppressResizesForMs(ms: number): void { - this.resizeSuppressedUntil = Date.now() + ms - } - - subscribeToTerminalResize( - ptyId: string, - listener: (event: { - cols: number - rows: number - displayMode: string - reason: string - seq?: number - }) => void - ): () => void { - return addListenerToMap(this.resizeListeners, ptyId, listener) - } - - private notifyTerminalResize( - ptyId: string, - event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } - ): void { - const listeners = this.resizeListeners.get(ptyId) - if (!listeners) { - return - } - notifyRuntimeListeners(listeners, (listener) => listener(event), 'pty-resize') - } - - // Why: Section 7.2 — the runtime detects agent exit directly and updates - // dispatch contexts immediately, rather than waiting for the coordinator's - // next poll cycle. This catches agent crashes and unexpected exits within - // milliseconds. The task is set back to 'pending' so it can be re-dispatched. - private failActiveDispatchOnExit( - handle: string, - paneKey: string | null, - exitCode: number, - cause: TerminalExitCause - ): void { - if (!this._orchestrationDb) { - return - } - - // Why the pane key too: a reminted handle no longer matches the row, but the - // pane identity behind it outlives the remint. - const dispatch = this._orchestrationDb.getActiveDispatchForTerminal( - handle, - paneKey ?? undefined - ) - if (!dispatch) { - return - } - - const errorContext = describeTerminalExitCause(cause) - const settled = this._orchestrationDb.failDispatch(dispatch.id, errorContext, { - workerProcessExited: true, - terminationReason: cause.kind - }) - - // Why: a deliberate close is not an incident. Escalating it trains - // coordinators to ignore the channel that should wake them for a real one. - if (isDeliberateTerminalExit(cause)) { - return - } - - // Why: failDispatch above is the authoritative state transition and has already - // committed. Everything below is best-effort mail on top of it — resolving the - // recipient reads the database too — and onPtyExit runs this synchronously per leaf, - // so letting any of it escape would abandon the remaining leaves and the pty record - // pruning that close out this exit. - try { - // Why: create an escalation message so the coordinator is notified about - // the unexpected exit, even if the circuit breaker hasn't tripped yet. - const recipient = this.resolveExitEscalationRecipient(dispatch.run_id) - if (!recipient) { - return - } - const escalation = this._orchestrationDb.insertMessage({ - from: handle, - to: recipient.to, - subject: `Agent exited unexpectedly (${errorContext})`, - body: this.describeWorkerExit(dispatch, cause, handle, settled?.status), - type: 'escalation', - priority: 'high', - // Why: applyEscalationToDispatch rejects an escalation without an exact Dispatch - // binding, and a coordinator reading this needs to know which Dispatch died. - payload: JSON.stringify({ - taskId: dispatch.task_id, - dispatchId: dispatch.id, - // Why both: `exitCode` stays for readers that already parse it, but it - // is the raw number the host handed over, not a verdict — `exitCause` - // is what says whether the agent was killed, finished, or was closed. - exitCode, - exitCause: cause, - handle - }), - ...(recipient.runId ? { runId: recipient.runId } : {}) - }) - // Why: worker death is the one escalation nobody will poll for — the dead pane - // can't nudge the coordinator, so wake its check --wait the way every other - // message producer does. - this.notifyMessageArrived(escalation.to_handle, escalation.type) - } catch (error) { - // Why: log the Run rather than the recipient — resolution itself can be what failed. - console.warn('[orchestration] failed to escalate worker exit', { - dispatchId: dispatch.id, - runId: dispatch.run_id, - error - }) - } - } - - // Why: the banner shows the subject and a raw payload, so without prose the coordinator - // has to resolve ids by hand to learn what died and whether the task is still retryable. - private describeWorkerExit( - dispatch: { id: string; task_id: string; run_id: string }, - cause: TerminalExitCause, - handle: string, - settledStatus: DispatchStatus | undefined - ): string { - const task = this._orchestrationDb?.getTask?.(dispatch.task_id, dispatch.run_id) - const title = - typeof task?.spec === 'string' - ? buildOrchestrationTaskDisplayMetadata({ - spec: task.spec, - taskTitle: task.task_title, - displayName: task.display_name - }).taskTitle - : '' - const named = title ? `"${title}" (${dispatch.task_id})` : dispatch.task_id - const outcome = - settledStatus === 'circuit_broken' - ? ' This task has now failed too many times, so it will not be retried automatically.' - : settledStatus === 'failed' - ? ' The task is ready to be dispatched again.' - : '' - // Why the cause and not a code: `code 0` reads as success even when the - // worker was killed, which is what sent operators chasing phantom OOMs. - return `Worker ${handle} stopped while running task ${named}. ${describeTerminalExitCause( - cause - )}.${outcome}` - } - - // Why: a lightweight Run keeps its coordinator in runs/run_coordinator_handles and - // never writes the legacy coordinator_runs table, so gating solely on that table - // dropped every worker-death escalation (STA-4604). Address the Run mailbox the - // coordinator's `orchestration check` actually reads, and leave legacy Runs on the - // legacy gate. - private resolveExitEscalationRecipient( - runId: string - ): { to: string; runId?: string } | undefined { - const owningRun = this._orchestrationDb?.getRun?.(runId) - if (owningRun && owningRun.legacy !== 1) { - return { to: `run:${owningRun.id}`, runId: owningRun.id } - } - const legacyRun = this._orchestrationDb?.getActiveCoordinatorRun?.() - return legacyRun ? { to: legacyRun.coordinator_handle } : undefined - } - - async listTerminals( - worktreeSelector?: string, - limit = DEFAULT_TERMINAL_LIST_LIMIT, - opts: { - handles?: readonly string[] - requireFreshPtyLiveness?: boolean - includeVisualLayouts?: boolean - } = {} - ): Promise { - if (!Number.isInteger(limit) || limit <= 0) { - throw new Error('invalid_limit') - } - const graphEpoch = this.graphStatus === 'ready' ? this.rendererGraphEpoch : null - const explicitTargetWorktreeId = worktreeSelector - ? this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) - : null - const initialResolvedWorktreeCache = this.resolvedWorktreeCache - const cachedResolvedWorktrees = - initialResolvedWorktreeCache && initialResolvedWorktreeCache.expiresAt > Date.now() - ? initialResolvedWorktreeCache.worktrees - : null - const cachedExplicitTargetWorktree = - explicitTargetWorktreeId && cachedResolvedWorktrees - ? (cachedResolvedWorktrees.find((worktree) => worktree.id === explicitTargetWorktreeId) ?? - null) - : null - const parsedExplicitTargetWorktree = - explicitTargetWorktreeId && !cachedExplicitTargetWorktree - ? this.buildResolvedWorktreeFromId(explicitTargetWorktreeId) - : null - const targetWorktree = - worktreeSelector && !explicitTargetWorktreeId - ? await this.resolveWorktreeSelector(worktreeSelector) - : (cachedExplicitTargetWorktree ?? parsedExplicitTargetWorktree) - const targetWorktreeId = explicitTargetWorktreeId ?? targetWorktree?.id ?? null - const classificationResolvedWorktreeCache = this.resolvedWorktreeCache - const classificationResolvedWorktrees = - targetWorktreeId && - classificationResolvedWorktreeCache && - classificationResolvedWorktreeCache.expiresAt > Date.now() - ? includeTargetResolvedWorktree( - classificationResolvedWorktreeCache.worktrees, - targetWorktree - ) - : targetWorktreeId && explicitTargetWorktreeId - ? this.listKnownResolvedWorktreesForExplicitTarget(targetWorktreeId, targetWorktree) - : null - const worktreesById = - targetWorktreeId && targetWorktree - ? new Map([[targetWorktree.id, targetWorktree]]) - : targetWorktreeId - ? new Map() - : await this.getResolvedWorktreeMap() - if (graphEpoch !== null) { - this.assertStableReadyGraph(graphEpoch) - } - - const resolvedWorktrees = - targetWorktreeId && classificationResolvedWorktrees - ? classificationResolvedWorktrees - : targetWorktreeId && targetWorktree - ? [targetWorktree] - : targetWorktreeId - ? [] - : [...worktreesById.values()] - const controllerInventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( - resolvedWorktrees, - targetWorktreeId - ) - const refreshedPtyLiveness = controllerInventory - ? new Set(controllerInventory.livePtyIds) - : null - if (opts.requireFreshPtyLiveness && !refreshedPtyLiveness) { - throw new Error('terminal_liveness_unavailable') - } - // Why: a proof of absence, not a proof of liveness — leaves whose PTY the - // controller answered for but did not list must not read as connected. An - // unavailable inventory (null) proves nothing and demotes nothing. - const provenLivePtyIds = controllerInventory?.allLivePtyIds ?? null - - const livePtyWorktreeIds = new Set() - for (const pty of this.ptysById.values()) { - if (pty.connected) { - livePtyWorktreeIds.add(pty.worktreeId) - } - } - - const terminals: RuntimeTerminalSummary[] = [] - const ptyIdsFromLeaves = new Set() - if (graphEpoch !== null) { - for (const leaf of this.leaves.values()) { - if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) { - continue - } - if ( - opts.requireFreshPtyLiveness && - (!leaf.ptyId || !refreshedPtyLiveness?.has(leaf.ptyId)) - ) { - continue - } - if (!leaf.ptyId && livePtyWorktreeIds.has(leaf.worktreeId)) { - continue - } - if (leaf.ptyId) { - ptyIdsFromLeaves.add(leaf.ptyId) - } - terminals.push(this.buildTerminalSummary(leaf, worktreesById, provenLivePtyIds)) - } - } - - // Why: worktree.ps can classify active worktrees from PTY records even when - // the renderer graph is missing a leaf. terminal.list needs the same fallback - // so mobile does not show a false "No terminals" create flow. - for (const pty of this.ptysById.values()) { - if (!pty.connected || ptyIdsFromLeaves.has(pty.ptyId)) { - continue - } - if (opts.requireFreshPtyLiveness && !refreshedPtyLiveness?.has(pty.ptyId)) { - continue - } - if (targetWorktreeId && pty.worktreeId !== targetWorktreeId) { - continue - } - terminals.push(this.buildPtyTerminalSummary(pty, worktreesById)) - } - - const requestedHandles = opts.handles ? new Set(opts.handles) : null - const matchingTerminals = requestedHandles - ? terminals.filter((terminal) => requestedHandles.has(terminal.handle)) - : terminals - const listedTerminals = matchingTerminals.slice(0, limit) - // Why: undefined (pre-flag client) must still get layouts; only an explicit - // `false` opts out. - const visualLayouts = - opts.includeVisualLayouts === false - ? [] - : this.buildTerminalVisualLayouts(listedTerminals, worktreesById, targetWorktreeId) - - return { - terminals: listedTerminals, - hostScope: this.buildTerminalListHostScope( - targetWorktreeId, - matchingTerminals, - worktreesById.values(), - controllerInventory?.queriedHostIds ?? new Set() - ), - ...(visualLayouts.length > 0 ? { visualLayouts } : {}), - topologyRevisions: Object.fromEntries( - [...new Set(matchingTerminals.map((terminal) => terminal.worktreeId))].map((worktreeId) => [ - worktreeId, - this.getTerminalTopologyRevision(worktreeId) - ]) - ), - totalCount: matchingTerminals.length, - truncated: matchingTerminals.length > limit - } - } - - // A worktree-scoped request answers for one host only, so name the hosts it - // skipped: absence from a scoped listing is not evidence a worker exited. - private buildTerminalListHostScope( - targetWorktreeId: string | null, - terminals: readonly RuntimeTerminalSummary[], - worktrees: Iterable, - queriedHostIds: ReadonlySet - ): RuntimeTerminalListHostScope { - const knownHostIds = this.listKnownExecutionHostIds( - queriedHostIds, - targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID - ) - let resolvedTargetHostId: ExecutionHostId | null = null - for (const worktree of worktrees) { - if (worktree.hostId) { - knownHostIds.add(worktree.hostId) - if (worktree.id === targetWorktreeId) { - resolvedTargetHostId = worktree.hostId - } - } - } - for (const terminal of terminals) { - if (terminal.executionHostId) { - knownHostIds.add(terminal.executionHostId) - } - } - const scopedHostId = targetWorktreeId - ? (resolvedTargetHostId ?? this.tryGetWorkspaceSessionHostIdForWorktree(targetWorktreeId)) - : null - if (scopedHostId) { - knownHostIds.add(scopedHostId) - } - const candidates = targetWorktreeId ? (scopedHostId ? [scopedHostId] : []) : knownHostIds - // Paired runtimes own a separate control plane. Mirrored rows are evidence - // for those rows only; this runtime cannot claim their complete inventory. - const coveredHostIds = new Set( - [...candidates].filter( - (hostId) => queriedHostIds.has(hostId) && parseExecutionHostId(hostId)?.kind !== 'runtime' - ) - ) - return { - hostIds: [...coveredHostIds].sort(), - omittedHostIds: [...knownHostIds].filter((hostId) => !coveredHostIds.has(hostId)).sort() - } - } - - async inspectTerminalProcessIncarnationLiveness( - processIncarnation: string, - serializedHostScope: string | null - ): Promise<'live' | 'exited' | 'unverifiable'> { - const hostScope = parseWorkerTerminalHostScope(serializedHostScope) - if (!hostScope || !this.ptyController?.listProcesses) { - return 'unverifiable' - } - const listed = await withTimeoutResult( - this.ptyController.listProcesses(hostScope.kind === 'ssh' ? hostScope.targetId : null), - PTY_CONTROLLER_LIST_TIMEOUT_MS - ) - if (!listed.ok) { - return 'unverifiable' - } - return classifyWorkerTerminalProcessIncarnation(processIncarnation, listed.value) - } - - private getTerminalTopologyRevision(worktreeId: string): number { - const repoId = getRepoIdFromWorktreeId(worktreeId) - return ( - this.getWorkspaceSessionForWorktree(worktreeId)?.terminalTopologyRevisionByRepoId?.[repoId] ?? - this.terminalTopologyRevisionByRepoId.get(repoId) ?? - 0 - ) - } - - async adoptTerminalOrphans( - request: RuntimeTerminalOrphanAdoptionRequest - ): Promise { - if (request.claims.length === 0) { - throw new Error('terminal_orphan_claims_required') - } - const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree) - return this.runWorktreeTerminalMutation(workspace.id, async () => { - const resolvedWorkspace = workspace.folderWorkspace - ? this.folderWorkspaceToResolvedWorktree(workspace.folderWorkspace) - : await this.resolveWorktreeSelector(`id:${workspace.id}`) - const inventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( - [resolvedWorkspace], - workspace.id, - undefined, - workspace.connectionId ?? null - ) - if (!inventory) { - throw new Error('terminal_liveness_unavailable') - } - return this.adoptTerminalOrphansFromInventoryUnderMutation(request, workspace, inventory) - }) - } - - private async adoptTerminalOrphansFromInventoryUnderMutation( - request: RuntimeTerminalOrphanAdoptionRequest, - workspace: TerminalWorkspaceLaunchScope, - inventory: PtyControllerInventory - ): Promise { - const { livePtyIds, terminalIdentityByPtyId } = inventory - const store = this.store - const session = this.getWorkspaceSessionForWorktree(workspace.id) - if ( - !store?.setWorkspaceSession || - (!store.flushPendingOrThrowAsync && !store.flushOrThrow) || - !session - ) { - throw new Error('workspace_session_unavailable') - } - const sessionWorktreeId = resolveTerminalSessionWorktreeId(session, workspace.id) - if (!sessionWorktreeId) { - throw new Error('terminal_orphan_competing_owner') - } - const repoId = getRepoIdFromWorktreeId(workspace.id) - const worktreeConnectionId = workspace.connectionId - let worktreeWslDistro: string | null = null - if (!worktreeConnectionId && workspace.repo) { - try { - worktreeWslDistro = - getLocalProjectWorktreeGitOptions(this.requireStore(), workspace.repo).wslDistro ?? null - } catch { - throw new Error('terminal_orphan_owner_mismatch') - } - } - const currentRevision = this.getTerminalTopologyRevision(workspace.id) - const seenPtyIds = new Set() - const seenPaneKeys = new Set() - const validated = request.claims.map((claim) => { - const paneKey = makePaneKey(claim.tabId, claim.leafId) - if (seenPtyIds.has(claim.ptyId) || seenPaneKeys.has(paneKey)) { - throw new Error('terminal_orphan_claim_duplicate') - } - seenPtyIds.add(claim.ptyId) - seenPaneKeys.add(paneKey) - const live = this.getLivePtyForHandle(claim.terminal) - const pty = live?.pty - const controllerIdentity = terminalIdentityByPtyId.get(claim.ptyId) - if ( - !pty || - pty.ptyId !== claim.ptyId || - controllerIdentity?.handle !== claim.terminal || - controllerIdentity?.incarnationId !== claim.incarnationId || - !livePtyIds.has(claim.ptyId) || - !pty.connected || - !pty.incarnationId || - pty.incarnationId !== claim.incarnationId - ) { - throw new Error('terminal_orphan_stale') - } - if ( - !worktreeIdsEqual(pty.worktreeId, workspace.id) || - !terminalOrphanExecutionOwnersEqual( - { connectionId: worktreeConnectionId, wslDistro: worktreeWslDistro }, - { - connectionId: pty.connectionId ?? null, - ...(controllerIdentity?.wslDistro !== undefined - ? { wslDistro: controllerIdentity.wslDistro } - : process.platform === 'win32' && !worktreeConnectionId - ? {} - : { wslDistro: null }) - } - ) - ) { - throw new Error('terminal_orphan_owner_mismatch') - } - const visualOwners = this.getLeavesForPty(claim.ptyId) - if ( - visualOwners.some( - (owner) => - !worktreeIdsEqual(owner.worktreeId, workspace.id) || - owner.tabId !== claim.tabId || - owner.leafId !== claim.leafId - ) - ) { - throw new Error('terminal_orphan_already_visual') - } - if ((pty.tabId && pty.tabId !== claim.tabId) || (pty.paneKey && pty.paneKey !== paneKey)) { - throw new Error('terminal_orphan_competing_owner') - } - return { claim, pty, paneKey } - }) - - const persistedBindingsByPtyId = new Map() - const addPersistedBinding = ( - ptyId: string, - binding: { worktreeId: string; paneKey: string } - ): void => { - const bindings = persistedBindingsByPtyId.get(ptyId) ?? [] - bindings.push(binding) - persistedBindingsByPtyId.set(ptyId, bindings) - } - for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) { - for (const tab of tabs) { - const layout = session.terminalLayoutsByTabId[tab.id] - for (const [leafId, boundPtyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) { - if (boundPtyId) { - addPersistedBinding(boundPtyId, { - worktreeId, - paneKey: makePaneKey(tab.id, leafId) - }) - } - } - if (tab.ptyId && !layout) { - addPersistedBinding(tab.ptyId, { worktreeId, paneKey: tab.id }) - } - } - } - const persistedBinding = (ptyId: string): { worktreeId: string; paneKey: string } | null => { - const bindings = persistedBindingsByPtyId.get(ptyId) ?? [] - if (bindings.length > 1) { - throw new Error('terminal_orphan_competing_owner') - } - return bindings[0] ?? null - } - const isExactPersisted = validated.every(({ claim, paneKey }) => { - const binding = persistedBinding(claim.ptyId) - return ( - binding !== null && - worktreeIdsEqual(binding.worktreeId, workspace.id) && - binding.paneKey === paneKey && - session.terminalPtyIncarnationsByPaneKey?.[paneKey] === claim.incarnationId - ) - }) - if (isExactPersisted && sessionWorktreeId === workspace.id) { - for (const { claim, pty, paneKey } of validated) { - pty.tabId = claim.tabId - pty.paneKey = paneKey - } - return { - adopted: false, - topologyRevision: currentRevision, - snapshot: this.getTerminalOrphanAdoptionSnapshot(workspace.id) - } - } - if (currentRevision !== request.expectedTopologyRevision) { - throw new Error('terminal_topology_conflict') - } - - const topologyTabsById = new Map(request.topology?.tabs.map((tab) => [tab.tabId, tab]) ?? []) - const topologyGroups = request.topology?.groups ?? [] - if (request.topology) { - const claimedLeafIdsByTabId = new Map>() - for (const { claim } of validated) { - const leafIds = claimedLeafIdsByTabId.get(claim.tabId) ?? new Set() - leafIds.add(claim.leafId) - claimedLeafIdsByTabId.set(claim.tabId, leafIds) - } - if ( - topologyTabsById.size !== request.topology.tabs.length || - topologyTabsById.size !== claimedLeafIdsByTabId.size - ) { - throw new Error('terminal_orphan_topology_invalid') - } - for (const [tabId, claimedLeafIds] of claimedLeafIdsByTabId) { - const topologyTab = topologyTabsById.get(tabId) - if (!topologyTab) { - throw new Error('terminal_orphan_topology_invalid') - } - const topologyLeafIds = new Set() - const nodes = [topologyTab.root] - let leafCount = 0 - while (nodes.length > 0) { - const node = nodes.pop()! - if (node.type === 'leaf') { - leafCount += 1 - topologyLeafIds.add(node.leafId) - } else { - nodes.push(node.first, node.second) - } - } - if ( - leafCount !== topologyLeafIds.size || - topologyLeafIds.size !== claimedLeafIds.size || - [...topologyLeafIds].some((leafId) => !claimedLeafIds.has(leafId)) || - !topologyLeafIds.has(topologyTab.activeLeafId) || - (topologyTab.expandedLeafId !== null && !topologyLeafIds.has(topologyTab.expandedLeafId)) - ) { - throw new Error('terminal_orphan_topology_invalid') - } - } - const seenGroupIds = new Set() - const groupedTabIds = new Set() - for (const group of topologyGroups) { - if (seenGroupIds.has(group.id) || !group.tabOrder.includes(group.activeTabId)) { - throw new Error('terminal_orphan_topology_invalid') - } - seenGroupIds.add(group.id) - for (const tabId of group.tabOrder) { - if (!topologyTabsById.has(tabId) || groupedTabIds.has(tabId)) { - throw new Error('terminal_orphan_topology_invalid') - } - groupedTabIds.add(tabId) - } - if (group.recentTabIds?.some((tabId) => !group.tabOrder.includes(tabId))) { - throw new Error('terminal_orphan_topology_invalid') - } - } - if (groupedTabIds.size !== topologyTabsById.size) { - throw new Error('terminal_orphan_topology_invalid') - } - if (request.topology.groupLayout) { - if (!hasExactTerminalOrphanGroupLayout(request.topology.groupLayout, seenGroupIds)) { - throw new Error('terminal_orphan_topology_invalid') - } - } - } - - for (const { claim, paneKey } of validated) { - const existingBinding = persistedBinding(claim.ptyId) - if ( - existingBinding && - (!worktreeIdsEqual(existingBinding.worktreeId, workspace.id) || - existingBinding.paneKey !== paneKey) - ) { - throw new Error('terminal_orphan_competing_owner') - } - const proposedPtyId = - session.terminalLayoutsByTabId[claim.tabId]?.ptyIdsByLeafId?.[claim.leafId] - if (proposedPtyId && proposedPtyId !== claim.ptyId) { - throw new Error('terminal_orphan_surface_occupied') - } - const graphOwner = this.leaves.get(this.getLeafKey(claim.tabId, claim.leafId)) - if ( - graphOwner && - (graphOwner.ptyId !== claim.ptyId || !worktreeIdsEqual(graphOwner.worktreeId, workspace.id)) - ) { - throw new Error('terminal_orphan_surface_occupied') - } - if ( - Object.entries(session.tabsByWorktree).some( - ([ownerWorktreeId, tabs]) => - !worktreeIdsEqual(ownerWorktreeId, workspace.id) && - tabs.some((tab) => tab.id === claim.tabId) - ) - ) { - throw new Error('terminal_orphan_surface_occupied') - } - if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { - throw new Error('terminal_orphan_surface_retired') - } - for (const snapshot of this.mobileSessionTabsByWorktree.values()) { - const surfaceOwner = snapshot.tabs.find( - (tab): tab is RuntimeMobileSessionTerminalTab => - tab.type === 'terminal' && - tab.parentTabId === claim.tabId && - tab.leafId === claim.leafId - ) - if ( - surfaceOwner && - (snapshot.worktree !== workspace.id || surfaceOwner.ptyId !== claim.ptyId) - ) { - throw new Error('terminal_orphan_surface_occupied') - } - const owner = snapshot.tabs.find( - (tab): tab is RuntimeMobileSessionTerminalTab => - tab.type === 'terminal' && tab.ptyId === claim.ptyId - ) - if ( - owner && - (snapshot.worktree !== workspace.id || - owner.parentTabId !== claim.tabId || - owner.leafId !== claim.leafId) - ) { - throw new Error('terminal_orphan_competing_owner') - } - } - } - - const next = structuredClone(session) - canonicalizeTerminalSessionWorktreeId(next, sessionWorktreeId, workspace.id) - const existingTabs = next.tabsByWorktree[workspace.id] ?? [] - const tabsById = new Map(existingTabs.map((tab) => [tab.id, tab])) - for (const { claim, pty, paneKey } of validated) { - let tab = tabsById.get(claim.tabId) - if (!tab) { - const title = - getLatestPtyTitle(pty) ?? pty.controllerTitle ?? `Terminal ${tabsById.size + 1}` - tab = { - id: claim.tabId, - ptyId: claim.ptyId, - worktreeId: workspace.id, - title, - defaultTitle: title, - customTitle: null, - color: null, - sortOrder: tabsById.size, - createdAt: Date.now(), - pendingActivationSpawn: true - } - tabsById.set(claim.tabId, tab) - } - const existingLayout = next.terminalLayoutsByTabId[claim.tabId] - const topologyTab = topologyTabsById.get(claim.tabId) - next.terminalLayoutsByTabId[claim.tabId] = topologyTab - ? { - ...existingLayout, - root: topologyTab.root, - activeLeafId: topologyTab.activeLeafId, - expandedLeafId: topologyTab.expandedLeafId, - ptyIdsByLeafId: { - ...existingLayout?.ptyIdsByLeafId, - [claim.leafId]: claim.ptyId - } - } - : existingLayout - ? { - ...existingLayout, - root: this.collectPersistedTerminalLeafIds(existingLayout).includes(claim.leafId) - ? existingLayout.root - : existingLayout.root === null - ? { type: 'leaf', leafId: claim.leafId } - : { - type: 'split', - direction: 'vertical', - first: existingLayout.root, - second: { type: 'leaf', leafId: claim.leafId } - }, - ptyIdsByLeafId: { - ...existingLayout.ptyIdsByLeafId, - [claim.leafId]: claim.ptyId - } - } - : { - root: { type: 'leaf', leafId: claim.leafId }, - activeLeafId: claim.leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [claim.leafId]: claim.ptyId } - } - next.terminalPtyIncarnationsByPaneKey = { - ...next.terminalPtyIncarnationsByPaneKey, - [paneKey]: claim.incarnationId - } - } - const adoptedTabIds = [...new Set(validated.map(({ claim }) => claim.tabId))] - next.tabsByWorktree[workspace.id] = [...tabsById.values()] - const activeTabId = - request.activeTabId && tabsById.has(request.activeTabId) - ? request.activeTabId - : (adoptedTabIds[0] ?? null) - const existingGroups = next.tabGroups?.[workspace.id] ?? [] - const targetGroupId = - (request.activeGroupId && existingGroups.some((group) => group.id === request.activeGroupId) - ? request.activeGroupId - : existingGroups[0]?.id) ?? - request.activeGroupId ?? - randomUUID() - const proposedGroups = topologyGroups.map((group) => ({ - ...group, - worktreeId: workspace.id - })) - const groups = - existingGroups.length === 0 && proposedGroups.length > 0 - ? proposedGroups - : existingGroups.length > 0 - ? existingGroups - .map((group) => { - const proposed = proposedGroups.find((candidate) => candidate.id === group.id) - const tabOrder = proposed - ? [ - ...group.tabOrder.filter((tabId) => !adoptedTabIds.includes(tabId)), - ...proposed.tabOrder - ] - : group.id === targetGroupId && proposedGroups.length === 0 - ? [...new Set([...group.tabOrder, ...adoptedTabIds])] - : group.tabOrder.filter((tabId) => !adoptedTabIds.includes(tabId)) - return { - ...group, - tabOrder, - activeTabId: proposed - ? proposed.activeTabId - : group.id === targetGroupId && activeTabId - ? activeTabId - : group.activeTabId && tabOrder.includes(group.activeTabId) - ? group.activeTabId - : (tabOrder[0] ?? null), - ...(proposed?.recentTabIds ? { recentTabIds: proposed.recentTabIds } : {}) - } - }) - .concat( - proposedGroups.filter( - (proposed) => !existingGroups.some((group) => group.id === proposed.id) - ) - ) - : [{ id: targetGroupId, worktreeId: workspace.id, activeTabId, tabOrder: adoptedTabIds }] - const retainedGroups = groups.filter((group) => group.tabOrder.length > 0) - next.tabGroups = { - ...next.tabGroups, - [workspace.id]: retainedGroups - } - const mergedGroupLayout = mergeTerminalOrphanGroupLayout({ - existingLayout: next.tabGroupLayouts?.[workspace.id], - existingGroupIds: existingGroups.map((group) => group.id), - proposedLayout: request.topology?.groupLayout, - proposedGroupIds: proposedGroups.map((group) => group.id), - mergedGroupIds: retainedGroups.map((group) => group.id) - }) - if (mergedGroupLayout) { - next.tabGroupLayouts = { - ...next.tabGroupLayouts, - [workspace.id]: mergedGroupLayout - } - } - const activeGroup = - (request.activeGroupId - ? retainedGroups.find( - (group) => - group.id === request.activeGroupId && - (!activeTabId || group.tabOrder.includes(activeTabId)) - ) - : undefined) ?? - retainedGroups.find((group) => activeTabId && group.tabOrder.includes(activeTabId)) ?? - retainedGroups[0]! - const convergedActiveTabId = - activeTabId && activeGroup.tabOrder.includes(activeTabId) - ? activeTabId - : activeGroup.activeTabId - next.activeTabIdByWorktree = { - ...next.activeTabIdByWorktree, - ...(convergedActiveTabId ? { [workspace.id]: convergedActiveTabId } : {}) - } - next.activeGroupIdByWorktree = { - ...next.activeGroupIdByWorktree, - [workspace.id]: activeGroup.id - } - const persisted = advanceTerminalTopologyRevision(next, workspace.id) - let staged: WorkspaceSessionState | null = null - try { - this.setWorkspaceSessionForWorktree(workspace.id, persisted) - staged = this.getWorkspaceSessionForWorktree(workspace.id) - await this.flushWorkspaceSessionOrThrowAsync() - } catch (error) { - const current = this.getWorkspaceSessionForWorktree(workspace.id) - if (staged && current) { - const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) - if (rolledBack !== current) { - this.setWorkspaceSessionForWorktree(workspace.id, rolledBack) - } - } - throw error - } - for (const { claim, pty, paneKey } of validated) { - pty.tabId = claim.tabId - pty.paneKey = paneKey - } - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(workspace.id, { - force: true, - allowAttachedWindow: true, - onlyRuntimeOwnedTerminals: true - }) - this.notifyMobileSessionTabsChanged(workspace.id) - return { - adopted: true, - topologyRevision: persisted.terminalTopologyRevisionByRepoId?.[repoId] ?? currentRevision + 1, - snapshot: this.getTerminalOrphanAdoptionSnapshot(workspace.id) - } - } - - private getTerminalOrphanAdoptionSnapshot(worktreeId: string): RuntimeMobileSessionTabsResult { - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { - allowAttachedWindow: true, - onlyRuntimeOwnedTerminals: true - }) - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) - return this.getMobileSessionTabsForWorktree(worktreeId) - } - - private buildTerminalVisualLayouts( - terminals: RuntimeTerminalSummary[], - worktreesById: Map, - targetWorktreeId: string | null - ): RuntimeTerminalVisualLayout[] { - if (terminals.length === 0) { - return [] - } - // Why: the mobile/session snapshot supplies topology, but terminal.list - // must print the same handles in both the flat list and visual tree. - const summariesByLeafKey = new Map( - terminals.map((terminal) => [this.getLeafKey(terminal.tabId, terminal.leafId), terminal]) - ) - const summariesByWorktree = new Map() - for (const terminal of terminals) { - const existing = summariesByWorktree.get(terminal.worktreeId) - if (existing) { - existing.push(terminal) - } else { - summariesByWorktree.set(terminal.worktreeId, [terminal]) - } - } - const snapshots = targetWorktreeId - ? [this.mobileSessionTabsByWorktree.get(targetWorktreeId)].filter( - (snapshot): snapshot is RuntimeMobileSessionTabsSnapshot => snapshot !== undefined - ) - : [...this.mobileSessionTabsByWorktree.values()] - const layouts: RuntimeTerminalVisualLayout[] = [] - for (const snapshot of snapshots) { - const worktreeTerminals = summariesByWorktree.get(snapshot.worktree) - if (!worktreeTerminals || worktreeTerminals.length === 0) { - continue - } - const groups = this.buildTerminalVisualGroups(snapshot, summariesByLeafKey) - if (groups.length === 0) { - continue - } - const groupsById = new Map( - groups - .filter((group): group is RuntimeTerminalVisualGroupNode & { groupId: string } => - Boolean(group.groupId) - ) - .map((group) => [group.groupId, group]) - ) - const root = - this.buildTerminalVisualGroupLayout(snapshot.tabGroupLayout, groupsById) ?? groups[0] - if (!root) { - continue - } - const worktree = worktreesById.get(snapshot.worktree) - layouts.push({ - worktreeId: snapshot.worktree, - worktreePath: worktree?.path ?? worktreeTerminals[0]?.worktreePath ?? '', - root - }) - } - return layouts - } - - private buildTerminalVisualGroups( - snapshot: RuntimeMobileSessionTabsSnapshot, - summariesByLeafKey: ReadonlyMap - ): RuntimeTerminalVisualGroupNode[] { - const terminalTabs = snapshot.tabs.filter( - (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' - ) - if (terminalTabs.length === 0) { - return [] - } - const tabsByParentId = new Map() - const parentOrder: string[] = [] - for (const tab of terminalTabs) { - const existing = tabsByParentId.get(tab.parentTabId) - if (existing) { - existing.push(tab) - } else { - parentOrder.push(tab.parentTabId) - tabsByParentId.set(tab.parentTabId, [tab]) - } - } - const groupSources = - snapshot.tabGroups && snapshot.tabGroups.length > 0 - ? snapshot.tabGroups - : [{ id: null, activeTabId: snapshot.activeTabId, tabOrder: parentOrder }] - return groupSources - .map((group): RuntimeTerminalVisualGroupNode | null => { - const tabs = group.tabOrder - .map((tabId) => { - const surfaces = - tabsByParentId.get(tabId) ?? terminalTabs.filter((tab) => tab.id === tabId) - return this.buildTerminalVisualTab(tabId, surfaces, summariesByLeafKey) - }) - .filter((tab): tab is RuntimeTerminalVisualTab => tab !== null) - if (tabs.length === 0) { - return null - } - return { - type: 'group', - groupId: group.id, - activeTabId: - group.activeTabId && tabs.some((tab) => tab.tabId === group.activeTabId) - ? group.activeTabId - : (tabs[0]?.tabId ?? null), - tabs - } - }) - .filter((group): group is RuntimeTerminalVisualGroupNode => group !== null) - } - - private buildTerminalVisualTab( - tabId: string, - surfaces: RuntimeMobileSessionTerminalTab[], - summariesByLeafKey: ReadonlyMap - ): RuntimeTerminalVisualTab | null { - const firstSurface = surfaces[0] - if (!firstSurface) { - return null - } - const parentTabId = firstSurface.parentTabId - const requestedActiveLeafId = - firstSurface.parentLayout?.activeLeafId ?? - surfaces.find((surface) => surface.isActive)?.leafId ?? - firstSurface.leafId - const root = firstSurface.parentLayout?.root ?? { - type: 'leaf' as const, - leafId: firstSurface.leafId - } - const visibleLeafIds = this.collectVisibleTerminalLeafIds(root, parentTabId, summariesByLeafKey) - if (visibleLeafIds.length === 0) { - return null - } - const activeLeafId = - (requestedActiveLeafId && visibleLeafIds.includes(requestedActiveLeafId) - ? requestedActiveLeafId - : surfaces.find((surface) => surface.isActive && visibleLeafIds.includes(surface.leafId)) - ?.leafId) ?? visibleLeafIds[0]! - const panes = this.buildTerminalVisualPane(root, parentTabId, activeLeafId, summariesByLeafKey) - if (!panes) { - return null - } - return { - tabId: parentTabId || tabId, - title: this.tabs.get(parentTabId)?.title ?? firstSurface.title ?? null, - activeLeafId, - panes - } - } - - private collectVisibleTerminalLeafIds( - node: TerminalPaneLayoutNode, - tabId: string, - summariesByLeafKey: ReadonlyMap - ): string[] { - if (node.type === 'leaf') { - return summariesByLeafKey.has(this.getLeafKey(tabId, node.leafId)) ? [node.leafId] : [] - } - return [ - ...this.collectVisibleTerminalLeafIds(node.first, tabId, summariesByLeafKey), - ...this.collectVisibleTerminalLeafIds(node.second, tabId, summariesByLeafKey) - ] - } - - private buildTerminalVisualPane( - node: TerminalPaneLayoutNode, - tabId: string, - activeLeafId: string | null, - summariesByLeafKey: ReadonlyMap - ): RuntimeTerminalVisualPaneNode | null { - if (node.type === 'leaf') { - const summary = summariesByLeafKey.get(this.getLeafKey(tabId, node.leafId)) - if (!summary) { - return null - } - return { - type: 'terminal', - handle: summary.handle, - tabId: summary.tabId, - leafId: summary.leafId, - title: summary.title, - connected: summary.connected, - active: summary.leafId === activeLeafId - } - } - const first = this.buildTerminalVisualPane(node.first, tabId, activeLeafId, summariesByLeafKey) - const second = this.buildTerminalVisualPane( - node.second, - tabId, - activeLeafId, - summariesByLeafKey - ) - if (first && second) { - return { type: 'pane-split', direction: node.direction, first, second } - } - return first ?? second - } - - private buildTerminalVisualGroupLayout( - node: TabGroupLayoutNode | null | undefined, - groupsById: ReadonlyMap - ): RuntimeTerminalVisualLayoutNode | null { - if (!node) { - return null - } - if (node.type === 'leaf') { - return groupsById.get(node.groupId) ?? null - } - const first = this.buildTerminalVisualGroupLayout(node.first, groupsById) - const second = this.buildTerminalVisualGroupLayout(node.second, groupsById) - if (first && second) { - return { type: 'split', direction: node.direction, first, second } - } - return first ?? second - } - - // Why: when --terminal is omitted, the CLI auto-resolves to the active - // terminal in the current worktree — matching browser's implicit active tab. - async resolveActiveTerminal(worktreeSelector?: string): Promise { - if (this.graphStatus !== 'ready') { - const targetWorktreeId = worktreeSelector - ? (await this.resolveWorktreeSelector(worktreeSelector)).id - : null - const snapshots = targetWorktreeId - ? [this.getMobileSessionTabsForWorktree(targetWorktreeId)] - : await this.listAllMobileSessionTabs() - for (const snapshot of snapshots) { - const activeTerminal = snapshot.tabs.find( - (tab) => - tab.type === 'terminal' && - tab.isActive && - tab.status === 'ready' && - typeof tab.terminal === 'string' - ) - if (activeTerminal?.type === 'terminal' && activeTerminal.terminal) { - return activeTerminal.terminal - } - } - const listed = await this.listTerminals(worktreeSelector, undefined, { - includeVisualLayouts: false - }) - const first = listed.terminals[0]?.handle - if (first) { - return first - } - throw new Error('no_active_terminal') - } - this.assertGraphReady() - - const targetWorktreeId = worktreeSelector - ? (await this.resolveWorktreeSelector(worktreeSelector)).id - : null - - // Prefer the tab's activeLeafId — this is the pane the user last focused - for (const tab of this.tabs.values()) { - if (targetWorktreeId && tab.worktreeId !== targetWorktreeId) { - continue - } - if (!tab.activeLeafId) { - continue - } - const leafKey = this.getLeafKey(tab.tabId, tab.activeLeafId) - const leaf = this.leaves.get(leafKey) - if (leaf) { - return this.issueHandle(leaf) - } - } - - // Fallback: any leaf in the target worktree - for (const leaf of this.leaves.values()) { - if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) { - continue - } - return this.issueHandle(leaf) - } - - throw new Error('no_active_terminal') - } - - // Why: orchestration records the pane key as the remint-stable assignee - // identity at dispatch time; null (best-effort) rather than throwing so - // dispatch still works for handles without a resolvable pane. - getTerminalPaneKey(handle: string): string | null { - return this.getPaneKeyForTerminalHandle(handle) - } - - getLiveTerminalPaneKey(handle: string): string | null { - const runtimePty = this.getLivePtyForHandle(handle) - if (runtimePty) { - return runtimePty.pty.connected ? (runtimePty.pty.paneKey ?? null) : null - } - try { - const leaf = this.resolveLiveLeafForHandle(handle) - if (!leaf?.ptyId) { - return null - } - const pty = this.ptysById.get(leaf.ptyId) - return pty?.connected === false ? null : this.getPaneKeyForTerminalHandle(handle) - } catch { - return null - } - } - - getTerminalWorktreeIdForPaneKey(paneKey: string): string | null { - const parsed = parsePaneKey(paneKey) - const leaf = parsed ? this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) : null - return leaf?.worktreeId ?? this.getPtyRecordForPaneKey(paneKey)?.worktreeId ?? null - } - - /** Read-only context of the worktree the user is focused on, for plugin - * panels (workspace.readContext). Prefers the persisted session focus and - * falls back to the last-focused pane's worktree; null when neither - * resolves so panels degrade instead of erroring. */ - async resolveActiveWorktreeContext(): Promise<{ - worktreeId: string - path: string - branch: string - displayName: string - } | null> { - let worktreeId = this.store?.getWorkspaceSession?.()?.activeWorktreeId ?? null - if (!worktreeId && this.graphStatus === 'ready') { - for (const tab of this.tabs.values()) { - if (tab.activeLeafId && tab.worktreeId) { - worktreeId = tab.worktreeId - break - } - } - } - if (!worktreeId) { - return null - } - try { - const resolved = await this.resolveWorktreeSelector(`id:${worktreeId}`) - return { - worktreeId: resolved.id, - path: resolved.git.path, - branch: resolved.git.branch, - displayName: resolved.displayName - } - } catch { - return null - } - } - - getTerminalProcessIncarnation(handle: string): string | null { - const live = this.getLivePtyForHandle(handle) - const record = live?.record ?? this.handles.get(handle) - if (!record?.ptyId) { - return null - } - const incarnationId = live?.pty.incarnationId ?? this.ptysById.get(record.ptyId)?.incarnationId - if (incarnationId) { - return `${record.ptyId}:${incarnationId}` - } - // Why: legacy providers may omit process incarnation; retain the prior restart-degraded fence. - return `${this.runtimeId}:${record.ptyId}:${record.ptyGeneration}` - } - - /** - * Records that we lost contact with a PTY's owning host. Callers must never - * read this as an exit: a detached relay PTY is designed to outlive the - * provider that addressed it. - */ - markPtyLivenessUnverifiable(ptyId: string, reason: string): void { - this.rememberPtyLivenessVerdict(ptyId, { status: 'unverifiable', reason }) - } - - markPtyLivenessLive(ptyId: string): void { - this.rememberPtyLivenessVerdict(ptyId, { status: 'live', ptyIds: [ptyId] }) - } - - /** - * Records that Orca asked this PTY to stop — a close, a stop, a teardown. - * - * Why before the kill and not at the exit: a requested stop can still be - * delivered by the provider's own exit event, which carries a process status - * indistinguishable from a natural finish. The intent is the only thing that - * separates "the operator closed it" from "the agent died", so it is recorded - * where it is known rather than reconstructed afterwards (STA-4603). - */ - markPtyStopRequested(ptyId: string): void { - this.stopRequestedPtyIds.add(ptyId) - } - - isPtyStopRequested(ptyId: string): boolean { - return this.stopRequestedPtyIds.has(ptyId) - } - - /** Null when nothing has been observed either way, so callers keep their own default. */ - getPtyLivenessVerdict(ptyId: string): PtyLivenessVerdict | null { - return this.ptyLivenessVerdictByPtyId.get(ptyId)?.verdict ?? null - } - - getTerminalLivenessVerdict(handle: string): PtyLivenessVerdict | null { - const record = this.getLivePtyForHandle(handle)?.record ?? this.handles.get(handle) - return record?.ptyId ? this.getPtyLivenessVerdict(record.ptyId) : null - } - - private rememberPtyLivenessVerdict(ptyId: string, verdict: PtyLivenessVerdict): void { - if (verdict.status === 'exited') { - // An earned death certificate ends the question; nothing left to remember. - this.ptyLivenessVerdictByPtyId.delete(ptyId) - return - } - this.ptyLivenessVerdictByPtyId.delete(ptyId) - this.ptyLivenessObservationSequence += 1 - this.ptyLivenessVerdictByPtyId.set(ptyId, { - verdict, - observedAt: this.ptyLivenessObservationSequence - }) - while (this.ptyLivenessVerdictByPtyId.size > MAX_TRACKED_PTY_LIVENESS_VERDICTS) { - let oldestOrphaned: string | null = null - for (const candidate of this.ptyLivenessVerdictByPtyId.keys()) { - if ( - !this.ptysById.has(candidate) && - !this.handleByPtyId.has(candidate) && - !this.leafExistsForPty(candidate) - ) { - oldestOrphaned = candidate - break - } - } - if (!oldestOrphaned) { - return - } - this.ptyLivenessVerdictByPtyId.delete(oldestOrphaned) - } - } - - private forgetPtyLivenessVerdict(ptyId: string, observedNoLaterThan?: number): void { - const tracked = this.ptyLivenessVerdictByPtyId.get(ptyId) - if (observedNoLaterThan !== undefined && tracked && tracked.observedAt > observedNoLaterThan) { - return - } - this.ptyLivenessVerdictByPtyId.delete(ptyId) - } - - getExactWorkerProviderSession( - handle: string, - observedAfter: number - ): ExactWorkerProviderSession | null { - const paneKey = this.getTerminalPaneKey(handle) - const processIncarnation = this.getTerminalProcessIncarnation(handle) - if (!paneKey || !processIncarnation) { - return null - } - let connectionId: string | null | undefined - let launchToken: string | null | undefined - try { - const ptyId = this.getTerminalAgentStatusPtyId(handle) - const pty = this.ptysById.get(ptyId) - connectionId = pty?.connectionId ?? null - launchToken = pty?.launchToken ?? null - } catch { - // Exact worker validation rejects this in production; test/legacy providers may not expose PTY metadata. - connectionId = undefined - launchToken = undefined - } - return selectExactWorkerProviderSession({ - paneKey, - processIncarnation, - connectionId, - launchToken, - observedAfter, - statuses: this.getAgentStatusSnapshotFn?.() ?? [] - }) - } - - validateOrchestrationAgentLauncher(agent: TuiAgent): void { - const settings = this.store?.getSettings() - if (!settings) { - throw new Error('runtime_unavailable') - } - if (!isTuiAgentEnabled(agent, settings.disabledTuiAgents)) { - throw new OrchestrationError( - 'agent_unconfigured', - `Agent launcher ${agent} is disabled or unavailable.` - ) - } - } - - resolveTerminalPane(paneKey: string, expectedWorktreeId?: string): RuntimeTerminalResolvePane { - // Why: the renderer context menu only knows the stable pane key; main owns - // the runtime terminal handle that agents and CLI commands can address. - const handle = this.getTerminalHandleForPaneKey(paneKey) - if (!handle) { - throw new Error('terminal_not_found') - } - const record = this.handles.get(handle) - const parsed = parsePaneKey(paneKey) - const leaf = parsed ? this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) : null - const pty = this.getPtyRecordForPaneKey(paneKey) - const candidateWorktreeIds = [leaf?.worktreeId, pty?.worktreeId].filter( - (worktreeId): worktreeId is string => Boolean(worktreeId) - ) - const worktreeId = candidateWorktreeIds[0] ?? null - if ( - (candidateWorktreeIds.length > 1 && new Set(candidateWorktreeIds).size > 1) || - (expectedWorktreeId && candidateWorktreeIds.some((id) => id !== expectedWorktreeId)) || - (expectedWorktreeId && candidateWorktreeIds.length === 0) - ) { - // Why: pane coordinates restored by a paired client must not cross workspace ownership. - throw new Error('terminal_not_found') - } - return { - handle, - tabId: parsed?.tabId ?? record?.tabId ?? '', - leafId: parsed?.leafId ?? record?.leafId ?? '', - ptyId: record?.ptyId ?? null, - connected: pty?.connected === true, - ...(worktreeId ? { worktreeId } : {}), - ...this.getPtyExecutionHostMetadata(record?.ptyId ?? pty?.ptyId ?? null) - } - } - - async recoverTerminalPane( - paneKey: string, - expectedWorktreeId: string, - expectedHandle?: string - ): Promise { - const parsed = parsePaneKey(paneKey) - const pty = this.getPtyRecordForPaneKey(paneKey) - if ( - !parsed || - !pty || - !expectedHandle || - pty.worktreeId !== expectedWorktreeId || - this.getPaneKeyForTerminalHandle(expectedHandle) !== paneKey - ) { - throw new Error('terminal_not_found') - } - const recoveryKey = `${expectedWorktreeId}\0${paneKey}` - const pending = this.terminalPaneRecoveryByIdentity.get(recoveryKey) - if (pending) { - return pending - } - if (pty?.connected) { - const current = this.resolveTerminalPane(paneKey, expectedWorktreeId) - if (expectedHandle === undefined || current.handle !== expectedHandle) { - return current - } - throw new Error('terminal_not_recoverable') - } - if ( - !this.getRecentExpiredSshLease(expectedWorktreeId, parsed.tabId, parsed.leafId, pty.ptyId) - ) { - // Why: an explicit close leaves a terminated lease; only relay expiry authorizes shell recreation. - throw new Error('terminal_not_recoverable') - } - // Why: disconnected PTYs can reissue handles during graph cleanup; only a connected replacement satisfies the pane CAS. - const recovery = this.createTerminal(`id:${expectedWorktreeId}`, { - tabId: parsed.tabId, - leafId: parsed.leafId, - focus: false - }).then((terminal) => ({ - handle: terminal.handle, - tabId: parsed.tabId, - leafId: parsed.leafId, - ptyId: terminal.ptyId ?? null, - worktreeId: expectedWorktreeId - })) - this.terminalPaneRecoveryByIdentity.set(recoveryKey, recovery) - const clearRecovery = (): void => { - if (this.terminalPaneRecoveryByIdentity.get(recoveryKey) === recovery) { - this.terminalPaneRecoveryByIdentity.delete(recoveryKey) - } - } - void recovery.then(clearRecovery, clearRecovery) - return recovery - } - - async showTerminal(handle: string): Promise { - const pty = this.getLivePtyForHandle(handle) - if (pty) { - const worktreesById = await this.getResolvedWorktreeMap() - const summary = this.buildPtyTerminalSummary(pty.pty, worktreesById) - const preview = await this.visibleSnapshotPreview(pty.pty.ptyId, summary.preview) - this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId) - return { - ...summary, - preview, - tabId: pty.pty.tabId ?? pty.record.tabId, - leafId: parsePaneKey(pty.pty.paneKey ?? '')?.leafId ?? pty.record.leafId, - paneRuntimeId: -1, - ptyId: pty.pty.ptyId, - rendererGraphEpoch: this.rendererGraphEpoch, - ...expandTerminalInteractiveWait(await this.getTerminalInteractiveWait(handle)) - } - } - const graphEpoch = this.captureReadyGraphEpoch() - const worktreesById = await this.getResolvedWorktreeMap() - this.assertStableReadyGraph(graphEpoch) - const { leaf } = this.getLiveLeafForHandle(handle) - const summary = this.buildTerminalSummary(leaf, worktreesById) - const preview = leaf.ptyId - ? await this.visibleSnapshotPreview(leaf.ptyId, summary.preview) - : summary.preview - this.assertStableReadyGraph(graphEpoch) - if (leaf.ptyId) { - this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId) - } - return { - ...summary, - preview, - paneRuntimeId: leaf.paneRuntimeId, - ptyId: leaf.ptyId, - rendererGraphEpoch: this.rendererGraphEpoch, - ...expandTerminalInteractiveWait(await this.getTerminalInteractiveWait(handle)) - } - } - - async readTerminal( - handle: string, - opts: { cursor?: number; limit?: number; screen?: boolean } = {}, - providerSnapshot: ProviderSnapshotReadOptions = {} - ): Promise { - const pty = this.getLivePtyForHandle(handle) - if (pty) { - const read = this.readPtyTerminal(handle, pty.pty, opts) - const visibleRead = opts.screen - ? await this.readRenderedScreen(pty.pty.ptyId, read, opts) - : await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts, providerSnapshot) - this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId) - return labelTerminalReadSource(visibleRead) - } - - const { leaf } = this.getLiveLeafForHandle(handle) - const read = readTerminalTail({ - handle, - status: getTerminalState(leaf), - previewLines: leaf.tailBuffer, - completedLines: leaf.tailTranscriptBuffer, - partialLine: leaf.tailPartialLine, - completedLineCount: leaf.tailLinesTotal, - bufferTruncated: leaf.tailTruncated, - cursor: opts.cursor, - limit: opts.limit - }) - if (!leaf.ptyId) { - return { ...read, source: opts.screen ? 'screen-unavailable' : 'stream' } - } - const visibleRead = opts.screen - ? await this.readRenderedScreen(leaf.ptyId, read, opts) - : await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts, providerSnapshot) - this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId) - return labelTerminalReadSource(visibleRead) - } - - // Why: the default read is the accumulated pty stream, which stacks every repaint of a line - // ("cclclecleaclear" for one `clear`) and drops spaces a prompt draws with cursor-forward. - // That is the right answer for "what happened over time" and the wrong one for "what is on - // screen", so an explicit screen read goes to the emulator state instead. When no rendered - // state exists the stream is still returned, but labelled `screen-unavailable` rather than - // passed off as a screen — silently answering the other question is the defect this exists to - // stop, and that label is what separates it from a stream the caller actually asked for. - // A cursor cannot reach here: pairing one with a screen read is refused at the RPC boundary, - // because rendered lines carrying the stream's pagination metadata would mix both frames. - private async readRenderedScreen( - ptyId: string, - read: RuntimeTerminalRead, - opts: { limit?: number } = {} - ): Promise { - const visibleState = await this.readVisibleTerminalState(ptyId) - const projection = visibleState ?? (await this.readProviderTerminalTailLines(ptyId, opts.limit)) - if (projection.lines.length === 0) { - return { ...read, source: 'screen-unavailable' } - } - return buildVisibleSnapshotReadFallback(read, projection.lines, opts.limit, projection.draft) - } - - // Why a cache: leaf-branch sends may arrive per keystroke; one proven-absent - // verdict per ptyId serves the burst instead of a probe round-trip each call. - private readonly provenAbsentLeafPtyVerdicts = new Map() - private readonly leafPtyAbsenceProbes = new Map>() - private controllerKnowsPtyIsLive(ptyId: string): boolean { - try { - return this.ptyController?.hasPty?.(ptyId) === true - } catch { - // Why: liveness lookup failures are doubt; doubt never gates a write. - return false - } - } - - /** True only on controller-proven absence; live, unknown, and probe errors all answer false. */ - private isLeafPtyProvenAbsent(ptyId: string): Promise { - // Why hasPty and not ptysById: graph sync mirrors a connected record for - // every leaf ptyId — including a prior process's — so runtime records can't - // distinguish live from stale. The controller's exact-id hasPty is the - // provider's own synchronous inventory: a known id is alive, skip probing - // and supersede any cached verdict (the id came back). - if (this.controllerKnowsPtyIsLive(ptyId)) { - this.provenAbsentLeafPtyVerdicts.delete(ptyId) - return Promise.resolve(false) - } - const verdictAt = this.provenAbsentLeafPtyVerdicts.get(ptyId) - if (verdictAt !== undefined) { - if (Date.now() - verdictAt < PROVEN_ABSENT_LEAF_PTY_TTL_MS) { - return Promise.resolve(true) - } - this.provenAbsentLeafPtyVerdicts.delete(ptyId) - } - const probeLiveness = this.ptyController?.probePtyLiveness?.bind(this.ptyController) - if (!probeLiveness) { - return Promise.resolve(false) - } - const inFlight = this.leafPtyAbsenceProbes.get(ptyId) - if (inFlight) { - return inFlight - } - const probe = (async () => { - try { - if ((await probeLiveness(ptyId)) !== false) { - return false - } - this.provenAbsentLeafPtyVerdicts.set(ptyId, Date.now()) - return true - } catch { - // Why: a failed probe is unknown, and unknown never rejects a write. - return false - } finally { - this.leafPtyAbsenceProbes.delete(ptyId) - } - })() - this.leafPtyAbsenceProbes.set(ptyId, probe) - return probe - } - - async sendTerminal( - handle: string, - action: { - text?: string - enter?: boolean - interrupt?: boolean - }, - options: { - beforeWrite?: (ptyId: string) => void | Promise - reserveWrite?: (ptyId: string) => void - afterWrite?: (ptyId: string) => void | Promise - suffixFailureError?: string - // Why: the pre-Enter wait now scales with the payload, so an abandoned request must be - // able to stop it instead of writing Enter minutes after the caller gave up. - signal?: AbortSignal - } = {} - ): Promise { - const pty = this.getLivePtyForHandle(handle) - if (pty) { - if (!pty.pty.connected) { - throw new Error('terminal_not_writable') - } - const payload = buildSendPayload(action) - if (payload === null) { - throw new Error('invalid_terminal_send') - } - await assertTerminalInputWithinLimitWithYield(action.text) - await this.writeTerminalAction(pty.pty.ptyId, action, payload, options) - return { - handle, - accepted: true, - bytesWritten: Buffer.byteLength(payload, 'utf8') - } - } - - const { leaf } = this.getLiveLeafForHandle(handle) - if (!leaf.writable || !leaf.ptyId) { - throw new Error('terminal_not_writable') - } - const payload = buildSendPayload(action) - if (payload === null) { - throw new Error('invalid_terminal_send') - } - await assertTerminalInputWithinLimitWithYield(action.text) - // Why: leaf.writable mirrors the renderer graph, which can still answer for - // a prior process's ptyId — and provider writes to unknown ids are accepted - // no-ops. Only controller-proven absence rejects; unknown proceeds (a - // restored daemon session takes writes before its pane remounts). - if (await this.isLeafPtyProvenAbsent(leaf.ptyId)) { - throw new Error('terminal_not_writable') - } - - await this.writeTerminalAction(leaf.ptyId, action, payload, options) - - return { - handle, - accepted: true, - bytesWritten: Buffer.byteLength(payload, 'utf8') - } - } - - async sendTerminalAgentPrompt( - handle: string, - prompt: string, - options: { - beforeWrite?: (ptyId: string) => void | Promise - suffixFailureError?: string - signal?: AbortSignal - } = {} - ): Promise { - const payload = buildAgentPromptPasteBytes(prompt) - const pty = this.getLivePtyForHandle(handle) - if (pty) { - if (!pty.pty.connected) { - throw new Error('terminal_not_writable') - } - await assertTerminalInputWithinLimitWithYield(payload) - const generation = this.getPtyLifecycleGeneration(pty.pty.ptyId) - const submits = await this.serializeAgentPromptSubmission( - pty.pty.ptyId, - generation, - async () => { - this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId) - this.assertAgentPromptGeneration(pty.pty.ptyId, generation) - return await this.writeTerminalAgentPrompt( - handle, - pty.pty.ptyId, - generation, - payload, - options - ) - } - ) - const bytesWritten = Buffer.byteLength(payload, 'utf8') + submits - return { handle, accepted: true, bytesWritten } - } - - const { leaf } = this.getLiveLeafForHandle(handle) - if (!leaf.writable || !leaf.ptyId) { - throw new Error('terminal_not_writable') - } - await assertTerminalInputWithinLimitWithYield(payload) - // Why: same absence gate as sendTerminal — a stale graph mirror must not - // accept a prompt into a void; unknown liveness still proceeds. - if (await this.isLeafPtyProvenAbsent(leaf.ptyId)) { - throw new Error('terminal_not_writable') - } - const generation = this.getPtyLifecycleGeneration(leaf.ptyId) - const submits = await this.serializeAgentPromptSubmission(leaf.ptyId, generation, async () => { - this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId!) - this.assertAgentPromptGeneration(leaf.ptyId!, generation) - return await this.writeTerminalAgentPrompt(handle, leaf.ptyId!, generation, payload, options) - }) - const bytesWritten = Buffer.byteLength(payload, 'utf8') + submits - return { handle, accepted: true, bytesWritten } - } - - async getTerminalAgentStatus(handle: string): Promise { - const ptyId = this.getTerminalAgentStatusPtyId(handle) - const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId) - const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle) - const lifecycle = this.agentPromptLifecycleByPtyId.get(ptyId) - if ( - (terminal.titleStatus === 'permission' && terminal.titleStatusIsLive) || - this.hasAuthoritativeTerminalWaitPermission(terminal, explicitStatus, lifecycle) - ) { - return { handle, isRunningAgent: true, status: 'permission' } - } - if (explicitStatus) { - // Why: permission titles can linger after hooks report the agent resumed. - // Fresh hook state is tighter, but current shell/management evidence wins. - const isRunningAgent = - !terminalTitleBlocksExplicitAgentStatus(terminal.title) && - !(await this.terminalHasShellForegroundProcess(handle, ptyId)) - this.assertTerminalAgentStatusPtyBinding(handle, ptyId) - return { - handle, - isRunningAgent, - status: isRunningAgent ? explicitStatus.status : null - } - } - if (terminal.titleStatus) { - // Why: an OpenCode marker and a lone quarter-circle spinner (STA-4028) are activity, - // not identity, so resolve both through the identity/foreground evidence path. - if ( - isOpenCodeNativeTitle(terminal.title) || - isQuarterCircleSpinnerOnlyAgentTitle(terminal.title) - ) { - const isRunningAgent = await this.isTerminalRunningAgent(handle) - this.assertTerminalAgentStatusPtyBinding(handle, ptyId) - return { - handle, - isRunningAgent, - status: isRunningAgent ? terminal.titleStatus : null - } - } - return { handle, isRunningAgent: true, status: terminal.titleStatus } - } - - const isRunningAgent = await this.isTerminalRunningAgent(handle) - this.assertTerminalAgentStatusPtyBinding(handle, ptyId) - return { handle, isRunningAgent, status: null } - } - - private getTerminalAgentStatusPtyId(handle: string): string { - const pty = this.getLivePtyForHandle(handle) - if (pty) { - if (!pty.pty.connected) { - throw new Error('terminal_gone') - } - return pty.pty.ptyId - } - const { leaf } = this.getLiveLeafForHandle(handle) - if (getTerminalState(leaf) !== 'running') { - throw new Error('terminal_exited') - } - if (!leaf.ptyId) { - throw new Error('terminal_gone') - } - return leaf.ptyId - } - - private assertTerminalAgentStatusPtyBinding(handle: string, expectedPtyId: string): void { - if (this.getTerminalAgentStatusPtyId(handle) === expectedPtyId) { - return - } - // Why: delayed process evidence belongs only to the PTY that started the - // read, while callers still rely on the established stale-handle contract. - throw new Error('terminal_handle_stale') - } - - private getTerminalAgentStatusSnapshot( - handle: string, - expectedPtyId: string, - waitTextOverride?: string - ): TerminalAgentStatusSnapshot { - const pty = this.getLivePtyForHandle(handle) - if (pty) { - if (!pty.pty.connected || pty.pty.ptyId !== expectedPtyId) { - throw new Error('terminal_not_writable') - } - const leaf = this.getPrimaryLeafForPty(pty.pty.ptyId) - const leafTitle = leaf - ? getLatestAgentCandidateTitleInfo( - { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, - { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } - ) - : null - const ptyTitle = - leafTitle ?? - getLatestAgentCandidateTitleInfo( - { title: pty.pty.title, updatedAt: pty.pty.titleUpdatedAt }, - { title: pty.pty.lastOscTitle, updatedAt: pty.pty.lastOscTitleAt } - ) - const waitText = - waitTextOverride ?? - buildTerminalWaitText(pty.pty.tailBuffer, pty.pty.tailPartialLine, pty.pty.preview) - return { - waitText, - waitBlockedAt: pty.pty.waitBlockedAt, - title: ptyTitle?.title ?? null, - titleStatus: ptyTitle - ? detectAgentStatusFromTitle(ptyTitle.title) - : pty.pty.lastAgentStatus, - titleStatusIsLive: ptyTitle !== null - } - } - - const { leaf } = this.getLiveLeafForHandle(handle) - if (getTerminalState(leaf) !== 'running') { - throw new Error('terminal_exited') - } - if (!leaf.ptyId) { - throw new Error('terminal_gone') - } - if (leaf.ptyId !== expectedPtyId) { - throw new Error('terminal_not_writable') - } - const title = getLatestAgentCandidateTitleInfo( - { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, - { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }, - { title: this.tabs.get(leaf.tabId)?.title, updatedAt: 0 } - ) - return { - waitText: - waitTextOverride ?? - buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview), - waitBlockedAt: leaf.waitBlockedAt, - title: title?.title ?? null, - titleStatus: title ? detectAgentStatusFromTitle(title.title) : leaf.lastAgentStatus, - titleStatusIsLive: (title?.updatedAt ?? 0) > 0 - } - } - - private hasAuthoritativeTerminalWaitPermission( - terminal: TerminalAgentStatusSnapshot, - explicitStatus: { status: AgentStatus; updatedAt: number } | null, - lifecycle: { status: AgentStatus | null; updatedAt: number } | null | undefined - ): boolean { - return ( - this.resolveAuthoritativeTerminalWaitPermission(terminal, explicitStatus, lifecycle) !== null - ) - } - - /** The matched prompt when the tail authoritatively proves a human wait, else null. */ - private resolveAuthoritativeTerminalWaitPermission( - terminal: TerminalAgentStatusSnapshot, - explicitStatus: { status: AgentStatus; updatedAt: number } | null, - lifecycle: { status: AgentStatus | null; updatedAt: number } | null | undefined - ): RuntimeTerminalWaitBlockedReason | null { - const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText) - if (!blockedByWaitText) { - return null - } - const liveTitleClearsBlockedText = - terminal.titleStatusIsLive && - terminal.titleStatus !== null && - terminal.titleStatus !== 'permission' && - !isOpenCodeNativeTitle(terminal.title) && - // Why exempt: cursor-agent keeps spinning in its title while an approval waits, so a - // "working" title there is not evidence of progress. - blockedByWaitText !== 'agent-approval-prompt' - if (liveTitleClearsBlockedText && lifecycle?.status !== terminal.titleStatus) { - return null - } - // Why no timestamp: the menu is only matched while it still owns the bottom of the screen, - // which is the dating. Requiring one would lose a dialog restored from history, across the - // app restart an unattended run has to survive. - if (blockedByWaitText === 'agent-approval-prompt') { - return blockedByWaitText - } - const newestPermissionAt = Math.max( - explicitStatus?.status === 'permission' ? explicitStatus.updatedAt : -1, - lifecycle?.status === 'permission' ? lifecycle.updatedAt : -1, - terminal.waitBlockedAt ?? -1 - ) - const newestClearAt = Math.max( - explicitStatus && explicitStatus.status !== 'permission' ? explicitStatus.updatedAt : -1, - lifecycle?.status && lifecycle.status !== 'permission' ? lifecycle.updatedAt : -1 - ) - // Equal wall-clock observations fail closed because their raw intra-chunk order is unknown. - return newestPermissionAt >= 0 && newestPermissionAt >= newestClearAt ? blockedByWaitText : null - } - - /** Why: "blocked on a human" is the one worker state a coordinator cannot infer — silence - * covers it, a long tool call, and a wedged process alike. `null` means this pane was - * evaluated and nothing proves a wait; `undefined` means it could not be evaluated, which - * is never the same as "not waiting". */ - async getTerminalInteractiveWait( - handle: string - ): Promise { - let ptyId: string - let terminal: TerminalAgentStatusSnapshot - try { - ptyId = this.getTerminalAgentStatusPtyId(handle) - terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId) - } catch { - return undefined - } - const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle) - const promptReason = this.resolveAuthoritativeTerminalWaitPermission( - terminal, - explicitStatus, - this.agentPromptLifecycleByPtyId.get(ptyId) - ) - if (promptReason) { - // A matched prompt is self-proving: it is on this pane's screen now. - return { - source: 'prompt-text', - reason: promptReason, - ...(terminal.waitBlockedAt !== null ? { since: terminal.waitBlockedAt } : {}) - } - } - if (terminal.titleStatus === 'permission' && terminal.titleStatusIsLive) { - return { source: 'title' } - } - if (explicitStatus?.status !== 'permission') { - return null - } - // Why the extra round trip for hook evidence only: a hook row outlives its agent by up to - // AGENT_STATUS_STALE_AFTER_MS, and only the shared verdict proves an agent still owns this - // PTY — a shell that took the pane back rarely leaves a title we can recognize as one. - // Bounded because it reaches a PTY controller that can be a remote host: a wedged probe - // must leave the wait unevaluated, never stall every caller of showTerminal. - const status = await withTimeout( - this.probeAgentStatusOncePerPty(handle, ptyId), - TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS, - undefined - ) - if (!status) { - return undefined - } - return status.isRunningAgent && status.status === 'permission' - ? { source: 'hook', since: explicitStatus.updatedAt } - : null - } - - // Why single-flight: the timeout above abandons the wait, not the request. A wedged remote - // host would otherwise accrue one live probe per poll for as long as a coordinator watches. - private readonly interactiveWaitProbesByPtyId = new Map< - string, - Promise - >() - - private probeAgentStatusOncePerPty( - handle: string, - ptyId: string - ): Promise { - const inFlight = this.interactiveWaitProbesByPtyId.get(ptyId) - if (inFlight) { - return inFlight - } - const probe = this.getTerminalAgentStatus(handle) - .catch(() => undefined) - .finally(() => { - if (this.interactiveWaitProbesByPtyId.get(ptyId) === probe) { - this.interactiveWaitProbesByPtyId.delete(ptyId) - } - }) - this.interactiveWaitProbesByPtyId.set(ptyId, probe) - return probe - } - - private async terminalHasShellForegroundProcess(handle: string, ptyId: string): Promise { - if (!this.ptyController) { - return false - } - let foregroundProcess: string | null - try { - foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) - } catch { - this.assertTerminalAgentStatusPtyBinding(handle, ptyId) - return false - } - this.assertTerminalAgentStatusPtyBinding(handle, ptyId) - if (!foregroundProcess || !isShellProcess(foregroundProcess)) { - return false - } - const confirmationController = this.ptyController - if (!confirmationController?.confirmForegroundProcess) { - return true - } - let confirmedProcess: string | null - try { - confirmedProcess = await confirmationController.confirmForegroundProcess(ptyId) - } catch { - this.assertTerminalAgentStatusPtyBinding(handle, ptyId) - return true - } - this.assertTerminalAgentStatusPtyBinding(handle, ptyId) - // Why: hook identity is generic; strong provider evidence only needs to - // prove that some recognized agent still owns this exact PTY. - return recognizeAgentProcess(confirmedProcess) === null - } - - private shouldDelayPtyBackedMobileSnapshotForForegroundAgent( - pty: RuntimePtyWorktreeRecord, - title: string - ): boolean { - return ( - !pty.launchAgent && pty.foregroundAgent === null && hasCompatibleAgentTitleIdentity(title) - ) - } - - private readPtyForegroundProcessFromController( - ptyId: string, - afterTitleObservation = 0 - ): Promise | null { - const controller = this.ptyController - if (!controller) { - return null - } - const pending = this.ptyForegroundProcessReads.get(ptyId) - if ( - pending?.controller === controller && - pending.startedAfterTitleObservation >= afterTitleObservation - ) { - return pending.promise - } - if (pending?.controller === controller) { - return pending.promise.then( - () => - this.readPtyForegroundProcessFromController(ptyId, afterTitleObservation) ?? { - controller, - process: null, - available: false - } - ) - } - const unavailable: PtyForegroundProcessRead = { - controller, - process: null, - available: false - } - let processRead: Promise - try { - processRead = Promise.resolve(controller.getForegroundProcess(ptyId)) - } catch { - const entry: PtyForegroundProcessReadEntry = { - controller, - startedAfterTitleObservation: afterTitleObservation, - promise: Promise.resolve(unavailable) - } - entry.promise = entry.promise.finally(() => { - if (this.ptyForegroundProcessReads.get(ptyId) === entry) { - this.ptyForegroundProcessReads.delete(ptyId) - } - }) - this.ptyForegroundProcessReads.set(ptyId, entry) - return entry.promise - } - let entry: PtyForegroundProcessReadEntry - const promise = processRead - .then((process) => ({ controller, process, available: true })) - .catch(() => unavailable) - .finally(() => { - if (this.ptyForegroundProcessReads.get(ptyId) === entry) { - this.ptyForegroundProcessReads.delete(ptyId) - } - }) - entry = { - controller, - startedAfterTitleObservation: afterTitleObservation, - promise - } - this.ptyForegroundProcessReads.set(ptyId, entry) - return entry.promise - } - - private confirmPtyAgentExit(ptyId: string): void { - const pty = this.ptysById.get(ptyId) - const titleObservedAt = pty?.lastOscTitleAt ?? null - const foregroundRead = this.readPtyForegroundProcessFromController(ptyId, titleObservedAt ?? 0) - if (!pty?.connected || !foregroundRead) { - this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) - return - } - void foregroundRead.then((result) => { - const current = this.ptysById.get(ptyId) - if (current !== pty || !current.connected) { - return - } - if (current.lastOscTitleAt !== titleObservedAt && current.lastAgentStatus !== null) { - return - } - if ( - result.controller === this.ptyController && - result.available && - recognizeAgentProcess(result.process) !== null - ) { - const restoredStatus = this.ptyTitleTrackersByPtyId - .get(ptyId) - ?.tracker.restoreLastAgentExit() - if (restoredStatus !== null && restoredStatus !== undefined) { - current.lastAgentStatus = restoredStatus - for (const leaf of this.getLeavesForPty(ptyId)) { - if (leaf.lastAgentStatus !== null) { - continue - } - // Why: the foreground agent disproved the neutral title's exit signal; keep runtime delivery state aligned with the restored tracker. - leaf.lastAgentStatus = restoredStatus - if (restoredStatus === 'idle') { - this.deliverPendingMessagesForLeaf(leaf) - } - } - } - return - } - this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) - }) - } - - /** - * Schedules an asynchronous query to check which agent process is currently - * running in the foreground of a PTY. - */ - private refreshPtyForegroundAgent(ptyId: string): void { - void this.refreshPtyForegroundAgentFromController(ptyId) - } - - private getPendingForegroundAgentRefreshForTitle( - ptyId: string, - titleObservedAt: number - ): Promise | undefined { - if (!this.ptyForegroundAgentRefreshes.has(ptyId)) { - return undefined - } - return this.refreshPtyForegroundAgentFromController(ptyId, { - afterTitleObservation: titleObservedAt - }) - } - - private delayPtyBackedMobileSnapshotForForegroundAgent( - ptyId: string, - titleObservedAt: number, - foregroundRefresh: Promise - ): void { - this.ptyDelayedForegroundSnapshotTitleObservations.set(ptyId, titleObservedAt) - void foregroundRefresh.then((foregroundAgentChanged) => { - if (this.ptyDelayedForegroundSnapshotTitleObservations.get(ptyId) !== titleObservedAt) { - return - } - this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId) - if (this.mobileSessionTabListeners.size > 0) { - this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId) - } - if (!foregroundAgentChanged) { - this.touchMobileSessionSnapshotsForPty(ptyId) - } - }) - } - - /** - * Deduplicates and manages in-flight foreground agent refresh queries - * for a specific PTY. - */ - private refreshPtyForegroundAgentFromController( - ptyId: string, - options: { afterTitleObservation?: number } = {} - ): Promise { - const startedAfterTitleObservation = options.afterTitleObservation ?? 0 - const pendingRefresh = this.ptyForegroundAgentRefreshes.get(ptyId) - if (pendingRefresh) { - pendingRefresh.requestedAfterTitleObservation = Math.max( - pendingRefresh.requestedAfterTitleObservation, - startedAfterTitleObservation - ) - return pendingRefresh.promise - } - const entry: PtyForegroundAgentRefresh = { - promise: Promise.resolve(false), - startedAfterTitleObservation, - requestedAfterTitleObservation: startedAfterTitleObservation - } - const refresh = (async (): Promise => { - while (true) { - entry.startedAfterTitleObservation = entry.requestedAfterTitleObservation - const foregroundAgentChanged = await this.loadPtyForegroundAgentFromController( - ptyId, - entry.startedAfterTitleObservation - ) - if ( - foregroundAgentChanged || - entry.requestedAfterTitleObservation <= entry.startedAfterTitleObservation - ) { - return foregroundAgentChanged - } - } - })().finally(() => { - if (this.ptyForegroundAgentRefreshes.get(ptyId) === entry) { - this.ptyForegroundAgentRefreshes.delete(ptyId) - } - }) - entry.promise = refresh - this.ptyForegroundAgentRefreshes.set(ptyId, entry) - return refresh - } - - /** - * Queries the PTY controller for the active foreground process, identifies if it - * is a recognized agent, and updates the PTY's foreground agent state if changed. - */ - private async loadPtyForegroundAgentFromController( - ptyId: string, - afterTitleObservation = 0 - ): Promise { - if (!this.ptyController) { - return false - } - const pty = this.ptysById.get(ptyId) - if (!pty?.connected) { - return false - } - // Why: foregroundAgent is only consulted as the owner fallback when - // launchAgent is unknown, so a known launchAgent makes the relay - // getForegroundProcess round-trip pure waste (covers all launched agents). - if (pty.launchAgent) { - return false - } - const foregroundRead = this.readPtyForegroundProcessFromController(ptyId, afterTitleObservation) - if (!foregroundRead) { - return false - } - const result = await foregroundRead - if (result.controller !== this.ptyController || !result.available) { - return false - } - const foregroundProcess = result.process - const foregroundAgent = foregroundProcess - ? (recognizeAgentProcess(foregroundProcess)?.agent ?? null) - : null - if (pty.foregroundAgent === foregroundAgent) { - return false - } - pty.foregroundAgent = foregroundAgent - this.touchMobileSessionSnapshotsForPty(ptyId) - return true - } - - private getFreshExplicitAgentStatusForHandle( - handle: string, - paneKeyOverride?: string | null - ): { - status: NonNullable - updatedAt: number - /** When this state was entered. Pinned across same-state pings, so it identifies the turn. */ - stateStartedAt: number - } | null { - const paneKey = paneKeyOverride ?? this.getPaneKeyForTerminalHandle(handle) - const now = Date.now() - let bestStatus: NonNullable | null = null - let bestUpdatedAt = -1 - let bestStateStartedAt = -1 - - const consider = ( - state: AgentStatusEntry['state'] | undefined, - updatedAt: number | null | undefined, - restoredUnconfirmed = false, - stateStartedAt?: number | null - ): void => { - if (!state || restoredUnconfirmed) { - return - } - if (typeof updatedAt !== 'number' || now - updatedAt > AGENT_STATUS_STALE_AFTER_MS) { - return - } - const status = mapExplicitAgentStateToRuntimeTerminalStatus(state) - // Why: older retained permission rows can remain visible after the agent - // resumes. Prefer the newest explicit state; only let permission win ties. - if (updatedAt > bestUpdatedAt || (updatedAt === bestUpdatedAt && status === 'permission')) { - bestStatus = status - bestUpdatedAt = updatedAt - bestStateStartedAt = typeof stateStartedAt === 'number' ? stateStartedAt : updatedAt - } - } - - if (paneKey) { - const retained = this.latestAgentStatusByPaneKey.get(paneKey) - consider(retained?.payload.state, retained?.updatedAt, false, retained?.stateStartedAt) - } - - for (const entry of this.getAgentStatusSnapshotFn?.() ?? []) { - if (entry.terminalHandle !== handle && (!paneKey || entry.paneKey !== paneKey)) { - continue - } - consider(entry.state, entry.receivedAt, entry.restoredUnconfirmed, entry.stateStartedAt) - } - - return bestStatus - ? { status: bestStatus, updatedAt: bestUpdatedAt, stateStartedAt: bestStateStartedAt } - : null - } - - private async writeTerminalAction( - ptyId: string, - action: { text?: string; enter?: boolean; interrupt?: boolean }, - payload: string, - options: { - beforeWrite?: (ptyId: string) => void | Promise - reserveWrite?: (ptyId: string) => void - afterWrite?: (ptyId: string) => void | Promise - suffixFailureError?: string - signal?: AbortSignal - } = {} - ): Promise { - // Why: the lease is checked before the mobile floor is reserved, so a refused send never takes - // a claim it will not use. - const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId) - // Why: direct terminal.send can carry paste-sized text from RPC/mobile - // clients; chunk text before PTY/ConPTY while preserving suffix separation. - const text = typeof action.text === 'string' ? action.text : '' - const hasSuffix = action.enter || action.interrupt - if (text) { - await this.writeTerminalInputChunks(ptyId, text, options, admitted) - } - if (hasSuffix) { - const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') - if (text) { - // Why: same hazard as the agent-prompt path -- Enter must not overtake text the - // execution host is still ingesting, and a flat 500 ms cannot cover 16 MB. - await waitForAgentPromptDelay( - getAgentPromptSubmitDelayMs( - this.getPtyWriteHostPlatform(ptyId), - Buffer.byteLength(text, 'utf8') - ), - options.signal - ) - } - // Why: the 500ms text/suffix pause is long enough for a handoff to complete, so the submit - // is re-checked against the fence the text was admitted under. - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) - try { - await options.beforeWrite?.(ptyId) - } catch (error) { - if (options.suffixFailureError) { - throw new Error(options.suffixFailureError) - } - throw error - } - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) - options.reserveWrite?.(ptyId) - const suffixWrote = this.ptyController?.write(ptyId, suffix) ?? false - if (!suffixWrote) { - throw new Error(options.suffixFailureError ?? 'terminal_not_writable') - } - await options.afterWrite?.(ptyId) - return - } - if (text) { - return - } - - await options.beforeWrite?.(ptyId) - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) - options.reserveWrite?.(ptyId) - const wrote = this.ptyController?.write(ptyId, payload) ?? false - if (!wrote) { - throw new Error('terminal_not_writable') - } - await options.afterWrite?.(ptyId) - } - - private async writeTerminalInputChunks( - ptyId: string, - text: string, - options: { - beforeWrite?: (ptyId: string) => void | Promise - reserveWrite?: (ptyId: string) => void - afterWrite?: (ptyId: string) => void | Promise - } = {}, - admitted: AgentSessionPtyWriteAdmittance - ): Promise { - const chunks = iterateTerminalInputChunks(text) - let chunk = chunks.next() - let firstChunk = true - while (!chunk.done) { - // Why: every inter-chunk yield is a window for a handoff to take the lease; the rest of a - // paste must not land in a session this runtime no longer owns. - if (!firstChunk) { - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) - } - firstChunk = false - await options.beforeWrite?.(ptyId) - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) - options.reserveWrite?.(ptyId) - const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false - if (!wrote) { - throw new Error('terminal_not_writable') - } - await options.afterWrite?.(ptyId) - chunk = chunks.next() - if (!chunk.done) { - await yieldBetweenTerminalInputChunks() - } - } - } - - /** Platform of the host whose pty transport ingests our writes -- deliberately NOT the OS - * the command runs under. A WSL pane is spawned as `wsl.exe` through the Windows ConPTY - * (see local-pty-provider), so it pays the ConPTY ingest cost even though its shell is - * Linux; an SSH pane is spawned by node-pty on the remote host, so the client's - * process.platform says nothing about it. */ - private getPtyWriteHostPlatform(ptyId: string): NodeJS.Platform { - const pty = this.ptysById.get(ptyId) - const connectionId = pty?.connectionId - if (!connectionId) { - return process.platform - } - const remotePlatform = getRegisteredSshState(connectionId)?.remotePlatform - if (remotePlatform) { - return remotePlatform - } - // Why: remotePlatform only arrives with the relay handshake; until then the worktree path - // flavor is the same signal getAgentLaunchPlatformForRepo already trusts for a remote repo. - const worktreePath = pty ? splitWorktreeIdForFilesystem(pty.worktreeId)?.worktreePath : null - return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'linux' - } - - private async writeTerminalAgentPrompt( - handle: string, - ptyId: string, - generation: number, - pastePayload: string, - options: { - beforeWrite?: (ptyId: string) => void | Promise - suffixFailureError?: string - signal?: AbortSignal - } = {} - ): Promise { - assertAgentPromptRequestActive(options.signal) - this.assertAgentPromptGeneration(ptyId, generation) - const permissionBaseline = this.getAgentPromptActivity(handle, ptyId) - this.assertAgentPromptPermissionSafe(permissionBaseline, permissionBaseline) - const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId) - // Why: the floor for every wait below. Enter must never overtake bytes the execution - // host is still feeding the child, and that cost is proportional to the payload. - const writeHostPlatform = this.getPtyWriteHostPlatform(ptyId) - const pasteByteLength = Buffer.byteLength(pastePayload, 'utf8') - const pasteIngestMs = getTerminalPasteIngestMs(writeHostPlatform, pasteByteLength) - const renderGate = this.createAgentPromptRenderGate(ptyId, pasteIngestMs) - try { - assertAgentPromptRequestActive(options.signal) - this.assertAgentPromptGeneration(ptyId, generation) - await options.beforeWrite?.(ptyId) - assertAgentPromptRequestActive(options.signal) - this.assertAgentPromptGeneration(ptyId, generation) - this.assertAgentPromptPermissionSafe( - permissionBaseline, - this.getAgentPromptActivity(handle, ptyId) - ) - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) - // Keep the bracketed paste frame in one PTY write; Claude's composer can drop the - // beginning when a large frame is split into independently processed chunks. - renderGate?.arm() - const wrote = this.ptyController?.write(ptyId, pastePayload) ?? false - if (!wrote) { - throw new Error('terminal_not_writable') - } - } catch (error) { - renderGate?.dispose() - throw error - } - - if (renderGate) { - try { - await waitForAgentPromptPromise(renderGate.wait(), options.signal) - } finally { - renderGate.dispose() - } - } else { - await waitForAgentPromptDelay( - getAgentPromptSubmitDelayMs(writeHostPlatform, pasteByteLength), - options.signal - ) - } - assertAgentPromptRequestActive(options.signal) - this.assertAgentPromptGeneration(ptyId, generation) - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) - try { - await options.beforeWrite?.(ptyId) - } catch (error) { - if (options.suffixFailureError) { - throw new Error(options.suffixFailureError) - } - throw error - } - assertAgentPromptRequestActive(options.signal) - this.assertAgentPromptGeneration(ptyId, generation) - const waitTextCache: AgentPromptWaitTextCache = {} - const baseline = this.getAgentPromptActivity(handle, ptyId, waitTextCache) - this.assertAgentPromptPermissionSafe(permissionBaseline, baseline) - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) - const suffixWrote = this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT) ?? false - if (!suffixWrote) { - throw new Error(options.suffixFailureError ?? 'terminal_not_writable') - } - await verifyAgentPromptSubmission({ - baseline, - readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache), - timeoutMs: resolveAgentPromptEffectTimeoutMs(this.getPtyAgent(ptyId)), - signal: options.signal - }) - return 1 - } - - private async serializeAgentPromptSubmission( - ptyId: string, - generation: number, - submit: () => Promise - ): Promise { - const queueKey = `${ptyId}\u0000${generation}` - const previous = this.agentPromptSubmissionTailByPtyId.get(queueKey) ?? Promise.resolve() - const submission = previous.catch(() => undefined).then(submit) - const tail = submission.then( - () => undefined, - () => undefined - ) - this.agentPromptSubmissionTailByPtyId.set(queueKey, tail) - try { - return await submission - } finally { - if (this.agentPromptSubmissionTailByPtyId.get(queueKey) === tail) { - this.agentPromptSubmissionTailByPtyId.delete(queueKey) - } - } - } - - private getAgentPromptActivity( - handle: string, - ptyId: string, - waitTextCache?: AgentPromptWaitTextCache - ): AgentPromptActivity { - this.assertLiveTerminalHandleTargetsPty(handle, ptyId) - const outputSequence = this.getPtyOutputSequence(ptyId) - const explicitCandidate = this.getFreshExplicitAgentStatusForHandle(handle) - const explicitFloor = this.agentPromptExplicitStatusFloorByPtyId.get(ptyId) - const explicit = - explicitCandidate && - (explicitFloor === undefined || explicitCandidate.updatedAt > explicitFloor) - ? explicitCandidate - : null - const lifecycle = this.agentPromptLifecycleByPtyId.get(ptyId) - const ptyStatus = - lifecycle || explicitFloor === undefined - ? (this.ptysById.get(ptyId)?.lastAgentStatus ?? null) - : null - const lifecycleIsNewer = - lifecycle && - (!explicit || - lifecycle.updatedAt > explicit.updatedAt || - (lifecycle.updatedAt === explicit.updatedAt && lifecycle.status === 'permission')) - const waitText = waitTextCache - ? readAgentPromptWaitText( - waitTextCache, - outputSequence, - () => this.getTerminalAgentStatusSnapshot(handle, ptyId).waitText - ) - : undefined - const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId, waitText) - const status = this.hasAuthoritativeTerminalWaitPermission(terminal, explicit, lifecycle) - ? 'permission' - : lifecycleIsNewer - ? lifecycle.status - : (explicit?.status ?? ptyStatus ?? null) - return { - generation: this.getPtyLifecycleGeneration(ptyId), - permissionSequence: this.agentPromptPermissionSequenceByPtyId.get(ptyId) ?? 0, - workingSequence: lifecycle?.workingSequence ?? 0, - // Why: hook status is the only turn-start signal agents without title coverage have, and it - // reaches here without the window-gated synthetic title frame (#16095). Anchored on - // stateStartedAt, not updatedAt — same-state tool/prompt pings refresh updatedAt and would - // otherwise pass off an in-progress turn as a new one. - explicitWorkingStartedAt: explicit?.status === 'working' ? explicit.stateStartedAt : null, - outputSequence, - status - } - } - - private getPtyAgent(ptyId: string): TuiAgent | null { - const pty = this.ptysById.get(ptyId) - return pty?.launchAgent ?? pty?.foregroundAgent ?? null - } - - private assertAgentPromptPermissionSafe( - baseline: AgentPromptActivity, - current: AgentPromptActivity - ): void { - if ( - current.status === 'permission' || - current.permissionSequence > baseline.permissionSequence - ) { - throw new Error('agent_prompt_blocked') - } - } - - private assertAgentPromptGeneration(ptyId: string, expected: number): void { - if (this.getPtyLifecycleGeneration(ptyId) !== expected) { - throw new Error('terminal_handle_stale') - } - } - - /** `pasteIngestMs` is the payload's ingest bound on the executing host. Nothing here may - * settle before it elapses: the agent can repaint mid-ingest, so marker-then-quiet alone - * would fire Enter into a paste ConPTY is still feeding. */ - private createAgentPromptRenderGate( - ptyId: string, - pasteIngestMs: number - ): { - arm: () => void - wait: () => Promise - dispose: () => void - } | null { - if (!isTerminalSendSettlementAgent(this.getPtyAgent(ptyId))) { - return null - } - let armed = false - let canSettle = false - let settled = false - let ingested = pasteIngestMs <= 0 - // Why absolute: the ingest clock starts once, here, but the cap is armed twice (at arm() - // and again on the marker). Re-adding the whole window would charge ingest twice. - const ingestDeadlineAt = Date.now() + pasteIngestMs - let markerCarry = '' - let quietTimer: NodeJS.Timeout | null = null - let hardTimer: NodeJS.Timeout | null = null - let ingestTimer: NodeJS.Timeout | null = null - let resolveRender!: () => void - const rendered = new Promise((resolve) => { - resolveRender = resolve - }) - - const clearGateTimers = (): void => { - if (quietTimer) { - clearTimeout(quietTimer) - quietTimer = null - } - if (hardTimer) { - clearTimeout(hardTimer) - hardTimer = null - } - if (ingestTimer) { - clearTimeout(ingestTimer) - ingestTimer = null - } - } - const finish = (): void => { - if (settled) { - return - } - settled = true - clearGateTimers() - resolveRender() - } - const armQuietTimer = (): void => { - // Why: the quiet window measures the agent going still after a *complete* paste. - // Silence during ingest is not settlement, so it cannot start the clock. - if (!ingested) { - return - } - if (quietTimer) { - clearTimeout(quietTimer) - } - quietTimer = setTimeout(finish, AGENT_PROMPT_RENDER_QUIET_MS) - } - const armHardTimer = (): void => { - if (hardTimer) { - clearTimeout(hardTimer) - } - // Why: the cap bounds the wait *after* the bytes land; a flat 8000 ms would expire - // mid-paste past ~770 KB on Windows and write Enter into it. - hardTimer = setTimeout( - finish, - AGENT_PROMPT_RENDER_TIMEOUT_MS + Math.max(0, ingestDeadlineAt - Date.now()) - ) - } - if (!ingested) { - ingestTimer = setTimeout(() => { - ingestTimer = null - ingested = true - if (canSettle) { - armQuietTimer() - } - }, pasteIngestMs) - } - const unsubscribe = this.subscribeToTerminalData(ptyId, (data) => { - if (!armed || settled) { - return - } - if (!canSettle) { - const combined = markerCarry + data - markerCarry = combined.slice(-(AGENT_PROMPT_RENDER_MARKER.length - 1)) - if (!combined.includes(AGENT_PROMPT_RENDER_MARKER)) { - return - } - canSettle = true - // Why: a slow initial redraw must still receive the full settlement window. - armHardTimer() - } - armQuietTimer() - }) - return { - arm: () => { - armed = true - markerCarry = '' - armHardTimer() - }, - wait: async () => { - if (settled) { - return - } - await rendered - }, - dispose: () => { - unsubscribe() - clearGateTimers() - } - } - } - - async waitForTerminal( - handle: string, - options?: { - condition?: RuntimeTerminalWaitCondition - timeoutMs?: number - signal?: AbortSignal - } - ): Promise { - const condition = options?.condition ?? 'exit' - const pty = this.getLivePtyForHandle(handle) - if (pty) { - if (condition === 'exit' && !pty.pty.connected) { - return buildPtyTerminalWaitResult(handle, condition, pty.pty) - } - const ptyWaitText = buildTerminalWaitText( - pty.pty.tailBuffer, - pty.pty.tailPartialLine, - pty.pty.preview - ) - const ptyBlockedReason = detectTerminalWaitBlockedReason(ptyWaitText) - if (condition === 'tui-idle' && ptyBlockedReason) { - return buildPtyTerminalWaitBlockedResult(handle, condition, pty.pty, ptyBlockedReason) - } - if (condition === 'tui-idle' && pty.pty.lastAgentStatus === 'idle') { - return buildPtyTerminalWaitResult(handle, condition, pty.pty) - } - if ( - condition === 'tui-idle' && - (this.getAdoptedPtyExplicitIdleStatus(pty.pty) === 'idle' || - isKnownReadyPromptPreview(ptyWaitText)) - ) { - return buildPtyTerminalWaitResult(handle, condition, pty.pty) - } - return await new Promise((resolve, reject) => { - const effectiveTimeoutMs = - typeof options?.timeoutMs === 'number' && options.timeoutMs > 0 - ? options.timeoutMs - : condition === 'tui-idle' - ? TUI_IDLE_DEFAULT_TIMEOUT_MS - : 0 - const waiter: TerminalWaiter = { - handle, - condition, - resolve, - reject, - timeout: null, - pollInterval: null, - abortCleanup: null - } - if (!this.bindTerminalWaiterAbort(waiter, options?.signal)) { - reject(new Error('request_aborted')) - return - } - if (effectiveTimeoutMs > 0) { - waiter.timeout = setTimeout(() => { - this.removeWaiter(waiter) - reject(new Error('timeout')) - }, effectiveTimeoutMs) - } - let waiters = this.waitersByHandle.get(handle) - if (!waiters) { - waiters = new Set() - this.waitersByHandle.set(handle, waiters) - } - waiters.add(waiter) - const live = this.getLivePtyForHandle(handle) - if (!live) { - this.removeWaiter(waiter) - reject(new Error('terminal_handle_stale')) - } else if (condition === 'exit' && !live.pty.connected) { - this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) - } else if (condition === 'tui-idle') { - const livePtyWaitText = buildTerminalWaitText( - live.pty.tailBuffer, - live.pty.tailPartialLine, - live.pty.preview - ) - const blockedReason = detectTerminalWaitBlockedReason(livePtyWaitText) - if (blockedReason) { - this.resolveWaiter( - waiter, - buildPtyTerminalWaitBlockedResult(handle, condition, live.pty, blockedReason) - ) - } else if (live.pty.lastAgentStatus === 'idle') { - this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) - } else if ( - this.getAdoptedPtyExplicitIdleStatus(live.pty) === 'idle' || - isKnownReadyPromptPreview(livePtyWaitText) - ) { - this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) - } else { - this.startPtyTuiIdleFallbackPoll(waiter, live.pty, effectiveTimeoutMs) - } - } - }) - } - const { leaf } = this.getLiveLeafForHandle(handle) - - if (condition === 'exit' && getTerminalState(leaf) === 'exited') { - return buildTerminalWaitResult(handle, condition, leaf) - } - - const leafWaitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) - const leafBlockedReason = detectTerminalWaitBlockedReason(leafWaitText) - if (condition === 'tui-idle' && leafBlockedReason) { - return buildTerminalWaitBlockedResult(handle, condition, leaf, leafBlockedReason) - } - - // Why: if the agent already transitioned to idle (or permission) before the - // waiter was registered, resolve immediately. This uses the same OSC title - // detection that powers the renderer's "Task complete" notifications. - // Why: only 'idle' satisfies tui-idle, not 'permission'. Permission means the - // agent is blocked on user approval, not finished with its task. - if (condition === 'tui-idle' && leaf.lastAgentStatus === 'idle') { - return buildTerminalWaitResult(handle, condition, leaf) - } - if (condition === 'tui-idle') { - const fastPathTitle = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title - if ( - (fastPathTitle && detectExplicitIdleStatusFromTitle(fastPathTitle) === 'idle') || - isKnownReadyPromptPreview(leafWaitText) - ) { - return buildTerminalWaitResult(handle, condition, leaf) - } - } - - return await new Promise((resolve, reject) => { - // Why: tui-idle depends on OSC title transitions from a recognized agent. - // If no agent is detected, the waiter would hang forever. Enforce a default - // timeout so unsupported CLIs fail predictably instead of silently blocking. - const effectiveTimeoutMs = - typeof options?.timeoutMs === 'number' && options.timeoutMs > 0 - ? options.timeoutMs - : condition === 'tui-idle' - ? TUI_IDLE_DEFAULT_TIMEOUT_MS - : 0 - - const waiter: TerminalWaiter = { - handle, - condition, - resolve, - reject, - timeout: null, - pollInterval: null, - abortCleanup: null - } - - if (!this.bindTerminalWaiterAbort(waiter, options?.signal)) { - reject(new Error('request_aborted')) - return - } - - if (effectiveTimeoutMs > 0) { - waiter.timeout = setTimeout(() => { - this.removeWaiter(waiter) - reject(new Error('timeout')) - }, effectiveTimeoutMs) - } - - let waiters = this.waitersByHandle.get(handle) - if (!waiters) { - waiters = new Set() - this.waitersByHandle.set(handle, waiters) - } - waiters.add(waiter) - - // Why: the handle may go stale or exit in the small gap between the first - // validation and waiter registration. Re-checking here keeps wait --for - // exit honest instead of hanging on a terminal that already changed. - try { - const live = this.getLiveLeafForHandle(handle) - if (getTerminalState(live.leaf) === 'exited') { - this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) - } else if (condition === 'tui-idle') { - const liveLeafWaitText = buildTerminalWaitText( - live.leaf.tailBuffer, - live.leaf.tailPartialLine, - live.leaf.preview - ) - const blockedReason = detectTerminalWaitBlockedReason(liveLeafWaitText) - if (blockedReason) { - this.resolveWaiter( - waiter, - buildTerminalWaitBlockedResult(handle, condition, live.leaf, blockedReason) - ) - } else if (live.leaf.lastAgentStatus === 'idle') { - // Why: don't clear lastAgentStatus here. It's a factual record of the - // last detected OSC state, not a one-shot signal. Clearing it causes - // subsequent tui-idle waiters to hang even though the agent is idle — - // the first waiter consumes the status and all later ones see null. - this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) - } else { - // Why: renderer-synced previews can show a known ready prompt even - // while the last OSC title is still "working"; keep polling the - // preview/title until the waiter resolves or hits its timeout. - const fastPathTitle = live.leaf.paneTitle ?? this.tabs.get(live.leaf.tabId)?.title - if ( - (fastPathTitle && detectExplicitIdleStatusFromTitle(fastPathTitle) === 'idle') || - isKnownReadyPromptPreview(liveLeafWaitText) - ) { - this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) - } else { - this.startTuiIdleFallbackPoll(waiter, live.leaf, effectiveTimeoutMs) - } - } - } - } catch (error) { - this.removeWaiter(waiter) - reject(error instanceof Error ? error : new Error(String(error))) - } - }) - } - - subscribeToPtyExit(ptyId: string, listener: () => void): () => void { - const lifecycleGeneration = this.getPtyLifecycleGeneration(ptyId) - if (this.isPtyKnownExited(ptyId)) { - listener() - return () => {} - } - let listeners = this.ptyExitListenersByPtyId.get(ptyId) - if (!listeners) { - listeners = new Set() - this.ptyExitListenersByPtyId.set(ptyId, listeners) - } - let active = true - const unsubscribe = (): void => { - if (!active) { - return - } - active = false - listeners.delete(listener) - if (listeners.size === 0 && this.ptyExitListenersByPtyId.get(ptyId) === listeners) { - this.ptyExitListenersByPtyId.delete(ptyId) - } - } - listeners.add(listener) - if ( - this.getPtyLifecycleGeneration(ptyId) !== lifecycleGeneration || - this.isPtyKnownExited(ptyId) - ) { - unsubscribe() - listener() - } - return unsubscribe - } - - async waitForSetupTerminalCompletion(handle: string): Promise<{ exitCode: number | null }> { - const ptyId = this.getLivePtyForHandle(handle)?.pty.ptyId - if (!ptyId) { - throw new Error('terminal_handle_stale') - } - const completionToken = this.setupCompletionTokenByPtyId.get(ptyId) - const exitAbort = new AbortController() - return await new Promise<{ exitCode: number | null }>((resolve, reject) => { - let settled = false - let unsubscribe: (() => void) | null = null - const cleanup = (): void => { - unsubscribe?.() - exitAbort.abort() - } - const finish = (exitCode: number | null): void => { - if (settled) { - return - } - settled = true - cleanup() - this.setupCompletionTokenByPtyId.delete(ptyId) - resolve({ exitCode }) - } - const fail = (error: unknown): void => { - if (settled) { - return - } - settled = true - cleanup() - reject(error) - } - const scanner = completionToken ? createSetupCompletionScanner(completionToken, finish) : null - - if (scanner) { - unsubscribe = this.subscribeToTerminalData(ptyId, scanner.scan) - } - // Why: setup can finish before the observer is registered on fast local worktrees. - const replay = this.recentPtyOutputById.get(ptyId)?.read() - if (scanner && replay) { - scanner.scan(replay) - } - if (!settled) { - void this.waitForTerminal(handle, { - condition: 'exit', - signal: exitAbort.signal - }) - .then((wait) => { - if (wait.satisfied && wait.condition === 'exit' && wait.status === 'exited') { - finish(wait.exitCode) - } - }) - .catch(fail) - } - }) - } - - async getWorktreePs( - limit = DEFAULT_WORKTREE_PS_LIMIT, - sourceDefaultsSupported = true - ): Promise<{ - worktrees: RuntimeWorktreePsSummary[] - totalCount: number - truncated: boolean - }> { - if (!Number.isInteger(limit) || limit <= 0) { - throw new Error('invalid_limit') - } - const resolvedWorktreeSnapshot = await this.listResolvedWorktreeSnapshot() - const settings = this.store?.getSettings() - const visibilityDefaults = sourceDefaultsSupported - ? settings?.worktreeVisibilityDefaults - : settings?.worktreeVisibilityDefaults - ? { external: settings.worktreeVisibilityDefaults.external } - : undefined - const visibilitySettings = settings - ? { ...settings, worktreeVisibilityDefaults: visibilityDefaults } - : undefined - const visibilitySourceMatchersByRepoId = this.buildRuntimeVisibilitySourceMatchersByRepoId( - resolvedWorktreeSnapshot.worktrees, - visibilityDefaults - ) - const resolvedWorktrees = resolvedWorktreeSnapshot.worktrees.filter((worktree) => - this.isRuntimeWorktreeVisible( - worktree, - visibilitySourceMatchersByRepoId.get(worktree.repoId), - visibilitySettings - ) - ) - // Why: worktree.ps backs the mobile sidebar, so it must use the same - // host-owned imported-worktree visibility gate as worktree.list/desktop. - const freshPtyLiveness = await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) - 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 - // PR info so mobile clients can group worktrees by PR state without making - // expensive `gh` CLI calls. Falls back to meta.linkedPR if no cache entry exists. - const ghCache = this.store?.getGitHubCache?.() - for (const worktree of resolvedWorktrees) { - const meta = - this.store?.getWorktreeMeta?.(worktree.id) ?? this.store?.getAllWorktreeMeta()[worktree.id] - const repo = repoById.get(worktree.repoId) - let linkedPR: { number: number; state: string } | null = null - const branch = worktree.branch.replace(/^refs\/heads\//, '') - if (branch && ghCache) { - // Why: the renderer keys the PR cache by `repoId::branch` (getGitHubPRCacheKey - // prefers repo.id over repo.path), so read by id first and fall back to path - // for legacy/path-keyed entries. Reading only by path missed every cached - // entry, leaving mobile's linked-PR badge stuck on the 'unknown' fallback. - const cached = - (repo?.id ? ghCache.pr[`${repo.id}::${branch}`] : undefined) ?? - (repo?.path ? ghCache.pr[`${repo.path}::${branch}`] : undefined) - if (cached?.data) { - linkedPR = { number: cached.data.number, state: cached.data.state } - } - } - if (!linkedPR && meta?.linkedPR != null) { - linkedPR = { number: meta.linkedPR, state: 'unknown' } - } - const terminalPlatform = platformByRepoId.get(worktree.repoId) ?? process.platform - // Why: use the instance-validated lineage from attachLineageToResolvedWorktrees, - // not the raw store entry — shipped mobile clients trust parentWorktreeId as-is, - // so a stale same-path entry would nest replacement checkouts under old parents. - const lineage = worktree.lineage - summaries.set(worktree.id, { - // Why: mobile mirrors desktop workspace grouping/order from persisted - // metadata, while older runtimes may not have hydrated every field yet. - workspaceKind: 'git', - worktreeId: worktree.id, - repoId: worktree.repoId, - ...((meta?.hostId ?? worktree.hostId) ? { hostId: meta?.hostId ?? worktree.hostId } : {}), - terminalPlatform, - repo: repo?.displayName ?? worktree.repoId, - path: worktree.path, - branch: worktree.branch, - isArchived: worktree.isArchived, - isMainWorktree: worktree.isMainWorktree, - hasHostSidebarActivity: false, - ...(worktree.instanceId !== undefined ? { worktreeInstanceId: worktree.instanceId } : {}), - ...(lineage?.worktreeInstanceId !== undefined - ? { lineageWorktreeInstanceId: lineage.worktreeInstanceId } - : {}), - ...(lineage?.parentWorktreeInstanceId !== undefined - ? { parentWorktreeInstanceId: lineage.parentWorktreeInstanceId } - : {}), - parentWorktreeId: worktree.parentWorktreeId, - childWorktreeIds: worktree.childWorktreeIds, - displayName: worktree.displayName, - workspaceStatus: meta?.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, - sortOrder: meta?.sortOrder ?? 0, - ...(meta?.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), - lastActivityAt: worktree.lastActivityAt, - ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), - ...(worktree.creatorProvenance ? { creatorProvenance: worktree.creatorProvenance } : {}), - linkedIssue: worktree.linkedIssue, - linkedPR, - linkedLinearIssue: meta?.linkedLinearIssue ?? null, - linkedGitLabMR: meta?.linkedGitLabMR ?? null, - linkedGitLabIssue: meta?.linkedGitLabIssue ?? null, - comment: meta?.comment ?? '', - isPinned: meta?.isPinned ?? false, - isActive: false, - unread: meta?.isUnread ?? false, - liveTerminalCount: 0, - hasAttachedPty: false, - lastOutputAt: null, - preview: '', - status: 'inactive', - agents: [] - }) - } - - const projectGroupById = new Map( - (this.store?.getProjectGroups?.() ?? []).map((group) => [group.id, group]) - ) - for (const folderWorkspace of this.store?.getFolderWorkspaces?.() ?? []) { - const projectGroup = projectGroupById.get(folderWorkspace.projectGroupId) - if (!projectGroup?.parentPath) { - continue - } - const worktree = folderWorkspaceToWorktree(folderWorkspace) - summaries.set(worktree.id, { - // Why: folder workspaces use the same mobile grouping/order contract as - // git worktrees, but legacy records may be missing order metadata. - workspaceKind: 'folder-workspace', - worktreeId: worktree.id, - repoId: worktree.repoId, - repo: projectGroup.name, - path: worktree.path, - branch: worktree.branch, - isArchived: worktree.isArchived, - isMainWorktree: worktree.isMainWorktree, - hasHostSidebarActivity: false, - ...(worktree.instanceId !== undefined ? { worktreeInstanceId: worktree.instanceId } : {}), - parentWorktreeId: null, - childWorktreeIds: [], - displayName: worktree.displayName, - workspaceStatus: worktree.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, - sortOrder: worktree.sortOrder ?? 0, - ...(worktree.manualOrder !== undefined ? { manualOrder: worktree.manualOrder } : {}), - lastActivityAt: worktree.lastActivityAt, - ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), - ...(worktree.creatorProvenance ? { creatorProvenance: worktree.creatorProvenance } : {}), - linkedIssue: worktree.linkedIssue ?? null, - linkedPR: null, - linkedLinearIssue: worktree.linkedLinearIssue ?? null, - linkedGitLabMR: worktree.linkedGitLabMR ?? null, - linkedGitLabIssue: worktree.linkedGitLabIssue ?? null, - comment: worktree.comment, - isPinned: worktree.isPinned, - isActive: false, - unread: worktree.isUnread, - liveTerminalCount: 0, - hasAttachedPty: false, - lastOutputAt: null, - preview: '', - status: 'inactive', - agents: [] - }) - } - - const runtimeWorktreeSummaryPathIndex = buildRuntimeWorktreeSummaryPathIndex( - summaries, - resolvedWorktrees, - platformByRepoId - ) - const missingRuntimeWorktreeIds = new Set() - const countedPtyIds = new Set() - const session = this.store?.getWorkspaceSession?.() - const savedTabOwnerById = new Map() - for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) { - for (const tab of tabs) { - savedTabOwnerById.set(tab.id, { worktreeId, title: tab.title }) - } - } - const savedLayoutTabIdByPtyId = new Map() - for (const [tabId, layout] of Object.entries(session?.terminalLayoutsByTabId ?? {})) { - for (const ptyId of Object.values(layout?.ptyIdsByLeafId ?? {})) { - if (ptyId) { - savedLayoutTabIdByPtyId.set(ptyId, tabId) - } - } - } - for (const leaf of this.leaves.values()) { - if ( - !leaf.ptyId || - !leaf.connected || - (freshPtyLiveness !== null && !freshPtyLiveness.has(leaf.ptyId)) - ) { - continue - } - const freshPtyOwner = this.ptysById.get(leaf.ptyId) - if ( - freshPtyLiveness !== null && - freshPtyOwner?.connected && - !worktreeIdsEqual(freshPtyOwner.worktreeId, leaf.worktreeId) - ) { - // Why: provider/persisted ownership is fresher than a renderer leaf left behind by graph migration or another client. - continue - } - const summary = this.getSummaryForRuntimeWorktreeId( - summaries, - runtimeWorktreeSummaryPathIndex, - missingRuntimeWorktreeIds, - leaf.worktreeId - ) - if (!summary) { - continue - } - countedPtyIds.add(leaf.ptyId) - summary.hasHostSidebarActivity = true - const previousLastOutputAt = summary.lastOutputAt - summary.liveTerminalCount += 1 - summary.hasAttachedPty = true - summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, leaf.lastOutputAt) - 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)) - ) { - summary.preview = leaf.preview - } - } - - for (const pty of this.ptysById.values()) { - if ( - !pty.connected || - countedPtyIds.has(pty.ptyId) || - (freshPtyLiveness !== null && !freshPtyLiveness.has(pty.ptyId)) - ) { - continue - } - const persistedTabId = savedLayoutTabIdByPtyId.get(pty.ptyId) - let owner = persistedTabId ? savedTabOwnerById.get(persistedTabId) : undefined - if (freshPtyLiveness !== null) { - // Why: refresh resolved provider/migration ownership; stale persisted tabs may supply a title but cannot reassign a live PTY. - owner = { - worktreeId: pty.worktreeId, - title: owner?.title ?? getLatestPtyTitle(pty) ?? '' - } - } - if (!owner && persistedTabId && pty.tabId === persistedTabId) { - owner = { - worktreeId: pty.worktreeId, - title: getLatestPtyTitle(pty) ?? '' - } - } - const parsedPaneKey = parsePaneKey(pty.paneKey ?? '') - const hasExplicitRuntimeOwner = - pty.tabId !== null && parsedPaneKey?.tabId === pty.tabId && parsedPaneKey.leafId.length > 0 - const savedTabOwner = pty.tabId ? savedTabOwnerById.get(pty.tabId) : undefined - const hasSavedLayout = - pty.tabId !== null && Object.hasOwn(session?.terminalLayoutsByTabId ?? {}, pty.tabId) - if (!owner && hasExplicitRuntimeOwner && !hasSavedLayout) { - owner = { - worktreeId: savedTabOwner?.worktreeId ?? pty.worktreeId, - title: savedTabOwner?.title ?? getLatestPtyTitle(pty) ?? '' - } - } - if (!owner) { - // Why: provider existence alone cannot attribute a reused or unbound PTY to a workspace. - continue - } - const summary = this.getSummaryForRuntimeWorktreeId( - summaries, - runtimeWorktreeSummaryPathIndex, - missingRuntimeWorktreeIds, - owner.worktreeId - ) - if (!summary) { - continue - } - const previousLastOutputAt = summary.lastOutputAt - summary.liveTerminalCount += 1 - summary.hasAttachedPty = true - summary.hasHostSidebarActivity = true - summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt) - 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)) - ) { - summary.preview = pty.preview - } - } - - const mirroredWorktreeIdByTabId = new Map() - const sessionsByHostId = new Map() - for (const summary of summaries.values()) { - const repo = repoById.get(summary.repoId) - const hostId = repo ? getRepoExecutionHostId(repo) : 'local' - const session = this.store?.getWorkspaceSession?.(hostId) - if (session) { - sessionsByHostId.set(hostId, session) - } - } - for (const session of sessionsByHostId.values()) { - for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { - for (const tab of tabs) { - mirroredWorktreeIdByTabId.set(tab.id, worktreeId) - } - if (tabs.length === 0) { - continue - } - const summary = this.getSummaryForRuntimeWorktreeId( - summaries, - runtimeWorktreeSummaryPathIndex, - missingRuntimeWorktreeIds, - worktreeId - ) - if (!summary) { - continue - } - if (tabs.some((tab) => tab.ptyId !== null && this.ptysById.get(tab.ptyId)?.connected)) { - summary.hasHostSidebarActivity = true - } - } - for (const [worktreeId, tabs] of Object.entries(session.browserTabsByWorktree ?? {})) { - if (tabs.length === 0) { - continue - } - const summary = this.getSummaryForRuntimeWorktreeId( - summaries, - runtimeWorktreeSummaryPathIndex, - missingRuntimeWorktreeIds, - worktreeId - ) - if (summary) { - summary.hasHostSidebarActivity = true - } - } - if (session.activeWorktreeId) { - const activeSummary = this.getSummaryForRuntimeWorktreeId( - summaries, - runtimeWorktreeSummaryPathIndex, - missingRuntimeWorktreeIds, - session.activeWorktreeId - ) - if (activeSummary) { - activeSummary.isActive = true - } - } - } - // Why: a live renderer graph may precede persistence, but persisted tab - // ownership wins when an automatic workspace rename has already rekeyed it. - for (const [tabId, tab] of this.tabs) { - if (!mirroredWorktreeIdByTabId.has(tabId)) { - mirroredWorktreeIdByTabId.set(tabId, tab.worktreeId) - } - } - - // Why: a connected PTY proves a pane is still live even when its tab has - // already left every session record (daemon-held terminals, graph gaps). - // Deliberately trusts the optimistic connected flag (no freshPtyLiveness - // gate, unlike the count loops above): evidence only KEEPS rows. - const connectedPtyEvidence = { - tabIds: new Set(), - paneKeys: new Set(), - ptyIds: new Set() - } - for (const pty of this.ptysById.values()) { - if (!pty.connected) { - continue - } - connectedPtyEvidence.ptyIds.add(pty.ptyId) - if (pty.tabId) { - connectedPtyEvidence.tabIds.add(pty.tabId) - } - if (pty.paneKey) { - connectedPtyEvidence.paneKeys.add(pty.paneKey) - } - } - - this.attachAgentRowsToSummaries( - summaries, - runtimeWorktreeSummaryPathIndex, - missingRuntimeWorktreeIds, - mirroredWorktreeIdByTabId, - connectedPtyEvidence, - workingTerminalEvidenceByWorktreeId - ) - - const sorted = [...summaries.values()].sort(compareWorktreePs) - return { - worktrees: sorted.slice(0, limit), - totalCount: sorted.length, - truncated: sorted.length > limit - } - } - - // Why: maps the retained per-pane agent snapshots into each worktree's inline - // agent list, mirroring the desktop sidebar. Lineage parent is resolved from - // the orchestration db (paneKey-keyed), not the OSC payload, since spawn - // hierarchy is pane-level state tracked separately from terminal output. - private attachAgentRowsToSummaries( - summaries: Map, - runtimeWorktreeSummaryPathIndex: RuntimeWorktreeSummaryPathIndex, - missingRuntimeWorktreeIds: Set, - mirroredWorktreeIdByTabId: ReadonlyMap, - connectedPtyEvidence: { - 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() - const now = Date.now() - for (const snapshot of this.latestAgentStatusByPaneKey.values()) { - const { payload } = snapshot - rowSources.set(snapshot.paneKey, { - paneKey: snapshot.paneKey, - ptyId: snapshot.ptyId, - 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, - toolName: payload.toolName ?? null, - toolInput: payload.toolInput ?? null, - interrupted: payload.interrupted ?? false, - stateStartedAt: snapshot.stateStartedAt, - updatedAt: snapshot.updatedAt - }) - } - for (const entry of this.getAgentStatusSnapshotFn?.() ?? []) { - // Why: old mobile clients ignore this provenance bit, so publishing the row would turn an explicitly unconfirmed restore into fresh activity under version skew. - if (entry.restoredUnconfirmed === true) { - 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, { - paneKey: entry.paneKey, - // Hook payloads carry no ptyId; keep the OSC-observed one so the - // connected-PTY rescue survives a hook row winning the freshness race. - ptyId: existing?.ptyId, - 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, - toolName: entry.toolName ?? null, - toolInput: entry.toolInput ?? null, - interrupted: entry.interrupted ?? false, - stateStartedAt: entry.stateStartedAt, - updatedAt: entry.receivedAt - }) - } - if (rowSources.size === 0) { - return - } - const orchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey() - const rowsByWorktree = new Map() - 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. - // Legacy numeric pane keys (non-UUID leaves) still name a real tab, so - // parse them too — otherwise their rows would bypass the stale filter. - const tabId = - src.tabId ?? - parsePaneKey(src.paneKey)?.tabId ?? - parseLegacyNumericPaneKey(src.paneKey)?.tabId - const mirroredWorktreeId = tabId ? mirroredWorktreeIdByTabId.get(tabId) : undefined - if ( - tabId !== undefined && - mirroredWorktreeId === undefined && - (src.connectionId === null || isWslHookRelayConnectionId(src.connectionId)) && - !connectedPtyEvidence.tabIds.has(tabId) && - !connectedPtyEvidence.paneKeys.has(src.paneKey) && - (src.ptyId === undefined || !connectedPtyEvidence.ptyIds.has(src.ptyId)) - ) { - // Why: hook snapshots hydrate from last-status.json for days, so a row - // from a local or WSL-relayed pane whose tab left every session and the - // live graph, with no connected PTY, is retained history — surfacing it - // resurrects closed agents on mobile (#6072). SSH rows are exempt (their - // tabs may exist only remotely), as are rows with no resolvable tabId - // (staleness unprovable). Session tabs count as existence: headless - // serve has no renderer graph, and session.tabs.list serves them. - continue - } - const worktreeId = mirroredWorktreeId ?? src.worktreeId - if (!worktreeId) { - continue - } - const summary = this.getSummaryForRuntimeWorktreeId( - summaries, - runtimeWorktreeSummaryPathIndex, - missingRuntimeWorktreeIds, - worktreeId - ) - if (!summary) { - continue - } - const taskTitle = orchestrationByPaneKey?.[src.paneKey]?.taskTitle ?? null - const displayName = orchestrationByPaneKey?.[src.paneKey]?.displayName ?? null - const row: RuntimeWorktreeAgentRow = { - paneKey: src.paneKey, - parentPaneKey: orchestrationByPaneKey?.[src.paneKey]?.parentPaneKey ?? null, - state: src.state, - ...(src.workingMode ? { workingMode: src.workingMode } : {}), - agentType: src.agentType, - prompt: src.prompt, - taskTitle, - displayName, - lastAssistantMessage: src.lastAssistantMessage, - toolName: src.toolName, - toolInput: src.toolInput, - interrupted: src.interrupted, - stateStartedAt: src.stateStartedAt, - updatedAt: src.updatedAt - } - // Why: SSH/runtime projections can spell an equivalent path differently; - // bucket by the canonical summary id so mobile keeps the agent activity. - const rows = rowsByWorktree.get(summary.worktreeId) - if (rows) { - rows.push(row) - } else { - rowsByWorktree.set(summary.worktreeId, [row]) - } - } - for (const [worktreeId, rows] of rowsByWorktree) { - // Oldest-started first, matching the desktop dashboard's start-order sort. - rows.sort((a, b) => a.stateStartedAt - b.stateStartedAt) - 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 - } - // 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 - 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' - ) - } - } - } - } - - listRepos(): Repo[] { - return this.store?.getRepos() ?? [] - } - - // Why a stable field and not a per-call closure: enrichment dedupes coalesced callers by callback - // identity, so a fresh closure per call would stack up for the length of a slow sweep. - private readonly onRepoGitRemoteIdentitiesChanged = (): void => { - this.invalidateResolvedWorktreeCache() - this.notifyReposChanged() - } - - enrichMissingRepoGitRemoteIdentities(): void { - if (!this.store) { - return - } - enrichMissingRepoGitRemoteIdentities(this.store, { - onChanged: this.onRepoGitRemoteIdentitiesChanged - }) - } - - listProjects(): Project[] { - return this.store?.getProjects?.() ?? [] - } - - updateProject(projectId: string, updates: ProjectUpdateArgs['updates']): Project { - if (!this.store?.updateProject) { - throw new Error('runtime_unavailable') - } - const project = this.store.updateProject(projectId, updates) - if (!project) { - throw new Error(`Project not found: ${projectId}`) - } - this.invalidateResolvedWorktreeCache() - this.notifyReposChanged() - return project - } - - listProjectHostSetups(): ProjectHostSetup[] { - return this.store?.getProjectHostSetups?.() ?? [] - } - - createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult { - if (!this.store?.createProjectHostSetup) { - throw new Error('runtime_unavailable') - } - const result = this.store.createProjectHostSetup(args) - if (!result) { - throw new Error(`Project not found: ${args.projectId}`) - } - return result - } - - async setupProjectExistingFolder( - args: ProjectHostSetupExistingFolderArgs - ): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - assertProjectHostSetupHostIsSupported(args.hostId) - const knownRepoIds = new Set(this.listRepos().map((repo) => repo.id)) - const repo = await this.addRepo( - args.path, - args.kind === 'folder' ? 'folder' : 'git', - args.hostId - ) - return this.completeProjectHostSetup(args, repo, !knownRepoIds.has(repo.id)) - } - - async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise { - // Why: guard before cloneRepo, which would otherwise clone to the local disk. - assertProjectHostSetupHostIsSupported(args.hostId) - const knownRepoIds = new Set(this.listRepos().map((repo) => repo.id)) - const repo = await this.cloneRepo(args.url, args.destination, args.hostId) - return this.completeProjectHostSetup( - { ...args, path: repo.path, kind: 'git', setupMethod: 'cloned' }, - repo, - !knownRepoIds.has(repo.id) - ) - } - - private completeProjectHostSetup( - args: ProjectHostSetupExistingFolderArgs, - initialRepo: Repo, - repoWasCreated: boolean - ): ProjectHostSetupResult { - try { - return this.linkRepoToProjectHostSetup(args, initialRepo) - } catch (err) { - if (repoWasCreated) { - // Why: a failed link must not leave a new repo registration or stale host caches behind. - this.store?.removeProject?.(initialRepo.id) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(initialRepo.id) - invalidateAuthorizedRootsCache() - this.notifyReposChanged() - } - throw err - } - } - - private linkRepoToProjectHostSetup( - args: ProjectHostSetupExistingFolderArgs, - initialRepo: Repo - ): ProjectHostSetupResult { - if (!this.store) { - throw new Error('runtime_unavailable') - } - let repo = initialRepo - let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) - if (setup.projectId !== args.projectId) { - const existingProject = this.listProjects().find((project) => project.id === args.projectId) - // Why: the selected project can exist only on the source host, so its structured identity travels with the request. - const identity = existingProject?.providerIdentity ?? args.projectProviderIdentity - if (!identity || getProjectIdForProviderIdentity(identity) !== args.projectId) { - throw new Error('Imported folder does not match the selected project identity.') - } - const updated = this.store.updateRepo(repo.id, { - upstream: { - owner: identity.owner, - repo: identity.repo, - ...(identity.host ? { host: identity.host } : {}) - } - }) - if (!updated) { - throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`) - } - repo = updated - setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) - } - const setupMethod = args.setupMethod ?? 'imported-existing-folder' - const updated = this.store.updateRepo(repo.id, { projectHostSetupMethod: setupMethod }) - if (!updated) { - throw new Error( - `Project setup repo disappeared before setup metadata could be linked: ${repo.id}` - ) - } - repo = updated - setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) - const project = this.listProjects().find((entry) => entry.id === setup.projectId) - if (!project) { - throw new Error(`Project setup was created without a project record: ${setup.projectId}`) - } - return { project, setup, repo } - } - - updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult { - if (!this.store?.updateProjectHostSetup) { - throw new Error('runtime_unavailable') - } - const result = this.store.updateProjectHostSetup(args) - if (!result) { - throw new Error(`Project host setup not found: ${args.setupId}`) - } - if ('worktreeBasePath' in args.updates && result.repo) { - void prepareLocalWorktreeRootForRepo(this.store, result.repo) - invalidateAuthorizedRootsCache() - } - return result - } - - deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult { - if (!this.store?.deleteProjectHostSetup) { - throw new Error('runtime_unavailable') - } - const result = this.store.deleteProjectHostSetup(args) - if (!result) { - throw new Error(`Project host setup not found: ${args.setupId}`) - } - return result - } - - listProjectGroups(): ProjectGroup[] { - return this.store?.getProjectGroups?.() ?? [] - } - - listFolderWorkspaces(): FolderWorkspace[] { - return this.store?.getFolderWorkspaces?.() ?? [] - } - - async createProjectGroup(input: { - name: string - parentPath?: string | null - connectionId?: string | null - parentGroupId?: string | null - createdFrom?: ProjectGroup['createdFrom'] - }): Promise { - if (!this.store?.createProjectGroup) { - throw new Error('runtime_unavailable') - } - const group = this.store.createProjectGroup({ - name: input.name, - parentPath: input.parentPath ?? null, - connectionId: input.connectionId ?? null, - parentGroupId: input.parentGroupId ?? null, - createdFrom: input.createdFrom ?? 'manual' - }) - this.notifyReposChanged() - return group - } - - async updateProjectGroup( - groupId: string, - updates: Partial> - ): Promise { - if (!this.store?.updateProjectGroup) { - throw new Error('runtime_unavailable') - } - const updated = this.store.updateProjectGroup(groupId, updates) - if (updated) { - this.notifyReposChanged() - } - return updated - } - - async deleteProjectGroup(groupId: string): Promise<{ deleted: boolean }> { - if (!this.store?.deleteProjectGroup) { - throw new Error('runtime_unavailable') - } - const deleted = this.store.deleteProjectGroup(groupId) - if (deleted) { - this.notifyReposChanged() - } - return { deleted } - } - - async moveProjectToGroup( - repoSelector: string, - groupId: string | null, - order?: number - ): Promise { - if (!this.store?.moveProjectToGroup) { - throw new Error('runtime_unavailable') - } - const repo = await this.resolveRepoSelector(repoSelector) - const moved = this.store.moveProjectToGroup(repo.id, groupId, order) - if (!moved) { - throw new Error('repo_not_found') - } - this.notifyReposChanged() - return moved - } - - async createFolderWorkspace(input: { - projectGroupId: string - name?: string - folderPath?: string | null - connectionId?: string | null - creatorProvenance?: FolderWorkspace['creatorProvenance'] - linkedTask?: FolderWorkspace['linkedTask'] - linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] - createdWithAgent?: FolderWorkspace['createdWithAgent'] - pendingFirstAgentMessageRename?: boolean - }): Promise { - if (!this.store?.createFolderWorkspace) { - throw new Error('runtime_unavailable') - } - const projectGroups = this.store.getProjectGroups?.() ?? [] - const group = projectGroups.find((entry) => entry.id === input.projectGroupId) - const folderPath = - typeof input.folderPath === 'string' && input.folderPath.trim().length > 0 - ? input.folderPath - : group?.parentPath - if (!group || !folderPath) { - throw new Error('folder_workspace_project_group_not_found') - } - const status = await getFolderWorkspacePathStatusForPath( - { - folderPath, - projectGroupId: group.id, - connectionId: input.connectionId ?? group.connectionId ?? null, - projectGroups, - repos: this.store.getRepos() - }, - { getSshFilesystemProvider } - ) - assertFolderWorkspacePathUsable(status) - const workspace = this.store.createFolderWorkspace({ - ...input, - creatorProvenance: input.creatorProvenance ?? { kind: 'host' } - }) - this.notifyReposChanged() - return workspace - } - - async getFolderWorkspacePathStatus( - request: FolderWorkspacePathStatusRequest - ): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - return getFolderWorkspacePathStatus(this.store, request, { getSshFilesystemProvider }) - } - - async updateFolderWorkspace( - folderWorkspaceId: string, - updates: Partial< - Pick< - FolderWorkspace, - | 'name' - | 'folderPath' - | 'linkedTask' - | 'linkedTaskSourceContext' - | 'comment' - | 'isArchived' - | 'isUnread' - | 'isPinned' - | 'sortOrder' - | 'manualOrder' - | 'workspaceStatus' - | 'createdWithAgent' - | 'pendingFirstAgentMessageRename' - | 'firstAgentMessageRenameError' - | 'lastActivityAt' - | 'diffComments' - > - > - ): Promise { - if (!this.store?.updateFolderWorkspace) { - throw new Error('runtime_unavailable') - } - if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) { - const workspace = this.store - .getFolderWorkspaces?.() - .find((entry) => entry.id === folderWorkspaceId) - if (!workspace) { - return null - } - const projectGroups = this.store.getProjectGroups?.() ?? [] - const status = await getFolderWorkspacePathStatusForPath( - { - folderPath: updates.folderPath, - projectGroupId: workspace.projectGroupId, - connectionId: - workspace.connectionId ?? - projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ?? - null, - projectGroups, - repos: this.store.getRepos() - }, - { getSshFilesystemProvider } - ) - assertFolderWorkspacePathUsable(status) - } - const updated = this.store.updateFolderWorkspace(folderWorkspaceId, updates) - if (updated) { - this.notifyReposChanged() - } - return updated - } - - async deleteFolderWorkspace(folderWorkspaceId: string): Promise<{ deleted: boolean }> { - if (!this.store?.removeFolderWorkspace) { - throw new Error('runtime_unavailable') - } - const deleted = this.store.removeFolderWorkspace(folderWorkspaceId) - if (deleted) { - this.notifyReposChanged() - } - return { deleted } - } - - async scanNestedRepos(path: string): Promise { - if (!isAbsolute(path)) { - throw new Error('Project path must be an absolute path') - } - await awaitWindowsHostGitEnvironmentReady({ cwd: path }) - return scanNestedRepos({ path, options: { timeoutMs: 15_000 } }) - } - - async browseServerDir(pathValue: string): Promise<{ - resolvedPath: string - entries: DirEntry[] - pathFlavor: FilesystemPathFlavor - }> { - // Windows resolves `/` to the current drive, so expose drive roots instead. - if (isServerDriveListRequest(pathValue)) { - return listWindowsDrives() - } - const dirPath = resolveServerBrowsePath(pathValue) - const dirStat = await stat(dirPath) - if (!dirStat.isDirectory()) { - throw new Error(`${dirPath} is not a directory`) - } - const entries = await readdir(dirPath, { withFileTypes: true }) - const mapped = entries - .filter((entry) => entry.name !== '.' && entry.name !== '..') - .map((entry) => ({ - name: entry.name, - isDirectory: entry.isDirectory(), - isSymlink: entry.isSymbolicLink() - })) - sortDirEntries(mapped) - return { - resolvedPath: dirPath, - entries: mapped, - pathFlavor: process.platform === 'win32' ? 'win32' : 'posix' - } - } - - async isGitAvailable(): Promise { - try { - await gitExecFileAsync(['--version'], { cwd: process.cwd(), timeout: 3000 }) - return true - } catch { - return false - } - } - - async importNestedRepos(args: { - parentPath: string - groupName: string - projectPaths: string[] - mode: ProjectGroupImportMode - }): Promise { - await awaitWindowsHostGitEnvironmentReady({ cwd: args.parentPath }) - if (!this.store?.createProjectGroup || !this.store?.moveProjectToGroup) { - throw new Error('runtime_unavailable') - } - if (!isAbsolute(args.parentPath)) { - throw new Error('Project path must be an absolute path') - } - const scan = await scanNestedRepos({ path: args.parentPath, options: { timeoutMs: 15_000 } }) - const selection = resolveNestedRepoSelection({ scan, projectPaths: args.projectPaths }) - const groupResolver = createNestedProjectGroupResolver({ - parentPath: args.parentPath, - groupName: args.groupName, - mode: args.mode, - connectionId: null, - repoPaths: selection.selectedPaths, - createGroup: (input) => this.store!.createProjectGroup!(input) - }) - const results: ProjectGroupImportResult['projects'] = selection.rejectedPaths.map( - (repoPath) => ({ - path: repoPath, - status: 'failed', - error: 'Repository was not found in the nested repo scan result' - }) - ) - const importedProjectIdsByRepoPath = new Map() - const importTargetResolver = createNestedRepoImportTargetResolver() - for (const [projectGroupOrder, repoPath] of selection.selectedPaths.entries()) { - try { - await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath }) - if (!isGitRepo(repoPath)) { - results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' }) - continue - } - const importRepoPath = await importTargetResolver.resolveLocal(repoPath) - const normalizedImportRepoPath = normalizeRuntimePathForComparison(importRepoPath) - const alreadyImportedProjectId = importedProjectIdsByRepoPath.get(normalizedImportRepoPath) - if (alreadyImportedProjectId) { - results.push({ - path: repoPath, - projectId: alreadyImportedProjectId, - status: 'already-known' - }) - continue - } - const existing = this.store - .getRepos() - .find((repo) => normalizeRuntimePathForComparison(repo.path) === normalizedImportRepoPath) - const group = groupResolver.getGroupForRepo(repoPath) - if (existing) { - if (group) { - this.store.moveProjectToGroup(existing.id, group.id, projectGroupOrder) - } - importedProjectIdsByRepoPath.set(normalizedImportRepoPath, existing.id) - results.push({ path: repoPath, projectId: existing.id, status: 'already-known' }) - continue - } - const repo: Repo = { - id: randomUUID(), - path: importRepoPath, - displayName: getRepoName(importRepoPath), - badgeColor: DEFAULT_REPO_BADGE_COLOR, - addedAt: Date.now(), - kind: 'git', - externalWorktreeVisibilityLegacy: false, - ...(group - ? { - projectGroupId: group.id, - projectGroupOrder - } - : {}) - } - this.store.addRepo(repo) - importedProjectIdsByRepoPath.set(normalizedImportRepoPath, repo.id) - results.push({ path: repoPath, projectId: repo.id, status: 'imported' }) - } catch (error) { - results.push({ - path: repoPath, - status: 'failed', - error: sanitizeNestedRepoRuntimeImportError( - 'Failed to import nested repository in runtime', - error - ) - }) - } - } - const importedCount = results.filter((entry) => entry.status === 'imported').length - const alreadyKnownCount = results.filter((entry) => entry.status === 'already-known').length - const failedCount = results.filter((entry) => entry.status === 'failed').length - if (importedCount + alreadyKnownCount === 0) { - for (const group of groupResolver.getCreatedGroups().toReversed()) { - this.store.deleteProjectGroup?.(group.id) - } - } - this.invalidateResolvedWorktreeCache() - for (const project of results) { - if (project.projectId) { - this.invalidateWorktreeScanCacheForRepo(project.projectId) - } - } - this.notifyReposChanged() - const rootGroup = groupResolver.getRootGroup() - return { - ...(rootGroup && importedCount + alreadyKnownCount > 0 ? { group: rootGroup } : {}), - projects: results, - importedCount, - alreadyKnownCount, - failedCount - } - } - - async listSparsePresets(repoSelector: string) { - if (!this.store?.getSparsePresets) { - throw new Error('runtime_unavailable') - } - const repo = await this.resolveRepoSelector(repoSelector) - return this.store.getSparsePresets(repo.id) - } - - async saveSparsePreset( - repoSelector: string, - args: { id?: string; name: string; directories: string[] } - ) { - if (!this.store?.getSparsePresets || !this.store.saveSparsePreset) { - throw new Error('runtime_unavailable') - } - const repo = await this.resolveRepoSelector(repoSelector) - const name = normalizeSparsePresetName(args.name) - const directories = normalizeSparsePresetDirectoriesForSave(args.directories) - const now = Date.now() - const existing = args.id - ? this.store.getSparsePresets(repo.id).find((preset) => preset.id === args.id) - : undefined - return this.store.saveSparsePreset({ - id: existing?.id ?? randomUUID(), - repoId: repo.id, - name, - directories, - createdAt: existing?.createdAt ?? now, - updatedAt: now - }) - } - - async addRepo( - path: string, - kind: 'git' | 'folder' = 'git', - executionHostId?: ExecutionHostId | null, - displayName?: string - ): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - if (!isAbsolute(path)) { - // Why: remote clients may run in a different cwd than the server. Require - // server-side repo paths to be explicit so `orca serve` cwd is irrelevant. - throw new Error('Project path must be an absolute path') - } - if (kind === 'git') { - await awaitWindowsHostGitEnvironmentReady({ cwd: path }) - } - if (kind === 'git' && !isGitRepo(path)) { - throw new Error(`Not a valid git repository: ${path}`) - } - - const existing = this.store.getRepos().find((repo) => { - if (!runtimePathsEqual(repo.path, path)) { - return false - } - return runtimeRepoMatchesExecutionHost(repo, executionHostId) - }) - if (existing) { - // Only a runtime host backfills a legacy unstamped repo. An unstamped repo is - // indistinguishable from a genuine local repo (both have null executionHostId and - // connectionId), so we never stamp local/ssh onto it — that would re-attribute a - // real local project to the wrong host. Runtime is the only host that lost its - // identity to the pre-#7018 path-only import and needs the backfill. - if ( - existing.executionHostId == null && - parseExecutionHostId(executionHostId)?.kind === 'runtime' - ) { - const adopted = - this.store.updateRepo(existing.id, { executionHostId }) ?? - ({ ...existing, executionHostId } as Repo) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(existing.id) - this.notifyReposChanged() - return adopted - } - return existing - } - - const detected = await detectRepoIconAndUpstream({ repoPath: path, kind }) - const repo: Repo = { - id: randomUUID(), - path, - displayName: displayName?.trim() || getRepoName(path), - badgeColor: DEFAULT_REPO_BADGE_COLOR, - ...(executionHostId != null ? { executionHostId } : {}), - ...detected, - addedAt: Date.now(), - kind, - ...(kind === 'git' ? { externalWorktreeVisibilityLegacy: false } : {}) - } - this.store.addRepo(repo) - await prepareLocalWorktreeRootForRepo(this.store, repo) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(repo.id) - this.notifyReposChanged() - return this.store.getRepo(repo.id) ?? repo - } - - async createRepo( - parentPath: string, - name: string, - kind: 'git' | 'folder' = 'git' - ): Promise<{ repo: Repo } | { error: string }> { - if (!this.store) { - throw new Error('runtime_unavailable') - } - const trimmedName = name.trim() - const trimmedParentPath = parentPath.trim() - const repoKind: 'git' | 'folder' = kind === 'folder' ? 'folder' : 'git' - if (!trimmedName) { - return { error: 'Name cannot be empty' } - } - if (/[\\/]/.test(trimmedName) || trimmedName === '.' || trimmedName === '..') { - return { error: 'Name cannot contain slashes or be "." / ".."' } - } - if (!trimmedParentPath) { - return { error: 'Parent directory is required' } - } - if (!isAbsolute(trimmedParentPath)) { - return { error: 'Parent directory must be an absolute path' } - } - - const targetPath = join(trimmedParentPath, trimmedName) - const existing = this.store.getRepos().find((repo) => runtimePathsEqual(repo.path, targetPath)) - if (existing) { - return { repo: existing } - } - - let createdDir = false - try { - // Why: default create-project parents are host-home based and may not exist - // before the first project is created on a fresh runtime. - await mkdir(trimmedParentPath, { recursive: true }) - const existingStat = await stat(targetPath).catch((error: unknown) => { - if (isENOENT(error)) { - return null - } - throw error - }) - if (existingStat) { - if (!existingStat.isDirectory()) { - return { error: `"${trimmedName}" already exists at this location and is not a folder.` } - } - const entries = await readdir(targetPath) - if (entries.length > 0) { - return { error: `"${trimmedName}" already exists at this location and is not empty.` } - } - } else { - await mkdir(targetPath, { recursive: false }) - createdDir = true - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { error: `Failed to prepare directory: ${message}` } - } - - if (repoKind === 'git') { - let step: 'init' | 'commit' = 'init' - try { - await gitExecFileAsync(['init'], { cwd: targetPath }) - step = 'commit' - await gitExecFileAsync(['commit', '--allow-empty', '-m', 'Initial commit'], { - cwd: targetPath - }) - } catch (error) { - if (createdDir) { - await rm(targetPath, { recursive: true, force: true }).catch(() => {}) - } else if (step === 'commit') { - await rm(join(targetPath, '.git'), { recursive: true, force: true }).catch(() => {}) - } - const message = error instanceof Error ? error.message : String(error) - if ( - step === 'commit' && - /Please tell me who you are|user\.name|user\.email/i.test(message) - ) { - return { - error: - 'Git author identity is not configured. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"`, then try again.' - } - } - const stepLabel = - step === 'init' - ? 'Failed to initialize git repository' - : 'Failed to create initial commit' - return { error: `${stepLabel}: ${message}` } - } - } - - const raceWinner = this.store - .getRepos() - .find((repo) => runtimePathsEqual(repo.path, targetPath)) - if (raceWinner) { - return { repo: raceWinner } - } - - const detected = await detectRepoIconAndUpstream({ repoPath: targetPath, kind: repoKind }) - const repo: Repo = { - id: randomUUID(), - path: targetPath, - displayName: trimmedName, - badgeColor: DEFAULT_REPO_BADGE_COLOR, - ...detected, - addedAt: Date.now(), - kind: repoKind, - ...(repoKind === 'git' ? { externalWorktreeVisibilityLegacy: false } : {}) - } - this.store.addRepo(repo) - await prepareLocalWorktreeRootForRepo(this.store, repo) - invalidateAuthorizedRootsCache() - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(repo.id) - this.notifyReposChanged() - return { repo: this.store.getRepo(repo.id) ?? repo } - } - - async cloneRepo( - url: string, - destination: string, - executionHostId?: ExecutionHostId | null - ): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - const trimmedUrl = url.trim() - const trimmedDestination = destination.trim() - if (!trimmedDestination) { - throw new Error('Clone destination is required') - } - const clonePath = deriveValidatedClonePath({ url: trimmedUrl, destination: trimmedDestination }) - const clonePathKey = getClonePathComparisonKey(clonePath) - const previous = this.cloneInFlightByPath.get(clonePathKey) ?? Promise.resolve() - let release!: () => void - const current = new Promise((resolve) => { - release = resolve - }) - const tail = previous.then( - () => current, - () => current - ) - this.cloneInFlightByPath.set(clonePathKey, tail) - - try { - await previous - return await runWithGitReadCacheInvalidation(() => - this.cloneRepoAfterPathLock( - trimmedUrl, - trimmedDestination, - clonePath, - clonePathKey, - executionHostId - ) - ) - } finally { - release() - if (this.cloneInFlightByPath.get(clonePathKey) === tail) { - this.cloneInFlightByPath.delete(clonePathKey) - } - } - } - - private async cloneRepoAfterPathLock( - trimmedUrl: string, - trimmedDestination: string, - clonePath: string, - clonePathKey: string, - executionHostId?: ExecutionHostId | null - ): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - const existingBeforeClone = this.store - .getRepos() - .find( - (repo) => - getClonePathComparisonKey(repo.path) === clonePathKey && - runtimeRepoMatchesExecutionHost(repo, executionHostId) - ) - if (existingBeforeClone && !isFolderRepo(existingBeforeClone)) { - return existingBeforeClone - } - - await mkdir(trimmedDestination, { recursive: true }) - const claimedTarget = await claimCloneTarget(clonePath) - let proc: Awaited> - try { - proc = await gitSpawnAfterWindowsEnvironmentReady( - ['clone', '--progress', '--', trimmedUrl, clonePath], - { - cwd: trimmedDestination, - admissionTier: 'interactive', - // Why: without the non-interactive guard, a clone that needs GitHub - // auth makes Git Credential Manager pop its "Connect to GitHub" OAuth - // window on Windows; in a network-restricted env the browser/device - // flow can never complete and git's credential retry re-pops it - // (issue #7652). Fail fast with a clear error instead. - env: nonInteractiveGitEnv(), - stdio: ['ignore', 'ignore', 'pipe'] - } - ) - } catch (err) { - await cleanupClaimedCloneTarget(clonePath, claimedTarget) - const message = err instanceof Error ? err.message : String(err) - throw new Error(`Clone failed: ${message}`) - } - await new Promise((resolve, reject) => { - let stderrTail = '' - let settled = false - proc.stderr?.on('data', (chunk: Buffer) => { - stderrTail = (stderrTail + chunk.toString()).slice(-4096) - }) - const finishClone = async ( - code: number | null, - signal: NodeJS.Signals | null, - error?: Error - ) => { - if (settled) { - return - } - settled = true - const cloneSucceeded = !error && code === 0 && !signal - if (!cloneSucceeded) { - await cleanupClaimedCloneTarget(clonePath, claimedTarget) - } - - if (error) { - reject(new Error(`Clone failed: ${error.message}`)) - } else if (signal === 'SIGTERM') { - reject(new Error('Clone aborted')) - } else if (code === 0) { - resolve() - } else { - reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderrTail, { clonePath })}`)) - } - } - proc.on('error', (error) => { - void finishClone(null, null, error) - }) - proc.on('close', (code, signal) => { - void finishClone(code, signal) - }) - }) - - const existing = this.store - .getRepos() - .find( - (repo) => - getClonePathComparisonKey(repo.path) === clonePathKey && - runtimeRepoMatchesExecutionHost(repo, executionHostId) - ) - if (existing) { - if (isFolderRepo(existing)) { - const updated = this.store.updateRepo(existing.id, { kind: 'git' }) - if (updated) { - await prepareLocalWorktreeRootForRepo(this.store, updated) - invalidateAuthorizedRootsCache() - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(updated.id) - this.notifyReposChanged() - return updated - } - } - return existing - } - - const detected = await detectRepoIconAndUpstream({ repoPath: clonePath, kind: 'git' }) - const repo: Repo = { - id: randomUUID(), - path: clonePath, - displayName: getRepoName(clonePath), - badgeColor: DEFAULT_REPO_BADGE_COLOR, - ...(executionHostId != null ? { executionHostId } : {}), - ...detected, - addedAt: Date.now(), - kind: 'git', - externalWorktreeVisibilityLegacy: false - } - this.store.addRepo(repo) - await prepareLocalWorktreeRootForRepo(this.store, repo) - invalidateAuthorizedRootsCache() - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(repo.id) - this.notifyReposChanged() - return this.store.getRepo(repo.id) ?? repo - } - - async showRepo(repoSelector: string): Promise { - return await this.resolveRepoSelector(repoSelector) - } - - async setRepoBaseRef(repoSelector: string, baseRef: string): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - const repo = await this.resolveRepoSelector(repoSelector) - if (isFolderRepo(repo)) { - throw new Error('Folder mode does not support base refs.') - } - const updated = this.store.updateRepo(repo.id, { worktreeBaseRef: baseRef }) - if (!updated) { - throw new Error('repo_not_found') - } - this.invalidateResolvedWorktreeCache() - this.notifyReposChanged() - return updated - } - - async updateRepo( - repoSelector: string, - updates: Partial< - Pick< - Repo, - | 'displayName' - | 'badgeColor' - | 'repoIcon' - | 'upstream' - | 'hookSettings' - | 'worktreeBaseRef' - | 'worktreeBasePath' - | 'kind' - | 'symlinkPaths' - | 'issueSourcePreference' - | 'externalWorktreeVisibilityPromptDismissedAt' - | 'externalWorktreeInboxBaselinePaths' - | 'importedExternalWorktreePaths' - | 'agentWorktreeVisibility' - | 'customWorktreeVisibilitySources' - | 'worktreeVisibilitySourcePreferences' - | 'projectGroupId' - | 'projectGroupOrder' - > - > & { - externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null - sourceControlAi?: Repo['sourceControlAi'] | null - externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null - } - ): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - const repo = await this.resolveRepoSelector(repoSelector) - const sanitizedUpdates = omitUndefinedProperties(updates) - if ('worktreeBasePath' in updates && updates.worktreeBasePath === undefined) { - sanitizedUpdates.worktreeBasePath = undefined - } - if ('externalWorktreeVisibility' in updates && updates.externalWorktreeVisibility === null) { - sanitizedUpdates.externalWorktreeVisibility = undefined - } - if ( - 'externalWorktreeDiscoverySuppressedAt' in updates && - updates.externalWorktreeDiscoverySuppressedAt === null - ) { - sanitizedUpdates.externalWorktreeDiscoverySuppressedAt = undefined - } - if ('sourceControlAi' in updates && updates.sourceControlAi === null) { - sanitizedUpdates.sourceControlAi = null - } - const updated = this.store.updateRepo(repo.id, sanitizedUpdates) - if (!updated) { - throw new Error('repo_not_found') - } - if ('worktreeBasePath' in updates) { - await prepareLocalWorktreeRootForRepo(this.store, updated) - invalidateAuthorizedRootsCache() - } - this.invalidateResolvedWorktreeCache() - if ('worktreeBasePath' in updates) { - this.invalidateWorktreeScanCacheForRepo(repo.id) - } - this.notifyReposChanged() - return updated - } - - async removeProject(repoSelector: string): Promise<{ removed: true }> { - if (!this.store?.removeProject) { - throw new Error('runtime_unavailable') - } - const repo = await this.resolveRepoSelector(repoSelector) - // Why: removeProject is id-only, but the same id may be registered on a sibling - // execution host; a path:/name: selector resolves one row and must remove only it. - const hostId = getRepoExecutionHostId(repo) - const idExistsOnOtherHost = this.store - .getRepos() - .some((entry) => entry.id === repo.id && getRepoExecutionHostId(entry) !== hostId) - if (idExistsOnOtherHost) { - if (!this.store.removeProjectForHost) { - throw new Error('runtime_unavailable') - } - this.store.removeProjectForHost(repo.id, hostId) - } else { - this.store.removeProject(repo.id) - } - this.terminalTopologyRevisionByRepoId.delete(repo.id) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(repo.id) - invalidateAuthorizedRootsCache() - this.notifyReposChanged() - return { removed: true } - } - - async inspectTerminalProcess( - terminalSelector: string - ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> { - const leaf = this.resolveLiveLeafForHandle(terminalSelector) - if (!leaf?.ptyId || !this.ptyController) { - throw new Error('terminal_gone') - } - if (this.ptyController.inspectProcess) { - return this.ptyController.inspectProcess(leaf.ptyId) - } - const foregroundProcess = await this.ptyController.getForegroundProcess(leaf.ptyId) - const hasChildProcesses = (await this.ptyController.hasChildProcesses?.(leaf.ptyId)) ?? false - return { foregroundProcess, hasChildProcesses } - } - - reorderRepos(orderedIds: string[]): { status: 'applied' | 'rejected' } { - if (!this.store?.reorderRepos) { - throw new Error('runtime_unavailable') - } - // Why: remote clients can race repo add/remove on the server just like - // local drag-reorder can race another window. Let the store validate the - // full permutation and signal a resync-worthy rejection. - const applied = this.store.reorderRepos(orderedIds) - if (!applied) { - return { status: 'rejected' } - } - this.invalidateResolvedWorktreeCache() - this.notifyReposChanged() - return { status: 'applied' } - } - - async searchRepoRefs( - repoSelector: string, - query: string, - limit = DEFAULT_REPO_SEARCH_REFS_LIMIT - ): Promise { - if (!isRepoSearchRefsRequestLimit(limit)) { - throw new Error('invalid_limit') - } - const effectiveLimit = clampRepoSearchRefsLimit(limit) - const probeLimit = getRepoSearchRefsProbeLimit(effectiveLimit) - const repo = await this.resolveRepoSelector(repoSelector) - if (isFolderRepo(repo)) { - return { - refs: [], - truncated: false - } - } - const refDetails = repo.connectionId - ? await this.searchRemoteRepoRefs(repo, query, probeLimit) - : await searchBaseRefDetails(repo.path, query, probeLimit) - return { - refs: refDetails.slice(0, effectiveLimit).map((entry) => entry.refName), - refDetails: refDetails.slice(0, effectiveLimit), - // An oversized request is intentionally reported as truncated even when - // this repo has fewer refs: the execution cap prevented fulfilling the - // requested page size. - truncated: limit > effectiveLimit || refDetails.length > effectiveLimit - } - } - - async getRepoBaseRefDefault( - repoSelector: string - ): Promise<{ defaultBaseRef: string | null; remoteCount: number }> { - const repo = await this.resolveRepoSelector(repoSelector) - if (isFolderRepo(repo)) { - return { defaultBaseRef: null, remoteCount: 0 } - } - if (repo.connectionId) { - return this.getRemoteRepoBaseRefDefault(repo) - } - const [defaultBaseRef, remoteCount] = await Promise.all([ - getBaseRefDefault(repo.path), - getRemoteCount(repo.path) - ]) - return { defaultBaseRef, remoteCount } - } - - private async getRemoteRepoBaseRefDefault( - repo: Repo - ): Promise<{ defaultBaseRef: string | null; remoteCount: number }> { - const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null - if (!provider) { - return { defaultBaseRef: null, remoteCount: 0 } - } - const [defaultBaseRef, remoteCount] = await Promise.all([ - resolveDefaultBaseRefViaExec(async (argv) => { - try { - return await provider.exec(argv, repo.path) - } catch (err) { - if (argv[0] === 'symbolic-ref') { - console.warn('[runtime:repo.baseRefDefault] SSH symbolic-ref failed', { - path: repo.path, - err - }) - } - throw err - } - }), - provider - .exec(['remote'], repo.path) - .then((result) => parseRemoteCount(result.stdout)) - .catch((err) => { - console.warn('[runtime:repo.baseRefDefault] SSH git remote count failed', { - path: repo.path, - err - }) - return 0 - }) - ]) - return { defaultBaseRef, remoteCount } - } - - private async searchRemoteRepoRefs( - repo: Repo, - query: string, - limit: number - ): Promise { - const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null - if (!provider) { - return [] - } - const normalizedQuery = normalizeRefSearchQuery(query) - try { - const remotesResult = await provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' })) - const remotes = remotesResult.stdout - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - const capabilities = getSshGitCapabilityCache(provider) - const runSearch = async (patternGroup?: 'segmented' | 'branchRoot'): Promise => { - return capabilities.runWithFallback( - 'for-each-ref-exclude', - async () => - ( - await provider.exec( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - remoteNames: remotes, - patternGroup - }), - repo.path - ) - ).stdout, - async () => - ( - await provider.exec( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - excludeRemoteHead: false, - remoteNames: remotes, - patternGroup - }), - repo.path - ) - ).stdout, - isForEachRefExcludeUnsupportedError - ) - } - const searchTokens = normalizedQuery.split('/').filter((token) => token.length > 0) - if (searchTokens.length > 1) { - const results = await Promise.all([runSearch('segmented'), runSearch('branchRoot')]) - return mergeBaseRefSearchResultGroups( - results.map((stdout) => parseAndFilterSearchRefDetails(stdout, limit, remotes)), - limit - ) - } - return parseAndFilterSearchRefDetails(await runSearch(), limit, remotes) - } catch (err) { - console.warn('[runtime:repo.searchRefs] SSH for-each-ref failed', { - path: repo.path, - err - }) - return [] - } - } - - private async resolveHostedReviewTarget(args: { - repoSelector: string - worktreeSelector?: string - }): Promise<{ repo: Repo; repoPath: string }> { - const repo = await this.resolveRepoSelector(args.repoSelector) - if (!args.worktreeSelector) { - return { repo, repoPath: repo.path } - } - - const worktree = await this.resolveWorktreeSelector(args.worktreeSelector) - if (worktree.repoId !== repo.id) { - throw new Error('Access denied: worktree does not belong to repository') - } - return { repo, repoPath: worktree.path } - } - - private getHostedReviewExecutionOptions( - repo: Repo, - admissionTier?: GitAdmissionTier - ): - | { - localGitExecOptions: { - wslDistro?: string - admissionTier?: GitAdmissionTier - } - } - | undefined { - const localGitOptions = { - ...this.getLocalGitExecutionOptionArgs(repo)[0], - ...(admissionTier && { admissionTier }) - } - return Object.keys(localGitOptions).length > 0 - ? { localGitExecOptions: localGitOptions } - : undefined - } - - private getLocalGitExecutionOptionArgs(repo: Repo): [] | [{ wslDistro?: string }] { - const localGitOptions = getLocalProjectWorktreeGitOptions(this.requireStore(), repo) - return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] - } - - private getAgentLaunchPlatformForRepo(repo: Repo): NodeJS.Platform { - const projectRuntime = repo.connectionId - ? undefined - : resolveLocalProjectRuntimeForRepo(this.requireStore(), repo) - return getAgentLaunchPlatformForRepo(repo, projectRuntime) - } - - private getAgentLaunchPlatformForWorkspace(scope: TerminalWorkspaceLaunchScope): NodeJS.Platform { - if (scope.repo) { - return this.getAgentLaunchPlatformForRepo(scope.repo) - } - if (scope.connectionId) { - return isWindowsAbsolutePathLike(scope.path) ? 'win32' : 'linux' - } - return isWslUncPath(scope.path) ? 'linux' : process.platform - } - - async getRepoSlug(repoSelector: string): Promise { - const repo = await this.resolveRepoSelector(repoSelector) - const options = this.getHostedReviewExecutionOptions(repo) - return options - ? getRepoSlug(repo.path, repo.connectionId ?? null, options) - : getRepoSlug(repo.path, repo.connectionId ?? null) - } - - async getRepoUpstream(repoSelector: string): Promise { - const repo = await this.resolveRepoSelector(repoSelector) - const options = this.getHostedReviewExecutionOptions(repo) - return options - ? getRepoUpstream(repo.path, repo.connectionId ?? null, options) - : getRepoUpstream(repo.path, repo.connectionId ?? null) - } - - // Why: repos added before fork detection existed have no stored `upstream`, so - // their avatar/badge would never self-correct. Resolve it once at startup for - // local git repos; SSH repos resolve lazily when their settings open (their - // connection may not be up yet). Sequential to respect the gh rate limit; - // failures leave `upstream` unset so the next launch retries. - private async backfillForkUpstreams(): Promise { - try { - const store = this.requireStore() - let changed = false - for (const repo of store.getRepos()) { - if (repo.upstream !== undefined || repo.kind === 'folder' || repo.connectionId) { - continue - } - let upstream: GitHubOwnerRepo | null - try { - upstream = await getRepoUpstream(repo.path, null) - } catch { - continue - } - const repoIcon = - upstream && repo.repoIcon?.type === 'image' && repo.repoIcon.source === 'github' - ? await detectGitHubAvatarIcon(repo.path, null, upstream) - : null - // Why: settings can change the repo while the probes above are pending, so - // re-read it — a stale snapshot must not clobber a user-chosen icon or an - // upstream another path already resolved. - const current = store.getRepos().find((candidate) => candidate.id === repo.id) - if (!current || current.upstream !== undefined) { - continue - } - const updates: Partial = { upstream: upstream ?? null } - // Only migrate the auto-detected origin avatar; never touch a chosen icon. - if ( - repoIcon && - current.repoIcon?.type === 'image' && - current.repoIcon.source === 'github' - ) { - updates.repoIcon = repoIcon - } - store.updateRepo(repo.id, updates) - changed = true - } - if (changed) { - this.notifyReposChanged() - } - } catch { - // Best-effort startup backfill; never disrupt launch. - } - } - - async listRepoWorkItems( - repoSelector: string, - limit?: number, - query?: string, - page?: number, - noCache?: boolean - ): Promise> { - const repo = await this.resolveRepoSelector(repoSelector) - return listWorkItems( - repo.path, - limit, - query, - page, - repo.issueSourcePreference, - repo.connectionId ?? null, - noCache, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async listRepoIssues( - repoSelector: string, - limit?: number - ): Promise>['items']> { - const repo = await this.resolveRepoSelector(repoSelector) - const result = await listGitHubIssues( - repo.path, - limit, - repo.issueSourcePreference, - repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - return result.items - } - - async getRepoWorkItem( - repoSelector: string, - number: number, - type?: 'issue' | 'pr' - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - // Why: open-by-number must pin the same source the list and start-point use, - // else a fork and its upstream sharing a PR number resolve to different PRs. - return getWorkItem( - repo.path, - number, - type, - repo.connectionId ?? null, - this.getLocalGitExecutionOptionArgs(repo)[0] ?? {}, - repo.issueSourcePreference - ) - } - - async getRepoWorkItemByOwnerRepo( - repoSelector: string, - ownerRepo: { owner: string; repo: string; host?: string }, - number: number, - type: 'issue' | 'pr' - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return getWorkItemByOwnerRepo( - repo.path, - ownerRepo, - number, - type, - repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async getRepoWorkItemDetails( - repoSelector: string, - number: number, - type?: 'issue' | 'pr' - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return getWorkItemDetails( - repo.path, - number, - type, - repo.connectionId ?? null, - this.getLocalGitExecutionOptionArgs(repo)[0] ?? {}, - repo.issueSourcePreference - ) - } - - async countRepoWorkItems(repoSelector: string, query?: string): Promise { - const repo = await this.resolveRepoSelector(repoSelector) - return countWorkItems( - repo.path, - query, - repo.issueSourcePreference, - repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async listRepoLabels(repoSelector: string): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return listLabels( - repo.path, - repo.issueSourcePreference, - repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async listRepoAssignableUsers( - repoSelector: string - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return listAssignableUsers( - repo.path, - repo.issueSourcePreference, - repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - getGitHubRateLimit(options?: { - force?: boolean - }): Promise>> { - return getRateLimit(options) - } - - async getRepoPRForBranch( - repoSelector: string, - branch: string, - linkedPRNumber?: number | null, - fallbackPRNumber?: number | null, - acceptMergedFallbackPR?: boolean, - currentHeadOid?: string | null, - reason?: GitHubPRRefreshReason - ): Promise { - const repo = await this.resolveRepoSelector(repoSelector) - const options: GitHubPRBranchLookupOptions = - this.getHostedReviewExecutionOptions( - repo, - reason ? admissionTierForRefreshReason(reason) : undefined - ) ?? {} - const lookupOptions = { ...options } - if (acceptMergedFallbackPR === true) { - lookupOptions.acceptMergedFallbackPR = true - } - if (typeof currentHeadOid === 'string' && currentHeadOid.trim().length > 0) { - lookupOptions.currentHeadOid = currentHeadOid.trim() - } - const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = - Object.keys(lookupOptions).length > 0 ? [lookupOptions] : [] - // Why: return the full classified outcome (not PRInfo|null) so a runtime gh - // auth/network failure crosses the RPC as `upstream-error` instead of - // collapsing to `null`, which the renderer would otherwise cache as a false - // accepted "no PR found" (design success criterion 1). - return getPRForBranchOutcome( - repo.path, - branch, - linkedPRNumber ?? null, - repo.connectionId ?? null, - linkedPRNumber == null ? (fallbackPRNumber ?? null) : null, - ...lookupOptionArgs - ) - } - - async getHostedReviewForBranch(args: { - repoSelector: string - branch: string - admissionTier?: GitAdmissionTier - currentHeadOid?: string | null - active?: boolean - linkedGitHubPR?: number | null - fallbackGitHubPR?: number | null - linkedGitLabMR?: number | null - linkedBitbucketPR?: number | null - linkedAzureDevOpsPR?: number | null - linkedGiteaPR?: number | null - }): Promise { - const repo = await this.resolveRepoSelector(args.repoSelector) - const executionOptions = this.getHostedReviewExecutionOptions( - repo, - args.admissionTier ?? 'background' - ) - const review = await getHostedReviewForBranchFromRepo({ - repoPath: repo.path, - connectionId: repo.connectionId ?? null, - branch: args.branch, - currentHeadOid: args.currentHeadOid ?? null, - ...(args.active === true ? { active: true } : {}), - linkedGitHubPR: args.linkedGitHubPR ?? null, - fallbackGitHubPR: args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null, - linkedGitLabMR: args.linkedGitLabMR ?? null, - linkedBitbucketPR: args.linkedBitbucketPR ?? null, - linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, - linkedGiteaPR: args.linkedGiteaPR ?? null, - ...executionOptions - }) - if (review?.provider === 'github' && this.stats && !this.stats.hasCountedPR(review.url)) { - this.stats.record({ - type: 'pr_created', - at: Date.now(), - repoId: repo.id, - meta: { prNumber: review.number, prUrl: review.url } - }) - } - return review - } - - async getHostedReviewCreationEligibility( - args: Omit & { - repoSelector: string - worktreeSelector?: string - } - ): Promise { - const { repo, repoPath } = await this.resolveHostedReviewTarget(args) - const executionOptions = this.getHostedReviewExecutionOptions(repo, 'interactive') - return getHostedReviewCreationEligibilityFromRepo({ - repoPath, - connectionId: repo.connectionId ?? null, - branch: args.branch, - base: args.base ?? null, - hasUncommittedChanges: args.hasUncommittedChanges, - hasUpstream: args.hasUpstream, - ahead: args.ahead, - behind: args.behind, - linkedGitHubPR: args.linkedGitHubPR ?? null, - fallbackGitHubPR: args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null, - linkedGitLabMR: args.linkedGitLabMR ?? null, - linkedBitbucketPR: args.linkedBitbucketPR ?? null, - linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, - linkedGiteaPR: args.linkedGiteaPR ?? null, - ...executionOptions - }) - } - - async createHostedReview( - args: CreateHostedReviewInput & { repoSelector: string; worktreeSelector?: string } - ): Promise { - const { repo, repoPath } = await this.resolveHostedReviewTarget(args) - const executionOptions = this.getHostedReviewExecutionOptions(repo, 'interactive') - const input = { - provider: args.provider, - base: args.base, - head: args.head, - title: args.title, - body: args.body, - draft: args.draft, - ...(args.useTemplate !== undefined ? { useTemplate: args.useTemplate } : {}) - } - const result = executionOptions - ? await createHostedReviewFromRepo( - repoPath, - input, - repo.connectionId ?? null, - executionOptions - ) - : await createHostedReviewFromRepo(repoPath, input, repo.connectionId ?? null) - if (result.ok && this.stats && !this.stats.hasCountedPR(result.url)) { - this.stats.record({ - type: 'pr_created', - at: Date.now(), - repoId: repo.id, - meta: { prNumber: result.number, prUrl: result.url } - }) - } - return result - } - - async submitHostedReview( - args: HostedReviewSubmissionInput & { repoSelector: string } - ): Promise { - const repo = await this.resolveRepoSelector(args.repoSelector) - const { repoSelector: _repoSelector, ...input } = args - return submitHostedReviewFromRepo( - repo.path, - input, - repo.issueSourcePreference, - repo.connectionId ?? null, - this.getHostedReviewExecutionOptions(repo) - ) - } - - async createStackedHostedReview( - args: CreateStackedHostedReviewInput & { repoSelector: string; worktreeSelector?: string } - ): Promise { - const { repo, repoPath } = await this.resolveHostedReviewTarget(args) - const executionOptions = this.getHostedReviewExecutionOptions(repo, 'interactive') - const result = await createStackedHostedReviewFromRepo( - repoPath, - { - provider: args.provider, - base: args.base, - head: args.head, - title: args.title, - body: args.body, - draft: args.draft, - ...(args.useTemplate !== undefined ? { useTemplate: args.useTemplate } : {}) - }, - repo.connectionId ?? null, - executionOptions ?? {} - ) - if (result.ok && this.stats && !this.stats.hasCountedPR(result.url)) { - this.stats.record({ - type: 'pr_created', - at: Date.now(), - repoId: repo.id, - meta: { prNumber: result.number, prUrl: result.url } - }) - } - return result - } - - async listGitLabRepoWorkItems( - repoSelector: string, - state?: MRListState, - page?: number, - perPage?: number, - query?: string - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return listGitLabWorkItems( - repo.path, - state ?? 'opened', - page ?? 1, - perPage ?? 20, - repo.issueSourcePreference, - query, - repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async listGitLabRepoMRs( - repoSelector: string, - state?: MRListState, - page?: number, - perPage?: number, - query?: string - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return listGitLabMergeRequests( - repo.path, - normalizeGitLabMRListState(state), - normalizeGitLabPositiveInteger(page, 1, 10_000), - normalizeGitLabPositiveInteger(perPage, 20, 100), - repo.issueSourcePreference, - query, - repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async listGitLabRepoIssues( - repoSelector: string, - state?: GitLabIssueListState, - assignee?: string, - limit?: number, - page?: number - ): Promise<{ - items: GitLabWorkItem[] - totalPages: number - error?: Awaited>['error'] - }> { - const repo = await this.resolveRepoSelector(repoSelector) - const normalized = normalizeGitLabIssueListArgs({ state, assignee, limit, page }) - // Why: page is after localGitOptions; never spread optional args before it (#13538). - const result = await listGitLabIssues( - repo.path, - normalized.limit, - repo.issueSourcePreference, - normalized.state, - normalized.assignee, - repo.connectionId ?? null, - this.getLocalGitExecutionOptionArgs(repo)[0] ?? {}, - normalized.page - ) - // Why: web runtime mirrors the desktop preload contract, where GitLab - // issue rows share the GitLabWorkItem shape with MRs on TaskPage. - const items: GitLabWorkItem[] = result.items.map((issue) => ({ - id: `gitlab-issue-${repo.id}-${issue.number}`, - type: 'issue' as const, - number: issue.number, - title: issue.title, - state: issue.state, - url: issue.url, - labels: issue.labels, - updatedAt: issue.updatedAt ?? '', - author: issue.author ?? null, - repoId: repo.id - })) - return { - items, - totalPages: result.totalPages, - ...(result.error ? { error: result.error } : {}) - } - } - - async listGitLabRepoTodos( - repoSelector: string - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return listGitLabTodos( - repo.path, - repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async diagnoseGitLabAuth(): Promise>> { - return diagnoseGitLabAuthClient() - } - - async getGitLabRateLimit(options?: { - force?: boolean - host?: string | null - }): Promise>> { - return getGitLabRateLimit(options) - } - - async listGitLabRepoLabels( - repoSelector: string - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return listGitLabLabels( - repo.path, - repo.issueSourcePreference, - repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async createGitLabRepoIssue( - repoSelector: string, - title: string, - body: string - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return createGitLabIssue( - repo.path, - title, - body, - repo.issueSourcePreference, - repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async updateGitLabRepoIssue( - repoSelector: string, - number: number, - updates: GitLabIssueUpdate, - projectRef?: GitLabProjectRef | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return updateGitLabIssue( - repo.path, - number, - updates, - repo.issueSourcePreference, - repo.connectionId ?? null, - projectRef, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async addGitLabRepoIssueComment( - repoSelector: string, - number: number, - body: string, - projectRef?: GitLabProjectRef | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return addGitLabIssueComment( - repo.path, - number, - body, - repo.issueSourcePreference, - repo.connectionId ?? null, - projectRef, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async addGitLabRepoMRComment( - repoSelector: string, - iid: number, - body: string, - projectRef?: GitLabProjectRef | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return addGitLabMRComment( - repo.path, - iid, - body, - repo.issueSourcePreference, - repo.connectionId ?? null, - projectRef, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async addGitLabRepoMRInlineComment( - repoSelector: string, - iid: number, - input: GitLabMRInlineCommentInput, - projectRef?: GitLabProjectRef | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return addGitLabMRInlineComment( - repo.path, - iid, - input, - repo.issueSourcePreference, - repo.connectionId ?? null, - projectRef, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async resolveGitLabRepoMRDiscussion( - repoSelector: string, - iid: number, - discussionId: string, - resolved: boolean, - projectRef?: GitLabProjectRef | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return resolveGitLabMRDiscussion( - repo.path, - iid, - discussionId, - resolved, - repo.issueSourcePreference, - repo.connectionId ?? null, - projectRef, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async getGitLabRepoJobTrace( - repoSelector: string, - jobId: number, - projectRef?: GitLabProjectRef | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return getGitLabJobTrace( - repo.path, - jobId, - repo.issueSourcePreference, - repo.connectionId ?? null, - projectRef, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async retryGitLabRepoJob( - repoSelector: string, - jobId: number, - projectRef?: GitLabProjectRef | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return retryGitLabJob( - repo.path, - jobId, - repo.issueSourcePreference, - repo.connectionId ?? null, - projectRef, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async mergeGitLabRepoMR( - repoSelector: string, - iid: number, - method?: 'merge' | 'squash' | 'rebase', - projectRef?: GitLabProjectRef | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return mergeGitLabMR( - repo.path, - iid, - method ?? 'merge', - repo.issueSourcePreference, - repo.connectionId ?? null, - projectRef, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async updateGitLabRepoMRState( - repoSelector: string, - iid: number, - state: 'opened' | 'closed', - projectRef?: GitLabProjectRef | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return state === 'closed' - ? closeGitLabMR( - repo.path, - iid, - repo.issueSourcePreference, - repo.connectionId ?? null, - projectRef, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - : reopenGitLabMR( - repo.path, - iid, - repo.issueSourcePreference, - repo.connectionId ?? null, - projectRef, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async updateGitLabRepoMR( - repoSelector: string, - iid: number, - updates: GitLabMRUpdate, - projectRef?: GitLabProjectRef | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return updateGitLabMR( - repo.path, - iid, - updates, - repo.issueSourcePreference, - repo.connectionId ?? null, - projectRef, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async updateGitLabRepoMRReviewers( - repoSelector: string, - iid: number, - reviewerIds: number[], - projectRef?: GitLabProjectRef | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return updateGitLabMRReviewers( - repo.path, - iid, - reviewerIds, - repo.issueSourcePreference, - repo.connectionId ?? null, - projectRef, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async getGitLabRepoWorkItemDetails( - repoSelector: string, - iid: number, - type: 'issue' | 'mr', - projectRef?: GitLabProjectRef | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return getGitLabWorkItemDetails( - repo.path, - iid, - type, - repo.issueSourcePreference, - repo.connectionId ?? null, - projectRef, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async getGitLabRepoWorkItemByPath( - repoSelector: string, - projectRef: GitLabProjectRef, - iid: number, - type: 'issue' | 'mr' - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - const result = await getGitLabWorkItemByProjectRef( - repo.path, - projectRef, - iid, - type, - repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - // Why: remote pasted-URL lookups should update GitLab recents exactly - // like the desktop IPC path, but only after a successful lookup. - if (result && this.store?.updateSettings) { - const store = this.store - recordGitLabProjectRecent( - { - getSettings: () => store.getSettings(), - updateSettings: (updates) => store.updateSettings?.(updates) - }, - projectRef.host, - projectRef.path - ) - } - return result - } - - async getRepoIssue( - repoSelector: string, - number: number - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return getIssue( - repo.path, - number, - repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async getRepoPRChecks( - repoSelector: string, - prNumber: number, - headSha?: string, - prRepo?: GitHubOwnerRepo | null, - options?: { noCache?: boolean } - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return getPRChecks( - repo.path, - prNumber, - headSha, - prRepo ?? null, - options, - repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async rerunRepoPRChecks( - repoSelector: string, - prNumber: number, - options?: { - headSha?: string - failedOnly?: boolean - prRepo?: GitHubOwnerRepo | null - } - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return rerunPRChecks( - repo.path, - prNumber, - options, - repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async getRepoPRCheckDetails( - repoSelector: string, - args: { - checkRunId?: number - workflowRunId?: number - checkName?: string - url?: string | null - prRepo?: GitHubOwnerRepo | null - }, - signal?: AbortSignal - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - const localGitOptions = this.getLocalGitExecutionOptionArgs(repo)[0] ?? {} - return getPRCheckDetails( - repo.path, - { ...args, prRepo: args.prRepo ?? null }, - repo.connectionId ?? null, - localGitOptions, - signal - ) - } - - async getRepoPRComments( - repoSelector: string, - prNumber: number, - prRepo?: GitHubOwnerRepo | null, - options?: { noCache?: boolean } - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return getPRComments( - repo.path, - prNumber, - { ...options, prRepo: prRepo ?? null }, - repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async setRepoPRCommentReaction( - repoSelector: string, - reactionSubjectId: string, - content: GitHubReactionContent, - reacted: boolean, - prRepo?: GitHubOwnerRepo | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return setPRCommentReaction( - repo.path, - reactionSubjectId, - content, - reacted, - repo.connectionId ?? null, - prRepo ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async getRepoPRFileContents( - repoSelector: string, - args: { - prNumber: number - prRepo?: GitHubOwnerRepo | null - path: string - oldPath?: string - status: GitHubPRFile['status'] - headSha: string - baseSha: string - } - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return getPRFileContents({ - repoPath: repo.path, - connectionId: repo.connectionId ?? null, - localGitOptions: this.getLocalGitExecutionOptionArgs(repo)[0], - ...args - }) - } - - async resolveRepoReviewThread( - repoSelector: string, - threadId: string, - resolve: boolean, - prRepo?: GitHubOwnerRepo | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return resolveReviewThread( - repo.path, - threadId, - resolve, - repo.connectionId ?? null, - prRepo ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async setRepoPRFileViewed( - repoSelector: string, - args: { - prRepo?: GitHubOwnerRepo | null - pullRequestId: string - path: string - viewed: boolean - } - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return setPRFileViewed({ - repoPath: repo.path, - connectionId: repo.connectionId ?? null, - localGitOptions: this.getLocalGitExecutionOptionArgs(repo)[0], - ...args - }) - } - - async updateRepoPRTitle( - repoSelector: string, - prNumber: number, - title: string, - prRepo?: GitHubOwnerRepo | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return updatePRTitle( - repo.path, - prNumber, - title, - repo.connectionId ?? null, - prRepo ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async updateRepoPRDetails( - repoSelector: string, - prNumber: number, - updates: { title?: string; body?: string }, - prRepo?: GitHubOwnerRepo | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return updatePRDetails( - repo.path, - prNumber, - updates, - repo.connectionId ?? null, - prRepo ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async mergeRepoPR( - repoSelector: string, - prNumber: number, - method?: 'merge' | 'squash' | 'rebase', - prRepo?: GitHubOwnerRepo | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return mergePR( - repo.path, - prNumber, - method, - repo.connectionId ?? null, - prRepo ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async setRepoPRAutoMerge( - repoSelector: string, - prNumber: number, - enabled: boolean, - method?: 'merge' | 'squash' | 'rebase', - prRepo?: GitHubOwnerRepo | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return setPRAutoMerge( - repo.path, - prNumber, - enabled, - method, - repo.connectionId ?? null, - prRepo ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async markRepoPRReadyForReview( - repoSelector: string, - prNumber: number, - prRepo?: GitHubOwnerRepo | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return markPRReadyForReview( - repo.path, - prNumber, - repo.connectionId ?? null, - prRepo ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async updateRepoPRState( - repoSelector: string, - prNumber: number, - updates: GitHubPullRequestStateUpdate, - prRepo?: GitHubOwnerRepo | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return updatePRState( - repo.path, - prNumber, - updates, - repo.connectionId ?? null, - prRepo ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async requestRepoPRReviewers( - repoSelector: string, - prNumber: number, - reviewers: string[], - prRepo?: GitHubOwnerRepo | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return requestPRReviewers( - repo.path, - prNumber, - reviewers, - repo.connectionId ?? null, - prRepo ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async removeRepoPRReviewers( - repoSelector: string, - prNumber: number, - reviewers: string[], - prRepo?: GitHubOwnerRepo | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return removePRReviewers( - repo.path, - prNumber, - reviewers, - repo.connectionId ?? null, - prRepo ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async createRepoIssue( - repoSelector: string, - title: string, - body: string, - fields?: GitHubCreateIssueFields - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return createIssue( - repo.path, - title, - body, - repo.issueSourcePreference, - repo.connectionId ?? null, - fields, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async updateRepoIssue( - repoSelector: string, - number: number, - updates: GitHubIssueUpdate - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return updateIssue( - repo.path, - number, - updates, - repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async addRepoIssueComment( - repoSelector: string, - number: number, - body: string, - prRepo?: GitHubOwnerRepo | null - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return addIssueComment( - repo.path, - number, - body, - repo.connectionId ?? null, - prRepo ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async addRepoPRReviewComment( - repoSelector: string, - args: Omit - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return addPRReviewComment({ - repoPath: repo.path, - connectionId: repo.connectionId ?? null, - localGitOptions: this.getLocalGitExecutionOptionArgs(repo)[0], - ...args - }) - } - - async addRepoPRReviewCommentReply( - repoSelector: string, - args: { - prNumber: number - commentId: number - body: string - threadId?: string - path?: string - line?: number - prRepo?: GitHubOwnerRepo | null - } - ): Promise>> { - const repo = await this.resolveRepoSelector(repoSelector) - return addPRReviewCommentReply( - repo.path, - args.prNumber, - args.commentId, - args.body, - args.threadId, - args.path, - args.line, - repo.connectionId ?? null, - args.prRepo ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) - ) - } - - async listGitHubProjects( - args?: ListAccessibleProjectsArgs - ): Promise>> { - return listAccessibleProjects(args) - } - - async listGitHubLabelsBySlug( - args: ListLabelsBySlugArgs - ): Promise>> { - return listLabelsBySlug(args) - } - - async listGitHubAssignableUsersBySlug( - args: ListAssignableUsersBySlugArgs - ): Promise>> { - return listAssignableUsersBySlug(args) - } - - async listGitHubIssueTypesBySlug( - args: ListIssueTypesBySlugArgs - ): Promise>> { - return listIssueTypesBySlug(args) - } - - async resolveGitHubProjectRef( - args: ResolveProjectRefArgs - ): Promise>> { - return resolveProjectRef(args) - } - - async listGitHubProjectViews( - args: ListProjectViewsArgs - ): Promise>> { - return listProjectViews(args) - } - - async getGitHubProjectViewTable( - args: GetProjectViewTableArgs - ): Promise>> { - return getProjectViewTable(args) - } - - async getGitHubProjectWorkItemDetailsBySlug( - args: ProjectWorkItemDetailsBySlugArgs - ): Promise>> { - return getWorkItemDetailsBySlug(args) - } - - async updateGitHubProjectItemField( - args: UpdateProjectItemFieldArgs - ): Promise>> { - return updateProjectItemFieldValue(args) - } - - async clearGitHubProjectItemField( - args: ClearProjectItemFieldArgs - ): Promise>> { - return clearProjectItemFieldValue(args) - } - - async updateGitHubIssueBySlug( - args: UpdateIssueBySlugArgs - ): Promise>> { - return updateIssueBySlug(args) - } - - async updateGitHubPullRequestBySlug( - args: UpdatePullRequestBySlugArgs - ): Promise>> { - return updatePullRequestBySlug(args) - } - - async updateGitHubIssueTypeBySlug( - args: UpdateIssueTypeBySlugArgs - ): Promise>> { - return updateIssueTypeBySlug(args) - } - - async addGitHubIssueCommentBySlug( - args: AddIssueCommentBySlugArgs - ): Promise>> { - return addIssueCommentBySlug(args) - } - - async updateGitHubIssueCommentBySlug( - args: UpdateIssueCommentBySlugArgs - ): Promise>> { - return updateIssueCommentBySlug(args) - } - - async deleteGitHubIssueCommentBySlug( - args: DeleteIssueCommentBySlugArgs - ): Promise>> { - return deleteIssueCommentBySlug(args) - } - - private getSetupHookTrustPayload( - repo: Repo, - scriptContentValue: string | undefined - ): { contentHash: string; scriptContent: string } | undefined { - const scriptContent = scriptContentValue?.trim() - if (!scriptContent || repo.hookSettings?.commandSourcePolicy === 'local-only') { - return undefined - } - return { - contentHash: createHash('sha256').update(scriptContent).digest('hex'), - scriptContent - } - } - - private getSharedSetupHookTrustPayload( - repo: Repo, - sharedSetupScript: string | undefined - ): { contentHash: string; scriptContent: string } | undefined { - if (repo.hookSettings?.commandSourcePolicy === 'local-only') { - return undefined - } - return this.getSetupHookTrustPayload(repo, sharedSetupScript) - } - - async getRepoHooks(repoSelector: string) { - const repo = await this.resolveRepoSelector(repoSelector) - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return { - hasHooksFile: false, - hooks: null, - setupRunPolicy: getEffectiveSetupRunPolicy(repo), - source: null - } - } - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - const hooks = result.isBinary ? null : parseOrcaYaml(result.content) - return { - hasHooksFile: Boolean(hooks), - hooks, - setupRunPolicy: getEffectiveSetupRunPolicy(repo), - source: hooks ? 'orca.yaml' : null, - setupTrust: this.getSharedSetupHookTrustPayload( - repo, - getDefaultTabCommandTrustContent(hooks) - ) - } - } catch { - return { - hasHooksFile: false, - hooks: null, - setupRunPolicy: getEffectiveSetupRunPolicy(repo), - source: null - } - } - } - const hasFile = hasHooksFile(repo.path) - const hooks = getEffectiveHooks(repo) - const sharedHooks = hasFile ? loadHooks(repo.path) : null - const setupRunPolicy = getEffectiveSetupRunPolicy(repo) - return { - hasHooksFile: hasFile, - hooks, - setupRunPolicy, - source: hasFile ? 'orca.yaml' : hooks ? 'legacy' : null, - setupTrust: this.getSharedSetupHookTrustPayload( - repo, - getDefaultTabCommandTrustContent(sharedHooks) - ) - } - } - - async checkRepoHooks(repoSelector: string) { - const repo = await this.resolveRepoSelector(repoSelector) - if (isFolderRepo(repo)) { - return { status: 'ok' as const, hasHooks: false, hooks: null, mayNeedUpdate: false } - } - - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) - // Why: callers cache "no hooks" as authoritative, so an unreadable repo must fail - // closed with an error status (mirrors the hooks:check IPC handler) instead of - // pinning a false "no setup script" verdict until the client remounts. - if (!fsProvider) { - return { status: 'error' as const, hasHooks: false, hooks: null, mayNeedUpdate: false } - } - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - if (result.isBinary) { - return { status: 'ok' as const, hasHooks: false, hooks: null, mayNeedUpdate: false } - } - return { - status: 'ok' as const, - hasHooks: true, - hooks: parseOrcaYaml(result.content), - mayNeedUpdate: false - } - } catch (error) { - return { - status: isENOENT(error) ? ('ok' as const) : ('error' as const), - hasHooks: false, - hooks: null, - mayNeedUpdate: false - } - } - } - - const has = hasHooksFile(repo.path) - const hooks = has ? loadHooks(repo.path) : null - return { - status: 'ok' as const, - hasHooks: has, - hooks, - mayNeedUpdate: has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) - } - } - - async inspectRepoSetupScriptImports(repoSelector: string) { - const repo = await this.resolveRepoSelector(repoSelector) - if (isFolderRepo(repo)) { - return [] - } - - return inspectSetupScriptImportCandidates(async (relativePath) => { - const filePath = joinWorktreeRelativePath(repo.path, relativePath) - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return null - } - try { - const result = await fsProvider.readFile(filePath) - return result.isBinary ? null : result.content - } catch { - return null - } - } - - try { - return await readFile(filePath, 'utf-8') - } catch (error) { - if (!isENOENT(error)) { - console.warn('[runtime] Failed to inspect setup script import candidate:', error) - } - return null - } - }) - } - - async readRepoIssueCommand(repoSelector: string) { - const repo = await this.resolveRepoSelector(repoSelector) - if (isFolderRepo(repo)) { - return { - localContent: null, - sharedContent: null, - effectiveContent: null, - localFilePath: '', - source: 'none' as const - } - } - - if (repo.connectionId) { - const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return { - localContent: null, - sharedContent: null, - effectiveContent: null, - localFilePath: issueCommandPath, - source: 'none' as const - } - } - const localContent = await this.readRemoteIssueCommandOverride(fsProvider, issueCommandPath) - const sharedContent = await this.readRemoteSharedIssueCommand(fsProvider, repo.path) - const effectiveContent = localContent ?? sharedContent - return { - localContent, - sharedContent, - effectiveContent, - localFilePath: issueCommandPath, - source: localContent - ? ('local' as const) - : sharedContent - ? ('shared' as const) - : ('none' as const) - } - } - - return readIssueCommand(repo.path) - } - - private async readRemoteIssueCommandOverride( - fsProvider: IFilesystemProvider, - issueCommandPath: string - ): Promise { - try { - const result = await fsProvider.readFile(issueCommandPath) - if (result.isBinary) { - return null - } - return result.content.trim() || null - } catch { - return null - } - } - - private async readRemoteSharedIssueCommand( - fsProvider: IFilesystemProvider, - repoPath: string - ): Promise { - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repoPath, 'orca.yaml')) - if (result.isBinary) { - return null - } - return parseOrcaYaml(result.content)?.issueCommand?.trim() || null - } catch { - return null - } - } - - async writeRepoIssueCommand(repoSelector: string, content: string): Promise<{ ok: true }> { - const repo = await this.resolveRepoSelector(repoSelector) - if (isFolderRepo(repo)) { - return { ok: true } - } - - if (repo.connectionId) { - const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return { ok: true } - } - const trimmed = content.trim() - if (!trimmed) { - await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => { - if (!isENOENT(error)) { - throw error - } - }) - return { ok: true } - } - await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) - await this.ensureRemoteOrcaDirIgnored(fsProvider, repo.path) - await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) - return { ok: true } - } - - writeIssueCommand(repo.path, content) - return { ok: true } - } - - private async ensureRemoteOrcaDirIgnored( - fsProvider: IFilesystemProvider, - repoPath: string, - options: { required?: boolean } = {} - ): Promise { - const gitignorePath = joinWorktreeRelativePath(repoPath, '.gitignore') - let result: Awaited> - try { - result = await fsProvider.readFile(gitignorePath) - } catch (error) { - if (!isENOENT(error)) { - if (options.required) { - throw error - } - console.warn('[runtime] Could not inspect remote .gitignore for .orca', error) - return - } - try { - await fsProvider.writeFile(gitignorePath, '.orca\n') - } catch (writeError) { - if (options.required) { - throw writeError - } - console.warn('[runtime] Could not update remote .gitignore to exclude .orca', writeError) - } - return - } - if (result.isBinary) { - if (options.required) { - throw new Error('Remote .gitignore is binary; cannot verify .orca is ignored') - } - return - } - if (/^\.orca\/?$/m.test(result.content)) { - return - } - const separator = result.content.endsWith('\n') ? '' : '\n' - try { - await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`) - } catch (writeError) { - if (options.required) { - throw writeError - } - console.warn('[runtime] Could not update remote .gitignore to exclude .orca', writeError) - } - } - - async listManagedWorktrees( - repoSelector?: string, - limit = DEFAULT_WORKTREE_LIST_LIMIT, - sourceDefaultsSupported = true - ): Promise { - if (!Number.isInteger(limit) || limit <= 0) { - throw new Error('invalid_limit') - } - const resolved = await this.listResolvedWorktrees() - const repoId = repoSelector ? (await this.resolveRepoSelector(repoSelector)).id : null - const settings = this.store?.getSettings() - const visibilityDefaults = sourceDefaultsSupported - ? settings?.worktreeVisibilityDefaults - : settings?.worktreeVisibilityDefaults - ? { external: settings.worktreeVisibilityDefaults.external } - : undefined - const visibilitySettings = settings - ? { ...settings, worktreeVisibilityDefaults: visibilityDefaults } - : undefined - const visibilitySourceMatchersByRepoId = this.buildRuntimeVisibilitySourceMatchersByRepoId( - resolved, - visibilityDefaults - ) - const worktrees = resolved.filter((worktree) => { - if (repoId && worktree.repoId !== repoId) { - return false - } - return this.isRuntimeWorktreeVisible( - worktree, - visibilitySourceMatchersByRepoId.get(worktree.repoId), - visibilitySettings - ) - }) - return { - worktrees: worktrees.slice(0, limit), - totalCount: worktrees.length, - truncated: worktrees.length > limit - } - } - - /** Keyed by repo id on the wire even though one repo is requested: the caller asked by selector - * and needs to know which repo answered. - * - * The tier watermark rides alongside the names rather than being expanded into them — expanding - * it would put 552 strings per spent tier on the wire, which is what compaction exists to - * avoid. A client predating the field reads the names only and under-retires, degrading to the - * pre-retirement behavior for compacted tiers instead of breaking. */ - async listRetiredWorktreeNames(repoSelector: string): Promise<{ - retiredNamesByRepo: Record - retiredNameTiersByRepo: Record - }> { - const store = this.store - if (!store) { - return { retiredNamesByRepo: {}, retiredNameTiersByRepo: {} } - } - const repo = await this.resolveRepoSelector(repoSelector) - const settings = store.getSettings() - const registry: RetiredNameRegistry = await getRetiredNameRegistryForRepo( - store, - repo, - store.getRepos(), - settings - ) - return { - retiredNamesByRepo: { [repo.id]: registry.names }, - retiredNameTiersByRepo: { [repo.id]: registry.exhaustedTiers } - } - } - - async listDetectedManagedWorktrees( - repoSelector: string, - connectionId?: string | null, - sourceDefaultsSupported = true - ): Promise { - return this.listDetectedWorktreesForResolvedRepo( - await this.resolveRepoSelectorForConnection(repoSelector, connectionId), - sourceDefaultsSupported - ) - } - - private async listDetectedWorktreesForResolvedRepo( - repo: Repo, - sourceDefaultsSupported = true - ): Promise { - const store = this.requireStore() - const settings = store.getSettings() - const visibilityDefaults = sourceDefaultsSupported - ? settings.worktreeVisibilityDefaults - : settings.worktreeVisibilityDefaults - ? { external: settings.worktreeVisibilityDefaults.external } - : undefined - const visibilitySettings = { ...settings, worktreeVisibilityDefaults: visibilityDefaults } - if (isFolderRepo(repo)) { - const worktrees = listRuntimeFolderWorkspaces(store, repo) - const metaById = store.getAllWorktreeMeta() - const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length - const matcher = createWorktreeVisibilitySourceMatcher( - [repo.path, ...worktrees.map((worktree) => worktree.path)], - resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults), - resolveConfiguredWorktreeBasePaths(repo) - ) - const detected = worktrees.map((worktree) => - this.toRuntimeDetectedWorktree( - repo, - worktree, - matcher, - visibilitySettings, - getRepoOwnedWorktreeMeta(repo, worktree.id, metaById, repoOwnerCount) ?? null - ) - ) - return { - repoId: repo.id, - authoritative: true, - source: 'git', - worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) - } - } - let scan: RuntimeWorktreeScanResult - try { - scan = await this.listRepoWorktreesForResolution(repo) - } catch { - scan = { ok: false, worktrees: [] } - } - if (scan.ok) { - pruneLineageForMissingRepoWorktrees(store, repo, scan.worktrees) - } - const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( - [repo.path, ...scan.worktrees.map((worktree) => worktree.path)], - resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults), - resolveConfiguredWorktreeBasePaths(repo) - ) - const expectedHostId = getRepoExecutionHostId(repo) - const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length - const metaById = store.getAllWorktreeMeta() - const detected = scan.worktrees.map((gitWorktree) => { - const worktreeId = `${repo.id}::${gitWorktree.path}` - // A host-qualified row is exact; the locator-keyed one is only trustworthy when this repo owns it. - const meta = - readWorktreeMetaForHost(store, worktreeId, expectedHostId) ?? - getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount) - const worktree = { - ...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), - hostId: repoOwnerCount === 1 ? (meta?.hostId ?? expectedHostId) : expectedHostId - } - const detectedWorktree = this.toRuntimeDetectedWorktree( - repo, - worktree, - worktreeVisibilitySourceMatcher, - visibilitySettings, - meta ?? null - ) - if (scan.ok) { - return detectedWorktree - } - return applyMetadataFallbackVisibility(detectedWorktree) - }) - return { - repoId: repo.id, - authoritative: scan.ok, - source: scan.ok ? 'git' : 'metadata-fallback', - worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) - } - } - - async teardownMissingManagedWorktreeTerminals( - repoSelector: string, - knownWorktreeIds: readonly string[], - connectionId?: string | null - ): Promise<{ stoppedWorktreeIds: string[] }> { - const repo = await this.resolveRepoSelectorForConnection(repoSelector, connectionId) - // Why: killing PTYs must be proven against the host right now — a cached scan - // (30s TTL) can still list a directory git already dropped, and the renderer - // purges its state either way, so a stale miss strands those processes for good. - this.invalidateWorktreeScanCacheForRepo(repo.id) - // Why: rescanning by `id:` would re-resolve the already-resolved repo, and a - // duplicate id across hosts makes that second lookup throw selector_ambiguous - // even though the caller's selector was unique — losing the sweep entirely. - const detected = await this.listDetectedWorktreesForResolvedRepo(repo) - if (!detected.authoritative) { - return { stoppedWorktreeIds: [] } - } - return stopMissingWorktreeTerminals( - repo, - knownWorktreeIds, - detected.worktrees.map((worktree) => worktree.id), - { - runtime: this, - getLocalProvider: () => this.getLocalProvider(), - getSshProvider: (connectionId) => this.getSshProviderFn?.(connectionId), - onPtyStopped: this.onPtyStopped ?? undefined - } - ) - } - - private resolveRepoSelectorForConnection( - repoSelector: string, - connectionId?: string | null - ): Promise { - if (connectionId === undefined) { - return this.resolveRepoSelector(repoSelector) - } - // Why: an explicit connection identity only *narrows* the selector; it must not - // change the grammar. Matching the selector as a bare repo id would make - // `path:`/`name:` selectors resolve to repo_not_found on this path alone. - const wanted = connectionId?.trim() || null - const matches = this.selectReposBySelector(repoSelector).filter( - (repo) => (repo.connectionId?.trim() || null) === wanted - ) - if (matches.length !== 1) { - throw new Error(matches.length > 1 ? 'selector_ambiguous' : 'repo_not_found') - } - return Promise.resolve(matches[0]) - } - - private isRuntimeWorktreeVisible( - worktree: Worktree, - worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher, - settings?: ReturnType - ): boolean { - const repo = this.store?.getRepo(worktree.repoId) - if (!repo || !this.store) { - return true - } - return this.toRuntimeDetectedWorktree(repo, worktree, worktreeVisibilitySourceMatcher, settings) - .visible - } - - private buildRuntimeVisibilitySourceMatchersByRepoId( - worktrees: readonly Worktree[], - visibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults'] - ): Map { - const checkoutPathsByRepoId = new Map() - for (const worktree of worktrees) { - const checkoutPaths = checkoutPathsByRepoId.get(worktree.repoId) ?? [] - checkoutPaths.push(worktree.path) - checkoutPathsByRepoId.set(worktree.repoId, checkoutPaths) - } - return new Map( - (this.store?.getRepos() ?? []) - .filter((repo) => checkoutPathsByRepoId.has(repo.id)) - .map((repo) => [ - repo.id, - createWorktreeVisibilitySourceMatcher( - [repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])], - resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults), - resolveConfiguredWorktreeBasePaths(repo) - ) - ]) - ) - } - - private toRuntimeDetectedWorktree( - repo: Repo, - worktree: Worktree, - worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher, - providedSettings?: ReturnType, - providedMeta?: WorktreeMeta | null - ): DetectedWorktree { - const settings = providedSettings ?? this.store?.getSettings() - if (!settings) { - return { - ...worktree, - ownership: 'unknown-legacy', - selectedCheckout: false, - visible: true - } - } - return toDetectedWorktree({ - repo, - worktree, - meta: - providedMeta === undefined - ? this.store?.getWorktreeMeta(worktree.id) - : (providedMeta ?? undefined), - settings, - knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo), - isLegacyRepoForVisibility: isLegacyRepoForExternalWorktreeVisibility(repo), - worktreeVisibilitySourceMatcher - }) - } - - async showManagedWorktree(worktreeSelector: string) { - return await this.resolveWorktreeSelector(worktreeSelector) - } - - async showManagedTerminalWorkspace(worktreeSelector: string) { - const target = await this.resolveTerminalWorkspaceLaunchTarget(worktreeSelector) - if (!target.managedWorktree) { - throw new Error('selector_not_found') - } - return target.managedWorktree - } - - async scanWorkspacePorts(repoId?: string): Promise { - return scanWorkspacePortProbes(await this.getWorkspacePortProbes(repoId)) - } - - async killWorkspacePort(args: WorkspacePortKillRequest): Promise { - return killWorkspacePort(await this.getWorkspacePortProbes(args.repoId), args) - } - - // Why: remote clients may invoke this over RPC, so the runtime derives - // allowed worktree paths from its own store instead of trusting client paths. - private async getWorkspacePortProbes(repoId?: string): Promise { - const reposById = new Map( - this.requireStore() - .getRepos() - .map((repo) => [repo.id, repo]) - ) - return filterWorkspacePortProbes( - (await this.listResolvedWorktrees()).map((worktree) => ({ - id: worktree.id, - repoId: worktree.repoId, - displayName: worktree.displayName, - path: worktree.git.path, - connectionId: reposById.get(worktree.repoId)?.connectionId ?? null - })), - repoId - ) - } - - async sleepManagedWorktree(worktreeSelector: string): Promise<{ worktreeId: string }> { - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - // Why: sleep is renderer-initiated on desktop (it tears down tab state - // before killing PTYs). The notifier tells the renderer to run its own - // sleep flow so all cleanup happens in the correct order. - this.notifier?.sleepWorktree(worktree.id) - return { worktreeId: worktree.id } - } - - async activateManagedWorktree( - worktreeSelector: string, - opts: { - notifyClients?: boolean - clientKind?: 'mobile' | 'runtime' - navigation?: RuntimeNavigationTarget - } = {} - ): Promise<{ - repoId: string - worktreeId: string - activated: boolean - /** Mobile-scoped slept-agent wake outcome. `unsupported-headless` means no - * renderer holds the sleeping records (headless `orca serve`), so nothing - * woke — clients must not present the worktree's agents as resumed. */ - sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' - }> { - this.assertGraphReady() - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - const repo = this.store?.getRepo(worktree.repoId) - if (!repo) { - throw new Error('repo_not_found') - } - const navigation = opts.navigation ?? (opts.notifyClients === false ? 'caller' : 'all') - const targetsHost = navigationTargetsHost(navigation) - const targetsClients = navigationTargetsClients(navigation) - - if (!targetsHost && this.store?.getWorktreeMeta(worktree.id)?.isUnread) { - // Why: mobile/web session activation intentionally bypasses renderer - // selection, so the runtime must acknowledge the unread state itself. - this.store.setWorktreeMeta(worktree.id, { isUnread: false }) - this.notifyWorktreesChanged(repo.id) - } - - let sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' = - 'not-applicable' - if (targetsHost || targetsClients) { - // Why: inactive worktree terminal panes are renderer-owned and may not have - // live PTYs until the desktop activates the worktree and mounts them. - if (targetsHost) { - this.notifyHostActivateWorktree(repo.id, worktree.id) - } - if (targetsClients) { - this.notifyClientsActivateWorktree(repo.id, worktree.id) - } - } - if (!targetsHost) { - // Why: mobile/web selection needs fresh session surfaces without forcing - // every attached desktop renderer to navigate to the phone's workspace. - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, { - allowAttachedWindow: true - }) - await this.refreshMobileSessionPtyRecords() - this.notifyMobileSessionTabsChanged(worktree.id) - // Why: a phone open must also wake the worktree's slept agents (experimental - // agent sleep). Only the host renderer holds the sleeping records + wake - // authority, so fire-and-forget ask it — mobile-scoped so web/desktop are - // unaffected. Headless serve has no renderer to wake anything, so report - // that explicitly instead of letting mobile assume the agents resumed. - if (opts.clientKind === 'mobile') { - if (this.getAvailableAuthoritativeWindow()) { - this.notifier?.resumeSleepingAgents?.(worktree.id) - sleepingAgentWake = 'requested' - } else if ( - // Why: sleeping records are partitioned by execution host; reading - // only the local partition would miss slept agents on SSH-host - // worktrees and skip the headless warning for them. - Object.values( - this.store?.getWorkspaceSession?.(getRepoExecutionHostId(repo)) - .sleepingAgentSessionsByPaneKey ?? {} - ).some((record) => record.worktreeId === worktree.id) - ) { - // Why: headless is only degraded when this worktree actually has a - // persisted resume record. Ordinary mobile activation must not show - // an unsupported warning merely because no desktop window is open. - sleepingAgentWake = 'unsupported-headless' - } - } - } - return { repoId: repo.id, worktreeId: worktree.id, activated: true, sleepingAgentWake } - } - - private async buildStartupForDraft( - repo: Repo, - draft: string, - requestedAgent?: TuiAgent - ): Promise<{ - agent: TuiAgent - startup: WorktreeStartupLaunch - draftPaste?: WorktreeStartupDraftPaste - } | null> { - if (!this.store) { - return null - } - const content = draft.trim() - if (!content) { - return null - } - const settings = this.store.getSettings() - const preferredAgent = requestedAgent ?? settings.defaultTuiAgent - if (preferredAgent === 'blank') { - // Why: `blank` is an explicit user preference to create a shell-only - // workspace, so linked task drafts must not auto-pick a detected agent. - return null - } - let agent = - isTuiAgent(preferredAgent) && isTuiAgentEnabled(preferredAgent, settings.disabledTuiAgents) - ? preferredAgent - : null - if (!agent) { - let detected: string[] = [] - try { - // Why: startup-draft fallback can run from sparse runtime launch envs too. - detected = repo.connectionId - ? await detectRemoteAgents({ connectionId: repo.connectionId }) - : await detectInstalledAgentsWithShellPathHydration() - } catch { - detected = [] - } - const typedDetected = detected.filter(isTuiAgent) - agent = pickTuiAgent(null, typedDetected, settings.disabledTuiAgents) - } - if (!agent) { - return null - } - - // Why: a mobile client can run on Windows while the workspace shell is - // Linux over SSH. Startup command quoting must target the shell that runs it. - const agentLaunchPlatform = this.getAgentLaunchPlatformForRepo(repo) - const isRemote = repoIsRemote(repo) - const queuedShell = resolveLocalWindowsAgentStartupShell({ - platform: agentLaunchPlatform, - isRemote, - terminalWindowsShell: settings.terminalWindowsShell - }) - const draftLaunchPlan = buildAgentDraftLaunchPlan({ - agent, - draft: content, - cmdOverrides: settings.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), - platform: agentLaunchPlatform, - shell: queuedShell, - isRemote - }) - if (draftLaunchPlan) { - return { - agent, - startup: { - command: draftLaunchPlan.launchCommand, - launchConfig: draftLaunchPlan.launchConfig, - ...(draftLaunchPlan.startupCommandDelivery - ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } - : {}), - ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) - } - } - } - - const startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides: settings.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), - platform: agentLaunchPlatform, - shell: queuedShell, - isRemote, - allowEmptyPromptLaunch: true - }) - if (!startupPlan) { - return null - } - return { - agent, - startup: { - command: startupPlan.launchCommand, - launchConfig: startupPlan.launchConfig, - ...(startupPlan.startupCommandDelivery - ? { startupCommandDelivery: startupPlan.startupCommandDelivery } - : {}), - ...(startupPlan.env ? { env: startupPlan.env } : {}) - }, - draftPaste: { agent, content } - } - } - - private buildStartupForAgent( - repo: Repo, - agent: TuiAgent, - prompt: string | undefined, - launchPreferences?: AgentLaunchPreferences - ): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } { - if (!this.store) { - throw new Error('runtime_unavailable') - } - const settings = this.store.getSettings() - if (!isTuiAgentEnabled(agent, settings.disabledTuiAgents)) { - throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') - } - // Why: CLI clients may target SSH runtimes from macOS/Windows, so quote for - // the workspace shell rather than the client shell. - const agentLaunchPlatform = this.getAgentLaunchPlatformForRepo(repo) - const isRemote = repoIsRemote(repo) - const queuedShell = resolveLocalWindowsAgentStartupShell({ - platform: agentLaunchPlatform, - isRemote, - terminalWindowsShell: settings.terminalWindowsShell - }) - const sessionOptions = this.toAgentSessionOptions(launchPreferences) - const startupPlan = buildAgentStartupPlan({ - agent, - prompt: prompt ?? '', - cmdOverrides: settings.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), - sessionOptions, - sessionOptionsOverrideAgentArgs: Boolean(sessionOptions), - platform: agentLaunchPlatform, - shell: queuedShell, - isRemote, - allowEmptyPromptLaunch: true - }) - if (!startupPlan) { - throw new Error(`Could not build launch command for ${agent}.`) - } - return { - agent, - startup: { - command: startupPlan.launchCommand, - launchConfig: startupPlan.launchConfig, - ...(startupPlan.startupCommandDelivery - ? { startupCommandDelivery: startupPlan.startupCommandDelivery } - : {}), - ...(startupPlan.env ? { env: startupPlan.env } : {}) - }, - ...(startupPlan.followupPrompt - ? { - followup: { - expectedProcess: startupPlan.expectedProcess, - prompt: startupPlan.followupPrompt - } - } - : {}) - } - } - - private markWorkspaceTrustedForAgent( - agent: TuiAgent, - connectionId: string | null | undefined, - workspacePath: string - ): Promise { - return connectionId - ? this.markRemoteWorkspaceTrustedForAgent(agent, connectionId, workspacePath) - : this.markLocalWorkspaceTrustedForAgent(agent, workspacePath) - } - - private async markLocalWorkspaceTrustedForAgent( - agent: TuiAgent, - workspacePath: string - ): Promise { - const preset = TUI_AGENT_CONFIG[agent].preflightTrust - if (!preset) { - return - } - try { - if (preset === 'cursor') { - markCursorWorkspaceTrusted(workspacePath) - } else if (preset === 'copilot') { - markCopilotFolderTrusted(workspacePath) - } else if (preset === 'codex') { - // Why: the Codex write queues behind any in-flight hook grant, so the - // agent must not launch until it has actually landed. - await markCodexProjectTrusted(workspacePath) - } - } catch { - // Best-effort: the user can still accept the agent trust prompt manually. - } - } - - private async markRemoteWorkspaceTrustedForAgent( - agent: TuiAgent, - connectionId: string, - workspacePath: string - ): Promise { - const preset = TUI_AGENT_CONFIG[agent].preflightTrust - if (!preset) { - return - } - try { - await markRemoteAgentWorkspaceTrusted({ preset, connectionId, workspacePath }) - } catch { - // Best-effort: the user can still accept the remote agent trust prompt manually. - } - } - - private recordCreatedWorktreeLineage( - worktree: Pick, - lineageResolution: WorktreeLineageResolution - ): { - lineage: WorktreeLineage | null - workspaceLineage: WorkspaceLineage | null - warnings: WorktreeLineageWarning[] - } { - const warnings = lineageResolution.kind === 'none' ? [...lineageResolution.warnings] : [] - let lineage: WorktreeLineage | null = null - let workspaceLineage: WorkspaceLineage | null = null - if (lineageResolution.kind !== 'lineage') { - return { lineage, workspaceLineage, warnings } - } - - const childInstanceId = worktree.instanceId - const parentInstanceId = lineageResolution.parent.instanceId - const createdAt = Date.now() - if ( - lineageResolution.parent.type === 'worktree' && - childInstanceId && - parentInstanceId && - this.store?.setWorktreeLineage - ) { - lineage = this.store.setWorktreeLineage(worktree.id, { - worktreeId: worktree.id, - worktreeInstanceId: childInstanceId, - parentWorktreeId: lineageResolution.parent.worktree.id, - parentWorktreeInstanceId: parentInstanceId, - origin: lineageResolution.origin, - capture: lineageResolution.capture, - ...(lineageResolution.orchestrationRunId - ? { orchestrationRunId: lineageResolution.orchestrationRunId } - : {}), - ...(lineageResolution.taskId ? { taskId: lineageResolution.taskId } : {}), - ...(lineageResolution.coordinatorHandle - ? { coordinatorHandle: lineageResolution.coordinatorHandle } - : {}), - ...(lineageResolution.createdByTerminalHandle - ? { createdByTerminalHandle: lineageResolution.createdByTerminalHandle } - : {}), - createdAt - }) - } else if (lineageResolution.parent.type === 'worktree') { - warnings.push({ - code: 'LINEAGE_PARENT_CONTEXT_MISSING', - message: - 'Worktree created, but Orca could not record lineage because instance identity was unavailable.', - details: { - childHasInstanceId: Boolean(childInstanceId), - parentHasInstanceId: Boolean(parentInstanceId), - storeSupportsLineage: Boolean(this.store?.setWorktreeLineage) - } - }) - } - if (childInstanceId && this.store?.setWorkspaceLineage) { - workspaceLineage = this.store.setWorkspaceLineage({ - childWorkspaceKey: worktreeWorkspaceKey(worktree.id), - childInstanceId, - parentWorkspaceKey: lineageResolution.parent.workspaceKey, - parentInstanceId, - origin: lineageResolution.origin, - capture: lineageResolution.capture, - ...(lineageResolution.taskId ? { taskId: lineageResolution.taskId } : {}), - ...(lineageResolution.orchestrationRunId - ? { orchestrationRunId: lineageResolution.orchestrationRunId } - : {}), - ...(lineageResolution.coordinatorHandle - ? { coordinatorHandle: lineageResolution.coordinatorHandle } - : {}), - ...(lineageResolution.createdByTerminalHandle - ? { createdByTerminalHandle: lineageResolution.createdByTerminalHandle } - : {}), - createdAt - }) - } - return { lineage, workspaceLineage, warnings } - } - - private pasteStartupDraftWhenReady(handle: string, draft: WorktreeStartupDraftPaste): void { - void this.waitForStartupDraftReady(handle, draft.agent) - .then((ptyId) => { - if (!ptyId) { - console.warn('[worktree-create] agent did not become ready for draft paste') - return - } - this.ptyController?.write( - ptyId, - `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}` - ) - }) - .catch((error) => { - console.warn('[worktree-create] failed to paste startup draft:', error) - }) - } - - private sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void { - void this.waitForStartupFollowupReady(handle, followup.expectedProcess) - .then((ptyId) => { - if (!ptyId) { - console.warn('[worktree-create] agent did not become ready for follow-up prompt') - return - } - this.ptyController?.write(ptyId, `${followup.prompt}\r`) - }) - .catch((error) => { - console.warn('[worktree-create] failed to send startup follow-up prompt:', error) - }) - } - - private async createDefaultTabTerminals( - worktreeSelector: string, - worktreeId: string, - defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined, - surfacing: { surfaceOwner?: false } = {} - ): Promise { - if (!defaultTabs || defaultTabs.tabs.length === 0 || !this.ptyController?.spawn) { - return [] - } - const handles: string[] = [] - for (const template of defaultTabs.tabs) { - try { - const command = template.command?.trim() - const terminal = await this.createTerminal(worktreeSelector, { - ...(template.title ? { title: template.title } : {}), - ...(command && defaultTabs.runCommands ? { command } : {}), - ...surfacing - }) - handles.push(terminal.handle) - if (template.color && terminal.tabId) { - await this.setMobileSessionTabProps(`id:${worktreeId}`, { - tabId: terminal.tabId, - color: template.color - }) - } - } catch (error) { - console.warn(`[worktree-create] Failed to create default tab for ${worktreeId}:`, error) - } - } - return handles - } - - private async provisionManagedWorktreeTerminals(args: { - worktreeSelector: string - worktreeId: string - worktreePath: string - setup?: CreateWorktreeResult['setup'] - defaultTabs?: CreateWorktreeResult['defaultTabs'] - primaryTerminalHandle?: string | null - hasStartupTerminal: boolean - setupCommandPlatform: 'windows' | 'posix' - observeSetupCompletion?: boolean - // Why: when the agent startup is sequenced to wait for setup - // (waitForAgentStartup), the startup PTY runs a wrapper that already embeds - // the setup command. Pass that wrapped command through so the Setup tab runs - // the same script the agent is waiting on instead of a bare runner. - wrappedSetupCommand?: string - // Why: a workspace provisioned in the background must not pull the sidebar - // to itself; the user never asked to look at these tabs. - surfaceOwner?: false - }): Promise<{ setupSpawned: boolean; setupTerminalHandle: string | null }> { - if (!this.ptyController?.spawn) { - return { setupSpawned: false, setupTerminalHandle: null } - } - const surfacing = ownerSurfacing(args.surfaceOwner !== false) - let setupSpawned = false - let setupTerminalHandle: string | null = null - try { - const defaultTabHandles = await this.createDefaultTabTerminals( - args.worktreeSelector, - args.worktreeId, - args.defaultTabs, - surfacing - ) - let primaryTerminalHandle = args.primaryTerminalHandle ?? defaultTabHandles[0] ?? null - const setupLaunchMode = - ( - this.requireStore().getSettings() as Partial< - Pick - > - ).setupScriptLaunchMode ?? 'new-tab' - if (!args.hasStartupTerminal && !primaryTerminalHandle) { - const terminal = await this.createTerminal(args.worktreeSelector, surfacing) - primaryTerminalHandle = terminal.handle - } - if (args.setup) { - const completionToken = - args.observeSetupCompletion && !args.wrappedSetupCommand ? randomUUID() : null - const observedCommand = completionToken - ? buildObservedSetupCommand( - args.setup.runnerScriptPath, - args.setupCommandPlatform, - completionToken, - args.setup.shell - ) - : null - const setupCommand = - args.wrappedSetupCommand ?? - observedCommand?.command ?? - buildSetupRunnerCommand( - args.setup.runnerScriptPath, - args.setupCommandPlatform, - args.setup.shell - ) - const setupEnv = { ...args.setup.envVars, ...observedCommand?.env } - const shouldSplitSetup = - primaryTerminalHandle && - (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') - const setupTerminal = await (shouldSplitSetup - ? this.splitTerminal(primaryTerminalHandle!, { - direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', - command: setupCommand, - env: setupEnv, - activate: false, - ...surfacing - }) - : this.createTerminal(args.worktreeSelector, { - title: 'Setup', - command: setupCommand, - env: setupEnv, - ...surfacing - })) - setupTerminalHandle = setupTerminal.handle - setupSpawned = true - const ptyId = this.getLivePtyForHandle(setupTerminal.handle)?.pty.ptyId - if (completionToken && ptyId) { - this.setupCompletionTokenByPtyId.set(ptyId, completionToken) - } - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - console.warn( - `[worktree-create] Failed to create setup/default terminals for ${args.worktreePath}: ${message}` - ) - } - return { setupSpawned, setupTerminalHandle } - } - - private async waitForStartupFollowupReady( - handle: string, - expectedProcess: string - ): Promise { - const livePty = this.getLivePtyForHandle(handle) - const ptyId = livePty?.pty.ptyId - if (!ptyId || !this.ptyController) { - return null - } - for (let attempt = 0; attempt < 30; attempt += 1) { - if (attempt > 0) { - await new Promise((resolve) => setTimeout(resolve, 150)) - } - try { - const foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) - if (isExpectedAgentProcess(foregroundProcess, expectedProcess)) { - return ptyId - } - if (attempt >= 4 && !isShellProcess(foregroundProcess ?? '')) { - const hasChildProcesses = - (await this.ptyController.hasChildProcesses?.(ptyId).catch(() => false)) ?? false - if (hasChildProcesses) { - return ptyId - } - } - } catch { - // Ignore transient PTY inspection failures and keep polling. - } - } - return null - } - - private waitForStartupDraftReady(handle: string, agent: TuiAgent): Promise { - const livePty = this.getLivePtyForHandle(handle) - const ptyId = livePty?.pty.ptyId - if (!ptyId) { - return Promise.resolve(null) - } - const readySignal = - TUI_AGENT_CONFIG[agent].draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' - return new Promise((resolve) => { - let settled = false - const scanner = createDraftPasteReadyScanner(readySignal) - let quietTimer: NodeJS.Timeout | null = null - let hardTimer: NodeJS.Timeout | null = null - let unsubscribe: (() => void) | null = null - - const finish = (value: string | null): void => { - if (settled) { - return - } - settled = true - if (quietTimer) { - clearTimeout(quietTimer) - } - if (hardTimer) { - clearTimeout(hardTimer) - } - unsubscribe?.() - resolve(value) - } - - const armQuietTimer = (): void => { - if (quietTimer) { - clearTimeout(quietTimer) - } - quietTimer = setTimeout(() => finish(ptyId), BRACKETED_PASTE_QUIET_MS) - } - - const observeData = (data: string): void => { - const { ready, armQuietTimer: shouldArm } = scanner.observe(data) - if (ready) { - finish(ptyId) - return - } - if (shouldArm) { - armQuietTimer() - } - } - - unsubscribe = this.subscribeToTerminalData(ptyId, observeData) - const replay = this.recentPtyOutputById.get(ptyId)?.read() - if (replay) { - observeData(replay) - } - hardTimer = setTimeout(() => finish(null), resolveDraftPasteReadyTimeoutMs(agent)) - }) - } - - async prefetchManagedWorktreeCreateBase(args: { - repoSelector: string - baseBranch?: string - }): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - - const repo = await this.resolveRepoSelector(args.repoSelector) - const store = this.requireStore() - const baseBranch = await prefetchWorktreeCreateBase({ - repo, - baseBranch: args.baseBranch, - runtime: this, - gitOptions: getWorktreeCreatePrefetchGitOptions(store, repo) - }) - if (baseBranch) { - try { - await prepareWorktreeCreateForRepo(store, repo, baseBranch) - } catch { - // Why: speculative preparation is an optimistic warm-up; the real create path reports failures. - } - } - } - - async createManagedWorktree(args: { - repoSelector: string - name: string - /** True only when `name` came from Orca's creature-name generator; gates retirement so a name - * the user typed stays reusable. Absent for CLI and automation callers. */ - nameWasGenerated?: boolean - baseBranch?: string - compareBaseRef?: string - branchNameOverride?: string - linkedIssue?: number | null - linkedPR?: number | null - linkedLinearIssue?: string - linkedLinearIssueWorkspaceId?: string | null - linkedLinearIssueOrganizationUrlKey?: string | null - linkedGitLabMR?: number | null - linkedGitLabIssue?: number | null - linkedBitbucketPR?: number | null - linkedAzureDevOpsPR?: number | null - linkedGiteaPR?: number | null - linkedWorkItem?: WorkspaceLinkedItem | null - linkedTaskSourceContext?: TaskSourceContext | null - comment?: string - displayName?: string - displayNameKind?: CreateWorktreeArgs['displayNameKind'] - telemetrySource?: WorkspaceCreateTelemetrySource - workspaceStatus?: string - manualOrder?: number - sparseCheckout?: { directories: string[]; presetId?: string } - pushTarget?: GitPushTarget - runHooks?: boolean - activate?: boolean - /** Who the create's activation is addressed to. Defaults to 'all' so host/CLI callers keep - * revealing on every surface; the RPC layer narrows it to 'caller' for paired clients. */ - navigation?: RuntimeNavigationTarget - setupDecision?: 'run' | 'skip' | 'inherit' - awaitTerminalProvisioning?: boolean - observeSetupCompletion?: boolean - createdWithAgent?: TuiAgent - startupAgent?: TuiAgent - startupLaunchPreferences?: AgentLaunchPreferences - startupPrompt?: string - pendingFirstAgentMessageRename?: boolean - automationProvenance?: AutomationWorkspaceProvenance - cliProvenance?: CliWorkspaceProvenance - creatorProvenance?: Worktree['creatorProvenance'] - startup?: WorktreeStartupLaunch - startupDraft?: string - startupDraftPaste?: WorktreeStartupDraftPaste - lineage?: WorktreeLineageInput - }): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - - const repo = await this.resolveRepoSelector(args.repoSelector) - const createSettings = this.store.getSettings() - const requestedAgent = args.startupAgent ?? args.createdWithAgent - const requestedAgentEnabled = - requestedAgent !== undefined - ? isTuiAgentEnabled(requestedAgent, createSettings.disabledTuiAgents) - : false - if ((args.startup || args.startupAgent) && requestedAgent && !requestedAgentEnabled) { - throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') - } - if ( - args.startup && - args.startupDraftPaste && - !isTuiAgentEnabled(args.startupDraftPaste.agent, createSettings.disabledTuiAgents) - ) { - throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') - } - const agentStartup = - !args.startup && args.startupAgent - ? this.buildStartupForAgent( - repo, - args.startupAgent, - args.startupPrompt, - args.startupLaunchPreferences - ) - : null - const draftStartup = - !args.startup && !agentStartup && args.startupDraft - ? await this.buildStartupForDraft(repo, args.startupDraft, requestedAgent) - : null - const effectiveStartup = args.startup ?? agentStartup?.startup ?? draftStartup?.startup - const effectiveStartupFollowup = agentStartup?.followup - const effectiveCreatedWithAgent = args.startup - ? args.createdWithAgent - : (agentStartup?.agent ?? - draftStartup?.agent ?? - (requestedAgentEnabled ? requestedAgent : undefined)) - const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste - if (isFolderRepo(repo)) { - const now = Date.now() - const settings = createSettings - const instanceId = randomUUID() - const worktreeId = getRuntimeFolderWorkspaceInstanceId(repo, instanceId) - const displayNameRequest = resolveWorktreeCreateDisplayNameRequest( - args.displayName, - args.displayNameKind, - args.name, - args.cliProvenance?.kind === 'created-by-cli', - args.nameWasGenerated === true - ) - const resolvedFolderDisplayName = displayNameRequest.value - const meta = this.store.setWorktreeMeta(worktreeId, { - instanceId, - ...getProjectHostSetupWorktreeMeta(this.store.getProjectHostSetups?.() ?? [], repo), - displayName: resolvedFolderDisplayName ?? args.name, - ...(displayNameRequest.kind === 'user' && resolvedFolderDisplayName - ? { displayNameIsPinned: true } - : {}), - lastActivityAt: now, - createdAt: now, - orcaCreatedAt: now, - orcaCreationSource: 'runtime', - orcaCreationWorkspaceLayout: { - path: settings.workspaceDir, - nestWorkspaces: settings.nestWorkspaces - }, - ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), - ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), - creatorProvenance: args.creatorProvenance ?? { kind: 'host' }, - ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), - ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), - ...(args.linkedLinearIssue !== undefined - ? { linkedLinearIssue: args.linkedLinearIssue } - : {}), - ...(args.linkedLinearIssueWorkspaceId !== undefined - ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } - : {}), - ...(args.linkedLinearIssueOrganizationUrlKey !== undefined - ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } - : {}), - ...(args.linkedGitLabIssue !== undefined - ? { linkedGitLabIssue: args.linkedGitLabIssue } - : {}), - ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), - ...(args.linkedBitbucketPR !== undefined - ? { linkedBitbucketPR: args.linkedBitbucketPR } - : {}), - ...(args.linkedAzureDevOpsPR !== undefined - ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } - : {}), - ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), - ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), - ...(args.linkedTaskSourceContext !== undefined - ? { linkedTaskSourceContext: args.linkedTaskSourceContext } - : {}), - ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), - ...(args.comment !== undefined ? { comment: args.comment } : {}), - ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), - ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) - }) - const worktree = mergeRuntimeFolderWorkspace(repo, worktreeId, meta) - this.invalidateResolvedWorktreeCache() - this.notifyWorktreesChanged(repo.id) - this.emitWorktreeLifecycle({ - kind: 'created', - worktreeId: worktree.id, - path: worktree.path, - branch: worktree.branch - }) - const shouldActivate = args.activate === true || args.runHooks === true - let warning: string | undefined - let didSpawnStartup = false - let startupTerminal: CreateWorktreeResult['startupTerminal'] - if (effectiveStartup && this.ptyController?.spawn) { - try { - const startupTrustAgent = effectiveDraftPaste?.agent ?? effectiveCreatedWithAgent - if (startupTrustAgent) { - await this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktree.path) - } - const terminal = await this.createTerminal(`id:${worktree.id}`, { - command: effectiveStartup.command, - env: effectiveStartup.env, - ...(effectiveStartup.launchConfig - ? { launchConfig: effectiveStartup.launchConfig } - : {}), - ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), - ...(effectiveStartup.viewMode ? { viewMode: effectiveStartup.viewMode } : {}), - startupCommandDelivery: effectiveStartup.startupCommandDelivery, - telemetry: effectiveStartup.telemetry, - ...ownerSurfacing(shouldActivate) - }) - if (effectiveDraftPaste) { - this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) - } - if (effectiveStartupFollowup) { - this.sendStartupFollowupWhenReady(terminal.handle, effectiveStartupFollowup) - } - didSpawnStartup = true - startupTerminal = { - spawned: true, - handle: terminal.handle, - ...(terminal.tabId ? { tabId: terminal.tabId } : {}), - ...(terminal.paneKey ? { paneKey: terminal.paneKey } : {}), - ...(terminal.ptyId ? { ptyId: terminal.ptyId } : {}), - surface: 'background' - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - warning = `Failed to create the startup terminal for ${worktree.path}: ${message}` - console.warn(`[worktree-create] ${warning}`) - } - } - if (shouldActivate) { - if (effectiveStartup && !didSpawnStartup) { - this.notifyActivateWorktree(repo.id, worktree.id, { - startup: effectiveStartup, - navigationTarget: args.navigation - }) - } else { - this.notifyActivateWorktree(repo.id, worktree.id, { - navigationTarget: args.navigation - }) - } - } else if (this.ptyController?.spawn && !didSpawnStartup) { - try { - await this.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - warning = warning - ? `${warning} Also failed to create the initial terminal for ${worktree.path}: ${message}` - : `Failed to create the initial terminal for ${worktree.path}: ${message}` - console.warn(`[worktree-create] ${warning}`) - } - } - return { - worktree: { - ...worktree, - parentWorktreeId: null, - childWorktreeIds: [], - lineage: null, - git: { - path: worktree.path, - head: worktree.head, - branch: worktree.branch, - isBare: worktree.isBare, - isMainWorktree: worktree.isMainWorktree - } - }, - ...(startupTerminal ? { startupTerminal } : {}), - ...(warning ? { warning } : {}) - } - } - const lineageInput = - args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined - const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput) - if (repo.connectionId) { - const result = await this.createManagedRemoteWorktree(repo, { - ...args, - activate: args.activate, - ...(effectiveStartup ? { startup: effectiveStartup } : {}), - ...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}), - ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), - ...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {}) - }) - const recordedLineage = this.recordCreatedWorktreeLineage(result.worktree, lineageResolution) - this.emitWorktreeLifecycle({ - kind: 'created', - worktreeId: result.worktree.id, - path: result.worktree.path, - branch: result.worktree.branch - }) - return { - ...result, - worktree: { - ...result.worktree, - parentWorktreeId: recordedLineage.lineage?.parentWorktreeId ?? null, - childWorktreeIds: result.worktree.childWorktreeIds ?? [], - lineage: recordedLineage.lineage, - workspaceLineage: recordedLineage.workspaceLineage - }, - ...(lineageInput - ? { - lineage: recordedLineage.lineage, - workspaceLineage: recordedLineage.workspaceLineage, - warnings: recordedLineage.warnings - } - : {}) - } - } - const settings = createSettings - const worktreePathSettings = getWorktreePathSettings( - repo, - settings, - getWorktreeMirrorDistro(this.requireStore(), repo) - ) - const localGitExecOptions = getLocalProjectGitExecOptions(this.requireStore(), repo) - const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(this.requireStore(), repo) - const hasLocalWorktreeGitOptions = hasLocalGitOptions(localWorktreeGitOptions) - const localWorktreeGitOptionArgs: [] | [{ wslDistro?: string }] = hasLocalWorktreeGitOptions - ? [localWorktreeGitOptions] - : [] - const addProjectGitOptions = (options?: AddWorktreeOptions): AddWorktreeOptions | undefined => { - if (!hasLocalWorktreeGitOptions) { - return options - } - return { ...options, ...localWorktreeGitOptions } - } - const hostedReviewExecutionContext = this.getHostedReviewExecutionOptions(repo) - let effectiveRequestedName = args.name - const displayNameRequest = resolveWorktreeCreateDisplayNameRequest( - args.displayName, - args.displayNameKind, - args.name, - args.cliProvenance?.kind === 'created-by-cli', - args.nameWasGenerated === true - ) - const requestedDisplayName = displayNameRequest.value - const sanitizedName = sanitizeWorktreeName(args.name) - let effectiveSanitizedName = sanitizedName - // Username and base resolution are independent read-only probes. Starting - // both before awaiting removes one serial git/config round trip from create. - const usernamePromise = - !args.branchNameOverride && settings.branchPrefix === 'git-username' - ? resolveLocalGitUsername(repo.path) - : Promise.resolve('') - const baseBranchPromise = resolveWorktreeCreateBase({ - requestedBaseBranch: args.baseBranch, - repoWorktreeBaseRef: repo.worktreeBaseRef, - resolveDefaultBaseRef: () => - hasLocalWorktreeGitOptions - ? resolveDefaultBaseRefWithLocalGit(localGitExecOptions) - : getBaseRefDefault(repo.path), - isBaseUsable: async (baseBranchCandidate) => { - const remoteTrackingBase = await this.resolveRemoteTrackingBase( - repo.path, - baseBranchCandidate, - ...localWorktreeGitOptionArgs - ) - if (remoteTrackingBase) { - if ( - await this.hasRemoteTrackingRef( - repo.path, - remoteTrackingBase, - ...localWorktreeGitOptionArgs - ) - ) { - return true - } - return hasLocalWorktreeBaseRef( - repo.path, - baseBranchCandidate, - hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} - ) - } - return hasLocalWorktreeBaseRef( - repo.path, - baseBranchCandidate, - hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} - ) - } - }) - const [username, baseBranch] = await Promise.all([usernamePromise, baseBranchPromise]) - if (!baseBranch) { - // Why: a null default means no suitable ref exists; fail clearly instead - // of handing Git a fabricated origin/main ref. - throw new Error( - 'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.' - ) - } - - const workspaceRoot = computeWorkspaceRoot(repo.path, worktreePathSettings) - // Why: CLI-managed WSL worktrees live under ~/orca/workspaces inside the - // distro filesystem through computeWorkspaceRoot. If home lookup fails, - // still validate against the effective workspace dir. - let branchName = '' - let checkoutExistingBranch = false - let selectedExistingLocalBranchName: string | null = null - let branchConflictKind: 'local' | 'remote' | null = null - let worktreePath = '' - let worktreePathResolved = false - const shouldRetireGeneratedName = - args.nameWasGenerated === true && isGeneratedWorktreeCreateName(sanitizedName) - const retiredNameRegistry = shouldRetireGeneratedName - ? await getRetiredNameRegistryForRepo(this.store, repo, this.store.getRepos(), settings) - : null - const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null - // Why: runtime/mobile create-from-review callers should get a new workspace - // even when the PR branch or review branch name is already in use. - for ( - let suffix = 1, attempts = 0; - attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; - suffix += 1 - ) { - effectiveSanitizedName = shouldRetireGeneratedName - ? getGeneratedWorktreeCreateCandidate( - sanitizedName, - suffix, - retiredNameRegistry?.exhaustedTiers - ) - : getWorktreeCreateCandidate(sanitizedName, suffix) - effectiveRequestedName = shouldRetireGeneratedName - ? effectiveSanitizedName - : args.name.trim() - ? getWorktreeCreateCandidate(args.name, suffix) - : effectiveSanitizedName - if (isRetiredName?.(effectiveSanitizedName)) { - continue - } - attempts += 1 - branchName = await resolveCreateBranchName( - repo.path, - selectedExistingLocalBranchName ?? - getBranchNameOverrideCandidate(args.branchNameOverride, suffix), - effectiveSanitizedName, - settings, - username, - localWorktreeGitOptions - ) - checkoutExistingBranch = await canCheckoutExistingLocalBranch( - repo.path, - branchName, - baseBranch, - ...localWorktreeGitOptionArgs - ) - if (checkoutExistingBranch && !selectedExistingLocalBranchName) { - // Why: once a user-selected branch is safe to reuse, path retries should - // keep that branch exact instead of creating a sibling branch. - selectedExistingLocalBranchName = branchName - } - branchConflictKind = checkoutExistingBranch - ? null - : await getBranchConflictKind( - repo.path, - branchName, - baseBranch, - ...localWorktreeGitOptionArgs - ) - const allowedPushTargetRemoteConflict = - branchConflictKind && - isAllowedPushTargetRemoteConflict(branchConflictKind, branchName, args) - let selectedReviewConflictMatched = false - if (branchConflictKind) { - if (allowedPushTargetRemoteConflict) { - let existingPR: Awaited> | null = null - const selectedReview = getSelectedReviewBranch(args) - if (selectedReview?.provider === 'github') { - try { - existingPR = await getLocalGitHubPrForBranch( - repo.path, - branchName, - localWorktreeGitOptions - ) - } catch { - // Retry with a suffixed branch when selected review verification is unavailable. - } - if (isMatchingSelectedGitHubPr(existingPR, args, branchName)) { - branchConflictKind = null - selectedReviewConflictMatched = true - } - } else if (selectedReview) { - const hostedReview = await getSelectedHostedReviewForBranch( - repo, - branchName, - args, - hostedReviewExecutionContext - ).catch(() => null) - if (hostedReview?.matchesSelected) { - branchConflictKind = null - selectedReviewConflictMatched = true - } - } - } - if (branchConflictKind) { - continue - } - } - - if (!checkoutExistingBranch && !selectedReviewConflictMatched) { - let existingPR: Awaited> | null = null - try { - existingPR = await getLocalGitHubPrForBranch( - repo.path, - branchName, - localWorktreeGitOptions - ) - } catch { - // Why: GitHub reachability should not block creating a suffixed - // workspace; git conflicts still decide whether this candidate works. - } - if (existingPR && !isMatchingSelectedGitHubPr(existingPR, args, branchName)) { - continue - } - } - worktreePath = ensurePathWithinWorkspace( - computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings), - workspaceRoot - ) - if (!(await pathExists(worktreePath))) { - worktreePathResolved = true - break - } - } - if (!worktreePathResolved) { - if (branchConflictKind) { - throw new Error( - `Branch "${branchName}" already exists ${branchConflictKind === 'local' ? 'locally' : 'on a remote'}.` - ) - } - throw new Error( - `Could not find an available worktree path for "${sanitizedName}". Pick a different worktree name.` - ) - } - let remoteTrackingBase = await this.resolveRemoteTrackingBase( - repo.path, - baseBranch, - ...localWorktreeGitOptionArgs - ) - if (remoteTrackingBase) { - const [hadRemoteTrackingBaseRef, hasNamedLocalBaseRef] = await Promise.all([ - this.hasRemoteTrackingRef(repo.path, remoteTrackingBase, ...localWorktreeGitOptionArgs), - hasLocalWorktreeBaseRef( - repo.path, - baseBranch, - hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} - ) - ]) - const hasLocalBaseRef = hadRemoteTrackingBaseRef || hasNamedLocalBaseRef - if (!hadRemoteTrackingBaseRef && hasLocalBaseRef) { - remoteTrackingBase = null - } else { - const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh( - repo.path, - remoteTrackingBase, - ...localWorktreeGitOptionArgs - ) - if (!refreshResult.ok && !hadRemoteTrackingBaseRef) { - // Why: only block creation when the refresh failed AND there is no - // usable local base ref to fall back on. If a local remote-tracking ref - // already exists, `git worktree add` can create from it — a possibly - // stale but valid base — so a transient offline/auth failure must not - // make the workspace uncreatable. The compare-to-base view reflects any - // drift once the remote is reachable again. - throw new Error( - `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.` - ) - } - if ( - !hadRemoteTrackingBaseRef && - !(await this.hasRemoteTrackingRef( - repo.path, - remoteTrackingBase, - ...localWorktreeGitOptionArgs - )) - ) { - throw new Error(`Base ref "${baseBranch}" was not found after fetching.`) - } - } - } else if ( - !(await hasLocalWorktreeBaseRef( - repo.path, - baseBranch, - hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} - )) - ) { - // Why: local bases keep legacy best-effort fetch behavior. Verified PR - // SHA bases already have the commit object needed by `git worktree add`. - try { - await this.fetchRemoteWithCache(repo.path, 'origin', ...localWorktreeGitOptionArgs) - } catch { - // Why: belt-and-suspenders. fetchRemoteWithCache already logs and does - // not throw; the outer try/catch guarantees create-path tolerance even - // if future refactors change that contract. - } - } - - const sparseDirectories = args.sparseCheckout - ? normalizeSparseDirectories(args.sparseCheckout.directories) - : [] - if (args.sparseCheckout && sparseDirectories.length === 0) { - throw new Error('Sparse checkout requires at least one repo-relative directory.') - } - - let preparedPushTarget: GitPushTarget | undefined - if (args.pushTarget) { - // Why: fork-PR worktrees created through a remote runtime need the same - // upstream target setup as local desktop creates, or Push would publish - // to the wrong remote after the client/server split. - preparedPushTarget = await prepareWorktreePushTarget( - repo.path, - args.pushTarget, - this.store, - repo.id, - localWorktreeGitOptions - ) - } - - const suggestLocalBaseRefUpdate = - !settings.refreshLocalBaseRefOnWorktreeCreate && - !settings.localBaseRefSuggestionDismissed && - Boolean(remoteTrackingBase) - const remoteTrackingBaseOption = remoteTrackingBase ? { remoteTrackingBase } : undefined - const existingBranchOption = { - checkoutExistingBranch, - ...remoteTrackingBaseOption, - ...(suggestLocalBaseRefUpdate ? { suggestLocalBaseRefUpdate } : {}) - } - const defaultAddWorktreeOption = addProjectGitOptions() - const preparedWorktreeOptions = suggestLocalBaseRefUpdate - ? addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }) - : remoteTrackingBaseOption - ? addProjectGitOptions(remoteTrackingBaseOption) - : defaultAddWorktreeOption - let addResult: AddWorktreeResult - try { - const preparedResult = - sparseDirectories.length === 0 && !checkoutExistingBranch - ? await consumePreparedWorktreeCreate({ - repoPath: repo.path, - workspaceRoot, - worktreePath, - branch: branchName, - baseBranch, - refreshLocalBaseRef: settings.refreshLocalBaseRefOnWorktreeCreate, - ...(preparedWorktreeOptions ? { options: preparedWorktreeOptions } : {}) - }) - : null - addResult = - preparedResult ?? - (await (sparseDirectories.length > 0 - ? checkoutExistingBranch - ? addSparseWorktree( - repo.path, - worktreePath, - branchName, - sparseDirectories, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate, - addProjectGitOptions(existingBranchOption) - ) - : suggestLocalBaseRefUpdate - ? addSparseWorktree( - repo.path, - worktreePath, - branchName, - sparseDirectories, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate, - addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }) - ) - : remoteTrackingBaseOption - ? addSparseWorktree( - repo.path, - worktreePath, - branchName, - sparseDirectories, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate, - addProjectGitOptions(remoteTrackingBaseOption) - ) - : defaultAddWorktreeOption - ? addSparseWorktree( - repo.path, - worktreePath, - branchName, - sparseDirectories, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate, - defaultAddWorktreeOption - ) - : addSparseWorktree( - repo.path, - worktreePath, - branchName, - sparseDirectories, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - ) - : checkoutExistingBranch - ? addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate, - false, - addProjectGitOptions(existingBranchOption) - ) - : suggestLocalBaseRefUpdate - ? addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate, - false, - addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }) - ) - : remoteTrackingBaseOption - ? addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate, - false, - addProjectGitOptions(remoteTrackingBaseOption) - ) - : defaultAddWorktreeOption - ? addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate, - false, - defaultAddWorktreeOption - ) - : addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - ))) ?? - {} - } catch (error) { - if (shouldRetireGeneratedName && failedWorktreeCreationNeedsRetirement(error)) { - await retireGeneratedWorktreeName(this.store, repo, settings, effectiveSanitizedName) - } - throw error - } - - // Why: fallible metadata work after creation must not leave a real workspace name reusable. - if (shouldRetireGeneratedName) { - await retireGeneratedWorktreeName(this.store, repo, settings, effectiveSanitizedName) - } - - let configuredPushTarget: GitPushTarget | undefined - if (preparedPushTarget) { - configuredPushTarget = await configureCreatedWorktreePushTarget( - worktreePath, - branchName, - preparedPushTarget, - localWorktreeGitOptions - ) - } - - const { created } = await resolveCreatedWorktree( - repo.path, - worktreePath, - branchName, - hasLocalWorktreeGitOptions ? localWorktreeGitOptions : undefined - ) - - const worktreeId = `${repo.id}::${created.path}` - const now = Date.now() - // Why: PR/MR-created worktrees can start from a head ref/SHA while Source - // Control must compare against the review target branch. - const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch - const displayNameMeta = resolveWorktreeCreateDisplayNameMeta( - requestedDisplayName, - branchName, - displayNameRequest.kind, - { requestedName: effectiveRequestedName, sanitizedName: effectiveSanitizedName } - ) - const meta = this.store.setWorktreeMeta(worktreeId, { - // Why: worktree IDs are path-derived. If a path is deleted outside Orca - // and later recreated, creation must mint a fresh instance identity so - // stale lineage records tied to the old occupant fail validation. - instanceId: randomUUID(), - ...getProjectHostSetupWorktreeMeta(this.store.getProjectHostSetups?.() ?? [], repo), - lastActivityAt: now, - // See createRemoteWorktree: createdAt grants the new worktree a grace - // window in Recent sort so ambient PTY bumps in OTHER worktrees can't - // push it down before the user has had a chance to notice it. Smart-sort - // uses max(lastActivityAt, createdAt + CREATE_GRACE_MS). - createdAt: now, - orcaCreatedAt: now, - orcaCreationSource: 'runtime', - orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), - ...displayNameMeta, - baseRef: metadataBaseRef, - ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), - ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), - ...(sparseDirectories.length > 0 - ? { - sparseDirectories, - sparseBaseRef: metadataBaseRef, - sparsePresetId: args.sparseCheckout?.presetId - } - : {}), - ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), - ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), - ...(args.linkedLinearIssue !== undefined - ? { linkedLinearIssue: args.linkedLinearIssue } - : {}), - ...(args.linkedLinearIssueWorkspaceId !== undefined - ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } - : {}), - ...(args.linkedLinearIssueOrganizationUrlKey !== undefined - ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } - : {}), - ...(args.linkedGitLabIssue !== undefined - ? { linkedGitLabIssue: args.linkedGitLabIssue } - : {}), - ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), - ...(args.linkedBitbucketPR !== undefined - ? { linkedBitbucketPR: args.linkedBitbucketPR } - : {}), - ...(args.linkedAzureDevOpsPR !== undefined - ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } - : {}), - ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), - ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), - ...(args.linkedTaskSourceContext !== undefined - ? { linkedTaskSourceContext: args.linkedTaskSourceContext } - : {}), - ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), - ...(args.pendingFirstAgentMessageRename === true && effectiveCreatedWithAgent - ? { pendingFirstAgentMessageRename: true } - : {}), - ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), - ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), - creatorProvenance: args.creatorProvenance ?? { kind: 'host' }, - ...(args.comment !== undefined ? { comment: args.comment } : {}), - ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), - ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) - }) - const worktree = { - ...mergeWorktree(repo.id, created, meta), - hostId: meta.hostId ?? getRepoExecutionHostId(repo) - } - const { - lineage, - workspaceLineage, - warnings: lineageWarnings - } = this.recordCreatedWorktreeLineage(worktree, lineageResolution) - - const symlinkPaths = repo.symlinkPaths ?? [] - if (symlinkPaths.length > 0) { - await createWorktreeLinkedPaths(repo.path, created.path, symlinkPaths) - } - - // Why: these discoveries are read-only; overlap them, but keep the - // shared-path mutation ahead of include copies below. - const [sharedDirectories, worktreeIncludePaths] = await Promise.all([ - resolveWorktreeSharedDirectories(repo.path, localWorktreeGitOptions), - resolveWorktreeIncludePaths(repo.path, localWorktreeGitOptions) - ]) - if (sharedDirectories.length > 0) { - await createWorktreeSharedPaths(repo.path, created.path, sharedDirectories) - } - - // Why: project-level `.worktreeinclude` travels with the repo (issue #7549); copy semantics - // (never symlink) so each worktree owns its files. Paths already linked above are skipped. - let includeCopyWarning: string | undefined - if (worktreeIncludePaths.length > 0) { - const skippedIncludePaths = await createWorktreeCopiedPaths( - repo.path, - created.path, - worktreeIncludePaths - ) - includeCopyWarning = formatWorktreeIncludeCopyWarning(skippedIncludePaths) - if (includeCopyWarning) { - console.warn(`[worktree-include] ${includeCopyWarning}`) - } - } - - let setup: CreateWorktreeResult['setup'] - let warning: string | undefined = includeCopyWarning - // Why: CLI-created worktrees do not have a renderer preview to mismatch - // against. Trust is granted by the direct CLI invocation (`--run-hooks`), - // so loading the setup hook from the created worktree is intentional here. - const yamlHooks = loadHooks(worktreePath) - const hooks = getEffectiveHooks(repo, worktreePath) - // Why: setupDecision lets mobile/CLI callers control whether the setup - // script runs. 'skip' suppresses it, 'run' forces it, 'inherit' (default) - // defers to the repo's orca.yaml setupRunPolicy. runHooks === true maps - // to 'run' for backwards compatibility with the desktop create flow. - const effectiveDecision = args.runHooks ? 'run' : (args.setupDecision ?? 'inherit') - let defaultTabs: CreateWorktreeResult['defaultTabs'] - try { - defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, effectiveDecision) - } catch (error) { - console.warn(`[hooks] default tab commands skipped for ${worktreePath}:`, error) - defaultTabs = yamlHooks?.defaultTabs - ? { tabs: yamlHooks.defaultTabs, runCommands: false } - : undefined - } - const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision) - // Why: the in-process hook uses a hardcoded cmd/bash shell, so it can only run - // when nothing downstream is able to launch the shell-aware runner script. - let didStartInProcessSetupHook = false - if (shouldRunSetup && hooks?.scripts.setup) { - const shouldUseSetupRunner = - this.authoritativeWindowId !== null || - Boolean(effectiveStartup) || - Boolean(this.ptyController?.spawn) - if (shouldUseSetupRunner) { - try { - // Why: setup+startup must share the terminal runner path even without - // a renderer window, so the startup shell can wait on setup completion - // and windowless creates resolve the same Windows setup shell. - const runtimeTarget = this.getLocalGitExecutionOptionArgs(repo)[0] - setup = createSetupRunnerScript( - repo, - worktreePath, - hooks.scripts.setup, - runtimeTarget, - resolveSetupRunnerShell(settings), - yamlHooks?.setupAgentStartupPolicy - ) - } catch (error) { - // Why: the git worktree is already real at this point. If runner - // generation fails, keep creation successful and surface the problem in - // logs rather than pretending the worktree was never created. - console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error) - } - } else { - didStartInProcessSetupHook = true - void runHook( - 'setup', - worktreePath, - repo, - worktreePath, - this.getLocalGitExecutionOptionArgs(repo)[0] - ).then((result) => { - if (!result.success) { - console.error(`[hooks] setup hook failed for ${worktreePath}:`, result.output) - } - }) - } - } else if (hooks?.scripts.setup && effectiveDecision !== 'skip') { - // Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in. - const setupSkipped = `orca.yaml setup hook skipped for ${worktreePath}; pass --setup run to run it.` - warning = warning ? `${warning} Also ${setupSkipped}` : setupSkipped - console.warn(`[hooks] ${setupSkipped}`) - } - - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(repo.id) - // Why: the filesystem-auth layer maintains a separate cache of registered - // worktree roots used by git IPC handlers (branchCompare, diff, status, etc.) - // to authorize paths. Without invalidating it here, CLI-created worktrees - // are not recognized and all git operations fail with "Access denied: - // unknown repository or worktree path". - invalidateAuthorizedRootsCache() - - this.notifyWorktreesChanged(repo.id) - const shouldActivate = args.activate === true || args.runHooks === true - let didSpawnStartup = false - // Why: tracks whether runtime itself launched the setup script (via - // provisionManagedWorktreeTerminals). When true, renderer activation and the - // RPC return value must omit setup so the client does not spawn it a second - // time. Mirrors the wait-for-agent setup contract from #6298. - let didSpawnSetup = false - let setupTerminalHandle: string | null = null - let startupTerminalHandle: string | null = null - let startupTerminalTabId: string | null = null - let startupTerminalPaneKey: string | null = null - let startupTerminalPtyId: string | null = null - - let sequencedStartup = effectiveStartup - let wrappedSetupCommandStr: string | undefined - if (effectiveStartup && setup?.waitForAgentStartup === true) { - const platform = getSetupRunnerCommandPlatformForLaunch( - setup, - process.platform === 'win32' ? 'windows' : 'posix' - ) - const sequenced = createSequencedSetupAgentCommands({ - runnerScriptPath: setup.runnerScriptPath, - startupCommand: effectiveStartup.command, - platform, - shell: setup.shell - }) - sequencedStartup = { - ...effectiveStartup, - command: sequenced.startupCommand, - ...(sequenced.startupEnv - ? { env: { ...effectiveStartup.env, ...sequenced.startupEnv } } - : {}) - } - wrappedSetupCommandStr = sequenced.setupCommand - } - - if (sequencedStartup && this.ptyController?.spawn) { - try { - // Why: automation startup must not depend on a renderer TerminalPane - // mounting. Runtime-spawned PTYs run immediately and the UI adopts the - // session later, matching `orca terminal create` background semantics. - const startupTrustAgent = effectiveDraftPaste?.agent ?? effectiveCreatedWithAgent - if (startupTrustAgent) { - await this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktreePath) - } - const terminal = await this.createTerminal(`id:${worktree.id}`, { - command: sequencedStartup.command, - ...(setup && effectiveStartup - ? { claudeAgentTeamsSourceCommand: effectiveStartup.command } - : {}), - env: sequencedStartup.env, - ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), - ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), - ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), - startupCommandDelivery: sequencedStartup.startupCommandDelivery, - telemetry: sequencedStartup.telemetry, - ...ownerSurfacing(shouldActivate) - }) - if (effectiveDraftPaste) { - this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) - } - if (effectiveStartupFollowup) { - this.sendStartupFollowupWhenReady(terminal.handle, effectiveStartupFollowup) - } - didSpawnStartup = true - startupTerminalHandle = terminal.handle - startupTerminalTabId = terminal.tabId ?? null - startupTerminalPaneKey = terminal.paneKey ?? null - startupTerminalPtyId = terminal.ptyId ?? null - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - warning = warning - ? `${warning} Also failed to create the startup terminal for ${worktreePath}: ${message}` - : `Failed to create the startup terminal for ${worktreePath}: ${message}` - console.warn(`[worktree-create] ${warning}`) - } - } - if (shouldActivate) { - // Why: plain CLI creates should not steal the user's current workspace. - // Explicit activation and hook-running still use renderer activation so - // the user can watch prompts/output in a visible pane. - const runtimeWillProvisionTerminals = didSpawnStartup && Boolean(setup || defaultTabs) - if (runtimeWillProvisionTerminals) { - // Why: once runtime spawned the startup PTY, renderer activation may see - // an existing terminal and skip setup/default tabs. Await provisioning so - // a failed setup spawn falls back to renderer activation (which still - // carries the wrapped command for retry); #6298's wait-for-setup - // guarantee is enforced by the shell marker, not by spawn timing. - const provisioned = await this.provisionManagedWorktreeTerminals({ - worktreeSelector: `id:${worktree.id}`, - worktreeId: worktree.id, - worktreePath, - ...(setup ? { setup } : {}), - ...(defaultTabs ? { defaultTabs } : {}), - primaryTerminalHandle: startupTerminalHandle, - hasStartupTerminal: didSpawnStartup, - setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'), - observeSetupCompletion: args.observeSetupCompletion, - // Why: carry the wait-for-agent wrapped setup command (#6298) so the - // Setup tab runs the same script the sequenced agent waits on. - ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) - }) - didSpawnSetup = provisioned.setupSpawned - setupTerminalHandle = provisioned.setupTerminalHandle - } - // Why: when runtime spawned setup, omit it from activation. When setup - // spawn failed, fall through with the wrapped command so renderer - // activation retries it. - const activationSetup = didSpawnSetup - ? undefined - : setup - ? { - ...setup, - ...(didSpawnStartup && wrappedSetupCommandStr - ? { command: wrappedSetupCommandStr } - : {}) - } - : undefined - const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : defaultTabs - if (effectiveStartup && !didSpawnStartup) { - this.notifyActivateWorktree(repo.id, worktree.id, { - setup: activationSetup, - startup: effectiveStartup, - defaultTabs: activationDefaultTabs, - navigationTarget: args.navigation - }) - } else { - this.notifyActivateWorktree(repo.id, worktree.id, { - setup: activationSetup, - defaultTabs: activationDefaultTabs, - navigationTarget: args.navigation - }) - } - } else if (this.ptyController?.spawn && (setup || defaultTabs || didSpawnStartup)) { - // Why: inactive terminal materialization matches normal worktree creation, - // but setup/default tab failures must not gate automation dispatch. - const provisioning = this.provisionManagedWorktreeTerminals({ - worktreeSelector: `id:${worktree.id}`, - worktreeId: worktree.id, - worktreePath, - ...(setup ? { setup } : {}), - ...(defaultTabs ? { defaultTabs } : {}), - primaryTerminalHandle: startupTerminalHandle, - hasStartupTerminal: didSpawnStartup, - setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'), - observeSetupCompletion: args.observeSetupCompletion, - ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}), - surfaceOwner: false - }) - // Why: runtime owns setup spawning here, so the RPC result must omit setup - // to keep the headless/mobile caller from launching it a second time. - if (args.awaitTerminalProvisioning) { - const provisioned = await provisioning - didSpawnSetup = provisioned.setupSpawned - setupTerminalHandle = provisioned.setupTerminalHandle - } else { - void provisioning - if (setup) { - didSpawnSetup = true - } - } - } else if (this.ptyController?.spawn) { - try { - await this.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - warning = warning - ? `${warning} Also failed to create the initial terminal for ${worktreePath}: ${message}` - : `Failed to create the initial terminal for ${worktreePath}: ${message}` - console.warn(`[worktree-create] ${warning}`) - } - } - const returnedSetup = didSpawnSetup - ? undefined - : setup - ? { - ...setup, - ...(didSpawnStartup && wrappedSetupCommandStr - ? { command: wrappedSetupCommandStr } - : {}) - } - : undefined - this.emitWorktreeLifecycle({ - kind: 'created', - worktreeId: worktree.id, - path: worktree.path, - branch: worktree.branch - }) - return { - worktree: { - ...worktree, - parentWorktreeId: lineage?.parentWorktreeId ?? null, - childWorktreeIds: [], - lineage, - workspaceLineage, - git: created - }, - ...(lineageInput ? { lineage, workspaceLineage, warnings: lineageWarnings } : {}), - ...(returnedSetup ? { setup: returnedSetup } : {}), - ...(args.awaitTerminalProvisioning - ? { - setupReceipt: { - requested: effectiveDecision, - hookFound: Boolean(hooks?.scripts.setup), - startupPolicy: setup?.waitForAgentStartup - ? ('wait-for-setup' as const) - : ('start-immediately' as const), - state: !hooks?.scripts.setup - ? ('not_configured' as const) - : effectiveDecision === 'skip' || !shouldRunSetup - ? ('skipped' as const) - : // Why: the in-process hook is already executing, so reporting - // spawn_failed would strand callers that retry on it. - didSpawnSetup || didStartInProcessSetupHook - ? ('running' as const) - : ('spawn_failed' as const), - ...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {}) - } - } - : {}), - ...(defaultTabs ? { defaultTabs } : {}), - ...(warning ? { warning } : {}), - ...(addResult.localBaseRefRefresh - ? { localBaseRefRefresh: addResult.localBaseRefRefresh } - : {}), - ...(addResult.localBaseRefUpdateSuggestion - ? { localBaseRefUpdateSuggestion: addResult.localBaseRefUpdateSuggestion } - : {}), - ...(didSpawnStartup && startupTerminalHandle - ? { - startupTerminal: { - spawned: true, - handle: startupTerminalHandle, - ...(startupTerminalTabId ? { tabId: startupTerminalTabId } : {}), - ...(startupTerminalPaneKey ? { paneKey: startupTerminalPaneKey } : {}), - ...(startupTerminalPtyId ? { ptyId: startupTerminalPtyId } : {}), - surface: 'background' as const - } - } - : {}) - } - } - - private async createManagedRemoteWorktree( - repo: Repo, - args: { - name: string - nameWasGenerated?: boolean - baseBranch?: string - compareBaseRef?: string - branchNameOverride?: string - linkedIssue?: number | null - linkedPR?: number | null - linkedLinearIssue?: string - linkedLinearIssueWorkspaceId?: string | null - linkedLinearIssueOrganizationUrlKey?: string | null - linkedGitLabMR?: number | null - linkedGitLabIssue?: number | null - linkedBitbucketPR?: number | null - linkedAzureDevOpsPR?: number | null - linkedGiteaPR?: number | null - linkedWorkItem?: WorkspaceLinkedItem | null - linkedTaskSourceContext?: TaskSourceContext | null - comment?: string - displayName?: string - displayNameKind?: CreateWorktreeArgs['displayNameKind'] - workspaceStatus?: string - manualOrder?: number - sparseCheckout?: { directories: string[]; presetId?: string } - pushTarget?: GitPushTarget - runHooks?: boolean - activate?: boolean - navigation?: RuntimeNavigationTarget - setupDecision?: 'run' | 'skip' | 'inherit' - awaitTerminalProvisioning?: boolean - observeSetupCompletion?: boolean - createdWithAgent?: TuiAgent - pendingFirstAgentMessageRename?: boolean - automationProvenance?: AutomationWorkspaceProvenance - cliProvenance?: CliWorkspaceProvenance - startup?: WorktreeStartupLaunch - startupFollowup?: WorktreeStartupFollowup - startupDraftPaste?: WorktreeStartupDraftPaste - } - ): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - - // Why: runtime/mobile callers do not own a renderer BrowserWindow, but the - // SSH create helper only uses it for progress and change notifications. - // Runtime emits those through RuntimeNotifier after the create succeeds. - const headlessWindow = { - isDestroyed: () => false, - webContents: { send: () => undefined } - } as unknown as BrowserWindow - - const result = await createRemoteWorktree( - { - repoId: repo.id, - name: args.name, - ...(args.nameWasGenerated === true ? { nameWasGenerated: true } : {}), - ...(args.displayName ? { displayName: args.displayName } : {}), - ...(args.displayNameKind ? { displayNameKind: args.displayNameKind } : {}), - ...(args.baseBranch ? { baseBranch: args.baseBranch } : {}), - ...(args.compareBaseRef ? { compareBaseRef: args.compareBaseRef } : {}), - ...(args.branchNameOverride ? { branchNameOverride: args.branchNameOverride } : {}), - ...(args.runHooks ? { setupDecision: 'run' as const } : {}), - ...(!args.runHooks && args.setupDecision ? { setupDecision: args.setupDecision } : {}), - ...(args.sparseCheckout ? { sparseCheckout: args.sparseCheckout } : {}), - ...(args.linkedIssue != null ? { linkedIssue: args.linkedIssue } : {}), - ...(args.linkedPR != null ? { linkedPR: args.linkedPR } : {}), - ...(args.linkedLinearIssue ? { linkedLinearIssue: args.linkedLinearIssue } : {}), - ...(args.linkedLinearIssueWorkspaceId !== undefined - ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } - : {}), - ...(args.linkedLinearIssueOrganizationUrlKey !== undefined - ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } - : {}), - ...(args.linkedGitLabMR != null ? { linkedGitLabMR: args.linkedGitLabMR } : {}), - ...(args.linkedGitLabIssue != null ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), - ...(args.linkedBitbucketPR != null ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}), - ...(args.linkedAzureDevOpsPR != null - ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } - : {}), - ...(args.linkedGiteaPR != null ? { linkedGiteaPR: args.linkedGiteaPR } : {}), - ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), - ...(args.linkedTaskSourceContext !== undefined - ? { linkedTaskSourceContext: args.linkedTaskSourceContext } - : {}), - ...(args.pushTarget ? { pushTarget: args.pushTarget } : {}), - ...(args.workspaceStatus ? { workspaceStatus: args.workspaceStatus as never } : {}), - ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), - ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), - ...(args.pendingFirstAgentMessageRename === true - ? { pendingFirstAgentMessageRename: true } - : {}), - ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), - ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}) - }, - repo, - this.store as unknown as Store, - headlessWindow - ) - - if (args.comment !== undefined) { - this.store.setWorktreeMeta(result.worktree.id, { comment: args.comment }) - result.worktree.comment = args.comment - } - - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(repo.id) - this.notifyWorktreesChanged(repo.id) - - const shouldActivate = args.activate === true || args.runHooks === true - let warning = result.warning - let didSpawnStartup = false - // Why: same no-double-spawn contract as the local path — once runtime - // provisions setup, omit it from activation and the RPC result. - let didSpawnSetup = false - let setupTerminalHandle: string | null = null - let startupTerminalHandle: string | null = null - let startupTerminalTabId: string | null = null - let startupTerminalPaneKey: string | null = null - let startupTerminalPtyId: string | null = null - - let sequencedStartup = args.startup - let wrappedSetupCommandStr: string | undefined - if (args.startup && result.setup?.waitForAgentStartup === true) { - const platform = getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix') - const sequenced = createSequencedSetupAgentCommands({ - runnerScriptPath: result.setup.runnerScriptPath, - startupCommand: args.startup.command, - platform, - shell: result.setup.shell - }) - sequencedStartup = { - ...args.startup, - command: sequenced.startupCommand, - ...(sequenced.startupEnv ? { env: { ...args.startup.env, ...sequenced.startupEnv } } : {}) - } - wrappedSetupCommandStr = sequenced.setupCommand - } - - if (sequencedStartup && this.ptyController?.spawn) { - try { - const startupTrustAgent = args.startupDraftPaste?.agent ?? args.createdWithAgent - if (startupTrustAgent) { - await this.markRemoteWorkspaceTrustedForAgent( - startupTrustAgent, - repo.connectionId!, - result.worktree.path - ) - } - const terminal = await this.createTerminal(`path:${result.worktree.path}`, { - command: sequencedStartup.command, - ...(result.setup && args.startup - ? { claudeAgentTeamsSourceCommand: args.startup.command } - : {}), - env: sequencedStartup.env, - ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), - ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), - ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), - startupCommandDelivery: sequencedStartup.startupCommandDelivery, - telemetry: sequencedStartup.telemetry, - ...ownerSurfacing(shouldActivate) - }) - if (args.startupDraftPaste) { - this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste) - } - if (args.startupFollowup) { - this.sendStartupFollowupWhenReady(terminal.handle, args.startupFollowup) - } - didSpawnStartup = true - startupTerminalHandle = terminal.handle - startupTerminalTabId = terminal.tabId ?? null - startupTerminalPaneKey = terminal.paneKey ?? null - startupTerminalPtyId = terminal.ptyId ?? null - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - warning = warning - ? `${warning} Also failed to create the startup terminal for ${result.worktree.path}: ${message}` - : `Failed to create the startup terminal for ${result.worktree.path}: ${message}` - } - } - - if (shouldActivate) { - const runtimeWillProvisionTerminals = - didSpawnStartup && Boolean(result.setup || result.defaultTabs) - if (runtimeWillProvisionTerminals) { - // Why: remote/mobile task creates spawn the agent terminal in runtime, - // so renderer activation may not materialize setup/default tabs. Await so - // a failed setup spawn falls back to renderer activation for retry. - const provisioned = await this.provisionManagedWorktreeTerminals({ - worktreeSelector: `path:${result.worktree.path}`, - worktreeId: result.worktree.id, - worktreePath: result.worktree.path, - ...(result.setup ? { setup: result.setup } : {}), - ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), - primaryTerminalHandle: startupTerminalHandle, - hasStartupTerminal: didSpawnStartup, - setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'), - observeSetupCompletion: args.observeSetupCompletion, - // Why: carry the wait-for-agent wrapped setup command (#6298) so the - // remote Setup tab runs the same script the sequenced agent waits on. - ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) - }) - didSpawnSetup = provisioned.setupSpawned - setupTerminalHandle = provisioned.setupTerminalHandle - } - // Why: omit setup from activation when runtime spawned it; on spawn - // failure fall through with the wrapped command so renderer retries. - const activationSetup = didSpawnSetup - ? undefined - : result.setup - ? { - ...result.setup, - ...(didSpawnStartup && wrappedSetupCommandStr - ? { command: wrappedSetupCommandStr } - : {}) - } - : undefined - const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : result.defaultTabs - if (args.startup && !didSpawnStartup) { - this.notifyActivateWorktree(repo.id, result.worktree.id, { - setup: activationSetup, - startup: args.startup, - defaultTabs: activationDefaultTabs, - navigationTarget: args.navigation - }) - } else { - this.notifyActivateWorktree(repo.id, result.worktree.id, { - setup: activationSetup, - defaultTabs: activationDefaultTabs, - navigationTarget: args.navigation - }) - } - } - - if ( - !shouldActivate && - this.ptyController?.spawn && - (result.setup || result.defaultTabs || didSpawnStartup) - ) { - // Why: inactive terminal materialization matches normal worktree creation, - // but setup/default tab failures must not gate automation dispatch. - const provisioning = this.provisionManagedWorktreeTerminals({ - worktreeSelector: `path:${result.worktree.path}`, - worktreeId: result.worktree.id, - worktreePath: result.worktree.path, - ...(result.setup ? { setup: result.setup } : {}), - ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), - primaryTerminalHandle: startupTerminalHandle, - hasStartupTerminal: didSpawnStartup, - setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'), - observeSetupCompletion: args.observeSetupCompletion, - ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}), - surfaceOwner: false - }) - // Why: runtime owns setup spawning here, so omit setup from the RPC result - // to keep the headless/mobile caller from launching it a second time. - if (args.awaitTerminalProvisioning) { - const provisioned = await provisioning - didSpawnSetup = provisioned.setupSpawned - setupTerminalHandle = provisioned.setupTerminalHandle - } else { - void provisioning - if (result.setup) { - didSpawnSetup = true - } - } - } else if (!shouldActivate && this.ptyController?.spawn) { - try { - await this.createTerminal(`path:${result.worktree.path}`, { surfaceOwner: false }) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - warning = warning - ? `${warning} Also failed to create the initial terminal for ${result.worktree.path}: ${message}` - : `Failed to create the initial terminal for ${result.worktree.path}: ${message}` - } - } - - const returnedSetup = didSpawnSetup - ? undefined - : result.setup - ? { - ...result.setup, - ...(didSpawnStartup && wrappedSetupCommandStr - ? { command: wrappedSetupCommandStr } - : {}) - } - : undefined - const resultForRenderer = returnedSetup - ? { ...result, setup: returnedSetup } - : (() => { - const { setup: _setup, ...resultWithoutSetup } = result - return resultWithoutSetup - })() - - const resultWithStartupTerminal = - didSpawnStartup && startupTerminalHandle - ? { - ...resultForRenderer, - startupTerminal: { - spawned: true, - handle: startupTerminalHandle, - ...(startupTerminalTabId ? { tabId: startupTerminalTabId } : {}), - ...(startupTerminalPaneKey ? { paneKey: startupTerminalPaneKey } : {}), - ...(startupTerminalPtyId ? { ptyId: startupTerminalPtyId } : {}), - surface: 'background' as const - } - } - : resultForRenderer - - const requestedSetupDecision = args.runHooks ? 'run' : (args.setupDecision ?? 'inherit') - const setupReceipt = { - requested: requestedSetupDecision, - hookFound: Boolean(result.setup), - startupPolicy: result.setup?.waitForAgentStartup - ? ('wait-for-setup' as const) - : ('start-immediately' as const), - state: - requestedSetupDecision === 'skip' - ? ('skipped' as const) - : !result.setup - ? ('not_configured' as const) - : didSpawnSetup - ? ('running' as const) - : ('spawn_failed' as const), - ...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {}) - } - const resultWithSetupReceipt = args.awaitTerminalProvisioning - ? { ...resultWithStartupTerminal, setupReceipt } - : resultWithStartupTerminal - return warning ? { ...resultWithSetupReceipt, warning } : resultWithSetupReceipt - } - - /** - * Fetch `remote` in `repoPath`, sharing the 30s freshness window + in-flight - * serialization with all other callers. Never rejects — callers - * log-and-proceed on offline failures (§3.3 Lifecycle). - * - * Why a shared cache on the runtime instead of module-scoped: §7.1 relies on - * one cache for BOTH the renderer create path and `probeWorktreeDrift`. A - * dispatch tick that reuses a just-completed create-path fetch is the - * primary telemetry target; splitting the cache by call-site would double - * the fetch load on warm repos. - */ - async getCanonicalFetchKey( - repoPath: string, - remote: string, - gitOptions: { wslDistro?: string } = {} - ): Promise { - const runtimeKey = gitOptions.wslDistro ? `wsl:${gitOptions.wslDistro}` : 'local' - const cacheKey = `${runtimeKey}::${repoPath}::${remote}` - const cached = this.canonicalFetchKeyCache.get(cacheKey) - if (cached !== undefined) { - setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, cached, REMOTE_FETCH_CACHE_MAX) - return cached - } - let resolved = cacheKey - try { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--path-format=absolute', '--git-common-dir'], - { cwd: repoPath, ...gitOptions } - ) - const commonDir = stdout.trim() - if (commonDir) { - resolved = `${runtimeKey}::${commonDir}::${remote}` - } - } catch { - // Fall through to the caller-provided path. The fetch still runs from - // repoPath; this key only controls cache sharing. - } - setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, resolved, REMOTE_FETCH_CACHE_MAX) - return resolved - } - - private enqueueRemoteFetch( - remoteKey: string, - runFetch: () => Promise - ): Promise { - const previous = this.remoteFetchQueueTail.get(remoteKey) - const promise = previous ? previous.then(runFetch, runFetch) : runFetch() - this.remoteFetchQueueTail.set(remoteKey, promise) - promise.finally(() => { - if (this.remoteFetchQueueTail.get(remoteKey) === promise) { - this.remoteFetchQueueTail.delete(remoteKey) - } - }) - return promise - } - - private getFreshFetchCompletedAt(key: string): number | null { - const lastAt = this.fetchLastCompletedAt.get(key) - if (lastAt === undefined) { - return null - } - if (Date.now() - lastAt < FETCH_FRESHNESS_MS) { - setBoundedMapEntry(this.fetchLastCompletedAt, key, lastAt, REMOTE_FETCH_CACHE_MAX) - return lastAt - } - this.fetchLastCompletedAt.delete(key) - return null - } - - private rememberFreshFetchCompletedAt(key: string, completedAt = Date.now()): void { - setBoundedMapEntry(this.fetchLastCompletedAt, key, completedAt, REMOTE_FETCH_CACHE_MAX) - } - - async getOrStartRemoteFetch( - repoPath: string, - remote: string, - gitOptions: { wslDistro?: string } = {} - ): Promise { - const key = await this.getCanonicalFetchKey(repoPath, remote, gitOptions) - if (this.getFreshFetchCompletedAt(key) !== null) { - // Why: freshness window hit — skip the fetch entirely. Do NOT reuse any - // in-flight promise here; the timestamp is only written on success, so - // hitting this branch means a previous fetch did succeed recently. - return { ok: true } - } - - const existing = this.fetchInflight.get(key) - if (existing) { - // Why: genuine serialization (not check-then-set). Two callers racing - // on the same repo+remote share the single underlying `git fetch`. - return existing - } - - const promise = this.enqueueRemoteFetch(key, () => - gitExecFileAsync(['fetch', remote], { - cwd: repoPath, - ...gitOptions, - // Why: cap the create-path base-ref fetch so a stuck first-auth on - // Windows (GCM prompt) fails fast instead of hanging creation (STA-1292). - timeout: REMOTE_FETCH_TIMEOUT_MS - }) - .then((): RemoteFetchResult => { - // Why (§3.3 Lifecycle): timestamp on success ONLY. Writing on rejection - // would make the freshness cache lie about the last known remote state. - this.rememberFreshFetchCompletedAt(key) - return { ok: true } - }) - .catch((err): RemoteFetchResult => { - // Why: swallow here so awaiters don't throw at the await site. Outer - // create/dispatch paths are already tolerant of offline fetch failure; - // this is the behavioral contract of this helper. - console.warn(`[fetchRemoteWithCache] ${remote} fetch failed for ${repoPath}:`, err) - return { ok: false, errorKind: 'git_error' } - }) - ).finally(() => { - // Why (§3.3 Lifecycle): evict on BOTH success and rejection. A - // rejected entry that survived in the Map would wedge every future - // create on this repo until Orca restarted (the F2 bug §3.3 pins). - this.fetchInflight.delete(key) - }) - - this.fetchInflight.set(key, promise) - return promise - } - - async getOrStartRemoteTrackingBaseRefresh( - repoPath: string, - base: RemoteTrackingBase, - gitOptions: { wslDistro?: string } = {} - ): Promise { - const remoteKey = await this.getCanonicalFetchKey(repoPath, base.remote, gitOptions) - const key = await this.getCanonicalFetchKey( - repoPath, - `base:${base.remote}:${base.branch}`, - gitOptions - ) - if (this.getFreshFetchCompletedAt(key) !== null) { - // Why: exact-base freshness is the safety boundary. A full remote fetch - // can be narrowed by repo refspecs, so it must not prove this branch. - return { ok: true } - } - - const existing = this.fetchInflight.get(key) - if (existing) { - return existing - } - - const promise = this.enqueueRemoteFetch(remoteKey, async () => { - if (this.getFreshFetchCompletedAt(key) !== null) { - return { ok: true } - } - // Why: this exact refresh gates worktree create; ordinary fetches still own maintenance. - return gitExecFileAsync( - [ - ...GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS, - 'fetch', - '--no-tags', - base.remote, - `+refs/heads/${base.branch}:${base.ref}` - ], - { - cwd: repoPath, - ...gitOptions, - // Why: exact remote-base refresh is the network gate for worktree - // creation, so honor repo SSH routing and bound custom wrappers. - useConfiguredSshCommandForNetwork: true, - timeout: REMOTE_FETCH_TIMEOUT_MS - } - ) - .then((): RemoteFetchResult => { - this.rememberFreshFetchCompletedAt(key) - return { ok: true } - }) - .catch((err): RemoteFetchResult => { - console.warn( - `[refreshRemoteTrackingBase] ${base.base} refresh failed for ${repoPath}:`, - err - ) - return { ok: false, errorKind: 'git_error' } - }) - }).finally(() => { - this.fetchInflight.delete(key) - }) - - this.fetchInflight.set(key, promise) - return promise - } - - async fetchRemoteWithCache( - repoPath: string, - remote: string, - gitOptions: { wslDistro?: string } = {} - ): Promise { - await this.getOrStartRemoteFetch(repoPath, remote, gitOptions) - } - - async resolveRemoteTrackingBase( - repoPath: string, - baseBranch: string, - gitOptions: { wslDistro?: string } = {} - ): Promise { - let remotes: string[] - try { - const { stdout } = await gitExecFileAsync(['remote'], { cwd: repoPath, ...gitOptions }) - remotes = stdout - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - } catch { - return null - } - - const remoteRefPrefix = 'refs/remotes/' - const shortBaseBranch = baseBranch.startsWith(remoteRefPrefix) - ? baseBranch.slice(remoteRefPrefix.length) - : baseBranch - const remote = remotes - .filter((candidate) => shortBaseBranch.startsWith(`${candidate}/`)) - .sort((a, b) => b.length - a.length)[0] - if (!remote) { - return null - } - const branch = shortBaseBranch.slice(remote.length + 1) - if (!branch) { - return null - } - return { - remote, - branch, - ref: `refs/remotes/${remote}/${branch}`, - base: `${remote}/${branch}` - } - } - - async hasRemoteTrackingRef( - repoPath: string, - base: RemoteTrackingBase, - gitOptions: { wslDistro?: string } = {} - ): Promise { - try { - await gitExecFileAsync(['rev-parse', '--verify', `${base.ref}^{commit}`], { - cwd: repoPath, - ...gitOptions - }) - return true - } catch { - return false - } - } - - recordOptimisticReconcileToken(worktreeId: string): string { - const token = randomUUID() - this.optimisticReconcileTokens.set(worktreeId, token) - return token - } - - clearOptimisticReconcileToken(worktreeId: string): void { - this.optimisticReconcileTokens.delete(worktreeId) - } - - emitWorktreeBaseStatus(event: WorktreeBaseStatusEvent): void { - this.notifier?.worktreeBaseStatus?.(event) - } - - async reconcileWorktreeBaseStatus(args: { - repoId: string - repoPath: string - worktreeId: string - base: RemoteTrackingBase - branchName: string - createdBaseSha: string - token: string - fetchPromise: Promise - }): Promise { - const stillCurrent = (): boolean => - this.optimisticReconcileTokens.get(args.worktreeId) === args.token - const emit = (event: Omit): void => { - if (!stillCurrent()) { - return - } - this.notifier?.worktreeBaseStatus?.({ - repoId: args.repoId, - worktreeId: args.worktreeId, - base: args.base.base, - remote: args.base.remote, - ...event - }) - } - const resolvePublishRemote = async (): Promise => { - // Why: repos whose canonical publish remote is named differently (e.g. - // `upstream`, a forked `myfork`, or any non-`origin` configuration — - // including multi-segment names like `foo/bar` that this PR's resolver - // explicitly supports) would otherwise silently skip the conflict - // signal. Resolve from git config in priority order: - // 1) branch..pushRemote (explicit per-branch override) - // 2) remote.pushDefault (workspace-wide override) - // 3) branch..remote (tracked remote) - // 4) the base ref's own remote (matches resolveRemoteTrackingBase) - // 5) `origin` as a final fallback. - const tryConfig = async (key: string): Promise => { - try { - const { stdout } = await gitExecFileAsync(['config', '--get', key], { - cwd: args.repoPath - }) - const value = stdout.trim() - return value || null - } catch { - return null - } - } - return ( - (await tryConfig(`branch.${args.branchName}.pushRemote`)) ?? - (await tryConfig('remote.pushDefault')) ?? - (await tryConfig(`branch.${args.branchName}.remote`)) ?? - args.base.remote ?? - 'origin' - ) - } - const checkPublishRemoteConflict = async (): Promise => { - const publishRemote = await resolvePublishRemote() - try { - if (publishRemote !== args.base.remote) { - const result = await this.getOrStartRemoteFetch(args.repoPath, publishRemote) - if (!result.ok) { - return - } - } - await gitExecFileAsync( - ['rev-parse', '--verify', `refs/remotes/${publishRemote}/${args.branchName}^{commit}`], - { cwd: args.repoPath } - ) - if (stillCurrent()) { - this.notifier?.worktreeRemoteBranchConflict?.({ - repoId: args.repoId, - worktreeId: args.worktreeId, - remote: publishRemote, - branchName: args.branchName - }) - } - } catch { - // No publish-remote conflict is the common case; stay quiet. - } - } - - try { - const fetchResult = await args.fetchPromise - if (!stillCurrent()) { - return - } - if (!fetchResult.ok) { - emit({ status: 'unknown' }) - return - } - - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--verify', `${args.base.ref}^{commit}`], - { cwd: args.repoPath } - ) - const postFetchSha = stdout.trim() - if (postFetchSha === args.createdBaseSha) { - emit({ status: 'current' }) - await checkPublishRemoteConflict() - return - } - - try { - await gitExecFileAsync(['merge-base', '--is-ancestor', args.createdBaseSha, postFetchSha], { - cwd: args.repoPath - }) - } catch { - emit({ status: 'base_changed' }) - await checkPublishRemoteConflict() - return - } - - const { stdout: countStdout } = await gitExecFileAsync( - ['rev-list', '--count', `${args.createdBaseSha}..${postFetchSha}`], - { cwd: args.repoPath } - ) - const behind = Number(countStdout.trim()) - if (!Number.isFinite(behind) || behind <= 0) { - emit({ status: 'current' }) - await checkPublishRemoteConflict() - return - } - const { stdout: logStdout } = await gitExecFileAsync( - ['log', '--format=%s', '-n', '5', `${args.createdBaseSha}..${postFetchSha}`], - { cwd: args.repoPath } - ) - emit({ - status: 'drift', - behind, - recentSubjects: logStdout.split('\n').filter((line) => line.trim().length > 0) - }) - await checkPublishRemoteConflict() - } catch (err) { - console.warn(`[worktree-base-status] reconcile failed for ${args.worktreeId}:`, err) - emit({ status: 'unknown' }) - } finally { - // Why: reconcile is one-shot; clear the token so long-lived sessions - // that create many worktrees without removing them don't grow the - // optimisticReconcileTokens map monotonically. Removal still no-ops - // because the entry is already gone. - if (this.optimisticReconcileTokens.get(args.worktreeId) === args.token) { - this.optimisticReconcileTokens.delete(args.worktreeId) - } - } - } - - /** - * Probe how far the worktree's HEAD is behind its tracking remote. Returns - * null when the probe cannot establish a signal (no default base ref, or - * git failure). Dispatch treats null as "unknown — proceed" (§3.1); only - * knowing-and-stale refuses. - */ - async probeWorktreeDrift(worktreeSelector: string): Promise<{ - base: string - behind: number - recentSubjects: string[] - } | null> { - const wt = await this.resolveWorktreeSelector(worktreeSelector) - if (!this.store) { - return null - } - const repo = this.store.getRepos().find((r) => r.id === wt.repoId) - if (!repo) { - return null - } - if (repo.connectionId) { - // Why: the drift probe uses local git helpers. Until the SSH provider - // exposes equivalent remote refs/log plumbing, fail closed to "unknown" - // instead of probing a server path on the desktop filesystem. - return null - } - const localGitExecOptions = getLocalProjectGitExecOptions(this.requireStore(), repo) - const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(this.requireStore(), repo) - const meta = this.store.getWorktreeMeta(wt.id) - const base = - meta?.baseRef || - meta?.sparseBaseRef || - repo.worktreeBaseRef || - (await getBaseRefDefault(repo.path, localWorktreeGitOptions)) - if (!base) { - // Why: brand-new repo with no remote primary — nothing to compare - // against, so there's no meaningful drift to report. Dispatch should - // not block on a probe that cannot form an opinion. - return null - } - const remoteTrackingBase = await this.resolveRemoteTrackingBase( - repo.path, - base, - localWorktreeGitOptions - ) - if (!remoteTrackingBase) { - return null - } - const remote = remoteTrackingBase.remote - // Why: fetch failures are non-fatal; we proceed with whatever the - // last-known remote ref points at. `fetchRemoteWithCache` never throws. - await this.fetchRemoteWithCache(repo.path, remote, localWorktreeGitOptions) - const drift = await getRemoteDrift(wt.path, 'HEAD', base, localGitExecOptions) - if (!drift) { - return null - } - // Why: behind=0 proves HEAD..base is empty, so git log cannot add subjects. - const recentSubjects = - drift.behind > 0 - ? await getRecentDriftSubjects( - wt.path, - 'HEAD', - base, - DRIFT_PROBE_SUBJECT_LIMIT, - localGitExecOptions - ) - : [] - return { base, behind: drift.behind, recentSubjects } - } - - async updateManagedWorktreeMeta( - worktreeSelector: string, - updates: Omit, 'pushTarget'> & { - pushTarget?: GitPushTarget | null - lineage?: { - parentWorktree?: string - noParent?: boolean - } - } - ) { - if (!this.store) { - throw new Error('runtime_unavailable') - } - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - const { lineage, ...metaUpdates } = updates - if (lineage?.parentWorktree) { - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(worktree.repoId) - } - const shouldClearPushTarget = - Object.hasOwn(metaUpdates, 'pushTarget') && metaUpdates.pushTarget === null - const normalizedMetaUpdates: Partial = shouldClearPushTarget - ? { ...metaUpdates, pushTarget: undefined } - : (metaUpdates as Partial) - const persistedMetaUpdates: Partial = omitUndefinedProperties( - normalizedMetaUpdates.displayName !== undefined - ? { - ...normalizedMetaUpdates, - pendingFirstAgentMessageRename: false, - firstAgentMessageRenameError: null - } - : normalizedMetaUpdates - ) - if (shouldClearPushTarget) { - // Why: omitUndefinedProperties protects ordinary optional RPC fields, but - // pushTarget:null is an explicit request to remove persisted target metadata. - persistedMetaUpdates.pushTarget = undefined - } - if (lineage?.noParent === true) { - this.store.removeWorktreeLineage?.(worktree.id) - this.store.removeWorkspaceLineage?.(worktreeWorkspaceKey(worktree.id)) - } else if (lineage?.parentWorktree) { - const parent = await this.resolveWorktreeSelector(lineage.parentWorktree) - - this.validateLineageParent(worktree, parent) - if (!worktree.instanceId || !parent.instanceId) { - throw new RuntimeLineageError( - 'LINEAGE_PARENT_CONTEXT_MISSING', - 'Worktree instance identity was unavailable.' - ) - } - if (!this.store.setWorktreeLineage) { - throw new RuntimeLineageError( - 'LINEAGE_PARENT_CONTEXT_MISSING', - 'Worktree lineage storage was unavailable.' - ) - } - const createdAt = Date.now() - this.store.setWorktreeLineage(worktree.id, { - worktreeId: worktree.id, - worktreeInstanceId: worktree.instanceId, - parentWorktreeId: parent.id, - parentWorktreeInstanceId: parent.instanceId, - origin: 'manual', - capture: { source: 'manual-action', confidence: 'explicit' }, - createdAt - }) - this.store.setWorkspaceLineage?.({ - childWorkspaceKey: worktreeWorkspaceKey(worktree.id), - childInstanceId: worktree.instanceId, - parentWorkspaceKey: worktreeWorkspaceKey(parent.id), - parentInstanceId: parent.instanceId, - origin: 'manual', - capture: { source: 'manual-action', confidence: 'explicit' }, - createdAt - }) - } - const metadataUpdates = stripOrcaProvenanceMetaUpdates(persistedMetaUpdates) - const executionHostId = worktree.identity?.executionHostId ?? worktree.hostId - if (executionHostId && this.store.setWorktreeMetaForHost) { - this.store.setWorktreeMetaForHost(worktree.id, executionHostId, metadataUpdates) - } else { - this.store.setWorktreeMeta(worktree.id, metadataUpdates) - } - // Why: unlike renderer-initiated optimistic updates, CLI callers need an - // explicit push so the editor refreshes metadata changed outside the UI. - this.invalidateResolvedWorktreeCache() - this.notifyWorktreesChanged(worktree.repoId) - return await this.showManagedWorktree( - worktree.identity?.key ? `identity:${worktree.identity.key}` : `id:${worktree.id}` - ) - } - - persistManagedWorktreeSortOrder(orderedIds: string[]): { updated: number } { - if (!this.store) { - throw new Error('runtime_unavailable') - } - const store = this.store - const updates = planWorktreeSortOrderUpdates( - orderedIds, - (worktreeId) => store.getWorktreeMeta(worktreeId), - Date.now() - ) - for (const update of updates) { - store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder }) - } - if (updates.length === 0) { - return { updated: 0 } - } - this.invalidateResolvedWorktreeCache() - const changedRepoIds = new Set( - updates.flatMap((update) => { - const parsed = splitWorktreeId(update.worktreeId) - return parsed ? [parsed.repoId] : [] - }) - ) - for (const repoId of changedRepoIds) { - this.notifyWorktreesChanged(repoId) - } - return { updated: updates.length } - } - - async resolveManagedPrBase(args: { - repoSelector: string - prNumber: number - headRefName?: string - baseRefName?: string - isCrossRepository?: boolean - }): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - let repo: Repo - try { - repo = await this.resolveRepoSelector(args.repoSelector) - } catch { - return { error: 'Repo not found' } - } - if (isFolderRepo(repo)) { - return { error: 'Folder mode does not support creating worktrees.' } - } - const sshGitProvider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null - const localGitExecOptions = sshGitProvider - ? undefined - : getLocalProjectGitExecOptions(this.requireStore(), repo) - const localWorktreeGitOptions = sshGitProvider - ? {} - : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) - const gitExec = sshGitProvider - ? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path) - : (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions ?? { cwd: repo.path }) - // Why: one resolver keeps source preference and hosting identity aligned - // across local, WSL, and SSH worktree creation. - const resolveRemote = (): Promise => - resolveGitHubReviewHeadRemote({ - repoPath: repo.path, - issueSourcePreference: repo.issueSourcePreference, - connectionId: repo.connectionId ?? null, - localGitOptions: localWorktreeGitOptions, - gitExec - }) - - // Why: SSH review-head fetches require narrow write-capable RPCs. - const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => - fetchPrHeadTrackingRef( - repo, - sshGitProvider, - remote, - branch, - localGitExecOptions ? { localGitExecOptions } : {} - ) - const fetchPullRequestHeadRef = (remote: string, prNumber: number): Promise => - fetchGitHubPullRequestHeadRef( - repo, - sshGitProvider, - remote, - prNumber, - localGitExecOptions ? { localGitExecOptions } : {} - ) - - return resolveGitHubPrStartPoint({ - repoPath: repo.path, - prNumber: args.prNumber, - headRefName: args.headRefName, - baseRefName: args.baseRefName, - isCrossRepository: args.isCrossRepository, - issueSourcePreference: repo.issueSourcePreference, - connectionId: repo.connectionId ?? null, - localGitOptions: localWorktreeGitOptions, - gitExec, - fetchRemoteTrackingRef, - fetchPullRequestHeadRef, - resolveRemote - }) - } - - async resolveManagedMrBase(args: { - repoSelector: string - mrIid: number - sourceBranch?: string - targetBranch?: string - isCrossRepository?: boolean - }): Promise< - { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } | { error: string } - > { - if (!this.store) { - throw new Error('runtime_unavailable') - } - let repo: Repo - try { - repo = await this.resolveRepoSelector(args.repoSelector) - } catch { - return { error: 'Repo not found' } - } - if (isFolderRepo(repo)) { - return { error: 'Folder mode does not support creating worktrees.' } - } - const sshGitProvider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null - const localGitExecOptions = sshGitProvider - ? undefined - : getLocalProjectGitExecOptions(this.requireStore(), repo) - const localWorktreeGitOptions = sshGitProvider - ? {} - : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) - const gitExec = sshGitProvider - ? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path) - : (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions ?? { cwd: repo.path }) - - let sourceBranch = args.sourceBranch?.trim() ?? '' - let targetBranch = args.targetBranch?.trim() ?? '' - let isCrossRepository = args.isCrossRepository === true - - if (!sourceBranch) { - let remote: string - try { - remote = await this.resolveGitLabIssueSourceRemote( - repo.path, - repo.issueSourcePreference, - repo.connectionId ?? null, - localWorktreeGitOptions - ) - } catch (error) { - return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' } - } - const knownHosts = await getGlabKnownHosts(repo.connectionId ?? null, localWorktreeGitOptions) - const projectRef = await getGitLabProjectRefForRemote( - repo.path, - remote, - knownHosts, - repo.connectionId ?? null, - localWorktreeGitOptions - ) - if (!projectRef) { - return { error: 'No GitLab project found for this repository.' } - } - const item = await getGitLabWorkItemByProjectRef( - repo.path, - projectRef, - args.mrIid, - 'mr', - repo.connectionId ?? null, - localWorktreeGitOptions - ) - if (!item || item.type !== 'mr') { - return { error: `MR !${args.mrIid} not found.` } - } - sourceBranch = (item.branchName ?? '').trim() - targetBranch = (item.baseRefName ?? '').trim() - if (!sourceBranch) { - return { error: `MR !${args.mrIid} has no source branch.` } - } - if (item.isCrossRepository === true) { - isCrossRepository = true - } - } - - let remote: string - try { - remote = await this.resolveGitLabIssueSourceRemote( - repo.path, - repo.issueSourcePreference, - repo.connectionId ?? null, - localWorktreeGitOptions - ) - } catch (error) { - return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' } - } - const compareBaseRef = targetBranch ? `refs/remotes/${remote}/${targetBranch}` : undefined - const fetchRemoteTrackingRef = async (branch: string, ref: string): Promise => { - await (sshGitProvider - ? sshGitProvider.fetchRemoteTrackingRef(repo.path, remote, branch, ref) - : gitExec(['fetch', remote, `+refs/heads/${branch}:${ref}`])) - } - // Why: the target/compare branch is optional (it only powers the diff - // base). A merged MR may have had its target ref deleted, so a fetch - // failure must NOT abort the whole resolution — that would discard the - // already-verified source-branch base and silently fall back to the repo - // default branch. Degrade gracefully by dropping compareBaseRef instead. - const fetchCompareBaseRef = (): Promise => - fetchCompareBaseRefWithLocalFallback({ - compareBaseRef, - fetchCompareBaseRef: (ref) => fetchRemoteTrackingRef(targetBranch, ref), - gitExec, - logLabel: '[runtime:resolveManagedMrBase]', - logContext: { remote, targetBranch, mrIid: args.mrIid } - }) - - if (isCrossRepository) { - const mrRef = `refs/merge-requests/${args.mrIid}/head` - // Why: soft-keep needs identity when the fetch throws before returning a path. - // Success uses the path returned by the fetch itself (writer-authoritative). - let softKeepLocalRefPromise: Promise | undefined - const resolveSoftKeepLocalRef = (): Promise => { - softKeepLocalRefPromise ??= (async () => { - try { - const { stdout } = await gitExec(['remote', 'get-url', remote]) - const remoteUrl = stdout.trim() - if (!remoteUrl) { - return null - } - return gitlabMergeRequestHeadLocalRef( - reviewHeadRemoteRefComponent(remote, remoteUrl), - args.mrIid - ) - } catch { - return null - } - })() - return softKeepLocalRefPromise - } - const resolveDurableHeadSha = async (localRef: string | null): Promise => { - if (!localRef) { - return null - } - try { - const { stdout } = await gitExec(['rev-parse', '--verify', `${localRef}^{commit}`]) - return stdout.trim() || null - } catch { - return null - } - } - try { - const localRef = await fetchGitLabMergeRequestHeadRef( - repo, - sshGitProvider, - remote, - args.mrIid, - localGitExecOptions ? { localGitExecOptions } : {} - ) - const sha = await resolveDurableHeadSha(localRef) - if (!sha) { - return { error: `Could not resolve fork MR !${args.mrIid} head after fetch.` } - } - const compareBaseFetched = await fetchCompareBaseRef() - return { baseBranch: sha, ...(compareBaseFetched ? { compareBaseRef } : {}) } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - // Why: mirror compare-base — a transient transport failure must not fail - // the resolve when a prior fetch already pinned the durable head ref. A - // missing remote ref (deleted MR/fork), auth failure, or stale-relay - // error must fail hard: serving the durable ref there would check out a - // dead or unauthorized tip and mask the actionable error. - if (isTransientReviewHeadFetchError(error)) { - const localSha = await resolveDurableHeadSha(await resolveSoftKeepLocalRef()) - if (localSha) { - console.warn( - '[runtime:resolveManagedMrBase] MR head fetch failed; using durable local ref', - { - remote, - mrIid: args.mrIid, - error: message.split('\n')[0] - } - ) - const compareBaseFetched = await fetchCompareBaseRef() - return { baseBranch: localSha, ...(compareBaseFetched ? { compareBaseRef } : {}) } - } - } - return { error: `Failed to fetch ${mrRef}: ${message.split('\n')[0]}` } - } - } - - try { - await fetchRemoteTrackingRef(sourceBranch, `refs/remotes/${remote}/${sourceBranch}`) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { error: `Failed to fetch ${remote}/${sourceBranch}: ${message.split('\n')[0]}` } - } - - const remoteRef = `${remote}/${sourceBranch}` - try { - await gitExec(['rev-parse', '--verify', remoteRef]) - } catch { - return { error: `Remote ref ${remoteRef} does not exist after fetch.` } - } - const compareBaseFetched = await fetchCompareBaseRef() - return { - baseBranch: remoteRef, - ...(compareBaseFetched ? { compareBaseRef } : {}), - pushTarget: { remoteName: remote, branchName: sourceBranch } - } - } - - private async resolveGitLabIssueSourceRemote( - repoPath: string, - preference?: Repo['issueSourcePreference'], - connectionId?: string | null, - localGitOptions: { wslDistro?: string } = {} - ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) - const localGitOptionArgs = - Object.keys(localGitOptions).length > 0 ? ([localGitOptions] as const) : [] - if (preference === 'origin') { - const origin = await getGitLabProjectRefForRemote( - repoPath, - 'origin', - knownHosts, - connectionId, - ...localGitOptionArgs - ) - if (origin) { - return 'origin' - } - throw new Error('No GitLab project found for origin.') - } - if (preference === 'upstream') { - const upstream = await getGitLabProjectRefForRemote( - repoPath, - 'upstream', - knownHosts, - connectionId, - ...localGitOptionArgs - ) - if (upstream) { - return 'upstream' - } - const origin = await getGitLabProjectRefForRemote( - repoPath, - 'origin', - knownHosts, - connectionId, - ...localGitOptionArgs - ) - if (origin) { - return 'origin' - } - throw new Error('No GitLab project found for upstream or origin.') - } - const upstream = await getGitLabProjectRefForRemote( - repoPath, - 'upstream', - knownHosts, - connectionId, - ...localGitOptionArgs - ) - if (upstream) { - return 'upstream' - } - const origin = await getGitLabProjectRefForRemote( - repoPath, - 'origin', - knownHosts, - connectionId, - ...localGitOptionArgs - ) - if (origin) { - return 'origin' - } - if (connectionId) { - const provider = requireSshGitProvider(connectionId) - const { stdout } = await provider.exec(['remote'], repoPath) - return pickPreferredGitRemote(stdout.split('\n')) - } - return getDefaultRemote(repoPath, localGitOptions) - } - - private async resolveWorktreeRemovalTarget( - worktreeSelector: string, - requiredHostId?: ExecutionHostId - ): Promise { - try { - const exactTarget = parseExactWorktreeIdSelector(worktreeSelector) - const worktree = - exactTarget && requiredHostId - ? ((await this.resolveExplicitWorktreeIdScoped(exactTarget.id, requiredHostId)) ?? - (() => { - throw new Error('selector_not_found') - })()) - : await this.resolveWorktreeSelector(worktreeSelector) - const removalTarget = { - id: worktree.id, - repoId: worktree.repoId, - path: worktree.path - } - return worktree.pushTarget - ? { ...removalTarget, pushTarget: worktree.pushTarget } - : removalTarget - } catch (error) { - if (!(error instanceof Error) || error.message !== 'selector_not_found') { - throw error - } - const removalTarget = parseExactWorktreeIdSelector(worktreeSelector) - const meta = removalTarget ? this.store?.getWorktreeMeta(removalTarget.id) : undefined - if ( - !removalTarget || - !meta || - (requiredHostId !== undefined && meta.hostId !== requiredHostId) - ) { - throw error - } - // Why: delete requests can arrive after Git no longer lists the worktree. - // Only exact IDs with persisted Orca metadata are accepted here so - // branch/path selectors cannot resolve to an arbitrary missing path. - return meta.pushTarget ? { ...removalTarget, pushTarget: meta.pushTarget } : removalTarget - } - } - - private removeWorktreeMetadataAndHistory( - store: RuntimeStore, - worktreeId: string, - hostId?: ExecutionHostId - ): void { - // Why: worktree IDs are path-derived and can be recreated, so removal must - // purge history and process-local caches before the ID points at new state. - const persistedHostId = store.getWorktreeMeta(worktreeId)?.hostId - const repoId = splitWorktreeId(worktreeId)?.repoId - const preservesSameIdOwner = Boolean( - hostId && - ((persistedHostId && persistedHostId !== hostId) || - (repoId && hasWorktreeRemovalRepoOwnerOnOtherHost(store, repoId, hostId))) - ) - if (hostId) { - store.removeWorktreeMeta(worktreeId, hostId) - } else { - store.removeWorktreeMeta(worktreeId) - } - if (!preservesSameIdOwner) { - this.mobileSessionTabsByWorktree.delete(worktreeId) - this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId) - this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) - advertisedUrlWatcher.forgetWorktree(worktreeId) - deleteWorktreeHistoryDir(worktreeId) - this.closeHeadlessBrowserPagesForWorktree(worktreeId) - closeClientHostedBrowserPagesForWorktree(this, worktreeId) - } - } - - // Why: headless offscreen browser pages are main-process BrowserWindows that - // outlive a worktree unless explicitly closed — removing a worktree without - // closing its open panes leaks the windows for the life of the serve process. - private closeHeadlessBrowserPagesForWorktree(worktreeId: string): void { - if (!this.offscreenBrowserBackend || !this.agentBrowserBridge?.tabList) { - return - } - for (const tab of this.agentBrowserBridge.tabList(worktreeId).tabs) { - void this.offscreenBrowserBackend.closeTab(tab.browserPageId).catch(() => {}) - } - } - - private rememberPreservedBranchCleanupTarget( - worktreeId: string, - hostId: ExecutionHostId | undefined, - result: RemoveWorktreeResult | undefined, - fallbackHead: string | undefined, - pushTarget: GitPushTarget | undefined - ): void { - if (result?.preservedBranch) { - const head = result.preservedBranch.head ?? fallbackHead - if (!head) { - throw new Error( - `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` - ) - } - this.preservedBranchCleanupByScope.set( - preservedBranchCleanupScopeKey({ worktreeId, hostId }), - { - worktreeId, - ...(hostId ? { hostId } : {}), - branchName: result.preservedBranch.branchName, - head, - ...(pushTarget ? { pushTarget } : {}) - } - ) - return - } - this.preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ worktreeId, hostId }) - ) - } - - private preserveBranchHeadFallback( - result: RemoveWorktreeResult | undefined, - fallbackHead: string | undefined - ): RemoveWorktreeResult { - if (!result?.preservedBranch || result.preservedBranch.head || !fallbackHead) { - return result ?? {} - } - return { - ...result, - preservedBranch: { - ...result.preservedBranch, - head: fallbackHead - } - } - } - - async forceDeletePreservedBranch( - worktreeSelector: string, - branchName: string, - expectedHead: string, - hostId?: string - ): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - const removalTarget = parseExactWorktreeIdSelector(worktreeSelector) - const normalizedHostId = parseExecutionHostId(hostId)?.id - const exactTarget = removalTarget - ? this.preservedBranchCleanupByScope.get( - preservedBranchCleanupScopeKey({ worktreeId: removalTarget.id, hostId: normalizedHostId }) - ) - : undefined - const legacyMatches = - removalTarget && !hostId - ? [...this.preservedBranchCleanupByScope.values()].filter( - (target) => - target.worktreeId === removalTarget.id && - target.branchName === branchName && - target.head === expectedHead - ) - : [] - const cleanupTarget = exactTarget ?? (legacyMatches.length === 1 ? legacyMatches[0] : undefined) - if ( - !removalTarget || - !cleanupTarget || - cleanupTarget.branchName !== branchName || - cleanupTarget.head !== expectedHead - ) { - throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) - } - - const repoOwner = resolveWorktreeRemovalRepoOwner( - this.store, - removalTarget.repoId, - cleanupTarget.hostId - ) - if (repoOwner.kind === 'ambiguous') { - throw new Error( - `Workspace identity is ambiguous across hosts: ${removalTarget.id}. Retry with an explicit host.` - ) - } - const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined - if (!repo) { - throw new Error('repo_not_found') - } - if (isFolderRepo(repo)) { - throw new Error('Folder workspaces do not have local Git branches.') - } - - if (repo.connectionId) { - const provider = requireSshGitProvider(repo.connectionId) - // Why: SSH must use the write-capable relay RPC; the shared exec-based - // helper routes through the read-only git.exec allowlist, which rejects - // the worktree/update-ref/config writes this delete needs. - await provider.forceDeletePreservedBranch( - repo.path, - cleanupTarget.branchName, - cleanupTarget.head - ) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider, - repo.path, - removalTarget.id, - cleanupTarget.pushTarget, - this.store - ) - } else { - const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(this.requireStore(), repo) - await (Object.keys(localWorktreeGitOptions).length > 0 - ? forceDeleteLocalBranch( - repo.path, - cleanupTarget.branchName, - cleanupTarget.head, - (argv, cwd) => gitExecFileAsync(argv, { cwd, ...localWorktreeGitOptions }) - ) - : forceDeleteLocalBranch(repo.path, cleanupTarget.branchName, cleanupTarget.head)) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - removalTarget.id, - cleanupTarget.pushTarget, - this.store, - localWorktreeGitOptions - ) - } - - this.preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ - worktreeId: removalTarget.id, - hostId: cleanupTarget.hostId - }) - ) - return { deleted: true } - } - - async removeManagedWorktree( - worktreeSelector: string, - force = false, - runHooks = false, - // Why (#11960): only an explicit Force Delete waives PTY-stop proof; `force` - // alone is already set by the ordinary delete confirmation. - allowUnverifiedPtyStop = false, - hostId?: string - ): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - const store = this.store - const cleanupHostId = parseExecutionHostId(hostId)?.id - const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector, cleanupHostId) - const cleanupScopeKey = preservedBranchCleanupScopeKey({ - worktreeId: removalTarget.id, - hostId: cleanupHostId - }) - const optionsKey = getRuntimeWorktreeRemovalOptionsKey(force, runHooks, allowUnverifiedPtyStop) - const inFlightRemoval = this.removeManagedWorktreeInFlight.get(cleanupScopeKey) - if (inFlightRemoval) { - if (inFlightRemoval.optionsKey === optionsKey) { - return inFlightRemoval.promise - } - throw new Error(`Worktree deletion already in progress: ${removalTarget.id}`) - } - - // Why: runtime callers can race the same workspace through CLI/mobile - // retries. Share one destructive operation per host-qualified workspace. - const removal = (async (): Promise => { - // Why: CLI, mobile and headless serve delete through here rather than the IPC handler; without - // this span their freezes are as invisible as desktop deletes were before `worktree.remove`. - return withWorktreeSpan({ stage: 'remove', path: removalTarget.path }, async () => { - // Why (STA-4343): a repo id can exist once per host. Honor the caller's - // host qualifier, and refuse an unqualified delete the runtime cannot - // pin to one owner rather than deleting a same-id workspace elsewhere. - const repoOwner = resolveWorktreeRemovalRepoOwner( - store, - removalTarget.repoId, - cleanupHostId - ) - if (repoOwner.kind === 'ambiguous') { - throw new Error( - `Workspace identity is ambiguous across hosts: ${removalTarget.id}. Retry with an explicit host.` - ) - } - const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined - // Why (STA-4343): metadata is keyed by the bare id, so purging it unqualified - // would evict a same-id row owned by another host. A caller that named no host - // still resolved exactly one repo above, and that repo names the owner. - const removalHostId = repo ? (cleanupHostId ?? getRepoExecutionHostId(repo)) : cleanupHostId - if (!repo) { - const orphanHost = parseExecutionHostId(store.getWorktreeMeta(removalTarget.id)?.hostId) - if (cleanupHostId && orphanHost?.id !== cleanupHostId) { - throw new Error( - `Workspace identity for ${removalTarget.id} no longer belongs to ${cleanupHostId}. Refresh projects and try again.` - ) - } - const sshPtyProvider = - orphanHost?.kind === 'ssh' ? this.getSshProviderFn?.(orphanHost.targetId) : undefined - const ptyProvider = sshPtyProvider ?? this.getLocalProvider() - const externalOrphanHost = orphanHost?.kind === 'ssh' || orphanHost?.kind === 'runtime' - if (ptyProvider) { - // External host inventories must never sweep a same-id local workspace. - await killAllProcessesForWorktree(removalTarget.id, { - runtime: this, - resolvedWorktreeId: removalTarget.id, - ...(orphanHost?.kind === 'ssh' ? { resolvedConnectionId: orphanHost.targetId } : {}), - ...(orphanHost?.kind === 'runtime' - ? { resolvedRuntimeEnvironmentId: orphanHost.environmentId } - : {}), - localProvider: ptyProvider, - onPtyStopped: this.onPtyStopped ?? undefined, - ...(externalOrphanHost - ? { - includeProviderInventory: orphanHost?.kind === 'ssh' && Boolean(sshPtyProvider), - includeLocalRegistry: false - } - : {}) - }).catch((error) => { - console.warn( - `[worktree-teardown] orphan cleanup failed for ${removalTarget.id}:`, - error - ) - }) - } - // Why: nothing is deleted on disk here, so watchers must be restored — a folder - // workspace or explorer pane rooted at the same path stays live. - const orphanFullPath = splitWorktreeId(removalTarget.id)?.worktreePath - const orphanWatcherPath = - splitWorktreeIdForFilesystem(removalTarget.id)?.worktreePath === orphanFullPath - ? orphanFullPath - : undefined - if (orphanWatcherPath) { - await this.acquireFileWatcherRemoval( - orphanWatcherPath, - orphanHost?.kind === 'ssh' ? orphanHost.targetId : undefined - ) - .then((gate) => gate.finish(false)) - .catch(() => {}) - } - await deleteRemoteWorktreeHistory(sshPtyProvider, removalTarget.id) - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory( - store, - removalTarget.id, - cleanupHostId ?? orphanHost?.id - ) - this.preservedBranchCleanupByScope.delete(cleanupScopeKey) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) - invalidateAuthorizedRootsCache() - this.notifyWorktreesChanged(removalTarget.repoId) - // Why: non-desktop callers must be able to tell "forgotten" from "deleted"; nothing left the disk. - return { - warning: `Project ${removalTarget.repoId} is no longer tracked, so ${removalTarget.path} was forgotten without deleting the directory or its Git worktree registration.` - } - } - if (isFolderRepo(repo)) { - if (removalTarget.id === getRuntimeFolderWorkspaceRootId(repo)) { - throw new Error( - 'Cannot delete the project root workspace. Remove the folder project instead.' - ) - } - // This service runs inside the selected runtime, so runtime-stamped repos use its - // local PTY namespace; only a direct SSH connection is external from here. - const folderConnectionId = repo.connectionId?.trim() || null - const folderSshPtyProvider = folderConnectionId - ? this.getSshProviderFn?.(folderConnectionId) - : undefined - const folderPtyProvider = folderSshPtyProvider ?? this.getLocalProvider() - if (folderPtyProvider) { - // Why: folder workspace deletion has no Git removal phase where PTYs - // would otherwise be swept; tear them down before hiding the workspace. - await killAllProcessesForWorktree(removalTarget.id, { - runtime: this, - resolvedWorktreeId: removalTarget.id, - ...(folderConnectionId ? { resolvedConnectionId: folderConnectionId } : {}), - localProvider: folderPtyProvider, - onPtyStopped: this.onPtyStopped ?? undefined, - ...(folderConnectionId - ? { - includeProviderInventory: Boolean(folderSshPtyProvider), - includeLocalRegistry: false - } - : {}) - }).catch((err) => { - console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err) - }) - } - await deleteRemoteWorktreeHistory(folderSshPtyProvider, removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) - this.preservedBranchCleanupByScope.delete(cleanupScopeKey) - this.invalidateResolvedWorktreeCache() - this.notifyWorktreesChanged(repo.id) - return {} - } - const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null - const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null - const localWorktreeGitOptions = repo.connectionId - ? {} - : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) - const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 - const registeredWorktrees = repo.connectionId - ? await provider!.listWorktrees(repo.path) - : hasLocalWorktreeGitOptions - ? await listWorktreesStrict(repo.path, localWorktreeGitOptions) - : await listWorktreesStrict(repo.path) - const removedMeta = resolveWorktreeRemovalMetadata( - store, - removalTarget.repoId, - removalTarget.id, - cleanupHostId ?? getRepoExecutionHostId(repo) - ) - const removedPushTarget = removedMeta?.pushTarget ?? removalTarget.pushTarget - const registeredWorktree = findRegisteredDeletableWorktree( - repo.path, - removalTarget.path, - registeredWorktrees - ) - if (!registeredWorktree) { - let canCleanOrphanedDirectory = false - if ( - canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: removedMeta - }) - ) { - if (repo.connectionId) { - if (!fsProvider) { - throw new Error('SSH filesystem provider unavailable') - } - if (!fsProvider.lstat) { - throw new Error('SSH filesystem provider lstat unavailable') - } - canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( - removalTarget.path, - repo.path, - (path) => fsProvider.lstat!(path), - (path) => fsProvider.readFile(path) - ) - } else { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - canCleanOrphanedDirectory = - !isDangerousWorktreeRemovalPath(removalTarget.path, repo.path) && - (await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(removalTarget.path, localWorktreeGitOptions), - toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - access.statPath, - access.readPath - )) - } - } - if (canCleanOrphanedDirectory) { - assertWorktreeDoesNotContainRegisteredWorktree(removalTarget.path, registeredWorktrees) - if (!force) { - throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) - } - if (repo.connectionId) { - const removalGate = await this.acquireFileWatcherRemoval( - removalTarget.path, - repo.connectionId - ) - let removalCompleted = false - try { - await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, { - connectionId: repo.connectionId, - allowUnverifiedStop: allowUnverifiedPtyStop - }) - await fsProvider!.deletePath(removalTarget.path, true) - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - removalTarget.id, - removedPushTarget, - store - ) - await deleteRemoteWorktreeHistory( - this.getSshProviderFn?.(repo.connectionId), - removalTarget.id - ) - } else { - const removalGate = await this.acquireFileWatcherRemoval(removalTarget.path) - let removalCompleted = false - try { - await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, { - allowUnverifiedStop: allowUnverifiedPtyStop - }) - await removeLocalWorktreePath(removalTarget.path, localWorktreeGitOptions) - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - removalTarget.id, - removedPushTarget, - store, - localWorktreeGitOptions - ) - } - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) - this.preservedBranchCleanupByScope.delete(cleanupScopeKey) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) - invalidateAuthorizedRootsCache() - this.notifyWorktreesChanged(repo.id) - return {} - } - if (!repo.connectionId) { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - const runtimeWorktreePath = toLocalWorktreeRuntimePath( - removalTarget.path, - localWorktreeGitOptions - ) - if ( - await canCleanupUnregisteredOrcaLeftoverDirectory({ - meta: removedMeta, - worktreePath: removalTarget.path, - runtimeWorktreePath, - repo, - runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - registeredWorktrees, - statPath: access.statPath, - isGitRepository: (path) => - isLocalRuntimeGitRepository(path, localWorktreeGitOptions) - }) - ) { - if (!force) { - throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) - } - const removalGate = await this.acquireFileWatcherRemoval(removalTarget.path) - let removalCompleted = false - try { - await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, { - allowUnverifiedStop: allowUnverifiedPtyStop - }) - await removeLocalWorktreePath(removalTarget.path, localWorktreeGitOptions) - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - removalTarget.id, - removedPushTarget, - store, - localWorktreeGitOptions - ) - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) - this.preservedBranchCleanupByScope.delete(cleanupScopeKey) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) - invalidateAuthorizedRootsCache() - this.notifyWorktreesChanged(repo.id) - return {} - } - } - if ( - await isRuntimeWorktreePathMissing(repo, removalTarget.path, localWorktreeGitOptions) - ) { - if (!force && !removedMeta) { - // Why: without persisted metadata, require the renderer recovery - // path before deleting Orca-only state for an unregistered path. - throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE) - } - // Why: a manually deleted worktree is already gone from Git and disk. - // Finish runtime metadata cleanup without requiring force or touching - // any unregistered path that still exists. - await (repo.connectionId - ? cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - removalTarget.id, - removedPushTarget, - store - ) - : cleanupUnusedWorktreePushTargetRemote( - repo.path, - removalTarget.id, - removedPushTarget, - store, - localWorktreeGitOptions - )) - if (repo.connectionId) { - await deleteRemoteWorktreeHistory( - this.getSshProviderFn?.(repo.connectionId), - removalTarget.id - ) - } - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) - this.preservedBranchCleanupByScope.delete(cleanupScopeKey) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) - invalidateAuthorizedRootsCache() - this.notifyWorktreesChanged(repo.id) - return {} - } - throw new Error(`Refusing to delete unregistered worktree path: ${removalTarget.path}`) - } - const canonicalWorktreePath = registeredWorktree.path - const deleteBranch = removedMeta?.preserveBranchOnDelete !== true - - // Why: a Git lock must block before archive hooks or linked-path cleanup - // mutate the workspace; dirty-file force is a separate permission. - try { - assertWorktreeUnlockedForRemoval(registeredWorktree) - } catch (error) { - throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) - } - - // Why: a prior forced Windows recovery can delete the directory but leave - // Git's stale registration; recover and verify it before clearing metadata. - if ( - !repo.connectionId && - force === true && - process.platform === 'win32' && - (isWindowsAbsolutePathLike(canonicalWorktreePath) || - !!localWorktreeGitOptions.wslDistro) && - removedMeta && - (await isRuntimeWorktreePathMissing(repo, canonicalWorktreePath, localWorktreeGitOptions)) - ) { - const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ - canonicalWorktreePath, - repoPath: repo.path, - localWorktreeGitOptions, - registeredWorktree, - deleteBranch - }) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - removalTarget.id, - removedPushTarget, - store, - localWorktreeGitOptions - ) - this.rememberPreservedBranchCleanupTarget( - removalTarget.id, - cleanupHostId, - removalResult, - registeredWorktree.head, - removedPushTarget - ) - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) - invalidateAuthorizedRootsCache() - this.notifyWorktreesChanged(repo.id) - return removalResult ?? {} - } - if (repo.connectionId) { - const remoteRemoveOptions = !deleteBranch ? { deleteBranch } : {} - const removalGate = await this.acquireFileWatcherRemoval( - canonicalWorktreePath, - repo.connectionId - ) - let rawRemovalResult: RemoveWorktreeResult | undefined - let removalCompleted = false - try { - await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, { - connectionId: repo.connectionId, - allowUnverifiedStop: allowUnverifiedPtyStop - }) - rawRemovalResult = await (Object.keys(remoteRemoveOptions).length > 0 - ? provider!.removeWorktree(canonicalWorktreePath, force, remoteRemoveOptions) - : provider!.removeWorktree(canonicalWorktreePath, force)) - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - const removalResult = this.preserveBranchHeadFallback( - rawRemovalResult, - registeredWorktree.head - ) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - removalTarget.id, - removedPushTarget, - store - ) - await deleteRemoteWorktreeHistory( - this.getSshProviderFn?.(repo.connectionId), - removalTarget.id - ) - this.rememberPreservedBranchCleanupTarget( - removalTarget.id, - cleanupHostId, - removalResult, - registeredWorktree.head, - removedPushTarget - ) - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) - invalidateAuthorizedRootsCache() - this.notifyWorktreesChanged(repo.id) - return removalResult ?? {} - } - - const hooks = getEffectiveHooks(repo) - let warning: string | undefined - if (hooks?.scripts.archive && runHooks) { - const result = await runHook( - 'archive', - canonicalWorktreePath, - repo, - undefined, - hasLocalWorktreeGitOptions ? localWorktreeGitOptions : undefined - ) - if (!result.success) { - console.error( - `[hooks] archive hook failed for ${canonicalWorktreePath}:`, - result.output - ) - } - } else if (hooks?.scripts.archive) { - // Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in. - warning = `orca.yaml archive hook skipped for ${canonicalWorktreePath}; pass --run-hooks to run it.` - console.warn(`[hooks] ${warning}`) - } - - const refreshedWorktrees = hasLocalWorktreeGitOptions - ? await listWorktreesStrict(repo.path, localWorktreeGitOptions) - : await listWorktreesStrict(repo.path) - const refreshedRegisteredWorktree = findRegisteredDeletableWorktree( - repo.path, - canonicalWorktreePath, - refreshedWorktrees - ) - if (!refreshedRegisteredWorktree) { - throw new Error( - `Worktree registration changed during deletion: ${canonicalWorktreePath}. Retry deletion.` - ) - } - try { - // Why: an archive hook can race another Git client that locks the row; - // recheck before linked-path, watcher, or terminal teardown side effects. - assertWorktreeUnlockedForRemoval(refreshedRegisteredWorktree) - } catch (error) { - throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) - } - - // Why: `orca.yaml` shared directories are symlinked in too, and a - // directory-only ignore rule leaves those links untracked, so removal must - // tolerate and unlink them exactly like the per-user shared paths. - const linkedPaths = getWorktreeSharedLinkPaths(repo) - const ignoredLinkedPaths = force - ? [] - : await findExistingWorktreeSymlinkPaths(canonicalWorktreePath, linkedPaths) - try { - await (hasLocalWorktreeGitOptions - ? assertWorktreeCleanForRemoval(canonicalWorktreePath, force, { - ...localWorktreeGitOptions, - ...(ignoredLinkedPaths.length > 0 - ? { ignoredUntrackedPaths: ignoredLinkedPaths } - : {}) - }) - : ignoredLinkedPaths.length > 0 - ? assertWorktreeCleanForRemoval(canonicalWorktreePath, force, { - ignoredUntrackedPaths: ignoredLinkedPaths - }) - : assertWorktreeCleanForRemoval(canonicalWorktreePath, force)) - } catch (error) { - if (!isOrphanCompatiblePreflightError(error)) { - throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) - } - // Why: Git can still classify this as an orphan after preflight; - // retain strict PTY teardown before any recursive fallback deletion. - } - - let removalResult: RemoveWorktreeResult | undefined - const removalGate = await this.acquireFileWatcherRemoval(canonicalWorktreePath) - let removalCompleted = false - try { - // Why: linked-path deletion is destructive too; PTYs must release every - // handle before Windows or WSL filesystem cleanup starts. - await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, { - allowUnverifiedStop: allowUnverifiedPtyStop - }) - - if (linkedPaths.length > 0) { - await removeWorktreeLinkedPaths(canonicalWorktreePath, linkedPaths) - } - - try { - const removeOptions = { - ...(!deleteBranch ? { deleteBranch } : {}), - // Why: removal already validated the Git row under the selected - // project runtime; keep branch cleanup on that same canonical row. - knownRemovedWorktree: refreshedRegisteredWorktree, - ...localWorktreeGitOptions - } - removalResult = this.preserveBranchHeadFallback( - await removeWorktree(repo.path, canonicalWorktreePath, force, removeOptions), - refreshedRegisteredWorktree.head - ) - } catch (error) { - // Why: Git for Windows can deregister a clean worktree before its - // recursive filesystem deletion fails transiently. - const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({ - error, - force, - canonicalWorktreePath, - repoPath: repo.path, - localWorktreeGitOptions, - registeredWorktree: refreshedRegisteredWorktree, - deleteBranch, - closeWatcher: (worktreePath) => this.closeFileWatchersForRemoval(worktreePath) - }) - if (recoveredRemovalResult) { - removalResult = recoveredRemovalResult - removalCompleted = true - } else if (isOrphanedWorktreeError(error)) { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - if ( - await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), - toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - access.statPath, - access.readPath - ) - ) { - await this.closeFileWatchersForRemoval(canonicalWorktreePath) - await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( - () => {} - ) - } else { - console.warn( - `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` - ) - } - // Why: `git worktree remove` failed, so git's internal worktree tracking - // (`.git/worktrees/`) is still intact. Without pruning, `git worktree - // list` continues to show the stale entry and the branch it had checked out - // remains locked — other worktrees cannot check it out. - await gitExecFileAsync(['worktree', 'prune'], { - cwd: repo.path, - ...localWorktreeGitOptions - }).catch(() => {}) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - removalTarget.id, - removedPushTarget, - store, - localWorktreeGitOptions - ) - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) - this.preservedBranchCleanupByScope.delete(cleanupScopeKey) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) - invalidateAuthorizedRootsCache() - this.notifyWorktreesChanged(repo.id) - removalCompleted = true - return { - ...(warning ? { warning } : {}) - } - } else { - throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) - } - } - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - removalTarget.id, - removedPushTarget, - store, - localWorktreeGitOptions - ) - this.rememberPreservedBranchCleanupTarget( - removalTarget.id, - cleanupHostId, - removalResult, - refreshedRegisteredWorktree.head, - removedPushTarget - ) - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) - invalidateAuthorizedRootsCache() - this.notifyWorktreesChanged(repo.id) - return { - ...removalResult, - ...(warning ? { warning } : {}) - } - }) - })() - this.removeManagedWorktreeInFlight.set(cleanupScopeKey, { optionsKey, promise: removal }) - try { - const result = await removal - this.emitWorktreeLifecycle({ - kind: 'removed', - worktreeId: removalTarget.id, - path: removalTarget.path - }) - return result - } finally { - if (this.removeManagedWorktreeInFlight.get(cleanupScopeKey)?.promise === removal) { - this.removeManagedWorktreeInFlight.delete(cleanupScopeKey) - } - } - } - - async renameTerminal(handle: string, title: string | null): Promise { - const pty = this.getLivePtyForHandle(handle) - if (pty) { - pty.pty.title = title - // Why: a manual rename must outrank later agent OSC title updates (which - // win by timestamp), so stamp it as the freshest title. - pty.pty.titleUpdatedAt = Date.now() - this.touchMobileSessionSnapshotsForPty(pty.pty.ptyId) - // Why: without a renderer the rename only lived on the live pty and was - // lost on restart. Persist customTitle so a headless rebuild keeps it. - if (!this.notifier?.renameTerminal && pty.pty.tabId) { - this.persistHeadlessTerminalTitle(pty.pty.worktreeId, pty.pty.tabId, title) - } - for (const leaf of this.leaves.values()) { - if (leaf.ptyId === pty.pty.ptyId) { - this.notifier?.renameTerminal(leaf.tabId, title) - return { handle, tabId: leaf.tabId, title } - } - } - return { handle, tabId: pty.pty.tabId ?? pty.record.tabId, title } - } - this.assertGraphReady() - const { leaf } = this.getLiveLeafForHandle(handle) - this.notifier?.renameTerminal(leaf.tabId, title) - return { handle, tabId: leaf.tabId, title } - } - - private async resolveAgentTerminalCreateOptions( - workspace: TerminalWorkspaceLaunchScope, - opts: TerminalCreateOptions - ): Promise { - // Why: raw shell commands like `codex exec` must remain user-authored shell. - // Only unmanaged, repo-backed, bare agent launches get Settings defaults. - const callerSuppliedLaunch = - opts.env || - opts.launchConfig || - opts.launchAgent || - opts.startupCommandDelivery || - opts.claudeAgentTeamsSourceCommand - const store = this.store - if (opts.startupAgent) { - // Why: falling through unresolved would spawn a bare shell that can only time - // out waiting for an agent. A caller-supplied launch contradicts the agent: - // `command` would be overwritten, `resumeProviderSession` would pair resume - // identity with a fresh launch. - if (callerSuppliedLaunch || opts.command || opts.resumeProviderSession) { - throw new Error( - `startupAgent ${opts.startupAgent} cannot combine with a caller-supplied launch.` - ) - } - if (!store) { - throw new Error('runtime_unavailable') - } - } else if (callerSuppliedLaunch || !store || !opts.command || !workspace.repo) { - return opts - } - - const settings = store.getSettings() - const platform = this.getAgentLaunchPlatformForWorkspace(workspace) - const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : Boolean(workspace.connectionId) - const queuedShell = resolveLocalWindowsAgentStartupShell({ - platform, - isRemote, - terminalWindowsShell: settings.terminalWindowsShell - }) - if (opts.startupAgent && !isTuiAgentEnabled(opts.startupAgent, settings.disabledTuiAgents)) { - throw new Error(`Agent ${opts.startupAgent} is disabled. Choose an enabled agent.`) - } - const agent = - opts.startupAgent ?? - resolveBareAgentLaunchCommand({ - command: opts.command, - settings, - platform, - isRemote - }) - if (!agent) { - return opts - } - - const sessionOptions = this.toAgentSessionOptions(opts.launchPreferences) - const startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides: settings.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), - sessionOptions, - sessionOptionsOverrideAgentArgs: Boolean(sessionOptions), - platform, - shell: queuedShell, - isRemote, - allowEmptyPromptLaunch: true - }) - if (!startupPlan) { - // Why: an explicit agent that yields no plan would otherwise spawn a bare - // shell that never reaches agent readiness. - if (opts.startupAgent) { - throw new Error(`Could not build launch command for ${opts.startupAgent}.`) - } - return opts - } - - await this.markWorkspaceTrustedForAgent(agent, workspace.connectionId, workspace.path) - - return { - ...opts, - command: startupPlan.launchCommand, - ...(startupPlan.env ? { env: startupPlan.env } : {}), - launchConfig: startupPlan.launchConfig, - launchAgent: agent, - startupCommandDelivery: startupPlan.startupCommandDelivery - } - } - - private getAgentSessionExecutionNamespace( - workspace: TerminalWorkspaceLaunchScope, - agent: TuiAgent - ): { machine: string; principal: string; container: string; providerRoot: string } | null { - if (workspace.connectionId) { - // Why: SSH target ids are not execution-namespace proof. Preserve the - // legacy launch until an attested route can safely participate in claims. - return null - } - const wsl = parseWslUncPath(workspace.path) - const principal = - typeof process.getuid === 'function' - ? `uid:${process.getuid()}` - : `user:${process.env.USERNAME ?? ''}` - return { - machine: wsl ? 'wsl-host' : `native:${process.platform}`, - principal, - container: wsl ? `wsl:${wsl.distro.toLocaleLowerCase('en-US')}` : 'native', - // Why: merging account roots is conservative (it may conflict) and can - // never permit two TUIs to own one provider session. - providerRoot: `profile-default:${agent}` - } - } - - private async executionOwnerSupportsAgentSessionOperation( - workspace: TerminalWorkspaceLaunchScope, - operation: 'resume' | 'create', - signal?: AbortSignal - ): Promise { - const provider = workspace.connectionId - ? this.getSshProviderFn?.(workspace.connectionId) - : this.getLocalProvider() - if (!provider) { - // An unavailable route is not proof of an old owner; preserve the structured failure. - return true - } - const probe = - operation === 'resume' - ? provider.supportsAgentSessionClaims - : provider.supportsAgentSessionCreateOperations - if (!probe) { - // Local in-process PTYs need no wire negotiation; unknown SSH providers are legacy. - return workspace.connectionId === null - } - try { - return (await probe.call(provider, { signal })) === true - } catch { - // Why: this read-only check has not launched anything, so the old route remains safe. - return false - } - } - - private toAgentSessionOptions( - preferences: AgentLaunchPreferences | undefined - ): Record | undefined { - if (!preferences) { - return undefined - } - const options = { - ...(preferences.model ? { model: preferences.model } : {}), - ...(preferences.effort ? { effort: preferences.effort } : {}), - ...(preferences.mode ? { mode: preferences.mode } : {}) - } - return Object.keys(options).length > 0 ? options : undefined - } - - async ensureAgentSession( - request: RuntimeEnsureAgentSessionRequest, - _caller: RuntimeAgentSessionRpcCaller = {}, - handoffAuthority?: { spawnToken: string; providerRoot: string; sessionId: string } - ): Promise { - if (request.kind === 'automatic') { - // Legacy renderer sleep records are migration evidence, not host authority. - throw new Error('agent_session_resume_not_authorized') - } - if (!this.store) { - throw new Error('runtime_unavailable') - } - const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree) - const resolvedNamespace = this.getAgentSessionExecutionNamespace(workspace, request.agent) - const namespace = - resolvedNamespace && handoffAuthority - ? { ...resolvedNamespace, providerRoot: handoffAuthority.providerRoot } - : resolvedNamespace - if ( - !namespace || - !(await this.executionOwnerSupportsAgentSessionOperation(workspace, 'resume', _caller.signal)) - ) { - // Why: the renderer still holds the exact old request and may retry it before any side effect. - throw new Error('agent_session_legacy_required') - } - // Why: nested SSH paths belong to the execution owner, so compatibility selection must happen before local filesystem canonicalization. - const identity = canonicalizeAgentSessionIdentity(request.agent, request.providerSession) - const claim = this.agentSessionClaimSigner.createClaim({ - namespace, - identity, - canonicalWorktreeId: workspace.id - }) - const settings = this.store.getSettings() - if (!isTuiAgentEnabled(request.agent, settings.disabledTuiAgents)) { - throw new Error('Selected agent is disabled. Choose an enabled agent before resuming.') - } - const platform = this.getAgentLaunchPlatformForWorkspace(workspace) - const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : Boolean(workspace.connectionId) - const shell = resolveLocalWindowsAgentStartupShell({ - platform, - isRemote, - terminalWindowsShell: settings.terminalWindowsShell - }) - const startup = buildAgentResumeStartupPlan({ - agent: request.agent, - providerSession: identity.providerSession, - cmdOverrides: settings.agentCmdOverrides ?? {}, - agentArgs: - request.agentArgs !== undefined - ? request.agentArgs - : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), - agentEnv: { - ...resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), - ...(handoffAuthority && request.agent === 'codex' - ? { CODEX_HOME: handoffAuthority.providerRoot } - : handoffAuthority && request.agent === 'claude' - ? { CLAUDE_CONFIG_DIR: handoffAuthority.providerRoot } - : {}) - }, - ompResumeFilePath: request.ompResumeFilePath, - sessionOptions: this.toAgentSessionOptions(request.launchPreferences), - sessionOptionsOverrideAgentArgs: Boolean(request.launchPreferences), - platform, - shell, - isRemote - }) - if (!startup) { - throw new Error('agent_session_identity_required') - } - await this.markWorkspaceTrustedForAgent(request.agent, workspace.connectionId, workspace.path) - if (_caller.signal?.aborted) { - throw new Error('client_disconnected') - } - const terminal = await this.createTerminal(`id:${workspace.id}`, { - command: startup.launchCommand, - env: startup.env, - launchConfig: startup.launchConfig, - launchAgent: request.agent, - startupCommandDelivery: startup.startupCommandDelivery, - presentation: request.presentation ?? 'background', - tabId: request.placement?.tabId, - leafId: request.placement?.leafId, - agentSessionClaim: claim, - ...(handoffAuthority - ? { - launchToken: handoffAuthority.spawnToken, - structuredAgentSessionId: handoffAuthority.sessionId - } - : {}), - signal: _caller.signal - }) - return { - terminal, - disposition: terminal.agentSessionDisposition ?? 'created' - } - } - - async createAgentSession( - request: RuntimeCreateAgentSessionRequest, - caller: RuntimeAgentSessionRpcCaller = {} - ): Promise { - if (!this.store) { - throw new Error('runtime_unavailable') - } - const now = Date.now() - const operationTimestamp = parseAgentSessionOperationTimestamp(request.clientOperationId) - if ( - operationTimestamp === null || - operationTimestamp > now + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS - ) { - throw new Error('agent_session_operation_invalid') - } - const callerKey = caller.clientId?.trim() || `trusted-local:${caller.clientKind ?? 'runtime'}` - const operationKey = `${callerKey}\0${request.clientOperationId}` - const requestFingerprint = createHash('sha256') - .update( - JSON.stringify([ - request.worktree, - request.agent, - request.prompt ?? null, - request.promptDelivery ?? null, - request.agentArgs ?? null, - request.agentArgs === undefined ? 'host-default' : 'client-override', - request.launchPreferences?.model ?? null, - request.launchPreferences?.effort ?? null, - request.launchPreferences?.mode ?? null, - request.startupCwd ?? null, - request.presentation ?? null, - request.placement?.tabId ?? null, - request.placement?.leafId ?? null, - request.viewMode ?? null - ]) - ) - .digest('base64url') - const existing = this.agentSessionCreateOperations.get(operationKey) - if (existing) { - if (existing.fingerprint !== requestFingerprint) { - throw new Error('agent_session_operation_conflict') - } - const replayed = await existing.promise - return { ...replayed, disposition: 'replayed' } - } - if (now - operationTimestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) { - // Why: once a tombstone could have expired, an unseen replay must never - // be reinterpreted as permission to start another fresh agent. - throw new Error('agent_session_operation_expired') - } - let callerOperationCount = 0 - const callerPrefix = `${callerKey}\0` - for (const key of this.agentSessionCreateOperations.keys()) { - if (key.startsWith(callerPrefix)) { - callerOperationCount += 1 - } - } - if ( - callerOperationCount >= AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT || - this.agentSessionCreateOperations.size >= AGENT_SESSION_OPERATION_GLOBAL_LIMIT - ) { - // Why: tombstones cannot be evicted early without making an old replay - // capable of spawning again; reject new IDs until retained entries age out. - throw new Error('agent_session_operation_capacity') - } - let retainReplayFence = false - const operation = (async (): Promise => { - // Why: reserve the client operation before any async preflight so concurrent retries cannot - // both observe an empty ledger and reach the execution owner independently. - const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree) - if ( - !(await this.executionOwnerSupportsAgentSessionOperation( - workspace, - 'create', - caller.signal - )) - ) { - // Why: the exact legacy launch remains client-owned until this pre-spawn check succeeds. - throw new Error('agent_session_legacy_required') - } - const startupCwd = this.resolveWorkspaceTerminalStartupCwd(workspace, request.startupCwd) - // Why: aliases and object property order are client syntax, not authority; - // fingerprint the host-resolved fields in one fixed order. - const resolvedFingerprint = createHash('sha256') - .update( - JSON.stringify([ - workspace.id, - request.agent, - request.prompt ?? null, - request.promptDelivery ?? null, - request.agentArgs ?? null, - request.agentArgs === undefined ? 'host-default' : 'client-override', - request.launchPreferences?.model ?? null, - request.launchPreferences?.effort ?? null, - request.launchPreferences?.mode ?? null, - startupCwd ?? null, - request.presentation ?? null, - request.placement?.tabId ?? null, - request.placement?.leafId ?? null, - request.viewMode ?? null - ]) - ) - .digest('base64url') - const settings = this.store!.getSettings() - if (!isTuiAgentEnabled(request.agent, settings.disabledTuiAgents)) { - throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') - } - const platform = this.getAgentLaunchPlatformForWorkspace(workspace) - const isRemote = workspace.repo - ? repoIsRemote(workspace.repo) - : Boolean(workspace.connectionId) - const shell = resolveLocalWindowsAgentStartupShell({ - platform, - isRemote, - terminalWindowsShell: settings.terminalWindowsShell - }) - const startupArgs = { - agent: request.agent, - cmdOverrides: settings.agentCmdOverrides ?? {}, - agentArgs: - request.agentArgs !== undefined - ? request.agentArgs - : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), - sessionOptions: this.toAgentSessionOptions(request.launchPreferences), - platform, - shell, - isRemote - } - const startup = - request.promptDelivery === 'draft' - ? buildAgentDraftLaunchPlan({ ...startupArgs, draft: request.prompt ?? '' }) - : buildAgentStartupPlan({ - ...startupArgs, - prompt: request.prompt ?? '', - allowEmptyPromptLaunch: true - }) - if (!startup) { - throw new Error('agent_session_identity_required') - } - await this.markWorkspaceTrustedForAgent(request.agent, workspace.connectionId, workspace.path) - if (caller.signal?.aborted) { - throw new Error('client_disconnected') - } - let terminal: RuntimeTerminalCreate - const executionOperationId = createHash('sha256') - .update(this.runtimeId) - .update('\0') - .update(operationKey) - .update('\0') - .update(resolvedFingerprint) - .digest('base64url') - const operationTabId = - request.placement?.tabId ?? deterministicAgentSessionUuid(`${executionOperationId}:tab`) - const operationLeafId = - request.placement?.leafId ?? deterministicAgentSessionUuid(`${executionOperationId}:leaf`) - const operationHandle = `term_${deterministicAgentSessionUuid(`${executionOperationId}:handle`)}` - try { - terminal = await this.createTerminal(`id:${workspace.id}`, { - command: startup.launchCommand, - env: startup.env, - launchConfig: startup.launchConfig, - launchAgent: request.agent, - startupCommandDelivery: startup.startupCommandDelivery, - cwd: startupCwd, - presentation: request.presentation ?? 'background', - tabId: operationTabId, - leafId: operationLeafId, - preAllocatedHandle: operationHandle, - viewMode: request.viewMode, - agentSessionCreateOperationId: executionOperationId, - signal: caller.signal, - onPtySpawnCommitted: () => { - retainReplayFence = true - } - }) - } catch (error) { - if (isAgentSessionOperationOutcomeUnknown(error)) { - retainReplayFence = true - } - throw error - } - return { terminal, disposition: 'created' } - })() - this.agentSessionCreateOperations.set(operationKey, { - fingerprint: requestFingerprint, - promise: operation - }) - const expireOperation = (): void => { - const expiresAt = Math.max(now, operationTimestamp) + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - const timer = setTimeout( - () => { - if (this.agentSessionCreateOperations.get(operationKey)?.promise === operation) { - this.agentSessionCreateOperations.delete(operationKey) - } - }, - Math.max(1, expiresAt - Date.now()) - ) - timer.unref?.() - } - try { - const result = await operation - expireOperation() - return result - } catch (error) { - if (retainReplayFence) { - // Why: the first PTY may still be alive; replay the same failure until - // expiry instead of interpreting a lost outcome as a fresh spawn grant. - expireOperation() - } else if (this.agentSessionCreateOperations.get(operationKey)?.promise === operation) { - this.agentSessionCreateOperations.delete(operationKey) - } - throw error - } - } - - async createTerminal( - worktreeSelector?: string, - opts: TerminalCreateOptions = {} - ): Promise { - if (opts.startupAgent && worktreeSelector === undefined) { - // Why: the launch is resolved against a workspace, so with no selector - // startupAgent is silently dropped and the terminal is a bare shell. - throw new Error(`startupAgent ${opts.startupAgent} requires a workspace selector.`) - } - const presentation = resolveTerminalPresentation(opts) - const requiresRendererFocus = opts.presentation === 'focused' || opts.focus === true - const availableAuthoritativeWindow = this.getAvailableAuthoritativeWindow() - // Why: pre-diff createTerminal fell back to the renderer's active worktree - // when no selector was provided. The new background-spawn branch hard- - // requires a resolvable selector, so route the no-selector case through - // the renderer IPC path to preserve that behavior. - const rendererWindow = opts.rendererBacked === true ? availableAuthoritativeWindow : null - const shouldCreateInBackground = - worktreeSelector !== undefined && - (Boolean(opts.agentSessionClaim) || - (!requiresRendererFocus && opts.rendererBacked !== true) || - // Why: `orca serve` exposes the local runtime without a renderer - // window. Renderer-backed and focus-requested creates are preferred on - // the renderer, but with no window a background spawn is the only - // usable path — otherwise getAuthoritativeWindow() below throws and the - // caller gets no terminal at all (#10333). Focus is not lost: the - // spawned pane is still published and revealed with `activate`. - availableAuthoritativeWindow === null) - - if (shouldCreateInBackground) { - if (!this.ptyController?.spawn) { - throw new Error('runtime_unavailable') - } - const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) - const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts) - let ptySpawnCommitReported = false - const reportPtySpawnCommitted = (): void => { - if (ptySpawnCommitReported) { - return - } - ptySpawnCommitReported = true - launchOpts.onPtySpawnCommitted?.() - } - const cwd = - this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path - let preAllocatedHandle = - launchOpts.preAllocatedHandle ?? this.createPreAllocatedTerminalHandle() - // Why: mint tabId in main before spawn so paneKey is known at PTY env - // build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys - // off `${tabId}:${leafId}` — without these vars set on the PTY, the - // hook payload arrives with an empty paneKey and the renderer cannot - // attribute the event. Use a stable UUID leaf because hooks reject the - // legacy numeric pane keys after the pane-id migration. - const hintedTabId = launchOpts.tabId?.trim() - const canAdoptPaneIdentity = - hintedTabId !== undefined && - isValidHostTerminalTabId(hintedTabId) && - launchOpts.leafId !== undefined && - isTerminalLeafId(launchOpts.leafId) - let tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID() - let leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : randomUUID() - let paneKey = makePaneKey(tabId, leafId) - const claimedStablePaneCreate = this.ptyController.claimStablePaneCreate?.({ - worktreeId: workspace.id, - connectionId: workspace.connectionId, - tabId, - leafId - }) - let stablePaneCreateReleased = false - const releaseStablePaneCreate = (): void => { - if (stablePaneCreateReleased) { - return - } - stablePaneCreateReleased = true - claimedStablePaneCreate?.() - } - try { - if (launchOpts.signal?.aborted) { - throw new Error('client_disconnected') - } - const adoptedBeforeLaunch = await this.ptyController.adoptStablePane?.({ - cols: 120, - rows: 40, - cwd, - connectionId: workspace.connectionId, - worktreeId: workspace.id, - preAllocatedHandle, - tabId, - leafId - }) - const launchToken = launchOpts.launchConfig - ? (launchOpts.launchToken ?? randomUUID()) - : undefined - const baseEnv = { - ...launchOpts.env, - ...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {}) - } - const claudeAgentTeamsSourceCommand = - launchOpts.claudeAgentTeamsSourceCommand?.trim() || - launchOpts.command?.trim() || - undefined - const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode - const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode( - launchOpts.launchConfig, - claudeAgentTeamsSourceCommand, - claudeAgentTeamsMode - ) - let agentTeamsPlan: Awaited> | undefined - try { - agentTeamsPlan = adoptedBeforeLaunch - ? undefined - : await buildClaudeAgentTeamsLaunchPlan({ - command: claudeAgentTeamsSourceCommand, - mode: effectiveClaudeAgentTeamsMode, - baseEnv: { - ...process.env, - ...baseEnv - }, - createTeamEnv: (shimDir, shimBin) => - this.claudeAgentTeams.createLaunchEnv({ - leaderHandle: preAllocatedHandle, - baseEnv: { - ...process.env, - ...baseEnv - }, - shimDir, - shimBin - }).env - }) - } catch (error) { - releaseStablePaneCreate?.() - throw error - } - const sequencedStartupCommand = - agentTeamsPlan && - claudeAgentTeamsSourceCommand && - launchOpts.command && - claudeAgentTeamsSourceCommand !== launchOpts.command - ? agentTeamsPlan.command - : undefined - const effectiveLaunchConfig = - launchOpts.launchConfig && agentTeamsPlan - ? { - ...launchOpts.launchConfig, - agentCommand: launchOpts.launchConfig.agentCommand - ? effectiveClaudeAgentTeamsMode === 'in-process' || process.platform === 'win32' - ? addClaudeTeammateModeInProcess(launchOpts.launchConfig.agentCommand) - : addClaudeTeammateModeAuto(launchOpts.launchConfig.agentCommand) - : agentTeamsPlan.command, - agentEnv: { - ...launchOpts.launchConfig.agentEnv, - ...agentTeamsPlan.env - } - } - : launchOpts.launchConfig - // Why: setup/agent sequencing wraps the PTY launch in a wait shell before - // Claude Agent Teams runs. Preserve the direct Claude command separately - // so the wrapper can exec the teammate-mode variant after setup completes. - const env = this.buildTerminalWorkspaceEnv( - workspace, - { - ...baseEnv, - ...(sequencedStartupCommand - ? { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand } - : {}) - }, - paneKey, - tabId, - agentTeamsPlan?.env - ) - const terminalColorQueryReplies = - launchOpts.terminalColorQueryReplies ?? getTerminalViewColorQueryReplyColors() - if (launchOpts.signal?.aborted) { - throw new Error('client_disconnected') - } - let result: Awaited>> - try { - result = await this.ptyController.spawn({ - cols: 120, - rows: 40, - cwd, - command: sequencedStartupCommand - ? launchOpts.command - : (agentTeamsPlan?.command ?? launchOpts.command), - launchAgent: launchOpts.launchAgent, - commandDelivery: 'provider', - startupCommandDelivery: launchOpts.startupCommandDelivery, - env, - envToDelete: mergeTerminalEnvDeletionKeys( - launchOpts.envToDelete, - agentTeamsPlan?.envToDelete - ), - resumeProviderSession: launchOpts.resumeProviderSession, - telemetry: launchOpts.telemetry, - connectionId: workspace.connectionId, - worktreeId: workspace.id, - preAllocatedHandle, - tabId, - leafId, - ...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}), - ...(launchOpts.agentSessionClaim - ? { - agentSessionEnsure: { - claim: launchOpts.agentSessionClaim, - surface: { - worktreeId: workspace.id, - tabId, - leafId, - terminalHandle: preAllocatedHandle - } - } - } - : {}), - ...(launchOpts.agentSessionCreateOperationId - ? { agentSessionCreateOperationId: launchOpts.agentSessionCreateOperationId } - : {}), - ...(launchOpts.signal ? { signal: launchOpts.signal } : {}), - ...(launchOpts.onPtySpawnCommitted - ? { onPtySpawnCommitted: reportPtySpawnCommitted } - : {}), - ...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}), - ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), - ...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}), - // Why: a host-initiated create has no renderer session writer, so - // without its own binding graph sync cannot classify the terminal - // and prunes the tab out from under a running agent. - persistHostSessionBinding: true - }) - } finally { - releaseStablePaneCreate?.() - } - if (!result.stablePaneOwner) { - reportPtySpawnCommitted() - } - const adoptedStablePane = Boolean(result.stablePaneOwner) - if (result.agentSessionEnsure) { - const canonicalSurface = result.agentSessionEnsure.owner.surface - preAllocatedHandle = canonicalSurface.terminalHandle - tabId = canonicalSurface.tabId - leafId = canonicalSurface.leafId - paneKey = makePaneKey(tabId, leafId) - } else if (result.stablePaneOwner) { - preAllocatedHandle = result.stablePaneOwner.handle - tabId = result.stablePaneOwner.tabId - leafId = result.stablePaneOwner.leafId - paneKey = makePaneKey(tabId, leafId) - } - try { - this.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId) - } catch (error) { - if (error instanceof Error && error.message === 'agent_session_exited_during_start') { - this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId) - } - throw error - } - this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) - if (result.wslDistro) { - this.preparePtyExecutionContext(result.id, result.wslDistro) - } - this.registerPty(result.id, workspace.id, workspace.connectionId, { - tabId, - leafId, - terminalHandle: preAllocatedHandle, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) - }) - if (launchOpts.structuredAgentSessionId) { - agentSessionPtyWriteGate.bindPty(result.id, launchOpts.structuredAgentSessionId) - } - const pty = this.getOrCreatePtyWorktreeRecord(result.id) - if (pty) { - // Released again by releaseRuntimeSessionOwnershipForRendererRetiredTabs - // once the renderer de-persists the tab, i.e. when the user closes it. - pty.runtimeSessionOwned = true - if (!adoptedStablePane) { - if (launchOpts.title) { - const observedAt = this.nextTitleObservationSequence() - pty.title = launchOpts.title - pty.titleUpdatedAt = observedAt - this.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt) - } else { - pty.title = null - pty.titleUpdatedAt = null - } - pty.launchConfig = effectiveLaunchConfig - ? copySleepingAgentLaunchConfig(effectiveLaunchConfig) - : null - pty.launchToken = launchToken ?? null - pty.launchIncarnationId = launchToken ? pty.incarnationId : null - pty.launchAgent = launchOpts.launchAgent ?? null - } - pty.tabId = tabId - pty.paneKey = paneKey - } - const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle - if (pty && !adoptedStablePane && launchOpts.deferMobileSessionPublish !== true) { - this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, { - tabId, - leafId, - title: launchOpts.title ?? null, - activate: presentation === 'focused', - // Why: explicit background presentation may carry legacy activate - // metadata from an already-owned renderer pane; don't select it on mobile. - selectIfNoActiveTab: presentation !== 'background', - ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), - ...(cwd !== workspace.path ? { startupCwd: cwd } : {}) - }) - } - let surface: RuntimeTerminalCreate['surface'] = 'background' - let warning: string | undefined - if (presentation !== 'background' && this.notifier?.revealTerminalSession) { - try { - // Why: after the PTY is spawned, renderer tab adoption is best-effort; - // failing here must not strand a live process without returning a handle. - // Pass the pre-minted tabId so the renderer adopts under the same id - // already baked into the PTY env — keeps paneKey hook attribution intact. - await this.notifier.revealTerminalSession(workspace.id, { - ptyId: result.id, - title: launchOpts.title ?? null, - ...(cwd !== workspace.path ? { cwd } : {}), - ...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}), - ...(launchToken ? { launchToken } : {}), - ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), - ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), - activate: presentation === 'focused', - ...(presentation ? { presentation } : {}), - ...ownerSurfacing(opts.surfaceOwner !== false), - tabId, - leafId - }) - surface = 'visible' - } catch (err) { - console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err) - warning = createTerminalRevealWarning(handle, err) - } - } else if (presentation !== 'background') { - warning = createTerminalRevealWarning(handle) - } - return { - handle, - tabId, - paneKey, - ptyId: result.id, - worktreeId: workspace.id, - title: pty?.title ?? launchOpts.title ?? null, - ...this.getPtyExecutionHostMetadata(result.id), - surface, - ...(result.pid ? { processId: result.pid } : {}), - ...(result.agentSessionEnsure - ? { agentSessionDisposition: result.agentSessionEnsure.disposition } - : {}), - ...(adoptedStablePane ? { isReattach: true as const } : {}), - ...(warning ? { warning } : {}) - } - } finally { - releaseStablePaneCreate() - } - } - - this.assertGraphReady() - const win = rendererWindow ?? this.getAuthoritativeWindow() - // Why: mirrors browserTabCreate — when no worktree is specified, pass - // undefined so the renderer uses its current active worktree. - const workspace = worktreeSelector - ? await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) - : null - const launchOpts = workspace - ? await this.resolveAgentTerminalCreateOptions(workspace, opts) - : opts - const worktreeId = workspace?.id - const cwd = workspace - ? this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) - : launchOpts.cwd - const requestId = randomUUID() - - // Why: terminal creation is a renderer-side Zustand store operation (like - // browser tab creation). The main process sends a request, the renderer - // creates the tab and replies with the tabId so we can resolve the handle. - const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { - const timer = setTimeout(() => { - getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) - reject(new Error('Terminal creation timed out')) - }, 10_000) - - const handler = ( - event: Electron.IpcMainEvent, - r: { requestId: string; tabId?: string; title?: string; error?: string } - ): void => { - if (event.sender !== win.webContents || r.requestId !== requestId) { - return - } - clearTimeout(timer) - getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) - if (r.error) { - reject(new Error(r.error)) - } else { - resolve({ tabId: r.tabId!, title: r.title ?? launchOpts.title ?? '' }) - } - } - getRuntimeDesktopSurface().onIpc('terminal:tabCreateReply', handler) - win.webContents.send('terminal:requestTabCreate', { - requestId, - worktreeId, - command: launchOpts.command, - cwd, - ...(launchOpts.env ? { env: launchOpts.env } : {}), - ...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}), - ...(launchOpts.resumeProviderSession - ? { resumeProviderSession: launchOpts.resumeProviderSession } - : {}), - ...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}), - ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), - ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), - startupCommandDelivery: launchOpts.startupCommandDelivery, - title: launchOpts.title, - activate: presentation === 'focused', - ...(presentation ? { presentation } : {}), - ...ownerSurfacing(opts.surfaceOwner !== false) - }) - }) - - // Why: the renderer created the tab immediately, but the graph sync that - // populates this.leaves may not have arrived yet. Wait for the leaf to - // appear so we can return a valid handle the caller can use right away. - const handle = await this.waitForTerminalHandle(reply.tabId) - return { - handle, - tabId: reply.tabId, - worktreeId: worktreeId ?? '', - title: reply.title, - ...this.getPtyExecutionHostMetadata(this.handles.get(handle)?.ptyId ?? null), - surface: 'visible' - } - } - - async dedupeTerminalCreate( - clientIdentity: string, - worktreeSelector: string | undefined, - clientMutationId: string | undefined, - reconcileExisting: boolean, - run: ( - canonicalWorktreeSelector: string | undefined, - preAllocatedHandle: string | undefined - ) => Promise - ): Promise { - if (!clientMutationId || !worktreeSelector) { - if (reconcileExisting) { - throw new Error('runtime_unavailable') - } - return await run(worktreeSelector, undefined) - } - const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) - const canonicalWorktreeSelector = `id:${workspace.id}` - const preAllocatedHandle = deriveRemoteRuntimeTerminalCreateHandle( - clientIdentity, - workspace.id, - clientMutationId - ) - return this.terminalCreateIdempotency.run( - clientIdentity, - workspace.id, - clientMutationId, - async () => { - if (reconcileExisting) { - const adopted = await this.reconcileRemoteTerminalCreate(workspace.id, preAllocatedHandle) - if (adopted) { - return adopted - } - } - return await run(canonicalWorktreeSelector, preAllocatedHandle) - } - ) - } - - private async reconcileRemoteTerminalCreate( - worktreeId: string, - terminalHandle: string - ): Promise { - if (!this.ptyController?.listProcesses) { - throw new Error('runtime_unavailable') - } - const listed = await withTimeoutResult( - this.ptyController.listProcesses(), - PTY_CONTROLLER_LIST_TIMEOUT_MS - ) - if (!listed.ok) { - // Why: unknown inventory cannot prove the first create failed, so spawning could duplicate a live shell. - throw new Error('runtime_unavailable') - } - const matches = listed.value.filter((session) => session.terminalHandle === terminalHandle) - if (matches.length > 1) { - throw new Error('terminal_create_identity_conflict') - } - if (matches.length === 0) { - const sameWorktreeHasUnknownIdentity = listed.value.some( - (session) => - (session.worktreeId ?? inferWorktreeIdFromPtyId(session.id)) === worktreeId && - !session.terminalHandle - ) - if (sameWorktreeHasUnknownIdentity) { - // Why: older retained providers may list the first shell without its handle; absence is not authoritative in that shape. - throw new Error('runtime_unavailable') - } - return null - } - const session = matches[0] - const authoritativeWorktreeId = session.worktreeId ?? inferWorktreeIdFromPtyId(session.id) - if (authoritativeWorktreeId !== worktreeId) { - // Why: a reused address or forged provider record must never adopt a PTY from another workspace. - throw new Error('terminal_create_identity_conflict') - } - this.adoptControllerTerminalHandle(session.id, terminalHandle) - const pty = this.recordPtyWorktree(session.id, worktreeId, { - connected: true, - title: session.title - }) - const adoptedHandle = this.issuePtyHandle(pty) - if (adoptedHandle !== terminalHandle) { - throw new Error('terminal_create_identity_conflict') - } - return { - handle: adoptedHandle, - ptyId: session.id, - worktreeId, - title: session.title || null, - surface: 'background' - } - } - - private getPtyExecutionHostMetadata( - ptyId: string | null - ): Pick { - if (!ptyId) { - return {} - } - const pty = this.ptysById.get(ptyId) - if (!pty) { - return {} - } - if (pty.connectionId) { - const remotePlatform = getRegisteredSshState(pty.connectionId)?.remotePlatform - return { - executionHostId: toSshExecutionHostId(pty.connectionId), - ...(remotePlatform ? { hostPlatform: remotePlatform } : {}) - } - } - return { - executionHostId: LOCAL_EXECUTION_HOST_ID, - hostPlatform: pty.isWsl || pty.wslDistro ? 'linux' : process.platform - } - } - - async launchAgentTerminal( - worktreeSelector: string, - opts: { agent: TuiAgent; prompt: string; title?: string } - ): Promise { - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - const repo = this.store?.getRepo(worktree.repoId) - if (!repo) { - throw new Error('Repository for the selected workspace is no longer available.') - } - const startup = this.buildStartupForAgent(repo, opts.agent, opts.prompt) - await this.markWorkspaceTrustedForAgent(opts.agent, repo.connectionId, worktree.path) - return await this.createTerminal(`id:${worktree.id}`, { - command: startup.startup.command, - env: startup.startup.env, - ...(startup.startup.launchConfig ? { launchConfig: startup.startup.launchConfig } : {}), - launchAgent: startup.agent, - startupCommandDelivery: startup.startup.startupCommandDelivery, - telemetry: startup.startup.telemetry, - title: opts.title - }) - } - - // Why: dedupes a worktree.create whose response was lost when a mobile - // connection migration (relay/direct hand-off on shoddy cellular) rejected the - // in-flight request. A retry with the same clientMutationId returns the - // in-flight or just-finished create instead of a duplicate worktree; failures - // drop immediately so a genuine retry starts fresh, and successes linger - // briefly so a retry whose response was lost in the cutover still reconciles. - dedupeWorktreeCreate( - repoSelector: string, - clientMutationId: string | undefined, - run: () => Promise - ): Promise { - if (!clientMutationId) { - return run() - } - const key = `${repoSelector}\0${clientMutationId}` - const inflight = this.worktreeCreateByMutationId.get(key) - if (inflight) { - return inflight as Promise - } - const created = run() - this.worktreeCreateByMutationId.set(key, created) - const drop = (): void => { - if (this.worktreeCreateByMutationId.get(key) === created) { - this.worktreeCreateByMutationId.delete(key) - } - } - void created.then(() => { - setTimeout(drop, WORKTREE_CREATE_RESULT_TTL_MS).unref?.() - }, drop) - return created - } - - async createMobileSessionTerminal( - worktreeSelector: string, - opts: { - afterTabId?: string - targetGroupId?: string - command?: string - cwd?: string - env?: Record - envToDelete?: string[] - startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] - agent?: TuiAgent - agentPrompt?: string - launchConfig?: SleepingAgentLaunchConfig - launchAgent?: TuiAgent - viewMode?: 'terminal' | 'chat' - activate?: boolean - select?: boolean - clientNavigationId?: string - navigation?: RuntimeNavigationTarget - clientMutationId?: string - signal?: AbortSignal - } = {} - ): Promise { - const navigation = opts.navigation ?? 'all' - const select = opts.select ?? opts.activate !== false - const runOpts = { - ...opts, - activate: select && navigationTargetsHost(navigation) - } - const mutationId = opts.clientMutationId - let result: RuntimeMobileSessionCreateTerminalResult - if (!mutationId) { - result = await this.runCreateMobileSessionTerminal(worktreeSelector, runOpts) - } else { - // Why: idempotency is caller-owned; two paired devices may reuse the same mutation id without sharing a result. - const mutationKey = `${opts.clientNavigationId ?? 'local'}\0${worktreeSelector}\0${mutationId}` - // Why: a retried create (double-tap, reconnect replay) with the same - // idempotency key must return the in-flight operation instead of spawning a - // duplicate terminal. Successes are kept briefly so a retry whose response - // was lost in transit reuses the created terminal; failures are dropped - // immediately so a retry can start a fresh create. - const inflight = this.mobileTerminalCreateByMutationId.get(mutationKey) - const run = inflight ?? this.runCreateMobileSessionTerminal(worktreeSelector, runOpts) - if (!inflight) { - this.mobileTerminalCreateByMutationId.set(mutationKey, run) - const drop = (): void => { - if (this.mobileTerminalCreateByMutationId.get(mutationKey) === run) { - this.mobileTerminalCreateByMutationId.delete(mutationKey) - } - } - void run.then(() => { - setTimeout(drop, MOBILE_TERMINAL_CREATE_RESULT_TTL_MS).unref?.() - }, drop) - } - result = await run - } - if (select) { - const worktreeId = - this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) ?? - (await this.resolveWorktreeSelector(worktreeSelector)).id - this.applyMobileSessionTabNavigation( - this.getMobileSessionTabsForWorktree(worktreeId), - result.tab.id, - navigation, - opts.clientNavigationId - ) - } - return result - } - - private async runCreateMobileSessionTerminal( - worktreeSelector: string, - opts: { - afterTabId?: string - targetGroupId?: string - command?: string - cwd?: string - env?: Record - envToDelete?: string[] - startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] - agent?: TuiAgent - agentPrompt?: string - launchConfig?: SleepingAgentLaunchConfig - launchAgent?: TuiAgent - viewMode?: 'terminal' | 'chat' - activate?: boolean - clientNavigationId?: string - clientMutationId?: string - signal?: AbortSignal - } = {} - ): Promise { - const pairedCreate = Boolean(opts.clientNavigationId) - const graphEpoch = this.captureReadyGraphEpoch() - const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) - const worktreeId = workspace.id - const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd) - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) - let afterDesktopTabId: string | undefined - if (opts.afterTabId) { - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - const anchor = snapshot?.tabs.find((tab) => tab.id === opts.afterTabId) - if (!anchor) { - throw new Error('after_tab_not_found') - } - afterDesktopTabId = anchor.type === 'terminal' ? anchor.parentTabId : anchor.id - } - const startupCommand = await this.resolveMobileSessionTerminalCommand(workspace, opts) - this.assertStableReadyGraph(graphEpoch) - if (opts.signal?.aborted) { - throw new Error('client_disconnected') - } - const win = this.getAvailableAuthoritativeWindow() - if (!win) { - return await this.createRuntimeOwnedMobileSessionTerminal( - worktreeId, - opts.activate !== false, - opts.afterTabId, - { - command: startupCommand.command, - cwd, - env: startupCommand.env, - envToDelete: startupCommand.envToDelete, - startupCommandDelivery: startupCommand.startupCommandDelivery, - launchAgent: startupCommand.launchAgent, - viewMode: opts.viewMode, - targetGroupId: opts.targetGroupId, - launchConfig: startupCommand.launchConfig, - signal: opts.signal - } - ) - } - if (win.webContents.isDestroyed?.()) { - throw new Error('runtime_unavailable') - } - const releasePublicationThrottle = pairedCreate - ? this.rendererPublicationThrottle.acquire(win.webContents) - : () => {} - try { - const requestId = randomUUID() - const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { - const timer = setTimeout(() => { - getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) - opts.signal?.removeEventListener('abort', onAbort) - reject(new Error('Terminal creation timed out')) - }, 10_000) - // Why: a dead client connection cancels the wait; the renderer tab (and - // its shell) stays alive for the host and mirrors on reconnect (#7718). - const onAbort = (): void => { - clearTimeout(timer) - getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) - reject(new Error('client_disconnected')) - } - - const handler = ( - event: Electron.IpcMainEvent, - r: { requestId: string; tabId?: string; title?: string; error?: string } - ): void => { - if (event.sender !== win.webContents || r.requestId !== requestId) { - return - } - clearTimeout(timer) - getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler) - opts.signal?.removeEventListener('abort', onAbort) - if (r.error) { - reject(new Error(r.error)) - } else { - resolve({ tabId: r.tabId!, title: r.title ?? '' }) - } - } - opts.signal?.addEventListener('abort', onAbort, { once: true }) - getRuntimeDesktopSurface().onIpc('terminal:tabCreateReply', handler) - win.webContents.send('terminal:requestTabCreate', { - requestId, - worktreeId, - afterTabId: afterDesktopTabId, - targetGroupId: opts.targetGroupId, - command: startupCommand.command, - cwd, - ...(startupCommand.env ? { env: startupCommand.env } : {}), - ...(startupCommand.envToDelete ? { envToDelete: startupCommand.envToDelete } : {}), - ...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}), - ...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}), - ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), - startupCommandDelivery: startupCommand.startupCommandDelivery, - source: 'runtime-session', - activate: opts.activate - }) - }) - - if (opts.activate !== false) { - this.notifier?.focusTerminal(reply.tabId, worktreeId, null) - } - // Why: register the wait before the renderer's PTY spawn arrives so that - // spawn (registerPty) can publish the pty-backed surface main-side even if - // graph-sync is stalled (#7587). Removed in the finally below. - const pendingCreateKey = `${worktreeId}::${reply.tabId}` - // Why: a rescue publishes into the active group (opts.targetGroupId is not - // threaded); the renderer's reconciling publication then moves the tab to the - // requested group, so any wrong-group placement is cosmetic and stall-window-only. - this.pendingMobileTerminalCreatesByKey.set(pendingCreateKey, { - activate: opts.activate !== false, - paired: pairedCreate, - selectIfNoActiveTab: true, - ...(startupCommand.command ? { startupCommand: startupCommand.command } : {}), - ...(opts.viewMode ? { viewMode: opts.viewMode } : {}) - }) - try { - // Why: the PTY spawn and the tabCreate reply race on independent IPC - // channels; if the spawn already registered, publish immediately so the - // wait resolves without depending on a graph sync. - this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId) - const surface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, { - timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS, - signal: opts.signal - }) - if (this.isReadyMobileTerminalSurface(surface)) { - this.deliverPendingStartupCommandToBareRendererPty(worktreeId, reply.tabId) - return surface - } - const readySurface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, { - timeoutMs: MOBILE_TERMINAL_READY_FALLBACK_MS, - requireReady: true, - signal: opts.signal - }).catch(() => null) - if (readySurface) { - this.deliverPendingStartupCommandToBareRendererPty(worktreeId, reply.tabId) - return readySurface - } - if (opts.signal?.aborted) { - // Why: nobody awaits this create anymore; don't materialize or roll back — the renderer's own publication settles the tab. - throw new Error('client_disconnected') - } - const pendingSurface = this.findMobileTerminalSurface(worktreeId, reply.tabId) - if (!pendingSurface) { - throw new Error('Timed out waiting for terminal surface after creation') - } - // Why: a hidden renderer can publish the tab shell before the PTY spawns; reuse the same identity so later focus adopts instead of creating another tab. - return await this.createRuntimeOwnedMobileSessionTerminal( - worktreeId, - opts.activate !== false, - opts.afterTabId, - { - command: startupCommand.command, - cwd, - env: startupCommand.env, - envToDelete: startupCommand.envToDelete, - startupCommandDelivery: startupCommand.startupCommandDelivery, - identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId }, - launchAgent: startupCommand.launchAgent, - viewMode: opts.viewMode, - targetGroupId: opts.targetGroupId, - launchConfig: startupCommand.launchConfig, - signal: opts.signal - } - ) - } catch (error) { - // Why: publication latency (hidden renderer) can trip the surface timeout; rescue only when a live PTY backs the tab, else a ghost tab skips rollback (#7587). - if (this.findLiveRegisteredPtyForRendererTab(worktreeId, reply.tabId)) { - const rescued = this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId) - if (rescued) { - this.deliverPendingStartupCommandToBareRendererPty(worktreeId, reply.tabId) - return rescued - } - } - // Why: don't roll back on a client disconnect or a live shell already backing the tab — that would kill a visible terminal ("tab dies after ~10s", #7718). - if ( - isClientDisconnectedError(error) || - this.hasLiveShellForRendererTab(worktreeId, reply.tabId) - ) { - throw error - } - // Why: renderer made the tab but no live PTY backs it (real spawn/handle failure); roll it back so it can't linger as a ghost in mobile snapshots. - this.notifier?.closeTerminal(reply.tabId) - throw error - } finally { - this.pendingMobileTerminalCreatesByKey.delete(pendingCreateKey) - } - } finally { - releasePublicationThrottle() - } - } - - private async resolveMobileSessionTerminalCommand( - workspace: TerminalWorkspaceLaunchScope, - opts: { - command?: string - env?: Record - envToDelete?: string[] - startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] - agent?: TuiAgent - agentPrompt?: string - launchConfig?: SleepingAgentLaunchConfig - launchAgent?: TuiAgent - } - ): Promise<{ - command?: string - env?: Record - envToDelete?: string[] - startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] - launchConfig?: SleepingAgentLaunchConfig - launchAgent?: TuiAgent - }> { - if (opts.command || !opts.agent) { - return { - command: opts.command, - env: opts.env, - envToDelete: opts.envToDelete, - launchConfig: opts.launchConfig, - launchAgent: opts.launchAgent, - startupCommandDelivery: opts.startupCommandDelivery - } - } - if (!this.store) { - throw new Error('runtime_unavailable') - } - const settings = this.store.getSettings() - if (!isTuiAgentEnabled(opts.agent, settings.disabledTuiAgents)) { - throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') - } - // Why: mobile may be iOS while the shell host is Windows/macOS/Linux or SSH Linux; quote for the host shell. - const platform = this.getAgentLaunchPlatformForWorkspace(workspace) - // Why: SSH runs the CLI through the relay shim (plain `orca`), so the Linux-only `orca-ide` rename must not apply. - const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : repoIsRemote(workspace) - const queuedShell = resolveLocalWindowsAgentStartupShell({ - platform, - isRemote, - terminalWindowsShell: settings.terminalWindowsShell - }) - const startupPlan = buildAgentStartupPlan({ - agent: opts.agent, - prompt: opts.agentPrompt ?? '', - cmdOverrides: settings.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(opts.agent, settings.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(opts.agent, settings.agentDefaultEnv), - platform, - shell: queuedShell, - isRemote, - allowEmptyPromptLaunch: true - }) - if (!startupPlan) { - throw new Error(`Could not build launch command for ${opts.agent}.`) - } - if (opts.agentPrompt && startupPlan.followupPrompt) { - throw new Error(`Agent ${opts.agent} does not support startup prompt quick commands.`) - } - await this.markWorkspaceTrustedForAgent(opts.agent, workspace.connectionId, workspace.path) - return { - command: startupPlan.launchCommand, - env: startupPlan.env, - // Why: a real-home Codex resume strips inherited CODEX_HOME via - // envToDelete; dropping it here would resume against the wrong home. - envToDelete: opts.envToDelete, - launchConfig: startupPlan.launchConfig, - launchAgent: opts.agent, - startupCommandDelivery: startupPlan.startupCommandDelivery - } - } - - private async createRuntimeOwnedMobileSessionTerminal( - worktreeId: string, - activate: boolean, - afterTabId?: string, - opts: { - command?: string - cwd?: string - env?: Record - envToDelete?: string[] - startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] - identity?: { tabId: string; leafId: string; sessionId?: string } - launchAgent?: TuiAgent - viewMode?: 'terminal' | 'chat' - targetGroupId?: string - launchConfig?: SleepingAgentLaunchConfig - signal?: AbortSignal - } = {} - ): Promise { - const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) - const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd) - // Why: SshPtyProvider treats sessionId as a relay reattach; only synthesize local serve ids so SSH fresh terminals still call pty.spawn. - const stableSessionId = - opts.identity?.sessionId ?? (workspace.connectionId ? undefined : `serve-${randomUUID()}`) - const isNewSession = stableSessionId !== undefined && opts.identity?.sessionId === undefined - const terminal = await this.createTerminal(`id:${worktreeId}`, { - focus: false, - command: opts.command, - cwd, - env: opts.env, - envToDelete: opts.envToDelete, - ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), - ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), - ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), - startupCommandDelivery: opts.startupCommandDelivery, - ...(opts.identity - ? { - tabId: opts.identity.tabId, - leafId: opts.identity.leafId, - ...(stableSessionId ? { sessionId: stableSessionId } : {}) - } - : stableSessionId - ? { sessionId: stableSessionId } - : {}), - ...(isNewSession ? { isNewSession: true } : {}), - // Why: this method publishes the authoritative snapshot below; skip the intermediate publish to avoid a wrong-group flash. - deferMobileSessionPublish: true, - signal: opts.signal - }) - const livePty = this.getLivePtyForHandle(terminal.handle) - if (!livePty) { - throw new Error('terminal_handle_stale') - } - const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}` - const leafId = parsePaneKey(livePty.pty.paneKey ?? '')?.leafId ?? randomUUID() - if (opts.viewMode) { - // Why: the runtime-owned binding must survive a serve restart with the same initial mode, not a later client's local default. - this.persistHeadlessSessionTabProps(worktreeId, parentTabId, { viewMode: opts.viewMode }) - } - const existing = this.mobileSessionTabsByWorktree.get(worktreeId) - const existingSurface = - existing?.tabs.find( - (candidate): candidate is RuntimeMobileSessionTerminalTab => - candidate.type === 'terminal' && - candidate.parentTabId === parentTabId && - candidate.leafId === leafId - ) ?? null - const parentLayout = this.buildMaterializedHeadlessParentLayout( - leafId, - livePty.pty.ptyId, - existingSurface?.parentLayout - ) - const tab: RuntimeMobileSessionTerminalTab = { - type: 'terminal', - id: `${parentTabId}::${leafId}`, - parentTabId, - leafId, - ptyId: livePty.pty.ptyId, - title: terminal.title ?? livePty.pty.title ?? 'Terminal', - ...(cwd ? { startupCwd: cwd } : {}), - ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), - ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), - parentLayout, - isActive: activate - } - const tabs = (existing?.tabs ?? []) - .filter((candidate) => candidate.id !== tab.id) - .map((candidate) => ({ - ...candidate, - ...(candidate.type === 'terminal' && candidate.parentTabId === parentTabId - ? { parentLayout } - : {}), - isActive: activate ? false : candidate.isActive - })) - const insertAfter = afterTabId ? tabs.findIndex((candidate) => candidate.id === afterTabId) : -1 - if (insertAfter >= 0) { - tabs.splice(insertAfter + 1, 0, tab) - } else { - tabs.push(tab) - } - const next: RuntimeMobileSessionTabsSnapshot = { - worktree: worktreeId, - publicationEpoch: `headless:${Date.now().toString(36)}`, - snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, - // Why: activating the new tab also focuses its group, so a "+" targeting a specific split group makes that group active too. - activeGroupId: - activate && opts.targetGroupId - ? opts.targetGroupId - : (existing?.activeGroupId ?? this.getHeadlessMobileSessionGroupId(worktreeId)), - activeTabId: activate ? tab.id : (existing?.activeTabId ?? null), - activeTabType: activate ? 'terminal' : (existing?.activeTabType ?? null), - tabGroups: this.buildHeadlessMobileSessionTabGroups( - worktreeId, - tabs, - activate ? tab : null, - existing?.tabGroups, - opts.targetGroupId ? { tabId: parentTabId, groupId: opts.targetGroupId } : undefined - ), - // Why: keep group split geometry on new-tab creation, else opening a terminal while split loses the arrangement. - ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), - tabs - } - this.mobileSessionTabsByWorktree.set(worktreeId, next) - const result = this.toMobileSessionTabsResult(next) - const changeSequence = ++this.mobileSessionTabsChangeSequence - for (const subscription of this.mobileSessionTabListeners) { - subscription.listener( - this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId), - changeSequence - ) - } - const created = result.tabs.find((candidate) => candidate.id === tab.id) - if (!created || created.type !== 'terminal') { - throw new Error('terminal_handle_stale') - } - return { - tab: created, - publicationEpoch: result.publicationEpoch, - snapshotVersion: result.snapshotVersion - } - } - - private waitForMobileTerminalSurface( - worktreeId: string, - parentTabId: string, - options: { timeoutMs?: number; requireReady?: boolean; signal?: AbortSignal } = {} - ): Promise { - const timeoutMs = options.timeoutMs ?? MOBILE_TERMINAL_SURFACE_TIMEOUT_MS - const existing = this.findMobileTerminalSurface(worktreeId, parentTabId, options) - if (existing) { - return Promise.resolve(existing) - } - if (options.signal?.aborted) { - return Promise.reject(new Error('client_disconnected')) - } - - return new Promise((resolve, reject) => { - const cleanup = (): void => { - clearTimeout(timer) - options.signal?.removeEventListener('abort', onAbort) - const idx = this.graphSyncCallbacks.indexOf(check) - if (idx !== -1) { - this.graphSyncCallbacks.splice(idx, 1) - } - } - const timer = setTimeout(() => { - cleanup() - reject(new Error('Timed out waiting for terminal surface after creation')) - }, timeoutMs) - // Why: a dead client connection cancels the wait immediately instead of running down the timeout into rollback (#7718). - const onAbort = (): void => { - cleanup() - reject(new Error('client_disconnected')) - } - options.signal?.addEventListener('abort', onAbort, { once: true }) - - const check = (): void => { - const next = this.findMobileTerminalSurface(worktreeId, parentTabId, options) - if (!next) { - return - } - cleanup() - resolve(next) - } - this.graphSyncCallbacks.push(check) - check() - }) - } - - private findMobileTerminalSurface( - worktreeId: string, - parentTabId: string, - options: { requireReady?: boolean } = {} - ): RuntimeMobileSessionCreateTerminalResult | null { - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - if (!snapshot) { - return null - } - const result = this.toMobileSessionTabsResult(snapshot) - const tab = result.tabs.find( - (candidate) => candidate.type === 'terminal' && candidate.parentTabId === parentTabId - ) - if (!tab || tab.type !== 'terminal') { - return null - } - const surface = { - tab, - publicationEpoch: result.publicationEpoch, - snapshotVersion: result.snapshotVersion - } - if (options.requireReady === true && !this.isReadyMobileTerminalSurface(surface)) { - return null - } - return surface - } - - private findMobileTerminalSurfaceForPty( - worktreeId: string, - ptyId: string - ): RuntimeMobileSessionCreateTerminalResult | null { - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - if (!snapshot) { - return null - } - const result = this.toMobileSessionTabsResult(snapshot) - const tabs = result.tabs.filter( - (candidate): candidate is RuntimeMobileSessionTerminalClientTab => - candidate.type === 'terminal' && - (candidate.ptyId === ptyId || - candidate.parentLayout?.ptyIdsByLeafId?.[candidate.leafId] === ptyId) - ) - if (tabs.length === 0 || new Set(tabs.map((tab) => tab.parentTabId)).size !== 1) { - return null - } - return { - tab: tabs[0]!, - publicationEpoch: result.publicationEpoch, - snapshotVersion: result.snapshotVersion - } - } - - private resolvePtyTabCloseSurfaceAuthority( - authority: RuntimePtyTabCloseAuthority - ): { pty: RuntimePtyWorktreeRecord; surface: RuntimeMobileSessionCreateTerminalResult } | null { - const live = this.getLivePtyForHandle(authority.handle) - if ( - !live || - live.pty.ptyId !== authority.ptyId || - live.pty.worktreeId !== authority.worktreeId || - live.record.worktreeId !== authority.worktreeId || - live.pty.incarnationId !== authority.incarnationId - ) { - return null - } - const surface = this.findMobileTerminalSurfaceForPty(authority.worktreeId, authority.ptyId) - if (!surface) { - return null - } - const session = this.getWorkspaceSessionForWorktree(authority.worktreeId) - const sessionWorktreeId = session - ? resolveTerminalSessionWorktreeId(session, authority.worktreeId) - : null - const persistedTab = sessionWorktreeId - ? session?.tabsByWorktree[sessionWorktreeId]?.find( - (tab) => tab.id === surface.tab.parentTabId - ) - : undefined - if (persistedTab && !worktreeIdsEqual(persistedTab.worktreeId, authority.worktreeId)) { - return null - } - const paneKey = makePaneKey(surface.tab.parentTabId, surface.tab.leafId) - const persistedPtyId = - session?.terminalLayoutsByTabId?.[surface.tab.parentTabId]?.ptyIdsByLeafId?.[ - surface.tab.leafId - ] ?? null - const persistedIncarnationId = session?.terminalPtyIncarnationsByPaneKey?.[paneKey] ?? null - if ( - (persistedPtyId && persistedPtyId !== authority.ptyId) || - (persistedIncarnationId && persistedIncarnationId !== authority.incarnationId) - ) { - return null - } - if ( - !this.resolveTerminalSplitSourceAuthority( - authority.worktreeId, - surface.tab.parentTabId, - surface.tab.leafId, - authority.ptyId - ) - ) { - return null - } - return { pty: live.pty, surface } - } - - // Why: publish an in-flight mobile create main-side from the live PTY so it can't stall on graph sync and destroy the session (#7587). - private ensurePtyBackedMobileSurfaceForRendererTab( - worktreeId: string, - tabId: string - ): RuntimeMobileSessionCreateTerminalResult | null { - const pending = this.pendingMobileTerminalCreatesByKey.get(`${worktreeId}::${tabId}`) - if (!pending) { - return null - } - const existing = this.findMobileTerminalSurface(worktreeId, tabId) - const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId) - if (pty) { - pty.runtimeSessionOwned = true - if (pending.paired) { - this.setPairedRendererSessionOwnership(pty.ptyId, true) - } - } - if ( - existing && - this.isReadyMobileTerminalSurface(existing) && - (pending.viewMode === undefined || existing.tab.viewMode === pending.viewMode) - ) { - // Why: the renderer's ready publication already landed with the intended mode; only a pending shell needs the main-side rescue. - return existing - } - const leafId = pty ? parsePaneKey(pty.paneKey ?? '')?.leafId : undefined - if (!pty || !leafId) { - return existing - } - this.publishPtyBackedMobileSessionTerminal(worktreeId, pty, { - tabId, - leafId, - title: null, - activate: pending.activate, - selectIfNoActiveTab: pending.selectIfNoActiveTab, - ...(pending.viewMode ? { viewMode: pending.viewMode } : {}) - }) - // Why: check closures normally drain only inside syncWindowGraph; a main-side publish must drain them too or the pending wait misses the insertion. - for (const cb of [...this.graphSyncCallbacks]) { - cb() - } - return this.findMobileTerminalSurface(worktreeId, tabId) - } - - private restoreLivePairedRendererSessionOwnedMobileTerminals( - worktreeId: string | null, - options: { missingSnapshotOnly?: boolean; notify?: boolean } = {} - ): void { - for (const ptyId of this.pairedRendererSessionOwnedPtyIds) { - const pty = this.ptysById.get(ptyId) - if ( - !pty?.connected || - !pty.tabId || - (worktreeId !== null && !worktreeIdsEqual(pty.worktreeId, worktreeId)) - ) { - continue - } - const targetWorktreeId = worktreeId ?? pty.worktreeId - const pane = parsePaneKey(pty.paneKey ?? '') - if (!pane || pane.tabId !== pty.tabId) { - continue - } - const existing = this.mobileSessionTabsByWorktree.get(targetWorktreeId) - if (existing && options.missingSnapshotOnly) { - continue - } - if ( - existing?.tabs.some( - (tab) => - tab.type === 'terminal' && - (tab.ptyId === pty.ptyId || - (tab.parentTabId === pty.tabId && tab.leafId === pane.leafId)) - ) - ) { - continue - } - if (!existing) { - this.mobileSessionTabsByWorktree.set(targetWorktreeId, { - worktree: targetWorktreeId, - publicationEpoch: `renderer-rescue:${Date.now().toString(36)}`, - snapshotVersion: 0, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabGroups: [], - tabs: [] - }) - } - this.publishPtyBackedMobileSessionTerminal(targetWorktreeId, pty, { - tabId: pty.tabId, - leafId: pane.leafId, - title: null, - activate: false, - selectIfNoActiveTab: false, - notify: options.notify - }) - } - } - - private setPairedRendererSessionOwnership(ptyId: string, owned: boolean): void { - if (owned) { - this.pairedRendererSessionOwnedPtyIds.add(ptyId) - } else { - this.pairedRendererSessionOwnedPtyIds.delete(ptyId) - } - } - - private findLiveRegisteredPtyForRendererTab( - worktreeId: string, - tabId: string - ): RuntimePtyWorktreeRecord | null { - for (const pty of this.ptysById.values()) { - if ( - pty.worktreeId === worktreeId && - pty.tabId === tabId && - pty.connected && - parsePaneKey(pty.paneKey ?? '')?.leafId - ) { - return pty - } - } - return null - } - - // Why: looser rollback guard than findLiveRegisteredPtyForRendererTab — a shell without a registered pane key is still a real terminal the timeout must not kill (#7718). - private hasLiveShellForRendererTab(worktreeId: string, tabId: string): boolean { - for (const pty of this.ptysById.values()) { - if (pty.worktreeId === worktreeId && pty.tabId === tabId && pty.connected) { - return true - } - } - return false - } - - private isReadyMobileTerminalSurface( - surface: RuntimeMobileSessionCreateTerminalResult | null - ): boolean { - return ( - surface?.tab.status === 'ready' && - typeof surface.tab.terminal === 'string' && - surface.tab.terminal.length > 0 - ) - } - - // Why: a create can settle over a renderer PTY that spawned without its - // startup command (the create's renderer stalled, #7587), silently binding - // the client to a plain shell under an agent tab forever — once the surface - // is ready, the activation-time materialize recovery (#7837) never runs - // (STA-3214). Spawn commands are recorded per PTY at spawn time, so a - // missing record on the locally registered live PTY proves the launch never - // ran; type it into the shell like the create would have. - private deliverPendingStartupCommandToBareRendererPty(worktreeId: string, tabId: string): void { - const pending = this.pendingMobileTerminalCreatesByKey.get(`${worktreeId}::${tabId}`) - const command = pending?.startupCommand - if (!command) { - return - } - const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId) - if (!pty || this.terminalSpawnCommandsByPtyId.has(pty.ptyId)) { - return - } - if (this.ptyController?.write(pty.ptyId, command)) { - // Why: Enter rides its own write so a long command cannot swallow it. - this.ptyController.write(pty.ptyId, '\r') - this.noteTerminalSpawnCommand(pty.ptyId, command) - } - } - - private waitForTerminalHandle(tabId: string, timeoutMs = 10_000): Promise { - const existing = this.resolveHandleForTab(tabId) - if (existing) { - return Promise.resolve(existing) - } - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - const idx = this.graphSyncCallbacks.indexOf(check) - if (idx !== -1) { - this.graphSyncCallbacks.splice(idx, 1) - } - reject(new Error('Timed out waiting for terminal handle after creation')) - }, timeoutMs) - - const check = (): void => { - const handle = this.resolveHandleForTab(tabId) - if (handle) { - clearTimeout(timer) - const idx = this.graphSyncCallbacks.indexOf(check) - if (idx !== -1) { - this.graphSyncCallbacks.splice(idx, 1) - } - resolve(handle) - } - } - this.graphSyncCallbacks.push(check) - // Why: graph sync may have fired between the initial check and registration; re-check to avoid a missed wake-up. - check() - }) - } - - // Why: mobile may subscribe before the PTY spawns; wait for it so subscribe proceeds with phone-fit instead of a bare scrollback+end. - waitForLeafPtyId(handle: string, timeoutMs = 10_000, signal?: AbortSignal): Promise { - const leaf = this.resolveLeafForHandle(handle) - if (leaf?.ptyId) { - return Promise.resolve(leaf.ptyId) - } - - // Why: ptyId null→real invalidates the old handle; capture tabId+leafId now for direct leaf lookup afterward. - const record = this.handles.get(handle) - const savedTabId = record?.tabId ?? null - const savedLeafId = record?.leafId ?? null - - return new Promise((resolve, reject) => { - let timer: ReturnType | null = null - let check: () => void = () => {} - const cleanup = (): void => { - if (timer) { - clearTimeout(timer) - timer = null - } - const idx = this.graphSyncCallbacks.indexOf(check) - if (idx !== -1) { - this.graphSyncCallbacks.splice(idx, 1) - } - signal?.removeEventListener('abort', onAbort) - } - const finish = (ptyId: string): void => { - cleanup() - resolve(ptyId) - } - const fail = (error: Error): void => { - cleanup() - reject(error) - } - const onAbort = (): void => { - fail(new Error('request_aborted')) - } - if (signal?.aborted) { - reject(new Error('request_aborted')) - return - } - signal?.addEventListener('abort', onAbort, { once: true }) - timer = setTimeout(() => { - fail(new Error('Timed out waiting for PTY to spawn')) - }, timeoutMs) - - check = (): void => { - // Try the handle first (works if handle wasn't invalidated yet) - let ptyId = this.resolveLeafForHandle(handle)?.ptyId - // Why: ptyId null→real invalidates the old handle; fall back to direct leaf lookup by saved coordinates. - if (!ptyId && savedTabId && savedLeafId) { - const directLeaf = this.leaves.get(this.getLeafKey(savedTabId, savedLeafId)) - ptyId = directLeaf?.ptyId ?? null - } - if (ptyId) { - finish(ptyId) - } - } - this.graphSyncCallbacks.push(check) - check() - }) - } - - // Why: never-mounted tabs have no PTY or snapshot; synthetic handles need the ptyId to mount the exact owning tab. - requestRendererTerminalTabMount(handle: string): boolean { - const record = this.handles.get(handle) - if (!record?.worktreeId) { - return false - } - const tabId = record.tabId.startsWith('pty:') ? undefined : record.tabId - const ptyId = record.ptyId ?? undefined - if (!tabId && !ptyId) { - return false - } - try { - this.getAuthoritativeWindow().webContents.send('terminal:requestTabMount', { - worktreeId: record.worktreeId, - ...(tabId ? { tabId } : {}), - ...(ptyId ? { ptyId } : {}) - }) - return true - } catch { - // No authoritative window (shutdown/headless): subscribe keeps its empty-snapshot fallback. - return false - } - } - - getRendererTerminalSerializerGeneration(ptyId: string): number { - return this.ptyController?.getRendererSerializerGeneration?.(ptyId) ?? 0 - } - - getRendererTerminalSerializerGenerationForHandle(handle: string): number { - const ptyId = this.handles.get(handle)?.ptyId - return ptyId ? this.getRendererTerminalSerializerGeneration(ptyId) : 0 - } - - replaceHeadlessTerminalFromRendererSnapshotForRecovery( - ptyId: string, - snapshot: { - data: string - cols: number - rows: number - cwd?: string | null - oscLinks?: TerminalOscLinkRange[] - }, - trailingOutput: { data: string; seq: number }[] = [] - ): void { - if (!snapshot.data) { - return - } - // Why: a redraw byte can create a suffix-only model before the renderer settles; replace it with the exact snapshot already sent mobile. - this.providerSnapshotPreferredPtys.add(ptyId) - this.disposeHeadlessTerminal(ptyId) - this.seedHeadlessTerminal( - ptyId, - snapshot.data, - { cols: snapshot.cols, rows: snapshot.rows }, - { cwd: snapshot.cwd, oscLinks: snapshot.oscLinks } - ) - for (const chunk of trailingOutput) { - this.trackHeadlessTerminalData(ptyId, chunk.data, chunk.seq) - } - // The seed's write chain owns subsequent live bytes; suppress on-data hydration from replacing this known-good seed. - this.headlessHydrationState.set(ptyId, 'done') - } - - waitForRendererTerminalSerializer( - ptyId: string, - afterGeneration: number, - timeoutMs?: number, - signal?: AbortSignal - ): Promise { - return ( - this.ptyController?.waitForRendererSerializer?.(ptyId, afterGeneration, timeoutMs, signal) ?? - Promise.resolve(false) - ) - } - - // Why: a leaf exists before its PTY spawns; a handle issued while ptyId is null gets invalidated on the next sync, so wait for a connected PTY. - private countLeavesInTab(tabId: string): number { - let count = 0 - for (const leaf of this.leaves.values()) { - if (leaf.tabId === tabId) { - count++ - } - } - return count - } - - private getPtyIdsForExplicitTabClose(worktreeId: string, tabId: string): string[] { - const ptyIds = new Set() - for (const pty of this.ptysById.values()) { - if (pty.connected && pty.worktreeId === worktreeId && pty.tabId === tabId) { - ptyIds.add(pty.ptyId) - } - } - for (const leaf of this.leaves.values()) { - if (leaf.worktreeId === worktreeId && leaf.tabId === tabId && leaf.ptyId) { - ptyIds.add(leaf.ptyId) - } - } - return [...ptyIds] - } - - private async stopExplicitlyClosedTabPtys( - ptyIds: readonly string[], - addressedPtyId: string - ): Promise { - let addressedPtyStopped = false - const deadlineMs = Date.now() + EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS - for (const ptyId of ptyIds) { - // Why here: this is the single funnel for an explicit close, and the - // intent must be on record before the stop, since the provider may report - // the exit itself with a status that reads like a natural finish. - this.markPtyStopRequested(ptyId) - let stopped = false - if (this.ptyController?.stopAndWait) { - try { - stopped = await this.ptyController.stopAndWait(ptyId, { deadlineMs }) - } catch (error) { - this.markPtyLivenessUnverifiable( - ptyId, - error instanceof Error ? error.message : String(error) - ) - } - if (!stopped) { - const verdict = this.getPtyLivenessVerdict(ptyId) - const providerAlreadyRetiredPty = - verdict?.status === 'unverifiable' && - verdict.reason === SSH_PROVIDER_UNREGISTERED_REASON - if (!providerAlreadyRetiredPty) { - this.ptyController.kill(ptyId) - if (!verdict || verdict.status === 'live') { - this.markPtyLivenessUnverifiable( - ptyId, - 'a follow-up stop was issued but its outcome could not be verified' - ) - } - } - } - } else { - stopped = this.ptyController?.kill(ptyId) ?? false - } - if (ptyId === addressedPtyId) { - addressedPtyStopped = stopped - } - } - return addressedPtyStopped - } - - private resolveHandleForTab(tabId: string): string | null { - for (const leaf of this.leaves.values()) { - if (leaf.tabId === tabId && leaf.ptyId !== null) { - return this.issueHandle(leaf) - } - } - return null - } - - async focusTerminal( - handle: string, - options: { navigateHost?: boolean } = {} - ): Promise { - const navigateHost = options.navigateHost !== false - const livePtyIdentity = (): RuntimeTerminalFocus => { - const live = this.getLivePtyForHandle(handle) - if (!live?.pty.connected) { - throw new Error('terminal_exited') - } - return { - handle, - tabId: live.pty.tabId ?? live.record.tabId, - worktreeId: live.pty.worktreeId, - navigated: false - } - } - const liveLeafIdentity = (): RuntimeTerminalFocus => { - this.assertGraphReady() - const { leaf: current } = this.getLiveLeafForHandle(handle) - return { - handle, - tabId: current.tabId, - worktreeId: current.worktreeId, - navigated: false - } - } - - const pty = this.getLivePtyForHandle(handle) - if (pty) { - if (!pty.pty.connected) { - throw new Error('terminal_exited') - } - if (!navigateHost || !this.notifier?.revealTerminalSession) { - return { - handle, - tabId: pty.pty.tabId ?? pty.record.tabId, - worktreeId: pty.pty.worktreeId, - navigated: false - } - } - // Coalesce concurrent host navigations: only the latest full reveal claims navigated. - return this.terminalFocusNavigationCoalescer.run({ - key: handle, - resolveSuperseded: (completed) => - completed ? { ...completed, navigated: false } : livePtyIdentity(), - run: async (ctx) => { - const live = this.getLivePtyForHandle(handle) - if (!live?.pty.connected) { - throw new Error('terminal_exited') - } - if (!ctx.isCurrent()) { - return { - handle, - tabId: live.pty.tabId ?? live.record.tabId, - worktreeId: live.pty.worktreeId, - navigated: false - } - } - const notifier = this.notifier - if (!notifier?.revealTerminalSession) { - return { - handle, - tabId: live.pty.tabId ?? live.record.tabId, - worktreeId: live.pty.worktreeId, - navigated: false - } - } - const parsedPaneKey = parsePaneKey(live.pty.paneKey ?? '') - const revealed = await notifier.revealTerminalSession(live.pty.worktreeId, { - ptyId: live.pty.ptyId, - title: getLatestPtyTitle(live.pty), - ...(live.pty.launchConfig - ? { launchConfig: copySleepingAgentLaunchConfig(live.pty.launchConfig) } - : {}), - ...(live.pty.launchToken ? { launchToken: live.pty.launchToken } : {}), - ...(live.pty.launchAgent ? { launchAgent: live.pty.launchAgent } : {}), - ...(live.pty.tabId !== null ? { tabId: live.pty.tabId } : {}), - ...(parsedPaneKey ? { leafId: parsedPaneKey.leafId } : {}) - }) - if (!ctx.isCurrent() || this.notifier !== notifier) { - return { - handle, - tabId: revealed?.tabId ?? live.pty.tabId ?? live.record.tabId, - worktreeId: live.pty.worktreeId, - navigated: false - } - } - return { - handle, - tabId: revealed?.tabId ?? live.pty.tabId ?? live.record.tabId, - worktreeId: live.pty.worktreeId, - navigated: true - } - } - }) - } - this.assertGraphReady() - const { leaf } = this.getLiveLeafForHandle(handle) - if (!navigateHost) { - return { - handle, - tabId: leaf.tabId, - worktreeId: leaf.worktreeId, - navigated: false - } - } - if (!this.notifier?.focusTerminal) { - return { - handle, - tabId: leaf.tabId, - worktreeId: leaf.worktreeId, - navigated: false - } - } - return this.terminalFocusNavigationCoalescer.run({ - key: handle, - resolveSuperseded: (completed) => - completed ? { ...completed, navigated: false } : liveLeafIdentity(), - run: async (ctx) => { - this.assertGraphReady() - const { leaf: liveLeaf } = this.getLiveLeafForHandle(handle) - if (!ctx.isCurrent()) { - return { - handle, - tabId: liveLeaf.tabId, - worktreeId: liveLeaf.worktreeId, - navigated: false - } - } - const notifier = this.notifier - if (!notifier?.focusTerminal) { - return { - handle, - tabId: liveLeaf.tabId, - worktreeId: liveLeaf.worktreeId, - navigated: false - } - } - notifier.focusTerminal(liveLeaf.tabId, liveLeaf.worktreeId, liveLeaf.leafId) - if (!ctx.isCurrent() || this.notifier !== notifier) { - return { - handle, - tabId: liveLeaf.tabId, - worktreeId: liveLeaf.worktreeId, - navigated: false - } - } - return { - handle, - tabId: liveLeaf.tabId, - worktreeId: liveLeaf.worktreeId, - navigated: true - } - } - }) - } - - async closeTerminal(handle: string): Promise { - const pty = this.getLivePtyForHandle(handle) - this.claudeAgentTeams.removeTeamForLeaderHandle(handle) - if (pty) { - const closeAuthority: RuntimePtyTabCloseAuthority = { - handle, - ptyId: pty.pty.ptyId, - incarnationId: pty.pty.incarnationId, - worktreeId: pty.pty.worktreeId - } - const ptyCloseAuthority = this.resolvePtyTabCloseSurfaceAuthority(closeAuthority) - const spawnSurface = pty.pty.tabId - ? this.findMobileTerminalSurface(pty.pty.worktreeId, pty.pty.tabId) - : null - // Why: PTY exit can immediately replace a ready SSH publication with a pending one, so capture its durable HUB surface before killing it. - const surface = - ptyCloseAuthority?.surface ?? - (spawnSurface && this.getMobileTerminalLeafPtyIds(spawnSurface.tab).length === 0 - ? spawnSurface - : null) - const tabId = surface?.tab.parentTabId ?? pty.pty.tabId ?? pty.record.tabId - // Why: relay recovery can leave stale renderer leaves; the persisted HUB layout defines whether closing this PTY closes the whole surface. - const siblingCount = surface?.tab.parentLayout - ? countTerminalLayoutLeaves(surface.tab.parentLayout.root) - : this.countLeavesInTab(tabId) - if (siblingCount <= 1 && surface && this.tabs.has(tabId) && this.notifier?.closeTerminalTab) { - const ptyIdsToKill = this.getPtyIdsForExplicitTabClose(pty.pty.worktreeId, tabId) - try { - await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { - localPtyTeardownOwnedExternally: true - }) - } catch (error) { - if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { - throw error - } - this.notifier.closeTerminal?.(tabId) - } - const ptyKilled = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) - return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled) - } - if ( - siblingCount <= 1 && - surface && - ptyCloseAuthority && - !this.tabs.has(surface.tab.parentTabId) - ) { - try { - await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { - reason: 'user', - localPtyTeardownOwnedExternally: true, - expectedPtyCloseAuthority: closeAuthority - }) - } catch (error) { - if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { - throw error - } - const ptyKilled = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) - this.notifier?.closeTerminal(tabId) - return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled) - } - const ptyKilled = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) - return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled) - } - if (siblingCount <= 1 && !surface && pty.pty.tabId && this.notifier?.closeTerminalTab) { - const ptyIdsToKill = this.getPtyIdsForExplicitTabClose(pty.pty.worktreeId, tabId) - await this.notifier.closeTerminalTab(tabId, { localPtyTeardownOwnedExternally: true }) - const ptyKilled = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) - return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled) - } - const ptyKilled = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) - if (!ptyKilled || siblingCount <= 1) { - if (surface) { - // Why: paired viewers keep ended streams mounted until the HUB publishes removal, so explicit close uses the durable host-tab transaction instead of viewer-local exit handling. - try { - await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId) - } catch (error) { - if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { - throw error - } - this.notifier?.closeTerminal(tabId) - } - } else { - this.notifier?.closeTerminal(tabId) - } - } - return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled) - } - this.assertGraphReady() - const { leaf } = this.getLiveLeafForHandle(handle) - // Why: in a multi-pane tab, killing the PTY is enough (renderer's exit handler closes the pane); an extra IPC close would race it and close the whole tab. - const siblingCount = this.countLeavesInTab(leaf.tabId) - const ptyIdsToKill = - siblingCount <= 1 - ? this.getPtyIdsForExplicitTabClose(leaf.worktreeId, leaf.tabId) - : leaf.ptyId - ? [leaf.ptyId] - : [] - if (siblingCount <= 1 && this.notifier?.closeTerminalTab) { - await this.notifier.closeTerminalTab(leaf.tabId, { - localPtyTeardownOwnedExternally: true - }) - } - const ptyKilled = leaf.ptyId - ? await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, leaf.ptyId) - : false - if (siblingCount > 1 ? !ptyKilled : !this.notifier?.closeTerminalTab) { - this.notifier?.closeTerminal(leaf.tabId, leaf.paneRuntimeId) - } - return this.describeTerminalClose(handle, leaf.tabId, leaf.ptyId ?? null, ptyKilled) - } - - /** - * A close receipt must not read as a kill nobody performed: when the stop was - * not confirmed, the receipt carries why so the CLI and callers can say so. - */ - private describeTerminalClose( - handle: string, - tabId: string, - ptyId: string | null, - ptyKilled: boolean - ): RuntimeTerminalClose { - if (ptyKilled || !ptyId) { - return { handle, tabId, ptyKilled } - } - const verdict = this.getPtyLivenessVerdict(ptyId) - if (verdict?.status === 'unverifiable') { - return { - handle, - tabId, - ptyKilled, - ptyStopVerdict: 'unverifiable', - ptyStopReason: verdict.reason - } - } - if (verdict?.status === 'live') { - return { handle, tabId, ptyKilled, ptyStopVerdict: 'live' } - } - return { handle, tabId, ptyKilled } - } - - async closeTerminalTab(handle: string): Promise { - const pty = this.getLivePtyForHandle(handle) - if (pty) { - const closeAuthority: RuntimePtyTabCloseAuthority = { - handle, - ptyId: pty.pty.ptyId, - incarnationId: pty.pty.incarnationId, - worktreeId: pty.pty.worktreeId - } - const tabId = - this.resolvePtyTabCloseSurfaceAuthority(closeAuthority)?.surface.tab.parentTabId ?? - pty.pty.tabId - if (!tabId) { - return this.closeTerminal(handle) - } - // Why: a handle-addressed CLI/automation close is an explicit intent, so - // it must stay destructive under the non-user close adjudication gate. - await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { - reason: 'user', - expectedPtyCloseAuthority: closeAuthority - }) - this.claudeAgentTeams.removeTeamForLeaderHandle(handle) - return { handle, tabId, closeMode: 'tab', ptyKilled: false } - } - this.assertGraphReady() - const { leaf } = this.getLiveLeafForHandle(handle) - await this.closeMobileSessionTab(`id:${leaf.worktreeId}`, leaf.tabId, { reason: 'user' }) - this.claudeAgentTeams.removeTeamForLeaderHandle(handle) - return { handle, tabId: leaf.tabId, closeMode: 'tab', ptyKilled: false } - } - - async splitTerminal( - handle: string, - opts: { - direction?: 'horizontal' | 'vertical' - command?: string - env?: Record - envToDelete?: string[] - activate?: boolean - // Why: same split as createTerminal — adopt the pane without revealing its - // workspace, for splits the user never asked to see. - surfaceOwner?: false - telemetrySource?: TerminalPaneSplitSource - } = {} - ): Promise { - const livePty = this.getLivePtyForHandle(handle) - if (livePty) { - return await this.splitPtyBackedTerminal(livePty.pty, opts) - } - this.assertGraphReady() - const { leaf } = this.getLiveLeafForHandle(handle) - const direction = opts.direction ?? 'horizontal' - - const newLeafId = randomUUID() - - this.notifier?.splitTerminal(leaf.tabId, leaf.paneRuntimeId, { - direction, - command: opts.command, - worktreeId: leaf.worktreeId, - sourceLeafId: leaf.leafId, - telemetrySource: opts.telemetrySource, - newLeafId - }) - - const newHandle = await this.waitForLeafInTab(leaf.tabId, newLeafId) - return { - handle: newHandle, - tabId: leaf.tabId, - paneRuntimeId: leaf.paneRuntimeId, - leafId: newLeafId - } - } - - private async splitPtyBackedTerminal( - pty: RuntimePtyWorktreeRecord, - opts: { - direction?: 'horizontal' | 'vertical' - command?: string - env?: Record - envToDelete?: string[] - activate?: boolean - // Why: same split as createTerminal — adopt the pane without revealing its - // workspace, for splits the user never asked to see. - surfaceOwner?: false - telemetrySource?: TerminalPaneSplitSource - } = {} - ): Promise { - if (!this.ptyController?.spawn) { - throw new Error('runtime_unavailable') - } - if (!pty.connected) { - throw new Error('terminal_exited') - } - const parsedPaneKey = parsePaneKey(pty.paneKey ?? '') - const parentTabId = pty.tabId?.trim() - if (!parentTabId || !parsedPaneKey) { - throw new Error('terminal_handle_stale') - } - const direction = opts.direction ?? 'horizontal' - const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${pty.worktreeId}`) - const sourceAuthority = this.resolveTerminalSplitSourceAuthority( - workspace.id, - parentTabId, - parsedPaneKey.leafId, - pty.ptyId - ) - if (!sourceAuthority) { - throw new Error('terminal_split_source_not_found') - } - const sourceIncarnationId = - sourceAuthority.liveIncarnationId ?? sourceAuthority.persistedIncarnationId - const leafId = randomUUID() - const preAllocatedHandle = this.createPreAllocatedTerminalHandle() - const paneKey = makePaneKey(parentTabId, leafId) - const result = await this.ptyController.spawn({ - cols: 120, - rows: 40, - cwd: workspace.path, - command: opts.command, - commandDelivery: 'provider', - env: this.buildTerminalWorkspaceEnv(workspace, opts.env ?? {}, paneKey, parentTabId), - envToDelete: opts.envToDelete, - connectionId: workspace.connectionId, - worktreeId: workspace.id, - preAllocatedHandle, - tabId: parentTabId, - leafId, - persistHostSessionBinding: true, - ...(sourceAuthority.persisted - ? { - expectedSourceBinding: { - ...(sourceAuthority.persistedWorktreeId - ? { worktreeId: sourceAuthority.persistedWorktreeId } - : {}), - tabId: parentTabId, - leafId: parsedPaneKey.leafId, - ptyId: pty.ptyId, - // Why: the store can only match its own persisted map, so a live-only id it never - // recorded would reject every split from a session restored without incarnations. - // The live id is fenced by revalidateSourceAuthority below instead. - ...(sourceAuthority.persistedIncarnationId - ? { incarnationId: sourceAuthority.persistedIncarnationId } - : {}) - } - } - : {}) - }) - this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) - if (result.wslDistro) { - this.preparePtyExecutionContext(result.id, result.wslDistro) - } - this.registerPty(result.id, workspace.id, workspace.connectionId) - const createdPty = this.getOrCreatePtyWorktreeRecord(result.id) - if (createdPty) { - createdPty.tabId = parentTabId - createdPty.paneKey = paneKey - createdPty.runtimeSessionOwned = pty.runtimeSessionOwned - this.setPairedRendererSessionOwnership( - createdPty.ptyId, - this.pairedRendererSessionOwnedPtyIds.has(pty.ptyId) - ) - } - - const revealSplit = async (): Promise => { - await this.notifier?.revealTerminalSession?.(workspace.id, { - ptyId: result.id, - title: null, - activate: opts.activate !== false, - ...ownerSurfacing(opts.surfaceOwner !== false), - tabId: parentTabId, - leafId, - splitFromLeafId: parsedPaneKey.leafId, - splitDirection: direction, - splitTelemetrySource: opts.telemetrySource - }) - } - - try { - const revalidateSourceAuthority = (): void => { - const current = this.resolveTerminalSplitSourceAuthority( - workspace.id, - parentTabId, - parsedPaneKey.leafId, - pty.ptyId - ) - if ( - !current || - (sourceAuthority.persisted && !current.persisted) || - (sourceIncarnationId !== null && - (current.liveIncarnationId ?? current.persistedIncarnationId) !== sourceIncarnationId) - ) { - throw new Error('terminal_split_source_not_found') - } - } - revalidateSourceAuthority() - if (!sourceAuthority.persisted) { - await revealSplit() - // Why: rejecting here unmounts the pane the reveal just added only because the retire - // below always emits its exit and the tab still holds the source sibling — the renderer's - // exit handler closes non-final panes. Never close it by tabId: that drops the whole tab. - revalidateSourceAuthority() - } - if (createdPty) { - const persisted = this.persistHeadlessTerminalSplit({ - worktreeId: workspace.id, - tabId: parentTabId, - leafId, - ptyId: createdPty.ptyId, - splitFromLeafId: parsedPaneKey.leafId, - direction - }) - if (sourceAuthority.persisted && !persisted) { - throw new Error('workspace_session_unavailable') - } - this.publishPtyBackedMobileSessionTerminal(workspace.id, createdPty, { - tabId: parentTabId, - leafId, - title: null, - activate: opts.activate !== false, - split: { splitFromLeafId: parsedPaneKey.leafId, direction } - }) - } - } catch (error) { - this.setPairedRendererSessionOwnership(result.id, false) - let stopped = false - try { - stopped = - (await this.ptyController.stopAndWait?.(result.id, { - deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS - })) ?? false - } catch { - // Best-effort fallback below preserves the original split authority error. - } - if (!stopped) { - try { - this.ptyController.kill(result.id) - } catch { - // Best-effort cleanup; retirement below still runs and the original error still throws. - } - } - try { - this.ptyController.retireRejectedPty?.(result.id, stopped) - } catch { - // Best-effort cleanup; preserve the original split authority error. - } - throw error - } - const committedSourceAuthority = sourceAuthority.persisted - ? this.resolveTerminalSplitSourceAuthority( - workspace.id, - parentTabId, - parsedPaneKey.leafId, - pty.ptyId - ) - : null - if (sourceAuthority.persisted && committedSourceAuthority?.rendererMounted) { - // Why: renderer adoption is a projection after the durable main commit; rejection cannot undo it. - void revealSplit().catch(() => undefined) - } - - return { - handle: this.issuePtyHandle(createdPty ?? pty), - tabId: parentTabId, - paneRuntimeId: -1, - leafId - } - } - - private resolveTerminalSplitSourceAuthority( - worktreeId: string, - tabId: string, - leafId: string, - ptyId: string - ): { - persisted: boolean - rendererMounted: boolean - persistedWorktreeId: string | null - persistedIncarnationId: string | null - liveIncarnationId: string | null - } | null { - const session = this.getWorkspaceSessionForWorktree(worktreeId) - const sessionWorktreeId = session ? resolveTerminalSessionWorktreeId(session, worktreeId) : null - const persistedTab = sessionWorktreeId - ? session?.tabsByWorktree[sessionWorktreeId]?.find( - (tab) => tab.id === tabId && worktreeIdsEqual(tab.worktreeId, worktreeId) - ) - : undefined - const persistedLayout = session?.terminalLayoutsByTabId?.[tabId] - const persistedIncarnationId = - session?.terminalPtyIncarnationsByPaneKey?.[makePaneKey(tabId, leafId)] ?? null - const liveIncarnationId = this.ptysById.get(ptyId)?.incarnationId ?? null - if ( - persistedIncarnationId && - liveIncarnationId && - persistedIncarnationId !== liveIncarnationId - ) { - return null - } - const persisted = Boolean( - persistedTab && - persistedLayout?.ptyIdsByLeafId?.[leafId] === ptyId && - terminalLayoutContainsLeaf(persistedLayout.root, leafId) - ) - const rendererTab = this.tabs.get(tabId) - const rendererLeaf = this.leaves.get(this.getLeafKey(tabId, leafId)) - const rendererMounted = Boolean( - rendererTab && - rendererLeaf && - worktreeIdsEqual(rendererTab.worktreeId, worktreeId) && - worktreeIdsEqual(rendererLeaf.worktreeId, worktreeId) && - rendererLeaf.ptyId === ptyId - ) - if (persisted && persistedLayout) { - return { - persisted: true, - rendererMounted, - persistedWorktreeId: sessionWorktreeId, - persistedIncarnationId, - liveIncarnationId - } - } - // Why: renderer adoption can precede graph sync; this path still requires reveal success before commit. - const projected = [...this.mobileSessionTabsByWorktree.entries()].some( - ([candidateWorktreeId, snapshot]) => - worktreeIdsEqual(candidateWorktreeId, worktreeId) && - snapshot.tabs.some( - (tab) => - tab.type === 'terminal' && - tab.parentTabId === tabId && - tab.leafId === leafId && - (tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[leafId] === ptyId) - ) - ) - if (!rendererMounted && !projected) { - return null - } - return { - persisted: false, - rendererMounted, - persistedWorktreeId: null, - persistedIncarnationId: null, - liveIncarnationId - } - } - - async handleAgentTeamsTmuxCompat( - request: AgentTeamsTmuxCompatRequest - ): Promise { - return await this.claudeAgentTeams.handleTmuxCompat(request, { - splitTerminal: (handle, opts) => this.splitTerminal(handle, opts), - readTerminal: (handle, opts) => this.readTerminal(handle, opts), - sendTerminal: (handle, action) => this.sendTerminal(handle, action), - focusTerminal: (handle) => this.focusTerminal(handle), - closeTerminal: (handle) => this.closeTerminal(handle), - showTerminal: (handle) => this.showTerminal(handle) - }) - } - - async prepareClaudeAgentTeamsLeader(args: { - paneKey: string - baseEnv?: Record - }): Promise<{ env: Record }> { - const handle = this.getTerminalHandleForPaneKey(args.paneKey) - if (!handle) { - throw new Error('claude_agent_teams_requires_orca_terminal') - } - return await this.prepareClaudeAgentTeamsLeaderForHandle({ - handle, - baseEnv: args.baseEnv - }) - } - - async prepareClaudeAgentTeamsLeaderForHandle(args: { - handle: string - baseEnv?: Record - }): Promise<{ env: Record }> { - const baseEnv = { - ...process.env, - ...args.baseEnv - } - const shimDir = await ensureClaudeAgentTeamsShimDir() - const shimBin = resolveClaudeAgentTeamsShimBin(baseEnv) - return this.claudeAgentTeams.createLaunchEnv({ - leaderHandle: args.handle, - baseEnv, - shimDir, - shimBin - }) - } - - // 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 waitForLeafInTab(tabId: string, leafId: string, timeoutMs = 10_000): Promise { - const tryResolve = (): string | null => { - const leaf = this.leaves.get(this.getLeafKey(tabId, leafId)) - return leaf?.ptyId !== null && leaf?.ptyId !== undefined ? this.issueHandle(leaf) : null - } - - const existing = tryResolve() - if (existing) { - return Promise.resolve(existing) - } - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - const idx = this.graphSyncCallbacks.indexOf(check) - if (idx !== -1) { - this.graphSyncCallbacks.splice(idx, 1) - } - reject(new Error('Timed out waiting for split pane handle')) - }, timeoutMs) - - const check = (): void => { - const handle = tryResolve() - if (handle) { - clearTimeout(timer) - const idx = this.graphSyncCallbacks.indexOf(check) - if (idx !== -1) { - this.graphSyncCallbacks.splice(idx, 1) - } - resolve(handle) - } - } - this.graphSyncCallbacks.push(check) - check() - }) - } - - async stopTerminalsForWorktree( - worktreeSelector: string, - options: { - deadline?: number - stopPty?: ( - ptyId: string, - stop: () => boolean | Promise - ) => Promise<{ stopped: boolean; owner: boolean }> - /** Authoritative id for an orphan whose selector no longer resolves. */ - resolvedWorktreeId?: string - resolvedConnectionId?: string - resolvedRuntimeEnvironmentId?: string - } = {} - ): Promise<{ stopped: number }> { - // Why: this mutates live PTYs, so reject while the graph is reloading rather than act on cached leaf ownership. - const graphEpoch = this.captureReadyGraphEpoch() - const worktree = options.resolvedWorktreeId - ? { id: options.resolvedWorktreeId } - : await this.resolveWorktreeSelector(worktreeSelector) - this.assertStableReadyGraph(graphEpoch) - if (options.deadline !== undefined && Date.now() >= options.deadline) { - return { stopped: 0 } - } - // Preserve folder-instance suffixes while normalizing cross-platform path spelling. - const ownsWorktree = options.resolvedWorktreeId - ? (candidate: string | undefined): boolean => - candidate ? worktreeIdsEqual(candidate, worktree.id) : false - : (candidate: string | undefined): boolean => candidate === worktree.id - const ownsHost = (ptyId: string, connectionId?: string | null): boolean => { - if (options.resolvedRuntimeEnvironmentId !== undefined) { - return ptyId.startsWith( - `remote:${encodeURIComponent(options.resolvedRuntimeEnvironmentId)}@@` - ) - } - return ( - options.resolvedConnectionId === undefined || connectionId === options.resolvedConnectionId - ) - } - const ptyIds = new Set() - for (const leaf of this.leaves.values()) { - if ( - ownsWorktree(leaf.worktreeId) && - leaf.ptyId && - ownsHost(leaf.ptyId, this.ptysById.get(leaf.ptyId)?.connectionId) - ) { - ptyIds.add(leaf.ptyId) - } - } - for (const pty of this.ptysById.values()) { - if (ownsWorktree(pty.worktreeId) && pty.connected && ownsHost(pty.ptyId, pty.connectionId)) { - ptyIds.add(pty.ptyId) - } - } - - let stopped = 0 - for (const ptyId of ptyIds) { - if (options.deadline !== undefined && Date.now() >= options.deadline) { - break - } - const stop = (): boolean | Promise => { - if (options.deadline !== undefined && Date.now() >= options.deadline) { - return false - } - if (options.stopPty) { - // Why: destructive worktree cleanup must not let its cross-surface - // dedupe treat fire-and-forget controller.kill as physical exit. - // Why: the RPC deadline makes shutdown/list RPCs settle before the sweep - // deadline so a wedged daemon yields the accurate stop failure; no deadline - // (non-destructive) keeps the provider default RPC timeout. - if (options.deadline !== undefined) { - return ( - this.ptyController?.stopAndWait?.(ptyId, { - deadlineMs: teardownRpcDeadline(options.deadline) - }) ?? false - ) - } - return this.ptyController?.stopAndWait?.(ptyId) ?? false - } - return Boolean(this.ptyController?.kill(ptyId)) - } - const stopResult = options.stopPty - ? await options.stopPty(ptyId, stop) - : { stopped: stop(), owner: true } - if (stopResult.owner && stopResult.stopped) { - stopped += 1 - } - } - return { stopped } - } - - async sleepTerminalsForWorktree( - worktreeSelector: string - ): Promise { - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - const existing = this.terminalSleepByWorktreeId.get(worktree.id) - if (existing) { - return await existing - } - - const sleeping = this.sleepResolvedWorktreeTerminals(worktree) - this.terminalSleepByWorktreeId.set(worktree.id, sleeping) - try { - return await sleeping - } finally { - if (this.terminalSleepByWorktreeId.get(worktree.id) === sleeping) { - this.terminalSleepByWorktreeId.delete(worktree.id) - } - } - } - - async acquireWorktreeTerminalSpawn(worktreeId?: string): Promise<() => void> { - if (!worktreeId) { - return () => {} - } - const release = await this.acquireWorktreeTerminalMutation(worktreeId, 'shared') - const key = runtimeWorktreeIdentityKey(worktreeId) - const sleepState = this.terminalSleepStateByWorktreeId.get(key) - if (sleepState?.phase === 'sleeping' || sleepState?.phase === 'partial') { - this.terminalSleepStateByWorktreeId.delete(key) - this.emitClientEvent({ - type: 'worktreeTerminalSleepState', - worktreeId: sleepState.worktreeId, - generation: sleepState.generation, - phase: 'woken', - ptyIds: sleepState.ptyIds, - terminalHandles: sleepState.terminalHandles - }) - } - return release - } - - private async runWorktreeTerminalMutation( - worktreeId: string, - operation: () => Promise - ): Promise { - // Why exclusive: adoption reconciles this worktree's terminal records, so - // it must not interleave with a spawn registering a pty or with a sleep. - const release = await this.acquireWorktreeTerminalMutation(worktreeId, 'exclusive') - try { - return await operation() - } finally { - release() - } - } - - private async acquireWorktreeTerminalMutation( - worktreeId: string, - kind: WorktreeTerminalMutationKind, - deadline?: number - ): Promise<() => void> { - return await this.terminalMutationLock.acquire( - runtimeWorktreeIdentityKey(worktreeId), - kind, - deadline - ) - } - - private async sleepResolvedWorktreeTerminals( - worktree: ResolvedWorktree - ): Promise { - const sleepDeadline = Date.now() + WORKTREE_TERMINAL_SLEEP_TIMEOUT_MS - const releaseMutation = await this.acquireWorktreeTerminalMutation( - worktree.id, - 'exclusive', - sleepDeadline - ) - const key = runtimeWorktreeIdentityKey(worktree.id) - const existingSleepState = this.terminalSleepStateByWorktreeId.get(key) - if (existingSleepState?.phase === 'sleeping') { - try { - const resolvedWorktrees = includeTargetResolvedWorktree( - [...(await this.getResolvedWorktreeMap()).values()], - worktree - ) - const refreshedPtyLiveness = await this.refreshPtyWorktreeRecordsFromController( - resolvedWorktrees, - worktree.id, - sleepDeadline - ) - if (!refreshedPtyLiveness) { - throw new Error('terminal_liveness_unavailable') - } - if (this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness).size === 0) { - releaseMutation() - return { - stopped: 0, - stoppedPtyIds: [], - livePtyIds: [], - postStopVerified: true - } - } - this.emitClientEvent({ - type: 'worktreeTerminalSleepState', - worktreeId: existingSleepState.worktreeId, - generation: existingSleepState.generation, - phase: 'woken', - ptyIds: existingSleepState.ptyIds, - terminalHandles: existingSleepState.terminalHandles - }) - this.terminalSleepStateByWorktreeId.delete(key) - } catch (error) { - releaseMutation() - throw error - } - } - const priorPartialState = existingSleepState?.phase === 'partial' ? existingSleepState : null - const committedPtyIds = new Set(priorPartialState?.ptyIds ?? []) - const terminalHandlesByPtyId = { ...priorPartialState?.terminalHandlesByPtyId } - const pendingPtyIds = new Set() - let generation = 0 - let fullyCommitted = false - let releaseReversibleRendererStops = (): void => {} - try { - const resolvedWorktrees = includeTargetResolvedWorktree( - [...(await this.getResolvedWorktreeMap()).values()], - worktree - ) - const refreshedPtyLiveness = await this.refreshPtyWorktreeRecordsFromController( - resolvedWorktrees, - worktree.id, - sleepDeadline - ) - if (!refreshedPtyLiveness) { - throw new Error('terminal_liveness_unavailable') - } - const livePtyIds = this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness) - generation = ++this.terminalSleepGeneration - for (const ptyId of livePtyIds) { - pendingPtyIds.add(ptyId) - terminalHandlesByPtyId[ptyId] = this.getTerminalHandlesForPtyId(ptyId) - } - const liveTerminalHandles = this.getRecordedTerminalSleepHandles( - livePtyIds, - terminalHandlesByPtyId - ) - this.terminalSleepStateByWorktreeId.set(key, { - worktreeId: worktree.id, - generation, - phase: 'stopping', - ptyIds: [...committedPtyIds].sort(), - terminalHandles: this.getRecordedTerminalSleepHandles( - committedPtyIds, - terminalHandlesByPtyId - ), - terminalHandlesByPtyId - }) - this.emitClientEvent({ - type: 'worktreeTerminalSleepState', - worktreeId: worktree.id, - generation, - phase: 'started', - ptyIds: [...livePtyIds].sort(), - terminalHandles: liveTerminalHandles - }) - if (committedPtyIds.size > 0) { - this.emitClientEvent({ - type: 'worktreeTerminalSleepState', - worktreeId: worktree.id, - generation, - phase: 'committed', - ptyIds: [...committedPtyIds].sort(), - terminalHandles: this.getRecordedTerminalSleepHandles( - committedPtyIds, - terminalHandlesByPtyId - ) - }) - } - if (livePtyIds.size === 0) { - const terminalHandles = this.getRecordedTerminalSleepHandles( - committedPtyIds, - terminalHandlesByPtyId - ) - this.terminalSleepStateByWorktreeId.set(key, { - worktreeId: worktree.id, - generation, - phase: 'sleeping', - ptyIds: [...committedPtyIds].sort(), - terminalHandles, - terminalHandlesByPtyId - }) - fullyCommitted = true - return { - stopped: 0, - stoppedPtyIds: [], - livePtyIds: [], - postStopVerified: true - } - } - const ptyController = this.ptyController - if (!ptyController?.stopAndWait) { - throw new Error('terminal_worktree_sleep_unavailable') - } - const stopAndWait = ptyController.stopAndWait.bind(ptyController) - - const orderedLivePtyIds = [...livePtyIds].sort() - releaseReversibleRendererStops = - ptyController.markReversibleStops?.(orderedLivePtyIds) ?? (() => {}) - const stopResults = await Promise.allSettled( - orderedLivePtyIds.map(async (ptyId) => ({ - ptyId, - stopped: await stopAndWait(ptyId, { - keepHistory: true, - deadlineMs: teardownRpcDeadline(sleepDeadline) - }) - })) - ) - const successfulStopPtyIds = orderedLivePtyIds.filter((_, index) => { - const result = stopResults[index] - return result?.status === 'fulfilled' && result.value.stopped - }) - const failedStopIndex = stopResults.findIndex((result) => - result.status === 'rejected' ? true : !result.value.stopped - ) - - const postStopLiveness = await this.refreshPtyWorktreeRecordsFromController( - resolvedWorktrees, - worktree.id, - sleepDeadline - ) - if (!postStopLiveness) { - this.commitWorktreeTerminalSleepPtys({ - worktreeId: worktree.id, - generation, - ptyIds: successfulStopPtyIds, - pendingPtyIds, - committedPtyIds, - terminalHandlesByPtyId - }) - if (failedStopIndex !== -1) { - const failedStop = stopResults[failedStopIndex] - throw Object.assign(new Error('terminal_worktree_sleep_failed'), { - ptyId: orderedLivePtyIds[failedStopIndex], - ...(failedStop.status === 'rejected' ? { cause: failedStop.reason } : {}) - }) - } - return { - stopped: successfulStopPtyIds.length, - stoppedPtyIds: successfulStopPtyIds, - livePtyIds: [...livePtyIds].sort(), - postStopVerified: false, - postStopFailure: 'terminal_liveness_unavailable' - } - } - const remainingLivePtyIds = this.getLivePtyIdsForWorktree(worktree.id, postStopLiveness) - const provenStoppedPtyIds = orderedLivePtyIds.filter( - (ptyId) => !remainingLivePtyIds.has(ptyId) - ) - this.commitWorktreeTerminalSleepPtys({ - worktreeId: worktree.id, - generation, - ptyIds: provenStoppedPtyIds, - pendingPtyIds, - committedPtyIds, - terminalHandlesByPtyId - }) - if (failedStopIndex !== -1 && remainingLivePtyIds.size > 0) { - const failedStop = stopResults[failedStopIndex] - console.error('[runtime] worktree terminal sleep physical stop failed', { - worktreeId: worktree.id, - ptyId: orderedLivePtyIds[failedStopIndex], - cause: failedStop.status === 'rejected' ? failedStop.reason : 'stop_not_acknowledged' - }) - throw Object.assign(new Error('terminal_worktree_sleep_failed'), { - ptyId: orderedLivePtyIds[failedStopIndex], - remainingLivePtyIds: [...remainingLivePtyIds].sort(), - ...(failedStop.status === 'rejected' ? { cause: failedStop.reason } : {}) - }) - } - if (remainingLivePtyIds.size > 0) { - return { - stopped: successfulStopPtyIds.length, - stoppedPtyIds: successfulStopPtyIds, - livePtyIds: [...livePtyIds].sort(), - postStopVerified: false, - postStopFailure: 'terminal_worktree_sleep_still_live', - remainingLivePtyIds: [...remainingLivePtyIds].sort() - } - } - const terminalHandles = this.getRecordedTerminalSleepHandles( - committedPtyIds, - terminalHandlesByPtyId - ) - this.terminalSleepStateByWorktreeId.set(key, { - worktreeId: worktree.id, - generation, - phase: 'sleeping', - ptyIds: [...committedPtyIds].sort(), - terminalHandles, - terminalHandlesByPtyId - }) - fullyCommitted = true - return { - stopped: provenStoppedPtyIds.length, - stoppedPtyIds: provenStoppedPtyIds, - livePtyIds: [...livePtyIds].sort(), - postStopVerified: true - } - } finally { - releaseReversibleRendererStops() - if (!fullyCommitted && generation > 0) { - const cancelledPtyIds = [...pendingPtyIds].sort() - if (cancelledPtyIds.length > 0) { - this.emitClientEvent({ - type: 'worktreeTerminalSleepState', - worktreeId: worktree.id, - generation, - phase: 'cancelled', - ptyIds: cancelledPtyIds, - terminalHandles: this.getRecordedTerminalSleepHandles( - cancelledPtyIds, - terminalHandlesByPtyId - ) - }) - } - if (committedPtyIds.size > 0) { - const terminalHandles = this.getRecordedTerminalSleepHandles( - committedPtyIds, - terminalHandlesByPtyId - ) - this.terminalSleepStateByWorktreeId.set(key, { - worktreeId: worktree.id, - generation, - phase: 'partial', - ptyIds: [...committedPtyIds].sort(), - terminalHandles, - terminalHandlesByPtyId - }) - } else { - this.terminalSleepStateByWorktreeId.delete(key) - } - } - releaseMutation() - } - } - - async stopExactTerminalsForWorktree( - worktreeSelector: string, - expectedPtyIds: readonly string[], - opts: { keepHistory?: boolean; targetOnly?: boolean } = {} - ): Promise<{ - stopped: number - stoppedPtyIds: string[] - livePtyIds: string[] - postStopVerified: boolean - postStopFailure?: string - remainingLivePtyIds?: string[] - }> { - // Why: exact stop hibernates one known pane; worktree sleep discovers its complete host-owned set separately. - const graphEpoch = this.captureReadyGraphEpoch() - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - this.assertStableReadyGraph(graphEpoch) - const expected = new Set(expectedPtyIds.filter((ptyId) => ptyId.length > 0)) - if (expected.size !== 1) { - throw new Error('terminal_exact_stop_requires_single_pty') - } - const resolvedWorktrees = [...(await this.getResolvedWorktreeMap()).values()] - const refreshedPtyLiveness = - await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) - if (!refreshedPtyLiveness) { - throw new Error('terminal_liveness_unavailable') - } - const livePtyIds = this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness) - const targetOnly = opts.targetOnly === true - const expectedIsLive = [...expected].every((ptyId) => livePtyIds.has(ptyId)) - if (targetOnly ? !expectedIsLive : !setsEqual(livePtyIds, expected)) { - const error = Object.assign(new Error('terminal_stop_pty_set_mismatch'), { - livePtyIds: [...livePtyIds].sort(), - expectedPtyIds: [...expected].sort() - }) - throw error - } - - if (!this.ptyController?.stopAndWait) { - throw new Error('terminal_exact_stop_unavailable') - } - - const stoppedPtyIds: string[] = [] - for (const ptyId of [...expected].sort()) { - if (opts.keepHistory) { - this.intentionalHandlelessPtyStops.set( - ptyId, - this.ptysById.get(ptyId)?.incarnationId ?? null - ) - } - try { - if (!(await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }))) { - throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId }) - } - } finally { - this.intentionalHandlelessPtyStops.delete(ptyId) - } - stoppedPtyIds.push(ptyId) - } - const postStopLiveness = await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) - if (!postStopLiveness) { - return { - stopped: stoppedPtyIds.length, - stoppedPtyIds, - livePtyIds: [...livePtyIds].sort(), - postStopVerified: false, - postStopFailure: 'terminal_liveness_unavailable' - } - } - const remainingLivePtyIds = this.getLivePtyIdsForWorktree(worktree.id, postStopLiveness) - const stoppedTargetsStillLive = [...expected].filter((ptyId) => remainingLivePtyIds.has(ptyId)) - if (targetOnly ? stoppedTargetsStillLive.length > 0 : remainingLivePtyIds.size > 0) { - return { - stopped: stoppedPtyIds.length, - stoppedPtyIds, - livePtyIds: [...livePtyIds].sort(), - postStopVerified: false, - postStopFailure: 'terminal_exact_stop_still_live', - remainingLivePtyIds: [...remainingLivePtyIds].sort() - } - } - return { - stopped: stoppedPtyIds.length, - stoppedPtyIds, - livePtyIds: [...livePtyIds].sort(), - postStopVerified: true, - ...(targetOnly && remainingLivePtyIds.size > 0 - ? { remainingLivePtyIds: [...remainingLivePtyIds].sort() } - : {}) - } - } - - private getLivePtyIdsForWorktree( - worktreeId: string, - freshPtyIds?: ReadonlySet - ): Set { - const ptyIds = new Set() - for (const leaf of this.leaves.values()) { - if ( - worktreeIdsEqual(leaf.worktreeId, worktreeId) && - leaf.connected && - leaf.ptyId && - (!freshPtyIds || freshPtyIds.has(leaf.ptyId)) - ) { - ptyIds.add(leaf.ptyId) - } - } - for (const pty of this.ptysById.values()) { - if ( - worktreeIdsEqual(pty.worktreeId, worktreeId) && - pty.connected && - (!freshPtyIds || freshPtyIds.has(pty.ptyId)) - ) { - ptyIds.add(pty.ptyId) - } - } - return ptyIds - } - - private getTerminalHandlesForPtyId(ptyId: string): string[] { - const handles = new Set( - this.getLeavesForPty(ptyId) - .filter((candidate) => candidate.connected) - .map((leaf) => this.issueHandle(leaf)) - ) - const runtimeHandle = this.handleByPtyId.get(ptyId) - if (runtimeHandle) { - handles.add(runtimeHandle) - } - const pty = this.getOrCreatePtyWorktreeRecord(ptyId) - if (!pty) { - throw Object.assign(new Error('terminal_worktree_sleep_handle_unavailable'), { ptyId }) - } - if (handles.size === 0) { - handles.add(this.issuePtyHandle(pty)) - } - return [...handles].sort() - } - - private getRecordedTerminalSleepHandles( - ptyIds: Iterable, - terminalHandlesByPtyId: Readonly> - ): string[] { - return [...new Set([...ptyIds].flatMap((ptyId) => terminalHandlesByPtyId[ptyId] ?? []))].sort() - } - - private commitWorktreeTerminalSleepPtys(args: { - worktreeId: string - generation: number - ptyIds: readonly string[] - pendingPtyIds: Set - committedPtyIds: Set - terminalHandlesByPtyId: Readonly> - }): void { - const newlyCommittedPtyIds = [...new Set(args.ptyIds)] - .filter((ptyId) => !args.committedPtyIds.has(ptyId)) - .sort() - for (const ptyId of newlyCommittedPtyIds) { - args.pendingPtyIds.delete(ptyId) - args.committedPtyIds.add(ptyId) - } - if (newlyCommittedPtyIds.length === 0) { - return - } - this.emitClientEvent({ - type: 'worktreeTerminalSleepState', - worktreeId: args.worktreeId, - generation: args.generation, - phase: 'committed', - ptyIds: newlyCommittedPtyIds, - terminalHandles: this.getRecordedTerminalSleepHandles( - newlyCommittedPtyIds, - args.terminalHandlesByPtyId - ) - }) - } - - async hasTerminalsForWorktree(worktreeSelector: string): Promise { - const graphEpoch = this.captureReadyGraphEpoch() - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - this.assertStableReadyGraph(graphEpoch) - for (const leaf of this.leaves.values()) { - if (leaf.worktreeId === worktree.id && leaf.ptyId) { - return true - } - } - for (const pty of this.ptysById.values()) { - if (pty.worktreeId === worktree.id && pty.connected) { - return true - } - } - return false - } - - markRendererReloading(windowId: number): RuntimeRendererReloadFence | null { - if ( - windowId !== HEADLESS_RUNTIME_WINDOW_ID && - this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID && - this.headlessGraphFallbackAvailable - ) { - this.attachWindow(windowId) - const revision = this.graphReloadLifecycle.getActiveRevision() - return this.authoritativeWindowId === windowId && revision !== null - ? { revision, recovery: 'headless' } - : null - } - if (windowId !== this.authoritativeWindowId) { - return null - } - if (this.graphStatus === 'reloading') { - return { - revision: this.graphReloadLifecycle.begin(windowId), - recovery: this.shouldRestoreHeadlessGraph(windowId) ? 'headless' : 'reloading' - } - } - if (this.graphStatus !== 'ready') { - return null - } - return { revision: this.beginGraphReload(windowId), recovery: 'renderer' } - } - - private beginGraphReload(windowId: number): number { - // Why: the rebuilt graph decides whether an incarnation survived; do not stale proven process identities before that comparison. - this.rendererGraphEpoch += 1 - this.graphStatus = 'reloading' - const revision = this.graphReloadLifecycle.begin(windowId) - this.setTerminalSideEffectConsumerAvailable(false) - this.rememberDetachedPreAllocatedLeaves() - const retainedHandles = new Set([ - ...this.handleByPtyId.values(), - ...[...this.handleByPtyIncarnation.values()].map((record) => record.handle) - ]) - for (const handle of this.waitersByHandle.keys()) { - if (!retainedHandles.has(handle)) { - this.rejectWaitersForHandle(handle, 'terminal_handle_stale') - } - } - this.handles.clear() - this.handleByLeafKey.clear() - this.refreshWritableFlags() - return revision - } - - markRendererReloadCancelled(windowId: number, fence: RuntimeRendererReloadFence): boolean { - if ( - windowId !== this.authoritativeWindowId || - this.graphStatus !== 'reloading' || - !this.graphReloadLifecycle.settle(fence.revision, 'cancelled') - ) { - return false - } - if (fence.recovery === 'headless' && this.shouldRestoreHeadlessGraph(windowId)) { - this.restoreHeadlessGraphAuthority() - return false - } - if (fence.recovery === 'renderer') { - const restoresPublishedInventory = - this.sessionTabsInventoryPublicationEpoch === this.rendererGraphEpoch - 1 - this.graphStatus = 'ready' - this.setTerminalSideEffectConsumerAvailable(true) - for (const leaf of this.leaves.values()) { - this.adoptPreAllocatedHandle(leaf) - } - this.reconcilePtyIncarnationHandles() - this.refreshWritableFlags() - if (restoresPublishedInventory) { - this.markSessionTabsInventoryPublished() - } - return true - } - this.graphReloadLifecycle.begin(windowId) - return false - } - - markGraphReady(windowId: number): void { - if (windowId !== this.authoritativeWindowId) { - return - } - this.graphReloadLifecycle.settleActive('success') - if (windowId !== HEADLESS_RUNTIME_WINDOW_ID) { - this.headlessGraphFallbackAvailable = false - this.pendingHeadlessPromotionWindowId = null - } - this.graphStatus = 'ready' - this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) - this.refreshWritableFlags() - } - - markGraphReloadFailed( - windowId: number, - _reason: 'renderer-frame-unavailable' | 'renderer-process-gone' - ): void { - if (windowId !== this.authoritativeWindowId) { - return - } - if (this.graphStatus === 'ready') { - this.beginGraphReload(windowId) - } - this.graphReloadLifecycle.settleActive('failure') - this.transitionGraphReloadToTerminalState(windowId) - } - - markGraphUnavailable(windowId: number): void { - if ( - this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID && - windowId === this.pendingHeadlessPromotionWindowId - ) { - this.pendingHeadlessPromotionWindowId = null - return - } - if (windowId !== this.authoritativeWindowId) { - return - } - this.graphReloadLifecycle.settleActive('cancelled') - if (this.shouldRestoreHeadlessGraph(windowId)) { - this.pendingHeadlessPromotionWindowId = null - this.restoreHeadlessGraphAuthority() - return - } - // Why: once the authoritative renderer graph disappears, fail closed for live-terminal ops instead of guessing from old state. - if (this.graphStatus !== 'unavailable') { - this.rendererGraphEpoch += 1 - } - this.graphStatus = 'unavailable' - this.setTerminalSideEffectConsumerAvailable(false) - this.authoritativeWindowId = null - this.rememberDetachedPreAllocatedLeaves() - this.tabs.clear() - this.leaves.clear() - this.leavesByPtyId.clear() - this.handles.clear() - this.handleByLeafKey.clear() - this.clearPtyIncarnationHandles() - // Why: pre-allocated CLI handles must survive graph unavailability so they can be re-adopted on reconnect. - this.rejectAllWaiters('terminal_handle_stale') - } - - private handleGraphReloadTimeout(windowId: number): void { - if (windowId !== this.authoritativeWindowId || this.graphStatus !== 'reloading') { - return - } - this.transitionGraphReloadToTerminalState(windowId) - } - - private transitionGraphReloadToTerminalState(windowId: number): void { - if (this.shouldRestoreHeadlessGraph(windowId)) { - this.restoreHeadlessGraphAuthority() - return - } - this.graphStatus = 'unavailable' - this.setTerminalSideEffectConsumerAvailable(false) - this.rememberDetachedPreAllocatedLeaves() - this.tabs.clear() - this.leaves.clear() - this.leavesByPtyId.clear() - this.handles.clear() - this.handleByLeafKey.clear() - this.clearPtyIncarnationHandles() - this.rejectAllWaiters('terminal_handle_stale') - this.refreshWritableFlags() - } - - private shouldRestoreHeadlessGraph(windowId: number): boolean { - return windowId !== HEADLESS_RUNTIME_WINDOW_ID && this.headlessGraphFallbackAvailable - } - - private restoreHeadlessGraphAuthority(): void { - this.rendererGraphEpoch += 1 - this.authoritativeWindowId = HEADLESS_RUNTIME_WINDOW_ID - this.graphStatus = 'ready' - this.rendererGeneration = null - this.setTerminalSideEffectConsumerAvailable(false) - this.tabs.clear() - this.leaves.clear() - this.leavesByPtyId.clear() - this.handles.clear() - this.handleByLeafKey.clear() - this.clearPtyIncarnationHandles() - this.rejectAllWaiters('terminal_handle_stale') - this.refreshWritableFlags() - this.markSessionTabsInventoryPublished() - } - - private assertGraphReady(): void { - if (this.graphStatus !== 'ready') { - throw new Error('runtime_unavailable') - } - } - - private captureReadyGraphEpoch(): number { - this.assertGraphReady() - return this.rendererGraphEpoch - } - - private assertStableReadyGraph(expectedGraphEpoch: number): void { - if (this.graphStatus !== 'ready' || this.rendererGraphEpoch !== expectedGraphEpoch) { - throw new Error('runtime_unavailable') - } - } - - private resolveFolderWorkspaceConnectionId(workspace: FolderWorkspace): string | null { - const repos = this.store?.getRepos() ?? [] - const projectGroups = this.store?.getProjectGroups?.() ?? [] - const connection = inferFolderWorkspacePathConnection({ - folderPath: workspace.folderPath, - projectGroupId: workspace.projectGroupId, - connectionId: workspace.connectionId ?? null, - projectGroups, - repos - }) - if (connection.kind === 'ambiguous') { - // Why: a PTY spawns on one runtime target; mixed child-repo connections need an explicit V2 routing decision. - throw new Error('folder_workspace_connection_ambiguous') - } - return connection.kind === 'ssh' ? connection.connectionId : null - } - - private async resolveFolderWorkspaceLaunchScope( - selector: string - ): Promise<(TerminalWorkspaceLaunchScope & { folderWorkspace: FolderWorkspace }) | null> { - const workspace = this.resolveFolderWorkspaceSelector(selector) - if (!workspace) { - return null - } - if (!this.store) { - throw new Error('runtime_unavailable') - } - const status = await getFolderWorkspacePathStatus( - this.store, - { scope: 'folder-workspace', folderWorkspaceId: workspace.id }, - { getSshFilesystemProvider } - ) - assertFolderWorkspacePathUsable(status) - return { - id: folderWorkspaceKey(workspace.id), - path: workspace.folderPath, - connectionId: this.resolveFolderWorkspaceConnectionId(workspace), - repo: null, - folderWorkspace: workspace - } - } - - private resolveFolderWorkspaceSelector(selector: string): FolderWorkspace | null { - const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector - const parsed = parseWorkspaceKey(workspaceSelector) - if (parsed?.type !== 'folder') { - return null - } - const workspace = this.store - ?.getFolderWorkspaces?.() - .find((entry) => entry.id === parsed.folderWorkspaceId) - if (!workspace) { - throw new Error('selector_not_found') - } - return workspace - } - - private async resolveEmulatorWorkspaceId(selector: string): Promise { - const folderWorkspace = this.resolveFolderWorkspaceSelector(selector) - return folderWorkspace - ? folderWorkspaceKey(folderWorkspace.id) - : (await this.resolveWorktreeSelector(selector)).id - } - - private async resolveBrowserWorkspace(selector: string): Promise { - const folderScope = await this.resolveFolderWorkspaceLaunchScope(selector) - return folderScope?.folderWorkspace - ? this.folderWorkspaceToResolvedWorktree(folderScope.folderWorkspace) - : this.resolveWorktreeSelector(selector) - } - - /** - * Closes the window in which snapshots warn that this client's client-hosted pages are still - * unaccounted for. Keyed by paired device because one client attaching says nothing about another. - */ - markClientHostedPagesReconciled(pairedDeviceId: string): void { - this.clientHostedPageReconciliation.markReconciled(pairedDeviceId) - } - - /** - * The execution-host key a client-hosted page in this workspace would be created under now. - * - * Adoption cannot reuse the key an inventory entry reports: native and WSL keys name the runtime - * that minted them, and an SSH key carries a per-process provider epoch, so a restart always - * invalidates them. - * - * The two failure modes are not the same answer. A workspace that no longer resolves is gone and - * its pages have nothing left to be restored into; an execution host that is merely not up yet -- - * an SSH provider mid-reconnect, a project runtime still repairing -- is a "not now", and must - * never be read as permission to retire the page. - */ - async resolveBrowserExecutionHostKeyForWorkspace( - workspaceId: string - ): Promise { - let worktree: ResolvedWorktree - try { - worktree = await this.resolveBrowserWorkspace(`id:${workspaceId}`) - } catch { - return { status: 'workspace-gone' } - } - try { - return { - status: 'resolved', - executionHostKey: browserNetworkExecutionHostKey( - await this.resolveBrowserNetworkExecutionHostForWorktree(worktree) - ) - } - } catch { - return { status: 'unavailable' } - } - } - - private resolveBrowserNetworkExecutionHostForWorktree(worktree?: { - id: string - repoId?: string - hostId?: ExecutionHostId - }): BrowserNetworkExecutionHost | Promise { - const repo = worktree?.repoId ? this.requireStore().getRepo(worktree.repoId) : undefined - const executionHostId = worktree - ? getWorktreeExecutionHostId(worktree, repo) - : LOCAL_EXECUTION_HOST_ID - const parsedHost = parseExecutionHostId(executionHostId) - return resolveRuntimeBrowserNetworkExecutionHost({ - runtimeId: this.getRuntimeId(), - runtimeRevision: this.getStartedAt(), - executionHostId, - ...(worktree - ? { - projectRuntime: resolveLocalProjectRuntimeForWorktreeId( - this.requireStore(), - worktree.id - ) - } - : {}), - ...(parsedHost?.kind === 'ssh' - ? { sshState: getRegisteredSshState(parsedHost.targetId) } - : {}) - }) - } - - private async resolveEmulatorCleanupWorkspaceId(selector: string): Promise { - const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector - const parsed = parseWorkspaceKey(workspaceSelector) - return parsed?.type === 'folder' - ? folderWorkspaceKey(parsed.folderWorkspaceId) - : this.resolveEmulatorWorkspaceId(selector) - } - - private folderWorkspaceToResolvedWorktree(folderWorkspace: FolderWorkspace): ResolvedWorktree { - const worktree = folderWorkspaceToWorktree(folderWorkspace) - return { - ...worktree, - parentWorktreeId: null, - childWorktreeIds: [], - lineage: null, - git: { - path: worktree.path, - head: worktree.head, - branch: worktree.branch, - isBare: worktree.isBare, - isMainWorktree: worktree.isMainWorktree - } - } - } - - private resolveWorkspaceTerminalStartupCwd( - workspace: Pick, - requestedCwd?: string | null - ): string | undefined { - return resolveTerminalStartupCwd(workspace.path, requestedCwd) - } - - private async resolveTerminalWorkspaceLaunchScope( - selector: string - ): Promise { - return (await this.resolveTerminalWorkspaceLaunchTarget(selector)).scope - } - - private async resolveTerminalWorkspaceLaunchTarget( - selector: string - ): Promise { - const floatingTerminalSelector = - selector === FLOATING_TERMINAL_WORKTREE_ID || - selector === `id:${FLOATING_TERMINAL_WORKTREE_ID}` - if (floatingTerminalSelector) { - // Why: the floating sentinel is terminal-only — no backing repo/worktree record for other workspace APIs. - return { - scope: { - id: FLOATING_TERMINAL_WORKTREE_ID, - path: homedir(), - connectionId: null, - repo: null, - folderWorkspace: null - }, - managedWorktree: null - } - } - - const folderScope = await this.resolveFolderWorkspaceLaunchScope(selector) - if (folderScope) { - return { - scope: folderScope, - managedWorktree: this.folderWorkspaceToResolvedWorktree(folderScope.folderWorkspace) - } - } - - const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector - const parsed = parseWorkspaceKey(workspaceSelector) - const worktreeSelector = parsed?.type === 'worktree' ? `id:${parsed.worktreeId}` : selector - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - const repo = this.store?.getRepo(worktree.repoId) ?? null - return { - scope: { - id: worktree.id, - path: worktree.path, - connectionId: repo?.connectionId ?? null, - repo, - folderWorkspace: null - }, - managedWorktree: worktree - } - } - - private buildTerminalWorkspaceEnv( - scope: TerminalWorkspaceLaunchScope, - baseEnv: Record, - paneKey: string, - tabId: string, - agentTeamsEnv?: Record - ): Record { - const cleanBaseEnv = { ...baseEnv } - for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) { - delete cleanBaseEnv[key] - } - const env = { - ...cleanBaseEnv, - ...agentTeamsEnv, - ...this.buildAgentHookPtyEnv?.(), - ORCA_PANE_KEY: paneKey, - ORCA_TAB_ID: tabId, - ORCA_WORKTREE_ID: scope.id - } - if (!scope.folderWorkspace) { - return env - } - return { - ...env, - ORCA_WORKSPACE_ID: scope.id, - ORCA_PROJECT_GROUP_ID: scope.folderWorkspace.projectGroupId, - ORCA_WORKSPACE_ROOT: scope.folderWorkspace.folderPath - } - } - - private getValidatedExplicitWorktreeIdSelector(selector: string | undefined): string | null { - const worktreeId = getExplicitWorktreeIdSelector(selector) - if ( - worktreeId && - !worktreeId.includes(WORKTREE_ID_SEPARATOR) && - this.store?.getRepo(worktreeId) - ) { - // Why: a registered repo id is a known-invalid worktree id; reject early before fast paths or Git/SSH scans hide the mistake. - throw new WorktreeIdRequiresFullPathError() - } - return worktreeId - } - - /** Resolves one workspace or throws `selector_not_found` / `selector_ambiguous` — never picks a winner. */ - private async resolveWorktreeSelector(selector: string): Promise { - const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(selector) - // Why only `id:`: every other selector kind is matched across the whole fleet, and their - // `selector_ambiguous` contract is defined over all repos. Scoping those would silently pick a - // winner where today they correctly refuse. An `id:` selector already names its repo. - if (explicitWorktreeId && !this.hasFreshResolvedWorktreeCache()) { - const scoped = await this.resolveExplicitWorktreeIdScoped(explicitWorktreeId) - if (scoped) { - return scoped - } - } - const worktrees = await this.listResolvedWorktrees() - let candidates: ResolvedWorktree[] - - if (selector === 'active') { - throw new Error('selector_not_found') - } - - if (selector.startsWith('identity:')) { - const identityKey = selector.slice('identity:'.length) - candidates = worktrees.filter((worktree) => worktree.identity?.key === identityKey) - } else if (selector.startsWith('id:')) { - const worktreeId = explicitWorktreeId ?? selector.slice(3) - candidates = worktrees.filter((worktree) => worktree.id === worktreeId) - if (candidates.length === 0) { - // Why (#16243): `id:` is the only shape the renderer can send, and a stored id can spell - // its path differently from the scan — the divergence `path:` has always absorbed. - // The bare unprefixed branch below stays byte-exact on purpose: only `id:` reaches a - // renderer caller, so `id:repo::p/` folds here while bare `repo::p/` still misses. - const comparisonKey = worktreeIdComparisonKey(worktreeId) - candidates = comparisonKey - ? worktrees.filter((worktree) => worktreeIdComparisonKey(worktree.id) === comparisonKey) - : candidates - } - if (candidates.length === 0) { - const parsed = splitWorktreeIdForFilesystem(worktreeId) - const repo = parsed ? this.store?.getRepo(parsed.repoId) : null - const fallback = - repo?.connectionId && this.store?.getWorktreeMeta(worktreeId) - ? this.buildResolvedWorktreeFromId(worktreeId) - : null - if (fallback !== null) { - candidates = [fallback] - } - } - } else if (selector.startsWith('path:')) { - // Why exact-spelling-only (#16628): a Linux path names a directory in *some* WSL distro, and - // only the CLI sits in one and can prove which. Guessing here would delete a stranger's. - candidates = worktrees.filter((worktree) => - runtimePathsEqual(worktree.path, selector.slice(5)) - ) - if (candidates.length > 1) { - const hostIds = new Set( - candidates.map((worktree) => { - const repo = this.store?.getRepo(worktree.repoId) - return getWorktreeExecutionHostId(worktree, repo) - }) - ) - // Why: duplicate registrations on one host describe one path; identical paths on different hosts do not. - if (hostIds.size === 1) { - candidates = [candidates[0]] - } - } - } else if (selector.startsWith('branch:')) { - const branchSelector = selector.slice(7) - candidates = worktrees.filter((worktree) => - branchSelectorMatches(worktree.branch, branchSelector) - ) - } else if (selector.startsWith('name:')) { - // Keep display-name matching exact so duplicate names hit the same ambiguity path as other selectors. - candidates = worktrees.filter((worktree) => worktree.displayName === selector.slice(5)) - } else if (selector.startsWith('issue:')) { - candidates = worktrees.filter( - (worktree) => - worktree.linkedIssue !== null && String(worktree.linkedIssue) === selector.slice(6) - ) - } else { - candidates = worktrees.filter( - (worktree) => - worktree.id === selector || - runtimePathsEqual(worktree.path, selector) || - branchSelectorMatches(worktree.branch, selector) - ) - } - - if (candidates.length === 1) { - return candidates[0] - } - if (candidates.length > 1) { - throw new Error('selector_ambiguous') - } - throw new Error('selector_not_found') - } - - private async resolveWorkspaceParentSelector(selector: string): Promise { - const rawSelector = selector.startsWith('id:') ? selector.slice('id:'.length) : selector - const parsed = parseWorkspaceKey(rawSelector) - if (parsed?.type === 'folder') { - const folderWorkspace = this.store - ?.getFolderWorkspaces?.() - .find((workspace) => workspace.id === parsed.folderWorkspaceId) - if (!folderWorkspace) { - throw new Error('selector_not_found') - } - return { - type: 'folder', - workspaceKey: folderWorkspaceKey(folderWorkspace.id), - folderWorkspace, - instanceId: null - } - } - const worktreeSelector = parsed?.type === 'worktree' ? `id:${parsed.worktreeId}` : selector - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - return { - type: 'worktree', - workspaceKey: worktreeWorkspaceKey(worktree.id), - worktree, - instanceId: worktree.instanceId ?? null - } - } - - private validateLineageParent(child: ResolvedWorktree, parent: ResolvedWorktree): void { - const childWorktreeId = child.id - const parentWorktreeId = parent.id - if (childWorktreeId === parentWorktreeId) { - throw new RuntimeLineageError('LINEAGE_PARENT_CYCLE', 'A worktree cannot parent itself.') - } - if (!sharesResolvedWorktreeLineageBoundary(child, parent)) { - throw new RuntimeLineageError( - 'LINEAGE_PARENT_CONTEXT_CONFLICT', - 'Parent worktree must belong to the same repository, execution host, and project.' - ) - } - const instanceByWorktreeId = new Map( - this.resolvedWorktreeCache?.worktrees.map((worktree) => [ - worktree.id, - worktree.instanceId - ]) ?? [ - [child.id, child.instanceId], - [parent.id, parent.instanceId] - ] - ) - let cursor: string | undefined = parentWorktreeId - const visited = new Set([childWorktreeId]) - while (cursor) { - if (visited.has(cursor)) { - throw new RuntimeLineageError( - 'LINEAGE_PARENT_CYCLE', - 'Parent selector would create a lineage cycle.' - ) - } - visited.add(cursor) - const lineage = this.store?.getWorktreeLineage?.(cursor) - if (!lineage) { - break - } - const cursorInstanceId = instanceByWorktreeId.get(cursor) - const parentInstanceId = instanceByWorktreeId.get(lineage.parentWorktreeId) - if ( - cursorInstanceId !== lineage.worktreeInstanceId || - parentInstanceId !== lineage.parentWorktreeInstanceId - ) { - break - } - cursor = lineage.parentWorktreeId - } - } - - private async resolveLineageForWorktreeCreate( - input?: WorktreeLineageInput - ): Promise { - const parentSelectorNextSteps = [ - 'Pass a valid --parent-worktree selector such as folder:, worktree:, id:::, branch:, issue:, path:, or active/current.', - 'Retry with --no-parent to create without lineage.' - ] - const parentSelectorNotFoundMessage = (err: unknown): string => - err instanceof WorktreeIdRequiresFullPathError - ? err.message - : 'Parent selector was not found.' - - if (!input) { - return { kind: 'none', warnings: [] } - } - - if (input.noParent === true && (input.parentWorkspace || input.parentWorktree)) { - throw new RuntimeLineageError( - 'LINEAGE_PARENT_CONTEXT_CONFLICT', - 'Choose either one parent selector or --no-parent.' - ) - } - if (input.parentWorkspace && input.parentWorktree) { - throw new RuntimeLineageError( - 'LINEAGE_PARENT_CONTEXT_CONFLICT', - 'Choose either one parent selector or --no-parent.' - ) - } - - if (input.noParent === true) { - return { kind: 'none', warnings: [] } - } - - if (input.parentWorkspace) { - try { - const parent = await this.resolveWorkspaceParentSelector(input.parentWorkspace) - // Why: a picker in the app must record the same provenance as a local create, or the same - // user action would carry different cleanup semantics depending on where the repo lives. - return { - kind: 'lineage', - parent, - origin: input.parentWorkspaceOrigin === 'manual' ? 'manual' : 'cli', - capture: - input.parentWorkspaceOrigin === 'manual' - ? { - source: parent.type === 'worktree' ? 'manual-action' : 'active-workspace', - confidence: 'explicit' - } - : { source: 'explicit-cli-flag', confidence: 'explicit' } - } - } catch (err) { - throw new RuntimeLineageError( - 'LINEAGE_PARENT_NOT_FOUND', - parentSelectorNotFoundMessage(err), - { - nextSteps: parentSelectorNextSteps - } - ) - } - } - - if (input.parentWorktree) { - try { - const parent = await this.resolveWorktreeSelector(input.parentWorktree) - return { - kind: 'lineage', - parent: { - type: 'worktree', - workspaceKey: worktreeWorkspaceKey(parent.id), - worktree: parent, - instanceId: parent.instanceId ?? null - }, - origin: 'cli', - capture: { source: 'explicit-cli-flag', confidence: 'explicit' } - } - } catch (err) { - throw new RuntimeLineageError( - 'LINEAGE_PARENT_NOT_FOUND', - parentSelectorNotFoundMessage(err), - { - nextSteps: parentSelectorNextSteps - } - ) - } - } - - const warnings: WorktreeLineageWarning[] = [] - const candidates: WorktreeLineageCandidate[] = [] - let cwdCandidate: WorktreeLineageCandidate | null = null - let terminalContextResolved = false - - if (input.envParentWorkspace) { - try { - candidates.push({ - source: 'env-workspace', - parent: await this.resolveWorkspaceParentSelector(input.envParentWorkspace) - }) - } catch { - warnings.push({ - code: 'LINEAGE_PARENT_CONTEXT_MISSING', - message: 'Worktree created, but Orca could not validate the environment parent context.', - details: { envParentWorkspace: input.envParentWorkspace } - }) - } - } - - if (input.orchestrationContext?.parentWorktreeId) { - try { - const parent = await this.resolveWorktreeSelector( - `id:${input.orchestrationContext.parentWorktreeId}` - ) - candidates.push({ - source: 'orchestration-context', - parent: { - type: 'worktree', - workspaceKey: worktreeWorkspaceKey(parent.id), - worktree: parent, - instanceId: parent.instanceId ?? null - } - }) - } catch { - // Keep creation recoverable; the warning below covers missing inferred context. - } - } - - const commentTaskId = extractOrchestrationTaskId(input.comment) - if (commentTaskId) { - const candidate = await this.resolveLineageCandidateForTaskId(commentTaskId) - if (candidate) { - candidates.push(candidate) - } - } - - if (input.callerTerminalHandle) { - try { - const terminal = await this.showTerminal(input.callerTerminalHandle) - const terminalParent = await this.resolveWorkspaceParentSelector( - `id:${terminal.worktreeId}` - ) - const activeDispatch = this._orchestrationDb?.getActiveDispatchForTerminal( - input.callerTerminalHandle - ) - const activeRun = this._orchestrationDb?.getActiveCoordinatorRun() - if (activeDispatch) { - candidates.push({ - source: 'orchestration-context', - parent: terminalParent, - taskId: activeDispatch.task_id, - ...(activeRun - ? { - orchestrationRunId: activeRun.id, - coordinatorHandle: activeRun.coordinator_handle - } - : {}) - }) - } else { - candidates.push({ - source: 'terminal-context', - parent: terminalParent - }) - } - terminalContextResolved = true - } catch { - // Why: a stale terminal handle (reload/SSH reconnect) shouldn't drop lineage; keep resolving other inferred candidates. - warnings.push({ - code: 'LINEAGE_PARENT_CONTEXT_MISSING', - message: - 'Worktree created, but Orca could not validate the caller terminal as a parent context.', - details: { callerTerminalHandle: input.callerTerminalHandle } - }) - } - } - - if (input.cwdParentWorktree) { - try { - cwdCandidate = { - source: 'cwd-context', - parent: await this.resolveWorkspaceParentSelector(input.cwdParentWorktree) - } - } catch { - warnings.push({ - code: 'LINEAGE_PARENT_CONTEXT_MISSING', - message: - 'Worktree created, but Orca could not validate the current directory as a parent context.', - details: { cwdParentWorktree: input.cwdParentWorktree } - }) - } - } - - if (candidates.length === 0 && cwdCandidate) { - candidates.push(cwdCandidate) - } - - if (candidates.length === 0) { - return { kind: 'none', warnings } - } - - const [first] = candidates - const conflict = candidates.find( - (candidate) => candidate.parent.workspaceKey !== first.parent.workspaceKey - ) - if (conflict) { - return { - kind: 'none', - warnings: [ - { - code: 'LINEAGE_PARENT_CONTEXT_CONFLICT', - message: 'Worktree created, but Orca could not prove which parent context caused it.', - details: { - terminalParentWorkspaceKey: candidates.find((c) => c.source === 'terminal-context') - ?.parent.workspaceKey, - envParentWorkspaceKey: candidates.find((c) => c.source === 'env-workspace')?.parent - .workspaceKey, - orchestrationParentWorkspaceKey: candidates.find( - (c) => c.source === 'orchestration-context' - )?.parent.workspaceKey - } - } - ] - } - } - - const preferred = - candidates.find((candidate) => candidate.source === 'env-workspace') ?? - candidates.find((candidate) => candidate.source === 'orchestration-context') ?? - first - return { - kind: 'lineage', - parent: preferred.parent, - origin: preferred.source === 'orchestration-context' ? 'orchestration' : 'cli', - capture: { source: preferred.source, confidence: 'inferred' }, - ...((preferred.orchestrationRunId ?? input.orchestrationContext?.orchestrationRunId) - ? { - orchestrationRunId: - preferred.orchestrationRunId ?? input.orchestrationContext?.orchestrationRunId - } - : {}), - ...((preferred.taskId ?? input.orchestrationContext?.taskId) - ? { taskId: preferred.taskId ?? input.orchestrationContext?.taskId } - : {}), - ...((preferred.coordinatorHandle ?? input.orchestrationContext?.coordinatorHandle) - ? { - coordinatorHandle: - preferred.coordinatorHandle ?? input.orchestrationContext?.coordinatorHandle - } - : {}), - ...(terminalContextResolved && input.callerTerminalHandle - ? { createdByTerminalHandle: input.callerTerminalHandle } - : {}) - } - } - - private async resolveLineageCandidateForTaskId( - taskId: string - ): Promise { - const db = this.getOrchestrationDbIfAvailable() - const dispatch = db?.getDispatchContext(taskId) - // Why: agent-created tasks may never be dispatched, but the creating terminal still identifies the parent workspace. - const parentHandle = - dispatch?.assignee_handle ?? db?.getTask(taskId)?.created_by_terminal_handle - if (!parentHandle) { - return null - } - try { - const terminal = await this.showTerminal(parentHandle) - const parent = await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`) - return { - source: 'orchestration-context', - parent: { - type: 'worktree', - workspaceKey: worktreeWorkspaceKey(parent.id), - worktree: parent, - instanceId: parent.instanceId ?? null - }, - taskId - } - } catch { - return null - } - } - - private getOrchestrationDbIfAvailable(): OrchestrationDb | null { - try { - return this._orchestrationDb ?? this.getOrchestrationDb() - } catch { - return this._orchestrationDb - } - } - - async hydrateInferredWorktreeLineage(): Promise { - const store = this.store - if ( - !store || - typeof store.getWorktreeLineage !== 'function' || - typeof store.setWorktreeLineage !== 'function' - ) { - return - } - - const worktrees = await this.listResolvedWorktrees() - for (const worktree of worktrees) { - if (store.getWorktreeLineage(worktree.id) || !worktree.instanceId) { - continue - } - const taskId = extractOrchestrationTaskId(worktree.comment) - if (!taskId) { - continue - } - const candidate = await this.resolveLineageCandidateForTaskId(taskId) - if ( - !candidate?.parent.instanceId || - candidate.parent.type !== 'worktree' || - candidate.parent.worktree.id === worktree.id - ) { - continue - } - try { - this.validateLineageParent(worktree, candidate.parent.worktree) - } catch { - continue - } - store.setWorktreeLineage(worktree.id, { - worktreeId: worktree.id, - worktreeInstanceId: worktree.instanceId, - parentWorktreeId: candidate.parent.worktree.id, - parentWorktreeInstanceId: candidate.parent.instanceId, - origin: 'orchestration', - capture: { source: 'orchestration-context', confidence: 'inferred' }, - taskId, - createdAt: Date.now() - }) - } - } - - async listWorktreeLineage(): Promise> { - await this.hydrateInferredWorktreeLineage() - return this.store?.getAllWorktreeLineage?.() ?? {} - } - - async listWorkspaceLineage(): Promise> { - await this.hydrateInferredWorktreeLineage() - return this.store?.getAllWorkspaceLineage?.() ?? {} - } - - // Why: one selector grammar, so connection-scoped resolution can narrow the same - // candidate set instead of reimplementing (and diverging from) the matching rules. - private selectReposBySelector(selector: string): Repo[] { - const repos = this.store?.getRepos() ?? [] - if (selector.startsWith('id:')) { - return repos.filter((repo) => repo.id === selector.slice(3)) - } - if (selector.startsWith('path:')) { - return repos.filter((repo) => runtimePathsEqual(repo.path, selector.slice(5))) - } - if (selector.startsWith('name:')) { - return repos.filter((repo) => repo.displayName === selector.slice(5)) - } - return repos.filter( - (repo) => - repo.id === selector || - runtimePathsEqual(repo.path, selector) || - repo.displayName === selector - ) - } - - private async resolveRepoSelector(selector: string): Promise { - if (!this.store) { - throw new Error('repo_not_found') - } - const candidates = this.selectReposBySelector(selector) - - if (candidates.length === 1) { - return candidates[0] - } - if (candidates.length > 1) { - throw new Error('selector_ambiguous') - } - throw new Error('repo_not_found') - } - - private requireStore(): Store { - if (!this.store) { - throw new Error('runtime_unavailable') - } - return this.store as unknown as Store - } - - private buildResolvedWorktreeFromId(worktreeId: string): ResolvedWorktree | null { - const parsed = splitWorktreeIdForFilesystem(worktreeId) - if (!parsed?.repoId || !parsed.worktreePath) { - return null - } - const repo = this.store?.getRepos?.()?.find((entry) => entry.id === parsed.repoId) - const git = { - path: parsed.worktreePath, - head: '', - branch: '', - isBare: false, - isMainWorktree: repo ? areWorktreePathsEqual(parsed.worktreePath, repo.path) : false - } - const meta = this.store?.getWorktreeMeta(worktreeId) - const merged = { - ...mergeWorktree(parsed.repoId, git, meta, repo?.displayName), - ...(repo ? { hostId: meta?.hostId ?? getRepoExecutionHostId(repo) } : {}) - } - return { - ...merged, - id: worktreeId, - parentWorktreeId: null, - childWorktreeIds: [], - lineage: null, - git, - displayName: merged.displayName, - comment: merged.comment - } - } - - private listKnownResolvedWorktreesForExplicitTarget( - targetWorktreeId: string, - targetWorktree: ResolvedWorktree | null - ): ResolvedWorktree[] { - if (!this.store || !targetWorktree) { - return [] - } - const target = splitWorktreeIdForFilesystem(targetWorktreeId) - if (!target?.repoId || !target.worktreePath) { - // Folder workspace keys have no repo/path tuple, but the converted row - // is already authoritative for this explicit target. - return [targetWorktree] - } - const worktreeIds = new Set( - Object.keys(this.store.getAllWorktreeMeta()).filter((worktreeId) => { - const parsed = splitWorktreeIdForFilesystem(worktreeId) - return ( - parsed?.repoId === target.repoId && - Boolean(parsed.worktreePath) && - (isPathInsideOrEqual(target.worktreePath, parsed.worktreePath) || - isPathInsideOrEqual(parsed.worktreePath, target.worktreePath)) - ) - }) - ) - worktreeIds.add(targetWorktreeId) - - const resolved: ResolvedWorktree[] = [] - for (const worktreeId of worktreeIds) { - const worktree = - worktreeId === targetWorktreeId - ? targetWorktree - : this.buildResolvedWorktreeFromId(worktreeId) - if (worktree) { - resolved.push(worktree) - } - } - return resolved - } - - /** A warm fleet snapshot already answers any selector for free, so scoped scanning must yield to it. */ - private hasFreshResolvedWorktreeCache(): boolean { - return Boolean(this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > Date.now()) - } - - private async listResolvedWorktrees(): Promise { - return (await this.listResolvedWorktreeSnapshot()).worktrees - } - - private async listResolvedWorktreeSnapshot(): Promise { - if (!this.store) { - return { worktrees: [], platformByRepoId: new Map() } - } - const now = Date.now() - if (this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > now) { - return this.resolvedWorktreeCache - } - const generation = this.resolvedWorktreeGeneration - if (this.resolvedWorktreeInFlight?.generation === generation) { - return this.resolvedWorktreeInFlight.promise - } - - const promise = this.computeResolvedWorktrees(generation) - this.resolvedWorktreeInFlight = { generation, promise } - try { - return await promise - } finally { - if (this.resolvedWorktreeInFlight?.promise === promise) { - this.resolvedWorktreeInFlight = null - } - } - } - - private async computeResolvedWorktrees(generation: number): Promise { - if (!this.store) { - return { worktrees: [], platformByRepoId: new Map() } - } - const metaById = this.store.getAllWorktreeMeta() ?? {} - const repos = this.store.getRepos() - const repoOwnerCounts = new Map() - for (const repo of repos) { - repoOwnerCounts.set(repo.id, (repoOwnerCounts.get(repo.id) ?? 0) + 1) - } - const projectRuntimeByRepoId = resolveLocalProjectRuntimesForRepos(this.requireStore(), repos) - const platformByRepoId = new Map( - repos.map((repo) => [ - repo.id, - getAgentLaunchPlatformForRepo(repo, projectRuntimeByRepoId.get(repo.id)) - ]) - ) - const deps = this.repoWorktreeRowDeps() - const perRepoWorktrees = await Promise.all( - repos.map( - async (repo) => - await resolveRepoWorktreeRows( - deps, - repo, - metaById, - projectRuntimeByRepoId, - repoOwnerCounts.get(repo.id) ?? 1 - ) - ) - ) - const lineageById = this.store?.getAllWorktreeLineage?.() ?? {} - const worktrees = perRepoWorktrees.flatMap((rows) => - projectResolvedWorktreeLineage(rows, lineageById) - ) - // Why: short TTL avoids shelling out on every frequent poll while still catching worktree changes made outside Orca. - // Why stamped on completion, not entry: a compute that spent longer than the TTL would otherwise publish an - // already-expired entry, so the very next poll recomputes and every caller repeats the same slow path. - if (generation === this.resolvedWorktreeGeneration) { - this.resolvedWorktreeCache = { - worktrees, - platformByRepoId, - expiresAt: Date.now() + RESOLVED_WORKTREE_CACHE_TTL_MS - } - } - return { worktrees, platformByRepoId } - } - - /** Bind the runtime-owned scan cache and folder-workspace stamping into the row resolver. */ - private repoWorktreeRowDeps(): RepoWorktreeRowDeps { - const store = this.requireStore() - return { - store, - scanRepo: (repo, projectRuntimeByRepoId) => - this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId), - listFolderWorkspaces: (repo, repoOwnerCount) => - listRuntimeFolderWorkspaces(store, repo, repoOwnerCount) - } - } - - private async resolveExplicitWorktreeIdScoped( - worktreeId: string, - requiredHostId?: ExecutionHostId - ): Promise { - if (!this.store) { - return null - } - return await resolveScopedWorktreeIdRow(this.repoWorktreeRowDeps(), worktreeId, requiredHostId) - } - - private async listRepoWorktreesForResolution( - repo: Repo, - projectRuntimeByRepoId?: ReadonlyMap - ): Promise { - const now = Date.now() - const scanScopeKey = `${repo.id}\0${getRepoExecutionHostId(repo)}` - const generation = this.worktreeScanGenerations.get(scanScopeKey) ?? 0 - const projectRuntime = repo.connectionId - ? undefined - : projectRuntimeByRepoId - ? projectRuntimeByRepoId.get(repo.id) - : resolveLocalProjectRuntimeForRepo(this.requireStore(), repo) - const runtimeKey = projectRuntime - ? projectRuntime.status === 'resolved' - ? projectRuntime.runtime.cacheKey - : projectRuntime.repair.cacheKey - : repo.connectionId - ? `ssh:${repo.connectionId}:${getSshGitProviderGeneration(repo.connectionId)}` - : 'local:default' - const cached = this.worktreeScanCache.get(scanScopeKey) - if ( - cached?.generation === generation && - cached.runtimeKey === runtimeKey && - cached.expiresAt > now - ) { - return cached.result - } - const inFlight = this.worktreeScanInFlight.get(scanScopeKey) - if (inFlight?.generation === generation && inFlight.runtimeKey === runtimeKey) { - const refresh = await inFlight.promise - if (generation !== (this.worktreeScanGenerations.get(scanScopeKey) ?? 0)) { - return this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId) - } - return refresh.result - } - const reusableCached = - cached?.generation === generation && cached.runtimeKey === runtimeKey ? cached : null - const promise = this.refreshRepoWorktreeScan(repo, projectRuntime, reusableCached) - this.worktreeScanInFlight.set(scanScopeKey, { generation, runtimeKey, promise }) - try { - const refresh = await promise - // Why: fence the caller as well as cache writeback, or an event refresh can consume a stale scan. - if (generation !== (this.worktreeScanGenerations.get(scanScopeKey) ?? 0)) { - return this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId) - } - // Why: back off local spawn failures under resource pressure while disconnected SSH can recover on the next poll. - if ( - (refresh.result.ok || !repo.connectionId) && - this.worktreeScanInFlight.get(scanScopeKey)?.promise === promise - ) { - const entry: RuntimeWorktreeScanCache = { - generation, - runtimeKey, - result: refresh.result, - expiresAt: Date.now() + resolveWorktreeScanCacheTtlMs(repo), - adminFingerprint: refresh.adminFingerprint, - scannedAt: refresh.scannedAt - } - this.worktreeScanCache.set(scanScopeKey, entry) - // Why a writeback instead of storing the promise: a probe that never settles must not be - // awaited by a later refresh. Identity check keeps a stale probe out of a newer entry. - void refresh.adminFingerprintProbe?.then((fingerprint) => { - if (this.worktreeScanCache.get(scanScopeKey) === entry) { - entry.adminFingerprint = fingerprint - } - }) - } - return refresh.result - } finally { - if (this.worktreeScanInFlight.get(scanScopeKey)?.promise === promise) { - this.worktreeScanInFlight.delete(scanScopeKey) - } - } - } - - /** - * Refresh one repo's worktree rows, skipping the `git worktree list` subprocess when a cheap - * Git-admin fingerprint proves nothing changed since the cached scan. - */ - private async refreshRepoWorktreeScan( - repo: Repo, - projectRuntime: ProjectExecutionRuntimeResolution | undefined, - cached: RuntimeWorktreeScanCache | null - ): Promise { - const scannedAt = Date.now() - // SSH and WSL-routed repos run Git off-host, so a local admin-dir read cannot describe them. - const fingerprintCapable = - !repo.connectionId && - // Why: a repo whose scan TTL already reaches the reconciliation interval can never reuse a - // fingerprint, so reading one would be pure work. Agent-scratch roots are that case today. - resolveWorktreeScanCacheTtlMs(repo) < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS && - !getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime).wslDistro - // Why issue it before the scan: a change landing while the scan runs must not be stamped as - // already-observed, or the next probe would mask it until the reconciliation deadline. - const probe = fingerprintCapable ? this.startRepoWorktreeAdminFingerprintProbe(repo) : null - const reusable = - cached?.result.ok === true && - scannedAt - cached.scannedAt < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS - ? cached - : null - if (probe && reusable) { - // Why await only here: this is the one branch whose decision needs the probe. A scan-bound - // caller must never wait on it, or every cold read pays filesystem latency it cannot use. - const probed = await withTimeoutResult(probe, WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS) - if (!probed.ok) { - // Why log: expiry and "fingerprint unavailable" both surface as `null`, so a wedged mount is - // otherwise indistinguishable from a repo that simply cannot be fingerprinted. - console.warn('[worktree-scan] admin fingerprint probe expired; running a full scan', { - repoId: repo.id, - timeoutMs: WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS - }) - } - const current = probed.ok ? probed.value : null - if (current !== null && current === reusable.adminFingerprint) { - return { - result: reusable.result, - adminFingerprint: current, - adminFingerprintProbe: null, - scannedAt: reusable.scannedAt - } - } - } - const result = await this.listRepoWorktreesForResolutionUncached(repo, projectRuntime) - return { result, adminFingerprint: null, adminFingerprintProbe: probe, scannedAt } - } - - /** - * Read one repo's Git-admin fingerprint, unless that repo's previous read is still outstanding. - * Why the gate: `withTimeout` abandons a probe without cancelling it, and readdir/stat take no - * AbortSignal — on a wedged mount a fresh probe per refresh would pin every libuv fs thread. - */ - private startRepoWorktreeAdminFingerprintProbe(repo: Repo): Promise | null { - if (this.worktreeAdminFingerprintProbes.has(repo.id)) { - return null - } - this.worktreeAdminFingerprintProbes.add(repo.id) - return readRepoWorktreeAdminFingerprint(repo.path) - .catch(() => null) - .finally(() => { - this.worktreeAdminFingerprintProbes.delete(repo.id) - }) - } - - private async listRepoWorktreesForResolutionUncached( - repo: Repo, - projectRuntime: ProjectExecutionRuntimeResolution | undefined - ): Promise { - if (!repo.connectionId) { - return await scanLocalRepoWorktreesForResolution( - repo.path, - getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime) - ) - } - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - return { ok: false, worktrees: this.listStoredWorktreesForResolution(repo) } - } - try { - return { ok: true, worktrees: await provider.listWorktrees(repo.path) } - } catch { - return { ok: false, worktrees: this.listStoredWorktreesForResolution(repo) } - } - } - - private listStoredWorktreesForResolution(repo: Repo): GitWorktreeInfo[] { - return this.store ? listStoredWorktreeRowsForRepo(this.requireStore(), repo) : [] - } - - private async getResolvedWorktreeMap(): Promise> { - return new Map((await this.listResolvedWorktrees()).map((worktree) => [worktree.id, worktree])) - } - - private invalidateResolvedWorktreeCache(): void { - this.resolvedWorktreeGeneration += 1 - this.resolvedWorktreeCache = null - } - - private invalidateWorktreeScanCacheForRepo(repoId: string): void { - const prefix = `${repoId}\0` - const scopeKeys = new Set( - this.store - ?.getRepos() - .filter((repo) => repo.id === repoId) - .map((repo) => `${repoId}\0${getRepoExecutionHostId(repo)}`) ?? [] - ) - for (const keys of [ - this.worktreeScanGenerations.keys(), - this.worktreeScanCache.keys(), - this.worktreeScanInFlight.keys() - ]) { - for (const key of keys) { - if (key.startsWith(prefix)) { - scopeKeys.add(key) - } - } - } - for (const key of scopeKeys) { - this.worktreeScanGenerations.set(key, (this.worktreeScanGenerations.get(key) ?? 0) + 1) - this.worktreeScanCache.delete(key) - this.worktreeScanInFlight.delete(key) - } - } - - private invalidateSshWorktreeScanCacheInternal(targetId: string): void { - const repos = this.store?.getRepos() ?? [] - const affectedRepos = repos.filter((repo) => repo.connectionId === targetId) - const affectedScopeKeys = new Set( - affectedRepos.map((repo) => `${repo.id}\0${getRepoExecutionHostId(repo)}`) - ) - for (const key of affectedScopeKeys) { - this.worktreeScanGenerations.set(key, (this.worktreeScanGenerations.get(key) ?? 0) + 1) - this.worktreeScanCache.delete(key) - this.worktreeScanInFlight.delete(key) - } - if (affectedScopeKeys.size > 0) { - this.resolvedWorktreeGeneration += 1 - this.resolvedWorktreeCache = null - } - } - - /** Invalidate the worktree cache and tell the renderer to re-list after an out-of-band branch change so the new name surfaces immediately. */ - notifyBranchRenamed(repoId: string): void { - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(repoId) - this.notifyWorktreesChanged(repoId) - } - - /** Like {@link notifyBranchRenamed} but carries old->new worktree id so the renderer re-keys instead of treating the id change as a deletion. */ - notifyWorktreeFolderRenamed(repoId: string, oldWorktreeId: string, newWorktreeId: string): void { - this.clientSessionTabSelections.migrateWorktree(oldWorktreeId, newWorktreeId) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(repoId) - this.notifier?.worktreesChanged(repoId, { oldWorktreeId, newWorktreeId }) - // Mirror notifyBranchRenamed so in-process onClientEvent listeners also see the rename. - this.emitClientEvent({ type: 'worktreesChanged', repoId }) - } - - notifyFolderWorkspaceChanged(): void { - this.invalidateResolvedWorktreeCache() - this.notifyReposChanged() - } - - private recordPtyWorktree( - ptyId: string, - worktreeId: string, - state: Partial< - Pick< - RuntimePtyWorktreeRecord, - | 'connected' - | 'lastOutputAt' - | 'preview' - | 'tabId' - | 'paneKey' - | 'title' - | 'connectionId' - | 'runtimeSessionOwned' - | 'isWsl' - | 'wslDistro' - | 'incarnationId' - | 'agentSessionOwners' - > - > = {} - ): RuntimePtyWorktreeRecord { - let pty = this.ptysById.get(ptyId) - if (!pty) { - const titleObservedAt = state.title ? this.nextTitleObservationSequence() : null - const connectionId = state.connectionId ?? parseAppSshPtyId(ptyId)?.connectionId ?? null - const worktreePath = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath - const fallbackWslDistro = - process.platform === 'win32' && connectionId === null && worktreePath - ? parseWslUncPath(worktreePath)?.distro - : undefined - const wslDistro = - connectionId === null - ? (state.wslDistro ?? this.wslDistroByPtyId.get(ptyId) ?? fallbackWslDistro ?? null) - : null - pty = { - ptyId, - incarnationId: state.incarnationId ?? null, - worktreeId, - connectionId, - runtimeSessionOwned: state.runtimeSessionOwned ?? false, - isWsl: state.isWsl ?? null, - wslDistro, - tabId: state.tabId ?? null, - paneKey: state.paneKey ?? null, - launchConfig: null, - launchToken: null, - launchIncarnationId: null, - launchAgent: null, - agentSessionOwners: (state.agentSessionOwners ?? []).map(cloneAgentSessionOwnerBinding), - foregroundAgent: null, - connected: state.connected ?? true, - disconnectedAt: state.connected === false ? Date.now() : null, - lastExitCode: null, - lastExitCause: null, - lastAgentStatus: null, - lastAgentStatusObservedLive: false, - lastAgentStatusStartedAtEpochMs: null, - lastAgentStatusRichInvalidatedAtEpochMs: null, - lastOscTitle: null, - lastOscTitleAt: null, - lastOscTitleEpochMs: null, - managementTitle: null, - managementTitleAt: null, - controllerTitle: null, - title: state.title ?? null, - titleUpdatedAt: titleObservedAt, - lastOutputAt: state.lastOutputAt ?? null, - tailBuffer: [], - tailTranscriptBuffer: [], - tailTranscriptChars: 0, - tailPartialLine: '', - tailPendingAnsi: '', - tailRedrawCursor: null, - tailTruncated: false, - tailLinesTotal: 0, - preview: state.preview ?? '', - waitBlockedAt: null - } - if (state.title) { - this.setPtyManagementTitleFromObservedTitle(pty, state.title, titleObservedAt ?? 0) - } - this.ptysById.set(ptyId, pty) - if (wslDistro) { - this.wslDistroByPtyId.set(ptyId, wslDistro) - } else if (connectionId !== null) { - // Why: restored SSH IDs can collide with stale local parser state; connection ownership must win before their first output is parsed. - this.wslDistroByPtyId.delete(ptyId) - } - // Why: restored/controller-discovered PTYs learn their worktree here without registerPty(), so URL enrichment must bind at this source. - advertisedUrlWatcher.bindPty(ptyId, worktreeId) - return pty - } - - pty.worktreeId = worktreeId - if ( - state.incarnationId !== undefined && - pty.incarnationId !== null && - state.incarnationId !== pty.incarnationId - ) { - pty.agentSessionOwners = [] - } - if (state.incarnationId !== undefined) { - if (pty.incarnationId && state.incarnationId && pty.incarnationId !== state.incarnationId) { - this.invalidatePtyIncarnationHandle(ptyId) - } - pty.incarnationId = state.incarnationId - } - if (state.agentSessionOwners !== undefined) { - pty.agentSessionOwners = state.agentSessionOwners.map(cloneAgentSessionOwnerBinding) - } - if (state.connectionId !== undefined) { - pty.connectionId = state.connectionId - if (state.connectionId !== null) { - pty.wslDistro = null - this.wslDistroByPtyId.delete(ptyId) - } - } - if (state.runtimeSessionOwned !== undefined) { - pty.runtimeSessionOwned = state.runtimeSessionOwned - } - if (state.isWsl !== undefined) { - pty.isWsl = state.isWsl - } - if (state.wslDistro !== undefined) { - pty.wslDistro = state.wslDistro - if (state.wslDistro) { - this.wslDistroByPtyId.set(ptyId, state.wslDistro) - } else { - this.wslDistroByPtyId.delete(ptyId) - } - } - if (state.tabId !== undefined) { - pty.tabId = state.tabId - } - if (state.paneKey !== undefined) { - pty.paneKey = state.paneKey - } - if (state.connected !== undefined) { - pty.connected = state.connected - pty.disconnectedAt = state.connected ? null : (pty.disconnectedAt ?? Date.now()) - } - if (state.lastOutputAt !== undefined) { - pty.lastOutputAt = maxTimestamp(pty.lastOutputAt, state.lastOutputAt) - } - if (state.preview !== undefined && state.preview.length > 0) { - pty.preview = state.preview - } - if (state.title !== undefined && state.title !== null && state.title.length > 0) { - const observedAt = this.nextTitleObservationSequence() - pty.title = state.title - pty.titleUpdatedAt = observedAt - this.setPtyManagementTitleFromObservedTitle(pty, state.title, observedAt) - } - // Why: recordPtyWorktree is the common lifecycle point for every path that resolves a PTY's worktree (renderer restore, controller list). - advertisedUrlWatcher.bindPty(ptyId, worktreeId) - return pty - } - - private makeRuntimePaneKey( - leaf: Pick - ): string { - return isTerminalLeafId(leaf.leafId) - ? makePaneKey(leaf.tabId, leaf.leafId) - : `${leaf.tabId}:${leaf.paneRuntimeId}` - } - - private getOrCreatePtyWorktreeRecord(ptyId: string): RuntimePtyWorktreeRecord | null { - const existing = this.ptysById.get(ptyId) - if (existing) { - return existing - } - const inferredWorktreeId = inferWorktreeIdFromPtyId(ptyId) - if (!inferredWorktreeId) { - return null - } - // Why: daemon-backed PTY session IDs are prefixed with the worktree ID so mobile summaries survive renderer graph gaps and reloads. - return this.recordPtyWorktree(ptyId, inferredWorktreeId) - } - - /** Synchronizes PTY tracking records with running daemon sessions, querying their foreground agent states. */ - private async refreshPtyWorktreeRecordsFromController( - resolvedWorktrees: ResolvedWorktree[], - targetWorktreeId: string | null = null, - deadline?: number - ): Promise | null> { - const inventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( - resolvedWorktrees, - targetWorktreeId, - deadline - ) - return inventory ? new Set(inventory.livePtyIds) : null - } - - private async refreshPtyWorktreeRecordsWithControllerInventory( - resolvedWorktrees: ResolvedWorktree[], - targetWorktreeId: string | null = null, - deadline?: number, - connectionId?: string | null, - retryStale = false - ): Promise { - if (targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID) { - const targetedLiveness = this.refreshFloatingWorkspacePtyLiveness() - if (targetedLiveness !== null) { - return { - livePtyIds: targetedLiveness, - allLivePtyIds: targetedLiveness, - terminalIdentityByPtyId: new Map(), - queriedHostIds: new Set([LOCAL_EXECUTION_HOST_ID]) - } - } - } - if (!this.ptyController?.listProcesses) { - return null - } - const inventoryGeneration = this.ptyControllerInventorySequence + 1 - this.ptyControllerInventorySequence = inventoryGeneration - const livenessObservationAtStart = this.ptyLivenessObservationSequence - const providerKey = typeof connectionId === 'string' ? `ssh:${connectionId}` : 'local' - if (connectionId === undefined) { - this.ptyControllerAggregateInventoryGeneration = inventoryGeneration - } else { - this.ptyControllerInventoryGenerationByProvider.set(providerKey, inventoryGeneration) - } - const listBudgetMs = - deadline === undefined - ? PTY_CONTROLLER_LIST_TIMEOUT_MS - : Math.max(1, Math.min(PTY_CONTROLLER_LIST_TIMEOUT_MS, deadline - Date.now())) - // Why: give each provider a deadline strictly inside our own, so a relay that - // never answers still leaves the aggregate time to return the providers that did - // — expiring at the same instant would discard the whole inventory instead. - const providerListOpts = { - deadlineMs: Date.now() + Math.max(1, listBudgetMs - PTY_CONTROLLER_LIST_PROVIDER_MARGIN_MS) - } - const processInventory = - connectionId === undefined && this.ptyController.listProcessesWithHostScope - ? this.ptyController.listProcessesWithHostScope(providerListOpts) - : this.ptyController.listProcesses(connectionId, providerListOpts).then((processes) => { - const hostIds = new Set() - if (connectionId === undefined || connectionId === null) { - hostIds.add(LOCAL_EXECUTION_HOST_ID) - } else { - hostIds.add(toSshExecutionHostId(connectionId)) - } - if (connectionId === undefined) { - for (const process of processes) { - const hostId = getPtyExecutionHost(process.id) - if ( - hostId && - hostId !== 'foreign' && - parseExecutionHostId(hostId)?.kind === 'ssh' - ) { - hostIds.add(hostId) - } - } - } - return { processes, hostIds: [...hostIds] } - }) - const sessionsResult = await withTimeoutResult(processInventory, listBudgetMs) - if (!sessionsResult.ok) { - // Why: a transient controller failure is not evidence that retained PTYs exited. - return null - } - const isCurrentInventory = - connectionId === undefined - ? this.ptyControllerAggregateInventoryGeneration === inventoryGeneration && - ![...this.ptyControllerInventoryGenerationByProvider.values()].some( - (generation) => generation > inventoryGeneration - ) - : this.ptyControllerInventoryGenerationByProvider.get(providerKey) === - inventoryGeneration && - this.ptyControllerAggregateInventoryGeneration <= inventoryGeneration - if (!isCurrentInventory) { - // A fleet census that began after this targeted poll must not turn a - // user-driven open into an empty result. Re-query the owning provider; - // the second generation is then fenced against both operations. - if (targetWorktreeId !== null && !retryStale) { - return this.refreshPtyWorktreeRecordsWithControllerInventory( - resolvedWorktrees, - targetWorktreeId, - deadline, - connectionId, - true - ) - } - return null - } - const sessions = sessionsResult.value.processes - const queriedHostIds = new Set(sessionsResult.value.hostIds) - const controllerIdentityByPtyId = new Map() - const ptyIdByControllerHandle = new Map() - const ambiguousControllerPtyIds = new Set() - for (const session of sessions) { - const handle = session.terminalHandle?.trim() - const incarnationId = session.incarnationId?.trim() - if (!handle?.startsWith('term_') || !incarnationId) { - continue - } - const priorPtyId = ptyIdByControllerHandle.get(handle) - if (priorPtyId && priorPtyId !== session.id) { - ambiguousControllerPtyIds.add(priorPtyId) - ambiguousControllerPtyIds.add(session.id) - controllerIdentityByPtyId.delete(priorPtyId) - continue - } - if (controllerIdentityByPtyId.has(session.id)) { - ambiguousControllerPtyIds.add(session.id) - controllerIdentityByPtyId.delete(session.id) - continue - } - ptyIdByControllerHandle.set(handle, session.id) - controllerIdentityByPtyId.set(session.id, { - handle, - incarnationId, - ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}) - }) - } - for (const ptyId of ambiguousControllerPtyIds) { - controllerIdentityByPtyId.delete(ptyId) - } - const findResolvedWorktree = createIncrementalResolvedWorktreeLookup(resolvedWorktrees) - const persistedIndexesByHostId = new Map< - ExecutionHostId, - { - worktreeIdByPtyId: ReadonlyMap - surfaceByPtyId: ReturnType - } - >() - const getPersistedIndexes = (hostId: ExecutionHostId) => { - const existing = persistedIndexesByHostId.get(hostId) - if (existing) { - return existing - } - const persistedSession = this.store?.getWorkspaceSession?.(hostId) - const indexes = { - worktreeIdByPtyId: indexPersistedPtyWorktreeBindings(persistedSession), - surfaceByPtyId: indexPersistedPtySurfaceBindings(persistedSession) - } - persistedIndexesByHostId.set(hostId, indexes) - return indexes - } - const allLivePtyIds = new Set(sessions.map((session) => session.id)) - const selectedLivePtyIds = new Set() - for (const session of sessions) { - // The owning inventory positively observed this PTY again; prior lost-contact doubt is stale. - this.forgetPtyLivenessVerdict(session.id, livenessObservationAtStart) - const sessionConnectionId = - parseAppSshPtyId(session.id)?.connectionId ?? - (typeof connectionId === 'string' ? connectionId : null) - const persistedIndexes = getPersistedIndexes( - sessionConnectionId ? toSshExecutionHostId(sessionConnectionId) : LOCAL_EXECUTION_HOST_ID - ) - const controllerIdentity = controllerIdentityByPtyId.get(session.id) - const persistedWorktreeId = persistedIndexes.worktreeIdByPtyId.get(session.id) - const providerWorktree = session.worktreeId - ? findResolvedWorktree(session.worktreeId) - : undefined - const inferredWorktreeId = inferWorktreeIdFromPtyId(session.id) - const persistedWorktree = persistedWorktreeId - ? findResolvedWorktree(persistedWorktreeId) - : undefined - const hasMigrationEvidence = - Boolean(session.worktreeId) && - !providerWorktree && - Boolean(persistedWorktree) && - Boolean(inferredWorktreeId) && - worktreeIdsEqual(session.worktreeId as string, inferredWorktreeId as string) - // Why: an unresolved explicit provider owner remains authoritative unless the session id proves it was frozen before a persisted rename migration. - const worktreeId = providerWorktree - ? providerWorktree.id - : hasMigrationEvidence - ? (persistedWorktree?.id ?? null) - : (session.worktreeId ?? - persistedWorktree?.id ?? - inferredWorktreeId ?? - findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd, targetWorktreeId)) - const persistedSurface = persistedIndexes.surfaceByPtyId.get(session.id) - const restoresExactSurface = - persistedSurface && - session.incarnationId && - persistedSurface.incarnationId === session.incarnationId && - Boolean(worktreeId) && - worktreeIdsEqual(persistedSurface.worktreeId, worktreeId as string) - this.adoptControllerTerminalHandle( - session.id, - controllerIdentity?.handle ?? session.terminalHandle, - controllerIdentity?.incarnationId ?? session.incarnationId, - { exactRestoredSurface: Boolean(restoresExactSurface && controllerIdentity) } - ) - if (!targetWorktreeId || (worktreeId && worktreeIdsEqual(worktreeId, targetWorktreeId))) { - selectedLivePtyIds.add(session.id) - } - if (targetWorktreeId && (!worktreeId || !worktreeIdsEqual(worktreeId, targetWorktreeId))) { - const receipt = this.restoredOrchestrationAuthorityByPtyId.get(session.id) - if (receipt && worktreeIdsEqual(receipt.worktreeId, targetWorktreeId)) { - this.restoredOrchestrationAuthorityByPtyId.delete(session.id) - } - continue - } - this.restoredOrchestrationAuthorityByPtyId.delete(session.id) - if (worktreeId) { - const pty = this.recordPtyWorktree(session.id, worktreeId, { - connected: true, - ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), - agentSessionOwners: session.incarnationId ? (session.agentSessionOwners ?? []) : [], - ...(session.wslDistro !== undefined - ? { isWsl: Boolean(session.wslDistro), wslDistro: session.wslDistro } - : {}), - ...(restoresExactSurface - ? { tabId: persistedSurface.tabId, paneKey: persistedSurface.paneKey } - : {}) - }) - if (restoresExactSurface && controllerIdentity) { - this.rememberRestoredOrchestrationAuthority( - pty, - controllerIdentity.handle, - controllerIdentity.incarnationId - ) - } else { - this.restoredOrchestrationAuthorityByPtyId.delete(session.id) - } - pty.controllerTitle = session.title?.trim() || null - this.reconcileSubscriberDrivenProviderAttach(session.id) - } - // Why: fire-and-forget so this listing hot path doesn't serialize a relay round-trip per session and a throw can't abort the sweep below. - this.refreshPtyForegroundAgent(session.id) - } - for (const pty of this.ptysById.values()) { - if (connectionId !== undefined && pty.connectionId !== connectionId) { - continue - } - if (!allLivePtyIds.has(pty.ptyId) && !this.leafExistsForPty(pty.ptyId)) { - const currentVerdict = this.ptyLivenessVerdictByPtyId.get(pty.ptyId) - if ( - currentVerdict && - currentVerdict.observedAt > livenessObservationAtStart && - currentVerdict.verdict.status === 'unverifiable' - ) { - pty.connected = false - pty.disconnectedAt ??= Date.now() - continue - } - const observed = this.ptyController.hasPty?.(pty.ptyId) - if (observed === true) { - // Why: an SSH spawn can become addressable before an overlapping relay list includes it. - allLivePtyIds.add(pty.ptyId) - if ( - !targetWorktreeId || - (pty.worktreeId && worktreeIdsEqual(pty.worktreeId, targetWorktreeId)) - ) { - selectedLivePtyIds.add(pty.ptyId) - } - pty.connected = true - pty.disconnectedAt = null - this.forgetPtyLivenessVerdict(pty.ptyId) - continue - } - pty.connected = false - pty.disconnectedAt ??= Date.now() - pty.agentSessionOwners = [] - // Why: this list only enumerates registered providers, so a dropped relay - // clears `connected` for every one of its PTYs at once. Only `false` here - // is an observed absence; `null` means no provider could be asked. - if (observed === false) { - this.forgetPtyLivenessVerdict(pty.ptyId) - } else if (observed === null) { - this.markPtyLivenessUnverifiable(pty.ptyId, NO_OBSERVING_PROVIDER_REASON) - } - } - } - // Why: runs after the hasPty rescue so a still-addressable pane keeps its receipt. - // A provider that failed to list is absent from `sessions`, and dropping authority on - // that silence would retire an orchestration handle the relay can still reach. - for (const [ptyId, receipt] of this.restoredOrchestrationAuthorityByPtyId) { - const inScope = - connectionId === undefined || - (connectionId === null && receipt.hostScope.kind !== 'ssh') || - (typeof connectionId === 'string' && - receipt.hostScope.kind === 'ssh' && - receipt.hostScope.targetId === connectionId) - if (inScope && !allLivePtyIds.has(ptyId)) { - this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) - } - } - this.pruneDisconnectedPtyRecords() - return { - livePtyIds: targetWorktreeId ? selectedLivePtyIds : allLivePtyIds, - allLivePtyIds, - terminalIdentityByPtyId: controllerIdentityByPtyId, - queriedHostIds - } - } - - private refreshFloatingWorkspacePtyLiveness(): Set | null { - const controller = this.ptyController - if (!controller?.hasPty) { - return null - } - const knownPtyIds = new Set() - const persistedBindingByPtyId = new Map() - for (const pty of this.ptysById.values()) { - if (pty.worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { - knownPtyIds.add(pty.ptyId) - } - } - for (const leaf of this.leaves.values()) { - if (leaf.worktreeId === FLOATING_TERMINAL_WORKTREE_ID && leaf.ptyId) { - knownPtyIds.add(leaf.ptyId) - } - } - const snapshot = this.mobileSessionTabsByWorktree.get(FLOATING_TERMINAL_WORKTREE_ID) - for (const tab of snapshot?.tabs ?? []) { - if (tab.type !== 'terminal') { - continue - } - if (tab.ptyId) { - knownPtyIds.add(tab.ptyId) - persistedBindingByPtyId.set(tab.ptyId, { - tabId: tab.parentTabId, - paneKey: this.getMobileTerminalPaneKey(tab) - }) - } - for (const [leafId, ptyId] of Object.entries(tab.parentLayout?.ptyIdsByLeafId ?? {})) { - knownPtyIds.add(ptyId) - persistedBindingByPtyId.set(ptyId, { - tabId: tab.parentTabId, - paneKey: isTerminalLeafId(leafId) - ? makePaneKey(tab.parentTabId, leafId) - : `${tab.parentTabId}:${/^pane:(\d+)$/.exec(leafId)?.[1] ?? leafId}` - }) - } - } - - const liveness = new Map() - try { - for (const ptyId of knownPtyIds) { - const live = controller.hasPty(ptyId) - if (live === null) { - return null - } - liveness.set(ptyId, live) - } - } catch { - return null - } - - const livePtyIds = new Set() - for (const [ptyId, live] of liveness) { - let pty = this.ptysById.get(ptyId) - if (live) { - livePtyIds.add(ptyId) - const binding = persistedBindingByPtyId.get(ptyId) - if (!pty && binding) { - // Why: a live daemon PTY restored from disk needs its pane identity before mobile can issue a safe handle. - pty = this.recordPtyWorktree(ptyId, FLOATING_TERMINAL_WORKTREE_ID, { - connected: true, - tabId: binding.tabId, - paneKey: binding.paneKey - }) - } - if (pty) { - pty.connected = true - pty.disconnectedAt = null - this.forgetPtyLivenessVerdict(ptyId) - this.refreshPtyForegroundAgent(ptyId) - } - } else if (pty && !this.leafExistsForPty(ptyId)) { - pty.connected = false - pty.disconnectedAt ??= Date.now() - } - } - this.pruneDisconnectedPtyRecords() - return livePtyIds - } - - private pruneDisconnectedPtyTranscript(pty: RuntimePtyWorktreeRecord): void { - if (pty.connected) { - return - } - // Why: disconnected PTY records stay addressable for status/exit reads, but their transcripts must not accumulate after the process dies. - pty.tailBuffer = [] - pty.tailTranscriptBuffer = [] - pty.tailTranscriptChars = 0 - pty.tailPartialLine = '' - pty.tailPendingAnsi = '' - pty.tailRedrawCursor = null - pty.tailTruncated = false - pty.tailLinesTotal = 0 - pty.waitBlockedAt = null - // Why: tail is now empty, so clear the memoized wait scan; onPtyData must recompute from the reset tail if this record resumes output. - pty.tailWaitState = undefined - } - - private pruneDisconnectedPtyRecords(): void { - const retained = [...this.ptysById.values()] - .filter((pty) => !pty.connected && !this.leafExistsForPty(pty.ptyId)) - .sort((a, b) => (a.disconnectedAt ?? 0) - (b.disconnectedAt ?? 0)) - const staleCount = Math.max(0, retained.length - DISCONNECTED_PTY_RECORD_MAX) - for (const stale of retained.slice(0, staleCount)) { - // Why: exited runtime-owned PTYs stay readable, but long-lived runtimes churn through many sessions; bound the archive. - this.dropDisconnectedPtyRecord(stale.ptyId) - } - } - - private dropDisconnectedPtyRecord(ptyId: string): void { - // Why: pruning can remove a PTY without the normal exit callback. - this.advancePtyLifecycleGeneration(ptyId) - this.pairedRendererSessionOwnedPtyIds.delete(ptyId) - this.ptysById.delete(ptyId) - this.pendingPtyHandleReplacementFences.delete(ptyId) - this.recentPtyOutputById.delete(ptyId) - this.setupCompletionTokenByPtyId.delete(ptyId) - this.clearWaitBlockedCheckState(ptyId) - this.recentPtyPathCandidatesById.delete(ptyId) - this.ptyOutputSequenceById.delete(ptyId) - this.providerSequenceInitializedPtys.delete(ptyId) - this.providerSequenceOffsetByPtyId.delete(ptyId) - this.providerSnapshotPreferredPtys.delete(ptyId) - this.providerModeTrackersByPtyId.delete(ptyId) - this.providerModeSnapshotScansByPtyId.delete(ptyId) - this.providerBufferAcquisitionsByPtyId.delete(ptyId) - this.providerVisibleStateByPtyId.delete(ptyId) - this.providerVisibleRetryAtByPtyId.delete(ptyId) - this.agentStatusOscProcessorsByPtyId.delete(ptyId) - this.terminalSpawnCommandsByPtyId.delete(ptyId) - this.disposePtyTitleTracker(ptyId) - this.invalidatePtyIncarnationHandle(ptyId) - this.oscTitleScanTailByPtyId.delete(ptyId) - this.osc7ScanTailByPtyId.delete(ptyId) - this.terminalCwdByPtyId.delete(ptyId) - this.terminalFileUriHostnameByPtyId.delete(ptyId) - this.wslDistroByPtyId.delete(ptyId) - this.clearAgentRowSnapshotsForPty(ptyId) - const handle = this.handleByPtyId.get(ptyId) - if (handle) { - // Why: pruning can remove a PTY without onPtyExit firing; release this leader's agent team so it doesn't leak. - this.claudeAgentTeams.removeTeamForLeaderHandle(handle) - this.handleByPtyId.delete(ptyId) - this.syntheticTerminalHandles.delete(handle) - const record = this.handles.get(handle) - if (record?.tabId.startsWith('pty:')) { - this.handles.delete(handle) - } - } - } - - private leafExistsForPty(ptyId: string): boolean { - return (this.leavesByPtyId.get(ptyId)?.length ?? 0) > 0 - } - - private rebuildLeafPtyIndex(): void { - const next = new Map() - for (const leaf of this.leaves.values()) { - if (!leaf.ptyId) { - continue - } - const leaves = next.get(leaf.ptyId) - if (leaves) { - leaves.push(leaf) - } else { - next.set(leaf.ptyId, [leaf]) - } - } - this.leavesByPtyId = next - } - - private getLeavesForPty(ptyId: string): RuntimeLeafRecord[] { - return this.leavesByPtyId.get(ptyId) ?? [] - } - - private getSummaryForRuntimeWorktreeId( - summaries: Map, - runtimeWorktreeSummaryPathIndex: RuntimeWorktreeSummaryPathIndex, - missingRuntimeWorktreeIds: Set, - runtimeWorktreeId: string - ): RuntimeWorktreePsSummary | null { - const exact = summaries.get(runtimeWorktreeId) - if (exact) { - return exact - } - if (missingRuntimeWorktreeIds.has(runtimeWorktreeId)) { - return null - } - const parsed = parseRuntimeWorktreeId(runtimeWorktreeId) - if (!parsed) { - return null - } - const comparisonPlatform = - runtimeWorktreeSummaryPathIndex.platformByRepoId.get(parsed.repoId) ?? process.platform - const indexed = findRuntimeWorktreeSummaryByPath( - runtimeWorktreeSummaryPathIndex, - parsed.repoId, - parsed.worktreePath, - comparisonPlatform - ) - if (indexed) { - return indexed - } - missingRuntimeWorktreeIds.add(runtimeWorktreeId) - return null - } - - /** Thin adapter so the summary builders stay declarative; the decision lives in `src/shared`. */ - private resolvePaneAgentIdentityField( - launchAgent: TuiAgent | null | undefined, - foregroundAgent: TuiAgent | null | undefined, - title: string | null, - paneKey: string | null - ): { agentIdentity?: TuiAgent } { - // Why hooks here: an agent the USER started from a shell has no launch record, and on WSL the - // Windows host reads its foreground process as `wsl.exe` rather than the agent inside the - // distro. The hook is the only signal that survives both, because the agent reports itself. - const hookRow = paneKey - ? this.getHookAgentRowForPane(this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? []) - : null - const hookAgent = isTuiAgent(hookRow?.agentType) ? hookRow.agentType : null - const agentIdentity = resolvePublishedPaneAgentIdentity({ - hookAgent, - hookIsLive: hookRow?.agentIsLive, - launchAgent, - foregroundAgent, - title - }) - return agentIdentity ? { agentIdentity } : {} - } - - private buildTerminalSummary( - leaf: RuntimeLeafRecord, - worktreesById: Map, - provenLivePtyIds: ReadonlySet | null = null - ): RuntimeTerminalSummary { - const worktree = worktreesById.get(leaf.worktreeId) - const tab = this.tabs.get(leaf.tabId) ?? null - - const pty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : undefined - const title = getLatestLeafTitle(leaf, tab?.title ?? null) - // Why: leaf.connected mirrors the renderer graph (`ptyId !== null`), so a - // restored surface whose PTY died with a prior run still reads connected. - // Demote only on a controller-proven absence, and only for locally-scoped - // ids the aggregate inventory authoritatively covers — SSH/remote scopes may - // be legitimately missing from it, and unknown liveness never demotes. - // The sync hasPty rescue closes the spawn/list race: a just-spawned PTY can - // register after the inventory snapshot, and federation reads one - // connected:false as exited. - const provenAbsent = - provenLivePtyIds !== null && - leaf.ptyId !== null && - !provenLivePtyIds.has(leaf.ptyId) && - !leaf.ptyId.startsWith('remote:') && - parseAppSshPtyId(leaf.ptyId) === null && - this.ptyController?.hasPty?.(leaf.ptyId) !== true - return { - handle: this.issueHandle(leaf), - ptyId: leaf.ptyId, - incarnationId: pty?.incarnationId ?? null, - orphaned: false, - worktreeId: leaf.worktreeId, - worktreePath: worktree?.path ?? '', - branch: worktree?.branch ?? '', - tabId: leaf.tabId, - leafId: leaf.leafId, - title, - connected: provenAbsent ? false : leaf.connected, - writable: provenAbsent ? false : leaf.writable, - lastOutputAt: leaf.lastOutputAt, - preview: leaf.preview, - ...(leaf.lastExitCause ? { exitCause: leaf.lastExitCause } : {}), - ...this.terminalExecutionHostField(leaf.ptyId, leaf.worktreeId), - ...this.resolvePaneAgentIdentityField( - pty?.launchAgent, - pty?.foregroundAgent, - title, - // Why guarded: makePaneKey THROWS on a non-UUID leaf id, and an unguarded call here took - // down terminal.list for every pane in the list, not just the odd one. - isTerminalLeafId(leaf.leafId) ? makePaneKey(leaf.tabId, leaf.leafId) : null - ) - } - } - - // Why: the PTY id names its own host when it has one; only a host-less id may - // fall back to the worktree's. A foreign id with no owner stays unset rather - // than inheriting a local worktree's host and reading as local. - private terminalExecutionHostField( - ptyId: string | null, - worktreeId: string - ): { executionHostId?: ExecutionHostId } { - const fromPtyId = getPtyExecutionHost(ptyId) - if (fromPtyId === 'foreign') { - return {} - } - const hostId = fromPtyId ?? this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) - return hostId ? { executionHostId: hostId } : {} - } - - // Returns the worktrees whose stored snapshot object changed during this - // sync, so the caller can fan out only actually-changed worktrees. - private syncMobileSessionTabs( - snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined, - unchangedWorktreeIds?: string[], - resyncWorktreeIds = new Set() - ): Set { - const changedWorktreeIds = new Set() - if (snapshots === undefined) { - return changedWorktreeIds - } - // Why: snapshots are immutable — every writer replaces the map entry with a - // new object, and the accept gate below drops semantically-unchanged - // renderer resends before they replace an entry — so reference identity - // before/after detects exactly the entries that actually changed. - const before = new Map(this.mobileSessionTabsByWorktree) - this.restoreLivePairedRendererSessionOwnedMobileTerminals(null, { - missingSnapshotOnly: true, - notify: false - }) - // Why: graph sync must scan each persisted host session once, not once per workspace. - const worktreeSessionsToHydrate = new Map( - this.getWorkspaceSessionHydrationTargets(Boolean(this.offscreenBrowserBackend)) - ) - if (this.offscreenBrowserBackend) { - for (const snapshot of snapshots) { - if (!worktreeSessionsToHydrate.has(snapshot.worktree)) { - worktreeSessionsToHydrate.set(snapshot.worktree, null) - } - } - } - // Why: an empty renderer publication after HUB restart must not hide SSH panes persisted in this HUB's host partition. - for (const [worktreeId, workspaceSession] of worktreeSessionsToHydrate) { - this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { - allowAttachedWindow: true, - onlyRuntimeOwnedTerminals: true, - ...(workspaceSession ? { runtimeOwnedTerminalCandidateKnown: true, workspaceSession } : {}) - }) - } - const nextWorktrees = new Set() - const incomingWorktreeIds = new Set(snapshots.map((snapshot) => snapshot.worktree)) - // Why: the renderer withholds unchanged snapshots to keep the graph payload - // small, so these worktrees are still live and must not fall into the prune - // below. Ask for a republish when main no longer holds that accepted renderer - // publication or a formerly-preserved runtime tab has gone stale. - for (const worktreeId of unchangedWorktreeIds ?? []) { - const existing = this.mobileSessionTabsByWorktree.get(worktreeId) - const accepted = this.acceptedRendererMobileSnapshotByWorktree.get(worktreeId) - if (existing) { - nextWorktrees.add(worktreeId) - } - if ( - existing && - accepted && - (existing.publicationEpoch === accepted.publicationEpoch || - existing.publicationEpoch.startsWith(`${accepted.publicationEpoch}:headless-merge:`)) && - existing.tabs.length >= accepted.rendererTabCount && - (existing.tabs.length === accepted.rendererTabCount || - !this.storedMobileSnapshotHasStalePreservedTab( - existing, - accepted.rendererTabIdentityKeys - )) - ) { - continue - } - if (!incomingWorktreeIds.has(worktreeId)) { - resyncWorktreeIds.add(worktreeId) - } - // Why: the accept gate compares against the renderer's last accepted pair, - // which outlives the dropped snapshot and would reject the republish. - this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) - } - for (const snapshot of snapshots) { - nextWorktrees.add(snapshot.worktree) - const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree) - // Why: judge renderer publication ordering against the renderer's own - // last-accepted (epoch, version) — the renderer reuses one pair for - // byte-identical content, so a same-epoch version <= the accepted one is - // a no-op resend (or a stale frame) and must be skipped. Never compare - // against the stored snapshot's version: main-local touches bump it - // independently and would reject genuinely newer renderer revisions. - const accepted = this.acceptedRendererMobileSnapshotByWorktree.get(snapshot.worktree) - if ( - accepted && - accepted.publicationEpoch === snapshot.publicationEpoch && - snapshot.snapshotVersion <= accepted.rendererVersion && - // Why: preservation is main-only state — a serve/SSH binding (or live - // browser page) can disappear without the renderer bumping its version, - // so a resend of the EXACT accepted revision (content-identical to the - // accepted publication, safe to re-merge) must still fall through to - // the merge, which prunes stale preserved tabs. Strictly-older frames - // stay skipped: their content is outdated, and the next accepted-pair - // resend performs the prune. - !( - existing && - snapshot.snapshotVersion === accepted.rendererVersion && - this.storedMobileSnapshotHasStalePreservedTab(existing, accepted.rendererTabIdentityKeys) - ) - ) { - continue - } - this.reconcileNativeChatLaunchDraftResolutionTombstones(snapshot) - const launchDraftFencedSnapshot = this.applyNativeChatLaunchDraftResolutionFence(snapshot) - const fencedSnapshot = this.applyMobileSessionRetirementFences(launchDraftFencedSnapshot) - this.releaseRuntimeSessionOwnershipForRendererRetiredTabs(fencedSnapshot, existing) - const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(fencedSnapshot, existing) - // Why: clients drop same-epoch frames whose version isn't strictly newer, - // and main-local touches may already have emitted a higher version than - // the renderer's counter — keep the stored version strictly monotonic so - // the accepted content is never discarded as stale downstream. - const storedVersion = existing - ? Math.max(nextSnapshot.snapshotVersion, existing.snapshotVersion + 1) - : nextSnapshot.snapshotVersion - this.mobileSessionTabsByWorktree.set( - snapshot.worktree, - storedVersion === nextSnapshot.snapshotVersion - ? nextSnapshot - : { ...nextSnapshot, snapshotVersion: storedVersion } - ) - this.acceptedRendererMobileSnapshotByWorktree.set(snapshot.worktree, { - publicationEpoch: snapshot.publicationEpoch, - rendererVersion: snapshot.snapshotVersion, - rendererTabCount: fencedSnapshot.tabs.length, - rendererTabIdentityKeys: new Set( - fencedSnapshot.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) - ) - }) - } - for (const [worktreeId, existing] of [...this.mobileSessionTabsByWorktree.entries()]) { - if (!nextWorktrees.has(worktreeId)) { - const preserved = this.buildPreservedHeadlessMobileSessionSnapshot(existing) - if (preserved) { - // Why: preservation filters existing.tabs in place (same objects) and - // the merge epoch hashes the preserved identities idempotently, so an - // equal epoch with every tab object retained means the recomputation - // was a no-op — keep the entry so no-op syncs don't fan out. - const preservedIsNoOp = - preserved.publicationEpoch === existing.publicationEpoch && - preserved.tabs.length === existing.tabs.length && - preserved.tabs.every((tab, index) => tab === existing.tabs[index]) - if (!preservedIsNoOp) { - this.mobileSessionTabsByWorktree.set(worktreeId, preserved) - } - // Why: the stored entry is no longer the renderer's publication, so a - // future renderer frame must be re-merged even if it reuses the pair. - this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) - nextWorktrees.add(worktreeId) - } else { - this.mobileSessionTabsByWorktree.delete(worktreeId) - this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId) - this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) - // Why: drop any pending coalesced notify so a stale snapshot can't land after the removed frame. - this.cancelScheduledMobileSessionTabsChanged(worktreeId) - this.notifyMobileSessionTabsRemoved(worktreeId) - } - } - } - for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { - if (before.get(worktreeId) !== snapshot) { - changedWorktreeIds.add(worktreeId) - } - } - return changedWorktreeIds - } - - private mergePreservedHeadlessMobileSessionTabs( - snapshot: RuntimeMobileSessionTabsSnapshot, - existing: RuntimeMobileSessionTabsSnapshot | undefined - ): RuntimeMobileSessionTabsSnapshot { - if (!existing) { - return snapshot - } - const preservedTabs = this.collectPreservedHeadlessMobileSessionTabs(existing, snapshot) - if (preservedTabs.length === 0) { - return snapshot - } - const preservedActiveTab = preservedTabs.find( - (tab) => tab.id === existing.activeTabId && tab.isActive - ) - const hasIncomingActiveTab = snapshot.tabs.some((tab) => tab.isActive) - const normalizedPreservedTabs = preservedTabs.map((tab) => - hasIncomingActiveTab && !preservedActiveTab ? { ...tab, isActive: false } : tab - ) - // Why: an omitting renderer frame predates the runtime-owned structured - // publication, so it cannot revoke that publication's focus intent. - const normalizedIncomingTabs = preservedActiveTab - ? snapshot.tabs.map((tab) => (tab.isActive ? { ...tab, isActive: false } : tab)) - : snapshot.tabs - const tabs = this.mergeMobileSessionSnapshotTabs( - normalizedIncomingTabs, - normalizedPreservedTabs - ) - if (tabs.length === snapshot.tabs.length) { - return snapshot - } - const activeTab = - preservedActiveTab ?? - normalizedIncomingTabs.find((tab) => tab.id === snapshot.activeTabId) ?? - tabs.find((tab) => tab.id === existing.activeTabId) ?? - tabs.find((tab) => tab.isActive) ?? - tabs[0] ?? - null - const terminalTabs = tabs.filter( - (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' - ) - const tabGroups = this.mergeMobileSessionTabGroups( - snapshot.worktree, - snapshot.tabGroups ?? existing.tabGroups ?? [], - terminalTabs, - activeTab?.type === 'terminal' ? activeTab : null - ) - return { - ...snapshot, - publicationEpoch: this.getMergedMobileSessionPublicationEpoch( - snapshot, - normalizedPreservedTabs - ), - snapshotVersion: Math.max(snapshot.snapshotVersion, existing.snapshotVersion), - activeGroupId: snapshot.activeGroupId ?? existing.activeGroupId, - activeTabId: activeTab?.id ?? null, - activeTabType: activeTab?.type ?? null, - tabGroups: this.mergeStructuredAgentSessionTabGroups( - tabGroups, - existing.tabGroups ?? [], - normalizedPreservedTabs, - activeTab?.id ?? null - ), - tabs - } - } - - private mergeStructuredAgentSessionTabGroups( - groups: readonly RuntimeMobileSessionTabGroup[], - existingGroups: readonly RuntimeMobileSessionTabGroup[], - preservedTabs: readonly RuntimeMobileSessionSnapshotTab[], - activeTabId: string | null - ): RuntimeMobileSessionTabGroup[] { - const structuredTabs = preservedTabs.filter((tab) => tab.type === 'agent-session') - if (structuredTabs.length === 0) { - return [...groups] - } - const next = groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] })) - for (const tab of structuredTabs) { - const priorGroupId = existingGroups.find((group) => group.tabOrder.includes(tab.id))?.id - const target = next.find((group) => group.id === priorGroupId) ?? next[0] - if (target && !target.tabOrder.includes(tab.id)) { - target.tabOrder.push(tab.id) - } - if (target && tab.id === activeTabId) { - target.activeTabId = tab.id - } - } - return next - } - - private buildPreservedHeadlessMobileSessionSnapshot( - existing: RuntimeMobileSessionTabsSnapshot - ): RuntimeMobileSessionTabsSnapshot | null { - const tabs = this.collectPreservedHeadlessMobileSessionTabs(existing) - if (tabs.length === 0) { - return null - } - const activeTab = - tabs.find((tab) => tab.id === existing.activeTabId) ?? - tabs.find((tab) => tab.isActive) ?? - tabs[0] ?? - null - const terminalTabs = tabs.filter( - (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' - ) - return { - ...existing, - publicationEpoch: this.getMergedMobileSessionPublicationEpoch(existing, tabs), - // Why: mint a fresh version or clients' same-epoch gate drops the prune frame. - snapshotVersion: existing.snapshotVersion + 1, - activeGroupId: - existing.activeGroupId ?? this.getHeadlessMobileSessionGroupId(existing.worktree), - activeTabId: activeTab?.id ?? null, - activeTabType: activeTab?.type ?? null, - tabGroups: this.mergeMobileSessionTabGroups( - existing.worktree, - existing.tabGroups ?? [], - terminalTabs, - activeTab?.type === 'terminal' ? activeTab : null - ), - tabs - } - } - - // Why: the accepted-revision no-op gate must not fossilize preserved runtime - // tabs. A stored merged snapshot's tabs absent from the accepted renderer - // publication exist only via preservation; if any such tab no longer - // passes the preservation predicate (binding removed from the live PTY table - // and persisted session, or browser page closed), the stored snapshot is stale. - private storedMobileSnapshotHasStalePreservedTab( - existing: RuntimeMobileSessionTabsSnapshot, - rendererTabIdentityKeys: ReadonlySet - ): boolean { - return existing.tabs.some( - (tab) => - !this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => - rendererTabIdentityKeys.has(id) - ) && !this.shouldPreserveHeadlessMobileSessionTab(existing, tab) - ) - } - - private collectPreservedHeadlessMobileSessionTabs( - existing: RuntimeMobileSessionTabsSnapshot, - incoming?: RuntimeMobileSessionTabsSnapshot - ): RuntimeMobileSessionSnapshotTab[] { - const incomingIds = new Set( - incoming?.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) ?? [] - ) - return existing.tabs.filter((tab) => { - if (this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => incomingIds.has(id))) { - return false - } - return this.shouldPreserveHeadlessMobileSessionTab(existing, tab) - }) - } - - private shouldPreserveHeadlessMobileSessionTab( - snapshot: RuntimeMobileSessionTabsSnapshot, - tab: RuntimeMobileSessionSnapshotTab - ): boolean { - if (tab.type === 'agent-session') { - return true - } - if (tab.type === 'browser') { - const liveClientPage = - typeof tab.browserPageId === 'string' - ? getRuntimeBrowserPageRegistry(this).getPage(tab.browserPageId) - : undefined - if ( - liveClientPage?.workspaceId === snapshot.worktree && - tab.placement?.kind === 'client' && - sameRuntimeBrowserPlacement(liveClientPage.placement, tab.placement) - ) { - return true - } - // Why: headless offscreen browser tabs exist only server-side, so a renderer-graph merge must keep them, not prune as "not in the graph". - if (!this.offscreenBrowserBackend) { - return false - } - // Why: in a renderer-based merged snapshot the browser entries can also - // be renderer-owned, so only pages the offscreen bridge still lists are - // runtime-owned and preservable; a pure renderer epoch preserves none. - return ( - this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch) || - (snapshot.publicationEpoch.includes(':headless-merge:') && - typeof tab.browserPageId === 'string' && - this.getLiveBrowserTabsByPageId(snapshot.worktree).has(tab.browserPageId)) - ) - } - if (tab.type !== 'terminal') { - return false - } - // Why: a merged renderer snapshot carries BOTH renderer-owned and - // runtime-owned tabs, so the epoch alone must not preserve every terminal — - // that resurrects renderer tabs the renderer already closed. Broad - // preservation applies only to genuinely headless-built snapshots; in a - // renderer-based one, only tabs with a live-or-persisted serve/SSH binding - // are runtime-owned and preservable. - return ( - this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch) || - this.hasLiveRuntimeSessionOwnedPtyBinding(snapshot.worktree, tab) || - this.hasLiveOrPersistedServeOrSshOwnedPtyBinding(snapshot.worktree, tab) - ) - } - - private isHeadlessMobileSessionPublication(publicationEpoch: string): boolean { - return ( - publicationEpoch.startsWith('headless:') || - publicationEpoch.startsWith('headless-hydrated:') || - publicationEpoch.includes(':headless-merge:') - ) - } - - // Why: `:headless-merge:` only marks that runtime tabs were merged in — the - // BASE epoch still says who published the snapshot. A renderer-based merged - // snapshot must not be classified as headless-built, or its renderer tabs - // read as runtime-owned. - private isHeadlessBuiltMobileSessionPublicationBase(publicationEpoch: string): boolean { - const base = publicationEpoch.split(':headless-merge:')[0] - return base.startsWith('headless:') || base.startsWith('headless-hydrated:') - } - - private getMergedMobileSessionPublicationEpoch( - snapshot: RuntimeMobileSessionTabsSnapshot, - preservedTabs: readonly RuntimeMobileSessionSnapshotTab[] - ): string { - // Why: preserved snapshots can merge repeatedly; strip the prior merge suffix first so the publication epoch stays idempotent. - const normalizedPublicationEpoch = snapshot.publicationEpoch.split(':headless-merge:')[0] - const signature = createHash('sha1') - .update( - preservedTabs - .map((tab) => - tab.type === 'terminal' - ? `${tab.id}:${tab.parentTabId}:${tab.ptyId ?? ''}:${tab.leafId}` - : tab.id - ) - .join('|') - ) - .digest('hex') - .slice(0, 12) - return `${normalizedPublicationEpoch}:headless-merge:${signature}` - } - - private readonly clientHostedBrowserRows = new ClientHostedBrowserRowPublisher({ - listClientPages: (worktreeId) => getRuntimeBrowserPageRegistry(this).listPages(worktreeId), - hasLivePlacement: (browserPageId) => - getBrowserHostLeaseRegistry(this).getPlacement(browserPageId) !== undefined, - resolveDeviceName: (pairedDeviceId) => this.getPairedDeviceNameFn(pairedDeviceId), - getEmitter: () => { - const notifier = this.notifier - const send = notifier?.clientHostedBrowserRowsChanged - return send ? (event) => send.call(notifier, event) : null - } - }) - - /** Worktrees whose persisted client-hosted rows this runtime is responsible for rewriting. */ - private readonly persistedClientHostedBrowserWorktreeIds = new Set() - - /** Serves a hydrating host renderer; the publisher counts this as a delivery, not a read. */ - listClientHostedBrowserRows(): ClientHostedBrowserRowsEvent[] { - return this.clientHostedBrowserRows.deliverHydrationSnapshot() - } - - private notifyMobileSessionTabsRemoved(worktreeId: string): void { - const removed: RuntimeMobileSessionTabsRemovedResult = { - worktree: worktreeId, - publicationEpoch: `removed:${Date.now().toString(36)}`, - snapshotVersion: 0, - removed: true, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - } - const changeSequence = ++this.mobileSessionTabsChangeSequence - for (const subscription of this.mobileSessionTabListeners) { - subscription.listener( - this.clientSessionTabSelections.project(removed, subscription.clientNavigationId), - changeSequence - ) - } - this.clientSessionTabSelections.forgetWorktree(worktreeId) - } - - notifyMobileSessionTabsChanged(worktreeId?: string): void { - if (!worktreeId) { - this.clientHostedBrowserRows.publishAll() - for (const id of new Set([ - ...this.persistedClientHostedBrowserWorktreeIds, - ...getRuntimeBrowserPageRegistry(this) - .listPages() - .map((page) => page.workspaceId) - ])) { - this.persistClientHostedBrowserPagesForWorktree(id) - } - this.notifyMobileSessionTabSnapshots() - return - } - // Why: every client-page mutation — create, navigate, metadata, host quit, recovery — reaches - // this announcement, so the host's own rows derive from it rather than from a second seam. - this.clientHostedBrowserRows.publish(worktreeId) - this.persistClientHostedBrowserPagesForWorktree(worktreeId) - const hasClientBrowserPages = - getRuntimeBrowserPageRegistry(this).listPages(worktreeId).length > 0 - if (this.offscreenBrowserBackend || hasClientBrowserPages) { - const reconciled = this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession( - worktreeId, - hasClientBrowserPages - ? { allowAttachedWindow: true, onlyRuntimeOwnedTerminals: true } - : undefined - ) - // Why: hydrate already reconciles an existing snapshot in place; only reconcile here when it didn't (fresh build or early-returned hydrate). - if (!reconciled.has(worktreeId)) { - const existing = this.mobileSessionTabsByWorktree.get(worktreeId) - if (existing) { - this.reconcileHeadlessMobileSessionBrowserTabs(worktreeId, existing) - } - } - } - // Why: structural changes must propagate promptly; cancel any pending coalesced notify since this immediate emit supersedes it. - this.cancelScheduledMobileSessionTabsChanged(worktreeId) - this.notifyMobileSessionTabsChangedNow(worktreeId, ++this.mobileSessionTabsChangeSequence) - } - - private scheduleMobileSessionTabsChanged(worktreeId: string): void { - this.pendingMobileSessionTabsChangeSequenceByWorktree.set( - worktreeId, - ++this.mobileSessionTabsChangeSequence - ) - this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) - } - - private cancelScheduledMobileSessionTabsChanged(worktreeId: string): void { - this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) - this.pendingMobileSessionTabsChangeSequenceByWorktree.delete(worktreeId) - } - - private flushScheduledMobileSessionTabsChanged(worktreeId: string): void { - const changeSequence = this.pendingMobileSessionTabsChangeSequenceByWorktree.get(worktreeId) - if (changeSequence === undefined) { - return - } - this.pendingMobileSessionTabsChangeSequenceByWorktree.delete(worktreeId) - this.notifyMobileSessionTabsChangedNow(worktreeId, changeSequence) - } - - private notifyMobileSessionTabsChangedNow(worktreeId: string, changeSequence: number): void { - if (this.mobileSessionTabListeners.size === 0) { - return - } - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - if (!snapshot) { - return - } - // Why: browser bridge events are already worktree-scoped; don't fan out every workspace snapshot during navigation/tab churn. - const result = this.toMobileSessionTabsResult(snapshot) - for (const subscription of this.mobileSessionTabListeners) { - subscription.listener( - this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId), - changeSequence - ) - } - } - - private notifyMobileSessionTabSnapshots(): void { - if (this.mobileSessionTabListeners.size === 0) { - return - } - for (const snapshot of this.mobileSessionTabsByWorktree.values()) { - const result = this.toMobileSessionTabsResult(snapshot) - const changeSequence = ++this.mobileSessionTabsChangeSequence - for (const subscription of this.mobileSessionTabListeners) { - subscription.listener( - this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId), - changeSequence - ) - } - } - } - - private getMobileSessionTabsForWorktree( - worktreeId: string, - clientNavigationId?: string - ): RuntimeMobileSessionTabsResult { - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - if (!snapshot) { - return this.projectMobileSessionTabsForClient( - { - worktree: worktreeId, - publicationEpoch: UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH, - snapshotVersion: 0, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - }, - clientNavigationId - ) - } - return this.projectMobileSessionTabsForClient( - this.toMobileSessionTabsResult(snapshot), - clientNavigationId - ) - } - - private emitMobileSessionTabsSnapshotToClient( - projected: RuntimeMobileSessionTabsResult, - clientNavigationId: string, - follow = false - ): void { - const changeSequence = ++this.mobileSessionTabsChangeSequence - for (const subscription of this.mobileSessionTabListeners) { - if (subscription.clientNavigationId === clientNavigationId) { - subscription.listener( - follow ? { ...projected, navigationIntent: 'follow' } : projected, - changeSequence - ) - } - } - } - - private async resolveMobileMarkdownWorktreeId( - worktreeSelector: string, - tabId: string - ): Promise { - const worktreeId = - this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) ?? - (await this.resolveWorktreeSelector(worktreeSelector)).id - const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) - const tab = snapshot?.tabs.find( - (candidate): candidate is RuntimeMobileSessionMarkdownTab => - candidate.type === 'markdown' && candidate.id === tabId - ) - if (!tab) { - throw new Error('tab_not_found') - } - return worktreeId - } - - private getLiveBrowserTabsByPageId(worktreeId: string): Map { - const liveTabs = this.agentBrowserBridge?.tabList?.(worktreeId).tabs ?? [] - const byPageId = new Map(liveTabs.map((tab) => [tab.browserPageId, tab])) - for (const [index, page] of getRuntimeBrowserPageRegistry(this) - .listPages(worktreeId) - .entries()) { - byPageId.set(page.browserPageId, { - browserPageId: page.browserPageId, - index: liveTabs.length + index, - url: page.url, - title: page.title, - active: page.active, - worktreeId, - profileId: page.browserProfileId - }) - } - return byPageId - } - - private collectReturnedSessionTabIds( - tabs: readonly RuntimeMobileSessionClientTab[] - ): Set { - const ids = new Set() - for (const tab of tabs) { - ids.add(tab.id) - if (tab.type === 'terminal') { - ids.add(tab.parentTabId) - } else if (tab.type === 'browser') { - ids.add(tab.browserWorkspaceId) - } - } - return ids - } - - private sanitizeMobileSessionTabGroups( - groups: readonly RuntimeMobileSessionTabGroup[] | undefined, - returnedTabs: readonly RuntimeMobileSessionClientTab[] - ): RuntimeMobileSessionTabGroup[] | undefined { - if (!groups || groups.length === 0) { - return undefined - } - const returnedIds = this.collectReturnedSessionTabIds(returnedTabs) - const sanitized = groups - .map((group): RuntimeMobileSessionTabGroup | null => { - const tabOrder = group.tabOrder.filter((tabId) => returnedIds.has(tabId)) - if (tabOrder.length === 0) { - return null - } - const activeTabId = - group.activeTabId && tabOrder.includes(group.activeTabId) - ? group.activeTabId - : (tabOrder[0] ?? null) - const recentTabIds = group.recentTabIds?.filter((tabId) => tabOrder.includes(tabId)) - return { - id: group.id, - activeTabId, - tabOrder, - ...(recentTabIds && recentTabIds.length > 0 ? { recentTabIds } : {}) - } - }) - .filter((group): group is RuntimeMobileSessionTabGroup => group !== null) - return sanitized.length > 0 ? sanitized : undefined - } - - private pruneMobileSessionTabGroupLayout( - layout: TabGroupLayoutNode | null | undefined, - validGroupIds: ReadonlySet - ): TabGroupLayoutNode | null { - if (!layout) { - return null - } - if (layout.type === 'leaf') { - return validGroupIds.has(layout.groupId) ? layout : null - } - const first = this.pruneMobileSessionTabGroupLayout(layout.first, validGroupIds) - const second = this.pruneMobileSessionTabGroupLayout(layout.second, validGroupIds) - if (first && second) { - return { ...layout, first, second } - } - return first ?? second - } - - /** Transforms an internal mobile session tab snapshot into a sanitized client payload, resolving launch-agent ownership and normalizing titles. */ - private toMobileSessionTabsResult( - snapshot: RuntimeMobileSessionTabsSnapshot - ): RuntimeMobileSessionTabsResult { - const tabs: RuntimeMobileSessionClientTab[] = [] - const liveBrowserTabsByPageId = this.getLiveBrowserTabsByPageId(snapshot.worktree) - // Production reads hook rows by pane; the snapshot fallback remains for tests - // and embedders that have not adopted the narrow getter. - let hookRowsByPaneKey: Map | null = null - const hookRowsForPane = new Map() - const getHookRowsForPane = (paneKey: string): AgentStatusIpcPayload[] => { - const cached = hookRowsForPane.get(paneKey) - if (cached) { - return cached - } - const direct = this.getAgentProviderSessionRowsForPaneFn?.(paneKey) - if (direct) { - hookRowsForPane.set(paneKey, direct) - return direct - } - hookRowsByPaneKey ??= indexAgentStatusRowsByPaneKey( - this.getAgentProviderSessionSnapshotFn?.() ?? [] - ) - const rows = hookRowsByPaneKey.get(paneKey) ?? [] - hookRowsForPane.set(paneKey, rows) - return rows - } - // Why: a live PTY backs one surface; claim each once so two leaves resolving to it can't emit duplicate React keys and crash the client. - const claimedLivePtyIds = new Set() - for (const tab of snapshot.tabs) { - if (tab.type === 'browser') { - const liveTab = tab.browserPageId - ? liveBrowserTabsByPageId.get(tab.browserPageId) - : undefined - if (!liveTab) { - continue - } - // Why: renderer snapshots lag BrowserView teardown/process swaps; only surface pages the browser bridge can still route to. - tabs.push({ - ...tab, - title: liveTab.title || tab.title, - url: liveTab.url || tab.url, - // Why: bridge "active" means active BrowserView/webContents, not active Orca tab; preserve the renderer's session focus. - isActive: tab.isActive - }) - continue - } - if (tab.type === 'markdown' || tab.type === 'file' || tab.type === 'agent-session') { - tabs.push(tab) - continue - } - const syncedTab = this.tabs.get(tab.parentTabId) - const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null - const liveLeaf = leaf?.ptyId && leaf.connected ? leaf : null - const liveLeafPtyId = liveLeaf?.ptyId ?? null - const liveLeafPty = liveLeafPtyId ? (this.ptysById.get(liveLeafPtyId) ?? null) : null - const pty = liveLeaf - ? null - : this.findPtyForMobileTerminalTab(snapshot.worktree, tab, { - allowWorktreeOnlyMatch: !snapshot.publicationEpoch.startsWith('headless') - }) - const livePty = pty?.connected ? pty : null - // Why: enforce one-live-PTY-per-tab; drop a later tab resolving to an already-claimed PTY so no two tabs share a handle. - const resolvedLivePtyId = liveLeafPtyId ?? livePty?.ptyId ?? null - if (resolvedLivePtyId !== null) { - if (claimedLivePtyIds.has(resolvedLivePtyId)) { - continue - } - claimedLivePtyIds.add(resolvedLivePtyId) - } - const legacyPaneId = /^pane:(\d+)$/.exec(tab.leafId)?.[1] ?? null - const paneKey = isTerminalLeafId(tab.leafId) - ? makePaneKey(tab.parentTabId, tab.leafId) - : `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}` - const mobileStatusPty = livePty ?? pty - // Why: headless hooks live only in main's retained rows; reuse this lookup - // for both title ownership and status publication so the two cannot diverge. - const retainedAgentStatus = tab.agentStatus - ? null - : this.getFreshRetainedAgentStatusForMobileTab(paneKey, liveLeafPty ?? mobileStatusPty, tab) - const hookAgentStatus = tab.agentStatus - ? this.getHookAgentRowForPane(getHookRowsForPane(paneKey)) - : null - // Why not tab.ptyId: findPtyForMobileTerminalTab already rejected it when it returned - // null, because persisted ids can collide with an unrelated pane after restart — reading - // that pane's tracker would publish its title here, ahead of every other source. - const trackerOnlyTitle = this.getUnpersistedTrackedTitleForPty( - liveLeafPtyId ?? pty?.ptyId ?? null - ) - const leafTitle = leaf - ? getLatestAgentCandidateTitle( - { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, - { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } - ) - : null - const ptyTitle = pty - ? getLatestAgentCandidateTitle( - { title: pty.title, updatedAt: pty.titleUpdatedAt }, - { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } - ) - : null - // Renderer omission is authoritative: PTY launch provenance outlives agent exit. - const launchAgent = tab.launchAgent ?? null - const launchOwnerAgent = launchAgent ?? liveLeafPty?.launchAgent ?? pty?.launchAgent ?? null - // Why: a retained OMP hook stays stable while wrapper foreground reads can report Pi. - const ownerRecord = resolvePaneAgentOwnerRecord({ - launchAgent: launchOwnerAgent, - hookAgent: - tab.agentStatus?.agentType ?? - hookAgentStatus?.agentType ?? - retainedAgentStatus?.payload.agentType ?? - null - }) - const ownerAgent = - ownerRecord?.agent ?? liveLeafPty?.foregroundAgent ?? pty?.foregroundAgent ?? null - const ownerOptions = { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true } - const title = normalizeCompatibleAgentTitleForOwner( - trackerOnlyTitle ?? leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title, - ownerAgent, - ownerOptions - ) - const liveTitleEvidence = leafTitle ?? ptyTitle - // Why: renderer status can precede hook session identity, leaving native chat with no transcript address. - const rendererStatusAgent = - resolveCompatibleAgentTypeForOwner(tab.agentStatus?.agentType, ownerAgent, ownerOptions) ?? - ownerAgent ?? - undefined - const hookSessionAgent = resolveCompatibleAgentTypeForOwner( - hookAgentStatus?.providerSessionAgentType, - ownerAgent, - ownerOptions - ) - const hookSessionMatchesRenderer = - !rendererStatusAgent || !hookSessionAgent || rendererStatusAgent === hookSessionAgent - const hookProviderSession = - hookAgentStatus?.providerSession && - hookSessionMatchesRenderer && - (!tab.agentStatus?.providerSession || - (hookAgentStatus.providerSessionReceivedAt ?? -1) >= tab.agentStatus.updatedAt) - ? hookAgentStatus.providerSession - : tab.agentStatus?.providerSession - const statusPty = liveLeafPty ?? mobileStatusPty - const normalizedTabAgentStatus = this.renewMobileAgentStatusFromPtyTitle( - tab.agentStatus - ? normalizeCompatibleAgentStatusEntryForOwner( - { - ...tab.agentStatus, - ...(hookProviderSession ? { providerSession: hookProviderSession } : {}) - }, - ownerAgent, - ownerOptions - ) - : null, - statusPty, - { preserveQuestionUnderShellTitle: true } - ) - // Why: keep rich status on a live prompt/tool, or interactivePrompt is lost under a non-agent title. - const hasLiveAgentSignal = - normalizedTabAgentStatus?.interactivePrompt != null || - normalizedTabAgentStatus?.toolName != null - // Why: only shell/management evidence proves the agent released the pane - // (same predicate as the terminal-status API). A merely neutral live title - // — 'Terminal', an editor, a cwd — proves nothing, and treating it as - // completion published a synthetic `done` that fought the client's own - // live status on every republication. - const keepFullAgentStatus = - normalizedTabAgentStatus && - (!terminalTitleBlocksExplicitAgentStatus(liveTitleEvidence) || hasLiveAgentSignal) - const agentStatus = keepFullAgentStatus - ? { agentStatus: normalizedTabAgentStatus } - : // Why: idle live title → drop stale "working" (no spinner) but keep agent identity so native chat can still address the transcript. - normalizedTabAgentStatus?.agentType != null - ? { - agentStatus: { - state: 'done' as const, - prompt: '', - updatedAt: statusPty?.lastOscTitleEpochMs ?? normalizedTabAgentStatus.updatedAt, - stateStartedAt: - statusPty?.lastAgentStatusStartedAtEpochMs ?? - normalizedTabAgentStatus.stateStartedAt, - paneKey: normalizedTabAgentStatus.paneKey, - stateHistory: [], - agentType: normalizedTabAgentStatus.agentType, - ...(normalizedTabAgentStatus.providerSession - ? { providerSession: normalizedTabAgentStatus.providerSession } - : {}) - } - } - : null - // Why: web/mobile clients hold handles across renderer graph syncs; leaf handles are epoch-bound but PTY handles stay streamable. - const terminalHandle = liveLeafPtyId - ? this.issuePtyHandle( - this.recordPtyWorktree(liveLeafPtyId, snapshot.worktree, { - tabId: tab.parentTabId, - paneKey, - connected: true - }) - ) - : livePty - ? this.issuePtyHandle(livePty) - : null - const projectedAgentStatus = - agentStatus ?? - this.buildPtyMobileAgentStatus( - mobileStatusPty, - tab, - terminalHandle, - retainedAgentStatus, - getHookRowsForPane - ) - const projectedStatusEntry = projectedAgentStatus.agentStatus as - | (AgentStatusEntry & { turnCompletedAt?: number }) - | undefined - const { turnCompletedAt: projectedTurnCompletedAt, ...clientStatusFields } = - projectedStatusEntry ?? {} - const clientAgentStatus = projectedStatusEntry - ? { agentStatus: clientStatusFields as AgentStatusEntry } - : {} - const rawTurnCompletedAt = - hookAgentStatus?.live?.payload.turnCompletedAt ?? - this.getHookAgentRowForPane(getHookRowsForPane(paneKey)).live?.payload.turnCompletedAt ?? - projectedTurnCompletedAt - const turnCompletedAt = - typeof rawTurnCompletedAt === 'number' && Number.isFinite(rawTurnCompletedAt) - ? rawTurnCompletedAt - : undefined - tabs.push({ - type: 'terminal', - id: tab.id, - parentTabId: tab.parentTabId, - leafId: tab.leafId, - title, - ...(tab.ptyId ? { ptyId: tab.ptyId } : {}), - ...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}), - ...(launchAgent ? { launchAgent } : {}), - ...clientAgentStatus, - ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}), - ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), - ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), - ...(tab.color != null ? { color: tab.color } : {}), - ...(tab.isPinned ? { isPinned: true } : {}), - ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), - ...(tab.launchDraft ? { launchDraft: tab.launchDraft } : {}), - ...(tab.launchDraftCreatedAt !== undefined - ? { launchDraftCreatedAt: tab.launchDraftCreatedAt } - : {}), - isActive: tab.isActive, - ...(terminalHandle - ? { status: 'ready' as const, terminal: terminalHandle } - : { status: 'pending-handle' as const, terminal: null }) - }) - } - const active = - tabs.find((tab) => tab.isActive && tab.id === snapshot.activeTabId) ?? - tabs.find((tab) => tab.isActive) ?? - (snapshot.activeTabId ? (tabs[0] ?? null) : null) - const normalizedTabs = - active && !tabs.some((tab) => tab.isActive) - ? tabs.map((tab) => (tab.id === active.id ? { ...tab, isActive: true } : tab)) - : tabs - const tabGroups = this.sanitizeMobileSessionTabGroups(snapshot.tabGroups, normalizedTabs) - const validGroupIds = new Set(tabGroups?.map((group) => group.id) ?? []) - const tabGroupLayout = - snapshot.tabGroupLayout === undefined - ? undefined - : this.pruneMobileSessionTabGroupLayout(snapshot.tabGroupLayout, validGroupIds) - const activeGroupId = - snapshot.activeGroupId && validGroupIds.has(snapshot.activeGroupId) - ? snapshot.activeGroupId - : (tabGroups?.find((group) => - active - ? group.tabOrder.some((tabId) => - this.collectReturnedSessionTabIds([active]).has(tabId) - ) - : false - )?.id ?? - tabGroups?.[0]?.id ?? - null) - return { - worktree: snapshot.worktree, - publicationEpoch: snapshot.publicationEpoch, - snapshotVersion: snapshot.snapshotVersion, - activeGroupId, - activeTabId: active?.id ?? null, - activeTabType: active?.type ?? null, - ...(tabGroups ? { tabGroups } : {}), - ...(snapshot.tabGroupLayout !== undefined ? { tabGroupLayout } : {}), - ...(snapshot.retiredTerminalSurfaces - ? { - retiredTerminalSurfaces: snapshot.retiredTerminalSurfaces.filter( - (retired) => - !snapshot.tabs.some( - (tab) => - tab.type === 'terminal' && - tab.parentTabId === retired.parentTabId && - tab.leafId === retired.leafId - ) - ) - } - : {}), - tabs: normalizedTabs - } - } - - private renewMobileAgentStatusFromPtyTitle( - status: AgentStatusEntry | null, - pty: RuntimePtyWorktreeRecord | null, - options: { preserveQuestionUnderShellTitle?: boolean } = {} - ): AgentStatusEntry | null { - if (!status || !pty) { - return status - } - // Why: pending a human answer is hook-only evidence an idle title cannot renew. A title - // reads `permission` only from a vendor glyph or a synthesized ` - action required` - // label, and SYNTHETIC_AGENT_TITLE_PROFILES has no Claude entry (OpenCode opts out), so - // `titleConfirmsState` below is unreachable for them. Timestamps can't arbitrate either: - // lastAgentStatusRichInvalidatedAtEpochMs moves only on a status-CLASS change while - // lastOscTitleEpochMs moves on EVERY write, so Claude's one same-class repaint ~123ms - // after PermissionRequest pushed title evidence past a still-current hook and published - // `done` — retiring the card while the user was still being asked. - // Only under an `idle` title: idle is the ABSENCE of activity evidence, but a `working` - // title (agent resumed) or a null/shell/identity-only one (agent released the pane) - // contradicts the hook and must still retire the row and its stale question (#11761). - // Both names: Claude's PermissionRequest normalizes to `waiting`, not `blocked`. - if ( - (status.state === 'waiting' || status.state === 'blocked') && - pty.lastAgentStatus === 'idle' && - Date.now() - status.updatedAt <= AGENT_STATUS_STALE_AFTER_MS - ) { - return status - } - if ( - options.preserveQuestionUnderShellTitle && - status.interactivePrompt != null && - terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle) - ) { - return status - } - const richStatusCanOwnTitleInterval = - pty.lastAgentStatusRichInvalidatedAtEpochMs === null || - status.updatedAt > pty.lastAgentStatusRichInvalidatedAtEpochMs - const titleEvidenceAt = pty.lastOscTitleEpochMs - if (titleEvidenceAt === null) { - return richStatusCanOwnTitleInterval ? status : null - } - const buildTitleOnlyStatus = ( - state: AgentStatusEntry['state'], - updatedAt: number, - stateStartedAt: number - ): AgentStatusEntry => ({ - state, - prompt: '', - updatedAt, - stateStartedAt, - paneKey: status.paneKey, - stateHistory: [], - ...(status.agentType ? { agentType: status.agentType } : {}), - ...(status.terminalHandle ? { terminalHandle: status.terminalHandle } : {}), - ...(status.worktreeId ? { worktreeId: status.worktreeId } : {}), - ...(status.tabId ? { tabId: status.tabId } : {}), - ...(status.terminalTitle ? { terminalTitle: status.terminalTitle } : {}), - ...(status.providerSession ? { providerSession: status.providerSession } : {}) - }) - const titleConfirmsState = - (pty.lastAgentStatus === 'working' && status.state === 'working') || - (pty.lastAgentStatus === 'permission' && - (status.state === 'blocked' || status.state === 'waiting')) - if (!titleConfirmsState) { - if (richStatusCanOwnTitleInterval && status.updatedAt >= titleEvidenceAt) { - return status - } - if ( - pty.lastAgentStatus === null && - !terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle) - ) { - return status - } - const titleState = - pty.lastAgentStatus === 'working' - ? 'working' - : pty.lastAgentStatus === 'permission' - ? 'blocked' - : 'done' - return buildTitleOnlyStatus( - titleState, - titleEvidenceAt, - pty.lastAgentStatusStartedAtEpochMs ?? titleEvidenceAt - ) - } - const richStatusIsFresh = Date.now() - status.updatedAt <= AGENT_STATUS_STALE_AFTER_MS - const richStatusOwnsCurrentState = richStatusIsFresh && richStatusCanOwnTitleInterval - // Fresh explicit evidence from this title interval owns acknowledgement identity. - const stateStartedAt = richStatusOwnsCurrentState - ? status.stateStartedAt - : (pty.lastAgentStatusStartedAtEpochMs ?? status.stateStartedAt) - if (richStatusOwnsCurrentState) { - pty.lastAgentStatusStartedAtEpochMs = stateStartedAt - } - const updatedAt = Math.max(status.updatedAt, titleEvidenceAt) - if (!richStatusOwnsCurrentState) { - return buildTitleOnlyStatus(status.state, updatedAt, stateStartedAt) - } - if (updatedAt === status.updatedAt && stateStartedAt === status.stateStartedAt) { - return status - } - return { ...status, updatedAt, stateStartedAt } - } - - /** Mobile-friendly status entry for a PTY, aligning agentType and titles with the active owner. */ - private buildPtyMobileAgentStatus( - pty: RuntimePtyWorktreeRecord | null, - tab: RuntimeMobileSessionTerminalTab, - terminalHandle: string | null, - retained: RuntimeAgentRowSnapshot | null, - getHookRowsForPane: (paneKey: string) => AgentStatusIpcPayload[] - ): { agentStatus: AgentStatusEntry } | Record { - const paneKey = this.getMobileTerminalPaneKey(tab) - // Why: neither the OSC-retained row nor a title-derived status can carry a - // provider session — only the hook payload does, and headless serve has no - // renderer to publish `tab.agentStatus`. Without it mobile native chat has no - // transcript to address and sits on the empty state forever. - const hookRow = this.getHookAgentRowForPane(getHookRowsForPane(paneKey)) - // Why: the hook row is evidence in its own right. Returning early on a missing - // PTY status/retained row put this check ahead of the only headless carrier, so - // an agent that reported its session but never emitted a recognized title got no - // `agentStatus` at all — exactly the hook-only case the fallback exists for. - if (!pty?.lastAgentStatus && !retained && !hookRow.agentType && !hookRow.providerSession) { - return {} - } - const providerSession = hookRow.providerSession - ? { providerSession: hookRow.providerSession } - : {} - const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null - const trackerOnlyTitle = this.getUnpersistedTrackedTitleForPty( - pty?.ptyId ?? leaf?.ptyId ?? null - ) - const ptyTitle = pty - ? getLatestAgentCandidateTitle( - { title: pty.title, updatedAt: pty.titleUpdatedAt }, - { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } - ) - : leaf - ? getLatestAgentCandidateTitle( - { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, - { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } - ) - : null - const ptyTitleClassification = classifyAgentTitle(ptyTitle) - const nonAgentTitle = ptyTitle !== null && ptyTitleClassification !== 'agent' - if (nonAgentTitle) { - // Why: non-agent title = shell reclaimed the pane; suppress to clear stuck spinners (#1437), though a live hook signal survives. - const hasLiveHookSignal = - retained?.payload.interactivePrompt != null || - retained?.payload.toolName != null || - // Why: a pending question is never inherited across hook events (unlike - // `toolName`), so it proves the agent is parked on a selector right now. - hookRow.live?.payload.interactivePrompt != null || - // Why: headless serve has no renderer to retain an OSC row, so a fresh hook - // agentType is the only live signal a hook-only pane can offer — and an agent - // that reports over HTTP need never set a title this gate would recognize. - // Scoped to panes with no PTY status at all, so it cannot revive a spinner: - // this branch publishes `done`. It only keeps the transcript addressable. - (!pty?.lastAgentStatus && (hookRow.agentType != null || hookRow.providerSession != null)) - if (!hasLiveHookSignal) { - return {} - } - } - // Why: a retained OMP hook stays stable while wrapper foreground reads can report Pi. - const ownerRecord = resolvePaneAgentOwnerRecord({ - launchAgent: tab.launchAgent ?? pty?.launchAgent ?? null, - hookAgent: retained?.payload.agentType ?? hookRow.agentType - }) - const ownerAgent = ownerRecord?.agent ?? pty?.foregroundAgent ?? null - const ownerOptions = { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true } - const terminalTitle = normalizeCompatibleAgentTitleForOwner( - trackerOnlyTitle ?? (pty ? getLatestPtyTitle(pty) : null) ?? tab.title, - ownerAgent, - ownerOptions - ) - // Why: OSC 9999 hook payload carries real state/prompt/agent; without preferring it, hook-only transitions never surfaced (#7970). - const liveRow = retained ?? this.resolveHookLiveAgentRow(hookRow.live, pty, nonAgentTitle) - if (liveRow) { - const liveStatus = normalizeCompatibleAgentStatusEntryForOwner( - { - ...liveRow.payload, - paneKey, - updatedAt: liveRow.updatedAt, - stateStartedAt: liveRow.stateStartedAt, - stateHistory: [], - ...(terminalHandle ? { terminalHandle } : {}), - ...((pty?.worktreeId ?? liveRow.worktreeId) - ? { worktreeId: pty?.worktreeId ?? liveRow.worktreeId } - : {}), - tabId: tab.parentTabId, - terminalTitle, - ...providerSession - }, - ownerAgent, - ownerOptions - ) - // A live question outranks only the shell title that currently obscures it. - const renewedStatus = this.renewMobileAgentStatusFromPtyTitle(liveStatus, pty, { - preserveQuestionUnderShellTitle: true - }) - if (renewedStatus) { - return { agentStatus: renewedStatus } - } - } - // Last resort: the pane's hook evidence is identity only (resume rows, stale - // rows, or a row the freshness gate rejected). `done` is the honest - // projection — and it is what retires the card once the agent exits. - // Why not lastOutputAt: this state is title-derived, so it must be dated by - // its evidence. Stamping it with the byte stream made the frame advance on - // every output byte, so a paired client's live status could never outrank it. - const evidenceAt = pty?.lastOscTitleEpochMs ?? hookRow.providerSessionReceivedAt ?? Date.now() - const agentType = ownerAgent ?? undefined - return { - agentStatus: { - state: - pty?.lastAgentStatus === 'working' - ? 'working' - : pty?.lastAgentStatus === 'permission' - ? 'blocked' - : 'done', - prompt: '', - updatedAt: evidenceAt, - stateStartedAt: pty?.lastAgentStatusStartedAtEpochMs ?? evidenceAt, - paneKey, - ...(terminalHandle ? { terminalHandle } : {}), - ...(agentType ? { agentType } : {}), - ...(pty?.worktreeId ? { worktreeId: pty.worktreeId } : {}), - tabId: tab.parentTabId, - terminalTitle, - stateHistory: [], - ...providerSession - } - } - } - - /** Live hook status to publish for a pane with no retained OSC row, or null when the - * pane's hook evidence only proves identity. - * - * Why the freshness rule: `pty.lastAgentStatus` is title-derived and refreshed live, - * so an unconditional hook precedence would let a 29-minute-old `done` erase a pane - * that is visibly working. A pending `interactivePrompt` outranks title evidence at - * any age — the agent is parked on a selector until it answers — and it is also the - * only signal allowed to survive the #1437 non-agent-title suppression. */ - private resolveHookLiveAgentRow( - live: HookLiveAgentRow | null, - pty: RuntimePtyWorktreeRecord | null, - nonAgentTitle: boolean - ): HookLiveAgentRow | null { - if (!live) { - return null - } - if (live.payload.interactivePrompt != null) { - return live - } - // Why only this stamp: it is the sole wall-clock date on the pane's live title, - // so it is the only one comparable to a hook `receivedAt`. The sibling - // `titleUpdatedAt`/`lastOscTitleAt`/`paneTitleUpdatedAt` fields are observation - // sequence numbers, and comparing them here can only ever misfire. - return !nonAgentTitle && live.updatedAt >= (pty?.lastOscTitleEpochMs ?? 0) ? live : null - } - - /** Hook-reported identity for this pane, newest wins per field. - * - * `providerSession` is deliberately unbounded: it is resume identity, not live - * state, it stays correct until the pane relaunches (which overwrites the row - * under the same paneKey), and it is only ever read once an agent is already - * established. Bounding it would blank mobile native chat on an idle session. - * - * `agentType` is bounded by the same staleness window the retained OSC path uses, - * because it is the signal that claims an agent owns the pane at all. A user who - * exits the agent leaves `pty.lastAgentStatus` behind forever, so an unbounded - * read would keep offering native chat for what is now a plain shell. - * - * `live` is the newest fresh row's status fields — bounded like `agentType` because - * it asserts liveness, and unlike `agentType` it excludes `providerSessionOnly` rows - * with no Pi exception: those carry resume identity, and their status-shaped fields - * are documented transport placeholders that must never reach a client. It also drops - * `restoredUnconfirmed` rows, which `isFreshNonDoneAgentStatus` already treats as - * never-fresh; they still count as `agentType` identity evidence. */ - private getHookAgentRowForPane(rows: readonly AgentStatusIpcPayload[]): { - providerSession: AgentProviderSessionMetadata | null - providerSessionAgentType: string | null - providerSessionReceivedAt: number | null - agentType: string | null - agentIsLive: boolean - live: HookLiveAgentRow | null - } { - let session: AgentStatusIpcPayload | null = null - let agent: AgentStatusIpcPayload | null = null - let live: AgentStatusIpcPayload | null = null - const agentTypeFreshAfter = Date.now() - AGENT_STATUS_STALE_AFTER_MS - // Why pane key only: the sibling `terminalHandle` arm this used to carry never - // matched. `toAgentStatusIpcPayload` does not emit the field on the hook path - // (only the renderer's own store stamps it, and headless serve has no renderer), - // and because it is optional TypeScript could not flag the dead comparison. - for (const entry of rows) { - if (entry.providerSession && (!session || entry.receivedAt > session.receivedAt)) { - session = entry - } - if ( - entry.agentType && - (entry.providerSessionOnly !== true || - (entry.agentType === 'pi' && entry.providerSession != null)) && - entry.receivedAt >= agentTypeFreshAfter && - (!agent || entry.receivedAt > agent.receivedAt) - ) { - agent = entry - } - if ( - entry.providerSessionOnly !== true && - // Why: a row hydrated from last-status.json describes a turn that may have ended - // while no receiver was up (#12346), so its `receivedAt` cannot prove liveness — - // publishing it would resurrect a zombie question card across a restart. - entry.restoredUnconfirmed !== true && - entry.receivedAt >= agentTypeFreshAfter && - (!live || entry.receivedAt > live.receivedAt) - ) { - live = entry - } - } - return { - providerSession: session?.providerSession ?? null, - providerSessionAgentType: session?.agentType ?? null, - providerSessionReceivedAt: session?.receivedAt ?? null, - agentType: agent?.agentType ?? null, - // A fresh completed row still projects its terminal `done` status, but it is - // past-tense identity evidence and must not outrank process or launch facts. - agentIsLive: agent != null && agent.state !== 'done', - live: live - ? { - payload: pickParsedAgentStatusPayload(live), - updatedAt: live.receivedAt, - stateStartedAt: live.stateStartedAt ?? live.receivedAt, - ...(live.worktreeId ? { worktreeId: live.worktreeId } : {}) - } - : null - } - } - - /** Retained OSC 9999 hook row for this mobile tab if still fresh; looked up by pane identity, then PTY ownership (legacy `pane:N` ids can drift). */ - private getFreshRetainedAgentStatusForMobileTab( - paneKey: string, - pty: RuntimePtyWorktreeRecord | null, - tab: RuntimeMobileSessionTerminalTab - ): RuntimeAgentRowSnapshot | null { - let retained = this.latestAgentStatusByPaneKey.get(paneKey) ?? null - if (!retained) { - const ptyId = pty?.ptyId ?? tab.ptyId ?? null - if (ptyId) { - for (const snapshot of this.latestAgentStatusByPaneKey.values()) { - if (snapshot.ptyId !== ptyId) { - continue - } - if (!retained || snapshot.updatedAt > retained.updatedAt) { - retained = snapshot - } - } - } - } - if (!retained || Date.now() - retained.updatedAt > AGENT_STATUS_STALE_AFTER_MS) { - return null - } - return retained - } - - private findPtyForMobileTerminalTab( - worktreeId: string, - tab: RuntimeMobileSessionTerminalTab, - options: { allowWorktreeOnlyMatch?: boolean } = {} - ): RuntimePtyWorktreeRecord | null { - const snapshotPtyId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? null - const paneKey = this.getMobileTerminalPaneKey(tab) - if (snapshotPtyId) { - const pty = this.ptysById.get(snapshotPtyId) - if (!pty) { - return null - } - // Why: persisted PTY ids can collide with unrelated provider ids after restart; only a matching spawn-time pane identity is safe to expose. - if (this.mobileTerminalTabMatchesPty(worktreeId, tab, pty, paneKey)) { - return pty - } - if ( - options.allowWorktreeOnlyMatch === true && - pty.worktreeId === worktreeId && - pty.tabId === null && - pty.paneKey === null - ) { - return pty - } - return null - } - const paneKeys = new Set([`${tab.parentTabId}:${tab.leafId}`]) - if (tab.leafId === `pane:${FIRST_PANE_ID}`) { - paneKeys.add(`${tab.parentTabId}:${FIRST_PANE_ID}`) - } - for (const pty of this.ptysById.values()) { - if (pty.tabId === tab.parentTabId && pty.paneKey && paneKeys.has(pty.paneKey)) { - return pty - } - } - return null - } - - private getMobileTerminalPaneKey(tab: RuntimeMobileSessionTerminalTab): string { - if (isTerminalLeafId(tab.leafId)) { - return makePaneKey(tab.parentTabId, tab.leafId) - } - const legacyPaneId = /^pane:(\d+)$/.exec(tab.leafId)?.[1] ?? null - return `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}` - } - - private mobileTerminalTabMatchesPty( - worktreeId: string, - tab: RuntimeMobileSessionTerminalTab, - pty: RuntimePtyWorktreeRecord, - paneKey = this.getMobileTerminalPaneKey(tab) - ): boolean { - return pty.worktreeId === worktreeId && pty.tabId === tab.parentTabId && pty.paneKey === paneKey - } - - // Why: group address resolution (Section 4.5) queries per-handle status and must not throw on stale handles; return null on any error. - getAgentStatusForHandle(handle: string): string | null { - try { - const ptyId = this.getTerminalAgentStatusPtyId(handle) - return this.getTerminalAgentStatusSnapshot(handle, ptyId).titleStatus - } catch { - return null - } - } - - getAgentStatusOrchestrationContextForPaneKey( - paneKey: string - ): AgentStatusOrchestrationContext | undefined { - const handle = this.getTerminalHandleForPaneKey(paneKey) - if (!handle) { - return undefined - } - return this.getAgentStatusOrchestrationContextForHandle(handle) - } - - getAgentStatusTerminalHandleForPaneKey(paneKey: string): string | undefined { - return this.getTerminalHandleForPaneKey(paneKey) ?? undefined - } - - getAgentStatusLaunchConfigForPaneKey( - paneKey: string, - args?: { launchToken?: string } - ): SleepingAgentLaunchConfig | undefined { - const pty = this.getPtyRecordForPaneKey(paneKey) - if (!pty?.launchConfig) { - return undefined - } - if (pty.launchToken === null || pty.launchToken !== args?.launchToken) { - return undefined - } - return copySleepingAgentLaunchConfig(pty.launchConfig) - } - - private buildAgentOrchestrationByPaneKey(): - | Record - | undefined { - const db = this.getOrchestrationDbIfAvailable() - if (!db) { - return undefined - } - // Why: this runs on every 16ms graph publish (title/status churn). With no - // dispatch rows — the overwhelming majority who never orchestrate — the - // per-terminal query fan-out below can only ever yield an empty result, so - // skip it wholesale via the DB's cached emptiness probe. Optional call so - // partial test-injected DBs without the probe fall through to the scan. - if (db.hasAnyDispatchContexts?.() === false) { - return undefined - } - const contexts: Record = {} - const queriedHandles = new Set() - for (const leaf of this.leaves.values()) { - if (!leaf.ptyId) { - continue - } - const handle = this.issueHandle(leaf) - queriedHandles.add(handle) - const context = this.getAgentStatusOrchestrationContextForHandle(handle, db) - if (context) { - contexts[this.makeRuntimePaneKey(leaf)] = context - } - } - for (const pty of this.ptysById.values()) { - if (!pty.paneKey || contexts[pty.paneKey]) { - continue - } - const handle = this.issuePtyHandle(pty) - if (queriedHandles.has(handle)) { - continue - } - queriedHandles.add(handle) - const context = this.getAgentStatusOrchestrationContextForHandle(handle, db) - if (context) { - contexts[pty.paneKey] = context - } - } - return Object.keys(contexts).length > 0 ? contexts : undefined - } - - private getAgentStatusOrchestrationContextForHandle( - handle: string, - db = this.getOrchestrationDbIfAvailable() - ): AgentStatusOrchestrationContext | undefined { - // Why: active dispatch is authoritative for reused terminals; settled context stale-groups later work once its row is gone. - const dispatch = - db?.getActiveDispatchForTerminal?.(handle) ?? - this.getRecentSettledDispatchForTerminal(handle, db) - if (!dispatch) { - return undefined - } - const task = db?.getTask?.(dispatch.task_id, dispatch.run_id) - const display = - typeof task?.spec === 'string' - ? buildOrchestrationTaskDisplayMetadata({ - spec: task.spec, - taskTitle: task.task_title, - displayName: task.display_name - }) - : { taskTitle: '', displayName: '' } - const owningRun = - task?.run_id && task.run_id === dispatch.run_id ? db?.getRun?.(dispatch.run_id) : undefined - const runCoordinatorHandle = owningRun?.coordinator_handle ?? undefined - const legacyActiveRun = - owningRun?.legacy === 1 && (dispatch.status === 'pending' || dispatch.status === 'dispatched') - ? db?.getActiveCoordinatorRun?.() - : undefined - // Why: legacy coordinator runs have no durable task ownership, so fail closed across worktrees. - const handleWorktreeId = legacyActiveRun ? this.getWorktreeIdForTerminalHandle(handle) : null - const legacyCoordinatorWorktreeId = legacyActiveRun - ? this.getWorktreeIdForTerminalHandle(legacyActiveRun.coordinator_handle) - : null - const scopedLegacyActiveRun = - legacyActiveRun && - handleWorktreeId && - legacyCoordinatorWorktreeId && - worktreeIdsEqual(legacyCoordinatorWorktreeId, handleWorktreeId) - ? legacyActiveRun - : undefined - const coordinatorHandle = runCoordinatorHandle ?? scopedLegacyActiveRun?.coordinator_handle - const orchestrationRunId = owningRun?.legacy === 0 ? owningRun.id : scopedLegacyActiveRun?.id - const creatorPaneKey = task?.created_by_pane_key - const creatorPaneHandle = creatorPaneKey - ? this.getTerminalHandleForPaneKey(creatorPaneKey) - : null - const creatorAuthority = creatorPaneHandle - ? this.getOrchestrationDispatchAuthority(creatorPaneHandle) - : null - const storedCreatorPane = creatorPaneKey ? parsePaneKey(creatorPaneKey) : null - const currentCreatorPane = creatorAuthority?.paneKey - ? parsePaneKey(creatorAuthority.paneKey) - : null - const sameCreatorPane = Boolean( - creatorPaneKey && - creatorAuthority?.paneKey && - (creatorPaneKey === creatorAuthority.paneKey || - (storedCreatorPane && - currentCreatorPane && - storedCreatorPane.leafId === currentCreatorPane.leafId)) - ) - const paneRun = creatorPaneKey ? db?.getCurrentRunForPane?.(creatorPaneKey) : undefined - const sameRunCreatorDispatch = Boolean( - task?.creator_dispatch_id && - task.creator_dispatch_run_id === owningRun?.id && - task.creator_dispatch_pane_key && - task.creator_dispatch_process_incarnation === task.created_by_process_incarnation && - parsePaneKey(task.creator_dispatch_pane_key)?.leafId === storedCreatorPane?.leafId - ) - const currentCreatorHandle = - owningRun?.legacy === 0 && - task?.created_by_run_generation === owningRun.consumer_generation && - task.created_by_process_incarnation === creatorAuthority?.processIncarnation && - sameCreatorPane && - (paneRun - ? paneRun.id === owningRun.id && - paneRun.consumer_generation === task.created_by_run_generation - : sameRunCreatorDispatch) - ? (creatorPaneHandle ?? undefined) - : undefined - const parentTerminalHandle = - currentCreatorHandle ?? - (coordinatorHandle && coordinatorHandle !== handle ? coordinatorHandle : undefined) - const parentPaneKey = parentTerminalHandle - ? this.getPaneKeyForTerminalHandle(parentTerminalHandle) - : undefined - - return { - taskId: dispatch.task_id, - dispatchId: dispatch.id, - dispatchStatus: dispatch.status, - ...(display.taskTitle ? { taskTitle: display.taskTitle } : {}), - ...(display.displayName ? { displayName: display.displayName } : {}), - ...(parentTerminalHandle ? { parentTerminalHandle } : {}), - ...(parentPaneKey ? { parentPaneKey } : {}), - ...(coordinatorHandle ? { coordinatorHandle } : {}), - ...(orchestrationRunId ? { orchestrationRunId } : {}) - } - } - - private getRecentSettledDispatchForTerminal( - handle: string, - db = this.getOrchestrationDbIfAvailable() - ): ReturnType { - const dispatch = db?.getLatestDispatchForTerminal?.(handle) - if ( - !dispatch?.completed_at || - dispatch.status === 'pending' || - dispatch.status === 'dispatched' - ) { - return undefined - } - const completedAtMs = Date.parse( - dispatch.completed_at.includes('T') - ? dispatch.completed_at - : `${dispatch.completed_at.replace(' ', 'T')}Z` - ) - if (!Number.isFinite(completedAtMs)) { - return undefined - } - return Date.now() - completedAtMs <= AGENT_STATUS_STALE_AFTER_MS ? dispatch : undefined - } - - // Why: public because automation completion watching runs in main but the - // pane→handle mapping is runtime-owned state. - getTerminalHandleForPaneKey(paneKey: string): string | null { - const parsed = parsePaneKey(paneKey) - const leaf = parsed ? this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) : undefined - if (leaf?.ptyId && leaf.connected) { - return this.issueHandle(leaf) - } - const panePty = this.getPtyRecordForPaneKey(paneKey) - if (panePty?.connected) { - return this.issuePtyHandle(panePty) - } - if (leaf?.ptyId) { - return this.issueHandle(leaf) - } - return panePty ? this.issuePtyHandle(panePty) : null - } - - private getPtyRecordForPaneKey(paneKey: string): RuntimePtyWorktreeRecord | null { - const parsed = parsePaneKey(paneKey) - let leafPty: RuntimePtyWorktreeRecord | null = null - if (parsed) { - const leaf = this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) - const pty = leaf?.ptyId ? this.ptysById.get(leaf.ptyId) : undefined - if (pty?.connected) { - return pty - } - leafPty = pty ?? null - for (const candidate of this.leaves.values()) { - if (candidate.leafId !== parsed.leafId || !candidate.ptyId) { - continue - } - const remintedPty = this.ptysById.get(candidate.ptyId) - if (remintedPty?.connected) { - return remintedPty - } - leafPty ??= remintedPty ?? null - } - } - let newestMatch: RuntimePtyWorktreeRecord | null = null - for (const pty of this.ptysById.values()) { - const ptyPane = parsePaneKey(pty.paneKey ?? '') - if (pty.paneKey === paneKey || (parsed && ptyPane && parsed.leafId === ptyPane.leafId)) { - if (pty.connected) { - return pty - } - newestMatch = pty - } - } - return leafPty ?? newestMatch - } - - private getPaneKeyForTerminalHandle(handle: string): string | null { - const livePty = this.getLivePtyForHandle(handle) - if (livePty?.pty.paneKey) { - return livePty.pty.paneKey - } - const record = this.handles.get(handle) - if (!record || record.runtimeId !== this.runtimeId) { - return null - } - if (!isTerminalLeafId(record.leafId)) { - return null - } - return makePaneKey(record.tabId, record.leafId) - } - - private getWorktreeIdForTerminalHandle(handle: string): string | null { - const livePty = this.getLivePtyForHandle(handle) - if (livePty?.pty.worktreeId) { - return livePty.pty.worktreeId - } - const record = this.handles.get(handle) - if (!record || record.runtimeId !== this.runtimeId) { - return null - } - return record.worktreeId - } - - private setPtyManagementTitleFromObservedTitle( - pty: RuntimePtyWorktreeRecord, - title: string | null | undefined, - observedAt: number - ): void { - const trimmed = title?.trim() - if (!trimmed) { - return - } - if (isClaudeManagementTitle(trimmed)) { - pty.managementTitle = trimmed - pty.managementTitleAt = observedAt - return - } - if ( - detectAgentStatusFromTitle(trimmed) !== null && - observedAt >= (pty.managementTitleAt ?? -1) - ) { - pty.managementTitle = null - pty.managementTitleAt = null - } - } - - private nextTitleObservationSequence(): number { - this.titleObservationSequence += 1 - return this.titleObservationSequence - } - - // Why: title is the tightest agent-presence signal, but a Claude management title is negative evidence for task activity. - async isTerminalRunningAgent( - handle: string, - options: { retryForegroundWrappers?: boolean } = {} - ): Promise { - try { - const pty = this.getLivePtyForHandle(handle) - if (pty) { - const leaf = this.getPrimaryLeafForPty(pty.pty.ptyId) - return await this.isPtyRunningAgent(pty.pty, leaf, options) - } - const { leaf } = this.getLiveLeafForHandle(handle) - const trackedPty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : null - // Why: check the leaf pane title and the tab title, which already carries OSC-enriched agent indicators (e.g. ✳ prefix). - const paneTitle = getLatestLeafTitle(leaf, null) - const paneTitleClassification = classifyAgentTitle(paneTitle) - if ( - trackedPty - ? ptyTitleProvesAgentPresence(trackedPty, paneTitle, paneTitleClassification) - : agentTitleProvesAgentPresence(paneTitle, paneTitleClassification) - ) { - return true - } - const tabTitle = this.tabs.get(leaf.tabId)?.title?.trim() || null - const tabTitleClassification = paneTitle === null ? classifyAgentTitle(tabTitle) : 'neutral' - if ( - trackedPty - ? ptyTitleProvesAgentPresence(trackedPty, tabTitle, tabTitleClassification) - : agentTitleProvesAgentPresence(tabTitle, tabTitleClassification) - ) { - return true - } - const openCodeMarkerTitle = paneTitle ?? tabTitle - const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) - if (!isOpenCodeNativeTitle(openCodeMarkerTitle) && isKnownReadyPromptPreview(waitText)) { - return true - } - const hasCurrentTitleEvidence = paneTitle !== null || tabTitle !== null - if (leaf.lastAgentStatus !== null && !hasCurrentTitleEvidence) { - return true - } - if (!leaf.ptyId || !this.ptyController) { - return false - } - const fg = await this.ptyController.getForegroundProcess(leaf.ptyId) - // Why: a bare `Cursor Agent` title is identity, not liveness — it reads the same - // whether cursor-agent is parked or long exited with the shell back. A null - // foreground is untracked, not alive, so no-evidence must stay a refusal. A live - // pane wrongly refused here means the read failed; fix that, not this. - if (!fg) { - return false - } - // Why: Claude's management UI runs under the Claude process but isn't a task-capable session; suppress only that process. - const shouldSuppressClaudeForeground = - paneTitleClassification === 'management' || tabTitleClassification === 'management' - if (shouldSuppressClaudeForeground && isExpectedAgentProcess(fg, 'claude')) { - return false - } - // Why: review-note delivery auto-submits with Enter, so only known agent processes are safe (not arbitrary focused TUIs). - return await this.isRecognizedForegroundAgentProcess(leaf.ptyId, fg, { - suppressClaude: shouldSuppressClaudeForeground, - retryWrappers: options.retryForegroundWrappers !== false - }) - } catch { - return false - } - } - - async isTerminalRunningSettledPromptAgent(handle: string): Promise { - try { - const livePty = this.getLivePtyForHandle(handle) - const leaf = livePty ? null : this.getLiveLeafForHandle(handle).leaf - const ptyId = livePty?.pty.ptyId ?? leaf?.ptyId ?? null - const trackedPty = livePty?.pty ?? (ptyId ? this.ptysById.get(ptyId) : null) - if (!ptyId || !trackedPty || !this.ptyController) { - return false - } - const recognized = recognizeAgentProcess(await this.ptyController.getForegroundProcess(ptyId)) - const recognizedAgent = recognized?.agent - if (!isTerminalSendSettlementAgent(recognizedAgent)) { - return false - } - if (!(await this.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }))) { - return false - } - trackedPty.foregroundAgent = recognizedAgent - return true - } catch { - return false - } - } - - private async isPtyRunningAgent( - pty: RuntimePtyWorktreeRecord, - leaf: RuntimeLeafRecord | null = null, - options: { retryForegroundWrappers?: boolean } = {} - ): Promise { - const leafTitle = leaf - ? getLatestAgentCandidateTitle( - { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, - { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } - ) - : null - const leafTitleClassification = classifyAgentTitle(leafTitle) - if (ptyTitleProvesAgentPresence(pty, leafTitle, leafTitleClassification)) { - return true - } - const ptyTitle = getLatestAgentCandidateTitle( - { title: pty.title, updatedAt: pty.titleUpdatedAt }, - { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } - ) - const ptyTitleClassification = classifyAgentTitle(ptyTitle) - if (leafTitle === null && ptyTitleProvesAgentPresence(pty, ptyTitle, ptyTitleClassification)) { - return true - } - const managementTitleClassification = classifyLatestAgentTitle({ - title: pty.managementTitle, - updatedAt: pty.managementTitleAt - }) - const openCodeMarkerTitle = leafTitle ?? ptyTitle - if (isOpenCodeNativeTitle(openCodeMarkerTitle) && pty.launchAgent === 'opencode') { - return true - } - const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) - if (!isOpenCodeNativeTitle(openCodeMarkerTitle) && isKnownReadyPromptPreview(waitText)) { - return true - } - // Why: stale status is a fallback only when no current title evidence exists; neutral titles (shells) clear it. - if ( - pty.lastAgentStatus !== null && - leafTitle === null && - ptyTitle === null && - managementTitleClassification !== 'management' - ) { - return true - } - if (!this.ptyController) { - return false - } - const fg = await this.ptyController.getForegroundProcess(pty.ptyId) - // Why: mirrors the leaf path — an unreadable foreground is indistinguishable from an - // exited one, so a bare Cursor identity title never substitutes for corroboration. - if (!fg) { - return false - } - const shouldSuppressClaudeForeground = - leafTitle !== null - ? leafTitleClassification === 'management' - : managementTitleClassification === 'management' - if (shouldSuppressClaudeForeground && isExpectedAgentProcess(fg, 'claude')) { - return false - } - // Why: review-note delivery auto-submits with Enter, so only known agent processes are safe (not arbitrary focused TUIs). - return await this.isRecognizedForegroundAgentProcess(pty.ptyId, fg, { - suppressClaude: shouldSuppressClaudeForeground, - retryWrappers: options.retryForegroundWrappers !== false - }) - } - - private async isRecognizedForegroundAgentProcess( - ptyId: string, - foregroundProcess: string, - options: { suppressClaude?: boolean; retryWrappers?: boolean } = {} - ): Promise { - const initialRecognition = recognizeAgentProcess(foregroundProcess) - if (initialRecognition !== null) { - return !( - options.suppressClaude === true && - isExpectedAgentProcess(initialRecognition.processName, 'claude') - ) - } - if ( - options.retryWrappers === false || - !this.isAgentWrapperForegroundProcess(foregroundProcess) || - !this.ptyController - ) { - return false - } - const startedAt = Date.now() - while (Date.now() - startedAt < FOREGROUND_AGENT_WRAPPER_RETRY_TIMEOUT_MS) { - await new Promise((resolve) => - setTimeout(resolve, FOREGROUND_AGENT_WRAPPER_RETRY_INTERVAL_MS) - ) - const refreshedProcess = await this.ptyController.getForegroundProcess(ptyId) - const refreshedRecognition = recognizeAgentProcess(refreshedProcess) - if (refreshedRecognition !== null) { - return !( - options.suppressClaude === true && - isExpectedAgentProcess(refreshedRecognition.processName, 'claude') - ) - } - if (!refreshedProcess || !this.isAgentWrapperForegroundProcess(refreshedProcess)) { - return false - } - } - return false - } - - private isAgentWrapperForegroundProcess(processName: string): boolean { - // Why: daemon/SSH PTYs can report the interpreter before the async cmdline cache resolves; retry only known wrappers. - return isAgentForegroundWrapperProcess(processName) - } - - private getPrimaryLeafForPty(ptyId: string): RuntimeLeafRecord | null { - return this.getLeavesForPty(ptyId)[0] ?? null - } - - deliverPendingMessagesForHandle(handle: string, reservedTypes?: ReadonlySet): void { - this.orchestrationMailboxNotifications.deliverForHandle(handle, reservedTypes) - } - - /** Admission snapshot taken when a mailbox pointer's text lands, asserted again - * before its Enter. The two writes straddle a 500ms pause, so a structured - * session that re-leases the pty in between must not receive the submit. */ - private readonly orchestrationPointerAdmissionByPtyId = new Map< - string, - AgentSessionPtyWriteAdmittance - >() - - private writeOrchestrationPointerPty(ptyId: string, data: string): boolean | Promise { - try { - if (data === '\r') { - const admitted = this.orchestrationPointerAdmissionByPtyId.get(ptyId) - this.orchestrationPointerAdmissionByPtyId.delete(ptyId) - if (admitted) { - // Throws when the lease moved under the in-flight pointer, withholding the submit. - agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) - } - } else { - const admission = agentSessionPtyWriteGate.admit(ptyId) - if (!admission.admitted) { - this.orchestrationPointerAdmissionByPtyId.delete(ptyId) - // Preserve the controller's own refusal reporting for internal deliveries. - return this.ptyController?.write(ptyId, data) ?? false - } - this.orchestrationPointerAdmissionByPtyId.set(ptyId, { - sessionId: admission.sessionId, - runtimeFence: admission.runtimeFence - }) - } - if (this.ptyController?.writeWithSettlement) { - return this.ptyController.writeWithSettlement(ptyId, data).catch(() => false) - } - return this.ptyController?.write(ptyId, data) ?? false - } catch { - return false - } - } - - private retireOrchestrationMailboxDeliveryForPty(ptyId: string): void { - this.orchestrationMailboxNotifications.retirePty(ptyId) - for (const leaf of this.getLeavesForPty(ptyId)) { - const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) - if (handle) { - this.mailPointerRepointScheduler.schedule(handle) - } - const run = this._orchestrationDb?.getCurrentRunForPane?.(`${leaf.tabId}:${leaf.leafId}`) - if (run) { - this.mailPointerRepointScheduler.schedule(`run:${run.id}`) - } - } - } - - private scheduleRestoredMessageRepoints(): void { - let handles: string[] - try { - handles = this._orchestrationDb?.getUndeliveredUnreadMailboxHandles?.() ?? [] - } catch (error) { - console.warn('[orchestration] failed to scan restored mailboxes', error) - return - } - for (const handle of handles) { - try { - if (handle.startsWith('dispatch:')) { - continue - } - if (handle.startsWith('run:')) { - this.mailPointerRepointScheduler.schedule(handle) - continue - } - const routed = this.orchestrationMailboxOwner.routeDetachedDirectMessages(handle) - for (const mailbox of routed.mailboxes) { - this.mailPointerRepointScheduler.schedule(mailbox.mailboxHandle) - } - if (!routed.hasMore) { - this.mailPointerRepointScheduler.schedule(handle) - } - } catch (error) { - console.warn(`[orchestration] failed to restore mailbox ${handle}`, error) - this.mailPointerRepointScheduler.schedule(handle) - } - } - } - - private repointPendingMessagesForHandle(handle: string): void { - try { - this.deliverPendingMessagesForHandle(handle) - } catch { - // The unref'd repair can outlive a test/runtime-owned database during shutdown. - } - } - - private deliverPendingMessagesForLeaf(leaf: RuntimeLeafRecord): void { - this.orchestrationMailboxNotifications.deliverForLeaf(leaf) - } - - // Why: wake blocking orchestration.check --wait calls on this handle so they return the new message immediately instead of polling. - notifyMessageArrived(handle: string, messageType?: string): void { - if (!handle.startsWith('dispatch:')) { - this.mailPointerRepointScheduler.schedule(handle) - } - this.orchestrationMailboxNotifications.notifyMessageArrived(handle, messageType) - } - - waitForMessage( - handle: string, - options?: { - typeFilter?: string[] - timeoutMs?: number - signal?: AbortSignal - exclusive?: boolean - } - ): Promise { - return new Promise((resolve) => { - const currentWaiters = this.messageWaitersByHandle.get(handle) - if (options?.exclusive && currentWaiters && currentWaiters.size > 0) { - resolve('waiter_exists') - return - } - const timeoutMs = options?.timeoutMs ?? ORCHESTRATION_MESSAGE_WAIT_DEFAULT_TIMEOUT_MS - - const waiter: MessageWaiter = { - handle, - typeFilter: options?.typeFilter, - resolve, - timeout: null, - abortCleanup: null - } - - // Why: on caller abort (RPC socket closed — design doc §3.1), resolve now to release the long-poll slot instead of waiting out timeoutMs. - const signal = options?.signal - const onAbort = (): void => { - this.removeMessageWaiter(waiter) - resolve('cancelled') - } - if (signal) { - if (signal.aborted) { - resolve('cancelled') - return - } - waiter.abortCleanup = () => signal.removeEventListener('abort', onAbort) - signal.addEventListener('abort', onAbort, { once: true }) - } - - waiter.timeout = setTimeout(() => { - this.removeMessageWaiter(waiter) - resolve('timed_out') - }, timeoutMs) - - let waiters = this.messageWaitersByHandle.get(handle) - if (!waiters) { - waiters = new Set() - this.messageWaitersByHandle.set(handle, waiters) - } - waiters.add(waiter) - }) - } - - cancelMessageWaiters(handle: string): void { - const waiters = this.messageWaitersByHandle.get(handle) - if (!waiters) { - return - } - for (const waiter of [...waiters]) { - this.resolveMessageWaiter(waiter, 'cancelled') - } - } - - private resolveMessageWaiter(waiter: MessageWaiter, result: MessageWaitResult): void { - this.removeMessageWaiter(waiter) - waiter.resolve(result) - } - - private removeMessageWaiter(waiter: MessageWaiter): void { - if (waiter.timeout) { - clearTimeout(waiter.timeout) - waiter.timeout = null - } - if (waiter.abortCleanup) { - waiter.abortCleanup() - waiter.abortCleanup = null - } - const waiters = this.messageWaitersByHandle.get(waiter.handle) - if (waiters) { - waiters.delete(waiter) - if (waiters.size === 0) { - this.messageWaitersByHandle.delete(waiter.handle) - } - } - } - - private buildPtyTerminalSummary( - pty: RuntimePtyWorktreeRecord, - worktreesById: Map - ): RuntimeTerminalSummary { - const worktree = worktreesById.get(pty.worktreeId) - const title = getLatestPtyTitle(pty) - - const pane = parsePaneKey(pty.paneKey ?? '') - const orphaned = !pty.tabId || !pane || pane.tabId !== pty.tabId - return { - handle: this.issuePtyHandle(pty), - ptyId: pty.ptyId, - incarnationId: pty.incarnationId, - orphaned, - worktreeId: pty.worktreeId, - worktreePath: worktree?.path ?? '', - branch: worktree?.branch ?? '', - tabId: orphaned ? `pty:${pty.ptyId}` : pty.tabId!, - leafId: orphaned ? `pty:${pty.ptyId}` : pane.leafId, - title, - connected: pty.connected, - writable: pty.connected, - lastOutputAt: pty.lastOutputAt, - preview: pty.preview, - ...(pty.lastExitCause ? { exitCause: pty.lastExitCause } : {}), - ...this.terminalExecutionHostField(pty.ptyId, pty.worktreeId), - ...this.resolvePaneAgentIdentityField( - pty.launchAgent, - pty.foregroundAgent, - title, - pty.paneKey ?? null - ) - } - } - - private getLiveLeafForHandle(handle: string): { - record: TerminalHandleRecord - leaf: RuntimeLeafRecord - } { - this.assertGraphReady() - const record = this.handles.get(handle) - if (!record || record.runtimeId !== this.runtimeId) { - throw new Error('terminal_handle_stale') - } - if (record.rendererGraphEpoch !== this.rendererGraphEpoch) { - throw new Error('terminal_handle_stale') - } - - const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId)) - if (!leaf || leaf.ptyId !== record.ptyId || leaf.ptyGeneration !== record.ptyGeneration) { - throw new Error('terminal_handle_stale') - } - return { record, leaf } - } - - private getLivePtyForHandle(handle: string): { - record: TerminalHandleRecord - pty: RuntimePtyWorktreeRecord - } | null { - let record = this.handles.get(handle) - if (!record) { - const ptyId = [...this.handleByPtyId.entries()].find( - ([, mappedHandle]) => mappedHandle === handle - )?.[0] - const pty = ptyId ? this.ptysById.get(ptyId) : null - if (pty) { - // Why: graph reload clears renderer handle records, but runtime-owned PTY handles remain the caller's control identity. - this.issuePtyHandle(pty) - record = this.handles.get(handle) - } - } - if (!record || record.runtimeId !== this.runtimeId || !record.tabId.startsWith('pty:')) { - return null - } - if (!record.ptyId) { - return null - } - const pty = this.ptysById.get(record.ptyId) - if (!pty || pty.ptyId !== record.ptyId) { - return null - } - // Why: renderer adoption can race with CLI reads; keep ptyId → handle populated so summaries don't mint a second handle for the same terminal. - this.handleByPtyId.set(record.ptyId, handle) - return { record, pty } - } - - private assertLiveTerminalHandleTargetsPty(handle: string, expectedPtyId: string): void { - const runtimePty = this.getLivePtyForHandle(handle) - if (runtimePty) { - if (runtimePty.pty.ptyId !== expectedPtyId) { - throw new Error('terminal_handle_stale') - } - return - } - const { leaf } = this.getLiveLeafForHandle(handle) - if (leaf.ptyId !== expectedPtyId) { - throw new Error('terminal_handle_stale') - } - } - - private readPtyTerminal( - handle: string, - pty: RuntimePtyWorktreeRecord, - opts: { cursor?: number; limit?: number } = {} - ): RuntimeTerminalRead { - return readTerminalTail({ - handle, - status: pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown', - previewLines: pty.tailBuffer, - completedLines: pty.tailTranscriptBuffer, - partialLine: pty.tailPartialLine, - completedLineCount: pty.tailLinesTotal, - bufferTruncated: pty.tailTruncated, - cursor: opts.cursor, - limit: opts.limit - }) - } - - private issueHandle(leaf: RuntimeLeafRecord): string { - const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) - const existingHandle = this.handleByLeafKey.get(leafKey) - if (existingHandle) { - const existingRecord = this.handles.get(existingHandle) - if ( - existingRecord && - existingRecord.rendererGraphEpoch === this.rendererGraphEpoch && - existingRecord.ptyId === leaf.ptyId && - existingRecord.ptyGeneration === leaf.ptyGeneration - ) { - return existingHandle - } - } - - const preAllocatedHandle = this.adoptPreAllocatedHandle(leaf) - if (preAllocatedHandle) { - return preAllocatedHandle - } - const incarnationId = leaf.ptyId ? (this.ptysById.get(leaf.ptyId)?.incarnationId ?? null) : null - const retained = leaf.ptyId ? this.handleByPtyIncarnation.get(leaf.ptyId) : undefined - if (retained && leaf.ptyId && retained.incarnationId !== incarnationId) { - this.invalidatePtyIncarnationHandle(leaf.ptyId) - } else if (retained) { - this.bindPtyIncarnationHandle(retained, leaf) - return retained.handle - } - - const handle = `term_${randomUUID()}` - this.syntheticTerminalHandles.add(handle) - this.handles.set(handle, { - handle, - runtimeId: this.runtimeId, - rendererGraphEpoch: this.rendererGraphEpoch, - worktreeId: leaf.worktreeId, - tabId: leaf.tabId, - leafId: leaf.leafId, - ptyId: leaf.ptyId, - ptyGeneration: leaf.ptyGeneration - }) - this.handleByLeafKey.set(leafKey, handle) - if (leaf.ptyId && incarnationId) { - this.handleByPtyIncarnation.set(leaf.ptyId, { handle, incarnationId, leafKey }) - } - return handle - } - - private bindPtyIncarnationHandle( - retained: PtyIncarnationHandleRecord, - leaf: RuntimeLeafRecord - ): void { - const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) - if (retained.leafKey !== leafKey) { - if (this.handleByLeafKey.get(retained.leafKey) === retained.handle) { - this.handleByLeafKey.delete(retained.leafKey) - } - retained.leafKey = leafKey - } - this.handles.set(retained.handle, { - handle: retained.handle, - runtimeId: this.runtimeId, - rendererGraphEpoch: this.rendererGraphEpoch, - worktreeId: leaf.worktreeId, - tabId: leaf.tabId, - leafId: leaf.leafId, - ptyId: leaf.ptyId, - ptyGeneration: leaf.ptyGeneration - }) - this.handleByLeafKey.set(leafKey, retained.handle) - } - - private invalidatePtyIncarnationHandle(ptyId: string): void { - const retained = this.handleByPtyIncarnation.get(ptyId) - if (!retained) { - return - } - this.handleByPtyIncarnation.delete(ptyId) - if (this.handleByLeafKey.get(retained.leafKey) === retained.handle) { - this.handleByLeafKey.delete(retained.leafKey) - } - this.handles.delete(retained.handle) - this.syntheticTerminalHandles.delete(retained.handle) - this.rejectWaitersForHandle(retained.handle, 'terminal_handle_stale') - } - - private clearPtyIncarnationHandles(): void { - for (const retained of this.handleByPtyIncarnation.values()) { - this.syntheticTerminalHandles.delete(retained.handle) - } - this.handleByPtyIncarnation.clear() - } - - private reconcilePtyIncarnationHandles(): void { - for (const [ptyId, retained] of this.handleByPtyIncarnation) { - const pty = this.ptysById.get(ptyId) - const leaves = this.getLeavesForPty(ptyId) - if ( - !pty?.incarnationId || - pty.incarnationId !== retained.incarnationId || - leaves.length !== 1 || - this.handleByPtyId.has(ptyId) - ) { - this.invalidatePtyIncarnationHandle(ptyId) - continue - } - this.bindPtyIncarnationHandle(retained, leaves[0]) - } - } - - private adoptPreAllocatedHandle(leaf: RuntimeLeafRecord): string | null { - if (!leaf.ptyId) { - return null - } - const preAllocated = this.handleByPtyId.get(leaf.ptyId) - if (!preAllocated) { - return null - } - const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) - this.handles.set(preAllocated, { - handle: preAllocated, - runtimeId: this.runtimeId, - rendererGraphEpoch: this.rendererGraphEpoch, - worktreeId: leaf.worktreeId, - tabId: leaf.tabId, - leafId: leaf.leafId, - ptyId: leaf.ptyId, - ptyGeneration: leaf.ptyGeneration - }) - this.handleByLeafKey.set(leafKey, preAllocated) - return preAllocated - } - - private issuePtyHandle(pty: RuntimePtyWorktreeRecord): string { - const existingHandle = - this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId) - if (existingHandle) { - const existingRecord = this.handles.get(existingHandle) - if ( - existingRecord && - existingRecord.runtimeId === this.runtimeId && - existingRecord.ptyId === pty.ptyId - ) { - this.handleByPtyId.set(pty.ptyId, existingHandle) - return existingHandle - } - } - - const handle = existingHandle ?? `term_${randomUUID()}` - if (!existingHandle) { - this.syntheticTerminalHandles.add(handle) - } - const syntheticId = `pty:${pty.ptyId}` - this.handles.set(handle, { - handle, - runtimeId: this.runtimeId, - rendererGraphEpoch: this.rendererGraphEpoch, - worktreeId: pty.worktreeId, - tabId: syntheticId, - leafId: syntheticId, - ptyId: pty.ptyId, - ptyGeneration: 0 - }) - this.handleByPtyId.set(pty.ptyId, handle) - return handle - } - - private findHandleForPtyRecord(ptyId: string): string | null { - for (const [handle, record] of this.handles) { - if ( - record.runtimeId === this.runtimeId && - record.ptyId === ptyId && - record.tabId.startsWith('pty:') - ) { - return handle - } - } - return null - } - - private refreshWritableFlags(): void { - for (const leaf of this.leaves.values()) { - leaf.writable = this.graphStatus === 'ready' && leaf.connected && leaf.ptyId !== null - } - } - - private invalidateLeafHandle(leafKey: string): void { - const handle = this.handleByLeafKey.get(leafKey) - if (!handle) { - return - } - const record = this.handles.get(handle) - if (record?.ptyId && this.handleByPtyIncarnation.get(record.ptyId)?.handle === handle) { - this.handleByPtyIncarnation.delete(record.ptyId) - } - this.handleByLeafKey.delete(leafKey) - this.handles.delete(handle) - this.syntheticTerminalHandles.delete(handle) - this.rejectWaitersForHandle(handle, 'terminal_handle_stale') - } - - private adoptFirstPtyForLeafHandle( - leafKey: string, - ptyId: string | null, - ptyGeneration: number - ): boolean { - const handle = this.handleByLeafKey.get(leafKey) - const record = handle ? this.handles.get(handle) : null - if (!handle || !record || record.ptyId !== null || ptyId === null) { - return false - } - this.handles.set(handle, { ...record, ptyId, ptyGeneration }) - return true - } - - private rememberDetachedPreAllocatedLeaves(): void { - for (const leaf of this.leaves.values()) { - if (leaf.ptyId && this.handleByPtyId.has(leaf.ptyId)) { - // Why: ORCA_TERMINAL_HANDLE is an agent identity, so CLI control survives renderer graph loss while the PTY is alive. - this.detachedPreAllocatedLeaves.set(leaf.ptyId, leaf) - } - } - } - - private resolveExitWaiters(leaf: RuntimeLeafRecord): void { - const handle = this.issueHandle(leaf) - if (!handle) { - return - } - const waiters = this.waitersByHandle.get(handle) - if (!waiters || waiters.size === 0) { - return - } - for (const waiter of [...waiters]) { - if (waiter.condition === 'exit') { - this.resolveWaiter(waiter, buildTerminalWaitResult(handle, 'exit', leaf)) - } else { - // Why: after exit, conditions like tui-idle can never be satisfied — reject now instead of spinning the poll until timeout on a dead process. - this.removeWaiter(waiter) - waiter.reject(new Error('terminal_exited')) - } - } - } - - private resolveTuiIdleWaiters(leaf: RuntimeLeafRecord): void { - const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) - const candidateHandle = - this.handleByLeafKey.get(leafKey) ?? - (leaf.ptyId - ? (this.handleByPtyId.get(leaf.ptyId) ?? - this.handleByPtyIncarnation.get(leaf.ptyId)?.handle) - : undefined) - if (!candidateHandle || !this.waitersByHandle.has(candidateHandle)) { - return - } - const handle = this.issueHandle(leaf) - const waiters = this.waitersByHandle.get(handle) - if (!waiters || waiters.size === 0) { - return - } - for (const waiter of [...waiters]) { - if (waiter.condition === 'tui-idle') { - this.resolveWaiter(waiter, buildTerminalWaitResult(handle, 'tui-idle', leaf)) - } - } - } - - private resolvePtyExitWaiters(pty: RuntimePtyWorktreeRecord, ptyId: string): void { - const handle = this.handleByPtyId.get(ptyId) - if (!handle) { - return - } - const waiters = this.waitersByHandle.get(handle) - if (!waiters || waiters.size === 0) { - return - } - for (const waiter of [...waiters]) { - if (waiter.condition === 'exit') { - this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, 'exit', pty)) - } else { - this.removeWaiter(waiter) - waiter.reject(new Error('terminal_exited')) - } - } - } - - private isPtyKnownExited(ptyId: string): boolean { - const pty = this.ptysById.get(ptyId) - if (pty) { - // Why: `!connected` is an inference, not proof. The liveness sweep clears it with no - // exit code for every PTY of a dropped relay, so reading that as an exit retires the - // lease of a process still running on the host — 'unknown' must keep watching. - return getPtyTerminalState(pty) === 'exited' - } - return this.getLeavesForPty(ptyId).some((leaf) => getTerminalState(leaf) === 'exited') - } - - private notifyPtyExitListeners(ptyId: string): void { - const listeners = this.ptyExitListenersByPtyId.get(ptyId) - if (!listeners) { - return - } - this.ptyExitListenersByPtyId.delete(ptyId) - notifyRuntimeListeners(listeners, (listener) => listener(), 'pty-exit') - } - - private resolvePtyTuiIdleWaiters(pty: RuntimePtyWorktreeRecord, ptyId: string): void { - const handle = this.handleByPtyId.get(ptyId) - if (!handle) { - return - } - const waiters = this.waitersByHandle.get(handle) - if (!waiters || waiters.size === 0) { - return - } - for (const waiter of [...waiters]) { - if (waiter.condition === 'tui-idle') { - this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, 'tui-idle', pty)) - } - } - } - - // Why: the primary OSC-title signal can't fire for daemon-hosted terminals (no PTY data through the runtime), so this fallback polls the renderer-synced tab title + foreground-process quiescence; self-cancels when the OSC path fires. - private startTuiIdleFallbackPoll( - waiter: TerminalWaiter, - leaf: RuntimeLeafRecord, - waiterTimeoutMs: number - ): void { - let foregroundPollInFlight = false - waiter.pollInterval = setInterval(async () => { - if (!waiter.pollInterval) { - return - } - let startedForegroundPoll = false - try { - if (leaf.lastAgentStatus === 'idle') { - if (waiter.pollInterval) { - clearInterval(waiter.pollInterval) - waiter.pollInterval = null - } - this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) - return - } - // Why: the renderer-synced title is the only path where OSC titles are visible for daemon-hosted terminals. - const pollTitle = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title - if (pollTitle) { - const titleStatus = detectExplicitIdleStatusFromTitle(pollTitle) - if (titleStatus === 'idle') { - if (waiter.pollInterval) { - clearInterval(waiter.pollInterval) - waiter.pollInterval = null - } - this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) - return - } - } - const leafWaitText = buildTerminalWaitText( - leaf.tailBuffer, - leaf.tailPartialLine, - leaf.preview - ) - const blockedReason = detectTerminalWaitBlockedReason(leafWaitText) - if (blockedReason) { - if (waiter.pollInterval) { - clearInterval(waiter.pollInterval) - waiter.pollInterval = null - } - this.resolveWaiter( - waiter, - buildTerminalWaitBlockedResult(waiter.handle, 'tui-idle', leaf, blockedReason) - ) - return - } - if (isKnownReadyPromptPreview(leafWaitText)) { - if (waiter.pollInterval) { - clearInterval(waiter.pollInterval) - waiter.pollInterval = null - } - this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) - return - } - // Foreground fallback: a reported non-shell process with quiet output is treated as idle. - if ( - leaf.lastAgentStatus === null && - leaf.ptyId && - this.ptyController && - !foregroundPollInFlight - ) { - foregroundPollInFlight = true - startedForegroundPoll = true - const fg = await this.ptyController.getForegroundProcess(leaf.ptyId) - if (fg && !isShellProcess(fg)) { - const quietMs = leaf.lastOutputAt ? Date.now() - leaf.lastOutputAt : 0 - if (quietMs >= TUI_IDLE_QUIESCENCE_MS) { - if (waiter.pollInterval) { - clearInterval(waiter.pollInterval) - waiter.pollInterval = null - } - this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) - } - } - } - } catch { - // Swallow transient PTY inspection errors and keep polling. - } finally { - if (startedForegroundPoll) { - foregroundPollInFlight = false - } - } - }, TUI_IDLE_POLL_INTERVAL_MS) - const retainedWaitText = buildTerminalWaitText( - leaf.tailBuffer, - leaf.tailPartialLine, - leaf.preview - ) - if (leaf.lastAgentStatus === null && retainedWaitText.length === 0) { - this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs) - } - } - - private startPtyTuiIdleFallbackPoll( - waiter: TerminalWaiter, - pty: RuntimePtyWorktreeRecord, - waiterTimeoutMs: number - ): void { - let foregroundPollInFlight = false - waiter.pollInterval = setInterval(async () => { - if (!waiter.pollInterval) { - return - } - let startedForegroundPoll = false - try { - if (pty.lastAgentStatus === 'idle') { - if (waiter.pollInterval) { - clearInterval(waiter.pollInterval) - waiter.pollInterval = null - } - this.resolveWaiter(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) - return - } - const ptyWaitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) - const blockedReason = detectTerminalWaitBlockedReason(ptyWaitText) - if (blockedReason) { - if (waiter.pollInterval) { - clearInterval(waiter.pollInterval) - waiter.pollInterval = null - } - this.resolveWaiter( - waiter, - buildPtyTerminalWaitBlockedResult(waiter.handle, 'tui-idle', pty, blockedReason) - ) - return - } - // Why: adopted background PTY handles use their live xterm title as the same readiness signal as leaf handles. - if ( - this.getAdoptedPtyExplicitIdleStatus(pty) === 'idle' || - isKnownReadyPromptPreview(ptyWaitText) - ) { - if (waiter.pollInterval) { - clearInterval(waiter.pollInterval) - waiter.pollInterval = null - } - this.resolveWaiter(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) - return - } - if (pty.lastAgentStatus === null && this.ptyController && !foregroundPollInFlight) { - foregroundPollInFlight = true - startedForegroundPoll = true - const fg = await this.ptyController.getForegroundProcess(pty.ptyId) - if (fg && !isShellProcess(fg)) { - const quietMs = pty.lastOutputAt ? Date.now() - pty.lastOutputAt : 0 - if (quietMs >= TUI_IDLE_QUIESCENCE_MS) { - if (waiter.pollInterval) { - clearInterval(waiter.pollInterval) - waiter.pollInterval = null - } - this.resolveWaiter(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) - } - } - } - } catch { - // Swallow transient PTY inspection errors and keep polling. - } finally { - if (startedForegroundPoll) { - foregroundPollInFlight = false - } - } - }, TUI_IDLE_POLL_INTERVAL_MS) - const retainedWaitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) - if (pty.lastAgentStatus === null && retainedWaitText.length === 0) { - this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs) - } - } - - /** One bounded look at the provider's screen for an adopted PTY whose retained - * readiness metadata was lost. Deliberately single-shot: it answers "is the - * screen already showing a settled prompt", and the poll above owns every - * later transition. A provider screen that is still working when this fires - * resolves through the poll, not here. */ - private startTuiIdleVisibleReadProbe(waiter: TerminalWaiter, waiterTimeoutMs: number): void { - const settleMarginMs = Math.min( - TUI_IDLE_VISIBLE_PROBE_SETTLE_MARGIN_MS, - Math.max(1, Math.floor(waiterTimeoutMs / 3)) - ) - const probeTimeoutMs = Math.min( - VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS + settleMarginMs, - Math.max(0, waiterTimeoutMs - settleMarginMs) - ) - const providerTimeoutMs = Math.min( - VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS, - Math.max(0, probeTimeoutMs - settleMarginMs) - ) - // Node clamps sub-millisecond timers to 1ms, so no distinct retirement deadline exists. - if (providerTimeoutMs < 1) { - return - } - // Retire the provider before the detached probe and waiter can settle. - void withTimeout( - this.readTerminal( - waiter.handle, - {}, - { - timeoutMs: providerTimeoutMs, - retireOnTimeout: true, - // Why: the ready banner stays in scrollback for the whole session, so - // classifying history would call a working agent idle (#15569 review). - visibleScreenOnly: true - } - ), - probeTimeoutMs, - null - ) - .then((read) => { - if ( - !read || - read.source !== 'screen' || - !this.waitersByHandle.get(waiter.handle)?.has(waiter) - ) { - return - } - const snapshotText = read.tail.join('\n') - const blockedReason = detectTerminalWaitBlockedReason(snapshotText) - if (!blockedReason && !isKnownReadyPromptPreview(snapshotText)) { - return - } - // Why resolve before clearing: a stale handle throws while locating the - // record, and a cleared interval would leave the waiter with no poll and - // no probe — able to end only in timeout. - const result = this.buildTuiIdleProbeResult(waiter.handle, blockedReason) - if (waiter.pollInterval) { - clearInterval(waiter.pollInterval) - waiter.pollInterval = null - } - this.resolveWaiter(waiter, result) - }) - .catch(() => {}) - } - - private buildTuiIdleProbeResult( - handle: string, - blockedReason: RuntimeTerminalWaitBlockedReason | null - ): RuntimeTerminalWait { - const pty = this.getLivePtyForHandle(handle) - if (pty) { - return blockedReason - ? buildPtyTerminalWaitBlockedResult(handle, 'tui-idle', pty.pty, blockedReason) - : buildPtyTerminalWaitResult(handle, 'tui-idle', pty.pty) - } - const { leaf } = this.getLiveLeafForHandle(handle) - return blockedReason - ? buildTerminalWaitBlockedResult(handle, 'tui-idle', leaf, blockedReason) - : buildTerminalWaitResult(handle, 'tui-idle', leaf) - } - - private getAdoptedPtyExplicitIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null { - for (const leaf of this.leaves.values()) { - if (leaf.ptyId !== pty.ptyId) { - continue - } - const title = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title - if (!title) { - continue - } - const status = detectExplicitIdleStatusFromTitle(title) - if (status !== null) { - return status - } - } - return null - } - - private resolveWaiter(waiter: TerminalWaiter, result: RuntimeTerminalWait): void { - this.removeWaiter(waiter) - waiter.resolve(result) - } - - private bindTerminalWaiterAbort( - waiter: TerminalWaiter, - signal: AbortSignal | undefined - ): boolean { - if (!signal) { - return true - } - if (signal.aborted) { - return false - } - const onAbort = (): void => { - this.removeWaiter(waiter) - waiter.reject(new Error('request_aborted')) - } - waiter.abortCleanup = () => signal.removeEventListener('abort', onAbort) - signal.addEventListener('abort', onAbort, { once: true }) - return true - } - - private rejectWaitersForHandle(handle: string, code: string): void { - const waiters = this.waitersByHandle.get(handle) - if (!waiters || waiters.size === 0) { - return - } - for (const waiter of [...waiters]) { - this.removeWaiter(waiter) - waiter.reject(new Error(code)) - } - } - - private rejectAllWaiters(code: string): void { - for (const handle of [...this.waitersByHandle.keys()]) { - this.rejectWaitersForHandle(handle, code) - } - } - - private removeWaiter(waiter: TerminalWaiter): void { - if (waiter.timeout) { - clearTimeout(waiter.timeout) - } - if (waiter.pollInterval) { - clearInterval(waiter.pollInterval) - } - if (waiter.abortCleanup) { - waiter.abortCleanup() - waiter.abortCleanup = null - } - const waiters = this.waitersByHandle.get(waiter.handle) - if (!waiters) { - return - } - waiters.delete(waiter) - if (waiters.size === 0) { - this.waitersByHandle.delete(waiter.handle) - } - } - - private getLeafKey(tabId: string, leafId: string): string { - return `${tabId}::${leafId}` - } - - // ── Linear integration ── - - linearConnect(apiKey: string): ReturnType { - return connectLinear(apiKey) - } - - linearDisconnect(workspaceId?: string): { ok: true } { - disconnectLinear(workspaceId) - return { ok: true } - } - - linearSelectWorkspace(workspaceId: LinearWorkspaceSelection): ReturnType { - return selectLinearWorkspace(workspaceId) - } - - linearStatus(): ReturnType { - return getLinearStatus() - } - - linearTestConnection(workspaceId?: string): ReturnType { - return testLinearConnection(workspaceId) - } - - linearSearchIssues( - query: string, - limit = 20, - workspaceId?: LinearWorkspaceSelection - ): ReturnType { - return searchLinearIssues(query, Math.min(Math.max(1, limit), 50), workspaceId) - } - - linearSearchForAgents(args: { - query: string - limit?: number - workspaceId?: (string & {}) | 'all' - }): ReturnType { - return searchLinearIssuesForAgents(args) - } - - linearIssueContext(request: LinearIssueRequest): ReturnType { - return readLinearIssueContext(request, (context) => this.linearResolveCurrentIssue(context)) - } - - async linearTeamListForAgents(params: { - workspaceId?: (string & {}) | 'all' - }): Promise { - try { - const result = await listLinearTeamsForAgent(params.workspaceId) - const workspaceErrors = result.errors.map((error) => ({ - workspace: { id: error.workspaceId, name: error.workspaceName ?? error.workspaceId }, - code: this.linearWorkspaceErrorCode(error.type), - message: sanitizeLinearErrorMessage(error.message) - })) - return { - teams: result.teams.map((team) => this.linearTeamSummary(team)), - meta: { - workspaceId: params.workspaceId, - returned: result.teams.length, - partial: workspaceErrors.length > 0, - workspaceErrors - } - } - } catch (error) { - throw this.mapLinearReadFailure(error) - } - } - - async linearTeamMembersForAgents(params: { - teamInput: string - workspaceId?: string - }): Promise { - const team = await this.resolveLinearTeamInput(params.teamInput, params.workspaceId) - try { - const members = await getLinearTeamMembersOrThrow(team.id, team.workspaceId) - return { - team: this.linearTeamSummary(team), - members: members.map((member) => ({ - id: member.id, - displayName: member.displayName, - avatarUrl: member.avatarUrl - })), - meta: { workspaceId: team.workspaceId, returned: members.length } - } - } catch (error) { - throw this.mapLinearReadFailure(error) - } - } - - async linearTeamStatesForAgents(params: { - teamInput: string - workspaceId?: string - }): Promise { - const team = await this.resolveLinearTeamInput(params.teamInput, params.workspaceId) - const states = await this.getLinearTeamStatesForWrite(team.id, team.workspaceId) - return { - team: this.linearTeamSummary(team), - states: states.map((state) => ({ - id: state.id, - name: state.name, - type: state.type, - color: state.color, - position: state.position - })), - meta: { workspaceId: team.workspaceId, returned: states.length } - } - } - - async linearTeamLabelsForAgents(params: { - teamInput: string - workspaceId?: string - }): Promise { - const team = await this.resolveLinearTeamInput(params.teamInput, params.workspaceId) - const labels = await this.getLinearTeamLabelsForWrite(team.id, team.workspaceId) - return { - team: this.linearTeamSummary(team), - labels: labels.map((label) => ({ id: label.id, name: label.name, color: label.color })), - meta: { workspaceId: team.workspaceId, returned: labels.length } - } - } - - async linearProjectListForAgents(params: { - query?: string - limit?: number - workspaceId?: (string & {}) | 'all' - }): Promise { - const limit = clampLinearSearchLimit(params.limit) - try { - const result = await this.linearListProjects(params.query, limit, params.workspaceId, true) - const projects = result.items.slice(0, limit).map((project) => ({ - id: project.id, - name: project.name, - ...(project.url ? { url: project.url } : {}), - ...(project.workspaceId ? { workspaceId: project.workspaceId } : {}), - ...(project.workspaceName ? { workspaceName: project.workspaceName } : {}), - ...(project.teams ? { teams: project.teams } : {}) - })) - const workspaceErrors = (result.errors ?? []).map((error) => ({ - workspace: { id: error.workspaceId, name: error.workspaceName ?? error.workspaceId }, - code: this.linearWorkspaceErrorCode(error.type), - message: sanitizeLinearErrorMessage(error.message) - })) - const hasMore = result.hasMore === true || result.items.length > limit - return { - projects, - truncated: hasMore, - meta: { - query: params.query, - workspaceId: params.workspaceId, - limit, - returned: projects.length, - hasMore, - partial: workspaceErrors.length > 0, - workspaceErrors - } - } - } catch (error) { - throw this.mapLinearReadFailure(error) - } - } - - async linearIssueListForAgents(params: { - filter?: LinearIssueListFilter - teamInput?: string - limit?: number - workspaceId?: (string & {}) | 'all' - }): Promise { - const filter = params.filter ?? 'assigned' - const limit = clampLinearIssueListLimit(params.limit) - const team = params.teamInput - ? await this.resolveLinearTeamInput(params.teamInput, params.workspaceId) - : null - const workspaceId = team?.workspaceId ?? params.workspaceId - try { - const result = await listLinearIssues(filter, limit, workspaceId, { - teamId: team?.id - }) - return { - issues: result.items.map((issue) => ({ - id: issue.id, - identifier: issue.identifier, - title: issue.title, - url: issue.url, - state: issue.state, - team: issue.team, - project: issue.project ?? null, - assignee: issue.assignee ?? null, - priority: issue.priority, - estimate: issue.estimate, - dueDate: issue.dueDate, - updatedAt: issue.updatedAt, - priorityLabel: linearPriorityLabel(issue.priority), - workspace: { - id: issue.workspaceId ?? workspaceId ?? '', - name: issue.workspaceName ?? issue.workspaceId ?? workspaceId ?? '' - } - })), - truncated: result.hasMore === true, - meta: { - filter, - workspaceId, - ...(team ? { team: this.linearTeamSummary(team) } : {}), - limit, - returned: result.items.length, - hasMore: result.hasMore === true, - partial: (result.errors?.length ?? 0) > 0, - workspaceErrors: (result.errors ?? []).map((error) => ({ - workspace: { id: error.workspaceId, name: error.workspaceName ?? error.workspaceId }, - code: this.linearWorkspaceErrorCode(error.type), - message: sanitizeLinearErrorMessage(error.message) - })) - } - } - } catch (error) { - throw this.mapLinearReadFailure(error) - } - } - - async linearMcpIssueList(params: LinearMcpIssueListRequest): Promise { - try { - return await listMcpIssues(params) - } catch (error) { - throw this.mapLinearReadFailure(error) - } - } - - async linearResolveCurrentIssue( - context?: LinearCurrentIssueContextHints - ): Promise> { - if (!this.store) { - throw new Error('runtime_unavailable') - } - - let worktree: ResolvedWorktree | null = null - if (context?.terminalHandle) { - try { - const terminal = await this.showTerminal(context.terminalHandle) - if (context.worktreeId && context.worktreeId !== terminal.worktreeId) { - throw new LinearAgentAccessError( - 'linear_permission_denied', - 'The provided Linear worktree context does not match the caller terminal.' - ) - } - worktree = await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`) - } catch (error) { - if (error instanceof LinearAgentAccessError) { - throw error - } - if (context.remote === true || context.worktreeId) { - throw new LinearAgentAccessError( - 'linear_issue_required', - 'Could not verify the current Linear-linked worktree.' - ) - } - } - } - - if (!worktree && context?.remote !== true && context?.cwd) { - worktree = await this.resolveWorktreeForContainedPath(context.cwd) - if (!worktree) { - throw new LinearAgentAccessError( - 'linear_issue_required', - 'Run --current from inside an Orca-managed worktree or pass an issue id.' - ) - } - } - - if (!worktree) { - throw new LinearAgentAccessError( - 'linear_issue_required', - 'Run --current from inside an Orca-managed worktree or pass an issue id.' - ) - } - - const link = getLinearCurrentIssueFromWorktree(worktree) - if (!link.workspaceId) { - const backfill = resolveLegacyLinearLinkWorkspace( - worktree.linkedLinearIssue ?? '', - worktree.linkedLinearIssueOrganizationUrlKey - ) - if (backfill?.workspaceId) { - this.store.setWorktreeMeta(worktree.id, { - linkedLinearIssueWorkspaceId: backfill.workspaceId, - linkedLinearIssueOrganizationUrlKey: backfill.organizationUrlKey ?? null - }) - return { - ...link, - workspaceId: backfill.workspaceId, - organizationUrlKey: backfill.organizationUrlKey ?? link.organizationUrlKey, - backfill - } - } - } - return link - } - - private async resolveWorktreeForContainedPath(cwd: string): Promise { - const currentPath = resolve(cwd) - let best: ResolvedWorktree | null = null - for (const candidate of await this.listResolvedWorktrees()) { - if (!isPathInsideOrEqual(candidate.path, currentPath)) { - continue - } - if (!best || candidate.path.length > best.path.length) { - best = candidate - } - } - return best - } - - linearListIssues( - filter?: LinearListFilter, - limit = 20, - workspaceId?: LinearWorkspaceSelection, - options?: LinearIssueListOptions - ): ReturnType { - return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId, options) - } - - linearCreateIssue( - teamId: string, - title: string, - description?: string, - workspaceId?: string, - parentIssueId?: string, - projectId?: string | null, - options?: { - stateId?: string - priority?: number - estimate?: number | null - dueDate?: string | null - assigneeId?: string | null - labelIds?: string[] - } - ): ReturnType { - return createLinearIssue(teamId, title, description, workspaceId, { - parentId: parentIssueId, - projectId, - ...options - }) - } - - linearGetIssue(id: string, workspaceId?: string): ReturnType { - return getLinearIssue(id, workspaceId) - } - - linearUpdateIssue( - id: string, - updates: LinearIssueUpdate, - workspaceId?: string - ): ReturnType { - return updateLinearIssue(id, updates, workspaceId) - } - - linearAddIssueComment( - issueId: string, - body: string, - workspaceId?: string - ): ReturnType { - return addLinearIssueComment(issueId, body, workspaceId) - } - - async linearIssueSetState(params: { - input?: string - current?: boolean - workspaceId?: string - to: string - context?: LinearCurrentIssueContextHints - }): Promise { - const target = await this.resolveLinearAgentWriteTarget(params) - const teamId = target.issue.team?.id - if (!teamId) { - throw linearError('linear_invalid_state', 'The Linear issue does not have a team.') - } - const states = await this.getLinearTeamStatesForWrite(teamId, target.workspaceId) - const state = this.resolveLinearAgentState(params.to, states) - if (!state) { - throw linearError( - 'linear_invalid_state', - `No workflow state exactly matched "${params.to}".`, - { - states: states.map(({ id, name, type }) => ({ id, name, type })), - nextSteps: [`Retry with one of the exact state names for ${target.issue.identifier}.`] - } - ) - } - - const previousState = - target.issue.state?.id && target.issue.state.name - ? { id: target.issue.state.id, name: target.issue.state.name } - : null - const alreadyInState = target.issue.state?.id === state.id - if (!alreadyInState) { - await this.runLinearAgentWrite( - async (signal) => { - const updated = await updateLinearIssueForAgent( - target.issue.id, - { stateId: state.id }, - target.workspaceId, - { - signal - } - ) - if (updated.state?.id !== state.id) { - throw new LinearWriteFailure( - 'unconfirmed', - 'Linear state update could not be confirmed.' - ) - } - return updated - }, - (cause) => - linearError( - 'linear_write_unconfirmed', - 'Linear may have applied the state change, but Orca could not confirm it.', - { - nextSteps: [ - `Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` and check the current state before retrying.` - ], - ...(cause ? { cause } : {}) - } - ) - ) - } - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) - return { - issue: this.linearWriteIssueRef(target.issue), - state: { id: state.id, name: state.name, type: state.type }, - previousState, - meta: { workspaceId: target.workspaceId, alreadyInState } - } - } - - async linearIssueRelationWrite( - params: LinearIssueRelationWriteRequest - ): Promise { - const target = await this.resolveLinearAgentWriteTarget(params) - const related = await this.resolveLinearAgentWriteTarget({ - input: params.relatedInput, - workspaceId: target.workspaceId, - context: params.context - }) - if (target.issue.id === related.issue.id) { - throw linearError('linear_write_failed', 'An issue cannot be related to itself.') - } - try { - const result = await this.runLinearAgentWrite( - (signal) => - writeIssueRelation({ - issue: { ...this.linearWriteIssueRef(target.issue), title: target.issue.title }, - relatedIssue: { - ...this.linearWriteIssueRef(related.issue), - title: related.issue.title - }, - relationship: params.relationship, - operation: params.operation, - workspaceId: target.workspaceId, - signal - }), - (cause) => - linearError( - 'linear_write_unconfirmed', - 'Linear may have applied the relation change, but Orca could not confirm it.', - { - nextSteps: [ - `Run \`orca linear issue ${target.issue.identifier} --relations --workspace ${target.workspaceId} --json\` before retrying.` - ], - ...(cause ? { cause } : {}) - } - ) - ) - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, [ - target.issue.identifier, - related.issue.identifier - ]) - return result - } catch (error) { - throw this.mapLinearReadFailure(error) - } - } - - async linearSaveIssue(params: LinearSaveIssueRequest): Promise { - if ((params.description?.length ?? 0) > LINEAR_WRITE_BODY_CAP) { - throw linearError('linear_body_too_large', 'Linear issue body is too large.') - } - if (!params.input && !params.current) { - if (!params.title || !params.team) { - throw linearError( - 'linear_write_failed', - 'Creating with save-issue requires both team and title.' - ) - } - const created = await this.linearIssueCreate({ - title: params.title, - body: params.description, - teamInput: params.team, - state: params.state, - assignee: params.assignee ?? undefined, - priority: params.priority, - estimate: params.estimate ?? undefined, - dueDate: params.dueDate ?? undefined, - labels: params.labels, - projectInput: params.project ?? undefined, - parentInput: params.parentId ?? undefined, - workspaceId: params.workspaceId, - writeId: params.writeId, - context: params.context - }) - return { ...created, meta: { ...created.meta, created: true } } - } - if (params.team !== undefined) { - throw linearError('linear_write_failed', 'Team can only be set when creating an issue.') - } - const target = await this.resolveLinearAgentWriteTarget(params) - const current = await this.readLinearAgentIssueWriteRecord(target.issue.id, target.workspaceId) - const fields = await this.buildLinearSaveUpdate(params, current, target.workspaceId) - if (Object.keys(fields).length === 0) { - throw linearError('linear_write_failed', 'No issue fields were provided to save.') - } - const alreadySet = this.linearSavedIssueMatchesIntent(current, fields) - const updated = alreadySet - ? current - : await this.runLinearAgentWrite( - async (signal) => { - const saved = await updateLinearIssueForAgent( - target.issue.id, - fields, - target.workspaceId, - { signal } - ) - if (!this.linearSavedIssueMatchesIntent(saved, fields)) { - throw new LinearWriteFailure( - 'unconfirmed', - 'Linear issue save could not be confirmed.' - ) - } - return saved - }, - (cause) => - linearError( - 'linear_write_unconfirmed', - 'Linear may have applied the issue save, but Orca could not confirm it.', - { - nextSteps: [ - `Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` before retrying.` - ], - ...(cause ? { cause } : {}) - } - ) - ) - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) - return { - issue: updated, - meta: { - workspaceId: target.workspaceId, - created: false - } - } - } - - async linearIssueUpdateTask( - params: LinearIssueTaskUpdateRequest - ): Promise { - const target = await this.resolveLinearAgentWriteTarget(params) - const current = await this.readLinearAgentIssueWriteRecord(target.issue.id, target.workspaceId) - const update = await this.buildLinearTaskUpdate(params, current, target.workspaceId) - if (!update) { - throw linearError('linear_write_failed', 'No Linear task field update was requested.') - } - const alreadySet = this.linearTaskFieldAlreadySet(params.operation, current, update) - if (!alreadySet) { - await this.runLinearAgentWrite( - async (signal) => { - const updated = await updateLinearIssueForAgent( - target.issue.id, - update.fields, - target.workspaceId, - { signal } - ) - if (!this.linearTaskFieldAlreadySet(params.operation, updated, update)) { - throw new LinearWriteFailure( - 'unconfirmed', - 'Linear task field update could not be confirmed.' - ) - } - return updated - }, - (cause) => - linearError( - 'linear_write_unconfirmed', - 'Linear may have applied the task update, but Orca could not confirm it.', - { - nextSteps: [ - `Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` and check the updated field before retrying.` - ], - ...(cause ? { cause } : {}) - } - ) - ) - } - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) - const finalRecord = alreadySet - ? current - : await this.readLinearAgentIssueWriteRecord(target.issue.id, target.workspaceId) - return this.linearTaskUpdateResult( - params.operation, - target.issue, - target.workspaceId, - current, - finalRecord, - alreadySet - ) - } - - async linearIssueAddComment(params: { - input?: string - current?: boolean - workspaceId?: string - body: string - replyTo?: string - writeId?: string - context?: LinearCurrentIssueContextHints - }): Promise { - if (params.body.length > LINEAR_WRITE_BODY_CAP) { - throw linearError('linear_body_too_large', 'Linear comment body is too large.') - } - const target = await this.resolveLinearAgentWriteTarget(params) - const parentId = params.replyTo - ? await this.resolveLinearCommentParentId(target.issue.id, params.replyTo, target.workspaceId) - : null - const writeId = params.writeId ?? randomUUID() - const existing = - params.writeId !== undefined - ? await this.getMatchingLinearCommentWrite( - writeId, - target.issue.id, - parentId, - target.workspaceId, - true - ) - : null - if (existing) { - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) - return this.linearCommentResult(existing, target, params.body.length, writeId, true) - } - - try { - const comment = await this.runLinearAgentWrite( - (signal) => - addLinearIssueCommentForAgent(target.issue.id, params.body, target.workspaceId, { - id: writeId, - parentId, - signal - }), - (cause) => - this.linearCreateStyleUnconfirmed('comment', writeId, target, { - parentId, - bodyRequired: true, - cause - }) - ) - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) - return this.linearCommentResult(comment, target, params.body.length, writeId, false) - } catch (error) { - if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { - const comment = await this.refetchLinearCommentAfterDuplicate( - writeId, - target.issue.id, - parentId, - target.workspaceId, - () => - this.linearCreateStyleUnconfirmed('comment', writeId, target, { - parentId, - bodyRequired: true - }) - ) - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) - return this.linearCommentResult(comment, target, params.body.length, writeId, true) - } - throw error - } - } - - async linearIssueAttachLink(params: { - input?: string - current?: boolean - workspaceId?: string - url: string - title?: string - writeId?: string - context?: LinearCurrentIssueContextHints - }): Promise { - const url = this.parseLinearAttachmentUrl(params.url) - const target = await this.resolveLinearAgentWriteTarget(params) - const writeId = params.writeId ?? randomUUID() - const title = params.title?.trim() || this.defaultLinearAttachmentTitle(url) - const existing = - params.writeId !== undefined - ? await this.getMatchingLinearAttachmentWrite( - writeId, - target.issue.id, - target.workspaceId, - true - ) - : null - if (existing) { - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) - return this.linearAttachResult(existing, target, writeId, true) - } - try { - const attachment = await this.runLinearAgentWrite( - (signal) => - createLinearIssueAttachment( - target.issue.id, - { id: writeId, title, url: url.toString() }, - target.workspaceId, - { signal } - ), - (cause) => - this.linearCreateStyleUnconfirmed('attach', writeId, target, { - title, - url: url.toString(), - cause - }) - ) - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) - return this.linearAttachResult(attachment, target, writeId, false) - } catch (error) { - if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { - const attachment = await this.refetchLinearAttachmentAfterDuplicate( - writeId, - target.issue.id, - target.workspaceId, - () => - this.linearCreateStyleUnconfirmed('attach', writeId, target, { - title, - url: url.toString() - }) - ) - await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) - return this.linearAttachResult(attachment, target, writeId, true) - } - throw error - } - } - - async linearIssueCreate(params: { - title: string - body?: string - teamInput?: string - teamKey?: string - state?: string - assignee?: string - priority?: number - estimate?: number - dueDate?: string - labels?: string[] - projectInput?: string - parentInput?: string - parentCurrent?: boolean - workspaceId?: string - writeId?: string - context?: LinearCurrentIssueContextHints - }): Promise { - if ((params.body?.length ?? 0) > LINEAR_WRITE_BODY_CAP) { - throw linearError('linear_body_too_large', 'Linear issue body is too large.') - } - const parent = - params.parentInput || params.parentCurrent - ? await this.resolveLinearAgentWriteTarget({ - input: params.parentInput, - current: params.parentCurrent, - workspaceId: params.workspaceId, - context: params.context - }) - : null - if (parent && params.workspaceId && params.workspaceId !== parent.workspaceId) { - throw linearError( - 'linear_invalid_workspace', - 'The parent issue belongs to a different workspace.' - ) - } - const team = await this.resolveLinearCreateTeam( - params.teamInput ?? params.teamKey, - params.workspaceId, - parent - ) - const createFields = await this.resolveLinearCreateFields(params, team) - const parentId = parent?.issue.id ?? null - const writeId = params.writeId ?? randomUUID() - const existing = - params.writeId !== undefined - ? await this.getMatchingLinearCreatedIssue( - writeId, - team.id, - parentId, - team.workspaceId, - true, - createFields - ) - : null - if (existing) { - if (parent) { - await this.notifyLinearLinkedIssueUpdated(parent.workspaceId, parent.issue.identifier) - } - return this.linearCreateResult(existing, team.workspaceId, writeId, true) - } - - try { - const issue = await this.runLinearAgentWrite( - async (signal) => { - const created = await createLinearIssueForAgent( - team.id, - params.title, - params.body, - team.workspaceId, - { - id: writeId, - parentId, - ...createFields, - signal - } - ) - if (!this.linearCreatedIssueMatchesIntent(created, createFields)) { - throw new LinearWriteFailure( - 'unconfirmed', - 'Linear issue create could not be confirmed with the requested task fields.' - ) - } - return created - }, - (cause) => - this.linearCreateStyleUnconfirmed('create', writeId, null, { - team, - parent, - title: params.title, - bodyRequired: params.body !== undefined, - createFields, - cause - }) - ) - if (parent) { - await this.notifyLinearLinkedIssueUpdated(parent.workspaceId, parent.issue.identifier) - } - return this.linearCreateResult(issue, team.workspaceId, writeId, false) - } catch (error) { - if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { - const issue = await this.refetchLinearIssueAfterDuplicate( - writeId, - team.id, - parentId, - team.workspaceId, - createFields, - () => - this.linearCreateStyleUnconfirmed('create', writeId, null, { - team, - parent, - title: params.title, - bodyRequired: params.body !== undefined, - createFields - }) - ) - if (parent) { - await this.notifyLinearLinkedIssueUpdated(parent.workspaceId, parent.issue.identifier) - } - return this.linearCreateResult(issue, team.workspaceId, writeId, true) - } - throw error - } - } - - private async resolveLinearAgentWriteTarget(params: { - input?: string - current?: boolean - workspaceId?: string - context?: LinearCurrentIssueContextHints - }): Promise { - const result = await readLinearIssueContext( - { - input: params.input, - current: params.current, - workspaceId: params.workspaceId, - include: { - comments: false, - children: false, - attachments: false, - relations: false, - activity: false - }, - depth: 0, - context: params.context - }, - (context) => this.linearResolveCurrentIssue(context) - ) - return { issue: result.issue, workspaceId: result.meta.resolved.workspaceId } - } - - private async getLinearTeamStatesForWrite( - teamId: string, - workspaceId: string - ): Promise>> { - try { - return await getLinearTeamStatesOrThrow(teamId, workspaceId) - } catch (error) { - throw this.mapLinearReadFailure(error) - } - } - - private resolveLinearAgentState( - input: string, - states: Awaited> - ): Awaited>[number] | null { - const normalized = input.toLocaleLowerCase() - const exact = states.find( - (state) => - state.id.toLocaleLowerCase() === normalized || state.name.toLocaleLowerCase() === normalized - ) - // Why: Linear MCP accepts lifecycle types; keep explicit IDs/names authoritative when they collide. - return exact ?? states.find((state) => state.type.toLocaleLowerCase() === normalized) ?? null - } - - private async getLinearTeamLabelsForWrite( - teamId: string, - workspaceId: string - ): Promise>> { - try { - return await getLinearTeamLabelsOrThrow(teamId, workspaceId) - } catch (error) { - throw this.mapLinearReadFailure(error) - } - } - - private async readLinearAgentIssueWriteRecord( - issueId: string, - workspaceId: string - ): Promise>>> { - const issue = await this.readLinearWriteLookup(() => - getLinearIssueByUuidForAgent(issueId, workspaceId) - ) - if (!issue) { - throw linearError('linear_issue_not_found', 'Linear issue was not found.') - } - return issue - } - - private async buildLinearTaskUpdate( - params: LinearIssueTaskUpdateRequest, - current: NonNullable>>, - workspaceId: string - ): Promise<{ - fields: { - assigneeId?: string | null - priority?: number - estimate?: number | null - dueDate?: string | null - labelIds?: string[] - } - labels?: { id: string; name: string }[] - } | null> { - if (params.operation === 'assignee') { - const assigneeId = params.assigneeMe - ? (await this.getLinearViewerForWrite(workspaceId)).id - : params.assigneeId - if (assigneeId === undefined) { - throw linearError('linear_invalid_assignee', 'Pass --me, --to-id, or clear assignee.') - } - return { fields: { assigneeId } } - } - if (params.operation === 'priority') { - if (params.priority === undefined) { - throw linearError('linear_write_failed', 'Missing priority value.') - } - return { fields: { priority: params.priority } } - } - if (params.operation === 'estimate') { - if (params.estimate === undefined) { - throw linearError('linear_write_failed', 'Missing estimate value.') - } - return { fields: { estimate: params.estimate } } - } - if (params.operation === 'dueDate') { - if (params.dueDate === undefined) { - throw linearError('linear_write_failed', 'Missing due date value.') - } - return { fields: { dueDate: params.dueDate } } - } - if (params.operation === 'labels') { - const mode = params.labelMode - const inputs = params.labels ?? [] - if (!mode || inputs.length === 0) { - throw linearError('linear_invalid_label', 'Pass at least one --label.') - } - const labels = await this.resolveLinearLabelsForIssue(current, inputs, workspaceId) - const requestedIds = labels.map((label) => label.id) - const existingIds = current.labelIds ?? current.labels?.map((label) => label.id) ?? [] - const nextIds = - mode === 'set' - ? requestedIds - : mode === 'add' - ? Array.from(new Set([...existingIds, ...requestedIds])) - : existingIds.filter((id) => !requestedIds.includes(id)) - return { - fields: { labelIds: nextIds }, - labels: labelsForIds(nextIds, [...(current.labels ?? []), ...labels]) - } - } - return null - } - - private async buildLinearSaveUpdate( - params: LinearSaveIssueRequest, - current: NonNullable>>, - workspaceId: string - ): Promise { - const fields: LinearIssueUpdate = {} - if (params.title !== undefined) { - fields.title = params.title - } - if (params.description !== undefined) { - fields.description = params.description - } - if (params.priority !== undefined) { - fields.priority = params.priority - } - if (params.estimate !== undefined) { - fields.estimate = params.estimate - } - if (params.dueDate !== undefined) { - fields.dueDate = params.dueDate - } - if (params.state !== undefined) { - const states = await this.getLinearTeamStatesForWrite(current.team.id, workspaceId) - const state = this.resolveLinearAgentState(params.state, states) - if (!state) { - throw linearError( - 'linear_invalid_state', - `No workflow state exactly matched "${params.state}".` - ) - } - fields.stateId = state.id - } - if (params.assignee !== undefined) { - fields.assigneeId = - params.assignee === null - ? null - : await this.resolveLinearAssignee(params.assignee, current.team.id, workspaceId) - } - if (params.labels !== undefined) { - if (params.labels.length === 0) { - fields.labelIds = [] - } else { - const labels = await this.resolveLinearLabelsForIssue(current, params.labels, workspaceId) - fields.labelIds = labels.map((label) => label.id) - } - } - if (params.project !== undefined) { - fields.projectId = - params.project === null - ? null - : ( - await this.resolveLinearCreateProject(params.project, { - id: current.team.id, - workspaceId - }) - ).id - } - if (params.parentId !== undefined) { - fields.parentId = - params.parentId === null - ? null - : ( - await this.resolveLinearAgentWriteTarget({ - input: params.parentId, - workspaceId, - context: params.context - }) - ).issue.id - if (fields.parentId === current.id) { - throw linearError('linear_invalid_parent', 'An issue cannot be its own parent.') - } - } - return fields - } - - private async resolveLinearAssignee( - input: string, - teamId: string, - workspaceId: string - ): Promise { - if (input.toLocaleLowerCase() === 'me') { - return (await this.getLinearViewerForWrite(workspaceId)).id - } - // Why: caller-supplied IDs were accepted directly before save-issue; avoid a paginated member scan on that existing fast path. - if (isLinearUuid(input)) { - return input - } - let members: Awaited> - try { - members = await getLinearTeamMembersOrThrow(teamId, workspaceId) - } catch (error) { - throw this.mapLinearReadFailure(error) - } - const normalized = input.toLocaleLowerCase() - const matches = members.filter( - (member) => - member.id.toLocaleLowerCase() === normalized || - member.displayName.toLocaleLowerCase() === normalized || - member.name?.toLocaleLowerCase() === normalized || - member.email?.toLocaleLowerCase() === normalized - ) - if (matches.length === 1) { - return matches[0].id - } - throw linearError( - 'linear_invalid_assignee', - matches.length === 0 - ? `No team member exactly matched "${input}".` - : `Multiple team members exactly matched "${input}".` - ) - } - - private linearSavedIssueMatchesIntent( - issue: NonNullable>>, - fields: LinearIssueUpdate - ): boolean { - if (fields.title !== undefined && issue.title !== fields.title) { - return false - } - if (fields.description !== undefined && (issue.description ?? '') !== fields.description) { - return false - } - if (fields.parentId !== undefined && (issue.parent?.id ?? null) !== fields.parentId) { - return false - } - if (fields.stateId !== undefined && issue.state?.id !== fields.stateId) { - return false - } - if (fields.assigneeId !== undefined && (issue.assignee?.id ?? null) !== fields.assigneeId) { - return false - } - if (fields.priority !== undefined && issue.priority !== fields.priority) { - return false - } - if (fields.estimate !== undefined && (issue.estimate ?? null) !== fields.estimate) { - return false - } - if (fields.dueDate !== undefined && (issue.dueDate ?? null) !== fields.dueDate) { - return false - } - if (fields.projectId !== undefined && (issue.project?.id ?? null) !== fields.projectId) { - return false - } - const issueLabelIds = issue.labelIds ?? issue.labels?.map((label) => label.id) ?? [] - return fields.labelIds === undefined || sameStringSet(issueLabelIds, fields.labelIds) - } - - private async resolveLinearCreateFields( - params: { - state?: string - assignee?: string - priority?: number - estimate?: number - dueDate?: string - labels?: string[] - projectInput?: string - }, - team: { id: string; workspaceId: string } - ): Promise { - const fields: LinearCreateFieldIntent = {} - if (params.state) { - const states = await this.getLinearTeamStatesForWrite(team.id, team.workspaceId) - const state = this.resolveLinearAgentState(params.state, states) - if (!state) { - throw linearError( - 'linear_invalid_state', - `No workflow state exactly matched "${params.state}".`, - { states: states.map(({ id, name, type }) => ({ id, name, type })) } - ) - } - fields.stateId = state.id - } - if (params.assignee) { - fields.assigneeId = await this.resolveLinearAssignee( - params.assignee, - team.id, - team.workspaceId - ) - } - if (params.priority !== undefined) { - fields.priority = params.priority - } - if (params.estimate !== undefined) { - fields.estimate = params.estimate - } - if (params.dueDate !== undefined) { - fields.dueDate = params.dueDate - } - if (params.labels && params.labels.length > 0) { - const labels = await this.resolveLinearLabelsForTeam(team.id, params.labels, team.workspaceId) - fields.labelIds = labels.map((label) => label.id) - } - if (params.projectInput) { - const project = await this.resolveLinearCreateProject(params.projectInput, team) - fields.projectId = project.id - } - return fields - } - - private async resolveLinearCreateProject( - input: string, - team: { id: string; workspaceId: string } - ): Promise { - const trimmed = input.trim() - if (!trimmed) { - throw linearError('linear_invalid_project', 'Pass a non-empty Linear project id or name.') - } - const byId = isLinearUuid(trimmed) - ? await this.readLinearProjectByIdForCreate(trimmed, team.workspaceId) - : null - if (byId) { - await this.assertLinearProjectIncludesTeam(byId, team.id, team.workspaceId, trimmed) - return byId - } - const searchCandidates = await this.readLinearProjectsForCreate(trimmed, team.workspaceId) - const normalized = trimmed.toLowerCase() - const idMatch = searchCandidates.find((project) => project.id.toLowerCase() === normalized) - if (idMatch) { - await this.assertLinearProjectIncludesTeam(idMatch, team.id, team.workspaceId, trimmed) - return idMatch - } - const slugMatch = searchCandidates.find( - (project) => project.slugId?.toLowerCase() === normalized - ) - if (slugMatch) { - await this.assertLinearProjectIncludesTeam(slugMatch, team.id, team.workspaceId, trimmed) - return slugMatch - } - const nameMatches = await this.readLinearProjectsByExactNameForCreate(trimmed, team.workspaceId) - const compatibleNameMatches = await this.filterLinearProjectsForTeam( - nameMatches, - team.id, - team.workspaceId - ) - if (compatibleNameMatches.length === 1) { - return compatibleNameMatches[0] - } - if (compatibleNameMatches.length > 1) { - throw linearError( - 'linear_invalid_project', - `Multiple Linear projects exactly matched "${trimmed}".`, - { - projects: compatibleNameMatches.map((project) => ({ - id: project.id, - name: project.name, - teams: project.teams - })), - nextSteps: ['Run `orca linear project list --query --json` and retry by id.'] - } - ) - } - if (nameMatches.length > 0) { - await this.assertLinearProjectIncludesTeam(nameMatches[0], team.id, team.workspaceId, trimmed) - } - throw linearError('linear_invalid_project', `No Linear project exactly matched "${trimmed}".`, { - projects: searchCandidates.map((project) => ({ - id: project.id, - name: project.name, - teams: project.teams - })), - nextSteps: ['Run `orca linear project list --query --json` and retry by id.'] - }) - } - - private async readLinearProjectByIdForCreate( - id: string, - workspaceId: string - ): Promise { - try { - return await getLinearProject(id, workspaceId, true) - } catch (error) { - throw this.mapLinearReadFailure(error) - } - } - - private async readLinearProjectsForCreate( - query: string, - workspaceId: string - ): Promise { - try { - return (await listLinearProjects(query, LINEAR_SEARCH_MAX_LIMIT, workspaceId, true)).items - } catch (error) { - throw this.mapLinearReadFailure(error) - } - } - - private async readLinearProjectsByExactNameForCreate( - name: string, - workspaceId: string - ): Promise { - try { - return await listLinearProjectsByExactName(name, workspaceId, true) - } catch (error) { - throw this.mapLinearReadFailure(error) - } - } - - private async assertLinearProjectIncludesTeam( - project: LinearProjectSummary, - teamId: string, - workspaceId: string, - input: string - ): Promise { - if (this.linearProjectIncludesTeam(project, teamId)) { - return - } - let teams: NonNullable = [] - try { - // Why: summary reads cap project teams, so large cross-team projects need a paged membership check before rejecting a valid create. - teams = await listLinearProjectTeams(project.id, workspaceId, true) - } catch (error) { - throw this.mapLinearReadFailure(error) - } - if (teams.some((team) => team.id === teamId)) { - return - } - throw linearError( - 'linear_invalid_project', - `Linear project "${input}" is not available to the target team.`, - { - project: { id: project.id, name: project.name, teams }, - nextSteps: ['Choose a project that includes the create target team, then retry by id.'] - } - ) - } - - private async filterLinearProjectsForTeam( - projects: LinearProjectSummary[], - teamId: string, - workspaceId: string - ): Promise { - const compatible: LinearProjectSummary[] = [] - for (const project of projects) { - if (this.linearProjectIncludesTeam(project, teamId)) { - compatible.push(project) - continue - } - try { - const teams = await listLinearProjectTeams(project.id, workspaceId, true) - if (teams.some((team) => team.id === teamId)) { - compatible.push({ ...project, teams }) - } - } catch (error) { - throw this.mapLinearReadFailure(error) - } - } - return compatible - } - - private linearProjectIncludesTeam(project: LinearProjectSummary, teamId: string): boolean { - return project.teams?.some((team) => team.id === teamId) === true - } - - private async getLinearViewerForWrite( - workspaceId: string - ): Promise<{ id: string; displayName?: string | null; avatarUrl?: string | null }> { - try { - return await getLinearViewerForWorkspaceOrThrow(workspaceId) - } catch (error) { - throw this.mapLinearReadFailure(error) - } - } - - private async resolveLinearLabelsForIssue( - issue: NonNullable>>, - inputs: string[], - workspaceId: string - ): Promise<{ id: string; name: string }[]> { - const labels = await this.getLinearTeamLabelsForWrite(issue.team.id, workspaceId) - const resolved = inputs.map((input) => { - const normalized = input.toLocaleLowerCase() - const idMatch = labels.find((label) => label.id.toLocaleLowerCase() === normalized) - if (idMatch) { - return { id: idMatch.id, name: idMatch.name } - } - const nameMatches = labels.filter((label) => label.name.toLocaleLowerCase() === normalized) - if (nameMatches.length === 1) { - return { id: nameMatches[0].id, name: nameMatches[0].name } - } - throw linearError( - 'linear_invalid_label', - nameMatches.length === 0 - ? `No label exactly matched "${input}".` - : `Multiple labels exactly matched "${input}".`, - { - labels: labels.map((label) => ({ id: label.id, name: label.name })), - nextSteps: ['Run `orca linear team labels --team --json` and retry by id.'] - } - ) - }) - return Array.from(new Map(resolved.map((label) => [label.id, label])).values()) - } - - private async resolveLinearLabelsForTeam( - teamId: string, - inputs: string[], - workspaceId: string - ): Promise<{ id: string; name: string }[]> { - const labels = await this.getLinearTeamLabelsForWrite(teamId, workspaceId) - const resolved = inputs.map((input) => { - const normalized = input.toLocaleLowerCase() - const idMatch = labels.find((label) => label.id.toLocaleLowerCase() === normalized) - if (idMatch) { - return { id: idMatch.id, name: idMatch.name } - } - const nameMatches = labels.filter((label) => label.name.toLocaleLowerCase() === normalized) - if (nameMatches.length === 1) { - return { id: nameMatches[0].id, name: nameMatches[0].name } - } - throw linearError( - 'linear_invalid_label', - nameMatches.length === 0 - ? `No label exactly matched "${input}".` - : `Multiple labels exactly matched "${input}".`, - { labels: labels.map((label) => ({ id: label.id, name: label.name })) } - ) - }) - return Array.from(new Map(resolved.map((label) => [label.id, label])).values()) - } - - private linearCreatedIssueMatchesIntent( - issue: NonNullable>>, - intent: LinearCreateFieldIntent - ): boolean { - if (intent.stateId !== undefined && issue.state?.id !== intent.stateId) { - return false - } - if (intent.assigneeId !== undefined && (issue.assignee?.id ?? null) !== intent.assigneeId) { - return false - } - if (intent.priority !== undefined && issue.priority !== intent.priority) { - return false - } - if (intent.estimate !== undefined && (issue.estimate ?? null) !== intent.estimate) { - return false - } - if (intent.dueDate !== undefined && (issue.dueDate ?? null) !== intent.dueDate) { - return false - } - if (intent.projectId !== undefined && (issue.project?.id ?? null) !== intent.projectId) { - return false - } - const issueLabelIds = issue.labelIds ?? issue.labels?.map((label) => label.id) ?? [] - if (intent.labelIds !== undefined && !sameStringSet(issueLabelIds, intent.labelIds)) { - return false - } - return true - } - - private linearTaskFieldAlreadySet( - operation: LinearIssueTaskUpdateRequest['operation'], - record: NonNullable>>, - update: { - fields: { - assigneeId?: string | null - priority?: number - estimate?: number | null - dueDate?: string | null - labelIds?: string[] - } - } - ): boolean { - if (operation === 'assignee') { - return (record.assignee?.id ?? null) === update.fields.assigneeId - } - if (operation === 'priority') { - return record.priority === update.fields.priority - } - if (operation === 'estimate') { - return (record.estimate ?? null) === update.fields.estimate - } - if (operation === 'dueDate') { - return (record.dueDate ?? null) === update.fields.dueDate - } - if (operation === 'labels') { - const recordLabelIds = record.labelIds ?? record.labels?.map((label) => label.id) ?? [] - return sameStringSet(recordLabelIds, update.fields.labelIds ?? []) - } - return false - } - - private linearTaskUpdateResult( - operation: LinearIssueTaskUpdateRequest['operation'], - issue: LinearIssueSummary, - workspaceId: string, - previous: NonNullable>>, - current: NonNullable>>, - alreadySet: boolean - ): LinearIssueTaskUpdateResult { - return { - issue: this.linearWriteIssueRef(issue), - operation, - previous: this.linearTaskResultFields(previous), - current: this.linearTaskResultFields(current), - meta: { workspaceId, alreadySet } - } - } - - private linearTaskResultFields( - record: NonNullable>> - ): LinearIssueTaskUpdateResult['current'] { - return { - assignee: record.assignee ?? null, - priority: record.priority ?? null, - estimate: record.estimate ?? null, - dueDate: record.dueDate ?? null, - labels: record.labels ?? [] - } - } - - private async resolveLinearCommentParentId( - issueId: string, - commentId: string, - workspaceId: string - ): Promise { - try { - const root = await getLinearIssueCommentThreadRoot(issueId, commentId, workspaceId) - if (!root) { - throw linearError( - 'linear_invalid_parent', - 'The reply target is not a comment on this issue.', - { - nextSteps: ['Run `orca linear issue --comments --json` to list valid comment ids.'] - } - ) - } - return root.id - } catch (error) { - if (error instanceof LinearAgentAccessError) { - throw error - } - throw this.mapLinearReadFailure(error) - } - } - - private async runLinearAgentWrite( - write: (signal: AbortSignal) => Promise, - unconfirmed: (cause?: string) => LinearAgentAccessError - ): Promise { - const controller = new AbortController() - const writePromise = write(controller.signal) - writePromise.catch(() => undefined) - let timer: ReturnType | null = null - try { - return await Promise.race([ - writePromise, - new Promise((_resolve, reject) => { - timer = setTimeout(() => { - controller.abort() - reject( - new LinearWriteFailure( - 'unconfirmed', - 'Linear write deadline elapsed before confirmation.' - ) - ) - }, 25_000) - }) - ]) - } catch (error) { - if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { - throw error - } - if (error instanceof LinearWriteFailure && error.kind === 'unconfirmed') { - throw unconfirmed(this.linearWriteFailureCauseMessage(error)) - } - if (error instanceof LinearWriteFailure && error.kind === 'network') { - throw linearError('linear_network_error', sanitizeLinearErrorMessage(error.message)) - } - if (error instanceof LinearWriteFailure) { - throw linearError('linear_write_failed', sanitizeLinearErrorMessage(error.message)) - } - throw this.mapLinearReadFailure(error) - } finally { - if (timer) { - clearTimeout(timer) - } - } - } - - private linearWriteFailureCauseMessage(error: LinearWriteFailure): string { - if (error.cause instanceof Error) { - return sanitizeLinearErrorMessage(error.cause.message) - } - if (error.cause !== undefined) { - return sanitizeLinearErrorMessage(String(error.cause)) - } - return sanitizeLinearErrorMessage(error.message) - } - - private mapLinearReadFailure(error: unknown): LinearAgentAccessError { - if (error instanceof LinearAgentAccessError) { - return error - } - if (isLinearAuthError(error)) { - return linearError('linear_auth_expired', 'Linear authentication expired.', { - nextSteps: ['Reconnect Linear from Orca settings.'] - }) - } - return linearError(classifyLinearError(error), linearMessage(error)) - } - - private async getMatchingLinearCommentWrite( - writeId: string, - issueId: string, - parentId: string | null, - workspaceId: string, - required: boolean - ): Promise> | null> { - const comment = await this.readLinearWriteLookup(() => - getLinearCommentByUuidForAgent(writeId, workspaceId) - ) - if (!comment) { - return null - } - if (comment.issue.id === issueId && comment.parentId === parentId) { - return comment - } - if (required) { - throw linearError( - 'linear_invalid_write_id', - 'The write id belongs to a different comment target.' - ) - } - return null - } - - private async getMatchingLinearAttachmentWrite( - writeId: string, - issueId: string, - workspaceId: string, - required: boolean - ): Promise> | null> { - const attachment = await this.readLinearWriteLookup(() => - getLinearAttachmentByUuidForAgent(writeId, workspaceId) - ) - if (!attachment) { - return null - } - if (attachment.issue.id === issueId) { - return attachment - } - if (required) { - throw linearError( - 'linear_invalid_write_id', - 'The write id belongs to a different attachment target.' - ) - } - return null - } - - private async getMatchingLinearCreatedIssue( - writeId: string, - teamId: string, - parentId: string | null, - workspaceId: string, - required: boolean, - intent: LinearCreateFieldIntent = {} - ): Promise> | null> { - const issue = await this.readLinearWriteLookup(() => - getLinearIssueByUuidForAgent(writeId, workspaceId) - ) - if (!issue) { - return null - } - if ( - issue.team.id === teamId && - (issue.parent?.id ?? null) === parentId && - this.linearCreatedIssueMatchesIntent(issue, intent) - ) { - return issue - } - if (required) { - throw linearError( - 'linear_invalid_write_id', - 'The write id belongs to a different issue target.' - ) - } - return null - } - - private async refetchLinearCommentAfterDuplicate( - writeId: string, - issueId: string, - parentId: string | null, - workspaceId: string, - unconfirmed: (cause?: string) => LinearAgentAccessError - ): Promise>>> { - try { - // Why: a duplicate-id response can mean the original write landed; only the exact target relationship proves this pinned retry. - const comment = await this.getMatchingLinearCommentWrite( - writeId, - issueId, - parentId, - workspaceId, - true - ) - if (comment) { - return comment - } - } catch (error) { - if (error instanceof LinearAgentAccessError && error.code === 'linear_invalid_write_id') { - throw error - } - throw unconfirmed( - error instanceof Error - ? sanitizeLinearErrorMessage(error.message) - : sanitizeLinearErrorMessage(String(error)) - ) - } - throw unconfirmed() - } - - private async refetchLinearAttachmentAfterDuplicate( - writeId: string, - issueId: string, - workspaceId: string, - unconfirmed: (cause?: string) => LinearAgentAccessError - ): Promise>>> { - try { - // Why: a duplicate-id response can mean the original write landed; only the exact target relationship proves this pinned retry. - const attachment = await this.getMatchingLinearAttachmentWrite( - writeId, - issueId, - workspaceId, - true - ) - if (attachment) { - return attachment - } - } catch (error) { - if (error instanceof LinearAgentAccessError && error.code === 'linear_invalid_write_id') { - throw error - } - throw unconfirmed( - error instanceof Error - ? sanitizeLinearErrorMessage(error.message) - : sanitizeLinearErrorMessage(String(error)) - ) - } - throw unconfirmed() - } - - private async refetchLinearIssueAfterDuplicate( - writeId: string, - teamId: string, - parentId: string | null, - workspaceId: string, - intent: LinearCreateFieldIntent, - unconfirmed: (cause?: string) => LinearAgentAccessError - ): Promise>>> { - try { - // Why: a duplicate-id response can mean the original write landed; only the exact target relationship proves this pinned retry. - const issue = await this.getMatchingLinearCreatedIssue( - writeId, - teamId, - parentId, - workspaceId, - true, - intent - ) - if (issue) { - return issue - } - } catch (error) { - if (error instanceof LinearAgentAccessError && error.code === 'linear_invalid_write_id') { - throw error - } - throw unconfirmed( - error instanceof Error - ? sanitizeLinearErrorMessage(error.message) - : sanitizeLinearErrorMessage(String(error)) - ) - } - throw unconfirmed() - } - - private async readLinearWriteLookup(lookup: () => Promise): Promise { - try { - return await lookup() - } catch (error) { - throw this.mapLinearReadFailure(error) - } - } - - private parseLinearAttachmentUrl(value: string): URL { - try { - const url = new URL(value) - if (url.protocol === 'http:' || url.protocol === 'https:') { - return url - } - } catch { - // Fall through to the stable agent-facing error below. - } - throw linearError('linear_invalid_url', 'Attachment URL must be an absolute http(s) URL.') - } - - private defaultLinearAttachmentTitle(url: URL): string { - const tail = url.pathname.split('/').findLast(Boolean) - return tail ? `${url.host}/${tail}` : url.host - } - - private linearWorkspaceErrorCode(type: string): LinearErrorCode { - if (type === 'auth') { - return 'linear_auth_expired' - } - if (type === 'network') { - return 'linear_network_error' - } - if (type === 'rate_limited') { - return 'linear_rate_limited' - } - return 'linear_write_failed' - } - - private linearTeamSummary(team: { - id: string - name: string - key: string - url?: string - workspaceId?: string - workspaceName?: string - }): { - id: string - name: string - key: string - url?: string - workspace?: { id: string; name: string } - } { - return { - id: team.id, - name: team.name, - key: team.key, - ...(team.url ? { url: team.url } : {}), - ...(team.workspaceId - ? { workspace: { id: team.workspaceId, name: team.workspaceName ?? team.workspaceId } } - : {}) - } - } - - private async resolveLinearTeamInput( - teamInput: string, - workspaceId?: (string & {}) | 'all' - ): Promise<{ - id: string - key: string - name: string - workspaceId: string - workspaceName?: string - }> { - this.validateLinearCreateWorkspaceScope(workspaceId === 'all' ? undefined : workspaceId) - let teams: Awaited> - try { - teams = await listLinearTeamsOrThrow(workspaceId ?? 'all') - } catch (error) { - throw this.mapLinearReadFailure(error) - } - const normalized = teamInput.toLocaleLowerCase() - const idMatches = teams.filter((team) => team.id.toLocaleLowerCase() === normalized) - const matches = - idMatches.length > 0 - ? idMatches - : teams.filter((team) => team.key.toLocaleLowerCase() === normalized) - if (matches.length === 1 && matches[0].workspaceId) { - return { - id: matches[0].id, - key: matches[0].key, - name: matches[0].name, - workspaceId: matches[0].workspaceId, - workspaceName: matches[0].workspaceName - } - } - if (matches.length > 1) { - throw linearError( - 'linear_workspace_ambiguous', - `Team ${teamInput} exists in multiple workspaces.`, - { - candidates: matches.map((team) => ({ - workspaceId: team.workspaceId, - workspaceName: team.workspaceName, - teamId: team.id, - teamKey: team.key - })) - } - ) - } - throw linearError('linear_team_required', `No connected Linear team matched ${teamInput}.`) - } - - private async resolveLinearCreateTeam( - teamInput: string | undefined, - workspaceId: string | undefined, - parent: LinearAgentWriteTarget | null - ): Promise<{ id: string; key: string; name: string; workspaceId: string }> { - if (!teamInput && parent?.issue.team?.id && parent.issue.team.key && parent.issue.team.name) { - return { - id: parent.issue.team.id, - key: parent.issue.team.key, - name: parent.issue.team.name, - workspaceId: parent.workspaceId - } - } - if (!teamInput) { - throw linearError('linear_team_required', 'Pass --team or create under a parent issue.', { - nextSteps: ['Run `orca linear create --team ...` or use --parent-current.'] - }) - } - - const scope = parent?.workspaceId ?? workspaceId - this.validateLinearCreateWorkspaceScope(scope) - let teams: Awaited> - try { - teams = await listLinearTeamsOrThrow(scope ?? 'all') - } catch (error) { - throw this.mapLinearReadFailure(error) - } - if (teams.length === 0 && (getLinearStatus().workspaces?.length ?? 0) === 0) { - throw linearError('linear_not_connected', 'Linear is not connected.', { - nextSteps: ['Connect Linear from Orca settings, then retry the issue create.'] - }) - } - const matches = teams.filter( - (team) => - team.id.toLocaleLowerCase() === teamInput.toLocaleLowerCase() || - team.key.toLocaleLowerCase() === teamInput.toLocaleLowerCase() - ) - if (matches.length === 1 && matches[0].workspaceId) { - return { - id: matches[0].id, - key: matches[0].key, - name: matches[0].name, - workspaceId: matches[0].workspaceId - } - } - if (matches.length > 1) { - throw linearError( - 'linear_workspace_ambiguous', - `Team ${teamInput} exists in multiple workspaces.`, - { - candidates: matches.map((team) => ({ - workspaceId: team.workspaceId, - workspaceName: team.workspaceName, - teamKey: team.key - })) - } - ) - } - if (parent) { - let globalTeams: Awaited> - try { - globalTeams = await listLinearTeamsOrThrow('all') - } catch (error) { - throw this.mapLinearReadFailure(error) - } - const globalMatch = globalTeams.find( - (team) => - team.id.toLocaleLowerCase() === teamInput.toLocaleLowerCase() || - team.key.toLocaleLowerCase() === teamInput.toLocaleLowerCase() - ) - if (globalMatch) { - throw linearError( - 'linear_invalid_workspace', - `Team ${teamInput} is not in the parent issue workspace.` - ) - } - } - throw linearError('linear_team_required', `No connected Linear team matched ${teamInput}.`) - } - - private validateLinearCreateWorkspaceScope(workspaceId: string | undefined): void { - if (!workspaceId) { - return - } - const workspaces = getLinearStatus().workspaces ?? [] - if (workspaces.length > 0 && !workspaces.some((workspace) => workspace.id === workspaceId)) { - throw linearError( - 'linear_invalid_workspace', - `No connected Linear workspace matched ${workspaceId}.` - ) - } - } - - private linearWriteIssueRef(issue: { id: string; identifier: string; url: string }): { - id: string - identifier: string - url: string - } { - return { id: issue.id, identifier: issue.identifier, url: issue.url } - } - - private linearCommentResult( - comment: NonNullable>>, - target: LinearAgentWriteTarget, - bodyChars: number, - writeId: string, - deduplicated: boolean - ): LinearCommentAddResult { - return { - comment: { id: comment.id, url: comment.url, parentId: comment.parentId }, - issue: this.linearWriteIssueRef(target.issue), - meta: { workspaceId: target.workspaceId, bodyChars, writeId, deduplicated } - } - } - - private linearAttachResult( - attachment: NonNullable>>, - target: LinearAgentWriteTarget, - writeId: string, - deduplicated: boolean - ): LinearAttachResult { - return { - attachment: { id: attachment.id, title: attachment.title, url: attachment.url }, - issue: this.linearWriteIssueRef(target.issue), - meta: { workspaceId: target.workspaceId, writeId, deduplicated } - } - } - - private linearCreateResult( - issue: NonNullable>>, - workspaceId: string, - writeId: string, - deduplicated: boolean - ): LinearCreateResult { - return { - issue, - meta: { workspaceId, writeId, deduplicated } - } - } - - private linearCreateFieldRetryTokens(fields: LinearCreateFieldIntent | undefined): string[] { - if (!fields) { - return [] - } - return [ - ...(fields.stateId ? [`--state=${this.commandToken(fields.stateId, 'STATE_ID')}`] : []), - ...(fields.assigneeId - ? [`--assignee=${this.commandToken(fields.assigneeId, 'ASSIGNEE_ID')}`] - : []), - ...(fields.priority !== undefined - ? [`--priority=${this.linearPriorityRetryToken(fields.priority)}`] - : []), - ...(fields.estimate !== undefined && fields.estimate !== null - ? [`--estimate=${fields.estimate}`] - : []), - ...(fields.dueDate ? [`--due-date=${fields.dueDate}`] : []), - ...(fields.projectId - ? [`--project=${this.commandToken(fields.projectId, 'PROJECT_ID')}`] - : []), - ...(fields.labelIds ?? []).map( - (labelId) => `--label=${this.commandToken(labelId, 'LABEL_ID')}` - ) - ] - } - - private linearPriorityRetryToken(priority: number): string { - if (priority === 1) { - return 'urgent' - } - if (priority === 2) { - return 'high' - } - if (priority === 3) { - return 'medium' - } - if (priority === 4) { - return 'low' - } - return 'none' - } - - private linearCreateStyleUnconfirmed( - verb: 'comment' | 'attach' | 'create', - writeId: string, - target: LinearAgentWriteTarget | null, - extra: { - parentId?: string | null - team?: { id: string; key: string; name: string; workspaceId: string } - parent?: LinearAgentWriteTarget | null - title?: string - url?: string - bodyRequired?: boolean - createFields?: LinearCreateFieldIntent - cause?: string - } = {} - ): LinearAgentAccessError { - const workspaceId = target?.workspaceId ?? extra.team?.workspaceId ?? '' - // Why: the retry preserves id and target so duplicate recovery can prove intent without matching mutable content. - const pinned = - verb === 'create' - ? [ - 'orca linear create', - `--workspace=${this.commandToken(workspaceId, 'WORKSPACE_ID')}`, - `--write-id=${this.commandToken(writeId, 'WRITE_ID')}`, - '--title TITLE_HERE', - ...(extra.bodyRequired ? ['--body-file -'] : []), - ...(extra.parent - ? [`--parent=${this.commandToken(extra.parent.issue.identifier, 'PARENT_ISSUE')}`] - : []), - ...(extra.team - ? [`--team=${this.commandToken(extra.team.key, 'TEAM_KEY')}`] - : [] - ).concat(this.linearCreateFieldRetryTokens(extra.createFields)) - ].join(' ') - : [ - `orca linear ${verb === 'attach' ? 'attach' : 'comment add'}`, - this.commandToken(target?.issue.identifier ?? '', 'ISSUE_ID'), - `--workspace=${this.commandToken(workspaceId, 'WORKSPACE_ID')}`, - `--write-id=${this.commandToken(writeId, 'WRITE_ID')}`, - ...(verb === 'comment' ? ['--body-file -'] : []), - ...(verb === 'comment' && extra.parentId - ? [`--reply-to=${this.commandToken(extra.parentId, 'COMMENT_ID')}`] - : []), - ...(verb === 'attach' ? ['--url URL_HERE', '--title TITLE_HERE'] : []) - ].join(' ') - const retryPrefix = extra.bodyRequired || verb === 'comment' ? 'Pipe the same body and r' : 'R' - const payloadNote = - verb === 'attach' - ? ' Replace TITLE_HERE/URL_HERE with the exact original payload values before running.' - : verb === 'create' - ? ' Replace TITLE_HERE with the exact original title before running.' - : '' - return linearError( - 'linear_write_unconfirmed', - 'Linear may have applied the write, but Orca could not confirm it.', - { - writeId, - workspaceId, - issueIdentifier: target?.issue.identifier, - parentId: extra.parentId, - team: extra.team ? { id: extra.team.id, key: extra.team.key } : undefined, - parentIdentifier: extra.parent?.issue.identifier, - createFields: extra.createFields, - nextSteps: [ - `${retryPrefix}etry once with the pinned command: \`${pinned}\`.${payloadNote}` - ], - ...(extra.cause ? { cause: sanitizeLinearErrorMessage(extra.cause) } : {}) - } - ) - } - - private commandToken(value: string, placeholder: string): string { - return /^[A-Za-z0-9._:@%+=,/-]+$/.test(value) ? value : placeholder - } - - private async notifyLinearLinkedIssueUpdated( - workspaceId: string, - identifier: string | readonly string[] - ): Promise { - const identifiers = typeof identifier === 'string' ? [identifier] : identifier - const normalized = new Map( - identifiers.map((value) => [value.toLocaleUpperCase(), value] as const) - ) - for (const worktree of await this.listResolvedWorktrees()) { - const linkedIdentifier = normalized.get( - (worktree.linkedLinearIssue ?? '').toLocaleUpperCase() - ) - if (!linkedIdentifier) { - continue - } - const linkedWorkspaceId = worktree.linkedLinearIssueWorkspaceId ?? workspaceId - if (linkedWorkspaceId !== workspaceId) { - continue - } - this.emitClientEvent({ - type: 'linearLinkedIssueUpdated', - worktreeId: worktree.id, - identifier: linkedIdentifier, - workspaceId - }) - } - } - - linearIssueComments( - issueId: string, - workspaceId?: string - ): ReturnType { - return getLinearIssueComments(issueId, workspaceId) - } - - linearListTeams(workspaceId?: LinearWorkspaceSelection): ReturnType { - return listLinearTeams(workspaceId) - } - - linearListProjects( - query?: string, - limit = 20, - workspaceId?: LinearWorkspaceSelection, - force?: boolean - ): ReturnType { - return listLinearProjects(query, Math.min(Math.max(1, limit), 50), workspaceId, force) - } - - linearCreateProject( - input: LinearProjectCreateInput, - workspaceId?: string - ): ReturnType { - return createLinearProject(input, workspaceId) - } - - linearGetProject( - id: string, - workspaceId: string, - force?: boolean - ): ReturnType { - return getLinearProject(id, workspaceId, force) - } - - linearListProjectIssues( - projectId: string, - limit = 20, - workspaceId: string, - force?: boolean - ): ReturnType { - return listLinearProjectIssues(projectId, clampLinearIssueListLimit(limit), workspaceId, force) - } - - linearListCustomViews( - model: LinearCustomViewModel, - limit = 20, - workspaceId?: LinearWorkspaceSelection, - force?: boolean - ): ReturnType { - return listLinearCustomViews(model, Math.min(Math.max(1, limit), 50), workspaceId, force) - } - - linearGetCustomView( - viewId: string, - model: LinearCustomViewModel, - workspaceId: string, - force?: boolean - ): ReturnType { - return getLinearCustomView(viewId, model, workspaceId, force) - } - - linearListCustomViewIssues( - viewId: string, - limit = 20, - workspaceId: string, - force?: boolean - ): ReturnType { - return listLinearCustomViewIssues(viewId, clampLinearIssueListLimit(limit), workspaceId, force) - } - - linearListCustomViewProjects( - viewId: string, - limit = 20, - workspaceId: string, - force?: boolean - ): ReturnType { - return listLinearCustomViewProjects( - viewId, - Math.min(Math.max(1, limit), 50), - workspaceId, - force - ) - } - - linearTeamStates(teamId: string, workspaceId?: string): ReturnType { - return getLinearTeamStates(teamId, workspaceId) - } - - linearTeamLabels(teamId: string, workspaceId?: string): ReturnType { - return getLinearTeamLabels(teamId, workspaceId) - } - - linearTeamMembers(teamId: string, workspaceId?: string): ReturnType { - return getLinearTeamMembers(teamId, workspaceId) - } - - // ── Jira integration ── - - jiraConnect(args: JiraConnectArgs): ReturnType { - return connectJira(args) - } - - jiraDisconnect(siteId?: string): { ok: true } { - disconnectJira(siteId) - return { ok: true } - } - - jiraSelectSite(siteId: JiraSiteSelection): ReturnType { - return selectJiraSite(siteId) - } - - jiraStatus(): ReturnType { - return getJiraStatus() - } - - jiraReadStatus(): ReturnType { - return getJiraStatus() - } - - jiraTestConnection(siteId?: string): ReturnType { - return testJiraConnection(siteId) - } - - jiraSearchIssues( - jql: string, - limit = 30, - siteId?: JiraSiteSelection, - signal?: AbortSignal - ): ReturnType { - return searchJiraIssues(jql, Math.min(Math.max(1, limit), 100), siteId, signal) - } - - jiraListIssues( - filter?: JiraIssueFilter, - limit = 30, - siteId?: JiraSiteSelection - ): ReturnType { - return listJiraIssues(filter, Math.min(Math.max(1, limit), 100), siteId) - } - - jiraCreateIssue(args: JiraCreateIssueArgs): ReturnType { - return createJiraIssue(args) - } - - jiraGetIssue(key: string, siteId?: string): ReturnType { - return getJiraIssue(key, siteId) - } - - jiraLookupIssueSummary( - key: string, - siteId: string, - signal?: AbortSignal - ): ReturnType { - return getJiraIssueSummary(key, siteId, signal) - } - - jiraUpdateIssue( - key: string, - updates: JiraIssueUpdate, - siteId?: string - ): ReturnType { - return updateJiraIssue(key, updates, siteId) - } - - jiraAddIssueComment( - key: string, - body: string, - siteId?: string - ): ReturnType { - return addJiraIssueComment(key, body, siteId) - } - - jiraIssueComments(key: string, siteId?: string): ReturnType { - return getJiraIssueComments(key, siteId) - } - - jiraListProjects(siteId?: JiraSiteSelection): ReturnType { - return listJiraProjects(siteId) - } - - jiraListIssueTypes( - projectIdOrKey: string, - siteId?: string - ): ReturnType { - return listJiraIssueTypes(projectIdOrKey, siteId) - } - - jiraListCreateFields( - projectIdOrKey: string, - issueTypeId: string, - siteId?: string - ): ReturnType { - return listJiraCreateFields(projectIdOrKey, issueTypeId, siteId) - } - - jiraListPriorities(siteId?: string): ReturnType { - return listJiraPriorities(siteId) - } - - jiraListAssignableUsers( - key: string, - query?: string, - siteId?: string - ): ReturnType { - return listJiraAssignableUsers(key, query, siteId) - } - - /** Searches all users on the site, for reporter and user-picker create fields. */ - jiraSearchUsers(query?: string, siteId?: string): ReturnType { - return searchJiraUsers(query, siteId) - } - - jiraListTransitions(key: string, siteId?: string): ReturnType { - return listJiraTransitions(key, siteId) - } - - jiraGetProjectStatusOrder( - projectKey: string, - siteId?: string - ): ReturnType { - return getJiraProjectStatusOrder(projectKey, siteId) - } - - // ── Browser automation ── - - routeClientHostedBrowserRpc( - method: string, - params: unknown - ): Promise { - return routeRuntimeBrowserClientAutomation({ - method, - params, - pages: getRuntimeBrowserPageRegistry(this), - leases: getBrowserHostLeaseRegistry(this), - resolveWorkspace: (selector) => this.resolveBrowserWorkspace(selector) - }) - } - - private readonly browserCommands = createRuntimeBrowserCommands({ - getAgentBrowserBridge: () => this.agentBrowserBridge, - resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), - resolveBrowserWorkspace: (selector) => this.resolveBrowserWorkspace(selector), - getBrowserHostLeaseRegistry: () => getBrowserHostLeaseRegistry(this), - getRuntimeBrowserPageRegistry: () => getRuntimeBrowserPageRegistry(this), - resolveBrowserNetworkExecutionHost: (worktree) => - this.resolveBrowserNetworkExecutionHostForWorktree(worktree), - getAuthoritativeWindow: () => this.getAuthoritativeWindow(), - getAvailableAuthoritativeWindow: () => this.getAvailableAuthoritativeWindow(), - getOffscreenBrowserBackend: () => this.offscreenBrowserBackend, - // Why: bind directly, not a wrapper arrow — a hand-listed wrapper dropped targetGroupId, so a right-split browser landed in the left. - markHeadlessBrowserSessionTabActive: this.markHeadlessBrowserSessionTabActive.bind(this), - notifyHeadlessBrowserSessionTabsChanged: (worktreeId) => - this.notifyMobileSessionTabsChanged(worktreeId), - retireRuntimeOwnedBrowserSessionTab: (worktreeId, browserPageId) => - this.retireRuntimeOwnedBrowserSessionTab(worktreeId, browserPageId) - }) - - private readonly emulatorCommands = new RuntimeEmulatorCommands({ - getEmulatorBridge: () => this.emulatorBridge, - resolveEmulatorWorkspaceId: (selector) => this.resolveEmulatorWorkspaceId(selector), - resolveEmulatorCleanupWorkspaceId: (selector) => - this.resolveEmulatorCleanupWorkspaceId(selector), - getAuthoritativeWindow: () => this.getAuthoritativeWindow(), - getSettings: () => this.requireStore().getSettings() - }) - - browserSnapshot: RuntimeBrowserCommands['browserSnapshot'] = - this.browserCommands.browserSnapshot.bind(this.browserCommands) - - browserClick: RuntimeBrowserCommands['browserClick'] = this.browserCommands.browserClick.bind( - this.browserCommands - ) - - browserGoto: RuntimeBrowserCommands['browserGoto'] = this.browserCommands.browserGoto.bind( - this.browserCommands - ) - - browserFill: RuntimeBrowserCommands['browserFill'] = this.browserCommands.browserFill.bind( - this.browserCommands - ) - - browserType: RuntimeBrowserCommands['browserType'] = this.browserCommands.browserType.bind( - this.browserCommands - ) - - browserSelect: RuntimeBrowserCommands['browserSelect'] = this.browserCommands.browserSelect.bind( - this.browserCommands - ) - - browserScroll: RuntimeBrowserCommands['browserScroll'] = this.browserCommands.browserScroll.bind( - this.browserCommands - ) - - browserBack: RuntimeBrowserCommands['browserBack'] = this.browserCommands.browserBack.bind( - this.browserCommands - ) - - browserReload: RuntimeBrowserCommands['browserReload'] = this.browserCommands.browserReload.bind( - this.browserCommands - ) - - browserScreenshot: RuntimeBrowserCommands['browserScreenshot'] = - this.browserCommands.browserScreenshot.bind(this.browserCommands) - - async browserScreencast( - params: Parameters[0], - options: { - connectionId?: string - pairedDeviceId?: string - clientKind?: 'mobile' | 'runtime' - sendBinary?: (bytes: Uint8Array) => boolean | void - signal?: AbortSignal - emit: (result: BrowserScreencastResult) => void - } - ): Promise { - if (!options.sendBinary) { - throw new BrowserError( - 'browser_error', - 'Browser screencast requires a binary streaming transport.' - ) - } - - const connectionKey = options.connectionId ?? 'local' - const drivesAsMobile = screencastSubscriberDrivesAsMobile(options.clientKind) - let existingStream = this.activeBrowserScreencastsByConnection.get(connectionKey) - while (existingStream) { - existingStream.cancel() - await existingStream.done - existingStream = this.activeBrowserScreencastsByConnection.get(connectionKey) - } - if (options.signal?.aborted) { - throw new BrowserError('browser_error', 'Browser screencast was cancelled.') - } - - let screencast: Awaited> | null = null - let registeredSubscriptionId: string | null = null - let activeBrowserPageId: string | null = null - let activePageStream: BrowserScreencastSubscriber | null = null - let ended = false - let cancelledBeforeStart = false - let readyEmitted = false - let resolveActiveDone!: () => void - const activeDone = new Promise((resolve) => { - resolveActiveDone = resolve - }) - const end = (emitEnd: boolean): void => { - if (ended) { - return - } - ended = true - screencast?.session.stop() - if (emitEnd && screencast) { - options.emit({ type: 'end', subscriptionId: screencast.subscriptionId }) - } - } - const cancel = (emitEnd = false): void => { - if (!screencast) { - cancelledBeforeStart = true - return - } - end(emitEnd) - } - const abortScreencast = (): void => cancel() - const sendBinaryAfterReady = (bytes: Uint8Array): boolean | void => { - if (!readyEmitted) { - // Why: clients learn the owning subscription from ready, so CDP frames must stay unacked until the JSON ready event is delivered. - return false - } - return options.sendBinary?.(bytes) - } - - // Why: a phone can rotate before the first stream reaches ready (no subscriptionId yet), so a same-socket replacement cancels and waits here instead of racing. - this.activeBrowserScreencastsByConnection.set(connectionKey, { - cancel, - done: activeDone, - connectionKey - }) - options.signal?.addEventListener('abort', abortScreencast, { once: true }) - try { - screencast = await this.browserCommands.browserScreencast(params, { - sendBinary: sendBinaryAfterReady, - emit: options.emit, - pairedDeviceId: options.pairedDeviceId - }) - if (cancelledBeforeStart || options.signal?.aborted) { - end(false) - await screencast.session.done - return - } - activeBrowserPageId = screencast.ready.browserPageId - activePageStream = { - cancel, - done: activeDone, - connectionKey, - drivesAsMobile - } - const pageStreams = - this.activeBrowserScreencastsByPage.get(activeBrowserPageId) ?? - new Set() - pageStreams.add(activePageStream) - this.activeBrowserScreencastsByPage.set(activeBrowserPageId, pageStreams) - this.publishBrowserRemoteViewers(activeBrowserPageId) - if (drivesAsMobile) { - this.setBrowserDriver(activeBrowserPageId, { kind: 'mobile', clientId: connectionKey }) - } - - // Why: screencast frames are connection-scoped; tie Page.stopScreencast to the exact socket so dropped connections don't leave Chromium streaming. - this.registerSubscriptionCleanup( - screencast.subscriptionId, - () => end(true), - options.connectionId - ) - registeredSubscriptionId = screencast.subscriptionId - options.emit(screencast.ready) - readyEmitted = true - // Why: a joining subscriber's viewport snapshot is captured before this gate opens, and - // on a static page Chromium emits nothing after it — without the replay the pane stays blank. - screencast.flushPendingFrame() - await screencast.session.done - end(true) - this.cleanupSubscription(screencast.subscriptionId) - } finally { - options.signal?.removeEventListener('abort', abortScreencast) - if (!ended) { - end(false) - } - if (registeredSubscriptionId) { - this.cleanupSubscription(registeredSubscriptionId) - } - const active = this.activeBrowserScreencastsByConnection.get(connectionKey) - if (active?.done === activeDone) { - this.activeBrowserScreencastsByConnection.delete(connectionKey) - } - if (activeBrowserPageId) { - const pageStreams = this.activeBrowserScreencastsByPage.get(activeBrowserPageId) - if (activePageStream && pageStreams) { - pageStreams.delete(activePageStream) - if (pageStreams.size === 0) { - this.activeBrowserScreencastsByPage.delete(activeBrowserPageId) - } - } - this.publishBrowserRemoteViewers(activeBrowserPageId) - const driver = this.getBrowserDriver(activeBrowserPageId) - if (driver.kind === 'mobile' && driver.clientId === connectionKey) { - this.setBrowserDriver( - activeBrowserPageId, - resolveBrowserDriverAfterMobileRelease(pageStreams ?? []) - ) - } - } - resolveActiveDone() - } - } - - browserEval: RuntimeBrowserCommands['browserEval'] = this.browserCommands.browserEval.bind( - this.browserCommands - ) - - browserTabList: RuntimeBrowserCommands['browserTabList'] = - this.browserCommands.browserTabList.bind(this.browserCommands) - browserProceedCertificate: RuntimeBrowserCommands['browserProceedCertificate'] = - this.browserCommands.browserProceedCertificate.bind(this.browserCommands) - - browserTabShow: RuntimeBrowserCommands['browserTabShow'] = - this.browserCommands.browserTabShow.bind(this.browserCommands) - - browserTabCurrent: RuntimeBrowserCommands['browserTabCurrent'] = - this.browserCommands.browserTabCurrent.bind(this.browserCommands) - - browserTabSwitch: RuntimeBrowserCommands['browserTabSwitch'] = - this.browserCommands.browserTabSwitch.bind(this.browserCommands) - - browserHover: RuntimeBrowserCommands['browserHover'] = this.browserCommands.browserHover.bind( - this.browserCommands - ) - - browserDrag: RuntimeBrowserCommands['browserDrag'] = this.browserCommands.browserDrag.bind( - this.browserCommands - ) - - browserUpload: RuntimeBrowserCommands['browserUpload'] = this.browserCommands.browserUpload.bind( - this.browserCommands - ) - - browserWait: RuntimeBrowserCommands['browserWait'] = this.browserCommands.browserWait.bind( - this.browserCommands - ) - - browserCheck: RuntimeBrowserCommands['browserCheck'] = this.browserCommands.browserCheck.bind( - this.browserCommands - ) - - browserFocus: RuntimeBrowserCommands['browserFocus'] = this.browserCommands.browserFocus.bind( - this.browserCommands - ) - - browserClear: RuntimeBrowserCommands['browserClear'] = this.browserCommands.browserClear.bind( - this.browserCommands - ) - - browserSelectAll: RuntimeBrowserCommands['browserSelectAll'] = - this.browserCommands.browserSelectAll.bind(this.browserCommands) - - browserKeypress: RuntimeBrowserCommands['browserKeypress'] = - this.browserCommands.browserKeypress.bind(this.browserCommands) - - browserPdf: RuntimeBrowserCommands['browserPdf'] = this.browserCommands.browserPdf.bind( - this.browserCommands - ) - - browserFullScreenshot: RuntimeBrowserCommands['browserFullScreenshot'] = - this.browserCommands.browserFullScreenshot.bind(this.browserCommands) - - browserCookieGet: RuntimeBrowserCommands['browserCookieGet'] = - this.browserCommands.browserCookieGet.bind(this.browserCommands) - - browserCookieSet: RuntimeBrowserCommands['browserCookieSet'] = - this.browserCommands.browserCookieSet.bind(this.browserCommands) - - browserCookieDelete: RuntimeBrowserCommands['browserCookieDelete'] = - this.browserCommands.browserCookieDelete.bind(this.browserCommands) - - browserSetViewport: RuntimeBrowserCommands['browserSetViewport'] = - this.browserCommands.browserSetViewport.bind(this.browserCommands) - - browserSetGeolocation: RuntimeBrowserCommands['browserSetGeolocation'] = - this.browserCommands.browserSetGeolocation.bind(this.browserCommands) - - browserInterceptEnable: RuntimeBrowserCommands['browserInterceptEnable'] = - this.browserCommands.browserInterceptEnable.bind(this.browserCommands) - - browserInterceptDisable: RuntimeBrowserCommands['browserInterceptDisable'] = - this.browserCommands.browserInterceptDisable.bind(this.browserCommands) - - browserInterceptList: RuntimeBrowserCommands['browserInterceptList'] = - this.browserCommands.browserInterceptList.bind(this.browserCommands) - - browserCaptureStart: RuntimeBrowserCommands['browserCaptureStart'] = - this.browserCommands.browserCaptureStart.bind(this.browserCommands) - - browserCaptureStop: RuntimeBrowserCommands['browserCaptureStop'] = - this.browserCommands.browserCaptureStop.bind(this.browserCommands) - - browserConsoleLog: RuntimeBrowserCommands['browserConsoleLog'] = - this.browserCommands.browserConsoleLog.bind(this.browserCommands) - - browserNetworkLog: RuntimeBrowserCommands['browserNetworkLog'] = - this.browserCommands.browserNetworkLog.bind(this.browserCommands) - - browserDblclick: RuntimeBrowserCommands['browserDblclick'] = - this.browserCommands.browserDblclick.bind(this.browserCommands) - - browserForward: RuntimeBrowserCommands['browserForward'] = - this.browserCommands.browserForward.bind(this.browserCommands) - - browserScrollIntoView: RuntimeBrowserCommands['browserScrollIntoView'] = - this.browserCommands.browserScrollIntoView.bind(this.browserCommands) - - browserGet: RuntimeBrowserCommands['browserGet'] = this.browserCommands.browserGet.bind( - this.browserCommands - ) - - browserIs: RuntimeBrowserCommands['browserIs'] = this.browserCommands.browserIs.bind( - this.browserCommands - ) - - browserKeyboardInsertText: RuntimeBrowserCommands['browserKeyboardInsertText'] = - this.browserCommands.browserKeyboardInsertText.bind(this.browserCommands) - - browserMouseMove: RuntimeBrowserCommands['browserMouseMove'] = - this.browserCommands.browserMouseMove.bind(this.browserCommands) - - browserMouseDown: RuntimeBrowserCommands['browserMouseDown'] = - this.browserCommands.browserMouseDown.bind(this.browserCommands) - - browserMouseClick: RuntimeBrowserCommands['browserMouseClick'] = - this.browserCommands.browserMouseClick.bind(this.browserCommands) - - browserMouseUp: RuntimeBrowserCommands['browserMouseUp'] = - this.browserCommands.browserMouseUp.bind(this.browserCommands) - - browserMouseWheel: RuntimeBrowserCommands['browserMouseWheel'] = - this.browserCommands.browserMouseWheel.bind(this.browserCommands) - - browserFind: RuntimeBrowserCommands['browserFind'] = this.browserCommands.browserFind.bind( - this.browserCommands - ) - - browserSetDevice: RuntimeBrowserCommands['browserSetDevice'] = - this.browserCommands.browserSetDevice.bind(this.browserCommands) - - browserSetOffline: RuntimeBrowserCommands['browserSetOffline'] = - this.browserCommands.browserSetOffline.bind(this.browserCommands) - - browserSetHeaders: RuntimeBrowserCommands['browserSetHeaders'] = - this.browserCommands.browserSetHeaders.bind(this.browserCommands) - - browserSetCredentials: RuntimeBrowserCommands['browserSetCredentials'] = - this.browserCommands.browserSetCredentials.bind(this.browserCommands) - - browserSetMedia: RuntimeBrowserCommands['browserSetMedia'] = - this.browserCommands.browserSetMedia.bind(this.browserCommands) - - browserClipboardRead: RuntimeBrowserCommands['browserClipboardRead'] = - this.browserCommands.browserClipboardRead.bind(this.browserCommands) - - browserClipboardWrite: RuntimeBrowserCommands['browserClipboardWrite'] = - this.browserCommands.browserClipboardWrite.bind(this.browserCommands) - - browserDialogAccept: RuntimeBrowserCommands['browserDialogAccept'] = - this.browserCommands.browserDialogAccept.bind(this.browserCommands) - - browserDialogDismiss: RuntimeBrowserCommands['browserDialogDismiss'] = - this.browserCommands.browserDialogDismiss.bind(this.browserCommands) - - browserStorageLocalGet: RuntimeBrowserCommands['browserStorageLocalGet'] = - this.browserCommands.browserStorageLocalGet.bind(this.browserCommands) - - browserStorageLocalSet: RuntimeBrowserCommands['browserStorageLocalSet'] = - this.browserCommands.browserStorageLocalSet.bind(this.browserCommands) - - browserStorageLocalClear: RuntimeBrowserCommands['browserStorageLocalClear'] = - this.browserCommands.browserStorageLocalClear.bind(this.browserCommands) - - browserStorageSessionGet: RuntimeBrowserCommands['browserStorageSessionGet'] = - this.browserCommands.browserStorageSessionGet.bind(this.browserCommands) - - browserStorageSessionSet: RuntimeBrowserCommands['browserStorageSessionSet'] = - this.browserCommands.browserStorageSessionSet.bind(this.browserCommands) - - browserStorageSessionClear: RuntimeBrowserCommands['browserStorageSessionClear'] = - this.browserCommands.browserStorageSessionClear.bind(this.browserCommands) - - browserDownload: RuntimeBrowserCommands['browserDownload'] = - this.browserCommands.browserDownload.bind(this.browserCommands) - - browserHighlight: RuntimeBrowserCommands['browserHighlight'] = - this.browserCommands.browserHighlight.bind(this.browserCommands) - - browserExec: RuntimeBrowserCommands['browserExec'] = this.browserCommands.browserExec.bind( - this.browserCommands - ) - - browserTabCreate: RuntimeBrowserCommands['browserTabCreate'] = - this.browserCommands.browserTabCreate.bind(this.browserCommands) - - browserOpenUrlOnClient: RuntimeBrowserCommands['browserOpenUrlOnClient'] = - this.browserCommands.browserOpenUrlOnClient.bind(this.browserCommands) - - browserTabSetProfile: RuntimeBrowserCommands['browserTabSetProfile'] = - this.browserCommands.browserTabSetProfile.bind(this.browserCommands) - - browserTabProfileShow: RuntimeBrowserCommands['browserTabProfileShow'] = - this.browserCommands.browserTabProfileShow.bind(this.browserCommands) - - browserTabProfileClone: RuntimeBrowserCommands['browserTabProfileClone'] = - this.browserCommands.browserTabProfileClone.bind(this.browserCommands) - - browserProfileList: RuntimeBrowserCommands['browserProfileList'] = - this.browserCommands.browserProfileList.bind(this.browserCommands) - - browserProfileCreate: RuntimeBrowserCommands['browserProfileCreate'] = - this.browserCommands.browserProfileCreate.bind(this.browserCommands) - - browserProfileDelete: RuntimeBrowserCommands['browserProfileDelete'] = - this.browserCommands.browserProfileDelete.bind(this.browserCommands) - - browserProfileDetectBrowsers: RuntimeBrowserCommands['browserProfileDetectBrowsers'] = - this.browserCommands.browserProfileDetectBrowsers.bind(this.browserCommands) - - browserProfileImportFromBrowser: RuntimeBrowserCommands['browserProfileImportFromBrowser'] = - this.browserCommands.browserProfileImportFromBrowser.bind(this.browserCommands) - - browserProfileClearDefaultCookies: RuntimeBrowserCommands['browserProfileClearDefaultCookies'] = - this.browserCommands.browserProfileClearDefaultCookies.bind(this.browserCommands) - - browserTabClose: RuntimeBrowserCommands['browserTabClose'] = - this.browserCommands.browserTabClose.bind(this.browserCommands) - - // Emulator bindings (delegated to dedicated commands for surface separation). - emulatorTap: RuntimeEmulatorCommands['emulatorTap'] = this.emulatorCommands.emulatorTap.bind( - this.emulatorCommands - ) - emulatorGesture: RuntimeEmulatorCommands['emulatorGesture'] = - this.emulatorCommands.emulatorGesture.bind(this.emulatorCommands) - emulatorType: RuntimeEmulatorCommands['emulatorType'] = this.emulatorCommands.emulatorType.bind( - this.emulatorCommands - ) - emulatorButton: RuntimeEmulatorCommands['emulatorButton'] = - this.emulatorCommands.emulatorButton.bind(this.emulatorCommands) - emulatorRotate: RuntimeEmulatorCommands['emulatorRotate'] = - this.emulatorCommands.emulatorRotate.bind(this.emulatorCommands) - emulatorExec: RuntimeEmulatorCommands['emulatorExec'] = this.emulatorCommands.emulatorExec.bind( - this.emulatorCommands - ) - emulatorAttach: RuntimeEmulatorCommands['emulatorAttach'] = - this.emulatorCommands.emulatorAttach.bind(this.emulatorCommands) - emulatorList: RuntimeEmulatorCommands['emulatorList'] = this.emulatorCommands.emulatorList.bind( - this.emulatorCommands - ) - emulatorKill: RuntimeEmulatorCommands['emulatorKill'] = this.emulatorCommands.emulatorKill.bind( - this.emulatorCommands - ) - emulatorShutdown: RuntimeEmulatorCommands['emulatorShutdown'] = - this.emulatorCommands.emulatorShutdown.bind(this.emulatorCommands) - emulatorListSimulators: RuntimeEmulatorCommands['emulatorListSimulators'] = - this.emulatorCommands.emulatorListSimulators.bind(this.emulatorCommands) - emulatorAvailability: RuntimeEmulatorCommands['emulatorAvailability'] = - this.emulatorCommands.emulatorAvailability.bind(this.emulatorCommands) - emulatorListDevices: RuntimeEmulatorCommands['emulatorListDevices'] = - this.emulatorCommands.emulatorListDevices.bind(this.emulatorCommands) - emulatorInstall: RuntimeEmulatorCommands['emulatorInstall'] = - this.emulatorCommands.emulatorInstall.bind(this.emulatorCommands) - emulatorLaunch: RuntimeEmulatorCommands['emulatorLaunch'] = - this.emulatorCommands.emulatorLaunch.bind(this.emulatorCommands) - emulatorPermissions: RuntimeEmulatorCommands['emulatorPermissions'] = - this.emulatorCommands.emulatorPermissions.bind(this.emulatorCommands) - emulatorAx: RuntimeEmulatorCommands['emulatorAx'] = this.emulatorCommands.emulatorAx.bind( - this.emulatorCommands - ) - emulatorLogcat: RuntimeEmulatorCommands['emulatorLogcat'] = - this.emulatorCommands.emulatorLogcat.bind(this.emulatorCommands) - emulatorUnregisterActive: RuntimeEmulatorCommands['emulatorUnregisterActive'] = - this.emulatorCommands.emulatorUnregisterActive.bind(this.emulatorCommands) - - private getAuthoritativeWindow(): BrowserWindow { - const win = this.getAvailableAuthoritativeWindow() - if (!win || win.isDestroyed()) { - throw new Error('No renderer window available') - } - return win - } - - private getAvailableAuthoritativeWindow(): BrowserWindow | null { - if (this.authoritativeWindowId === null) { - return null - } - const win = getRuntimeDesktopSurface().findWindowById(this.authoritativeWindowId) - return win && !win.isDestroyed() ? win : null - } -} - -const WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS = 50 -// Why: chunks that could complete an actionable prompt bypass the throttle so blocked stamps stay immediate; scanned over the new chunk + short carry, never the whole window. -const WAIT_BLOCKED_KEYWORD_PATTERN = - /press enter|press t to trust|do you trust|trust this|trusted workspace|permission required|requires permission|allow once|allow always|update available|choose working directory|codex just got an upgrade|hooks need review/ -const WAIT_BLOCKED_KEYWORD_CARRY_CHARS = 31 -const MAX_TAIL_LINES = 2000 -const MAX_TAIL_CHARS = 256 * 1024 -const MAX_TAIL_PARTIAL_CHARS = 4000 -const MAX_TAIL_PENDING_ANSI_CHARS = 4096 -const DEFAULT_TERMINAL_READ_LIMIT = 120 -const MAX_TERMINAL_READ_LIMIT = 2000 -const MAX_TERMINAL_PREVIEW_CHARS = 32 * 1024 -export const AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS = 8_000 -const VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS = 750 -const VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS = 1_000 -const TUI_IDLE_VISIBLE_PROBE_SETTLE_MARGIN_MS = 10 -const MAX_PREVIEW_LINES = 6 -const MAX_PREVIEW_CHARS = 300 -const WORKTREE_STATUS_PRIORITY: Record = { - inactive: 0, - active: 1, - done: 2, - working: 3, - permission: 4 -} -const DEFAULT_REPO_SEARCH_REFS_LIMIT = REPO_SEARCH_REFS_DEFAULT_LIMIT -const DEFAULT_TERMINAL_LIST_LIMIT = 200 -const DEFAULT_WORKTREE_LIST_LIMIT = 200 -const DEFAULT_WORKTREE_PS_LIMIT = 200 -const DISCONNECTED_PTY_RECORD_MAX = 128 -const RESOLVED_WORKTREE_CACHE_TTL_MS = 1000 -const WORKTREE_SCAN_CACHE_TTL_MS = 30_000 -// Why: agent-scratch repos don't need 30s freshness — the steady-state scan -// fan-out was measured at ~128 git execs/min on real installs, mostly against -// these (crash-cluster diagnostics, 2026-07). -const WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS = 5 * 60_000 -// Why: the Git-admin fingerprint reads HEAD and its ref tip exactly, but sparse-checkout pattern -// edits are invisible to it and a tip living in packed-refs or reftable only gets an mtime + size -// stamp, so a real scan still runs on this interval even while the probe reports "unchanged". -export const WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS = 5 * 60_000 -// Why reserved rather than spent on the probe: when the probe expires the caller still has to run -// `git worktree list` and answer inside the same budget, so the fallback needs its own room. Sized -// for a healthy Git on a busy host, well above the tens of milliseconds a warm list costs. -const WORKTREE_SCAN_FALLBACK_ALLOWANCE_MS = 1500 -// Why derived from the caller's budget instead of a generous absolute: this wait runs *inside* -// RESOLVED_WORKTREE_REPO_TIMEOUT_MS, so outlasting it buys nothing — the caller has already given up -// and restored persisted rows — while turning a reusable scan into a full-budget stall that repeats -// on every TTL expiry. Subtracting keeps that invariant true by construction if either side moves. -// Why not smaller: the probe reads a subset of what the fallback scan reads, so a probe too slow to -// fit is a scan that will not fit either — waiting is strictly better right up to the budget. -// Expiring yields `null`, the existing "cannot prove unchanged" sentinel, so a real scan runs. -export const WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS = - RESOLVED_WORKTREE_REPO_TIMEOUT_MS - WORKTREE_SCAN_FALLBACK_ALLOWANCE_MS - -export function resolveWorktreeScanCacheTtlMs(repo: Pick): number { - return !repo.connectionId && isAgentScratchRepoRootPath(repo.path) - ? WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS - : WORKTREE_SCAN_CACHE_TTL_MS -} -const PTY_CONTROLLER_LIST_TIMEOUT_MS = 3000 -// Why: the slice of the list budget reserved for the aggregate to collect the providers -// that answered after a stalled one gives up. -const PTY_CONTROLLER_LIST_PROVIDER_MARGIN_MS = 500 -// Why: the renderer waits 15s; leave room for the verified failure response and release the spawn fence before its caller times out. -const WORKTREE_TERMINAL_SLEEP_TIMEOUT_MS = 12_000 - -// Why: listener fan-out is best-effort delivery. One subscriber throwing synchronously — e.g. a -// paired-client relay whose stream is closed — must never abort the emitting operation or leak -// state (a lock/mutation) the caller holds across the emit. Isolate every listener and log. -function notifyRuntimeListeners( - listeners: Iterable, - deliver: (listener: L) => void, - context: string -): void { - for (const listener of listeners) { - try { - deliver(listener) - } catch (error) { - console.error(`[runtime] ${context} listener threw`, error) - } - } -} -// Why (§3.3): 30s freshness window reuses a recent fetch for repeat create/dispatch on the same repo+remote; short enough a changed remote is seen next action. -const FETCH_FRESHNESS_MS = 30_000 -// Why: bound fetches so a Windows credential-manager GUI hang (STA-1292) can't wedge worktree creation; parity with the exact-base refresh sibling. -const REMOTE_FETCH_TIMEOUT_MS = 60_000 -const REMOTE_FETCH_CACHE_MAX = 512 -const DRIFT_PROBE_SUBJECT_LIMIT = 5 - -function setBoundedMapEntry(map: Map, key: K, value: V, maxEntries: number): void { - if (map.has(key)) { - map.delete(key) - } - map.set(key, value) - while (map.size > maxEntries) { - const oldest = map.keys().next() - if (oldest.done) { - return - } - map.delete(oldest.value) - } -} - -function getExplicitWorktreeIdSelector(selector: string | undefined): string | null { - if (!selector?.startsWith('id:')) { - return null - } - const id = selector.slice(3) - return id.length > 0 ? id : null -} - -function withTimeoutResult( - promise: Promise, - timeoutMs: number -): Promise<{ ok: true; value: T } | { ok: false }> { - return withTimeout( - promise.then((value) => ({ ok: true, value }) as const), - timeoutMs, - { - ok: false - } - ) -} - -export function buildPreview(lines: string[], partialLine: string): string { - const previewLines: string[] = [] - const collectVisibleLine = (line: string): void => { - const trimmed = line.trim() - if (trimmed.length > 0) { - previewLines.push(trimmed) - } - } - - if (partialLine.length > 0) { - collectVisibleLine(partialLine) - } - for ( - let index = lines.length - 1; - index >= 0 && previewLines.length < MAX_PREVIEW_LINES; - index-- - ) { - collectVisibleLine(lines[index]) - } - previewLines.reverse() - - const preview = previewLines.join('\n') - return preview.length > MAX_PREVIEW_CHARS - ? preview.slice(preview.length - MAX_PREVIEW_CHARS) - : preview -} - -// Why: restore payloads can be multi-MB; the records only retain a bounded tail, -// so cap the one-time parse on the spawn path to the suffix that can matter. -const MAX_RESTORE_TAIL_SEED_CHARS = 256 * 1024 - -type RestoredTerminalTailSeed = { - lines: string[] - transcriptLines: string[] - transcriptChars: number - partialLine: string - pendingAnsi: string - redrawCursor: RetainedTailRedrawCursor | null - truncated: boolean - linesTotal: number - preview: string -} - -type RestorableTerminalTailRecord = Pick< - RuntimePtyWorktreeRecord, - | 'lastOutputAt' - | 'tailBuffer' - | 'tailTranscriptBuffer' - | 'tailTranscriptChars' - | 'tailPartialLine' - | 'tailPendingAnsi' - | 'tailRedrawCursor' - | 'tailTruncated' - | 'tailLinesTotal' - | 'preview' -> - -export function buildRestoredTerminalTailSeed(text: string): RestoredTerminalTailSeed | null { - let bounded = text - let sliced = false - if (bounded.length > MAX_RESTORE_TAIL_SEED_CHARS) { - bounded = bounded.slice(-MAX_RESTORE_TAIL_SEED_CHARS) - // Why: an arbitrary suffix can start mid-escape; restarting after the first - // line break resumes at a boundary (escape params never span \n or \r — - // \r covers newline-free CR-redraw streams). Consume a full \r\n pair so - // the seed does not begin with a phantom blank line. - const anchor = bounded.search(/[\r\n]/) - if (anchor !== -1) { - bounded = bounded.slice( - bounded[anchor] === '\r' && bounded[anchor + 1] === '\n' ? anchor + 2 : anchor + 1 - ) - } - sliced = true - } - // Why: the live-path pipeline, so seeded records equal what streaming the - // same bytes through onPtyData would have produced. - const normalized = normalizeTerminalChunk(bounded) - const tail = appendNormalizedToTailBuffer([], '', normalized.text, null) - if (tail.lines.length === 0 && tail.partialLine.length === 0) { - return null - } - const transcript = appendCompletedTerminalTranscript( - [], - 0, - tail.newlyCompletedLines, - tail.newCompleteLines - ) - return { - lines: tail.lines, - transcriptLines: transcript.lines, - transcriptChars: transcript.characters, - partialLine: tail.partialLine, - pendingAnsi: normalized.pendingAnsi, - redrawCursor: tail.redrawCursor, - truncated: sliced || tail.truncated || transcript.truncated, - linesTotal: tail.newCompleteLines, - preview: buildPreview(tail.lines, tail.partialLine) - } -} - -function restoredTerminalTailSeedAllowed(record: RestorableTerminalTailRecord): boolean { - return ( - record.lastOutputAt === null && - record.preview.length === 0 && - record.tailBuffer.length === 0 && - record.tailPartialLine.length === 0 - ) -} - -// Deliberately untouched: lastOutputAt (historical bytes must not read as fresh -// activity) and waitBlockedAt/tailWaitState (a restored prompt is not a live -// wait signal; the next live chunk recomputes both from this seeded tail). -function applyRestoredTerminalTailSeed( - record: RestorableTerminalTailRecord, - seed: RestoredTerminalTailSeed -): void { - // Why shared instances: append helpers never mutate prior arrays, and equal - // references let tailStateMatches keep its O(1) leaf/pty reuse fast path. - record.tailBuffer = seed.lines - record.tailTranscriptBuffer = seed.transcriptLines - record.tailTranscriptChars = seed.transcriptChars - record.tailPartialLine = seed.partialLine - record.tailPendingAnsi = seed.pendingAnsi - record.tailRedrawCursor = seed.redrawCursor - record.tailTruncated = seed.truncated - record.tailLinesTotal = seed.linesTotal - record.preview = seed.preview -} - -function buildTerminalWaitText(lines: string[], partialLine: string, preview: string): string { - const waitText = buildTailLines(lines, partialLine) - .map((line) => line.trim()) - .filter(Boolean) - .join('\n') - // Why: the preview is intentionally short, but wait readiness needs the retained tail so ready headers aren't truncated away. - return waitText.length > 0 ? waitText : preview -} - -export type TerminalTailWaitState = { - waitText: string - signal: { reason: RuntimeTerminalWaitBlockedReason; index: number } | null - // Why: preview is only an empty-tail fallback, recomputed each append, so a preview-derived state can't be reused as the next previous state (gated on fromTail). - fromTail: boolean -} - -// Why: runs per PTY chunk (hundreds/sec); only candidate-bearing tails parse the full 256 KiB, and the cached state avoids repeating that work next chunk. -export function computeTerminalTailWaitState( - lines: string[], - partialLine: string, - preview: string -): TerminalTailWaitState { - const tailShape = inspectTerminalWaitTail(lines, partialLine) - if (!tailShape.fromTail) { - return { - waitText: preview, - signal: findActionableTerminalWaitBlockedSignal(preview.toLowerCase()), - fromTail: false - } - } - if (!tailShape.mayContainBlockedSignal) { - // Why: reads waitText only when a signal exists; avoid retaining a rebuilt 256 KiB string in the common case. - return { waitText: '', signal: null, fromTail: true } - } - const tailText = buildTailLines(lines, partialLine) - .map((line) => line.trim()) - .filter(Boolean) - .join('\n') - const fromTail = tailText.length > 0 - const waitText = fromTail ? tailText : preview - return { - waitText, - signal: findActionableTerminalWaitBlockedSignal(waitText.toLowerCase()), - fromTail - } -} - -function inspectTerminalWaitTail( - lines: string[], - partialLine: string -): { fromTail: boolean; mayContainBlockedSignal: boolean } { - let fromTail = false - let mayContainBlockedSignal = false - for (const line of lines) { - if (!fromTail && line.trim().length > 0) { - fromTail = true - } - if (!mayContainBlockedSignal && TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(line)) { - mayContainBlockedSignal = true - } - } - if (!fromTail && partialLine.trim().length > 0) { - fromTail = true - } - if (!mayContainBlockedSignal && TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine)) { - mayContainBlockedSignal = true - } - return { fromTail, mayContainBlockedSignal } -} - -// Why: consumes precomputed wait states so full-tail scans aren't repeated per chunk (replaces the former inline double full-tail scan). -export function tailGainedNewerBlockedReason( - previous: TerminalTailWaitState, - next: TerminalTailWaitState, - appendedText: string -): boolean { - if (next.signal === null) { - return false - } - // Why: permission prompts can split across PTY chunks; stamp when the tail first becomes blocked, or a later prompt follows stale blocked text. - if (previous.signal === null) { - return true - } - const appendCandidateSignal = findActionableTerminalWaitBlockedSignal( - `${previous.waitText}${appendedText}`.toLowerCase() - ) - return appendCandidateSignal !== null && appendCandidateSignal.index > previous.signal.index -} - -export function appendNormalizedToTailBuffer( - previousLines: string[], - previousPartialLine: string, - normalizedChunk: string, - previousRedrawCursor: RetainedTailRedrawCursor | null = null -): { - lines: string[] - partialLine: string - redrawCursor: RetainedTailRedrawCursor | null - truncated: boolean - newCompleteLines: number - newlyCompletedLines: string[] -} { - if (normalizedChunk.length === 0) { - return { - lines: previousLines, - partialLine: previousPartialLine, - redrawCursor: previousRedrawCursor, - truncated: false, - newCompleteLines: 0, - newlyCompletedLines: [] - } - } - - // Why: fullscreen TUIs emit long newline-free redraw streams; keep the line transcript for pagination but bound partial-line work. - const previousPartialWasCapped = previousPartialLine.length > MAX_TAIL_PARTIAL_CHARS - const boundedPreviousPartialLine = previousPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS) - const combinedChunk = `${boundedPreviousPartialLine}${normalizedChunk}` - if (previousRedrawCursor || containsTerminalVerticalLineControl(combinedChunk)) { - return appendNormalizedToMultilineTailBuffer( - previousLines, - boundedPreviousPartialLine, - normalizedChunk, - previousPartialWasCapped, - previousRedrawCursor - ) - } - - // Why: status UIs redraw one line via CR/backspace/erase; retain the latest redraw segment instead of appending every spinner frame. - const segments = splitRetainedTerminalTailSegments(combinedChunk) - const pieces = processTerminalTailCompleteSegments(segments.completeSegments) - const newlyCompletedLines = pieces.map((line) => trimTerminalLineRight(line)) - const partialResult = applyTerminalLineControls(segments.partialSegment) - const nextPartialLine = trimTerminalLineRight(partialResult.text) - const retainedPartialLine = nextPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS) - const newCompleteLines = segments.completeLineCount - const omittedNewCompleteLines = newCompleteLines - pieces.length - let nextLines = - newCompleteLines > 0 - ? [...(omittedNewCompleteLines > 0 ? [] : previousLines), ...newlyCompletedLines] - : previousLines - let truncated = - previousPartialWasCapped || - omittedNewCompleteLines > 0 || - nextPartialLine.length > MAX_TAIL_PARTIAL_CHARS - - if (nextLines.length > MAX_TAIL_LINES) { - nextLines = nextLines.slice(nextLines.length - MAX_TAIL_LINES) - truncated = true - } - - if (newCompleteLines > 0 || retainedPartialLine.length > previousPartialLine.length) { - if (nextLines === previousLines) { - nextLines = [...previousLines] - } - let totalChars = - nextLines.reduce((sum, line) => sum + line.length, 0) + retainedPartialLine.length - let trimStartIndex = 0 - while (trimStartIndex < nextLines.length && totalChars > MAX_TAIL_CHARS) { - totalChars -= nextLines[trimStartIndex].length - trimStartIndex += 1 - } - if (trimStartIndex > 0) { - nextLines = nextLines.slice(trimStartIndex) - truncated = true - } - } - - const redrawCursor = - !partialResult.hadControl || partialResult.cursorColumn === nextPartialLine.length - ? null - : { - rowFromEnd: 0, - column: partialResult.cursorColumn - } - - return { - lines: nextLines, - partialLine: retainedPartialLine, - redrawCursor, - truncated, - newCompleteLines, - newlyCompletedLines - } -} - -function trimTerminalLineRight(line: string): string { - let end = line.length - while (end > 0) { - const code = line.charCodeAt(end - 1) - if (code !== 0x20 && code !== 0x09) { - break - } - end -= 1 - } - return end === line.length ? line : line.slice(0, end) -} - -// Why a window: the unwindowed impl below is O(tail) per chunk (~93% of the event loop under TUI flood, findings log 2026-07-03); a redraw only touches rows the cursor reaches, so window the suffix and share the prefix by reference. Equivalence fuzz-verified in retained-tail-redraw-window.equivalence.test.ts. -const REDRAW_WINDOW_SAFETY_ROWS = 8 - -function maxUpwardCursorReach( - normalizedChunk: string, - previousRedrawCursor: RetainedTailRedrawCursor | null -): number { - let reach = previousRedrawCursor ? previousRedrawCursor.rowFromEnd : 0 - const cursorUpPattern = /\x1b\[(\d*)(?:;[\d;]*)?A/g - let match: RegExpExecArray | null - while ((match = cursorUpPattern.exec(normalizedChunk)) !== null) { - reach += match[1] ? Number.parseInt(match[1], 10) : 1 - } - return reach -} - -function appendNormalizedToMultilineTailBuffer( - previousLines: string[], - boundedPreviousPartialLine: string, - normalizedChunk: string, - previousPartialWasCapped: boolean, - previousRedrawCursor: RetainedTailRedrawCursor | null -): { - lines: string[] - partialLine: string - redrawCursor: RetainedTailRedrawCursor | null - truncated: boolean - newCompleteLines: number - newlyCompletedLines: string[] -} { - const windowRows = - maxUpwardCursorReach(normalizedChunk, previousRedrawCursor) + REDRAW_WINDOW_SAFETY_ROWS - if (windowRows >= previousLines.length) { - return appendNormalizedToMultilineTailBufferUnwindowed( - previousLines, - boundedPreviousPartialLine, - normalizedChunk, - previousPartialWasCapped, - previousRedrawCursor - ) - } - const prefixLength = previousLines.length - windowRows - const suffix = previousLines.slice(prefixLength) - const windowed = appendNormalizedToMultilineTailBufferUnwindowed( - suffix, - boundedPreviousPartialLine, - normalizedChunk, - previousPartialWasCapped, - previousRedrawCursor - ) - let lines = previousLines.slice(0, prefixLength) - // Why: the shared prefix must match the unwindowed finalize's trailing-space trim without paying a regex per untouched row. - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index]! - const lastChar = line.charCodeAt(line.length - 1) - if (lastChar === 32 || lastChar === 9) { - lines[index] = line.replace(/[ \t]+$/g, '') - } - } - for (const line of windowed.lines) { - lines.push(line) - } - let truncated = windowed.truncated - if (lines.length > MAX_TAIL_LINES) { - lines = lines.slice(lines.length - MAX_TAIL_LINES) - truncated = true - } - let totalChars = windowed.partialLine.length - for (const line of lines) { - totalChars += line.length - } - let dropCount = 0 - while (dropCount < lines.length && totalChars > MAX_TAIL_CHARS) { - totalChars -= lines[dropCount]!.length - dropCount += 1 - } - if (dropCount > 0) { - lines = lines.slice(dropCount) - truncated = true - } - return { - lines, - partialLine: windowed.partialLine, - redrawCursor: windowed.redrawCursor, - truncated, - newCompleteLines: windowed.newCompleteLines, - newlyCompletedLines: windowed.newlyCompletedLines - } -} - -export function appendNormalizedToMultilineTailBufferUnwindowed( - previousLines: string[], - boundedPreviousPartialLine: string, - normalizedChunk: string, - previousPartialWasCapped: boolean, - previousRedrawCursor: RetainedTailRedrawCursor | null -): { - lines: string[] - partialLine: string - redrawCursor: RetainedTailRedrawCursor | null - truncated: boolean - newCompleteLines: number - newlyCompletedLines: string[] -} { - const rows: RetainedTerminalRow[] = [ - ...previousLines.map((line) => ({ text: line, completed: true })), - { text: boundedPreviousPartialLine, completed: false } - ] - let cursorRow = previousRedrawCursor - ? Math.max(0, rows.length - 1 - previousRedrawCursor.rowFromEnd) - : rows.length - 1 - let cursorColumn = previousRedrawCursor?.column ?? boundedPreviousPartialLine.length - let newCompleteLines = 0 - const newlyCompletedLines: string[] = [] - let newlyCompletedLineCharacters = 0 - let newlyCompletedLineStart = 0 - let truncated = previousPartialWasCapped - - const retainNewlyCompletedLine = (line: string): void => { - newlyCompletedLines.push(line) - newlyCompletedLineCharacters += line.length - while ( - newlyCompletedLines.length - newlyCompletedLineStart > MAX_TAIL_LINES || - newlyCompletedLineCharacters > MAX_TAIL_CHARS - ) { - newlyCompletedLineCharacters -= newlyCompletedLines[newlyCompletedLineStart]!.length - newlyCompletedLineStart += 1 - } - // Why: a single PTY chunk can carry unbounded newlines; compact in batches while retaining the suffix needed for stable pagination. - if (newlyCompletedLineStart >= MAX_TAIL_LINES) { - newlyCompletedLines.splice(0, newlyCompletedLineStart) - newlyCompletedLineStart = 0 - } - } - - const ensureCursorRow = (): void => { - while (cursorRow >= rows.length) { - rows.push({ text: '', completed: false }) - } - } - const trimRows = (): void => { - const maxRows = MAX_TAIL_LINES + 1 - if (rows.length <= maxRows) { - return - } - const removeCount = rows.length - maxRows - rows.splice(0, removeCount) - cursorRow = Math.max(0, cursorRow - removeCount) - truncated = true - } - const moveCursorToColumn = (nextColumn: number): void => { - cursorColumn = clampTerminalPreviewCursor(nextColumn) - } - const markCursorRowRewritten = (): void => { - ensureCursorRow() - rows[cursorRow]!.completed = false - } - const writeChar = (char: string): void => { - ensureCursorRow() - markCursorRowRewritten() - const row = rows[cursorRow]! - if (cursorColumn > row.text.length) { - row.text = `${row.text}${' '.repeat(cursorColumn - row.text.length)}` - } - row.text = - cursorColumn >= row.text.length - ? `${row.text}${char}` - : `${row.text.slice(0, cursorColumn)}${char}${row.text.slice(cursorColumn + 1)}` - cursorColumn += 1 - } - const eraseLine = (mode: number): void => { - ensureCursorRow() - markCursorRowRewritten() - const row = rows[cursorRow]! - if (mode === 0) { - row.text = row.text.slice(0, cursorColumn) - } else if (mode === 1) { - const deleteCount = Math.min(cursorColumn + 1, row.text.length) - row.text = `${' '.repeat(deleteCount)}${row.text.slice(deleteCount)}` - } else if (mode === 2) { - row.text = '' - } - } - - for (let index = 0; index < normalizedChunk.length; index += 1) { - const char = normalizedChunk[index] - if (char === '\n') { - ensureCursorRow() - rows[cursorRow]!.completed = true - newCompleteLines += 1 - retainNewlyCompletedLine(trimTerminalLineRight(rows[cursorRow]!.text)) - cursorRow += 1 - cursorColumn = 0 - ensureCursorRow() - trimRows() - continue - } - if (char === '\r') { - cursorColumn = 0 - continue - } - if (char === '\u0008') { - cursorColumn = Math.max(0, cursorColumn - 1) - continue - } - if (char === '\u001b') { - const parsed = parseAnsiControlSequence(normalizedChunk, index) - if (!parsed) { - continue - } - index = parsed.endIndex - if (parsed.kind !== 'csi' || !hasCanonicalNumericCsiParams(parsed.params)) { - continue - } - const firstParam = parsed.firstParam ?? 1 - if (parsed.final === 'A') { - cursorRow = Math.max(0, cursorRow - firstParam) - rows.splice(cursorRow + 1) - } else if (parsed.final === 'K') { - eraseLine(parsed.firstParam ?? 0) - } else if (parsed.final === 'G' || parsed.final === '`') { - moveCursorToColumn(firstParam - 1) - } else if (parsed.final === 'D') { - cursorColumn = Math.max(0, cursorColumn - firstParam) - } else if (parsed.final === 'C') { - moveCursorToColumn(cursorColumn + firstParam) - } - continue - } - writeChar(char) - } - - return finalizeRetainedTerminalRows( - rows, - cursorRow, - cursorColumn, - truncated, - newCompleteLines, - newlyCompletedLineStart > 0 - ? newlyCompletedLines.slice(newlyCompletedLineStart) - : newlyCompletedLines - ) -} - -type RetainedTailRedrawCursor = { - rowFromEnd: number - column: number -} - -type RetainedTerminalRow = { - text: string - completed: boolean -} - -function finalizeRetainedTerminalRows( - rows: RetainedTerminalRow[], - cursorRow: number, - cursorColumn: number, - initialTruncated: boolean, - newCompleteLines: number, - newlyCompletedLines: string[] -): { - lines: string[] - partialLine: string - redrawCursor: RetainedTailRedrawCursor | null - truncated: boolean - newCompleteLines: number - newlyCompletedLines: string[] -} { - let truncated = initialTruncated - let retainedRows = rows.map((row) => ({ ...row, text: row.text.replace(/[ \t]+$/g, '') })) - - if (retainedRows.length > MAX_TAIL_LINES + 1) { - const removeCount = retainedRows.length - (MAX_TAIL_LINES + 1) - retainedRows = retainedRows.slice(removeCount) - cursorRow = Math.max(0, cursorRow - removeCount) - truncated = true - } - - let totalChars = retainedRows.reduce((sum, row) => sum + row.text.length, 0) - let trimStartIndex = 0 - while (trimStartIndex < retainedRows.length - 1 && totalChars > MAX_TAIL_CHARS) { - totalChars -= retainedRows[trimStartIndex]!.text.length - trimStartIndex += 1 - } - if (trimStartIndex > 0) { - retainedRows = retainedRows.slice(trimStartIndex) - cursorRow = Math.max(0, cursorRow - trimStartIndex) - truncated = true - } - while ( - retainedRows.length > 1 && - cursorRow < retainedRows.length - 1 && - retainedRows.at(-1)?.completed === false && - retainedRows.at(-1)?.text.length === 0 - ) { - retainedRows.pop() - } - - const lastRow = retainedRows.at(-1) - let partialLine = lastRow && !lastRow.completed ? lastRow.text : '' - let lines = (lastRow && !lastRow.completed ? retainedRows.slice(0, -1) : retainedRows).map( - (row) => row.text - ) - - if (partialLine.length > MAX_TAIL_PARTIAL_CHARS) { - partialLine = partialLine.slice(-MAX_TAIL_PARTIAL_CHARS) - truncated = true - } - if (lines.length > MAX_TAIL_LINES) { - lines = lines.slice(lines.length - MAX_TAIL_LINES) - truncated = true - } - const outputRowCount = lines.length + 1 - const defaultCursorRow = outputRowCount - 1 - const defaultCursorColumn = partialLine.length - const redrawCursor = - cursorRow === defaultCursorRow && cursorColumn === defaultCursorColumn - ? null - : { - rowFromEnd: Math.max(0, outputRowCount - 1 - cursorRow), - column: clampTerminalPreviewCursor(cursorColumn) - } - - return { - lines, - partialLine, - redrawCursor, - truncated, - newCompleteLines, - newlyCompletedLines - } -} - -function splitRetainedTerminalTailSegments(value: string): { - completeSegments: string[] - partialSegment: string - completeLineCount: number -} { - let completeLineCount = 0 - for (let index = 0; index < value.length; index += 1) { - if (value[index] === '\n') { - completeLineCount += 1 - } - } - - const retainedCompleteCount = Math.min(completeLineCount, MAX_TAIL_LINES) - const omittedCompleteCount = completeLineCount - retainedCompleteCount - let startIndex = 0 - if (omittedCompleteCount > 0) { - let seen = 0 - for (let index = 0; index < value.length; index += 1) { - if (value[index] !== '\n') { - continue - } - seen += 1 - if (seen === omittedCompleteCount) { - startIndex = index + 1 - break - } - } - } - - const completeSegments: string[] = [] - let segmentStart = startIndex - for (let index = startIndex; index < value.length; index += 1) { - if (value[index] !== '\n') { - continue - } - completeSegments.push(value.slice(segmentStart, index)) - segmentStart = index + 1 - } - - return { - completeSegments, - partialSegment: value.slice(segmentStart), - completeLineCount - } -} - -function processTerminalTailCompleteSegments(segments: string[]): string[] { - const processed: string[] = [] - let totalChars = 0 - for (let index = segments.length - 1; index >= 0; index -= 1) { - const line = applyTerminalLineControls(segments[index]!).text - processed.push(line) - totalChars += line.length - if (totalChars > MAX_TAIL_CHARS) { - break - } - } - processed.reverse() - return processed -} - -function applyTerminalLineControls(line: string): { - text: string - cursorColumn: number - hadControl: boolean -} { - const carriageIndex = line.lastIndexOf('\r') - const latestRedraw = carriageIndex !== -1 ? line.slice(carriageIndex + 1) : line - if (!latestRedraw.includes('\u0008') && !latestRedraw.includes('\u001b')) { - return { - text: latestRedraw, - cursorColumn: latestRedraw.length, - hadControl: carriageIndex !== -1 - } - } - - const chars: string[] = [] - let cursor = 0 - const moveCursorTo = (nextCursor: number): void => { - cursor = clampTerminalPreviewCursor(nextCursor) - } - const writeChar = (char: string): void => { - if (cursor > chars.length) { - const oldLength = chars.length - chars.length = cursor - chars.fill(' ', oldLength, cursor) - } - if (cursor >= chars.length) { - chars.push(char) - } else { - chars[cursor] = char - } - cursor += 1 - } - for (let index = 0; index < latestRedraw.length; index += 1) { - const char = latestRedraw[index] - if (char === '\u0008') { - if (cursor > 0) { - cursor -= 1 - } - } else if (char === '\u001b') { - const parsed = parseAnsiControlSequence(latestRedraw, index) - if (!parsed) { - continue - } - index = parsed.endIndex - if (parsed.kind !== 'csi') { - continue - } - if (!hasCanonicalNumericCsiParams(parsed.params)) { - continue - } - if (parsed.final === 'K') { - const mode = parsed.firstParam ?? 0 - if (mode === 0) { - chars.length = cursor - } else if (mode === 1) { - const deleteCount = Math.min(cursor + 1, chars.length) - chars.fill(' ', 0, deleteCount) - } else if (mode === 2) { - chars.length = 0 - } - } else if (parsed.final === 'G' || parsed.final === '`') { - moveCursorTo((parsed.firstParam ?? 1) - 1) - } else if (parsed.final === 'D') { - cursor = Math.max(0, cursor - (parsed.firstParam ?? 1)) - } else if (parsed.final === 'C') { - moveCursorTo(cursor + (parsed.firstParam ?? 1)) - } - } else { - writeChar(char) - } - } - return { text: chars.join(''), cursorColumn: cursor, hadControl: true } -} - -function clampTerminalPreviewCursor(nextCursor: number): number { - if (!Number.isFinite(nextCursor)) { - return MAX_TAIL_PARTIAL_CHARS - } - return Math.min(MAX_TAIL_PARTIAL_CHARS, Math.max(0, Math.floor(nextCursor))) -} - -function parseAnsiControlSequence( - value: string, - escapeIndex: number -): - | { kind: 'csi'; final: string; params: string; firstParam: number | null; endIndex: number } - | { - kind: 'other' - endIndex: number - } - | null { - const introducer = value[escapeIndex + 1] - if (introducer === '[') { - for (let index = escapeIndex + 2; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (code < 0x40 || code > 0x7e) { - continue - } - const params = value.slice(escapeIndex + 2, index) - const firstParamMatch = /^(\d+)/.exec(params) - return { - kind: 'csi', - final: value[index] ?? '', - params, - firstParam: firstParamMatch ? Number(firstParamMatch[1]) : null, - endIndex: index - } - } - return null - } - if (introducer === ']') { - for (let index = escapeIndex + 2; index < value.length; index += 1) { - if (value[index] === '\u0007') { - return { kind: 'other', endIndex: index } - } - if (value[index] === '\u001b' && value[index + 1] === '\\') { - return { kind: 'other', endIndex: index + 1 } - } - } - return null - } - if (isStTerminatedStringControlIntroducer(introducer)) { - for (let index = escapeIndex + 2; index < value.length; index += 1) { - if (value[index] === '\u001b' && value[index + 1] === '\\') { - return { kind: 'other', endIndex: index + 1 } - } - } - return null - } - return { kind: 'other', endIndex: escapeIndex + 1 } -} - -function isStTerminatedStringControlIntroducer(introducer: string | undefined): boolean { - return introducer === 'P' || introducer === 'X' || introducer === '^' || introducer === '_' -} - -function hasCanonicalNumericCsiParams(params: string): boolean { - return /^[0-9;]*$/.test(params) -} - -function containsTerminalVerticalLineControl(value: string): boolean { - for (let index = 0; index < value.length; index += 1) { - if (value[index] !== '\u001b') { - continue - } - const parsed = parseAnsiControlSequence(value, index) - if (!parsed) { - return false - } - index = parsed.endIndex - if ( - parsed.kind === 'csi' && - parsed.final === 'A' && - hasCanonicalNumericCsiParams(parsed.params) - ) { - return true - } - } - return false -} - -function appendCompletedTerminalTranscript( - previousLines: string[], - previousCharacters: number, - newlyCompletedLines: string[], - newCompleteLineCount: number -): { lines: string[]; characters: number; truncated: boolean } { - if (newCompleteLineCount === 0) { - return { lines: previousLines, characters: previousCharacters, truncated: false } - } - - const omittedNewLineCount = Math.max(0, newCompleteLineCount - newlyCompletedLines.length) - const lines = omittedNewLineCount > 0 ? [] : [...previousLines] - let characters = omittedNewLineCount > 0 ? 0 : previousCharacters - for (const line of newlyCompletedLines) { - lines.push(line) - characters += line.length - } - - let dropCount = Math.max(0, lines.length - MAX_TAIL_LINES) - for (let index = 0; index < dropCount; index += 1) { - characters -= lines[index]!.length - } - while (dropCount < lines.length && characters > MAX_TAIL_CHARS) { - characters -= lines[dropCount]!.length - dropCount += 1 - } - - return { - lines: dropCount > 0 ? lines.slice(dropCount) : lines, - characters, - truncated: omittedNewLineCount > 0 || dropCount > 0 - } -} - -function tailStateMatches( - lines: string[], - transcriptLines: string[], - partialLine: string, - pendingAnsi: string, - redrawCursor: RetainedTailRedrawCursor | null, - truncated: boolean, - linesTotal: number, - snapshot: { - lines: string[] - transcriptLines: string[] - partialLine: string - pendingAnsi: string - redrawCursor: RetainedTailRedrawCursor | null - truncated: boolean - linesTotal: number - } -): boolean { - if ( - partialLine !== snapshot.partialLine || - pendingAnsi !== snapshot.pendingAnsi || - !tailRedrawCursorsMatch(redrawCursor, snapshot.redrawCursor) || - truncated !== snapshot.truncated || - linesTotal !== snapshot.linesTotal || - lines.length !== snapshot.lines.length || - transcriptLines.length !== snapshot.transcriptLines.length - ) { - return false - } - if (lines === snapshot.lines) { - return true - } - for (let index = 0; index < lines.length; index++) { - if (lines[index] !== snapshot.lines[index]) { - return false - } - } - if (transcriptLines !== snapshot.transcriptLines) { - for (let index = 0; index < transcriptLines.length; index++) { - if (transcriptLines[index] !== snapshot.transcriptLines[index]) { - return false - } - } - } - return true -} - -function tailRedrawCursorsMatch( - left: RetainedTailRedrawCursor | null, - right: RetainedTailRedrawCursor | null -): boolean { - if (left === right) { - return true - } - if (!left || !right) { - return false - } - return left.rowFromEnd === right.rowFromEnd && left.column === right.column -} - -function buildTailLines(lines: string[], partialLine: string): string[] { - return partialLine.length > 0 ? [...lines, partialLine] : lines -} - -function terminalReadLimit(limit: number | undefined, defaultLimit: number): number { - if (typeof limit !== 'number' || !Number.isFinite(limit) || limit <= 0) { - return defaultLimit - } - return Math.min(Math.max(1, Math.floor(limit)), MAX_TERMINAL_READ_LIMIT) -} - -function trimTerminalPreviewToCharacterBudget( - lines: string[], - characterBudget: number -): { tail: string[]; limited: boolean; omittedLineCount: number; slicedFirstLine: boolean } { - let totalCharacters = lines.reduce((sum, line) => sum + line.length, 0) - if (totalCharacters <= characterBudget) { - return { tail: lines, limited: false, omittedLineCount: 0, slicedFirstLine: false } - } - - let omittedLineCount = 0 - while ( - omittedLineCount < lines.length && - totalCharacters - lines[omittedLineCount].length >= characterBudget - ) { - totalCharacters -= lines[omittedLineCount].length - omittedLineCount += 1 - } - const tail = omittedLineCount > 0 ? lines.slice(omittedLineCount) : [...lines] - - let slicedFirstLine = false - if (tail.length > 0 && totalCharacters > characterBudget) { - tail[0] = tail[0].slice(totalCharacters - characterBudget) - slicedFirstLine = true - } - - return { tail, limited: true, omittedLineCount, slicedFirstLine } -} - -function readTerminalTail(args: { - handle: string - status: RuntimeTerminalState - previewLines: string[] - completedLines: string[] - partialLine: string - completedLineCount: number - bufferTruncated: boolean - cursor?: number - limit?: number -}): RuntimeTerminalRead { - const oldestCursor = Math.max(0, args.completedLineCount - args.completedLines.length) - const latestCursor = args.completedLineCount - - if (typeof args.cursor === 'number' && args.cursor >= 0) { - const limit = terminalReadLimit(args.limit, MAX_TERMINAL_READ_LIMIT) - if (args.cursor > latestCursor) { - return { - handle: args.handle, - status: args.status, - tail: [], - truncated: false, - limited: false, - oldestCursor: String(oldestCursor), - nextCursor: String(latestCursor), - latestCursor: String(latestCursor), - returnedLineCount: 0 - } - } - // Why: cursor reads return completed lines only, so a partial isn't delivered once as "hel" then again as "hello" after the newline. - const startCursor = Math.max(args.cursor, oldestCursor) - const startIndex = startCursor - oldestCursor - const available = args.completedLines.slice(startIndex) - const tail = available.slice(0, limit) - const nextCursor = startCursor + tail.length - return { - handle: args.handle, - status: args.status, - tail, - truncated: args.cursor < oldestCursor, - limited: tail.length < available.length, - oldestCursor: String(oldestCursor), - nextCursor: String(nextCursor), - latestCursor: String(latestCursor), - returnedLineCount: tail.length - } - } - - // Why: un-cursored reads are preview reads — return the latest bounded view; the larger buffer stays available via cursor reads + --limit. - const limit = terminalReadLimit(args.limit, DEFAULT_TERMINAL_READ_LIMIT) - const allLines = buildTailLines(args.previewLines, args.partialLine) - const lineBoundedTail = allLines.slice(-limit) - const charBoundedTail = trimTerminalPreviewToCharacterBudget( - lineBoundedTail, - MAX_TERMINAL_PREVIEW_CHARS - ) - const lineBoundedStartIndex = Math.max(0, allLines.length - lineBoundedTail.length) - const charBoundedStartIndex = lineBoundedStartIndex + charBoundedTail.omittedLineCount - const hasPageableOmittedCompletedLines = - Math.min(args.completedLineCount, charBoundedStartIndex) > 0 || - (charBoundedTail.slicedFirstLine && charBoundedStartIndex < args.completedLineCount) - // Why: a long partial line trimmed by the char budget can't be recovered via nextCursor, since cursor reads only page completed lines. - const truncatedByNonPageablePartial = charBoundedTail.limited && !hasPageableOmittedCompletedLines - return { - handle: args.handle, - status: args.status, - tail: charBoundedTail.tail, - truncated: args.bufferTruncated || truncatedByNonPageablePartial, - limited: lineBoundedTail.length < allLines.length || charBoundedTail.limited, - oldestCursor: String(oldestCursor), - nextCursor: String(latestCursor), - latestCursor: String(latestCursor), - returnedLineCount: charBoundedTail.tail.length - } -} - -function shouldFallbackToVisibleTerminalSnapshot( - read: RuntimeTerminalRead, - opts: { cursor?: number; limit?: number } -): boolean { - if (typeof opts.cursor === 'number') { - return false - } - if (read.tail.length === 0) { - return false - } - const hasSubstantialBlankTail = - read.limited === true || read.truncated || read.tail.length >= DEFAULT_TERMINAL_READ_LIMIT - return hasSubstantialBlankTail && read.tail.every((line) => line.trim().length === 0) -} - -function visibleNonBlankTerminalLines(lines: string[]): string[] { - return lines.map((line) => line.trimEnd()).filter((line) => line.trim().length > 0) -} - -function projectVisibleTerminalLines(emulator: HeadlessEmulator): { - lines: string[] - draft?: string -} { - const lines = emulator.getVisibleLines() - const draft = detectTerminalComposerDraft(emulator.getCursorLineContext()) - if (draft) { - lines[draft.promptRow] = draft.promptGlyph - for (let row = draft.promptRow + 1; row <= draft.endRow; row += 1) { - lines[row] = '' - } - } - return { - lines: visibleNonBlankTerminalLines(lines), - ...(draft ? { draft: draft.text } : {}) - } -} - -export function projectTerminalTailLines( - emulator: HeadlessEmulator, - limit: number -): RuntimeTerminalProjection { - const tail = emulator.getBufferTailLines(limit) - const visible = emulator.getVisibleLines() - const visibleRange = emulator.getVisibleBufferRange() - const draft = detectTerminalComposerDraft(emulator.getCursorLineContext()) - if (draft && visibleRange.endExclusive === visibleRange.totalLength) { - visible[draft.promptRow] = draft.promptGlyph - for (let row = draft.promptRow + 1; row <= draft.endRow; row += 1) { - visible[row] = '' - } - const scrollbackTail = tail.slice(0, Math.max(0, tail.length - visible.length)) - tail.splice(0, tail.length, ...scrollbackTail, ...visibleNonBlankTerminalLines(visible)) - } - return { - lines: visibleNonBlankTerminalLines(tail).slice(-limit), - ...(draft ? { draft: draft.text } : {}) - } -} - -// Why: every read carries its source, so a caller that asked for a screen and got a response -// with no source at all knows it reached a host that predates screen reads — rather than -// mistaking the stream for the screen. Rendered lines only ever enter a read through -// buildVisibleSnapshotReadFallback, which stamps `screen` itself, so anything still unlabelled -// here is the accumulated stream. -function labelTerminalReadSource(resolved: RuntimeTerminalRead): RuntimeTerminalRead { - return resolved.source ? resolved : { ...resolved, source: 'stream' } -} - -function buildVisibleSnapshotReadFallback( - read: RuntimeTerminalRead, - visibleLines: string[], - limit: number | undefined, - draft?: string -): RuntimeTerminalRead { - const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT) - const lineBoundedTail = visibleLines.slice(-lineLimit) - const charBoundedTail = trimTerminalPreviewToCharacterBudget( - lineBoundedTail, - MAX_TERMINAL_PREVIEW_CHARS - ) - return { - ...read, - tail: charBoundedTail.tail, - limited: - read.limited || lineBoundedTail.length < visibleLines.length || charBoundedTail.limited, - returnedLineCount: charBoundedTail.tail.length, - source: 'screen', - ...(draft ? { draft } : {}) - } -} - -function getTerminalState(leaf: RuntimeLeafRecord): RuntimeTerminalState { - if (leaf.connected) { - return 'running' - } - if (leaf.lastExitCode !== null) { - return 'exited' - } - return 'unknown' -} - -function buildSendPayload(action: { - text?: string - enter?: boolean - interrupt?: boolean -}): string | null { - let payload = '' - if (typeof action.text === 'string' && action.text.length > 0) { - payload += action.text - } - if (action.enter) { - payload += '\r' - } - if (action.interrupt) { - payload += '\x03' - } - return payload.length > 0 ? payload : null -} - -async function assertTerminalInputWithinLimitWithYield(text: string | undefined): Promise { - if (!text) { - return - } - if (await isTerminalInputTooLargeWithYield(text)) { - throw new Error(TERMINAL_INPUT_TOO_LARGE_ERROR) - } -} - -// Why: tui-idle needs OSC title transitions; an unsupported CLI/plain shell never fires one, so cap at 5min to avoid indefinite hangs. -const TUI_IDLE_DEFAULT_TIMEOUT_MS = 5 * 60 * 1000 -const TUI_IDLE_POLL_INTERVAL_MS = 2000 -const TUI_IDLE_QUIESCENCE_MS = 3000 -const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i -const CLAUDE_IDLE_PREFIX = '\u2733' -const GEMINI_IDLE_PREFIX = '\u25c7' -const PI_IDLE_PREFIX = '\u03c0 - ' - -// Clamp for mobileAutoRestoreFitMs: floor above the legacy 300ms debounce, 1h ceiling (a held PTY beyond that is "I forgot", not intentional). -const MOBILE_AUTO_RESTORE_FIT_MIN_MS = 5_000 -const MOBILE_AUTO_RESTORE_FIT_MAX_MS = 60 * 60 * 1000 - -function detectExplicitIdleStatusFromTitle(title: string): AgentStatus | null { - const status = detectAgentStatusFromTitle(title) - if (status !== 'idle') { - return null - } - // Why: launch titles like "Codex YOLO" contain an agent name but aren't readiness signals; terminal.wait needs explicit idle evidence. - if ( - EXPLICIT_IDLE_TITLE_RE.test(title) || - // Why: unblock hookless remote waits; guarded writes corroborate this marker. - isOpenCodeNativeTitle(title) || - title.startsWith(CLAUDE_IDLE_PREFIX) || - title.startsWith('* ') || - title.includes(GEMINI_IDLE_PREFIX) || - title.startsWith(PI_IDLE_PREFIX) - ) { - return 'idle' - } - return null -} - -function isKnownReadyPromptPreview(preview: string): boolean { - const normalized = preview.toLowerCase() - const readyIndex = findKnownReadyPromptIndex(normalized) - if (readyIndex === null) { - return false - } - const blockedSignal = findTerminalWaitBlockedSignal(normalized) - if (blockedSignal !== null && blockedSignal.index > readyIndex) { - return false - } - return true -} - -function detectTerminalWaitBlockedReason(preview: string): RuntimeTerminalWaitBlockedReason | null { - const normalized = preview.toLowerCase() - return findActionableTerminalWaitBlockedSignal(normalized)?.reason ?? null -} - -function findActionableTerminalWaitBlockedSignal( - normalized: string -): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { - const blockedSignal = findTerminalWaitBlockedSignal(normalized) - if (blockedSignal === null) { - return null - } - const dismissedModalIndex = findDismissedStartupModalIndex(normalized) - // Why: a live prompt after the modal means it was dismissed → signal no longer actionable, even mid-run (Cursor never reports idle via OSC title). - return dismissedModalIndex !== null && dismissedModalIndex > blockedSignal.index - ? null - : blockedSignal -} - -// Why: a live prompt (idle OR busy) proves the startup modal was dismissed, so a mid-run Cursor lane stops reporting stale trust hits. -function findDismissedStartupModalIndex(normalized: string): number | null { - const indexes = [ - findCodexReadyPromptIndex(normalized), - findAntigravityReadyPromptIndex(normalized), - findCursorActivePromptIndex(normalized) - ].filter((index): index is number => index !== null) - return indexes.length > 0 ? Math.max(...indexes) : null -} - -function findKnownReadyPromptIndex(normalized: string): number | null { - const indexes = [ - findCodexReadyPromptIndex(normalized), - findAntigravityReadyPromptIndex(normalized), - findCursorReadyPromptIndex(normalized) - ].filter((index): index is number => index !== null) - return indexes.length > 0 ? Math.max(...indexes) : null -} - -// Why: match the banner's last occurrence to skip the trust dialog's own "Cursor Agent" text; "→" is cursor-agent's persistent input prompt. -function findCursorActivePromptIndex(normalized: string): number | null { - const headerIndex = normalized.lastIndexOf('cursor agent') - if (headerIndex === -1) { - return null - } - return normalized.includes('→', headerIndex) ? headerIndex : null -} - -// Why: cursor-agent emits no idle OSC title; infer idle from the tail (braille spinner = busy, its absence = idle). -const CURSOR_BUSY_SPINNER_RE = /[⠁-⣿]/ - -function findCursorReadyPromptIndex(normalized: string): number | null { - const activeIndex = findCursorActivePromptIndex(normalized) - if (activeIndex === null) { - return null - } - return CURSOR_BUSY_SPINNER_RE.test(normalized.slice(activeIndex)) ? null : activeIndex -} - -function findCodexReadyPromptIndex(normalized: string): number | null { - const headerIndex = normalized.lastIndexOf('openai codex') - if (headerIndex === -1) { - return null - } - const readySegment = normalized.slice(headerIndex) - // Why: Codex prints permissions only in YOLO mode; the stable ready header is OpenAI Codex + model + directory. - return readySegment.includes('model:') && readySegment.includes('directory:') ? headerIndex : null -} - -function findAntigravityReadyPromptIndex(normalized: string): number | null { - const headerIndex = normalized.lastIndexOf('antigravity cli') - if (headerIndex === -1) { - return null - } - let lineStart = headerIndex - let modelIndex: number | null = null - let promptIndex: number | null = null - - // Why: ready previews can include echoed paste after the header; scan line bounds directly instead of splitting the whole tail. - for (let cursor = headerIndex; cursor <= normalized.length; cursor += 1) { - if (cursor < normalized.length && normalized.charCodeAt(cursor) !== 10) { - continue - } - let trimmedStart = lineStart - let trimmedEnd = cursor - while (trimmedStart < trimmedEnd && isTerminalWaitWhitespace(normalized, trimmedStart)) { - trimmedStart += 1 - } - while (trimmedEnd > trimmedStart && isTerminalWaitWhitespace(normalized, trimmedEnd - 1)) { - trimmedEnd -= 1 - } - if (lineStart > headerIndex && trimmedStart < trimmedEnd) { - if (modelIndex === null && normalized.startsWith('gemini', trimmedStart)) { - modelIndex = trimmedStart - } - if ( - promptIndex === null && - trimmedEnd - trimmedStart === 1 && - normalized.charCodeAt(trimmedStart) === 62 - ) { - promptIndex = trimmedStart - } - } - lineStart = cursor + 1 - } - - return modelIndex !== null && promptIndex !== null ? Math.max(modelIndex, promptIndex) : null -} - -function isTerminalWaitWhitespace(value: string, index: number): boolean { - const code = value.charCodeAt(index) - return code === 32 || (code >= 9 && code <= 13) -} - -const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = - /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i - -// Why text at all: cursor-agent's hook set has no approval event and beforeShellExecution -// fires for auto-allowed commands too, so the menu is the only authority. Match the key-bound -// choices rather than the prose above them. -const CURSOR_APPROVAL_CHOICE_MARKERS = [ - 'run (once)', - 'to allowlist?', - 'run everything', - 'skip & tell the agent' -] -// Why bounded to the last lines: an answered menu stays in scrollback, and a stale hit fails -// tui-idle and refuses prompt injection. Only a dialog that still owns the bottom of the -// screen is live, and confining the whole match to that window also keeps prose above or -// below — an agent narrating "I'll pick Run Everything" — from anchoring it. -const CURSOR_APPROVAL_TAIL_LINES = 8 - -function findCursorApprovalPromptIndex(normalized: string): number | null { - const windowStart = startOfLastLines(normalized, CURSOR_APPROVAL_TAIL_LINES) - const tail = normalized.slice(windowStart) - if (!tail.includes('run this command?')) { - return null - } - const lines = tail.split('\n') - while (lines.length > 0 && lines.at(-1)?.trim() === '') { - lines.pop() - } - let matchedLines = 0 - let lastChoiceLine = -1 - for (let index = 0; index < lines.length; index += 1) { - if (!isCursorApprovalChoiceLine(lines[index])) { - continue - } - matchedLines += 1 - lastChoiceLine = index - } - if (matchedLines < 2) { - return null - } - // Why no slack: every capture of a live dialog ends on its last choice, and one line of - // tolerance is enough for the agent's own narration of a choice to revive an answered menu. - // A redraw caught mid-flight reads as no wait until the next poll, which is the safe way - // to be wrong. - return lastChoiceLine === lines.length - 1 - ? windowStart + tail.lastIndexOf('run this command?') - : null -} - -// Why the trailing key and not the wording alone: an agent narrating "next time I'll suggest -// Run Everything" writes the same words as the menu. A selectable row ends in the key that -// picks it, and prose does not. Spelled as key names rather than a character class, because -// any lowercase run would readmit "…suggest Run Everything (as before)". -const CURSOR_APPROVAL_CHOICE_KEY_RE = - /\((?:shift\+tab|ctrl\+[a-z]|esc(?: or [a-z])*|tab|enter|return|space|[a-z]|[\u21b5\u21e7\u21b9\u238b\u23ce]{1,3})\)\s*$/ - -function isCursorApprovalChoiceLine(line: string): boolean { - return ( - CURSOR_APPROVAL_CHOICE_KEY_RE.test(line) && - CURSOR_APPROVAL_CHOICE_MARKERS.some((marker) => line.includes(marker)) - ) -} - -/** Offset of the first character of the last `count` newline-separated lines. */ -function startOfLastLines(value: string, count: number): number { - let cursor = value.length - for (let seen = 0; seen < count; seen += 1) { - const previous = value.lastIndexOf('\n', cursor - 1) - if (previous === -1) { - return 0 - } - cursor = previous - } - return cursor + 1 -} - -/** Why a spread and not `agentWait: value`: an absent key is the only way to say the pane was - * never evaluated, which a reader must not confuse with an evaluated "no wait". */ -function expandTerminalInteractiveWait( - agentWait: RuntimeTerminalInteractiveWait | null | undefined -): { agentWait?: RuntimeTerminalInteractiveWait | null } { - return agentWait === undefined ? {} : { agentWait } -} - -/** A wedged PTY controller must not stall every reader of this pane. */ -const TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS = 2_000 - -function findTerminalWaitBlockedSignal( - normalized: string -): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { - // Why: one combined negative scan over the up-to-256 KiB tail avoids a dozen full-tail searches when no prompt can match. - if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { - return null - } - const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] - const updateIndex = normalized.lastIndexOf('update available') - if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) { - candidates.push({ reason: 'codex-update-prompt', index: updateIndex }) - } - const cwdIndex = normalized.lastIndexOf('choose working directory to') - if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) { - candidates.push({ reason: 'codex-cwd-prompt', index: cwdIndex }) - } - const modelMigrationIndex = normalized.lastIndexOf('codex just got an upgrade') - if ( - modelMigrationIndex !== -1 && - normalized.includes('press enter to continue', modelMigrationIndex) - ) { - candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex }) - } - const hooksIndex = normalized.lastIndexOf('hooks need review') - if (hooksIndex !== -1 && normalized.includes('press enter to confirm', hooksIndex)) { - candidates.push({ reason: 'codex-hooks-review-prompt', index: hooksIndex }) - } - const trustIndex = Math.max( - normalized.lastIndexOf('do you trust'), - normalized.lastIndexOf('trust this'), - normalized.lastIndexOf('trusted workspace') - ) - const trustSegment = trustIndex === -1 ? '' : normalized.slice(trustIndex) - if ( - trustIndex !== -1 && - (trustSegment.includes('workspace') || - trustSegment.includes('folder') || - trustSegment.includes('directory') || - trustSegment.includes('repo')) - ) { - candidates.push({ reason: 'codex-trust-workspace', index: trustIndex }) - } - const interactivePromptIndex = Math.max( - normalized.lastIndexOf('press enter to confirm'), - normalized.lastIndexOf('press enter to continue'), - normalized.lastIndexOf('press enter to view'), - normalized.lastIndexOf('press enter to insert'), - normalized.lastIndexOf('press t to trust') - ) - const interactivePromptContext = - interactivePromptIndex === -1 - ? '' - : normalized.slice(Math.max(0, interactivePromptIndex - 600), interactivePromptIndex + 200) - const hasCodexInteractiveContext = - interactivePromptContext.includes('codex') || - interactivePromptContext.includes('permission') || - interactivePromptContext.includes('sandbox') || - interactivePromptContext.includes('trust') || - interactivePromptContext.includes('hook') - if (interactivePromptIndex !== -1 && hasCodexInteractiveContext) { - const contextStart = Math.max(0, interactivePromptIndex - 600) - const hasSpecificPromptInContext = candidates.some( - (candidate) => candidate.index >= contextStart && candidate.index <= interactivePromptIndex - ) - if (!hasSpecificPromptInContext) { - candidates.push({ reason: 'codex-interactive-prompt', index: interactivePromptIndex }) - } - } - const cursorApprovalIndex = findCursorApprovalPromptIndex(normalized) - if (cursorApprovalIndex !== null) { - candidates.push({ reason: 'agent-approval-prompt', index: cursorApprovalIndex }) - } - const permissionPromptIndex = Math.max( - normalized.lastIndexOf('permission required'), - normalized.lastIndexOf('requires permission') - ) - if (permissionPromptIndex !== -1) { - const permissionSegment = normalized.slice(permissionPromptIndex, permissionPromptIndex + 1_500) - const decisionCount = ['allow once', 'allow always', 'reject', 'deny'].filter((choice) => - permissionSegment.includes(choice) - ).length - if (decisionCount >= 2) { - // Why: preserve the existing remote receipt value for mixed-version clients. - candidates.push({ reason: 'codex-interactive-prompt', index: permissionPromptIndex }) - } - } - return candidates.length > 0 - ? candidates.reduce((latest, candidate) => - candidate.index > latest.index ? candidate : latest - ) - : null -} - -function buildTerminalWaitResult( - handle: string, - condition: RuntimeTerminalWaitCondition, - leaf: RuntimeLeafRecord -): RuntimeTerminalWait { - return buildTerminalWait( - handle, - condition, - getTerminalState(leaf), - leaf.lastExitCode, - undefined, - leaf.lastExitCause - ) -} - -function buildTerminalWaitBlockedResult( - handle: string, - condition: RuntimeTerminalWaitCondition, - leaf: RuntimeLeafRecord, - blockedReason: RuntimeTerminalWaitBlockedReason -): RuntimeTerminalWait { - return buildTerminalWait( - handle, - condition, - getTerminalState(leaf), - leaf.lastExitCode, - blockedReason, - leaf.lastExitCause - ) -} - -function buildPtyTerminalWaitResult( - handle: string, - condition: RuntimeTerminalWaitCondition, - pty: RuntimePtyWorktreeRecord -): RuntimeTerminalWait { - return buildTerminalWait( - handle, - condition, - getPtyTerminalState(pty), - pty.lastExitCode, - undefined, - pty.lastExitCause - ) -} - -function buildPtyTerminalWaitBlockedResult( - handle: string, - condition: RuntimeTerminalWaitCondition, - pty: RuntimePtyWorktreeRecord, - blockedReason: RuntimeTerminalWaitBlockedReason -): RuntimeTerminalWait { - return buildTerminalWait( - handle, - condition, - getPtyTerminalState(pty), - pty.lastExitCode, - blockedReason, - pty.lastExitCause - ) -} - -function buildTerminalWait( - handle: string, - condition: RuntimeTerminalWaitCondition, - status: RuntimeTerminalState, - exitCode: number | null, - blockedReason?: RuntimeTerminalWaitBlockedReason, - exitCause?: TerminalExitCause | null -): RuntimeTerminalWait { - return { - handle, - condition, - satisfied: blockedReason === undefined, - status, - exitCode, - ...(exitCause ? { exitCause } : {}), - ...(blockedReason ? { blockedReason } : {}) - } -} - -function getPtyTerminalState(pty: RuntimePtyWorktreeRecord): RuntimeTerminalState { - return pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown' -} - -function branchSelectorMatches(branch: string, selector: string): boolean { - // Why: Git can report a local branch as `refs/heads/foo` or `foo` depending on the plumbing path; accept either. - return normalizeLocalBranchName(branch) === normalizeLocalBranchName(selector) -} - -function runtimePathsEqual(left: string, right: string): boolean { - return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) -} - -function runtimeWorktreeIdentityKey(worktreeId: string): string { - // Same suffix rule: this keys PTY refresh, sleep, and mutation-queue state per session. - const parsed = splitWorktreeId(worktreeId) - return parsed - ? `${parsed.repoId}\0${normalizeRuntimePathForComparison(parsed.worktreePath)}` - : worktreeId -} - -function runtimeWorktreeLookupKey(worktreeId: string): string { - const parsed = splitWorktreeId(worktreeId) - return JSON.stringify( - parsed - ? ['parsed', parsed.repoId, normalizeRuntimePathForComparison(parsed.worktreePath)] - : ['raw', worktreeId] - ) -} - -function createIncrementalResolvedWorktreeLookup( - resolvedWorktrees: ResolvedWorktree[] -): (worktreeId: string) => ResolvedWorktree | undefined { - const worktreeByIdentity = new Map() - let indexedCount = 0 - return (worktreeId) => { - const lookupKey = runtimeWorktreeLookupKey(worktreeId) - const indexed = worktreeByIdentity.get(lookupKey) - if (indexed) { - return indexed - } - while (indexedCount < resolvedWorktrees.length) { - const worktree = resolvedWorktrees[indexedCount] - indexedCount += 1 - const key = runtimeWorktreeLookupKey(worktree.id) - // Why: preserve Array.find's first match when normalized identities collide. - if (!worktreeByIdentity.has(key)) { - worktreeByIdentity.set(key, worktree) - } - if (key === lookupKey) { - return worktreeByIdentity.get(key) - } - } - return undefined - } -} - -function resolveTerminalSessionWorktreeId( - session: WorkspaceSessionState, - targetWorktreeId: string -): string | null { - const keyedWorktreeIds = new Set([ - ...Object.keys(session.tabsByWorktree), - ...Object.keys(session.tabGroups ?? {}), - ...Object.keys(session.tabGroupLayouts ?? {}), - ...Object.keys(session.activeTabIdByWorktree ?? {}), - ...Object.keys(session.activeGroupIdByWorktree ?? {}) - ]) - const matches = [...keyedWorktreeIds].filter((worktreeId) => - worktreeIdsEqual(worktreeId, targetWorktreeId) - ) - return matches.length > 1 ? null : (matches[0] ?? targetWorktreeId) -} - -function canonicalizeTerminalSessionWorktreeId( - session: WorkspaceSessionState, - sourceWorktreeId: string, - targetWorktreeId: string -): void { - if (sourceWorktreeId === targetWorktreeId) { - return - } - const tabs = session.tabsByWorktree[sourceWorktreeId] ?? [] - delete session.tabsByWorktree[sourceWorktreeId] - session.tabsByWorktree[targetWorktreeId] = tabs.map((tab) => ({ - ...tab, - worktreeId: targetWorktreeId - })) - - const groups = session.tabGroups?.[sourceWorktreeId] - if (groups) { - delete session.tabGroups![sourceWorktreeId] - session.tabGroups![targetWorktreeId] = groups.map((group) => ({ - ...group, - worktreeId: targetWorktreeId - })) - } - for (const keyedState of [ - session.tabGroupLayouts, - session.activeTabIdByWorktree, - session.activeGroupIdByWorktree - ]) { - if (!keyedState || !Object.hasOwn(keyedState, sourceWorktreeId)) { - continue - } - keyedState[targetWorktreeId] = keyedState[sourceWorktreeId] as never - delete keyedState[sourceWorktreeId] - } -} - -function inferWorktreeIdFromPtyId(ptyId: string): string | null { - return parsePtySessionId(ptyId).worktreeId -} - -function indexPersistedPtyWorktreeBindings( - session: WorkspaceSessionState | null | undefined -): ReadonlyMap { - const worktreeIdByPtyId = new Map() - const ambiguousPtyIds = new Set() - const bind = (ptyId: string | null | undefined, worktreeId: string): void => { - if (!ptyId || ambiguousPtyIds.has(ptyId)) { - return - } - const existingWorktreeId = worktreeIdByPtyId.get(ptyId) - if (existingWorktreeId && existingWorktreeId !== worktreeId) { - // Why: a corrupt/stale duplicate binding must not attribute a live PTY to whichever workspace was visited first. - worktreeIdByPtyId.delete(ptyId) - ambiguousPtyIds.add(ptyId) - return - } - worktreeIdByPtyId.set(ptyId, worktreeId) - } - - for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) { - for (const tab of tabs) { - bind(tab.ptyId, worktreeId) - bind(session?.remoteSessionIdsByTabId?.[tab.id], worktreeId) - const layout = session?.terminalLayoutsByTabId[tab.id] - for (const ptyId of Object.values(layout?.ptyIdsByLeafId ?? {})) { - bind(ptyId, worktreeId) - } - } - } - return worktreeIdByPtyId -} - -function indexPersistedPtySurfaceBindings( - session: WorkspaceSessionState | null | undefined -): ReadonlyMap< - string, - { worktreeId: string; tabId: string; paneKey: string; incarnationId: string } -> { - const bindingByPtyId = new Map< - string, - { worktreeId: string; tabId: string; paneKey: string; incarnationId: string } - >() - const ambiguousPtyIds = new Set() - for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) { - for (const tab of tabs) { - for (const [leafId, ptyId] of Object.entries( - session?.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {} - )) { - if (!ptyId || ambiguousPtyIds.has(ptyId)) { - continue - } - const paneKey = makePaneKey(tab.id, leafId) - const incarnationId = session?.terminalPtyIncarnationsByPaneKey?.[paneKey] - if (!incarnationId) { - continue - } - const binding = { worktreeId, tabId: tab.id, paneKey, incarnationId } - const existing = bindingByPtyId.get(ptyId) - if ( - existing && - (existing.worktreeId !== worktreeId || - existing.paneKey !== paneKey || - existing.incarnationId !== incarnationId) - ) { - bindingByPtyId.delete(ptyId) - ambiguousPtyIds.add(ptyId) - continue - } - bindingByPtyId.set(ptyId, binding) - } - } - } - return bindingByPtyId -} - -function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { - if (a.size !== b.size) { - return false - } - for (const value of a) { - if (!b.has(value)) { - return false - } - } - return true -} - -function parseRuntimeWorktreeId( - worktreeId: string -): { repoId: string; worktreePath: string } | null { - const parsed = splitWorktreeId(worktreeId) - if (!parsed?.repoId) { - return null - } - if (!parsed.worktreePath) { - return null - } - return parsed -} - -type RuntimeWorktreeSummaryPathCandidate = { - summary: RuntimeWorktreePsSummary - order: number -} - -type RuntimeWorktreeSummaryPathIndex = { - platformByRepoId: ReadonlyMap - posixAbsolute: Map - posixRelative: Map - windows: Map - windowsAbsolute: Map -} - -function buildRuntimeWorktreeSummaryPathIndex( - summaries: ReadonlyMap, - resolvedWorktrees: readonly ResolvedWorktree[], - platformByRepoId: ReadonlyMap -): RuntimeWorktreeSummaryPathIndex { - const index: RuntimeWorktreeSummaryPathIndex = { - platformByRepoId, - posixAbsolute: new Map(), - posixRelative: new Map(), - windows: new Map(), - windowsAbsolute: new Map() - } - for (const [order, worktree] of resolvedWorktrees.entries()) { - const summary = summaries.get(worktree.id) - if (!summary) { - continue - } - const platform = platformByRepoId.get(worktree.repoId) ?? process.platform - const candidate = { summary, order } - if (isPosixAbsoluteRuntimeWorktreePath(worktree.path)) { - setFirstRuntimeWorktreePathCandidate( - index.posixAbsolute, - runtimeWorktreeSummaryPathKey(worktree.repoId, worktree.path, platform), - candidate - ) - continue - } - - const windowsKey = runtimeWorktreeSummaryPathKey(worktree.repoId, worktree.path, 'win32') - setFirstRuntimeWorktreePathCandidate(index.windows, windowsKey, candidate) - if (isWindowsAbsolutePathLike(worktree.path)) { - setFirstRuntimeWorktreePathCandidate(index.windowsAbsolute, windowsKey, candidate) - } else if (platform !== 'win32') { - setFirstRuntimeWorktreePathCandidate( - index.posixRelative, - runtimeWorktreeSummaryPathKey(worktree.repoId, worktree.path, platform), - candidate - ) - } - } - return index -} - -function findRuntimeWorktreeSummaryByPath( - index: RuntimeWorktreeSummaryPathIndex, - repoId: string, - worktreePath: string, - platform: NodeJS.Platform -): RuntimeWorktreePsSummary | null { - if (isPosixAbsoluteRuntimeWorktreePath(worktreePath)) { - return ( - index.posixAbsolute.get(runtimeWorktreeSummaryPathKey(repoId, worktreePath, platform)) - ?.summary ?? null - ) - } - - const windowsKey = runtimeWorktreeSummaryPathKey(repoId, worktreePath, 'win32') - if (platform === 'win32' || isWindowsAbsolutePathLike(worktreePath)) { - return index.windows.get(windowsKey)?.summary ?? null - } - - const posixCandidate = index.posixRelative.get( - runtimeWorktreeSummaryPathKey(repoId, worktreePath, platform) - ) - const windowsCandidate = index.windowsAbsolute.get(windowsKey) - // Why: a malformed path can match both the POSIX and Windows indexes; keep the old pairwise scan's first-match order. - if (!posixCandidate) { - return windowsCandidate?.summary ?? null - } - if (!windowsCandidate || posixCandidate.order < windowsCandidate.order) { - return posixCandidate.summary - } - return windowsCandidate.summary -} - -function setFirstRuntimeWorktreePathCandidate( - candidates: Map, - key: string, - candidate: RuntimeWorktreeSummaryPathCandidate -): void { - if (!candidates.has(key)) { - candidates.set(key, candidate) - } -} - -function isPosixAbsoluteRuntimeWorktreePath(worktreePath: string): boolean { - return worktreePath.startsWith('/') && !worktreePath.startsWith('//') -} - -function runtimeWorktreeSummaryPathKey( - repoId: string, - worktreePath: string, - platform: NodeJS.Platform -): string { - return `${repoId}\0${worktreePathComparisonKey(worktreePath, platform)}` -} - -function includeTargetResolvedWorktree( - resolvedWorktrees: ResolvedWorktree[], - targetWorktree: ResolvedWorktree | null -): ResolvedWorktree[] { - if (!targetWorktree || resolvedWorktrees.some((worktree) => worktree.id === targetWorktree.id)) { - return resolvedWorktrees - } - return [...resolvedWorktrees, targetWorktree] -} - -function findResolvedWorktreeIdForPath( - resolvedWorktrees: ResolvedWorktree[], - cwd: string, - targetWorktreeId?: string | null -): string | null { - if (!cwd) { - return null - } - const matches = resolvedWorktrees - .filter((worktree) => isPathInsideOrEqual(worktree.path, cwd)) - .sort((left, right) => right.path.length - left.path.length) - // Why: a cwd cannot distinguish folder-workspace siblings, which all share one - // directory. Break that tie toward the caller's target instead of store order, - // so an unattributed PTY still lands in the workspace being listed. Only ties at - // the deepest path qualify — a nested worktree must still beat its parent. - const deepest = matches.filter((worktree) => worktree.path.length === matches[0]?.path.length) - return ( - (deepest.length > 1 - ? deepest.find((worktree) => worktree.id === targetWorktreeId)?.id - : undefined) ?? - matches[0]?.id ?? - null - ) -} - -function getLeafWorktreeStatus( - leaf: RuntimeLeafRecord, - tabTitle: string | null -): RuntimeWorktreeStatus { - // Why: recompute from the live title each call (no sticky state) so worktree.ps mirrors the desktop sidebar's getWorktreeStatus. - const titleCandidates = [ - { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, - { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }, - { title: tabTitle, updatedAt: 0 } - ] - const latestTitle = getLatestAgentCandidateTitle(...titleCandidates) - const detected = latestTitle ? detectAgentStatusFromTitle(latestTitle) : leaf.lastAgentStatus - return getDetectedWorktreeStatus(detected, leaf.ptyId !== null) -} - -function classifyLatestAgentTitle( - ...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[] -): 'agent' | 'management' | 'neutral' { - return classifyAgentTitle(getLatestAgentCandidateTitle(...titles)) -} - -function getLatestPtyTitle(pty: RuntimePtyWorktreeRecord): string | null { - return getLatestAgentCandidateTitle( - { title: pty.title, updatedAt: pty.titleUpdatedAt }, - { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } - ) -} - -function getLatestLeafTitle(leaf: RuntimeLeafRecord, tabTitle: string | null): string | null { - return getLatestAgentCandidateTitle( - { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, - { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }, - { title: tabTitle, updatedAt: 0 } - ) -} - -// Why: an 'agent' title only proves an agent owns the pane when something other than a -// quarter-circle spinner carries it — those glyphs are generic progress frames (STA-4028). -function agentTitleProvesAgentPresence( - title: string | null, - classification: 'agent' | 'management' | 'neutral' -): boolean { - return ( - classification === 'agent' && - !isOpenCodeNativeTitle(title) && - !isQuarterCircleSpinnerOnlyAgentTitle(title) - ) -} - -function ptyTitleProvesAgentPresence( - pty: RuntimePtyWorktreeRecord, - title: string | null, - classification: 'agent' | 'management' | 'neutral' -): boolean { - return ( - agentTitleProvesAgentPresence(title, classification) || - (isQuarterCircleSpinnerOnlyAgentTitle(title) && - pty.launchAgent === 'claude' && - pty.launchToken !== null && - pty.launchIncarnationId === pty.incarnationId) - ) -} - -function classifyAgentTitle(title: string | null): 'agent' | 'management' | 'neutral' { - if (!title) { - return 'neutral' - } - if (isClaudeManagementTitle(title)) { - return 'management' - } - return detectAgentStatusFromTitle(title) !== null ? 'agent' : 'neutral' -} - -function isTerminalSendSettlementAgent( - agent: TuiAgent | null | undefined -): agent is 'claude' | 'codex' { - return agent === 'claude' || agent === 'codex' -} - -function findLastCompleteOscTitleRange(data: string): { start: number; end: number } | null { - // Why: one forward cursor keeps hostile unterminated OSC output linear-time. - let last: { start: number; end: number } | null = null - let searchFrom = 0 - while (searchFrom < data.length) { - const start = data.indexOf('\x1b]', searchFrom) - if (start === -1) { - break - } - const command = data[start + 2] - if ((command !== '0' && command !== '1' && command !== '2') || data[start + 3] !== ';') { - searchFrom = start + 2 - continue - } - let cursor = start + 4 - for (; cursor < data.length; cursor += 1) { - if (data[cursor] === '\x07') { - last = { start, end: cursor + 1 } - searchFrom = cursor + 1 - break - } - if (data[cursor] !== '\x1b') { - continue - } - if (data[cursor + 1] === '\\') { - last = { start, end: cursor + 2 } - searchFrom = cursor + 2 - } else { - searchFrom = cursor - } - break - } - if (cursor === data.length) { - break - } - } - return last -} - -function terminalTitleBlocksExplicitAgentStatus(title: string | null): boolean { - if (!title) { - return false - } - return isClaudeManagementTitle(title) || isShellProcess(title) -} - -function getLatestAgentCandidateTitle( - ...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[] -): string | null { - return getLatestAgentCandidateTitleInfo(...titles)?.title ?? null -} - -function getLatestAgentCandidateTitleInfo( - ...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[] -): { title: string; updatedAt: number } | null { - let latest: { title: string; updatedAt: number } | null = null - for (const candidate of titles) { - const title = candidate.title?.trim() - if (!title) { - continue - } - const updatedAt = candidate.updatedAt ?? 0 - if (!latest || updatedAt > latest.updatedAt) { - latest = { title, updatedAt } - } - } - return latest -} - -function getSavedTabWorktreeStatus(title: string, hasPty: boolean): RuntimeWorktreeStatus { - return getDetectedWorktreeStatus(detectAgentStatusFromTitle(title), hasPty) -} - -function getDetectedWorktreeStatus( - detected: AgentStatus | null, - hasPty: boolean -): RuntimeWorktreeStatus { - if (detected === 'permission') { - return 'permission' - } - if (detected === 'working') { - return 'working' - } - return hasPty ? 'active' : 'inactive' -} - -function mapExplicitAgentStateToRuntimeTerminalStatus( - state: AgentStatusEntry['state'] -): NonNullable { - switch (state) { - case 'blocked': - case 'waiting': - return 'permission' - case 'working': - return 'working' - case 'done': - return 'idle' - } -} - -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( - chunk: string, - pendingAnsi: string = '' -): { text: string; pendingAnsi: string } { - // Why: skip full ANSI/OSC scanning for the common plain-text PTY chunk (perf on high-throughput streams). - if (pendingAnsi.length === 0 && !terminalChunkNeedsNormalization(chunk)) { - return { text: chunk, pendingAnsi: '' } - } - const combined = `${pendingAnsi}${chunk}` - const parts: string[] = [] - let textStart = 0 - for (let index = 0; index < combined.length; index += 1) { - const char = combined[index] - if (char === '\x1b') { - appendTerminalNormalizedSpan(parts, combined, textStart, index) - if (index + 1 >= combined.length) { - return { text: parts.join(''), pendingAnsi: combined.slice(index) } - } - const parsed = parseAnsiControlSequence(combined, index) - if (!parsed) { - return { - text: parts.join(''), - pendingAnsi: trimPendingAnsiControl(combined.slice(index)) - } - } - if (parsed.kind === 'csi' && isTerminalPreviewLineControl(parsed)) { - // Why: Codex redraws status text with ANSI controls but no CR; keep them so the tail overwrites the prior frame. - parts.push(combined.slice(index, parsed.endIndex + 1)) - } - index = parsed.endIndex - textStart = index + 1 - continue - } - if (char === '\r' && combined[index + 1] === '\n') { - appendTerminalNormalizedSpan(parts, combined, textStart, index) - parts.push('\n') - index += 1 - textStart = index + 1 - continue - } - const code = combined.charCodeAt(index) - if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0d) { - appendTerminalNormalizedSpan(parts, combined, textStart, index) - parts.push(char) - textStart = index + 1 - } else if (!isTerminalPreviewPrintableCodeUnit(code)) { - appendTerminalNormalizedSpan(parts, combined, textStart, index) - textStart = index + 1 - } - } - appendTerminalNormalizedSpan(parts, combined, textStart, combined.length) - return { text: parts.join(''), pendingAnsi: '' } -} - -function appendTerminalNormalizedSpan( - parts: string[], - value: string, - start: number, - end: number -): void { - if (end > start) { - parts.push(value.slice(start, end)) - } -} - -function isTerminalPreviewPrintableCodeUnit(code: number): boolean { - return code >= 0x20 && code !== 0x7f && (code < 0x80 || code > 0x9f) -} - -function terminalChunkNeedsNormalization(chunk: string): boolean { - for (let index = 0; index < chunk.length; index++) { - const code = chunk.charCodeAt(index) - if ( - code === 0x1b || - code === 0x7f || - code === 0x0d || - code < 0x09 || - (code > 0x0a && code < 0x20) || - (code >= 0x80 && code <= 0x9f) - ) { - return true - } - } - return false -} - -function trimPendingAnsiControl(value: string): string { - if (value.length <= MAX_TAIL_PENDING_ANSI_CHARS) { - return value - } - const introducer = value.slice(0, Math.min(2, value.length)) - const suffixBudget = Math.max(0, MAX_TAIL_PENDING_ANSI_CHARS - introducer.length) - return `${introducer}${value.slice(-suffixBudget)}` -} - -function isTerminalPreviewLineControl(parsed: { - final: string - params: string - firstParam: number | null -}): boolean { - if (!hasCanonicalNumericCsiParams(parsed.params)) { - return false - } - if (parsed.final === 'K') { - const mode = parsed.firstParam ?? 0 - return mode === 0 || mode === 1 || mode === 2 - } - return ( - parsed.final === 'A' || - parsed.final === 'G' || - parsed.final === '`' || - parsed.final === 'D' || - parsed.final === 'C' - ) -} - -function maxTimestamp(left: number | null, right: number | null): number | null { - if (left === null) { - return right - } - if (right === null) { - return left - } - return Math.max(left, right) -} - -function compareWorktreePs( - left: RuntimeWorktreePsSummary, - right: RuntimeWorktreePsSummary -): number { - // Pinned and unread worktrees sort above others so they survive truncation. - if (left.isPinned !== right.isPinned) { - return left.isPinned ? -1 : 1 - } - if (left.unread !== right.unread) { - return left.unread ? -1 : 1 - } - // Why: worktree.ps is truncated for mobile, so host-visible activity must sort above inactive rows. - if (left.hasHostSidebarActivity !== right.hasHostSidebarActivity) { - return left.hasHostSidebarActivity ? -1 : 1 - } - const leftLast = left.lastOutputAt ?? -1 - const rightLast = right.lastOutputAt ?? -1 - if (leftLast !== rightLast) { - return rightLast - leftLast - } - if (left.liveTerminalCount !== right.liveTerminalCount) { - return right.liveTerminalCount - left.liveTerminalCount - } - return left.path.localeCompare(right.path) -} +import { installRuntimeLinearCommandSurface } from './runtime-linear-command-surface' +import { OrcaRuntimeWithResolveWaiter } from './orca-runtime-resolve-waiter' +import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' + +class OrcaRuntimeService extends OrcaRuntimeWithResolveWaiter {} +type OrcaRuntimeServiceExport = RuntimeCommandSurfaceHost +const OrcaRuntimeServiceExport = OrcaRuntimeService as unknown as { + new (...args: ConstructorParameters): OrcaRuntimeServiceExport + readonly prototype: OrcaRuntimeServiceExport +} +export { OrcaRuntimeServiceExport as OrcaRuntimeService } +export type { RuntimeNativeChatTranscriptBinding } from './orca-runtime-state-fields' +installRuntimeLinearCommandSurface(OrcaRuntimeServiceExport.prototype) + +export type { LegacyWorkerTerminalRecoveryResult } from './runtime-legacy-worker-terminal-recovery-types' +export type { + RuntimeAutomationCreateInput, + RuntimeAutomationUpdateInput +} from './runtime-automation-controller' +export type { SubscriptionRegistration } from './runtime-subscription-registry' +export type { + OrchestrationCompatibilityCallerAuthority, + OrchestrationCompatibilityTerminalAuthority, + RuntimePtyDataAdmission, + RuntimeTerminalAgentStatusEvent +} from './runtime-terminal-contracts' +export type { MessageWaitResult } from './runtime-message-waiters' +export type { AccountsSnapshot, CodexRateLimitResetRpcResult } from './runtime-account-controller' +export type { + MobileNotificationDispatchEvent, + MobileNotificationDismissEvent, + MobileNotificationEvent +} from './runtime-mobile-notification-controller' +export type { RuntimeTerminalDataMeta } from './runtime-terminal-stream-consumers' +export type { RemoteFetchResult, RemoteTrackingBase } from './runtime-remote-fetch-controller' +export { + computeTerminalTailWaitState, + tailGainedNewerBlockedReason, + type TerminalTailWaitState +} from './terminal-wait-tail-state' +export { appendNormalizedToTailBuffer } from './terminal-tail-buffer' +export { appendNormalizedToMultilineTailBufferUnwindowed } from './terminal-tail-redraw-buffer' +export { buildPreview } from './terminal-tail-state' +export { buildRestoredTerminalTailSeed } from './terminal-tail-restore-seed' +export { projectTerminalTailLines } from './orca-runtime-terminal-projection' +export { resolveWorktreeScanCacheTtlMs } from './runtime-worktree-scan-cache' +export type { + RuntimeWorktreeLifecycleEvent, + DriverState, + PtyLayoutTarget, + PtyLayoutState, + ApplyLayoutResult, + RuntimeRendererReloadFence +} from './orca-runtime-core' +export { + AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS, + WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS, + WORKTREE_SCAN_ADMIN_FINGERPRINT_TIMEOUT_MS +} from './orca-runtime-postlude' diff --git a/src/main/runtime/runtime-file-command-surface.ts b/src/main/runtime/runtime-file-command-surface.ts index cd891301175..a861e1687db 100644 --- a/src/main/runtime/runtime-file-command-surface.ts +++ b/src/main/runtime/runtime-file-command-surface.ts @@ -9,6 +9,7 @@ type RuntimeFileCommandName = | 'readMobileFile' | 'resolveTerminalPath' | 'readTerminalArtifactFile' + | 'readTerminalArtifactChunk' | 'readTerminalArtifactPreview' | 'writeTerminalArtifactFile' | 'revokeTerminalFileGrantsForClient' @@ -47,6 +48,7 @@ export function installRuntimeFileCommandSurface( readMobileFile: commands.readMobileFile.bind(commands), resolveTerminalPath: commands.resolveTerminalPath.bind(commands), readTerminalArtifactFile: commands.readTerminalArtifactFile.bind(commands), + readTerminalArtifactChunk: commands.readTerminalArtifactChunk.bind(commands), readTerminalArtifactPreview: commands.readTerminalArtifactPreview.bind(commands), writeTerminalArtifactFile: commands.writeTerminalArtifactFile.bind(commands), revokeTerminalFileGrantsForClient: commands.revokeTerminalFileGrantsForClient.bind(commands), diff --git a/src/main/runtime/runtime-file-commands-write-terminal-artifact-file.ts b/src/main/runtime/runtime-file-commands-write-terminal-artifact-file.ts index a37f53d3f34..1ac20f5e1c0 100644 --- a/src/main/runtime/runtime-file-commands-write-terminal-artifact-file.ts +++ b/src/main/runtime/runtime-file-commands-write-terminal-artifact-file.ts @@ -4,6 +4,7 @@ import type { TerminalFileGrant } from './runtime-file-commands-mobile-file-list import { LOCAL_PREVIEWABLE_BINARY_MAX_BYTES, MOBILE_FILE_READ_MAX_BYTES, + RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES, previewableBinaryByteLimit } from './runtime-file-commands-mobile-file-list-limit' import { isBinaryBuffer, isMobileBinaryPath } from './runtime-file-command-host' @@ -17,9 +18,86 @@ import { chmod, constants, rename, rm, writeFile } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' import type { FileStat, IFilesystemProvider } from '../providers/types' -import type { RuntimeFilePreviewResult } from '../../shared/runtime-types' +import type { + RuntimeFilePreviewResult, + RuntimeFileReadChunkResult +} from '../../shared/runtime-types' export class RuntimeFileCommandsWithWriteTerminalArtifactFile extends RuntimeFileCommandsWithRevokeTerminalFileGrantsForClient { + async readTerminalArtifactChunk( + worktreeSelector: string, + grantId: string, + absolutePath: string, + offset: number, + length: number, + maxBytes: number, + clientId?: string + ): Promise { + if ( + !Number.isSafeInteger(offset) || + offset < 0 || + !Number.isSafeInteger(length) || + length < 1 || + length > 512 * 1024 || + !Number.isSafeInteger(maxBytes) || + maxBytes < 1 || + maxBytes > RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES + ) { + throw new Error('invalid_terminal_artifact_chunk') + } + const { grant } = await this.requireTerminalFileGrant( + worktreeSelector, + grantId, + absolutePath, + clientId + ) + let result: RuntimeFileReadChunkResult + if (grant.connectionId) { + const provider = await this.assertRemoteTerminalFileGrantPathStillCanonical(grant) + if (!provider.readTerminalArtifactChunk) { + throw new Error('terminal_file_grant_unavailable') + } + result = await provider.readTerminalArtifactChunk( + grant.absolutePath, + offset, + length, + this.terminalArtifactAccessOptions(grant, maxBytes) + ) + } else { + const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) + try { + const fileStats = await handle.stat() + if (fileStats.isDirectory()) { + throw new Error('Cannot read a directory') + } + if (fileStats.size > maxBytes) { + throw new Error('file_too_large') + } + assertTerminalFileGrantFresh(grant, fileStats) + const buffer = Buffer.alloc(Math.min(length, Math.max(0, fileStats.size - offset))) + const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset) + result = { + contentBase64: buffer.subarray(0, bytesRead).toString('base64'), + bytesRead, + eof: offset + bytesRead >= fileStats.size + } + } finally { + await handle.close() + } + } + const decoded = Buffer.from(result.contentBase64, 'base64') + if ( + decoded.toString('base64') !== result.contentBase64 || + result.bytesRead !== decoded.byteLength || + result.bytesRead > length || + (result.bytesRead === 0 && !result.eof) + ) { + throw new Error('invalid_terminal_artifact_chunk') + } + this.refreshTerminalFileGrant(grant) + return result + } + async writeTerminalArtifactFile( worktreeSelector: string, grantId: string, diff --git a/src/main/runtime/runtime-hosted-review-commands.ts b/src/main/runtime/runtime-hosted-review-commands.ts index 88be80bbdb8..6a4f7c260eb 100644 --- a/src/main/runtime/runtime-hosted-review-commands.ts +++ b/src/main/runtime/runtime-hosted-review-commands.ts @@ -13,6 +13,10 @@ import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types' import type { Repo } from '../../shared/repo-types' +import type { + HostedReviewSubmissionInput, + HostedReviewSubmissionResult +} from '../../shared/hosted-review-submission' import { getPRForBranchOutcome, getRepoSlug, @@ -28,6 +32,7 @@ import { } from '../source-control/hosted-review-creation' import type { HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' import { createStackedHostedReview } from '../source-control/stacked-hosted-review-creation' +import { submitHostedReview } from '../source-control/hosted-review-submission' type HostedReviewTargetArgs = { repoSelector: string; worktreeSelector?: string } @@ -176,6 +181,20 @@ export class RuntimeHostedReviewCommands { return result } + async submitHostedReview( + args: HostedReviewSubmissionInput & { repoSelector: string } + ): Promise { + const repo = await this.deps.resolveRepo(args.repoSelector) + const { repoSelector: _repoSelector, ...input } = args + return submitHostedReview( + repo.path, + input, + repo.issueSourcePreference, + repo.connectionId ?? null, + this.deps.getExecutionOptions(repo) + ) + } + async createStackedHostedReview( args: CreateStackedHostedReviewInput & HostedReviewTargetArgs ): Promise { diff --git a/src/main/runtime/runtime-repository-command-surface.ts b/src/main/runtime/runtime-repository-command-surface.ts index 5c9fcbea425..be87666cdac 100644 --- a/src/main/runtime/runtime-repository-command-surface.ts +++ b/src/main/runtime/runtime-repository-command-surface.ts @@ -19,6 +19,7 @@ type HostedReviewCommandName = | 'getHostedReviewForBranch' | 'getHostedReviewCreationEligibility' | 'createHostedReview' + | 'submitHostedReview' | 'createStackedHostedReview' type GitHubRepositoryQueryCommandName = | 'listRepoWorkItems' @@ -146,6 +147,7 @@ export function installRuntimeRepositoryCommandSurface( getHostedReviewForBranch: reviews.getHostedReviewForBranch.bind(reviews), getHostedReviewCreationEligibility: reviews.getHostedReviewCreationEligibility.bind(reviews), createHostedReview: reviews.createHostedReview.bind(reviews), + submitHostedReview: reviews.submitHostedReview.bind(reviews), createStackedHostedReview: reviews.createStackedHostedReview.bind(reviews), listRepoWorkItems: queries.listRepoWorkItems.bind(queries), listRepoIssues: queries.listRepoIssues.bind(queries),