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